Skip to content

Commit e0cd2e4

Browse files
authored
fix(dashboard): preserve remote run detail context
Bead: av-agy
1 parent a62931a commit e0cd2e4

5 files changed

Lines changed: 323 additions & 68 deletions

File tree

apps/dashboard/src/components/RunDetail.tsx

Lines changed: 59 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Link } from '@tanstack/react-router';
1818
import type { EvalResult } from '~/lib/types';
1919

2020
import { isPassing, useRunLog, useStudioConfig } from '~/lib/api';
21+
import { formatCategoryDisplay } from '~/lib/run-detail-context';
2122

2223
import { PassRatePill } from './PassRatePill';
2324
import { StatsCards } from './StatsCards';
@@ -38,6 +39,8 @@ interface SuiteStats {
3839

3940
interface CategoryGroup {
4041
name: string;
42+
displayName: string;
43+
mutedDisplayName?: string;
4144
suites: SuiteStats[];
4245
total: number;
4346
passed: number;
@@ -80,8 +83,12 @@ function buildCategoryGroups(results: EvalResult[], passThreshold: number): Cate
8083
const failed = suites.reduce((s, d) => s + d.failed, 0);
8184
const scoreSum = suites.reduce((s, d) => s + d.avgScore * d.total, 0);
8285

86+
const display = formatCategoryDisplay(catName);
87+
8388
return {
8489
name: catName,
90+
displayName: display.label,
91+
mutedDisplayName: display.mutedLabel,
8592
suites,
8693
total,
8794
passed,
@@ -141,39 +148,59 @@ export function RunDetail({ results, runId, projectId }: RunDetailProps) {
141148
</tr>
142149
</thead>
143150
<tbody className="divide-y divide-gray-800/50">
144-
{categories.map((cat) => (
145-
<tr key={cat.name} className="transition-colors hover:bg-gray-900/30">
146-
<td className="px-4 py-2.5 font-medium text-gray-200">
147-
{projectId ? (
148-
<Link
149-
to="/projects/$projectId/runs/$runId/category/$category"
150-
params={{ projectId, runId, category: cat.name }}
151-
className="text-cyan-400 hover:text-cyan-300 hover:underline"
152-
>
153-
{cat.name}
154-
</Link>
155-
) : (
156-
<Link
157-
to="/runs/$runId/category/$category"
158-
params={{ runId, category: cat.name }}
159-
className="text-cyan-400 hover:text-cyan-300 hover:underline"
151+
{categories.map((cat) => {
152+
const label = (
153+
<span className="flex min-w-0 flex-col">
154+
<span className="truncate">{cat.displayName}</span>
155+
{cat.mutedDisplayName ? (
156+
<span
157+
className="mt-0.5 truncate text-xs font-normal text-gray-500"
158+
title={cat.mutedDisplayName}
160159
>
161-
{cat.name}
162-
</Link>
163-
)}
164-
</td>
165-
<td className="px-4 py-2.5">
166-
<PassRatePill rate={cat.total > 0 ? cat.passed / cat.total : 0} />
167-
</td>
168-
<td className="px-4 py-2.5 text-right tabular-nums text-emerald-400">
169-
{cat.passed}
170-
</td>
171-
<td className="px-4 py-2.5 text-right tabular-nums text-red-400">
172-
{cat.failed > 0 ? cat.failed : <span className="text-gray-600">0</span>}
173-
</td>
174-
<td className="px-4 py-2.5 text-right tabular-nums text-gray-400">{cat.total}</td>
175-
</tr>
176-
))}
160+
{cat.mutedDisplayName}
161+
</span>
162+
) : null}
163+
</span>
164+
);
165+
166+
return (
167+
<tr key={cat.name} className="transition-colors hover:bg-gray-900/30">
168+
<td className="px-4 py-2.5 font-medium text-gray-200">
169+
{projectId ? (
170+
<Link
171+
to="/projects/$projectId/runs/$runId/category/$category"
172+
params={{ projectId, runId, category: cat.name }}
173+
className="text-cyan-400 hover:text-cyan-300 hover:underline"
174+
title={cat.mutedDisplayName ?? cat.displayName}
175+
>
176+
{label}
177+
</Link>
178+
) : (
179+
<Link
180+
to="/runs/$runId/category/$category"
181+
params={{ runId, category: cat.name }}
182+
className="text-cyan-400 hover:text-cyan-300 hover:underline"
183+
title={cat.mutedDisplayName ?? cat.displayName}
184+
>
185+
{label}
186+
</Link>
187+
)}
188+
</td>
189+
<td className="px-4 py-2.5">
190+
<PassRatePill rate={cat.total > 0 ? cat.passed / cat.total : 0} />
191+
</td>
192+
<td className="px-4 py-2.5 text-right tabular-nums text-emerald-400">
193+
{cat.passed}
194+
</td>
195+
<td className="px-4 py-2.5 text-right tabular-nums text-red-400">
196+
{cat.failed > 0 ? cat.failed : <span className="text-gray-600">0</span>}
197+
</td>
198+
<td className="px-4 py-2.5 text-right tabular-nums text-gray-400">
199+
{cat.total}
200+
</td>
201+
</tr>
202+
);
203+
})}
177204
</tbody>
178205
</table>
179206
</div>
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, expect, it } from 'bun:test';
2+
3+
import type { EvalResult } from './types';
4+
5+
import { buildRunDetailHeader, formatCategoryDisplay } from './run-detail-context';
6+
7+
const remoteRunDetailFixture = {
8+
runId: 'remote::smoke-wtg-2026-06-04T02-19-00Z',
9+
source: 'remote' as const,
10+
sourceLabel: 'smoke-wtg-2026-06-04T02-19-00Z',
11+
remoteRepo: 'WiseTechGlobal/WTG.AI.Prompts.EvalResults',
12+
results: [
13+
{
14+
testId: 'smoke',
15+
score: 1,
16+
target: 'codex',
17+
experiment: 'smoke',
18+
timestamp: '2026-06-04T02:19:00.000Z',
19+
category: '../../../../../tmp',
20+
suite: 'wtg-smoke',
21+
} satisfies EvalResult,
22+
],
23+
};
24+
25+
const localRunResults: EvalResult[] = [
26+
{
27+
testId: 'case-a',
28+
score: 1,
29+
target: 'azure',
30+
experiment: 'default',
31+
timestamp: '2026-06-04T02:19:00.000Z',
32+
},
33+
];
34+
35+
describe('buildRunDetailHeader', () => {
36+
it('preserves remote run source context on detail pages', () => {
37+
const header = buildRunDetailHeader({
38+
...remoteRunDetailFixture,
39+
formatTimestamp: (timestamp) => timestamp,
40+
});
41+
42+
expect(header.heading).toBe('smoke-wtg-2026-06-04T02-19-00Z');
43+
expect(header.sourceBadge).toBe('Remote');
44+
expect(header.sourceLabel).toBe('smoke-wtg-2026-06-04T02-19-00Z');
45+
expect(header.sourceContext).toEqual([
46+
{ label: 'Repo', value: 'WiseTechGlobal/WTG.AI.Prompts.EvalResults' },
47+
]);
48+
expect(header.meta).toBe('codex · smoke · 2026-06-04T02:19:00.000Z');
49+
});
50+
51+
it('keeps local run heading and meta behavior unchanged', () => {
52+
const header = buildRunDetailHeader({
53+
runId: 'local-run',
54+
source: 'local',
55+
results: localRunResults,
56+
formatTimestamp: (timestamp) => timestamp,
57+
});
58+
59+
expect(header.heading).toBe('azure');
60+
expect(header.meta).toBe('azure · 2026-06-04T02:19:00.000Z · local');
61+
expect(header.sourceBadge).toBeUndefined();
62+
expect(header.sourceContext).toEqual([]);
63+
});
64+
});
65+
66+
describe('formatCategoryDisplay', () => {
67+
it('uses the basename as the primary label for traversal-like categories', () => {
68+
expect(formatCategoryDisplay(remoteRunDetailFixture.results[0].category)).toEqual({
69+
label: 'tmp',
70+
mutedLabel: '../../../../../tmp',
71+
});
72+
});
73+
74+
it('leaves normal slash-separated categories intact', () => {
75+
expect(formatCategoryDisplay('examples/showcase')).toEqual({ label: 'examples/showcase' });
76+
});
77+
});
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Pure helpers for run detail headings and labels.
3+
*
4+
* The API returns local and remote runs through the same shape, but remote
5+
* runs carry extra source identity (`source_label`, results repo). Keep that
6+
* presentation logic here so route components stay thin and tests can pin
7+
* the remote-context contract without rendering React.
8+
*/
9+
10+
import type { EvalResult, RunDetailResponse } from './types';
11+
12+
type RunSource = RunDetailResponse['source'];
13+
14+
type HeaderResult = Pick<EvalResult, 'experiment' | 'target' | 'timestamp'>;
15+
16+
export interface RunDetailHeaderInput {
17+
runId: string;
18+
results: readonly HeaderResult[];
19+
source?: RunSource;
20+
sourceLabel?: string;
21+
remoteRepo?: string;
22+
formatTimestamp?: (timestamp: string) => string;
23+
}
24+
25+
export interface RunDetailHeaderContextItem {
26+
label: string;
27+
value: string;
28+
}
29+
30+
export interface RunDetailHeader {
31+
heading: string;
32+
meta: string;
33+
sourceBadge?: 'Remote';
34+
sourceLabel?: string;
35+
sourceContext: RunDetailHeaderContextItem[];
36+
}
37+
38+
export interface CategoryDisplay {
39+
label: string;
40+
mutedLabel?: string;
41+
}
42+
43+
function nonDefaultExperiment(experiment: string | undefined): string | undefined {
44+
return experiment && experiment !== 'default' ? experiment : undefined;
45+
}
46+
47+
function resultHeading(runId: string, firstResult: HeaderResult | undefined): string {
48+
const parts = [nonDefaultExperiment(firstResult?.experiment), firstResult?.target].filter(
49+
(part): part is string => Boolean(part),
50+
);
51+
return parts.length > 0 ? parts.join(' · ') : runId;
52+
}
53+
54+
function cleanOptional(value: string | undefined): string | undefined {
55+
const trimmed = value?.trim();
56+
return trimmed ? trimmed : undefined;
57+
}
58+
59+
export function buildRunDetailHeader(input: RunDetailHeaderInput): RunDetailHeader {
60+
const firstResult = input.results[0];
61+
const sourceLabel = cleanOptional(input.sourceLabel);
62+
const isRemote = input.source === 'remote';
63+
const heading = isRemote && sourceLabel ? sourceLabel : resultHeading(input.runId, firstResult);
64+
const formattedTimestamp =
65+
firstResult?.timestamp && input.formatTimestamp
66+
? input.formatTimestamp(firstResult.timestamp)
67+
: firstResult?.timestamp;
68+
69+
const metaItems = [
70+
firstResult?.target,
71+
nonDefaultExperiment(firstResult?.experiment),
72+
formattedTimestamp,
73+
isRemote ? undefined : input.source,
74+
].filter((item): item is string => Boolean(item));
75+
76+
const remoteRepo = cleanOptional(input.remoteRepo);
77+
const sourceContext: RunDetailHeaderContextItem[] = [];
78+
if (isRemote) {
79+
if (sourceLabel && sourceLabel !== heading) {
80+
sourceContext.push({ label: 'Source', value: sourceLabel });
81+
}
82+
if (remoteRepo) {
83+
sourceContext.push({ label: 'Repo', value: remoteRepo });
84+
}
85+
}
86+
87+
return {
88+
heading,
89+
meta: metaItems.join(' · '),
90+
...(isRemote && { sourceBadge: 'Remote' as const }),
91+
...(sourceLabel && { sourceLabel }),
92+
sourceContext,
93+
};
94+
}
95+
96+
function isTraversalLikeCategory(category: string): boolean {
97+
const normalized = category.replace(/\\/g, '/');
98+
return (
99+
normalized === '.' ||
100+
normalized === '..' ||
101+
normalized.startsWith('./') ||
102+
normalized.startsWith('../') ||
103+
normalized.startsWith('/') ||
104+
/^[A-Za-z]:\//.test(normalized) ||
105+
normalized.includes('/../') ||
106+
normalized.includes('/./')
107+
);
108+
}
109+
110+
function basenameFromCategory(category: string): string | undefined {
111+
const segment = category
112+
.split(/[\\/]+/)
113+
.filter((part) => part && part !== '.' && part !== '..')
114+
.at(-1)
115+
?.trim();
116+
return segment || undefined;
117+
}
118+
119+
export function formatCategoryDisplay(category: string | undefined): CategoryDisplay {
120+
const raw = cleanOptional(category) ?? 'Uncategorized';
121+
if (!isTraversalLikeCategory(raw)) {
122+
return { label: raw };
123+
}
124+
125+
return {
126+
label: basenameFromCategory(raw) ?? 'Uncategorized',
127+
mutedLabel: raw,
128+
};
129+
}

0 commit comments

Comments
 (0)