Skip to content

Commit 6b79edc

Browse files
christsoCopilot
andauthored
fix(studio): scope experiment detail pages to project (#1251)
Keep experiment detail navigation and data loading on the selected project's API surface in multi-project mode, and reuse the same shared detail view in single-project mode. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9666712 commit 6b79edc

7 files changed

Lines changed: 276 additions & 101 deletions

File tree

apps/studio/src/components/Breadcrumbs.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,18 @@ function deriveSegments(matches: ReturnType<typeof useMatches>): BreadcrumbSegme
3131

3232
if (routeId === '/' || routeId === '/_layout') continue;
3333

34+
if (routeId.includes('/projects/$projectId') && params.projectId) {
35+
if (!segments.some((s) => s.label === params.projectId)) {
36+
segments.push({
37+
label: params.projectId,
38+
to: `/projects/${encodeURIComponent(params.projectId)}`,
39+
});
40+
}
41+
if (routeId === '/projects/$projectId') {
42+
continue;
43+
}
44+
}
45+
3446
if (routeId.includes('/runs/$runId/category/$category')) {
3547
if (!segments.some((s) => s.label === params.runId)) {
3648
segments.push({
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Shared experiment detail view for both single-project and project-scoped routes.
3+
*
4+
* Reads experiment summary and run list from the matching API surface so the UI
5+
* stays on the same data source in both single and multi-project modes.
6+
*/
7+
8+
import { useQuery } from '@tanstack/react-query';
9+
10+
import {
11+
experimentsOptions,
12+
projectExperimentsOptions,
13+
projectRunListOptions,
14+
runListOptions,
15+
} from '~/lib/api';
16+
17+
import { RunList } from './RunList';
18+
19+
interface ExperimentDetailProps {
20+
experimentName: string;
21+
projectId?: string;
22+
}
23+
24+
export function ExperimentDetail({ experimentName, projectId }: ExperimentDetailProps) {
25+
const { data: experimentsData, isLoading: expLoading } = useQuery(
26+
projectId ? projectExperimentsOptions(projectId) : experimentsOptions,
27+
);
28+
const { data: runListData, isLoading: runsLoading } = useQuery(
29+
projectId ? projectRunListOptions(projectId) : runListOptions,
30+
);
31+
32+
const isLoading = expLoading || runsLoading;
33+
34+
if (isLoading) {
35+
return (
36+
<div className="space-y-4">
37+
<div className="h-8 w-64 animate-pulse rounded bg-gray-800" />
38+
<div className="grid grid-cols-4 gap-4">
39+
{['s1', 's2', 's3', 's4'].map((id) => (
40+
<div key={id} className="h-20 animate-pulse rounded-lg bg-gray-900" />
41+
))}
42+
</div>
43+
</div>
44+
);
45+
}
46+
47+
const experiment = experimentsData?.experiments?.find((entry) => entry.name === experimentName);
48+
const runs = (runListData?.runs ?? []).filter(
49+
(run) => (run.experiment ?? 'default') === experimentName,
50+
);
51+
52+
const passRate = experiment?.pass_rate ?? 0;
53+
const runCount = experiment?.run_count ?? runs.length;
54+
const targetCount = experiment?.target_count ?? 0;
55+
56+
return (
57+
<div className="space-y-6">
58+
<div>
59+
<h1 className="text-2xl font-semibold text-white">{experimentName}</h1>
60+
<p className="mt-1 text-sm text-gray-400">
61+
{runCount} run{runCount !== 1 ? 's' : ''} &middot; {targetCount} target
62+
{targetCount !== 1 ? 's' : ''}
63+
{experiment?.last_run && (
64+
<span className="ml-2">&middot; Last run: {formatTimestamp(experiment.last_run)}</span>
65+
)}
66+
</p>
67+
</div>
68+
69+
{experiment && (
70+
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
71+
<StatCard label="Runs" value={String(runCount)} />
72+
<StatCard label="Targets" value={String(targetCount)} />
73+
<StatCard
74+
label="Pass Rate"
75+
value={`${Math.round(passRate * 100)}%`}
76+
accent="text-cyan-400"
77+
/>
78+
<StatCard label="Last Run" value={formatTimestamp(experiment.last_run)} />
79+
</div>
80+
)}
81+
82+
<div>
83+
<h2 className="mb-4 text-lg font-medium text-gray-200">All Runs</h2>
84+
<RunList
85+
runs={runs}
86+
projectId={projectId}
87+
emptyMessage={
88+
<div>
89+
<p className="text-lg text-gray-400">No evaluation runs found for this experiment.</p>
90+
<p className="mt-2 text-sm text-gray-500">
91+
Runs will appear here once this experiment has execution results.
92+
</p>
93+
</div>
94+
}
95+
/>
96+
</div>
97+
</div>
98+
);
99+
}
100+
101+
function StatCard({
102+
label,
103+
value,
104+
accent,
105+
}: {
106+
label: string;
107+
value: string;
108+
accent?: string;
109+
}) {
110+
return (
111+
<div className="rounded-lg border border-gray-800 bg-gray-900 p-4">
112+
<p className="text-sm text-gray-400">{label}</p>
113+
<p className={`mt-1 text-2xl font-semibold tabular-nums ${accent ?? 'text-white'}`}>
114+
{value}
115+
</p>
116+
</div>
117+
);
118+
}
119+
120+
function formatTimestamp(ts: string | undefined | null): string {
121+
if (!ts) return 'N/A';
122+
try {
123+
const d = new Date(ts);
124+
if (Number.isNaN(d.getTime())) return 'N/A';
125+
return d.toLocaleString();
126+
} catch {
127+
return 'N/A';
128+
}
129+
}

apps/studio/src/components/ExperimentsTab.tsx

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,23 @@ export function ExperimentsTab({ projectId }: ExperimentsTabProps) {
5656
{experiments.map((exp: ExperimentSummary) => (
5757
<tr key={exp.name} className="transition-colors hover:bg-gray-900/30">
5858
<td className="px-4 py-3">
59-
<Link
60-
to="/experiments/$experimentName"
61-
params={{ experimentName: exp.name }}
62-
className="font-medium text-cyan-400 hover:text-cyan-300 hover:underline"
63-
>
64-
{exp.name}
65-
</Link>
59+
{projectId ? (
60+
<Link
61+
to="/projects/$projectId/experiments/$experimentName"
62+
params={{ projectId, experimentName: exp.name }}
63+
className="font-medium text-cyan-400 hover:text-cyan-300 hover:underline"
64+
>
65+
{exp.name}
66+
</Link>
67+
) : (
68+
<Link
69+
to="/experiments/$experimentName"
70+
params={{ experimentName: exp.name }}
71+
className="font-medium text-cyan-400 hover:text-cyan-300 hover:underline"
72+
>
73+
{exp.name}
74+
</Link>
75+
)}
6676
</td>
6777
<td className="px-4 py-3 text-right tabular-nums text-gray-400">{exp.run_count}</td>
6878
<td className="px-4 py-3 text-right tabular-nums text-gray-400">

apps/studio/src/components/Sidebar.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414

1515
import { type ReactNode, useEffect } from 'react';
1616

17+
import { useQuery } from '@tanstack/react-query';
1718
import { Link, useLocation, useMatchRoute } from '@tanstack/react-router';
1819

1920
import {
2021
isPassing,
22+
projectExperimentsOptions,
2123
useAllProjectRuns,
2224
useCategorySuites,
2325
useEvalRuns,
@@ -80,6 +82,10 @@ export function Sidebar() {
8082
to: '/projects/$projectId/runs/$runId',
8183
fuzzy: true,
8284
});
85+
const projectExperimentMatch = matchRoute({
86+
to: '/projects/$projectId/experiments/$experimentName',
87+
fuzzy: true,
88+
});
8389
const projectMatch = matchRoute({
8490
to: '/projects/$projectId',
8591
fuzzy: true,
@@ -101,6 +107,18 @@ export function Sidebar() {
101107
return <ProjectRunDetailSidebar projectId={projectId} currentRunId={runId} />;
102108
}
103109

110+
if (
111+
projectExperimentMatch &&
112+
typeof projectExperimentMatch === 'object' &&
113+
'projectId' in projectExperimentMatch
114+
) {
115+
const { projectId, experimentName } = projectExperimentMatch as {
116+
projectId: string;
117+
experimentName: string;
118+
};
119+
return <ProjectExperimentSidebar projectId={projectId} currentExperiment={experimentName} />;
120+
}
121+
104122
// Project home (runs/experiments/targets)
105123
if (projectMatch && typeof projectMatch === 'object' && 'projectId' in projectMatch) {
106124
const { projectId } = projectMatch as { projectId: string };
@@ -518,6 +536,64 @@ function ProjectEvalSidebar({
518536
);
519537
}
520538

539+
function ProjectExperimentSidebar({
540+
projectId,
541+
currentExperiment,
542+
}: {
543+
projectId: string;
544+
currentExperiment: string;
545+
}) {
546+
const { data } = useQuery(projectExperimentsOptions(projectId));
547+
const experiments = data?.experiments ?? [];
548+
549+
return (
550+
<SidebarShell>
551+
<div className="flex items-center gap-2 border-b border-gray-800 px-4 py-4">
552+
<Link to="/" className="text-lg font-semibold text-white hover:text-cyan-400">
553+
AgentV Studio
554+
</Link>
555+
</div>
556+
557+
<div className="border-b border-gray-800 px-4 py-2">
558+
<Link
559+
to="/projects/$projectId"
560+
params={{ projectId }}
561+
search={{ tab: 'experiments' } as Record<string, string>}
562+
className="text-xs text-gray-400 hover:text-cyan-400"
563+
>
564+
&larr; All experiments
565+
</Link>
566+
<p className="mt-1 truncate text-sm font-medium text-gray-300">{projectId}</p>
567+
</div>
568+
569+
<nav className="flex-1 overflow-y-auto px-2 py-3">
570+
<div className="mb-2 px-2 text-xs font-medium uppercase tracking-wider text-gray-500">
571+
Experiments
572+
</div>
573+
574+
{experiments.map((exp) => {
575+
const isActive = exp.name === currentExperiment;
576+
577+
return (
578+
<Link
579+
key={exp.name}
580+
to="/projects/$projectId/experiments/$experimentName"
581+
params={{ projectId, experimentName: exp.name }}
582+
className={`mb-0.5 block truncate rounded-md px-2 py-1.5 text-sm transition-colors ${
583+
isActive
584+
? 'bg-gray-800 text-cyan-400'
585+
: 'text-gray-400 hover:bg-gray-800/50 hover:text-gray-200'
586+
}`}
587+
>
588+
{exp.name}
589+
</Link>
590+
);
591+
})}
592+
</nav>
593+
</SidebarShell>
594+
);
595+
}
596+
521597
function ExperimentSidebar({ currentExperiment }: { currentExperiment: string }) {
522598
const { data } = useExperiments();
523599
const experiments = data?.experiments ?? [];

apps/studio/src/routeTree.gen.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { Route as EvalsRunIdEvalIdRouteImport } from './routes/evals/$runId.$eva
1919
import { Route as RunsRunIdSuiteSuiteRouteImport } from './routes/runs/$runId_.suite.$suite'
2020
import { Route as RunsRunIdCategoryCategoryRouteImport } from './routes/runs/$runId_.category.$category'
2121
import { Route as ProjectsProjectIdRunsRunIdRouteImport } from './routes/projects/$projectId_/runs/$runId'
22+
import { Route as ProjectsProjectIdExperimentsExperimentNameRouteImport } from './routes/projects/$projectId_/experiments/$experimentName'
2223
import { Route as ProjectsProjectIdEvalsRunIdEvalIdRouteImport } from './routes/projects/$projectId_/evals/$runId.$evalId'
2324

2425
const SettingsRoute = SettingsRouteImport.update({
@@ -74,6 +75,12 @@ const ProjectsProjectIdRunsRunIdRoute =
7475
path: '/projects/$projectId/runs/$runId',
7576
getParentRoute: () => rootRouteImport,
7677
} as any)
78+
const ProjectsProjectIdExperimentsExperimentNameRoute =
79+
ProjectsProjectIdExperimentsExperimentNameRouteImport.update({
80+
id: '/projects/$projectId_/experiments/$experimentName',
81+
path: '/projects/$projectId/experiments/$experimentName',
82+
getParentRoute: () => rootRouteImport,
83+
} as any)
7784
const ProjectsProjectIdEvalsRunIdEvalIdRoute =
7885
ProjectsProjectIdEvalsRunIdEvalIdRouteImport.update({
7986
id: '/projects/$projectId_/evals/$runId/$evalId',
@@ -89,6 +96,7 @@ export interface FileRoutesByFullPath {
8996
'/projects/$projectId': typeof ProjectsProjectIdRoute
9097
'/runs/$runId': typeof RunsRunIdRoute
9198
'/evals/$runId/$evalId': typeof EvalsRunIdEvalIdRoute
99+
'/projects/$projectId/experiments/$experimentName': typeof ProjectsProjectIdExperimentsExperimentNameRoute
92100
'/projects/$projectId/runs/$runId': typeof ProjectsProjectIdRunsRunIdRoute
93101
'/runs/$runId/category/$category': typeof RunsRunIdCategoryCategoryRoute
94102
'/runs/$runId/suite/$suite': typeof RunsRunIdSuiteSuiteRoute
@@ -102,6 +110,7 @@ export interface FileRoutesByTo {
102110
'/projects/$projectId': typeof ProjectsProjectIdRoute
103111
'/runs/$runId': typeof RunsRunIdRoute
104112
'/evals/$runId/$evalId': typeof EvalsRunIdEvalIdRoute
113+
'/projects/$projectId/experiments/$experimentName': typeof ProjectsProjectIdExperimentsExperimentNameRoute
105114
'/projects/$projectId/runs/$runId': typeof ProjectsProjectIdRunsRunIdRoute
106115
'/runs/$runId/category/$category': typeof RunsRunIdCategoryCategoryRoute
107116
'/runs/$runId/suite/$suite': typeof RunsRunIdSuiteSuiteRoute
@@ -116,6 +125,7 @@ export interface FileRoutesById {
116125
'/projects/$projectId': typeof ProjectsProjectIdRoute
117126
'/runs/$runId': typeof RunsRunIdRoute
118127
'/evals/$runId/$evalId': typeof EvalsRunIdEvalIdRoute
128+
'/projects/$projectId_/experiments/$experimentName': typeof ProjectsProjectIdExperimentsExperimentNameRoute
119129
'/projects/$projectId_/runs/$runId': typeof ProjectsProjectIdRunsRunIdRoute
120130
'/runs/$runId_/category/$category': typeof RunsRunIdCategoryCategoryRoute
121131
'/runs/$runId_/suite/$suite': typeof RunsRunIdSuiteSuiteRoute
@@ -131,6 +141,7 @@ export interface FileRouteTypes {
131141
| '/projects/$projectId'
132142
| '/runs/$runId'
133143
| '/evals/$runId/$evalId'
144+
| '/projects/$projectId/experiments/$experimentName'
134145
| '/projects/$projectId/runs/$runId'
135146
| '/runs/$runId/category/$category'
136147
| '/runs/$runId/suite/$suite'
@@ -144,6 +155,7 @@ export interface FileRouteTypes {
144155
| '/projects/$projectId'
145156
| '/runs/$runId'
146157
| '/evals/$runId/$evalId'
158+
| '/projects/$projectId/experiments/$experimentName'
147159
| '/projects/$projectId/runs/$runId'
148160
| '/runs/$runId/category/$category'
149161
| '/runs/$runId/suite/$suite'
@@ -157,6 +169,7 @@ export interface FileRouteTypes {
157169
| '/projects/$projectId'
158170
| '/runs/$runId'
159171
| '/evals/$runId/$evalId'
172+
| '/projects/$projectId_/experiments/$experimentName'
160173
| '/projects/$projectId_/runs/$runId'
161174
| '/runs/$runId_/category/$category'
162175
| '/runs/$runId_/suite/$suite'
@@ -171,6 +184,7 @@ export interface RootRouteChildren {
171184
ProjectsProjectIdRoute: typeof ProjectsProjectIdRoute
172185
RunsRunIdRoute: typeof RunsRunIdRoute
173186
EvalsRunIdEvalIdRoute: typeof EvalsRunIdEvalIdRoute
187+
ProjectsProjectIdExperimentsExperimentNameRoute: typeof ProjectsProjectIdExperimentsExperimentNameRoute
174188
ProjectsProjectIdRunsRunIdRoute: typeof ProjectsProjectIdRunsRunIdRoute
175189
RunsRunIdCategoryCategoryRoute: typeof RunsRunIdCategoryCategoryRoute
176190
RunsRunIdSuiteSuiteRoute: typeof RunsRunIdSuiteSuiteRoute
@@ -249,6 +263,13 @@ declare module '@tanstack/react-router' {
249263
preLoaderRoute: typeof ProjectsProjectIdRunsRunIdRouteImport
250264
parentRoute: typeof rootRouteImport
251265
}
266+
'/projects/$projectId_/experiments/$experimentName': {
267+
id: '/projects/$projectId_/experiments/$experimentName'
268+
path: '/projects/$projectId/experiments/$experimentName'
269+
fullPath: '/projects/$projectId/experiments/$experimentName'
270+
preLoaderRoute: typeof ProjectsProjectIdExperimentsExperimentNameRouteImport
271+
parentRoute: typeof rootRouteImport
272+
}
252273
'/projects/$projectId_/evals/$runId/$evalId': {
253274
id: '/projects/$projectId_/evals/$runId/$evalId'
254275
path: '/projects/$projectId/evals/$runId/$evalId'
@@ -267,6 +288,8 @@ const rootRouteChildren: RootRouteChildren = {
267288
ProjectsProjectIdRoute: ProjectsProjectIdRoute,
268289
RunsRunIdRoute: RunsRunIdRoute,
269290
EvalsRunIdEvalIdRoute: EvalsRunIdEvalIdRoute,
291+
ProjectsProjectIdExperimentsExperimentNameRoute:
292+
ProjectsProjectIdExperimentsExperimentNameRoute,
270293
ProjectsProjectIdRunsRunIdRoute: ProjectsProjectIdRunsRunIdRoute,
271294
RunsRunIdCategoryCategoryRoute: RunsRunIdCategoryCategoryRoute,
272295
RunsRunIdSuiteSuiteRoute: RunsRunIdSuiteSuiteRoute,

0 commit comments

Comments
 (0)