diff --git a/apps/dashboard/src/components/RunDetail.tsx b/apps/dashboard/src/components/RunDetail.tsx index f33747f00..6eddbd472 100644 --- a/apps/dashboard/src/components/RunDetail.tsx +++ b/apps/dashboard/src/components/RunDetail.tsx @@ -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'; @@ -38,6 +39,8 @@ interface SuiteStats { interface CategoryGroup { name: string; + displayName: string; + mutedDisplayName?: string; suites: SuiteStats[]; total: number; passed: number; @@ -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, @@ -141,39 +148,59 @@ export function RunDetail({ results, runId, projectId }: RunDetailProps) { - {categories.map((cat) => ( - - - {projectId ? ( - - {cat.name} - - ) : ( - { + const label = ( + + {cat.displayName} + {cat.mutedDisplayName ? ( + - {cat.name} - - )} - - - 0 ? cat.passed / cat.total : 0} /> - - - {cat.passed} - - - {cat.failed > 0 ? cat.failed : 0} - - {cat.total} - - ))} + {cat.mutedDisplayName} + + ) : null} + + ); + + return ( + + + {projectId ? ( + + {label} + + ) : ( + + {label} + + )} + + + 0 ? cat.passed / cat.total : 0} /> + + + {cat.passed} + + + {cat.failed > 0 ? cat.failed : 0} + + + {cat.total} + + + ); + })} diff --git a/apps/dashboard/src/lib/run-detail-context.test.ts b/apps/dashboard/src/lib/run-detail-context.test.ts new file mode 100644 index 000000000..29aa9f23d --- /dev/null +++ b/apps/dashboard/src/lib/run-detail-context.test.ts @@ -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' }); + }); +}); diff --git a/apps/dashboard/src/lib/run-detail-context.ts b/apps/dashboard/src/lib/run-detail-context.ts new file mode 100644 index 000000000..1425dbef3 --- /dev/null +++ b/apps/dashboard/src/lib/run-detail-context.ts @@ -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; + +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, + }; +} diff --git a/apps/dashboard/src/routes/projects/$projectId_/runs/$runId.tsx b/apps/dashboard/src/routes/projects/$projectId_/runs/$runId.tsx index e18e267c3..a33dbc132 100644 --- a/apps/dashboard/src/routes/projects/$projectId_/runs/$runId.tsx +++ b/apps/dashboard/src/routes/projects/$projectId_/runs/$runId.tsx @@ -10,7 +10,8 @@ import { RunDetail } from '~/components/RunDetail'; import { RunEvalModal } from '~/components/RunEvalModal'; import { RunStatusIndicator } from '~/components/RunStatusIndicator'; import { StopRunButton } from '~/components/StopRunButton'; -import { useProjectRunDetail, useStudioConfig } from '~/lib/api'; +import { useProjectRunDetail, useRemoteStatus, useStudioConfig } from '~/lib/api'; +import { buildRunDetailHeader } from '~/lib/run-detail-context'; export const Route = createFileRoute('/projects/$projectId_/runs/$runId')({ component: ProjectRunDetailPage, @@ -20,6 +21,7 @@ function ProjectRunDetailPage() { const { projectId, runId } = Route.useParams(); const { data, isLoading, error } = useProjectRunDetail(projectId, runId); const { data: config } = useStudioConfig(projectId); + const { data: remoteStatus } = useRemoteStatus(projectId); const [showRunEval, setShowRunEval] = useState(false); const isReadOnly = config?.read_only === true; @@ -46,32 +48,41 @@ function ProjectRunDetailPage() { const firstResult = data?.results?.[0]; const target = firstResult?.target; - const experiment = firstResult?.experiment; - const timestamp = firstResult?.timestamp; const prefill = target ? { target } : undefined; const runStatus = data?.status; const isActiveRun = runStatus === 'starting' || runStatus === 'running'; - const heading = (() => { - const parts = [experiment, target].filter((p) => p && p !== 'default'); - return parts.length > 0 ? parts.join(' · ') : runId; - })(); - - const meta = [ - target, - experiment && experiment !== 'default' ? experiment : null, - timestamp ? new Date(timestamp).toLocaleString() : null, - data?.source, - ] - .filter(Boolean) - .join(' · '); + const header = buildRunDetailHeader({ + runId, + results: data?.results ?? [], + source: data?.source, + sourceLabel: data?.source_label, + remoteRepo: data?.source === 'remote' ? remoteStatus?.repo : undefined, + formatTimestamp: (value) => new Date(value).toLocaleString(), + }); return (
-

{heading}

-

{meta}

+
+

{header.heading}

+ {header.sourceBadge ? ( + + {header.sourceBadge} + + ) : null} +
+ {header.meta ?

{header.meta}

: null} + {header.sourceContext.length > 0 ? ( +
+ {header.sourceContext.map((item) => ( + + {item.label}: {item.value} + + ))} +
+ ) : null}
{!isReadOnly && isActiveRun ? ( diff --git a/apps/dashboard/src/routes/runs/$runId.tsx b/apps/dashboard/src/routes/runs/$runId.tsx index cc17e5b7c..be82650a5 100644 --- a/apps/dashboard/src/routes/runs/$runId.tsx +++ b/apps/dashboard/src/routes/runs/$runId.tsx @@ -10,7 +10,8 @@ import { RunDetail } from '~/components/RunDetail'; import { RunEvalModal } from '~/components/RunEvalModal'; import { RunStatusIndicator } from '~/components/RunStatusIndicator'; import { StopRunButton } from '~/components/StopRunButton'; -import { useRunDetail, useStudioConfig } from '~/lib/api'; +import { useRemoteStatus, useRunDetail, useStudioConfig } from '~/lib/api'; +import { buildRunDetailHeader } from '~/lib/run-detail-context'; export const Route = createFileRoute('/runs/$runId')({ component: RunDetailPage, @@ -20,6 +21,7 @@ function RunDetailPage() { const { runId } = Route.useParams(); const { data, isLoading, error } = useRunDetail(runId); const { data: config } = useStudioConfig(); + const { data: remoteStatus } = useRemoteStatus(); const [showRunEval, setShowRunEval] = useState(false); const isReadOnly = config?.read_only === true; @@ -46,33 +48,42 @@ function RunDetailPage() { const firstResult = data?.results?.[0]; const target = firstResult?.target; - const experiment = firstResult?.experiment; - const timestamp = firstResult?.timestamp; const prefill = target ? { target } : undefined; const runStatus = data?.status; const isActiveRun = runStatus === 'starting' || runStatus === 'running'; - const heading = (() => { - const parts = [experiment, target].filter((p) => p && p !== 'default'); - return parts.length > 0 ? parts.join(' · ') : runId; - })(); - - const meta = [ - target, - experiment && experiment !== 'default' ? experiment : null, - timestamp ? new Date(timestamp).toLocaleString() : null, - data?.source, - ] - .filter(Boolean) - .join(' · '); + const header = buildRunDetailHeader({ + runId, + results: data?.results ?? [], + source: data?.source, + sourceLabel: data?.source_label, + remoteRepo: data?.source === 'remote' ? remoteStatus?.repo : undefined, + formatTimestamp: (value) => new Date(value).toLocaleString(), + }); return (
-

{heading}

-

{meta}

+
+

{header.heading}

+ {header.sourceBadge ? ( + + {header.sourceBadge} + + ) : null} +
+ {header.meta ?

{header.meta}

: null} + {header.sourceContext.length > 0 ? ( +
+ {header.sourceContext.map((item) => ( + + {item.label}: {item.value} + + ))} +
+ ) : null}
{!isReadOnly && isActiveRun ? (