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
12 changes: 12 additions & 0 deletions apps/studio/src/components/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ function deriveSegments(matches: ReturnType<typeof useMatches>): 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({
Expand Down
129 changes: 129 additions & 0 deletions apps/studio/src/components/ExperimentDetail.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-4">
<div className="h-8 w-64 animate-pulse rounded bg-gray-800" />
<div className="grid grid-cols-4 gap-4">
{['s1', 's2', 's3', 's4'].map((id) => (
<div key={id} className="h-20 animate-pulse rounded-lg bg-gray-900" />
))}
</div>
</div>
);
}

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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-white">{experimentName}</h1>
<p className="mt-1 text-sm text-gray-400">
{runCount} run{runCount !== 1 ? 's' : ''} &middot; {targetCount} target
{targetCount !== 1 ? 's' : ''}
{experiment?.last_run && (
<span className="ml-2">&middot; Last run: {formatTimestamp(experiment.last_run)}</span>
)}
</p>
</div>

{experiment && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<StatCard label="Runs" value={String(runCount)} />
<StatCard label="Targets" value={String(targetCount)} />
<StatCard
label="Pass Rate"
value={`${Math.round(passRate * 100)}%`}
accent="text-cyan-400"
/>
<StatCard label="Last Run" value={formatTimestamp(experiment.last_run)} />
</div>
)}

<div>
<h2 className="mb-4 text-lg font-medium text-gray-200">All Runs</h2>
<RunList
runs={runs}
projectId={projectId}
emptyMessage={
<div>
<p className="text-lg text-gray-400">No evaluation runs found for this experiment.</p>
<p className="mt-2 text-sm text-gray-500">
Runs will appear here once this experiment has execution results.
</p>
</div>
}
/>
</div>
</div>
);
}

function StatCard({
label,
value,
accent,
}: {
label: string;
value: string;
accent?: string;
}) {
return (
<div className="rounded-lg border border-gray-800 bg-gray-900 p-4">
<p className="text-sm text-gray-400">{label}</p>
<p className={`mt-1 text-2xl font-semibold tabular-nums ${accent ?? 'text-white'}`}>
{value}
</p>
</div>
);
}

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';
}
}
24 changes: 17 additions & 7 deletions apps/studio/src/components/ExperimentsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,23 @@ export function ExperimentsTab({ projectId }: ExperimentsTabProps) {
{experiments.map((exp: ExperimentSummary) => (
<tr key={exp.name} className="transition-colors hover:bg-gray-900/30">
<td className="px-4 py-3">
<Link
to="/experiments/$experimentName"
params={{ experimentName: exp.name }}
className="font-medium text-cyan-400 hover:text-cyan-300 hover:underline"
>
{exp.name}
</Link>
{projectId ? (
<Link
to="/projects/$projectId/experiments/$experimentName"
params={{ projectId, experimentName: exp.name }}
className="font-medium text-cyan-400 hover:text-cyan-300 hover:underline"
>
{exp.name}
</Link>
) : (
<Link
to="/experiments/$experimentName"
params={{ experimentName: exp.name }}
className="font-medium text-cyan-400 hover:text-cyan-300 hover:underline"
>
{exp.name}
</Link>
)}
</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-400">{exp.run_count}</td>
<td className="px-4 py-3 text-right tabular-nums text-gray-400">
Expand Down
76 changes: 76 additions & 0 deletions apps/studio/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -101,6 +107,18 @@ export function Sidebar() {
return <ProjectRunDetailSidebar projectId={projectId} currentRunId={runId} />;
}

if (
projectExperimentMatch &&
typeof projectExperimentMatch === 'object' &&
'projectId' in projectExperimentMatch
) {
const { projectId, experimentName } = projectExperimentMatch as {
projectId: string;
experimentName: string;
};
return <ProjectExperimentSidebar projectId={projectId} currentExperiment={experimentName} />;
}

// Project home (runs/experiments/targets)
if (projectMatch && typeof projectMatch === 'object' && 'projectId' in projectMatch) {
const { projectId } = projectMatch as { projectId: string };
Expand Down Expand Up @@ -518,6 +536,64 @@ function ProjectEvalSidebar({
);
}

function ProjectExperimentSidebar({
projectId,
currentExperiment,
}: {
projectId: string;
currentExperiment: string;
}) {
const { data } = useQuery(projectExperimentsOptions(projectId));
const experiments = data?.experiments ?? [];

return (
<SidebarShell>
<div className="flex items-center gap-2 border-b border-gray-800 px-4 py-4">
<Link to="/" className="text-lg font-semibold text-white hover:text-cyan-400">
AgentV Studio
</Link>
</div>

<div className="border-b border-gray-800 px-4 py-2">
<Link
to="/projects/$projectId"
params={{ projectId }}
search={{ tab: 'experiments' } as Record<string, string>}
className="text-xs text-gray-400 hover:text-cyan-400"
>
&larr; All experiments
</Link>
<p className="mt-1 truncate text-sm font-medium text-gray-300">{projectId}</p>
</div>

<nav className="flex-1 overflow-y-auto px-2 py-3">
<div className="mb-2 px-2 text-xs font-medium uppercase tracking-wider text-gray-500">
Experiments
</div>

{experiments.map((exp) => {
const isActive = exp.name === currentExperiment;

return (
<Link
key={exp.name}
to="/projects/$projectId/experiments/$experimentName"
params={{ projectId, experimentName: exp.name }}
className={`mb-0.5 block truncate rounded-md px-2 py-1.5 text-sm transition-colors ${
isActive
? 'bg-gray-800 text-cyan-400'
: 'text-gray-400 hover:bg-gray-800/50 hover:text-gray-200'
}`}
>
{exp.name}
</Link>
);
})}
</nav>
</SidebarShell>
);
}

function ExperimentSidebar({ currentExperiment }: { currentExperiment: string }) {
const { data } = useExperiments();
const experiments = data?.experiments ?? [];
Expand Down
23 changes: 23 additions & 0 deletions apps/studio/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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',
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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'
Expand All @@ -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'
Expand All @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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'
Expand All @@ -267,6 +288,8 @@ const rootRouteChildren: RootRouteChildren = {
ProjectsProjectIdRoute: ProjectsProjectIdRoute,
RunsRunIdRoute: RunsRunIdRoute,
EvalsRunIdEvalIdRoute: EvalsRunIdEvalIdRoute,
ProjectsProjectIdExperimentsExperimentNameRoute:
ProjectsProjectIdExperimentsExperimentNameRoute,
ProjectsProjectIdRunsRunIdRoute: ProjectsProjectIdRunsRunIdRoute,
RunsRunIdCategoryCategoryRoute: RunsRunIdCategoryCategoryRoute,
RunsRunIdSuiteSuiteRoute: RunsRunIdSuiteSuiteRoute,
Expand Down
Loading
Loading