Skip to content

Commit ddd6156

Browse files
authored
feat(studio): v0 experiment detail page (#260)
* feat(experiments): add experimentDetail route constant and utility Signed-off-by: Nathan Walston <nwalston@nvidia.com> * feat(experiments): add ExperimentDetailRoute, ExperimentDetailMetrics, and ExperimentSessionsDataView Signed-off-by: Nathan Walston <nwalston@nvidia.com> * feat(experiments): navigate to experiment detail on row click in group view Signed-off-by: Nathan Walston <nwalston@nvidia.com> * fix(experiments): add experimentName to title-change spec pathParams Signed-off-by: Nathan Walston <nwalston@nvidia.com> * fix(experiments): add loading prop to Created and Updated KVPairs in ExperimentDetailMetrics Signed-off-by: Nathan Walston <nwalston@nvidia.com> * feat(experiments): add CLI/coding agent tabs to test cases empty state with dynamic group and dataset Signed-off-by: Nathan Walston <nwalston@nvidia.com> * fix(experiments): resolve merge conflict and wire up sessions empty state - Remove leftover conflict marker from ExperimentGroupDataView - Add tabbed CLI/coding-agent empty state to ExperimentSessionsDataView - Add LINK_DOCS_EXPERIMENTS_CLI constant Signed-off-by: Nathan Walston <nwalston@nvidia.com> * feat(experiments): add bot and terminal icons to empty state tabs Signed-off-by: Nathan Walston <nwalston@nvidia.com> * refactor(experiments): extract sessions empty state into Empty.tsx Signed-off-by: Nathan Walston <nwalston@nvidia.com> * refactor(experiments): use StudioDataView onRowClick instead of useRowClick Signed-off-by: Nathan Walston <nwalston@nvidia.com> --------- Signed-off-by: Nathan Walston <nwalston@nvidia.com>
1 parent 1d51120 commit ddd6156

10 files changed

Lines changed: 402 additions & 0 deletions

File tree

web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ import type {
2020
} from '@nemo/sdk/generated/platform/schema';
2121
import { Text, Tooltip } from '@nvidia/foundations-react-core';
2222
import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
23+
import { getExperimentDetailRoute } from '@studio/routes/utils';
2324
import { tooltipClassName } from '@studio/styles/common';
2425
import { keepPreviousData } from '@tanstack/react-query';
2526
import { Columns3 } from 'lucide-react';
2627
import { type ComponentProps, type FC, useCallback, useMemo } from 'react';
28+
import { useNavigate } from 'react-router-dom';
2729

2830
export type ExperimentRow = ExperimentResponse & { id: string };
2931

@@ -49,6 +51,7 @@ export const ExperimentGroupDataView: FC<ExperimentGroupDataViewProps> = ({
4951
experimentGroupName,
5052
}) => {
5153
const workspace = useWorkspaceFromPath();
54+
const navigate = useNavigate();
5255
const {
5356
data: group,
5457
isLoading: isGroupLoading,
@@ -231,6 +234,9 @@ export const ExperimentGroupDataView: FC<ExperimentGroupDataViewProps> = ({
231234
dataViewState={dataViewState}
232235
makeColumns={makeColumns}
233236
searchField="name"
237+
onRowClick={(row) =>
238+
navigate(getExperimentDetailRoute(workspace, experimentGroupName, row.name))
239+
}
234240
toolbarSlotEnd={
235241
<EditColumnsMenu
236242
kind="secondary"
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { TableEmptyState } from '@nemo/common/src/components/TableEmptyState';
5+
import {
6+
Button,
7+
CodeSnippet,
8+
TabsContent,
9+
TabsList,
10+
TabsRoot,
11+
TabsTrigger,
12+
Text,
13+
} from '@nvidia/foundations-react-core';
14+
import { LINK_DOCS_EXPERIMENTS_CLI } from '@studio/constants/links';
15+
import { Bot, ChevronRight, File, FlaskConical, Terminal } from 'lucide-react';
16+
17+
interface EmptyProps {
18+
experimentGroupName: string;
19+
datasetName: string;
20+
}
21+
22+
export const Empty = ({ experimentGroupName, datasetName }: EmptyProps) => {
23+
const cliCommand =
24+
`nemo exp run \\\n` +
25+
` --group "${experimentGroupName}" \\\n` +
26+
` --dataset "${datasetName}" \\\n` +
27+
` --evaluators correctness,helpfulness,groundedness,tool-error`;
28+
29+
return (
30+
<TableEmptyState
31+
icon={<FlaskConical className="size-12" />}
32+
header="No test cases"
33+
emptyMessage="Run an experiment to see test case results."
34+
actions={
35+
<div className="w-[560px] border border-base rounded-lg overflow-hidden">
36+
<TabsRoot defaultValue="cli">
37+
<TabsList className="px-density-md">
38+
<TabsTrigger value="coding-agent">
39+
<Bot className="size-4" />
40+
Coding agent
41+
</TabsTrigger>
42+
<TabsTrigger value="cli">
43+
<Terminal className="size-4" />
44+
CLI command
45+
</TabsTrigger>
46+
</TabsList>
47+
<div className="px-density-md pb-density-md flex flex-col gap-density-sm">
48+
<TabsContent value="coding-agent" className="px-0 pb-0 w-full">
49+
<CodeSnippet
50+
value="To be determined"
51+
language="text"
52+
kind="block"
53+
className="w-full whitespace-pre-line"
54+
/>
55+
</TabsContent>
56+
<TabsContent value="cli" className="px-0 pb-0">
57+
<CodeSnippet value={cliCommand} language="bash" kind="block" className="w-full" />
58+
</TabsContent>
59+
<Button
60+
asChild
61+
color="neutral"
62+
kind="tertiary"
63+
size="small"
64+
className="w-full justify-start"
65+
>
66+
<a href={LINK_DOCS_EXPERIMENTS_CLI} target="_blank" rel="noreferrer">
67+
<File className="!text-brand" />
68+
<Text className="flex-1">CLI docs — learn more</Text>
69+
<ChevronRight />
70+
</a>
71+
</Button>
72+
</div>
73+
</TabsRoot>
74+
</div>
75+
}
76+
/>
77+
);
78+
};
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { Root as DataViewRoot } from '@nemo/common/src/components/DataView/internal';
5+
import { StudioDataView } from '@nemo/common/src/components/DataView/StudioDataView';
6+
import { RelativeTime } from '@nemo/common/src/components/RelativeTime';
7+
import { StatusBadge } from '@nemo/common/src/components/StatusBadge';
8+
import { useStudioDataViewState } from '@nemo/common/src/hooks/useStudioDataViewState';
9+
import { useGetExperiment, useListExperimentSessions } from '@nemo/sdk/generated/platform/api';
10+
import type { ExperimentSessionResponse } from '@nemo/sdk/generated/platform/schema';
11+
import { Text, Tooltip } from '@nvidia/foundations-react-core';
12+
import { Empty } from '@studio/components/dataViews/ExperimentSessionsDataView/Empty';
13+
import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
14+
import { tooltipClassName } from '@studio/styles/common';
15+
import { keepPreviousData } from '@tanstack/react-query';
16+
import { type ComponentProps, type FC, useMemo } from 'react';
17+
18+
type SessionRow = ExperimentSessionResponse & { _rowId: string };
19+
20+
interface ExperimentSessionsDataViewProps {
21+
experimentName: string;
22+
experimentGroupName: string;
23+
}
24+
25+
const mapStatusForBadge = (status: ExperimentSessionResponse['status']) =>
26+
status === 'success' ? 'completed' : status;
27+
28+
const formatEvaluatorScores = (scores: ExperimentSessionResponse['evaluator_scores']): string => {
29+
if (!scores || Object.keys(scores).length === 0) return '-';
30+
return Object.entries(scores)
31+
.map(([name, value]) => `${name}: ${(value * 100).toFixed(1)}%`)
32+
.join(', ');
33+
};
34+
35+
export const ExperimentSessionsDataView: FC<ExperimentSessionsDataViewProps> = ({
36+
experimentName,
37+
experimentGroupName,
38+
}) => {
39+
const workspace = useWorkspaceFromPath();
40+
const dataViewState = useStudioDataViewState({});
41+
const { data: experiment } = useGetExperiment(workspace, experimentName);
42+
43+
const page = dataViewState.pagination.state.pageIndex + 1;
44+
const pageSize = dataViewState.pagination.state.pageSize;
45+
46+
const { data: sessionsResponse, isLoading } = useListExperimentSessions(
47+
workspace,
48+
experimentName,
49+
{ page, page_size: pageSize },
50+
{ query: { placeholderData: keepPreviousData } }
51+
);
52+
53+
const sessionsData = sessionsResponse?.data;
54+
const totalCount = sessionsResponse?.pagination?.total_results ?? sessionsData?.length ?? 0;
55+
56+
const tableData = useMemo<SessionRow[]>(
57+
() =>
58+
(sessionsData ?? []).map((session, i) => ({
59+
...session,
60+
_rowId: session.session_id ?? String(i),
61+
})),
62+
[sessionsData]
63+
);
64+
65+
const makeColumns: ComponentProps<typeof DataViewRoot<SessionRow>>['makeColumns'] = ({
66+
accessor,
67+
}) => [
68+
accessor('test_case_id', {
69+
header: 'Case',
70+
enableSorting: false,
71+
size: 200,
72+
cell: ({ row }) => {
73+
const value = row.original.test_case_id;
74+
if (!value) return <Text>-</Text>;
75+
return (
76+
<Tooltip slotContent={value} className={tooltipClassName} side="bottom">
77+
<Text className="cursor-default truncate max-w-[180px] block">{value}</Text>
78+
</Tooltip>
79+
);
80+
},
81+
}),
82+
accessor('input', {
83+
header: 'Input',
84+
enableSorting: false,
85+
size: 240,
86+
cell: ({ row }) => {
87+
const value = row.original.input;
88+
if (!value) return <Text>-</Text>;
89+
return (
90+
<Tooltip slotContent={value} className={tooltipClassName} side="bottom">
91+
<Text className="cursor-default truncate max-w-[220px] block">{value}</Text>
92+
</Tooltip>
93+
);
94+
},
95+
}),
96+
accessor('started_at', {
97+
header: 'Started at',
98+
enableSorting: false,
99+
cell: ({ row }) =>
100+
row.original.started_at ? (
101+
<RelativeTime datetime={row.original.started_at} />
102+
) : (
103+
<Text>-</Text>
104+
),
105+
}),
106+
accessor('ended_at', {
107+
header: 'Ended at',
108+
enableSorting: false,
109+
cell: ({ row }) =>
110+
row.original.ended_at ? <RelativeTime datetime={row.original.ended_at} /> : <Text>-</Text>,
111+
}),
112+
accessor('latency_ms', {
113+
header: 'Latency',
114+
enableSorting: false,
115+
cell: ({ row }) => {
116+
const ms = row.original.latency_ms;
117+
return <Text>{ms != null ? `${Math.round(ms)} ms` : '-'}</Text>;
118+
},
119+
}),
120+
accessor('status', {
121+
header: 'Status',
122+
enableSorting: false,
123+
cell: ({ row }) => <StatusBadge status={mapStatusForBadge(row.original.status)} />,
124+
}),
125+
accessor(
126+
(original) =>
127+
original.input_tokens != null || original.output_tokens != null
128+
? (original.input_tokens ?? 0) + (original.output_tokens ?? 0)
129+
: undefined,
130+
{
131+
id: 'tokens',
132+
header: 'Tokens',
133+
enableSorting: false,
134+
cell: ({ row }) => {
135+
const { input_tokens, output_tokens } = row.original;
136+
if (input_tokens == null && output_tokens == null) return <Text>-</Text>;
137+
return <Text>{String((input_tokens ?? 0) + (output_tokens ?? 0))}</Text>;
138+
},
139+
}
140+
),
141+
accessor('cost_total_usd', {
142+
header: 'Cost',
143+
enableSorting: false,
144+
cell: ({ row }) => {
145+
const cost = row.original.cost_total_usd;
146+
return <Text>{cost != null ? `$${cost.toFixed(3)}` : '-'}</Text>;
147+
},
148+
}),
149+
accessor((original) => formatEvaluatorScores(original.evaluator_scores), {
150+
id: 'evaluator_scores',
151+
header: 'Evaluator scores',
152+
enableSorting: false,
153+
cell: ({ row }) => {
154+
const formatted = formatEvaluatorScores(row.original.evaluator_scores);
155+
if (formatted === '-') return <Text>-</Text>;
156+
return (
157+
<Tooltip slotContent={formatted} className={tooltipClassName} side="bottom">
158+
<Text className="cursor-default truncate max-w-[200px] block">{formatted}</Text>
159+
</Tooltip>
160+
);
161+
},
162+
}),
163+
];
164+
165+
return (
166+
<StudioDataView
167+
dataViewState={dataViewState}
168+
makeColumns={makeColumns}
169+
attributes={{
170+
DataViewRoot: {
171+
data: tableData,
172+
totalCount,
173+
requestStatus: isLoading && !sessionsData ? 'loading' : undefined,
174+
},
175+
DataViewTableContent: {
176+
renderEmptyState: () => (
177+
<Empty
178+
experimentGroupName={experimentGroupName}
179+
datasetName={experiment?.dataset_name ?? '<dataset>'}
180+
/>
181+
),
182+
},
183+
}}
184+
/>
185+
);
186+
};

web/packages/studio/src/constants/links.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,6 @@ export const LINK_DOCS_JOBS = `${DOCS_BASE_URL}studio/?#jobs`;
6363

6464
// Secrets documentation links
6565
export const LINK_DOCS_SECRETS = `${DOCS_BASE_URL}get-started/concepts/manage-secrets/`;
66+
67+
// Experiments
68+
export const LINK_DOCS_EXPERIMENTS_CLI = `${DOCS_BASE_URL}experiments/cli/`;

web/packages/studio/src/constants/routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const ROUTE_PARAMS = {
3939
/** Benchmark entity name segment under evaluation/benchmarks/:name */
4040
benchmarkName: 'benchmarkName',
4141
experimentGroupName: 'experimentGroupName',
42+
experimentName: 'experimentName',
4243
} as const;
4344

4445
// Just an alias to make the routes more readable
@@ -72,6 +73,7 @@ export const ROUTES = {
7273
/** Empty landing page for the EXPERIMENT feature (gated by VITE_FF_EXPERIMENT). */
7374
experiment: `/workspaces/:${P.workspace}/experiment`,
7475
experimentGroupDetail: `/workspaces/:${P.workspace}/experiment/:${P.experimentGroupName}`,
76+
experimentDetail: `/workspaces/:${P.workspace}/experiment/:${P.experimentGroupName}/:${P.experimentName}`,
7577
customizationJobList: `/workspaces/:${P.workspace}/customizations`,
7678
customizationJobDetails: `/workspaces/:${P.workspace}/customizations/:${P.customizationJobName}`,
7779
newCustomizationJob: `/workspaces/:${P.workspace}/customizations/fine-tuned/new`,
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { KVPair } from '@nemo/common/src/components/KVPair';
5+
import { RelativeTime } from '@nemo/common/src/components/RelativeTime';
6+
import { useGetExperiment } from '@nemo/sdk/generated/platform/api';
7+
import { Divider } from '@nvidia/foundations-react-core';
8+
import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
9+
import { type FC } from 'react';
10+
11+
interface ExperimentDetailMetricsProps {
12+
experimentName: string;
13+
}
14+
15+
export const ExperimentDetailMetrics: FC<ExperimentDetailMetricsProps> = ({ experimentName }) => {
16+
const workspace = useWorkspaceFromPath();
17+
const { data: experiment, isLoading } = useGetExperiment(workspace, experimentName);
18+
19+
const avgCost =
20+
experiment?.cost_usd?.mean != null ? `$${experiment.cost_usd.mean.toFixed(3)}` : undefined;
21+
22+
const avgLatency =
23+
experiment?.latency_ms?.mean != null
24+
? `${Math.round(experiment.latency_ms.mean)} ms`
25+
: undefined;
26+
27+
return (
28+
<div className="flex gap-8">
29+
<KVPair
30+
label="Agent Name"
31+
value={experiment?.agent_name || undefined}
32+
loading={isLoading}
33+
orientation="vertical"
34+
/>
35+
<Divider orientation="vertical" className="grow-0 self-stretch" />
36+
<KVPair
37+
label="Created"
38+
value={
39+
experiment?.created_at ? <RelativeTime datetime={experiment.created_at} /> : undefined
40+
}
41+
loading={isLoading}
42+
orientation="vertical"
43+
/>
44+
<Divider orientation="vertical" className="grow-0 self-stretch" />
45+
<KVPair
46+
label="Updated"
47+
value={
48+
experiment?.updated_at ? <RelativeTime datetime={experiment.updated_at} /> : undefined
49+
}
50+
loading={isLoading}
51+
orientation="vertical"
52+
/>
53+
<Divider orientation="vertical" className="grow-0 self-stretch" />
54+
<KVPair label="Avg Cost" value={avgCost} loading={isLoading} orientation="vertical" />
55+
<Divider orientation="vertical" className="grow-0 self-stretch" />
56+
<KVPair label="Avg Latency" value={avgLatency} loading={isLoading} orientation="vertical" />
57+
</div>
58+
);
59+
};

0 commit comments

Comments
 (0)