diff --git a/apps/studio/src/components/Breadcrumbs.tsx b/apps/studio/src/components/Breadcrumbs.tsx index 680f7cb5c..361bf8b12 100644 --- a/apps/studio/src/components/Breadcrumbs.tsx +++ b/apps/studio/src/components/Breadcrumbs.tsx @@ -31,6 +31,18 @@ function deriveSegments(matches: ReturnType): BreadcrumbSegme if (routeId === '/' || routeId === '/_layout') continue; + if (routeId.includes('/projects/$projectId') && params.projectId) { + if (!segments.some((s) => s.label === params.projectId)) { + segments.push({ + label: params.projectId, + to: `/projects/${encodeURIComponent(params.projectId)}`, + }); + } + if (routeId === '/projects/$projectId') { + continue; + } + } + if (routeId.includes('/runs/$runId/category/$category')) { if (!segments.some((s) => s.label === params.runId)) { segments.push({ diff --git a/apps/studio/src/components/ExperimentDetail.tsx b/apps/studio/src/components/ExperimentDetail.tsx new file mode 100644 index 000000000..2f354afed --- /dev/null +++ b/apps/studio/src/components/ExperimentDetail.tsx @@ -0,0 +1,129 @@ +/** + * Shared experiment detail view for both single-project and project-scoped routes. + * + * Reads experiment summary and run list from the matching API surface so the UI + * stays on the same data source in both single and multi-project modes. + */ + +import { useQuery } from '@tanstack/react-query'; + +import { + experimentsOptions, + projectExperimentsOptions, + projectRunListOptions, + runListOptions, +} from '~/lib/api'; + +import { RunList } from './RunList'; + +interface ExperimentDetailProps { + experimentName: string; + projectId?: string; +} + +export function ExperimentDetail({ experimentName, projectId }: ExperimentDetailProps) { + const { data: experimentsData, isLoading: expLoading } = useQuery( + projectId ? projectExperimentsOptions(projectId) : experimentsOptions, + ); + const { data: runListData, isLoading: runsLoading } = useQuery( + projectId ? projectRunListOptions(projectId) : runListOptions, + ); + + const isLoading = expLoading || runsLoading; + + if (isLoading) { + return ( +
+
+
+ {['s1', 's2', 's3', 's4'].map((id) => ( +
+ ))} +
+
+ ); + } + + const experiment = experimentsData?.experiments?.find((entry) => entry.name === experimentName); + const runs = (runListData?.runs ?? []).filter( + (run) => (run.experiment ?? 'default') === experimentName, + ); + + const passRate = experiment?.pass_rate ?? 0; + const runCount = experiment?.run_count ?? runs.length; + const targetCount = experiment?.target_count ?? 0; + + return ( +
+
+

{experimentName}

+

+ {runCount} run{runCount !== 1 ? 's' : ''} · {targetCount} target + {targetCount !== 1 ? 's' : ''} + {experiment?.last_run && ( + · Last run: {formatTimestamp(experiment.last_run)} + )} +

+
+ + {experiment && ( +
+ + + + +
+ )} + +
+

All Runs

+ +

No evaluation runs found for this experiment.

+

+ Runs will appear here once this experiment has execution results. +

+
+ } + /> +
+
+ ); +} + +function StatCard({ + label, + value, + accent, +}: { + label: string; + value: string; + accent?: string; +}) { + return ( +
+

{label}

+

+ {value} +

+
+ ); +} + +function formatTimestamp(ts: string | undefined | null): string { + if (!ts) return 'N/A'; + try { + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return 'N/A'; + return d.toLocaleString(); + } catch { + return 'N/A'; + } +} diff --git a/apps/studio/src/components/ExperimentsTab.tsx b/apps/studio/src/components/ExperimentsTab.tsx index 54588be29..4fabb8908 100644 --- a/apps/studio/src/components/ExperimentsTab.tsx +++ b/apps/studio/src/components/ExperimentsTab.tsx @@ -56,13 +56,23 @@ export function ExperimentsTab({ projectId }: ExperimentsTabProps) { {experiments.map((exp: ExperimentSummary) => ( - - {exp.name} - + {projectId ? ( + + {exp.name} + + ) : ( + + {exp.name} + + )} {exp.run_count} diff --git a/apps/studio/src/components/Sidebar.tsx b/apps/studio/src/components/Sidebar.tsx index 708bbbfc3..42f6984ab 100644 --- a/apps/studio/src/components/Sidebar.tsx +++ b/apps/studio/src/components/Sidebar.tsx @@ -14,10 +14,12 @@ import { type ReactNode, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Link, useLocation, useMatchRoute } from '@tanstack/react-router'; import { isPassing, + projectExperimentsOptions, useAllProjectRuns, useCategorySuites, useEvalRuns, @@ -80,6 +82,10 @@ export function Sidebar() { to: '/projects/$projectId/runs/$runId', fuzzy: true, }); + const projectExperimentMatch = matchRoute({ + to: '/projects/$projectId/experiments/$experimentName', + fuzzy: true, + }); const projectMatch = matchRoute({ to: '/projects/$projectId', fuzzy: true, @@ -101,6 +107,18 @@ export function Sidebar() { return ; } + if ( + projectExperimentMatch && + typeof projectExperimentMatch === 'object' && + 'projectId' in projectExperimentMatch + ) { + const { projectId, experimentName } = projectExperimentMatch as { + projectId: string; + experimentName: string; + }; + return ; + } + // Project home (runs/experiments/targets) if (projectMatch && typeof projectMatch === 'object' && 'projectId' in projectMatch) { const { projectId } = projectMatch as { projectId: string }; @@ -518,6 +536,64 @@ function ProjectEvalSidebar({ ); } +function ProjectExperimentSidebar({ + projectId, + currentExperiment, +}: { + projectId: string; + currentExperiment: string; +}) { + const { data } = useQuery(projectExperimentsOptions(projectId)); + const experiments = data?.experiments ?? []; + + return ( + +
+ + AgentV Studio + +
+ +
+ } + className="text-xs text-gray-400 hover:text-cyan-400" + > + ← All experiments + +

{projectId}

+
+ + +
+ ); +} + function ExperimentSidebar({ currentExperiment }: { currentExperiment: string }) { const { data } = useExperiments(); const experiments = data?.experiments ?? []; diff --git a/apps/studio/src/routeTree.gen.ts b/apps/studio/src/routeTree.gen.ts index 7ac53f821..14cd36976 100644 --- a/apps/studio/src/routeTree.gen.ts +++ b/apps/studio/src/routeTree.gen.ts @@ -19,6 +19,7 @@ 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 ProjectsProjectIdExperimentsExperimentNameRouteImport } from './routes/projects/$projectId_/experiments/$experimentName' import { Route as ProjectsProjectIdEvalsRunIdEvalIdRouteImport } from './routes/projects/$projectId_/evals/$runId.$evalId' const SettingsRoute = SettingsRouteImport.update({ @@ -74,6 +75,12 @@ const ProjectsProjectIdRunsRunIdRoute = path: '/projects/$projectId/runs/$runId', getParentRoute: () => rootRouteImport, } as any) +const ProjectsProjectIdExperimentsExperimentNameRoute = + ProjectsProjectIdExperimentsExperimentNameRouteImport.update({ + id: '/projects/$projectId_/experiments/$experimentName', + path: '/projects/$projectId/experiments/$experimentName', + getParentRoute: () => rootRouteImport, + } as any) const ProjectsProjectIdEvalsRunIdEvalIdRoute = ProjectsProjectIdEvalsRunIdEvalIdRouteImport.update({ id: '/projects/$projectId_/evals/$runId/$evalId', @@ -89,6 +96,7 @@ export interface FileRoutesByFullPath { '/projects/$projectId': typeof ProjectsProjectIdRoute '/runs/$runId': typeof RunsRunIdRoute '/evals/$runId/$evalId': typeof EvalsRunIdEvalIdRoute + '/projects/$projectId/experiments/$experimentName': typeof ProjectsProjectIdExperimentsExperimentNameRoute '/projects/$projectId/runs/$runId': typeof ProjectsProjectIdRunsRunIdRoute '/runs/$runId/category/$category': typeof RunsRunIdCategoryCategoryRoute '/runs/$runId/suite/$suite': typeof RunsRunIdSuiteSuiteRoute @@ -102,6 +110,7 @@ export interface FileRoutesByTo { '/projects/$projectId': typeof ProjectsProjectIdRoute '/runs/$runId': typeof RunsRunIdRoute '/evals/$runId/$evalId': typeof EvalsRunIdEvalIdRoute + '/projects/$projectId/experiments/$experimentName': typeof ProjectsProjectIdExperimentsExperimentNameRoute '/projects/$projectId/runs/$runId': typeof ProjectsProjectIdRunsRunIdRoute '/runs/$runId/category/$category': typeof RunsRunIdCategoryCategoryRoute '/runs/$runId/suite/$suite': typeof RunsRunIdSuiteSuiteRoute @@ -116,6 +125,7 @@ export interface FileRoutesById { '/projects/$projectId': typeof ProjectsProjectIdRoute '/runs/$runId': typeof RunsRunIdRoute '/evals/$runId/$evalId': typeof EvalsRunIdEvalIdRoute + '/projects/$projectId_/experiments/$experimentName': typeof ProjectsProjectIdExperimentsExperimentNameRoute '/projects/$projectId_/runs/$runId': typeof ProjectsProjectIdRunsRunIdRoute '/runs/$runId_/category/$category': typeof RunsRunIdCategoryCategoryRoute '/runs/$runId_/suite/$suite': typeof RunsRunIdSuiteSuiteRoute @@ -131,6 +141,7 @@ export interface FileRouteTypes { | '/projects/$projectId' | '/runs/$runId' | '/evals/$runId/$evalId' + | '/projects/$projectId/experiments/$experimentName' | '/projects/$projectId/runs/$runId' | '/runs/$runId/category/$category' | '/runs/$runId/suite/$suite' @@ -144,6 +155,7 @@ export interface FileRouteTypes { | '/projects/$projectId' | '/runs/$runId' | '/evals/$runId/$evalId' + | '/projects/$projectId/experiments/$experimentName' | '/projects/$projectId/runs/$runId' | '/runs/$runId/category/$category' | '/runs/$runId/suite/$suite' @@ -157,6 +169,7 @@ export interface FileRouteTypes { | '/projects/$projectId' | '/runs/$runId' | '/evals/$runId/$evalId' + | '/projects/$projectId_/experiments/$experimentName' | '/projects/$projectId_/runs/$runId' | '/runs/$runId_/category/$category' | '/runs/$runId_/suite/$suite' @@ -171,6 +184,7 @@ export interface RootRouteChildren { ProjectsProjectIdRoute: typeof ProjectsProjectIdRoute RunsRunIdRoute: typeof RunsRunIdRoute EvalsRunIdEvalIdRoute: typeof EvalsRunIdEvalIdRoute + ProjectsProjectIdExperimentsExperimentNameRoute: typeof ProjectsProjectIdExperimentsExperimentNameRoute ProjectsProjectIdRunsRunIdRoute: typeof ProjectsProjectIdRunsRunIdRoute RunsRunIdCategoryCategoryRoute: typeof RunsRunIdCategoryCategoryRoute RunsRunIdSuiteSuiteRoute: typeof RunsRunIdSuiteSuiteRoute @@ -249,6 +263,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectsProjectIdRunsRunIdRouteImport parentRoute: typeof rootRouteImport } + '/projects/$projectId_/experiments/$experimentName': { + id: '/projects/$projectId_/experiments/$experimentName' + path: '/projects/$projectId/experiments/$experimentName' + fullPath: '/projects/$projectId/experiments/$experimentName' + preLoaderRoute: typeof ProjectsProjectIdExperimentsExperimentNameRouteImport + parentRoute: typeof rootRouteImport + } '/projects/$projectId_/evals/$runId/$evalId': { id: '/projects/$projectId_/evals/$runId/$evalId' path: '/projects/$projectId/evals/$runId/$evalId' @@ -267,6 +288,8 @@ const rootRouteChildren: RootRouteChildren = { ProjectsProjectIdRoute: ProjectsProjectIdRoute, RunsRunIdRoute: RunsRunIdRoute, EvalsRunIdEvalIdRoute: EvalsRunIdEvalIdRoute, + ProjectsProjectIdExperimentsExperimentNameRoute: + ProjectsProjectIdExperimentsExperimentNameRoute, ProjectsProjectIdRunsRunIdRoute: ProjectsProjectIdRunsRunIdRoute, RunsRunIdCategoryCategoryRoute: RunsRunIdCategoryCategoryRoute, RunsRunIdSuiteSuiteRoute: RunsRunIdSuiteSuiteRoute, diff --git a/apps/studio/src/routes/experiments/$experimentName.tsx b/apps/studio/src/routes/experiments/$experimentName.tsx index 29b6868bb..b2be36c1a 100644 --- a/apps/studio/src/routes/experiments/$experimentName.tsx +++ b/apps/studio/src/routes/experiments/$experimentName.tsx @@ -1,14 +1,10 @@ /** - * Experiment detail route: shows aggregate stats and the run list. - * - * Fetches experiment data from the experiments API for stats, - * and the full run list for the table below. + * Experiment detail route for single-project mode. */ import { createFileRoute } from '@tanstack/react-router'; -import { RunList } from '~/components/RunList'; -import { useExperiments, useRunList } from '~/lib/api'; +import { ExperimentDetail } from '~/components/ExperimentDetail'; export const Route = createFileRoute('/experiments/$experimentName')({ component: ExperimentDetailPage, @@ -16,92 +12,5 @@ export const Route = createFileRoute('/experiments/$experimentName')({ function ExperimentDetailPage() { const { experimentName } = Route.useParams(); - const { data: experimentsData, isLoading: expLoading } = useExperiments(); - const { data: runListData, isLoading: runsLoading } = useRunList(); - - const isLoading = expLoading || runsLoading; - - if (isLoading) { - return ( -
-
-
- {['s1', 's2', 's3', 's4'].map((id) => ( -
- ))} -
-
- ); - } - - const experiment = experimentsData?.experiments?.find((e) => e.name === experimentName); - const runs = runListData?.runs ?? []; - - // Derive stats from the experiment summary if available - const passRate = experiment?.pass_rate ?? 0; - const runCount = experiment?.run_count ?? 0; - const targetCount = experiment?.target_count ?? 0; - - return ( -
-
-

{experimentName}

-

- {runCount} run{runCount !== 1 ? 's' : ''} · {targetCount} target - {targetCount !== 1 ? 's' : ''} - {experiment?.last_run && ( - · Last run: {formatTimestamp(experiment.last_run)} - )} -

-
- - {experiment && ( -
- - - - -
- )} - -
-

All Runs

- -
-
- ); -} - -function StatCard({ - label, - value, - accent, -}: { - label: string; - value: string; - accent?: string; -}) { - return ( -
-

{label}

-

- {value} -

-
- ); -} - -function formatTimestamp(ts: string | undefined | null): string { - if (!ts) return 'N/A'; - try { - const d = new Date(ts); - if (Number.isNaN(d.getTime())) return 'N/A'; - return d.toLocaleString(); - } catch { - return 'N/A'; - } + return ; } diff --git a/apps/studio/src/routes/projects/$projectId_/experiments/$experimentName.tsx b/apps/studio/src/routes/projects/$projectId_/experiments/$experimentName.tsx new file mode 100644 index 000000000..3033b5eca --- /dev/null +++ b/apps/studio/src/routes/projects/$projectId_/experiments/$experimentName.tsx @@ -0,0 +1,16 @@ +/** + * Project-scoped experiment detail route. + */ + +import { createFileRoute } from '@tanstack/react-router'; + +import { ExperimentDetail } from '~/components/ExperimentDetail'; + +export const Route = createFileRoute('/projects/$projectId_/experiments/$experimentName')({ + component: ProjectExperimentDetailPage, +}); + +function ProjectExperimentDetailPage() { + const { projectId, experimentName } = Route.useParams(); + return ; +}