From 64c4c337db8eaa51568089b4a7009718525cb01c Mon Sep 17 00:00:00 2001 From: Christopher Date: Tue, 19 May 2026 20:17:14 +1000 Subject: [PATCH 1/2] chore(studio): track project-mode unification Document the follow-up work to converge Studio onto one project-scoped routing and data model so single-project mode becomes a presentation choice instead of a separate app path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From 58251b5ae466f28f899c0d8860c0a331fc9b7da9 Mon Sep 17 00:00:00 2001 From: Christopher Date: Wed, 20 May 2026 08:44:58 +1000 Subject: [PATCH 2/2] chore(studio): unify project-scoped navigation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/cli/src/commands/results/serve.ts | 2 +- apps/cli/test/commands/results/serve.test.ts | 4 +- apps/cli/test/unit/studio-navigation.test.ts | 62 +++++++ apps/studio/src/components/Breadcrumbs.tsx | 93 ++++++++-- apps/studio/src/components/EvalDetail.tsx | 8 +- .../src/components/ResumeRunActions.tsx | 9 +- apps/studio/src/components/RunDetail.tsx | 22 ++- apps/studio/src/components/RunEvalModal.tsx | 5 +- apps/studio/src/components/RunList.tsx | 2 +- apps/studio/src/components/Sidebar.tsx | 162 +++++++++++++++++- apps/studio/src/lib/api.ts | 13 +- apps/studio/src/lib/navigation.ts | 81 +++++++++ apps/studio/src/routeTree.gen.ts | 68 ++++++++ apps/studio/src/routes/index.tsx | 55 ++++-- .../studio/src/routes/projects/$projectId.tsx | 6 +- .../$projectId_/evals/$runId.$evalId.tsx | 2 +- .../projects/$projectId_/jobs/$runId.tsx | 147 ++++++++++++++++ .../projects/$projectId_/runs/$runId.tsx | 2 +- .../runs/$runId_.category.$category.tsx | 93 ++++++++++ .../$projectId_/runs/$runId_.suite.$suite.tsx | 121 +++++++++++++ 20 files changed, 908 insertions(+), 49 deletions(-) create mode 100644 apps/cli/test/unit/studio-navigation.test.ts create mode 100644 apps/studio/src/lib/navigation.ts create mode 100644 apps/studio/src/routes/projects/$projectId_/jobs/$runId.tsx create mode 100644 apps/studio/src/routes/projects/$projectId_/runs/$runId_.category.$category.tsx create mode 100644 apps/studio/src/routes/projects/$projectId_/runs/$runId_.suite.$suite.tsx diff --git a/apps/cli/src/commands/results/serve.ts b/apps/cli/src/commands/results/serve.ts index 9c33d03e..53e55dee 100644 --- a/apps/cli/src/commands/results/serve.ts +++ b/apps/cli/src/commands/results/serve.ts @@ -133,7 +133,7 @@ export function resolveDashboardMode( return { projectDashboard: false }; } - return { projectDashboard: projectCount > 1 }; + return { projectDashboard: projectCount > 0 }; } // ── Feedback persistence ───────────────────────────────────────────────── diff --git a/apps/cli/test/commands/results/serve.test.ts b/apps/cli/test/commands/results/serve.test.ts index 7d82771f..094a44b2 100644 --- a/apps/cli/test/commands/results/serve.test.ts +++ b/apps/cli/test/commands/results/serve.test.ts @@ -111,9 +111,9 @@ describe('resolveDashboardMode', () => { }); }); - it('defaults to single-project mode when exactly one project is registered', () => { + it('uses the project dashboard flow when exactly one project is registered', () => { expect(resolveDashboardMode(1, {})).toEqual({ - projectDashboard: false, + projectDashboard: true, }); }); diff --git a/apps/cli/test/unit/studio-navigation.test.ts b/apps/cli/test/unit/studio-navigation.test.ts new file mode 100644 index 00000000..41eb8b3a --- /dev/null +++ b/apps/cli/test/unit/studio-navigation.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'bun:test'; + +import { + categoryPath, + evalPath, + experimentPath, + jobPath, + projectHomePath, + resolveIndexRoute, + runPath, + runsHomePath, + suitePath, +} from '../../../studio/src/lib/navigation.ts'; + +describe('studio navigation helpers', () => { + it('redirects the root entrypoint to the only registered project', () => { + expect(resolveIndexRoute(['demo-project'], undefined, 'analytics')).toEqual({ + kind: 'redirect', + redirectPath: '/projects/demo-project?tab=analytics', + }); + }); + + it('keeps explicit single-project mode on the legacy root home', () => { + expect(resolveIndexRoute(['demo-project'], false, 'runs')).toEqual({ + kind: 'single-project-home', + }); + }); + + it('keeps the dashboard for zero or many projects', () => { + expect(resolveIndexRoute([], true)).toEqual({ kind: 'dashboard' }); + expect(resolveIndexRoute(['one', 'two'], true)).toEqual({ kind: 'dashboard' }); + }); + + it('builds project-scoped drill-down paths', () => { + expect(projectHomePath('demo project', 'runs')).toBe('/projects/demo%20project?tab=runs'); + expect(runPath('run::1', 'demo project')).toBe('/projects/demo%20project/runs/run%3A%3A1'); + expect(evalPath('run::1', 'case/a', 'demo project')).toBe( + '/projects/demo%20project/evals/run%3A%3A1/case%2Fa', + ); + expect(jobPath('job/1', 'demo project')).toBe('/projects/demo%20project/jobs/job%2F1'); + expect(categoryPath('run::1', 'Safety > PII', 'demo project')).toBe( + '/projects/demo%20project/runs/run%3A%3A1/category/Safety%20%3E%20PII', + ); + expect(suitePath('run::1', 'evals/smoke.eval.yaml', 'demo project')).toBe( + '/projects/demo%20project/runs/run%3A%3A1/suite/evals%2Fsmoke.eval.yaml', + ); + expect(experimentPath('prod-baseline', 'demo project')).toBe( + '/projects/demo%20project/experiments/prod-baseline', + ); + }); + + it('keeps unscoped paths for legacy single-project routes', () => { + expect(runPath('run::1')).toBe('/runs/run%3A%3A1'); + expect(evalPath('run::1', 'case/a')).toBe('/evals/run%3A%3A1/case%2Fa'); + expect(jobPath('job/1')).toBe('/jobs/job%2F1'); + expect(categoryPath('run::1', 'Safety')).toBe('/runs/run%3A%3A1/category/Safety'); + expect(suitePath('run::1', 'evals/smoke.eval.yaml')).toBe( + '/runs/run%3A%3A1/suite/evals%2Fsmoke.eval.yaml', + ); + expect(runsHomePath()).toBe('/?tab=runs'); + }); +}); diff --git a/apps/studio/src/components/Breadcrumbs.tsx b/apps/studio/src/components/Breadcrumbs.tsx index 361bf8b1..5f18df96 100644 --- a/apps/studio/src/components/Breadcrumbs.tsx +++ b/apps/studio/src/components/Breadcrumbs.tsx @@ -7,6 +7,16 @@ import { Link, useMatches } from '@tanstack/react-router'; +import { + categoryPath, + evalPath, + experimentPath, + jobPath, + projectHomePath, + runPath, + suitePath, +} from '~/lib/navigation'; + interface BreadcrumbSegment { label: string; to?: string; @@ -35,7 +45,7 @@ function deriveSegments(matches: ReturnType): BreadcrumbSegme if (!segments.some((s) => s.label === params.projectId)) { segments.push({ label: params.projectId, - to: `/projects/${encodeURIComponent(params.projectId)}`, + to: projectHomePath(params.projectId), }); } if (routeId === '/projects/$projectId') { @@ -43,43 +53,104 @@ function deriveSegments(matches: ReturnType): BreadcrumbSegme } } - if (routeId.includes('/runs/$runId/category/$category')) { - if (!segments.some((s) => s.label === params.runId)) { + if (routeId.includes('/projects/$projectId_/jobs/$runId')) { + if (!segments.some((s) => s.label === formatRunLabel(params.runId))) { + segments.push({ + label: formatRunLabel(params.runId), + to: jobPath(params.runId, params.projectId), + }); + } + } else if (routeId.includes('/projects/$projectId_/runs/$runId/category/$category')) { + if (!segments.some((s) => s.label === formatRunLabel(params.runId))) { segments.push({ label: formatRunLabel(params.runId), - to: `/runs/${encodeURIComponent(params.runId)}`, + to: runPath(params.runId, params.projectId), }); } segments.push({ label: params.category ?? 'Category', - to: match.pathname, + to: categoryPath(params.runId, params.category ?? 'Category', params.projectId), + }); + } else if (routeId.includes('/projects/$projectId_/runs/$runId/suite/$suite')) { + if (!segments.some((s) => s.label === formatRunLabel(params.runId))) { + segments.push({ + label: formatRunLabel(params.runId), + to: runPath(params.runId, params.projectId), + }); + } + segments.push({ + label: params.suite ?? 'Suite', + to: suitePath(params.runId, params.suite ?? 'Suite', params.projectId), + }); + } else if (routeId.includes('/projects/$projectId_/runs/$runId')) { + segments.push({ + label: formatRunLabel(params.runId), + to: runPath(params.runId, params.projectId), + }); + } else if (routeId.includes('/projects/$projectId_/evals/$runId/$evalId')) { + if (!segments.some((s) => s.label === formatRunLabel(params.runId))) { + segments.push({ + label: formatRunLabel(params.runId), + to: runPath(params.runId, params.projectId), + }); + } + segments.push({ + label: params.evalId ?? 'Eval', + to: evalPath(params.runId, params.evalId ?? 'Eval', params.projectId), + }); + } else if (routeId.includes('/projects/$projectId_/experiments/$experimentName')) { + segments.push({ + label: params.experimentName ?? 'Experiment', + to: experimentPath(params.experimentName ?? 'Experiment', params.projectId), + }); + } else if (routeId.includes('/runs/$runId/category/$category')) { + if (!segments.some((s) => s.label === formatRunLabel(params.runId))) { + segments.push({ + label: formatRunLabel(params.runId), + to: runPath(params.runId), + }); + } + segments.push({ + label: params.category ?? 'Category', + to: categoryPath(params.runId, params.category ?? 'Category'), }); } else if (routeId.includes('/runs/$runId/suite/$suite')) { + if (!segments.some((s) => s.label === formatRunLabel(params.runId))) { + segments.push({ + label: formatRunLabel(params.runId), + to: runPath(params.runId), + }); + } segments.push({ label: params.suite ?? 'Suite', - to: match.pathname, + to: suitePath(params.runId, params.suite ?? 'Suite'), + }); + } else if (routeId.includes('/jobs/$runId')) { + segments.push({ + label: formatRunLabel(params.runId), + to: jobPath(params.runId), }); } else if (routeId.includes('/runs/$runId')) { segments.push({ label: formatRunLabel(params.runId), - to: match.pathname, + to: runPath(params.runId), }); } else if (routeId.includes('/evals/$runId/$evalId')) { // For eval pages, show the run as a parent segment too - if (!segments.some((s) => s.label === params.runId)) { + if (!segments.some((s) => s.label === formatRunLabel(params.runId))) { segments.push({ label: formatRunLabel(params.runId), - to: `/runs/${encodeURIComponent(params.runId)}`, + to: runPath(params.runId), }); } segments.push({ label: params.evalId ?? 'Eval', - to: match.pathname, + to: evalPath(params.runId, params.evalId ?? 'Eval'), }); } else if (routeId.includes('/experiments/$experimentName')) { segments.push({ label: params.experimentName ?? 'Experiment', - to: match.pathname, + to: experimentPath(params.experimentName ?? 'Experiment'), }); } else if (routeId === '/index' || routeId === '/') { segments.push({ label: 'Home', to: '/' }); diff --git a/apps/studio/src/components/EvalDetail.tsx b/apps/studio/src/components/EvalDetail.tsx index 1691dcc4..283a665a 100644 --- a/apps/studio/src/components/EvalDetail.tsx +++ b/apps/studio/src/components/EvalDetail.tsx @@ -48,7 +48,7 @@ function findFirstFile(nodes: FileNode[]): string | null { export function EvalDetail({ eval: result, runId, projectId }: EvalDetailProps) { const [activeTab, setActiveTab] = useState('checks'); - const { data: config } = useStudioConfig(); + const { data: config } = useStudioConfig(projectId); const isReadOnly = config?.read_only === true; const tabs: { id: Tab; label: string }[] = [ @@ -83,7 +83,7 @@ export function EvalDetail({ eval: result, runId, projectId }: EvalDetailProps)
{activeTab === 'checks' && (
- +
)} {activeTab === 'files' && ( @@ -133,8 +133,8 @@ function AssertionCard({ assertion }: { assertion: AssertionEntry }) { * Checks tab: overall score → per-grader scores → assertions → failure reasons. * Assertions are grouped by evaluator when per-score assertion data is available. */ -function ChecksTab({ result }: { result: EvalResult }) { - const { data: config } = useStudioConfig(); +function ChecksTab({ result, projectId }: { result: EvalResult; projectId?: string }) { + const { data: config } = useStudioConfig(projectId); const passThreshold = config?.threshold ?? config?.pass_threshold ?? 0.8; const hasFailed = diff --git a/apps/studio/src/components/ResumeRunActions.tsx b/apps/studio/src/components/ResumeRunActions.tsx index 8529d240..c7f6f521 100644 --- a/apps/studio/src/components/ResumeRunActions.tsx +++ b/apps/studio/src/components/ResumeRunActions.tsx @@ -71,7 +71,14 @@ export function ResumeRunActions({ try { const body = buildResumeRequestBody({ mode, runDir, suiteFilter, target }); const response = await launchEvalRun(body, projectId); - navigate({ to: '/jobs/$runId', params: { runId: response.id } }); + if (projectId) { + navigate({ + to: '/projects/$projectId/jobs/$runId', + params: { projectId, runId: response.id }, + }); + } else { + navigate({ to: '/jobs/$runId', params: { runId: response.id } }); + } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to launch resume'); setBusy(null); diff --git a/apps/studio/src/components/RunDetail.tsx b/apps/studio/src/components/RunDetail.tsx index d4ab52d7..ed353999 100644 --- a/apps/studio/src/components/RunDetail.tsx +++ b/apps/studio/src/components/RunDetail.tsx @@ -93,7 +93,7 @@ function buildCategoryGroups(results: EvalResult[], passThreshold: number): Cate } export function RunDetail({ results, runId, projectId }: RunDetailProps) { - const { data: config } = useStudioConfig(); + const { data: config } = useStudioConfig(projectId); const passThreshold = config?.threshold ?? config?.pass_threshold ?? 0.8; const total = results.length; @@ -143,7 +143,25 @@ export function RunDetail({ results, runId, projectId }: RunDetailProps) { {categories.map((cat) => ( - {cat.name} + + {projectId ? ( + + {cat.name} + + ) : ( + + {cat.name} + + )} + 0 ? cat.passed / cat.total : 0} /> diff --git a/apps/studio/src/components/RunEvalModal.tsx b/apps/studio/src/components/RunEvalModal.tsx index 9350baa7..00682c26 100644 --- a/apps/studio/src/components/RunEvalModal.tsx +++ b/apps/studio/src/components/RunEvalModal.tsx @@ -23,6 +23,7 @@ import { useEvalTargets, useStudioConfig, } from '~/lib/api'; +import { runsHomePath } from '~/lib/navigation'; import type { RunEvalRequest } from '~/lib/types'; import { buildRunEvalRequest, @@ -69,7 +70,7 @@ export function RunEvalModal({ open, onClose, projectId, prefill }: RunEvalModal // Data const { data: discoverData } = useEvalDiscover(projectId); const { data: targetsData } = useEvalTargets(projectId); - const { data: runStatus } = useEvalRunStatus(activeRunId); + const { data: runStatus } = useEvalRunStatus(activeRunId, projectId); const { data: studioConfig } = useStudioConfig(projectId); const evalFiles = useMemo(() => discoverData?.eval_files ?? [], [discoverData]); @@ -170,7 +171,7 @@ export function RunEvalModal({ open, onClose, projectId, prefill }: RunEvalModal if (activeRunId && runStatus) { function handleRunInBackground() { onClose(); - navigate({ to: '/', search: { tab: 'runs' } as Record }); + navigate({ to: runsHomePath(projectId) }); } return ( diff --git a/apps/studio/src/components/RunList.tsx b/apps/studio/src/components/RunList.tsx index ee52e19c..974d169a 100644 --- a/apps/studio/src/components/RunList.tsx +++ b/apps/studio/src/components/RunList.tsx @@ -49,7 +49,7 @@ function formatDate(ts: string | undefined | null): { date: string; full: string } export function RunList({ runs, projectId, emptyMessage }: RunListProps) { - const { data: config } = useStudioConfig(); + const { data: config } = useStudioConfig(projectId); const passThreshold = config?.threshold ?? DEFAULT_PASS_THRESHOLD; if (runs.length === 0) { diff --git a/apps/studio/src/components/Sidebar.tsx b/apps/studio/src/components/Sidebar.tsx index 42f6984a..d0c9ef9f 100644 --- a/apps/studio/src/components/Sidebar.tsx +++ b/apps/studio/src/components/Sidebar.tsx @@ -19,6 +19,7 @@ import { Link, useLocation, useMatchRoute } from '@tanstack/react-router'; import { isPassing, + projectCategorySuitesOptions, projectExperimentsOptions, useAllProjectRuns, useCategorySuites, @@ -86,6 +87,18 @@ export function Sidebar() { to: '/projects/$projectId/experiments/$experimentName', fuzzy: true, }); + const projectCategoryMatch = matchRoute({ + to: '/projects/$projectId/runs/$runId/category/$category', + fuzzy: true, + }); + const projectSuiteMatch = matchRoute({ + to: '/projects/$projectId/runs/$runId/suite/$suite', + fuzzy: true, + }); + const projectJobMatch = matchRoute({ + to: '/projects/$projectId/jobs/$runId', + fuzzy: true, + }); const projectMatch = matchRoute({ to: '/projects/$projectId', fuzzy: true, @@ -107,6 +120,37 @@ export function Sidebar() { return ; } + if (projectJobMatch && typeof projectJobMatch === 'object' && 'projectId' in projectJobMatch) { + const { projectId } = projectJobMatch as { projectId: string }; + return ; + } + + if ( + projectCategoryMatch && + typeof projectCategoryMatch === 'object' && + 'projectId' in projectCategoryMatch + ) { + const { projectId, runId, category } = projectCategoryMatch as { + projectId: string; + runId: string; + category: string; + }; + return ; + } + + if ( + projectSuiteMatch && + typeof projectSuiteMatch === 'object' && + 'projectId' in projectSuiteMatch + ) { + const { projectId, runId, suite } = projectSuiteMatch as { + projectId: string; + runId: string; + suite: string; + }; + return ; + } + if ( projectExperimentMatch && typeof projectExperimentMatch === 'object' && @@ -484,7 +528,7 @@ function ProjectEvalSidebar({ currentEvalId: string; }) { const { data } = useProjectRunDetail(projectId, runId); - const { data: config } = useStudioConfig(); + const { data: config } = useStudioConfig(projectId); const passThreshold = config?.threshold ?? config?.pass_threshold ?? 0.8; return ( @@ -536,6 +580,122 @@ function ProjectEvalSidebar({ ); } +function ProjectSuiteSidebar({ + projectId, + runId, + suite, +}: { + projectId: string; + runId: string; + suite: string; +}) { + const { data } = useProjectRunDetail(projectId, runId); + const { data: config } = useStudioConfig(projectId); + const passThreshold = config?.threshold ?? config?.pass_threshold ?? 0.8; + const suiteResults = (data?.results ?? []).filter((r) => (r.suite ?? 'Uncategorized') === suite); + + return ( + +
+ + AgentV Studio + +
+ +
+ + ← Back to run + +

{runId}

+

{suite}

+
+ + +
+ ); +} + +function ProjectCategorySidebar({ + projectId, + runId, + category, +}: { + projectId: string; + runId: string; + category: string; +}) { + const { data } = useQuery(projectCategorySuitesOptions(projectId, runId, category)); + const suites = data?.suites ?? []; + + return ( + +
+ + AgentV Studio + +
+ +
+ + ← Back to run + +

{runId}

+

{category}

+
+ + +
+ ); +} + function ProjectExperimentSidebar({ projectId, currentExperiment, diff --git a/apps/studio/src/lib/api.ts b/apps/studio/src/lib/api.ts index 3eafc241..642e28b2 100644 --- a/apps/studio/src/lib/api.ts +++ b/apps/studio/src/lib/api.ts @@ -582,10 +582,13 @@ export async function stopEvalRun( return res.json() as Promise<{ stopped: boolean; reason?: string; status?: string }>; } -export function evalRunStatusOptions(runId: string | null) { +export function evalRunStatusOptions(runId: string | null, projectId?: string) { + const url = projectId + ? `${projectApiBase(projectId)}/eval/status/${runId}` + : `/api/eval/status/${runId}`; return queryOptions({ - queryKey: ['eval-status', runId], - queryFn: () => fetchJson(`/api/eval/status/${runId}`), + queryKey: ['eval-status', projectId ?? '', runId], + queryFn: () => fetchJson(url), enabled: !!runId, refetchInterval: (query) => { const status = query.state.data?.status; @@ -595,8 +598,8 @@ export function evalRunStatusOptions(runId: string | null) { }); } -export function useEvalRunStatus(runId: string | null) { - return useQuery(evalRunStatusOptions(runId)); +export function useEvalRunStatus(runId: string | null, projectId?: string) { + return useQuery(evalRunStatusOptions(runId, projectId)); } export function evalRunsOptions(projectId?: string) { diff --git a/apps/studio/src/lib/navigation.ts b/apps/studio/src/lib/navigation.ts new file mode 100644 index 00000000..9c224388 --- /dev/null +++ b/apps/studio/src/lib/navigation.ts @@ -0,0 +1,81 @@ +/** + * Pure Studio route helpers. + * + * These keep project-aware path generation in one place so redirects, + * breadcrumbs, and regression tests all agree on the canonical URLs. + */ + +export type StudioTabId = 'runs' | 'experiments' | 'analytics' | 'targets'; + +export interface IndexRouteDecision { + kind: 'dashboard' | 'single-project-home' | 'redirect'; + redirectPath?: string; +} + +export function projectHomePath(projectId: string, tab?: StudioTabId): string { + const base = `/projects/${encodeURIComponent(projectId)}`; + return tab ? `${base}?tab=${encodeURIComponent(tab)}` : base; +} + +export function runPath(runId: string, projectId?: string): string { + return projectId + ? `/projects/${encodeURIComponent(projectId)}/runs/${encodeURIComponent(runId)}` + : `/runs/${encodeURIComponent(runId)}`; +} + +export function evalPath(runId: string, evalId: string, projectId?: string): string { + return projectId + ? `/projects/${encodeURIComponent(projectId)}/evals/${encodeURIComponent(runId)}/${encodeURIComponent(evalId)}` + : `/evals/${encodeURIComponent(runId)}/${encodeURIComponent(evalId)}`; +} + +export function experimentPath(experimentName: string, projectId?: string): string { + return projectId + ? `/projects/${encodeURIComponent(projectId)}/experiments/${encodeURIComponent(experimentName)}` + : `/experiments/${encodeURIComponent(experimentName)}`; +} + +export function jobPath(runId: string, projectId?: string): string { + return projectId + ? `/projects/${encodeURIComponent(projectId)}/jobs/${encodeURIComponent(runId)}` + : `/jobs/${encodeURIComponent(runId)}`; +} + +export function categoryPath(runId: string, category: string, projectId?: string): string { + return projectId + ? `/projects/${encodeURIComponent(projectId)}/runs/${encodeURIComponent(runId)}/category/${encodeURIComponent(category)}` + : `/runs/${encodeURIComponent(runId)}/category/${encodeURIComponent(category)}`; +} + +export function suitePath(runId: string, suite: string, projectId?: string): string { + return projectId + ? `/projects/${encodeURIComponent(projectId)}/runs/${encodeURIComponent(runId)}/suite/${encodeURIComponent(suite)}` + : `/runs/${encodeURIComponent(runId)}/suite/${encodeURIComponent(suite)}`; +} + +export function runsHomePath(projectId?: string): string { + return projectId ? projectHomePath(projectId, 'runs') : '/?tab=runs'; +} + +export function experimentsHomePath(projectId?: string): string { + return projectId ? projectHomePath(projectId, 'experiments') : '/?tab=experiments'; +} + +export function resolveIndexRoute( + projectIds: string[], + projectDashboard: boolean | undefined, + tab?: StudioTabId, +): IndexRouteDecision { + if (projectDashboard === false) { + return { kind: 'single-project-home' }; + } + + if (projectIds.length === 1) { + return { + kind: 'redirect', + redirectPath: projectHomePath(projectIds[0], tab), + }; + } + + return { kind: 'dashboard' }; +} diff --git a/apps/studio/src/routeTree.gen.ts b/apps/studio/src/routeTree.gen.ts index 14cd3697..e77db636 100644 --- a/apps/studio/src/routeTree.gen.ts +++ b/apps/studio/src/routeTree.gen.ts @@ -19,8 +19,11 @@ import { Route as EvalsRunIdEvalIdRouteImport } from './routes/evals/$runId.$eva import { Route as RunsRunIdSuiteSuiteRouteImport } from './routes/runs/$runId_.suite.$suite' import { Route as RunsRunIdCategoryCategoryRouteImport } from './routes/runs/$runId_.category.$category' import { Route as ProjectsProjectIdRunsRunIdRouteImport } from './routes/projects/$projectId_/runs/$runId' +import { Route as ProjectsProjectIdJobsRunIdRouteImport } from './routes/projects/$projectId_/jobs/$runId' import { Route as ProjectsProjectIdExperimentsExperimentNameRouteImport } from './routes/projects/$projectId_/experiments/$experimentName' import { Route as ProjectsProjectIdEvalsRunIdEvalIdRouteImport } from './routes/projects/$projectId_/evals/$runId.$evalId' +import { Route as ProjectsProjectIdRunsRunIdSuiteSuiteRouteImport } from './routes/projects/$projectId_/runs/$runId_.suite.$suite' +import { Route as ProjectsProjectIdRunsRunIdCategoryCategoryRouteImport } from './routes/projects/$projectId_/runs/$runId_.category.$category' const SettingsRoute = SettingsRouteImport.update({ id: '/settings', @@ -75,6 +78,12 @@ const ProjectsProjectIdRunsRunIdRoute = path: '/projects/$projectId/runs/$runId', getParentRoute: () => rootRouteImport, } as any) +const ProjectsProjectIdJobsRunIdRoute = + ProjectsProjectIdJobsRunIdRouteImport.update({ + id: '/projects/$projectId_/jobs/$runId', + path: '/projects/$projectId/jobs/$runId', + getParentRoute: () => rootRouteImport, + } as any) const ProjectsProjectIdExperimentsExperimentNameRoute = ProjectsProjectIdExperimentsExperimentNameRouteImport.update({ id: '/projects/$projectId_/experiments/$experimentName', @@ -87,6 +96,18 @@ const ProjectsProjectIdEvalsRunIdEvalIdRoute = path: '/projects/$projectId/evals/$runId/$evalId', getParentRoute: () => rootRouteImport, } as any) +const ProjectsProjectIdRunsRunIdSuiteSuiteRoute = + ProjectsProjectIdRunsRunIdSuiteSuiteRouteImport.update({ + id: '/projects/$projectId_/runs/$runId_/suite/$suite', + path: '/projects/$projectId/runs/$runId/suite/$suite', + getParentRoute: () => rootRouteImport, + } as any) +const ProjectsProjectIdRunsRunIdCategoryCategoryRoute = + ProjectsProjectIdRunsRunIdCategoryCategoryRouteImport.update({ + id: '/projects/$projectId_/runs/$runId_/category/$category', + path: '/projects/$projectId/runs/$runId/category/$category', + getParentRoute: () => rootRouteImport, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -97,10 +118,13 @@ export interface FileRoutesByFullPath { '/runs/$runId': typeof RunsRunIdRoute '/evals/$runId/$evalId': typeof EvalsRunIdEvalIdRoute '/projects/$projectId/experiments/$experimentName': typeof ProjectsProjectIdExperimentsExperimentNameRoute + '/projects/$projectId/jobs/$runId': typeof ProjectsProjectIdJobsRunIdRoute '/projects/$projectId/runs/$runId': typeof ProjectsProjectIdRunsRunIdRoute '/runs/$runId/category/$category': typeof RunsRunIdCategoryCategoryRoute '/runs/$runId/suite/$suite': typeof RunsRunIdSuiteSuiteRoute '/projects/$projectId/evals/$runId/$evalId': typeof ProjectsProjectIdEvalsRunIdEvalIdRoute + '/projects/$projectId/runs/$runId/category/$category': typeof ProjectsProjectIdRunsRunIdCategoryCategoryRoute + '/projects/$projectId/runs/$runId/suite/$suite': typeof ProjectsProjectIdRunsRunIdSuiteSuiteRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -111,10 +135,13 @@ export interface FileRoutesByTo { '/runs/$runId': typeof RunsRunIdRoute '/evals/$runId/$evalId': typeof EvalsRunIdEvalIdRoute '/projects/$projectId/experiments/$experimentName': typeof ProjectsProjectIdExperimentsExperimentNameRoute + '/projects/$projectId/jobs/$runId': typeof ProjectsProjectIdJobsRunIdRoute '/projects/$projectId/runs/$runId': typeof ProjectsProjectIdRunsRunIdRoute '/runs/$runId/category/$category': typeof RunsRunIdCategoryCategoryRoute '/runs/$runId/suite/$suite': typeof RunsRunIdSuiteSuiteRoute '/projects/$projectId/evals/$runId/$evalId': typeof ProjectsProjectIdEvalsRunIdEvalIdRoute + '/projects/$projectId/runs/$runId/category/$category': typeof ProjectsProjectIdRunsRunIdCategoryCategoryRoute + '/projects/$projectId/runs/$runId/suite/$suite': typeof ProjectsProjectIdRunsRunIdSuiteSuiteRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -126,10 +153,13 @@ export interface FileRoutesById { '/runs/$runId': typeof RunsRunIdRoute '/evals/$runId/$evalId': typeof EvalsRunIdEvalIdRoute '/projects/$projectId_/experiments/$experimentName': typeof ProjectsProjectIdExperimentsExperimentNameRoute + '/projects/$projectId_/jobs/$runId': typeof ProjectsProjectIdJobsRunIdRoute '/projects/$projectId_/runs/$runId': typeof ProjectsProjectIdRunsRunIdRoute '/runs/$runId_/category/$category': typeof RunsRunIdCategoryCategoryRoute '/runs/$runId_/suite/$suite': typeof RunsRunIdSuiteSuiteRoute '/projects/$projectId_/evals/$runId/$evalId': typeof ProjectsProjectIdEvalsRunIdEvalIdRoute + '/projects/$projectId_/runs/$runId_/category/$category': typeof ProjectsProjectIdRunsRunIdCategoryCategoryRoute + '/projects/$projectId_/runs/$runId_/suite/$suite': typeof ProjectsProjectIdRunsRunIdSuiteSuiteRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -142,10 +172,13 @@ export interface FileRouteTypes { | '/runs/$runId' | '/evals/$runId/$evalId' | '/projects/$projectId/experiments/$experimentName' + | '/projects/$projectId/jobs/$runId' | '/projects/$projectId/runs/$runId' | '/runs/$runId/category/$category' | '/runs/$runId/suite/$suite' | '/projects/$projectId/evals/$runId/$evalId' + | '/projects/$projectId/runs/$runId/category/$category' + | '/projects/$projectId/runs/$runId/suite/$suite' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -156,10 +189,13 @@ export interface FileRouteTypes { | '/runs/$runId' | '/evals/$runId/$evalId' | '/projects/$projectId/experiments/$experimentName' + | '/projects/$projectId/jobs/$runId' | '/projects/$projectId/runs/$runId' | '/runs/$runId/category/$category' | '/runs/$runId/suite/$suite' | '/projects/$projectId/evals/$runId/$evalId' + | '/projects/$projectId/runs/$runId/category/$category' + | '/projects/$projectId/runs/$runId/suite/$suite' id: | '__root__' | '/' @@ -170,10 +206,13 @@ export interface FileRouteTypes { | '/runs/$runId' | '/evals/$runId/$evalId' | '/projects/$projectId_/experiments/$experimentName' + | '/projects/$projectId_/jobs/$runId' | '/projects/$projectId_/runs/$runId' | '/runs/$runId_/category/$category' | '/runs/$runId_/suite/$suite' | '/projects/$projectId_/evals/$runId/$evalId' + | '/projects/$projectId_/runs/$runId_/category/$category' + | '/projects/$projectId_/runs/$runId_/suite/$suite' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -185,10 +224,13 @@ export interface RootRouteChildren { RunsRunIdRoute: typeof RunsRunIdRoute EvalsRunIdEvalIdRoute: typeof EvalsRunIdEvalIdRoute ProjectsProjectIdExperimentsExperimentNameRoute: typeof ProjectsProjectIdExperimentsExperimentNameRoute + ProjectsProjectIdJobsRunIdRoute: typeof ProjectsProjectIdJobsRunIdRoute ProjectsProjectIdRunsRunIdRoute: typeof ProjectsProjectIdRunsRunIdRoute RunsRunIdCategoryCategoryRoute: typeof RunsRunIdCategoryCategoryRoute RunsRunIdSuiteSuiteRoute: typeof RunsRunIdSuiteSuiteRoute ProjectsProjectIdEvalsRunIdEvalIdRoute: typeof ProjectsProjectIdEvalsRunIdEvalIdRoute + ProjectsProjectIdRunsRunIdCategoryCategoryRoute: typeof ProjectsProjectIdRunsRunIdCategoryCategoryRoute + ProjectsProjectIdRunsRunIdSuiteSuiteRoute: typeof ProjectsProjectIdRunsRunIdSuiteSuiteRoute } declare module '@tanstack/react-router' { @@ -263,6 +305,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectsProjectIdRunsRunIdRouteImport parentRoute: typeof rootRouteImport } + '/projects/$projectId_/jobs/$runId': { + id: '/projects/$projectId_/jobs/$runId' + path: '/projects/$projectId/jobs/$runId' + fullPath: '/projects/$projectId/jobs/$runId' + preLoaderRoute: typeof ProjectsProjectIdJobsRunIdRouteImport + parentRoute: typeof rootRouteImport + } '/projects/$projectId_/experiments/$experimentName': { id: '/projects/$projectId_/experiments/$experimentName' path: '/projects/$projectId/experiments/$experimentName' @@ -277,6 +326,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectsProjectIdEvalsRunIdEvalIdRouteImport parentRoute: typeof rootRouteImport } + '/projects/$projectId_/runs/$runId_/suite/$suite': { + id: '/projects/$projectId_/runs/$runId_/suite/$suite' + path: '/projects/$projectId/runs/$runId/suite/$suite' + fullPath: '/projects/$projectId/runs/$runId/suite/$suite' + preLoaderRoute: typeof ProjectsProjectIdRunsRunIdSuiteSuiteRouteImport + parentRoute: typeof rootRouteImport + } + '/projects/$projectId_/runs/$runId_/category/$category': { + id: '/projects/$projectId_/runs/$runId_/category/$category' + path: '/projects/$projectId/runs/$runId/category/$category' + fullPath: '/projects/$projectId/runs/$runId/category/$category' + preLoaderRoute: typeof ProjectsProjectIdRunsRunIdCategoryCategoryRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -290,11 +353,16 @@ const rootRouteChildren: RootRouteChildren = { EvalsRunIdEvalIdRoute: EvalsRunIdEvalIdRoute, ProjectsProjectIdExperimentsExperimentNameRoute: ProjectsProjectIdExperimentsExperimentNameRoute, + ProjectsProjectIdJobsRunIdRoute: ProjectsProjectIdJobsRunIdRoute, ProjectsProjectIdRunsRunIdRoute: ProjectsProjectIdRunsRunIdRoute, RunsRunIdCategoryCategoryRoute: RunsRunIdCategoryCategoryRoute, RunsRunIdSuiteSuiteRoute: RunsRunIdSuiteSuiteRoute, ProjectsProjectIdEvalsRunIdEvalIdRoute: ProjectsProjectIdEvalsRunIdEvalIdRoute, + ProjectsProjectIdRunsRunIdCategoryCategoryRoute: + ProjectsProjectIdRunsRunIdCategoryCategoryRoute, + ProjectsProjectIdRunsRunIdSuiteSuiteRoute: + ProjectsProjectIdRunsRunIdSuiteSuiteRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/studio/src/routes/index.tsx b/apps/studio/src/routes/index.tsx index 6c23ad82..964df7ee 100644 --- a/apps/studio/src/routes/index.tsx +++ b/apps/studio/src/routes/index.tsx @@ -1,13 +1,13 @@ /** - * Home route: shows the projects dashboard by default when multiple projects - * are registered, or the existing tabbed landing page (Runs, Experiments, - * Analytics, Targets) in single-project mode. + * Home route: thin entry layer that either redirects to the only registered + * project, shows the projects dashboard, or falls back to the legacy + * single-project home when Studio is explicitly running in single mode. * * Uses URL search param `?tab=` for tab persistence. */ import { Link, createFileRoute, useNavigate, useRouterState } from '@tanstack/react-router'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { AnalyticsTab } from '~/components/AnalyticsTab'; @@ -27,8 +27,8 @@ import { useRunList, useStudioConfig, } from '~/lib/api'; - -type TabId = 'runs' | 'experiments' | 'analytics' | 'targets'; +import { type StudioTabId, resolveIndexRoute } from '~/lib/navigation'; +type TabId = StudioTabId; const tabs: { id: TabId; label: string }[] = [ { id: 'runs', label: '🏃 Recent Runs' }, @@ -42,16 +42,34 @@ export const Route = createFileRoute('/')({ }); function HomePage() { + const navigate = useNavigate(); + const routerState = useRouterState(); + const searchParams = routerState.location.search as Record; + const tab = searchParams.tab as TabId | undefined; const { data: projectData, isLoading: projectsLoading } = useProjectList(); const { data: config, isLoading: configLoading } = useStudioConfig(); - const hasProjects = (projectData?.projects.length ?? 0) > 0; - const projectDashboard = config?.project_dashboard; + const projects = projectData?.projects ?? []; + const decision = resolveIndexRoute( + projects.map((project) => project.id), + config?.project_dashboard, + tab, + ); + + useEffect(() => { + if (decision.kind === 'redirect' && decision.redirectPath) { + navigate({ to: decision.redirectPath, replace: true }); + } + }, [decision, navigate]); if (projectsLoading || configLoading) { return ; } - if (projectDashboard === true || (projectDashboard === undefined && hasProjects)) { + if (decision.kind === 'redirect') { + return ; + } + + if (decision.kind === 'dashboard') { return ; } @@ -138,11 +156,20 @@ function ProjectsDashboard() {
)} -
- {projects.map((project) => ( - - ))} -
+ {projects.length === 0 ? ( +
+

No projects registered yet.

+

+ Add a project path to start browsing runs, experiments, analytics, and targets. +

+
+ ) : ( +
+ {projects.map((project) => ( + + ))} +
+ )} {!isReadOnly && setShowRunEval(false)} />} diff --git a/apps/studio/src/routes/projects/$projectId.tsx b/apps/studio/src/routes/projects/$projectId.tsx index 28c242c5..bb54cc72 100644 --- a/apps/studio/src/routes/projects/$projectId.tsx +++ b/apps/studio/src/routes/projects/$projectId.tsx @@ -43,7 +43,7 @@ function ProjectHomePage() { const tab = searchParams.tab as TabId | undefined; const navigate = useNavigate(); const [showRunEval, setShowRunEval] = useState(false); - const { data: config } = useStudioConfig(); + const { data: config } = useStudioConfig(projectId); const isReadOnly = config?.read_only === true; const activeTab: TabId = tabs.some((t) => t.id === tab) ? (tab as TabId) : 'experiments'; @@ -177,8 +177,8 @@ function ProjectRunsTab({ projectId }: { projectId: string }) { View Log → diff --git a/apps/studio/src/routes/projects/$projectId_/evals/$runId.$evalId.tsx b/apps/studio/src/routes/projects/$projectId_/evals/$runId.$evalId.tsx index b88a9052..5d62014a 100644 --- a/apps/studio/src/routes/projects/$projectId_/evals/$runId.$evalId.tsx +++ b/apps/studio/src/routes/projects/$projectId_/evals/$runId.$evalId.tsx @@ -16,7 +16,7 @@ export const Route = createFileRoute('/projects/$projectId_/evals/$runId/$evalId function ProjectEvalDetailPage() { const { projectId, runId, evalId } = Route.useParams(); const { data, isLoading, error } = useProjectRunDetail(projectId, runId); - const { data: config } = useStudioConfig(); + const { data: config } = useStudioConfig(projectId); const [showRunEval, setShowRunEval] = useState(false); const isReadOnly = config?.read_only === true; diff --git a/apps/studio/src/routes/projects/$projectId_/jobs/$runId.tsx b/apps/studio/src/routes/projects/$projectId_/jobs/$runId.tsx new file mode 100644 index 00000000..8c509502 --- /dev/null +++ b/apps/studio/src/routes/projects/$projectId_/jobs/$runId.tsx @@ -0,0 +1,147 @@ +/** + * Project-scoped job detail route for Studio-launched eval runs. + */ + +import { Link, createFileRoute } from '@tanstack/react-router'; + +import { StopRunButton } from '~/components/StopRunButton'; +import { useEvalRunStatus, useStudioConfig } from '~/lib/api'; + +export const Route = createFileRoute('/projects/$projectId_/jobs/$runId')({ + component: ProjectJobDetailPage, +}); + +function ProjectJobDetailPage() { + const { projectId, runId } = Route.useParams(); + const { data: status, isLoading, error } = useEvalRunStatus(runId, projectId); + const { data: config } = useStudioConfig(projectId); + const isReadOnly = config?.read_only === true; + + if (isLoading) { + return ( +
+ +
+
+
+ ); + } + + if (error || !status) { + return ( +
+ +
+ {error ? `Failed to load run: ${error.message}` : 'Run not found.'} +
+
+ ); + } + + const isTerminal = status.status === 'finished' || status.status === 'failed'; + + const statusColors: Record = { + starting: 'text-yellow-400', + running: 'text-cyan-400', + finished: 'text-emerald-400', + failed: 'text-red-400', + }; + + const statusColor = statusColors[status.status] ?? 'text-gray-400'; + + return ( +
+ + +
+
+

{runId}

+

+ Started {new Date(status.started_at).toLocaleString()} + {status.finished_at && ( + <> + {' · '}Finished {new Date(status.finished_at).toLocaleString()} + {' · '} + {Math.round( + (new Date(status.finished_at).getTime() - new Date(status.started_at).getTime()) / + 1000, + )} + s + + )} +

+
+
+ + + {status.status.charAt(0).toUpperCase() + status.status.slice(1)} + + {!isTerminal && ( + + )} +
+
+ +
+

Command

+ {status.command} +
+ + {status.stdout ? ( +
+

Output

+
+
+              {status.stdout}
+            
+
+
+ ) : ( + !isTerminal && ( +
+ + Waiting for output… +
+ ) + )} + + {status.stderr && ( +
+

Stderr

+
+
+              {status.stderr}
+            
+
+
+ )} + + {isTerminal && ( +

+ Exit code:{' '} + + {status.exit_code} + +

+ )} +
+ ); +} + +function BackLink({ projectId }: { projectId: string }) { + return ( + } + className="text-xs text-gray-400 hover:text-cyan-400" + > + ← Back to Runs + + ); +} diff --git a/apps/studio/src/routes/projects/$projectId_/runs/$runId.tsx b/apps/studio/src/routes/projects/$projectId_/runs/$runId.tsx index 505c57e7..84e5c989 100644 --- a/apps/studio/src/routes/projects/$projectId_/runs/$runId.tsx +++ b/apps/studio/src/routes/projects/$projectId_/runs/$runId.tsx @@ -17,7 +17,7 @@ export const Route = createFileRoute('/projects/$projectId_/runs/$runId')({ function ProjectRunDetailPage() { const { projectId, runId } = Route.useParams(); const { data, isLoading, error } = useProjectRunDetail(projectId, runId); - const { data: config } = useStudioConfig(); + const { data: config } = useStudioConfig(projectId); const [showRunEval, setShowRunEval] = useState(false); const isReadOnly = config?.read_only === true; diff --git a/apps/studio/src/routes/projects/$projectId_/runs/$runId_.category.$category.tsx b/apps/studio/src/routes/projects/$projectId_/runs/$runId_.category.$category.tsx new file mode 100644 index 00000000..443526cc --- /dev/null +++ b/apps/studio/src/routes/projects/$projectId_/runs/$runId_.category.$category.tsx @@ -0,0 +1,93 @@ +/** + * Project-scoped category drill-down route. + */ + +import { useQuery } from '@tanstack/react-query'; +import { Link, createFileRoute } from '@tanstack/react-router'; + +import { ScoreBar } from '~/components/ScoreBar'; +import { StatsCards } from '~/components/StatsCards'; +import { projectCategorySuitesOptions } from '~/lib/api'; + +export const Route = createFileRoute('/projects/$projectId_/runs/$runId_/category/$category')({ + component: ProjectCategoryPage, +}); + +function ProjectCategoryPage() { + const { projectId, runId, category } = Route.useParams(); + const { data, isLoading, error } = useQuery( + projectCategorySuitesOptions(projectId, runId, category), + ); + + if (isLoading) { + return ( +
+
+
+ {['s1', 's2', 's3', 's4', 's5'].map((id) => ( +
+ ))} +
+
+ ); + } + + if (error) { + return ( +
+ Failed to load category: {error.message} +
+ ); + } + + const suites = data?.suites ?? []; + const total = suites.reduce((sum, suite) => sum + suite.total, 0); + const passed = suites.reduce((sum, suite) => sum + suite.passed, 0); + const failed = total - passed; + const passRate = total > 0 ? passed / total : 0; + + return ( +
+
+

{category}

+

Category in run: {runId}

+
+ + + + {suites.length === 0 ? ( +
+

No suites in this category

+
+ ) : ( +
+

Suites

+
+ {suites.map((suite) => ( + +
+ {suite.name} + + {suite.passed}/{suite.total} + +
+
+ +
+
+ {suite.passed} passed + {suite.failed > 0 && {suite.failed} failed} +
+ + ))} +
+
+ )} +
+ ); +} diff --git a/apps/studio/src/routes/projects/$projectId_/runs/$runId_.suite.$suite.tsx b/apps/studio/src/routes/projects/$projectId_/runs/$runId_.suite.$suite.tsx new file mode 100644 index 00000000..ecebb724 --- /dev/null +++ b/apps/studio/src/routes/projects/$projectId_/runs/$runId_.suite.$suite.tsx @@ -0,0 +1,121 @@ +/** + * Project-scoped suite drill-down route. + */ + +import { Link, createFileRoute } from '@tanstack/react-router'; + +import { PassRatePill } from '~/components/PassRatePill'; +import { StatsCards } from '~/components/StatsCards'; +import { isPassing, useProjectRunDetail, useStudioConfig } from '~/lib/api'; + +export const Route = createFileRoute('/projects/$projectId_/runs/$runId_/suite/$suite')({ + component: ProjectSuitePage, +}); + +function ProjectSuitePage() { + const { projectId, runId, suite } = Route.useParams(); + const { data, isLoading, error } = useProjectRunDetail(projectId, runId); + const { data: config } = useStudioConfig(projectId); + const passThreshold = config?.threshold ?? config?.pass_threshold ?? 0.8; + + if (isLoading) { + return ( +
+
+
+ {['s1', 's2', 's3', 's4', 's5'].map((id) => ( +
+ ))} +
+
+ ); + } + + if (error) { + return ( +
+ Failed to load run: {error.message} +
+ ); + } + + const results = (data?.results ?? []).filter( + (result) => (result.suite ?? 'Uncategorized') === suite, + ); + const total = results.length; + const passed = results.filter((result) => isPassing(result.score, passThreshold)).length; + const failed = total - passed; + const passRate = total > 0 ? passed / total : 0; + const totalCost = results.reduce((sum, result) => sum + (result.costUsd ?? 0), 0); + + return ( +
+
+

{suite}

+

Suite in run: {runId}

+
+ + 0 ? totalCost : undefined} + /> + + {total === 0 ? ( +
+

No evaluations in this suite

+
+ ) : ( +
+ + + + + + + + + + + + {results.map((result, idx) => ( + + + + + + + + ))} + +
Test IDTargetScoreDurationCost
+ + {result.testId} + + {result.target ?? '-'} + {result.executionStatus === 'execution_error' ? ( + + ERR + + ) : ( + + )} + + {result.durationMs != null ? `${(result.durationMs / 1000).toFixed(1)}s` : '-'} + + {result.costUsd != null ? `$${result.costUsd.toFixed(4)}` : '-'} +
+
+ )} +
+ ); +}