Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 59 additions & 32 deletions apps/dashboard/src/components/RunDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Link } from '@tanstack/react-router';
import type { EvalResult } from '~/lib/types';

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

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

interface CategoryGroup {
name: string;
displayName: string;
mutedDisplayName?: string;
suites: SuiteStats[];
total: number;
passed: number;
Expand Down Expand Up @@ -80,8 +83,12 @@ function buildCategoryGroups(results: EvalResult[], passThreshold: number): Cate
const failed = suites.reduce((s, d) => s + d.failed, 0);
const scoreSum = suites.reduce((s, d) => s + d.avgScore * d.total, 0);

const display = formatCategoryDisplay(catName);

return {
name: catName,
displayName: display.label,
mutedDisplayName: display.mutedLabel,
suites,
total,
passed,
Expand Down Expand Up @@ -141,39 +148,59 @@ export function RunDetail({ results, runId, projectId }: RunDetailProps) {
</tr>
</thead>
<tbody className="divide-y divide-gray-800/50">
{categories.map((cat) => (
<tr key={cat.name} className="transition-colors hover:bg-gray-900/30">
<td className="px-4 py-2.5 font-medium text-gray-200">
{projectId ? (
<Link
to="/projects/$projectId/runs/$runId/category/$category"
params={{ projectId, runId, category: cat.name }}
className="text-cyan-400 hover:text-cyan-300 hover:underline"
>
{cat.name}
</Link>
) : (
<Link
to="/runs/$runId/category/$category"
params={{ runId, category: cat.name }}
className="text-cyan-400 hover:text-cyan-300 hover:underline"
{categories.map((cat) => {
const label = (
<span className="flex min-w-0 flex-col">
<span className="truncate">{cat.displayName}</span>
{cat.mutedDisplayName ? (
<span
className="mt-0.5 truncate text-xs font-normal text-gray-500"
title={cat.mutedDisplayName}
>
{cat.name}
</Link>
)}
</td>
<td className="px-4 py-2.5">
<PassRatePill rate={cat.total > 0 ? cat.passed / cat.total : 0} />
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-emerald-400">
{cat.passed}
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-red-400">
{cat.failed > 0 ? cat.failed : <span className="text-gray-600">0</span>}
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-gray-400">{cat.total}</td>
</tr>
))}
{cat.mutedDisplayName}
</span>
) : null}
</span>
);

return (
<tr key={cat.name} className="transition-colors hover:bg-gray-900/30">
<td className="px-4 py-2.5 font-medium text-gray-200">
{projectId ? (
<Link
to="/projects/$projectId/runs/$runId/category/$category"
params={{ projectId, runId, category: cat.name }}
className="text-cyan-400 hover:text-cyan-300 hover:underline"
title={cat.mutedDisplayName ?? cat.displayName}
>
{label}
</Link>
) : (
<Link
to="/runs/$runId/category/$category"
params={{ runId, category: cat.name }}
className="text-cyan-400 hover:text-cyan-300 hover:underline"
title={cat.mutedDisplayName ?? cat.displayName}
>
{label}
</Link>
)}
</td>
<td className="px-4 py-2.5">
<PassRatePill rate={cat.total > 0 ? cat.passed / cat.total : 0} />
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-emerald-400">
{cat.passed}
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-red-400">
{cat.failed > 0 ? cat.failed : <span className="text-gray-600">0</span>}
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-gray-400">
{cat.total}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
Expand Down
77 changes: 77 additions & 0 deletions apps/dashboard/src/lib/run-detail-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'bun:test';

import type { EvalResult } from './types';

import { buildRunDetailHeader, formatCategoryDisplay } from './run-detail-context';

const remoteRunDetailFixture = {
runId: 'remote::smoke-wtg-2026-06-04T02-19-00Z',
source: 'remote' as const,
sourceLabel: 'smoke-wtg-2026-06-04T02-19-00Z',
remoteRepo: 'WiseTechGlobal/WTG.AI.Prompts.EvalResults',
results: [
{
testId: 'smoke',
score: 1,
target: 'codex',
experiment: 'smoke',
timestamp: '2026-06-04T02:19:00.000Z',
category: '../../../../../tmp',
suite: 'wtg-smoke',
} satisfies EvalResult,
],
};

const localRunResults: EvalResult[] = [
{
testId: 'case-a',
score: 1,
target: 'azure',
experiment: 'default',
timestamp: '2026-06-04T02:19:00.000Z',
},
];

describe('buildRunDetailHeader', () => {
it('preserves remote run source context on detail pages', () => {
const header = buildRunDetailHeader({
...remoteRunDetailFixture,
formatTimestamp: (timestamp) => timestamp,
});

expect(header.heading).toBe('smoke-wtg-2026-06-04T02-19-00Z');
expect(header.sourceBadge).toBe('Remote');
expect(header.sourceLabel).toBe('smoke-wtg-2026-06-04T02-19-00Z');
expect(header.sourceContext).toEqual([
{ label: 'Repo', value: 'WiseTechGlobal/WTG.AI.Prompts.EvalResults' },
]);
expect(header.meta).toBe('codex · smoke · 2026-06-04T02:19:00.000Z');
});

it('keeps local run heading and meta behavior unchanged', () => {
const header = buildRunDetailHeader({
runId: 'local-run',
source: 'local',
results: localRunResults,
formatTimestamp: (timestamp) => timestamp,
});

expect(header.heading).toBe('azure');
expect(header.meta).toBe('azure · 2026-06-04T02:19:00.000Z · local');
expect(header.sourceBadge).toBeUndefined();
expect(header.sourceContext).toEqual([]);
});
});

describe('formatCategoryDisplay', () => {
it('uses the basename as the primary label for traversal-like categories', () => {
expect(formatCategoryDisplay(remoteRunDetailFixture.results[0].category)).toEqual({
label: 'tmp',
mutedLabel: '../../../../../tmp',
});
});

it('leaves normal slash-separated categories intact', () => {
expect(formatCategoryDisplay('examples/showcase')).toEqual({ label: 'examples/showcase' });
});
});
129 changes: 129 additions & 0 deletions apps/dashboard/src/lib/run-detail-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* Pure helpers for run detail headings and labels.
*
* The API returns local and remote runs through the same shape, but remote
* runs carry extra source identity (`source_label`, results repo). Keep that
* presentation logic here so route components stay thin and tests can pin
* the remote-context contract without rendering React.
*/

import type { EvalResult, RunDetailResponse } from './types';

type RunSource = RunDetailResponse['source'];

type HeaderResult = Pick<EvalResult, 'experiment' | 'target' | 'timestamp'>;

export interface RunDetailHeaderInput {
runId: string;
results: readonly HeaderResult[];
source?: RunSource;
sourceLabel?: string;
remoteRepo?: string;
formatTimestamp?: (timestamp: string) => string;
}

export interface RunDetailHeaderContextItem {
label: string;
value: string;
}

export interface RunDetailHeader {
heading: string;
meta: string;
sourceBadge?: 'Remote';
sourceLabel?: string;
sourceContext: RunDetailHeaderContextItem[];
}

export interface CategoryDisplay {
label: string;
mutedLabel?: string;
}

function nonDefaultExperiment(experiment: string | undefined): string | undefined {
return experiment && experiment !== 'default' ? experiment : undefined;
}

function resultHeading(runId: string, firstResult: HeaderResult | undefined): string {
const parts = [nonDefaultExperiment(firstResult?.experiment), firstResult?.target].filter(
(part): part is string => Boolean(part),
);
return parts.length > 0 ? parts.join(' · ') : runId;
}

function cleanOptional(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}

export function buildRunDetailHeader(input: RunDetailHeaderInput): RunDetailHeader {
const firstResult = input.results[0];
const sourceLabel = cleanOptional(input.sourceLabel);
const isRemote = input.source === 'remote';
const heading = isRemote && sourceLabel ? sourceLabel : resultHeading(input.runId, firstResult);
const formattedTimestamp =
firstResult?.timestamp && input.formatTimestamp
? input.formatTimestamp(firstResult.timestamp)
: firstResult?.timestamp;

const metaItems = [
firstResult?.target,
nonDefaultExperiment(firstResult?.experiment),
formattedTimestamp,
isRemote ? undefined : input.source,
].filter((item): item is string => Boolean(item));

const remoteRepo = cleanOptional(input.remoteRepo);
const sourceContext: RunDetailHeaderContextItem[] = [];
if (isRemote) {
if (sourceLabel && sourceLabel !== heading) {
sourceContext.push({ label: 'Source', value: sourceLabel });
}
if (remoteRepo) {
sourceContext.push({ label: 'Repo', value: remoteRepo });
}
}

return {
heading,
meta: metaItems.join(' · '),
...(isRemote && { sourceBadge: 'Remote' as const }),
...(sourceLabel && { sourceLabel }),
sourceContext,
};
}

function isTraversalLikeCategory(category: string): boolean {
const normalized = category.replace(/\\/g, '/');
return (
normalized === '.' ||
normalized === '..' ||
normalized.startsWith('./') ||
normalized.startsWith('../') ||
normalized.startsWith('/') ||
/^[A-Za-z]:\//.test(normalized) ||
normalized.includes('/../') ||
normalized.includes('/./')
);
}

function basenameFromCategory(category: string): string | undefined {
const segment = category
.split(/[\\/]+/)
.filter((part) => part && part !== '.' && part !== '..')
.at(-1)
?.trim();
return segment || undefined;
}

export function formatCategoryDisplay(category: string | undefined): CategoryDisplay {
const raw = cleanOptional(category) ?? 'Uncategorized';
if (!isTraversalLikeCategory(raw)) {
return { label: raw };
}

return {
label: basenameFromCategory(raw) ?? 'Uncategorized',
mutedLabel: raw,
};
}
Loading
Loading