Skip to content
Open
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
2 changes: 1 addition & 1 deletion web/packages/studio/src/constants/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,4 @@ export const LINK_DOCS_JOBS = `${DOCS_BASE_URL}studio#jobs`;
export const LINK_DOCS_SECRETS = `${DOCS_BASE_URL}get-started/core-concepts/manage-secrets`;

// Evaluations
export const LINK_DOCS_EXPERIMENTS_CLI = `${DOCS_BASE_URL}reference/cli-reference`;
export const LINK_DOCS_EXPERIMENTS_CLI = `${DOCS_BASE_URL}evaluate-models/experiments`;
101 changes: 49 additions & 52 deletions web/packages/studio/src/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,47 @@ export interface HypermodelParams {
hypermodelId: string;
}

/** Shared by the agents list and get-by-name handlers so both agree on the same fixtures. */
const mockAgents = (workspace: unknown) => {
const config = {
functions: { wiki: { _type: 'wiki_search' }, clock: { _type: 'current_datetime' } },
llms: {
llm: {
_type: 'openai',
api_key: 'not-used',
model_name: 'meta-llama-3-1-70b-instruct',
temperature: 0,
},
},
workflow: {
_type: 'react_agent',
tool_names: ['wiki', 'clock'],
llm_name: 'llm',
verbose: false,
parse_agent_response_max_retries: 3,
},
};

return [
{
name: 'react-agent',
workspace,
description: '',
created_at: '2026-04-20T10:00:00Z',
config,
config_format: 'nat-workflow-v1',
},
{
name: 'react-agent2',
workspace,
description: 'Second react agent',
created_at: '2026-04-22T10:00:00Z',
config,
config_format: 'nat-workflow-v1',
},
];
};

/**
* Happy path handlers for all UI tests. They usually return mock fixtures, like example Hypermodel response objects.
* Having a single source of happy path MSW handlers is listed in the [MSW docs as a best practice](https://mswjs.io/docs/best-practices/structuring-handlers#handlers-structure),
Expand Down Expand Up @@ -593,58 +634,7 @@ export const handlers = [
http.get(
`${PLATFORM_BASE_URL}/apis/agents/v2/workspaces/:workspace/agents`,
({ params, request }) => {
const data = [
{
name: 'react-agent',
workspace: params['workspace'],
description: '',
created_at: '2026-04-20T10:00:00Z',
config: {
functions: { wiki: { _type: 'wiki_search' }, clock: { _type: 'current_datetime' } },
llms: {
llm: {
_type: 'openai',
api_key: 'not-used',
model_name: 'meta-llama-3-1-70b-instruct',
temperature: 0,
},
},
workflow: {
_type: 'react_agent',
tool_names: ['wiki', 'clock'],
llm_name: 'llm',
verbose: false,
parse_agent_response_max_retries: 3,
},
},
config_format: 'nat-workflow-v1',
},
{
name: 'react-agent2',
workspace: params['workspace'],
description: 'Second react agent',
created_at: '2026-04-22T10:00:00Z',
config: {
functions: { wiki: { _type: 'wiki_search' }, clock: { _type: 'current_datetime' } },
llms: {
llm: {
_type: 'openai',
api_key: 'not-used',
model_name: 'meta-llama-3-1-70b-instruct',
temperature: 0,
},
},
workflow: {
_type: 'react_agent',
tool_names: ['wiki', 'clock'],
llm_name: 'llm',
verbose: false,
parse_agent_response_max_retries: 3,
},
},
config_format: 'nat-workflow-v1',
},
];
const data = mockAgents(params['workspace']);

const url = new URL(request.url);
const sort = url.searchParams.get('sort') ?? '-created_at';
Expand Down Expand Up @@ -677,6 +667,13 @@ export const handlers = [
});
}
),
http.get(
`${PLATFORM_BASE_URL}/apis/agents/v2/workspaces/:workspace/agents/:name`,
({ params }) => {
const agent = mockAgents(params['workspace']).find((a) => a.name === params['name']);
return agent ? HttpResponse.json(agent) : new HttpResponse(null, { status: 404 });
}
),
http.delete(
`${PLATFORM_BASE_URL}/apis/agents/v2/workspaces/:workspace/agents/:name`,
() => new HttpResponse(null, { status: 204 })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,44 @@ import { bucketAdverbForRange } from '@studio/components/AgentTraceStatistics/ut
import { INTAKE_ENABLED, OPTIMIZER_ENABLED } from '@studio/constants/environment';
import { AgentSummaryPanel } from '@studio/routes/agents/AgentDetailRoute/overview/AgentSummaryPanel';
import { OpenInsightsPanel } from '@studio/routes/agents/AgentDetailRoute/overview/OpenInsightsPanel';
import { toRecentExperiments } from '@studio/routes/agents/AgentDetailRoute/overview/recentExperiments';
import { RecentExperimentsPanel } from '@studio/routes/agents/AgentDetailRoute/overview/RecentExperimentsPanel';
import { useOpenInsights } from '@studio/routes/agents/AgentDetailRoute/overview/useOpenInsights';
import { useOverviewTraces } from '@studio/routes/agents/AgentDetailRoute/overview/useOverviewTraces';
import type { AgentEvaluationRow } from '@studio/routes/agents/AgentDetailRoute/useAgentDetails';
import {
getExperimentDetailRoute,
getIntakeTracesRoute,
getOptimizerInsightRoute,
getOptimizerRoute,
} from '@studio/routes/utils';
import { type FC, useState } from 'react';
import { type FC, useMemo, useState } from 'react';
import { useNavigate } from 'react-router';

interface OverviewTabProps {
workspace: string;
agent?: Agent;
modelNames: string[];
/** The agent's published evaluations, which the experiment cards are rolled up from. */
evals: AgentEvaluationRow[];
/** Jump to the chat tab so the agent emits its first traces. */
onRunAgent: () => void;
/** Open the submit-evaluation modal from the experiments empty state. */
onRunEvaluation?: () => void;
}

/** Landing view for an agent: how it has been running, next to what it is. */
export const OverviewTab: FC<OverviewTabProps> = ({ workspace, agent, modelNames, onRunAgent }) => {
export const OverviewTab: FC<OverviewTabProps> = ({
workspace,
agent,
modelNames,
evals,
onRunAgent,
onRunEvaluation,
}) => {
const navigate = useNavigate();
const [range, setRange] = useState<TraceStatisticsRange>('week');
const experiments = useMemo(() => toRecentExperiments(evals), [evals]);
const { traces, isPending } = useOverviewTraces({ workspace, range, enabled: INTAKE_ENABLED });
const {
insights,
Expand All @@ -50,14 +66,23 @@ export const OverviewTab: FC<OverviewTabProps> = ({ workspace, agent, modelNames
return (
<Flex gap="density-2xl" align="start" wrap="wrap" className="w-full pb-6">
<Stack gap="density-2xl" className="min-w-0 flex-1 basis-[32rem]">
<AgentTraceStatistics
traces={traces}
range={range}
onRangeChange={setRange}
onViewTraces={() => navigate(getIntakeTracesRoute(workspace))}
onRunAgent={onRunAgent}
isPending={isPending}
caption={`${bucketAdverbForRange(range)} · Workspace-wide`}
{INTAKE_ENABLED && (
<AgentTraceStatistics
traces={traces}
range={range}
onRangeChange={setRange}
onViewTraces={() => navigate(getIntakeTracesRoute(workspace))}
onRunAgent={onRunAgent}
isPending={isPending}
caption={`${bucketAdverbForRange(range)}`}
/>
)}
<RecentExperimentsPanel
experiments={experiments}
onOpenExperiment={(experiment) =>
experiment.name && navigate(getExperimentDetailRoute(workspace, experiment.name))
}
onRunEvaluation={onRunEvaluation}
/>
</Stack>
<Stack gap="density-2xl" className="w-full shrink-0 lg:w-90">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ describe('AgentDetailRoute', () => {
expect(screen.getByRole('tab', { name: 'Details' })).toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'Configuration' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Open traces' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Run evaluation' })).toBeInTheDocument();
// The overview's experiments empty state offers the same action, so this label appears twice:
// once in the header, once in that empty state.
expect(screen.getAllByRole('button', { name: 'Run evaluation' })).toHaveLength(2);
expect(screen.getByRole('button', { name: 'Deploy' })).toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,9 @@ export const AgentDetailRoute: FC = () => {
workspace={workspace}
agent={agent}
modelNames={modelNames}
evals={agentEvals}
onRunAgent={() => setSelectedTab('chat')}
onRunEvaluation={agentName ? () => setSubmitEvalOpen(true) : undefined}
/>
</TabsContent>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { Meta, StoryObj } from '@storybook/react';
import { toRecentExperiments } from '@studio/routes/agents/AgentDetailRoute/overview/recentExperiments';
import { RecentExperimentsPanel } from '@studio/routes/agents/AgentDetailRoute/overview/RecentExperimentsPanel';
import type { AgentEvaluationRow } from '@studio/routes/agents/AgentDetailRoute/useAgentDetails';

const DAY_MS = 24 * 60 * 60 * 1000;

/** Fixed rather than `Date.now()` so the stories render identically every run. */
const LATEST = Date.parse('2026-08-18T00:00:00Z');

interface RunOptions {
experimentId: string;
experimentName: string;
experimentDescription: string;
/** How long before {@link LATEST} the run published. */
daysAgo: number;
scores: Record<string, number>;
}

/** Fixtures are pushed through the real `toRecentExperiments`, so the stories exercise the actual
* derivation (series ordering, delta window) rather than hand-built props. */
const run = ({
experimentId,
experimentName,
experimentDescription,
daysAgo,
scores,
}: RunOptions): AgentEvaluationRow =>
({
id: `${experimentId}-${daysAgo}`,
name: `${experimentId}-run-${daysAgo}`,
workspace: 'default',
experiment_ids: [experimentId],
dataset_name: 'support-bench-v3',
experimentName,
experimentDescription,
created_at: new Date(LATEST - daysAgo * DAY_MS).toISOString(),
aggregate_scores: Object.fromEntries(
Object.entries(scores).map(([key, mean]) => [key, { mean }])
),
}) as AgentEvaluationRow;

/** A run per week going back, each evaluator drifting by a fixed step so the trend is legible. */
const weeklyRuns = (
experiment: Omit<RunOptions, 'daysAgo' | 'scores'>,
evaluators: Record<string, { from: number; step: number }>,
weeks = 8
): AgentEvaluationRow[] =>
Array.from({ length: weeks }, (_, index) =>
run({
...experiment,
daysAgo: index * 7,
scores: Object.fromEntries(
Object.entries(evaluators).map(([name, { from, step }]) => [
name,
Number((from - index * step).toFixed(3)),
])
),
})
);

const evaluations: AgentEvaluationRow[] = [
...weeklyRuns(
{
experimentId: 'exp-v2',
experimentName: 'v2 use cases',
experimentDescription:
'Dataset of early v2 use cases to support feature development ahead of the release.',
},
{ solved: { from: 0.16, step: 0.011 }, helpfulness: { from: 0.84, step: 0.02 } }
),
...weeklyRuns(
{
experimentId: 'exp-primary',
experimentName: 'Primary use cases',
experimentDescription:
'Continuously evaluate every merge to main against the full Support-Bench v3 benchmark.',
},
{
solved: { from: 0.78, step: 0.02 },
// Negative step: this one has regressed over the window, so its delta renders red and down.
'llm-judge.tone': { from: 0.75, step: -0.01 },
}
),
];

const meta: Meta<typeof RecentExperimentsPanel> = {
title: 'Studio/RecentExperimentsPanel',
component: RecentExperimentsPanel,
args: {
experiments: toRecentExperiments(evaluations),
onOpenExperiment: () => {},
onRunEvaluation: () => {},
},
decorators: [
(Story) => (
<div className="max-w-4xl">
<Story />
</div>
),
],
};

export default meta;

type Story = StoryObj<typeof RecentExperimentsPanel>;

export const Default: Story = {};

/** A brand-new experiment: one published run, so there is a score but no trend and no delta. */
export const SingleRun: Story = {
args: {
experiments: toRecentExperiments([
run({
experimentId: 'exp-primary',
experimentName: 'Primary use cases',
experimentDescription:
'Continuously evaluate every merge to main against the full Support-Bench v3 benchmark.',
daysAgo: 0,
scores: { solved: 0.78, accuracy: 0.91, 'llm-judge.tone': 0.75 },
}),
]),
},
};

export const Empty: Story = {
args: { experiments: [] },
};

export const Loading: Story = {
args: { isPending: true },
};
Loading