Skip to content

Commit 2007054

Browse files
committed
feat(dashboard): identify results by eval path
1 parent b2b4f80 commit 2007054

15 files changed

Lines changed: 348 additions & 60 deletions

File tree

‎apps/cli/src/commands/results/serve.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1203,6 +1203,7 @@ function attachRunDetailReadModelFields<T extends Record<string, unknown>>(
12031203
return {
12041204
...result,
12051205
...(record.aggregation && { aggregation: record.aggregation }),
1206+
...(record.eval_path && { eval_path: record.eval_path }),
12061207
...(record.result_dir && { result_dir: record.result_dir }),
12071208
...(record.summary_path && { summary_path: record.summary_path }),
12081209
...(record.grading_path && { grading_path: record.grading_path }),

‎apps/cli/test/commands/results/serve.test.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2581,18 +2581,30 @@ describe('serve app', () => {
25812581
const filename = '2026-03-25T10-00-00-000Z';
25822582
const runDir = path.join(runsDir, filename);
25832583
mkdirSync(runDir, { recursive: true });
2584-
writeFileSync(path.join(runDir, 'index.jsonl'), toJsonl(RESULT_A, RESULT_B));
2584+
writeFileSync(
2585+
path.join(runDir, 'index.jsonl'),
2586+
toJsonl(
2587+
{
2588+
...RESULT_A,
2589+
eval_path: 'evals/demo.eval.yaml',
2590+
result_dir: 'demo/test-greeting',
2591+
},
2592+
RESULT_B,
2593+
),
2594+
);
25852595

25862596
const app = createApp([], tempDir, tempDir, undefined, { studioDir });
25872597
const res = await app.request(`/api/runs/${filename}`);
25882598
expect(res.status).toBe(200);
25892599
const data = (await res.json()) as {
2590-
results: { testId: string }[];
2600+
results: { testId: string; eval_path?: string; result_dir?: string }[];
25912601
source: 'local' | 'remote';
25922602
source_label: string;
25932603
};
25942604
expect(data.results).toHaveLength(2);
25952605
expect(data.results[0].testId).toBe('test-greeting');
2606+
expect(data.results[0].eval_path).toBe('evals/demo.eval.yaml');
2607+
expect(data.results[0].result_dir).toBe('demo/test-greeting');
25962608
expect(data.source).toBe('local');
25972609
expect(data.source_label).toBe(filename);
25982610
});

‎apps/dashboard/src/components/EvalDetail.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,8 +335,9 @@ function SourceTab({ result }: { result: EvalResult }) {
335335
<h4 className="mb-3 text-sm font-medium text-gray-300">Traceability</h4>
336336
<dl className="grid gap-3 md:grid-cols-2">
337337
<SourceMetaRow label="Eval file" value={traceability.eval_file?.display_path} />
338+
<SourceMetaRow label="Eval" value={result.eval_path} />
338339
<SourceMetaRow label="Test ID" value={traceability.test_id ?? result.testId} />
339-
<SourceMetaRow label="Suite" value={result.suite} />
340+
<SourceMetaRow label="Legacy suite" value={result.suite} />
340341
<SourceMetaRow label="Category" value={result.category} />
341342
<SourceMetaRow label="Target" value={result.target} />
342343
</dl>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { formatEvalSourceDisplay } from '~/lib/run-detail-context';
2+
import type { EvalResult } from '~/lib/types';
3+
4+
interface EvalSourceLabelProps {
5+
result: Pick<EvalResult, 'eval_path' | 'suite'>;
6+
className?: string;
7+
}
8+
9+
export function EvalSourceLabel({ result, className = '' }: EvalSourceLabelProps) {
10+
const display = formatEvalSourceDisplay(result);
11+
if (!display) return null;
12+
13+
return (
14+
<span className={`block truncate text-gray-500 ${className}`} title={display.title}>
15+
{display.label}
16+
</span>
17+
);
18+
}

‎apps/dashboard/src/components/ResultTable.tsx‎

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type React from 'react';
1010
import { Fragment, useEffect, useMemo, useState } from 'react';
1111

1212
import { useFeedback } from '~/lib/api';
13+
import { evalResultPath } from '~/lib/navigation';
1314
import {
1415
RESULT_TABLE_VIEW_PRESETS,
1516
type RepeatRunGroup,
@@ -651,8 +652,8 @@ function TrialResultCell({
651652
return <TargetCell target={row.targetLabel} tone="text-gray-500" />;
652653
case 'score':
653654
return <PassRatePill rate={trial.score ?? 0} />;
654-
case 'suite':
655-
return <TruncatedMuted value={row.suiteLabel} tone="text-gray-500" />;
655+
case 'eval':
656+
return <TruncatedMuted value={row.evalLabel} tone="text-gray-500" />;
656657
case 'category':
657658
return <TruncatedMuted value={row.categoryLabel} tone="text-gray-500" />;
658659
case 'duration':
@@ -833,8 +834,8 @@ function ResultCell({
833834
) : (
834835
<PassRatePill rate={row.result.score} />
835836
);
836-
case 'suite':
837-
return <TruncatedMuted value={row.suiteLabel} />;
837+
case 'eval':
838+
return <TruncatedMuted value={row.evalLabel} />;
838839
case 'category':
839840
return <TruncatedMuted value={row.categoryLabel} />;
840841
case 'duration':
@@ -910,11 +911,10 @@ function ResultDetailPanel({
910911
onOpenTrialDetail: (trial: EvalCaseTrial, initialTab?: DetailTab) => void;
911912
onClose: () => void;
912913
}) {
913-
const evalDetailHref = buildEvalDetailHref({
914+
const evalDetailHref = evalResultPath(runId, row.testId, {
914915
projectId,
915-
runId,
916-
evalId: row.testId,
917916
resultDir: row.result.result_dir,
917+
evalPath: row.result.eval_path,
918918
});
919919
const title = selectedTrialPath ? `${row.testId} · ${selectedTrialPath}` : row.testId;
920920
const showAggregateRepeatDetail = repeatGroup && !selectedTrial;
@@ -934,7 +934,7 @@ function ResultDetailPanel({
934934
</h4>
935935
<p className="mt-1 truncate text-xs text-gray-500" title={row.targetLabel}>
936936
{row.targetLabel}
937-
{row.suiteLabel ? ` · ${row.suiteLabel}` : ''}
937+
{row.evalLabel ? ` · ${row.evalLabel}` : ''}
938938
</p>
939939
</div>
940940
<div className="flex shrink-0 items-center gap-2">
@@ -1003,19 +1003,6 @@ function ExpanderCell({
10031003
);
10041004
}
10051005

1006-
function buildEvalDetailHref(options: {
1007-
projectId?: string;
1008-
runId: string;
1009-
evalId: string;
1010-
resultDir?: string;
1011-
}): string {
1012-
const base = options.projectId
1013-
? `/projects/${encodeURIComponent(options.projectId)}/evals/${encodeURIComponent(options.runId)}/${encodeURIComponent(options.evalId)}`
1014-
: `/evals/${encodeURIComponent(options.runId)}/${encodeURIComponent(options.evalId)}`;
1015-
if (!options.resultDir) return base;
1016-
return `${base}?result_dir=${encodeURIComponent(options.resultDir)}`;
1017-
}
1018-
10191006
function scrollPanelIntoView(panel: HTMLElement | null) {
10201007
if (!panel) return;
10211008
window.requestAnimationFrame(() => {

‎apps/dashboard/src/components/Sidebar.tsx‎

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,18 @@ import {
3333
useRunList,
3434
useStudioConfig,
3535
} from '~/lib/api';
36-
import { shouldShowSuiteLabels } from '~/lib/run-detail-context';
36+
import {
37+
evalResultIdentityKey,
38+
evalResultSearchParams,
39+
matchesEvalResultIdentity,
40+
} from '~/lib/navigation';
41+
import { shouldShowEvalSourceLabels } from '~/lib/run-detail-context';
3742
import { formatRunDisplay } from '~/lib/run-label';
3843
import { useSidebarContext } from '~/lib/sidebar-context';
3944
import type { EvalResult } from '~/lib/types';
4045

4146
import { BrandName } from './BrandName';
42-
import { EvalSuiteLabel } from './EvalSuiteLabel';
47+
import { EvalSourceLabel } from './EvalSourceLabel';
4348

4449
/** Responsive <aside> wrapper. Handles mobile overlay and desktop static placement. */
4550
function SidebarShell({ children }: { children: ReactNode }) {
@@ -104,11 +109,11 @@ function SidebarRunText({ display }: { display: ReturnType<typeof formatRunDispl
104109
function EvalSidebarItemContent({
105110
result,
106111
passThreshold,
107-
showSuiteLabel,
112+
showEvalSourceLabel,
108113
}: {
109114
result: EvalResult;
110115
passThreshold: number;
111-
showSuiteLabel: boolean;
116+
showEvalSourceLabel: boolean;
112117
}) {
113118
const passed = isPassing(result.score, passThreshold);
114119

@@ -119,14 +124,23 @@ function EvalSidebarItemContent({
119124
</span>
120125
<span className="min-w-0 flex-1">
121126
<span className="block truncate">{result.testId}</span>
122-
{showSuiteLabel ? (
123-
<EvalSuiteLabel suite={result.suite} className="mt-1 max-w-full text-[11px] leading-4" />
127+
{showEvalSourceLabel ? (
128+
<EvalSourceLabel result={result} className="mt-1 max-w-full text-[11px] leading-4" />
124129
) : null}
125130
</span>
126131
</>
127132
);
128133
}
129134

135+
function useCurrentEvalIdentitySearch() {
136+
const location = useLocation();
137+
const search = location.search as Record<string, string | undefined>;
138+
return {
139+
resultDir: search.result_dir,
140+
evalPath: search.eval_path,
141+
};
142+
}
143+
130144
type ProjectTabId = 'runs' | 'experiments' | 'analytics' | 'targets';
131145

132146
const projectNavItems: { id: ProjectTabId; label: string; description: string }[] = [
@@ -409,8 +423,9 @@ function RunDetailSidebar({ currentRunId }: { currentRunId: string }) {
409423
function EvalSidebar({ runId, currentEvalId }: { runId: string; currentEvalId: string }) {
410424
const { data } = useRunDetail(runId);
411425
const { data: config } = useStudioConfig();
426+
const currentIdentity = useCurrentEvalIdentitySearch();
412427
const passThreshold = config?.threshold ?? config?.pass_threshold ?? 0.8;
413-
const showSuiteLabels = shouldShowSuiteLabels(data?.results ?? []);
428+
const showEvalSourceLabels = shouldShowEvalSourceLabels(data?.results ?? []);
414429

415430
return (
416431
<SidebarShell>
@@ -434,13 +449,18 @@ function EvalSidebar({ runId, currentEvalId }: { runId: string; currentEvalId: s
434449
</div>
435450

436451
{data?.results.map((result) => {
437-
const isActive = result.testId === currentEvalId;
452+
const search = evalResultSearchParams({
453+
resultDir: result.result_dir,
454+
evalPath: result.eval_path,
455+
});
456+
const isActive = matchesEvalResultIdentity(result, currentEvalId, currentIdentity);
438457

439458
return (
440459
<Link
441-
key={result.testId}
460+
key={evalResultIdentityKey(result)}
442461
to="/evals/$runId/$evalId"
443462
params={{ runId, evalId: result.testId }}
463+
search={search}
444464
className={`mb-0.5 flex items-start gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${
445465
isActive
446466
? 'bg-gray-800 text-cyan-400'
@@ -450,7 +470,7 @@ function EvalSidebar({ runId, currentEvalId }: { runId: string; currentEvalId: s
450470
<EvalSidebarItemContent
451471
result={result}
452472
passThreshold={passThreshold}
453-
showSuiteLabel={showSuiteLabels}
473+
showEvalSourceLabel={showEvalSourceLabels}
454474
/>
455475
</Link>
456476
);
@@ -490,12 +510,17 @@ function SuiteSidebar({ runId, suite }: { runId: string; suite: string }) {
490510

491511
{suiteResults.map((result) => {
492512
const passed = isPassing(result.score, passThreshold);
513+
const search = evalResultSearchParams({
514+
resultDir: result.result_dir,
515+
evalPath: result.eval_path,
516+
});
493517

494518
return (
495519
<Link
496-
key={result.testId}
520+
key={evalResultIdentityKey(result)}
497521
to="/evals/$runId/$evalId"
498522
params={{ runId, evalId: result.testId }}
523+
search={search}
499524
className="mb-0.5 flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-400 transition-colors hover:bg-gray-800/50 hover:text-gray-200"
500525
>
501526
<span className={`text-xs ${passed ? 'text-emerald-400' : 'text-red-400'}`}>
@@ -609,8 +634,9 @@ function ProjectEvalSidebar({
609634
}) {
610635
const { data } = useProjectRunDetail(projectId, runId);
611636
const { data: config } = useStudioConfig(projectId);
637+
const currentIdentity = useCurrentEvalIdentitySearch();
612638
const passThreshold = config?.threshold ?? config?.pass_threshold ?? 0.8;
613-
const showSuiteLabels = shouldShowSuiteLabels(data?.results ?? []);
639+
const showEvalSourceLabels = shouldShowEvalSourceLabels(data?.results ?? []);
614640

615641
return (
616642
<SidebarShell>
@@ -632,12 +658,17 @@ function ProjectEvalSidebar({
632658
Evaluations
633659
</div>
634660
{data?.results.map((result) => {
635-
const isActive = result.testId === currentEvalId;
661+
const search = evalResultSearchParams({
662+
resultDir: result.result_dir,
663+
evalPath: result.eval_path,
664+
});
665+
const isActive = matchesEvalResultIdentity(result, currentEvalId, currentIdentity);
636666
return (
637667
<Link
638-
key={result.testId}
668+
key={evalResultIdentityKey(result)}
639669
to="/projects/$projectId/evals/$runId/$evalId"
640670
params={{ projectId, runId, evalId: result.testId }}
671+
search={search}
641672
className={`mb-0.5 flex items-start gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${
642673
isActive
643674
? 'bg-gray-800 text-cyan-400'
@@ -647,7 +678,7 @@ function ProjectEvalSidebar({
647678
<EvalSidebarItemContent
648679
result={result}
649680
passThreshold={passThreshold}
650-
showSuiteLabel={showSuiteLabels}
681+
showEvalSourceLabel={showEvalSourceLabels}
651682
/>
652683
</Link>
653684
);
@@ -693,11 +724,16 @@ function ProjectSuiteSidebar({
693724
</div>
694725
{suiteResults.map((result) => {
695726
const passed = isPassing(result.score, passThreshold);
727+
const search = evalResultSearchParams({
728+
resultDir: result.result_dir,
729+
evalPath: result.eval_path,
730+
});
696731
return (
697732
<Link
698-
key={result.testId}
733+
key={evalResultIdentityKey(result)}
699734
to="/projects/$projectId/evals/$runId/$evalId"
700735
params={{ projectId, runId, evalId: result.testId }}
736+
search={search}
701737
className="mb-0.5 flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-400 transition-colors hover:bg-gray-800/50 hover:text-gray-200"
702738
>
703739
<span className={`text-xs ${passed ? 'text-emerald-400' : 'text-red-400'}`}>

‎apps/dashboard/src/lib/navigation.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@ import { describe, expect, it } from 'bun:test';
33
import {
44
categoryPath,
55
evalPath,
6+
evalResultIdentityKey,
7+
evalResultPath,
8+
evalResultSearchParams,
69
experimentPath,
710
initialProjectRedirectStorageKey,
811
jobPath,
12+
matchesEvalResultIdentity,
913
resolveIndexRoute,
1014
resolveInitialProjectRedirect,
1115
runPath,
@@ -58,6 +62,15 @@ describe('route path helpers', () => {
5862
expect(evalPath('run::1', 'case/a', 'demo project')).toBe(
5963
'/projects/demo%20project/evals/run%3A%3A1/case%2Fa',
6064
);
65+
expect(
66+
evalResultPath('run::1', 'case/a', {
67+
projectId: 'demo project',
68+
resultDir: 'evals/auth.eval.yaml/case-a',
69+
evalPath: 'evals/auth.eval.yaml',
70+
}),
71+
).toBe(
72+
'/projects/demo%20project/evals/run%3A%3A1/case%2Fa?result_dir=evals%2Fauth.eval.yaml%2Fcase-a',
73+
);
6174
expect(jobPath('job/1', 'demo project')).toBe('/projects/demo%20project/jobs/job%2F1');
6275
expect(categoryPath('run::1', 'Safety > PII', 'demo project')).toBe(
6376
'/projects/demo%20project/runs/run%3A%3A1/category/Safety%20%3E%20PII',
@@ -74,11 +87,42 @@ describe('route path helpers', () => {
7487
it('keeps unscoped paths for legacy single-project routes', () => {
7588
expect(runPath('run::1')).toBe('/runs/run%3A%3A1');
7689
expect(evalPath('run::1', 'case/a')).toBe('/evals/run%3A%3A1/case%2Fa');
90+
expect(evalResultPath('run::1', 'case/a', { evalPath: 'evals/smoke.eval.yaml' })).toBe(
91+
'/evals/run%3A%3A1/case%2Fa?eval_path=evals%2Fsmoke.eval.yaml',
92+
);
7793
expect(jobPath('job/1')).toBe('/jobs/job%2F1');
7894
expect(categoryPath('run::1', 'Safety')).toBe('/runs/run%3A%3A1/category/Safety');
7995
expect(suitePath('run::1', 'evals/smoke.eval.yaml')).toBe(
8096
'/runs/run%3A%3A1/suite/evals%2Fsmoke.eval.yaml',
8197
);
8298
expect(runsHomePath()).toBe('/?tab=runs');
8399
});
100+
101+
it('prefers result_dir over eval_path for eval result query identity', () => {
102+
expect(
103+
evalResultSearchParams({
104+
resultDir: 'opaque/case',
105+
evalPath: 'evals/smoke.eval.yaml',
106+
}),
107+
).toEqual({ result_dir: 'opaque/case' });
108+
expect(evalResultSearchParams({ evalPath: 'evals/smoke.eval.yaml' })).toEqual({
109+
eval_path: 'evals/smoke.eval.yaml',
110+
});
111+
});
112+
113+
it('matches and keys eval results by result_dir before eval_path', () => {
114+
const result = {
115+
testId: 'shared',
116+
target: 'codex',
117+
result_dir: 'opaque/shared',
118+
eval_path: 'evals/auth.eval.yaml',
119+
};
120+
121+
expect(evalResultIdentityKey(result)).toBe('opaque/shared');
122+
expect(matchesEvalResultIdentity(result, 'shared', { resultDir: 'opaque/shared' })).toBe(true);
123+
expect(matchesEvalResultIdentity(result, 'shared', { resultDir: 'other/shared' })).toBe(false);
124+
expect(matchesEvalResultIdentity(result, 'shared', { evalPath: 'evals/auth.eval.yaml' })).toBe(
125+
true,
126+
);
127+
});
84128
});

0 commit comments

Comments
 (0)