-
- {panels.map((panel) => (
-
- ))}
+
+
+ {panels.map((panel) => (
+
+ ))}
+
+ {gutter}
+ {slotComposer && (
+
+
{slotComposer}
+ {/* Invisible gutter copy keeps the composer's right edge aligned to the panels. */}
+
+
+ )}
);
};
diff --git a/web/packages/studio/src/components/ModelComparePrompts/index.tsx b/web/packages/studio/src/components/ModelComparePrompts/index.tsx
index 5c352804fb..dbc73764dd 100644
--- a/web/packages/studio/src/components/ModelComparePrompts/index.tsx
+++ b/web/packages/studio/src/components/ModelComparePrompts/index.tsx
@@ -1,18 +1,29 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
+import type { ModelWorkspaceGroup } from '@nemo/common/src/api/models/useModels';
import { ModelSelectV2, type ModelSelection } from '@nemo/common/src/components/ModelSelectV2';
+import { UploadModal } from '@nemo/common/src/components/UploadModal';
+import type { SubmitUploadType } from '@nemo/common/src/components/UploadModal/types';
import { useChatCompletion } from '@nemo/common/src/hooks/useChatCompletion';
import { getPartsFromReference } from '@nemo/common/src/namedEntity';
-import { resolveKeyPath } from '@nemo/common/src/utils/file';
-import { groupModelsByWorkspace } from '@nemo/common/src/utils/models';
+import { FileFormat, InputFileSchemaType } from '@nemo/common/src/types';
+import { extractUserFriendlyKeysFromRow, resolveKeyPath } from '@nemo/common/src/utils/file';
+import { detectFileStructure, validateFileFormat } from '@nemo/common/src/utils/fileValidation';
import { type FileSampleMethod, sampleIndices } from '@nemo/common/src/utils/sampleTextLines';
-import type { ModelEntity } from '@nemo/sdk/generated/platform/schema';
-import { Button, Flex, Modal, Stack, Text } from '@nvidia/foundations-react-core';
-import { DatasetInputFile, type DatasetInputFileResult } from '@studio/components/DatasetInputFile';
+import { filesDownloadFile } from '@nemo/sdk/generated/platform/api';
+import { Button, Flex, Modal, Select, Text, Tooltip } from '@nvidia/foundations-react-core';
+import { SAMPLE_DATASETS } from '@studio/components/chat/sampleDatasets';
+import { StatsBadge } from '@studio/components/chat/StatsBadge';
+import type { DatasetInputFileResult } from '@studio/components/DatasetInputFile';
import { FileSamplingMethodSelect } from '@studio/components/FileSamplingSnippet/FileSamplingMethodSelect';
-import type { SharedModelEntry } from '@studio/routes/ModelCompareRoute/types';
-import { Loader2, Maximize2, Play, Trash2 } from 'lucide-react';
+import {
+ PANEL_ROLE_COLORS,
+ PANEL_ROLE_DOT_CLASS,
+ PANEL_ROLE_LABELS,
+ type SharedModelEntry,
+} from '@studio/routes/ModelCompareRoute/types';
+import { Maximize2, Plus, Trash2 } from 'lucide-react';
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
const DEFAULT_SAMPLE_SIZE = 5;
@@ -20,18 +31,37 @@ const DEFAULT_SAMPLE_SIZE = 5;
/** Number of inference requests to run concurrently; the rest queue. */
const INFERENCE_BATCH_SIZE = 10;
+/** Sentinel item values for the dataset picker. */
+const UPLOADED_FILE_VALUE = '__uploaded__';
+const FILESET_PICKER_VALUE = '__fileset_picker__';
+
+interface ResponseStats {
+ /** Wall-clock time from request fire to response, in ms. */
+ totalMs: number;
+ /** From `usage.completion_tokens` when the gateway returns it; otherwise estimated from text length. */
+ completionTokens: number;
+ /** Derived: completionTokens / (totalMs / 1000). */
+ tokensPerSec: number;
+}
+
+interface ResponseResult {
+ text: string;
+ stats: ResponseStats;
+}
+
interface PromptRow {
/** Index in the parsed dataset. */
sourceIndex: number;
/** Resolved prompt text */
prompt: string;
- /** Model id -> response text (null = error, undefined = not yet run) */
- responses: Record
;
+ /** Model id -> response data (null = error, undefined = not yet run) */
+ responses: Record;
}
interface ExpandedCellState {
title: string;
content: string;
+ stats?: ResponseStats;
}
/** Builds prompt rows from parsed dataset rows using the shared sampling controls. */
@@ -62,25 +92,98 @@ function buildPromptRowsFromParsedRows(
return rows;
}
+/**
+ * Inline upload parser. Mirrors `DatasetInputFile`'s file path but runs without
+ * its full validation UI — errors surface as a small inline banner under the
+ * picker. We can't reuse `DatasetInputFile` here because we want a single
+ * dropdown that owns both sample selection and upload.
+ */
+async function parseUploadedFile(file: File): Promise {
+ const validation = await validateFileFormat(file);
+ if (!validation.isValid || !validation.format) {
+ return { error: validation.error ?? 'Invalid file format' };
+ }
+ const detection = await detectFileStructure(file, validation.format);
+ const text = await file.text();
+ let parsedRows: Record[];
+ try {
+ if (validation.format === FileFormat.JSONL) {
+ parsedRows = text
+ .trim()
+ .split('\n')
+ .filter((line) => line.length > 0)
+ .map((line) => JSON.parse(line) as Record);
+ } else {
+ const parsed: unknown = JSON.parse(text);
+ parsedRows = Array.isArray(parsed)
+ ? (parsed as Record[])
+ : [parsed as Record];
+ }
+ } catch (err) {
+ return { error: err instanceof Error ? err.message : 'Failed to parse file contents' };
+ }
+ if (parsedRows.length === 0) {
+ return { error: 'File contains no rows' };
+ }
+ const firstRow = (detection?.firstRow as Record | undefined) ?? parsedRows[0];
+ const availableKeys = firstRow ? extractUserFriendlyKeysFromRow(firstRow) : [];
+
+ // Auto-detect prompt key: prefer the detector's answer, then fall back to common keys.
+ let promptKey: string | null = null;
+ if (detection?.schemaType === InputFileSchemaType.COMPLETION) {
+ promptKey = detection.detectedFields.prompt ?? null;
+ } else if (detection?.schemaType === InputFileSchemaType.CHAT_COMPLETION) {
+ promptKey = detection.detectedMessages.user?.selector ?? null;
+ }
+ if (!promptKey) {
+ const candidates = ['prompt', 'question', 'input', 'text'];
+ promptKey = candidates.find((k) => typeof firstRow[k] === 'string') ?? null;
+ }
+ // If detection couldn't find a prompt column we still return the parsed file
+ // (with `promptKey: null`) so the inline column picker can let the user choose.
+ return {
+ fileUrl: `upload://${file.name}`,
+ format: validation.format,
+ validationResult: validation,
+ detectionResult: detection,
+ availableKeys,
+ keyMapping: { promptKey, completionKey: null, idealResponseKey: null },
+ firstRow,
+ parsedRows,
+ rowCount: parsedRows.length,
+ };
+}
+
interface ModelComparePromptsProps {
workspace: string;
- availableModels: ModelEntity[];
+ modelGroups: ModelWorkspaceGroup[];
isLoadingModels: boolean;
models: SharedModelEntry[];
onRemoveModel: (id: number) => void;
onSetModel: (id: number, modelURN: string | null) => void;
/** Called when the view's readiness to add models changes (i.e. file is loaded with a valid prompt key) */
onReadyChange?: (ready: boolean) => void;
+ /** Called when the user clicks the Add Model button. Omit to hide the button. */
+ onAddModel?: () => void;
+ /**
+ * When set, default-select the matching `SAMPLE_DATASETS` entry on mount so
+ * the user lands on the agent's golden-prompts dataset without a click.
+ * Matching is by id equality (e.g. agent name "calculator-agent" matches the
+ * "calculator-agent" sample). Other samples remain pickable.
+ */
+ agentName?: string | null;
}
export const ModelComparePrompts: FC = ({
workspace,
- availableModels,
+ modelGroups,
isLoadingModels,
models,
onRemoveModel,
onSetModel,
onReadyChange,
+ agentName,
+ onAddModel,
}) => {
const [fileResult, setFileResult] = useState(null);
const [promptRows, setPromptRows] = useState([]);
@@ -88,25 +191,46 @@ export const ModelComparePrompts: FC = ({
const [sampleSize, setSampleSize] = useState(DEFAULT_SAMPLE_SIZE);
const [sampleMethod, setSampleMethod] = useState('random');
const [expandedCell, setExpandedCell] = useState(null);
-
+ const [pickerValue, setPickerValue] = useState(undefined);
+ // Bumped to remount the dataset Select after the "Select from dataset file..."
+ // sentinel is chosen, so the action can be retriggered (re-selecting the same
+ // option otherwise fires no change event).
+ const [pickerSelectKey, setPickerSelectKey] = useState(0);
+ const [uploadedFileName, setUploadedFileName] = useState(null);
+ const [parseError, setParseError] = useState(null);
+ const [isFilesetPickerOpen, setIsFilesetPickerOpen] = useState(false);
+ // True when the loaded file's prompt column was auto-detected. In that case
+ // we hide the manual column picker; we only surface it when detection failed.
+ const [promptKeyAutoDetected, setPromptKeyAutoDetected] = useState(false);
const { mutateAsync: createCompletion } = useChatCompletion();
- // Monotonic run id. Incremented when a run starts and when prompts
- // change, so any in-flight run that finishes later checks runIdRef before
- // writing results and drops the update if it's stale.
+ // Monotonic run id. Incremented on invalidation; guards stale writeCell calls.
const runIdRef = useRef(0);
+ // AbortController for the active run; aborted when a new run starts,
+ // dataset/sampling changes, or the component unmounts.
+ const runAbortRef = useRef(null);
const rowCount = fileResult?.rowCount ?? 0;
const handleFileChange = useCallback((result: DatasetInputFileResult | null) => {
- runIdRef.current += 1; // invalidate any in-flight run
+ runIdRef.current += 1;
+ runAbortRef.current?.abort();
setFileResult(result);
setPromptRows([]);
+ setPromptKeyAutoDetected(result?.keyMapping.promptKey != null);
if (result) {
setSampleSize(Math.min(DEFAULT_SAMPLE_SIZE, result.rowCount || DEFAULT_SAMPLE_SIZE));
}
}, []);
+ // Override the auto-detected prompt column. Updating `keyMapping.promptKey`
+ // triggers the row-rebuild effect below; fresh rows clear stale responses.
+ const handlePromptKeyChange = useCallback((key: string) => {
+ setFileResult((prev) =>
+ prev ? { ...prev, keyMapping: { ...prev.keyMapping, promptKey: key } } : prev
+ );
+ }, []);
+
/**
* Clear cached inference responses. If `columnId` is provided, only that
* column's responses are cleared (e.g. when a new model is picked for the
@@ -143,16 +267,20 @@ export const ModelComparePrompts: FC = ({
runIdRef.current += 1;
const myRunId = runIdRef.current;
+ runAbortRef.current?.abort();
+ const runController = new AbortController();
+ runAbortRef.current = runController;
+
setIsRunning(true);
clearResponses();
- // Writes a single cell's response, but only if this run is still current.
- const writeCell = (sourceIndex: number, modelId: number, content: string | null) => {
+ // Writes a single cell's result, but only if this run is still current.
+ const writeCell = (sourceIndex: number, modelId: number, result: ResponseResult | null) => {
if (runIdRef.current !== myRunId) return;
setPromptRows((prev) =>
prev.map((row) =>
row.sourceIndex === sourceIndex
- ? { ...row, responses: { ...row.responses, [modelId]: content } }
+ ? { ...row, responses: { ...row.responses, [modelId]: result } }
: row
)
);
@@ -163,44 +291,108 @@ export const ModelComparePrompts: FC = ({
const taskFactories: Array<() => Promise> = [];
snapshotActiveModels.forEach((model) => {
snapshotPromptRows.forEach((row) => {
- taskFactories.push(() =>
- createCompletion({
+ taskFactories.push(() => {
+ const startTime = performance.now();
+ return createCompletion({
model: model.name,
workspace: model.modelWorkspace || workspace,
messages: [{ role: 'user', content: row.prompt }],
stream: false,
+ signal: runController.signal,
})
.then((result) => {
+ const totalMs = performance.now() - startTime;
const content =
result && 'choices' in result
? (result.choices[0]?.message?.content ?? null)
: null;
- writeCell(row.sourceIndex, model.id, content);
+ if (content === null) {
+ writeCell(row.sourceIndex, model.id, null);
+ return;
+ }
+ const usage = result && 'usage' in result ? result.usage : undefined;
+ // Fallback estimate: ~4 chars per token. Good enough for the badge when
+ // the gateway elides usage stats.
+ const completionTokens =
+ usage?.completion_tokens ?? Math.max(1, Math.round(content.length / 4));
+ const tokensPerSec = totalMs > 0 ? completionTokens / (totalMs / 1000) : 0;
+ writeCell(row.sourceIndex, model.id, {
+ text: content,
+ stats: { totalMs, completionTokens, tokensPerSec },
+ });
})
.catch((error) => {
console.error('Inference request failed:', error);
writeCell(row.sourceIndex, model.id, null);
- })
- );
+ });
+ });
});
});
// Run tasks in capped-size batches so we don't flood the gateway.
- for (let i = 0; i < taskFactories.length; i += INFERENCE_BATCH_SIZE) {
- if (runIdRef.current !== myRunId) break; // stale run: stop firing more
- const batch = taskFactories.slice(i, i + INFERENCE_BATCH_SIZE).map((fn) => fn());
- await Promise.allSettled(batch);
- }
-
- if (runIdRef.current === myRunId) {
- setIsRunning(false);
+ try {
+ for (let i = 0; i < taskFactories.length; i += INFERENCE_BATCH_SIZE) {
+ if (runController.signal.aborted) break;
+ const batch = taskFactories.slice(i, i + INFERENCE_BATCH_SIZE).map((fn) => fn());
+ await Promise.allSettled(batch);
+ }
+ } finally {
+ if (runAbortRef.current === runController) {
+ runAbortRef.current = null;
+ setIsRunning(false);
+ }
}
}, [models, promptRows, workspace, createCompletion, clearResponses]);
+ // Cancel an in-flight run without clearing results. Bumping the run id makes
+ // any writes from aborted (rejected) requests no-op, so completed cells keep
+ // their results and still-pending cells stay blank. A later Run clears all.
+ const cancelRun = useCallback(() => {
+ runIdRef.current += 1;
+ runAbortRef.current?.abort();
+ runAbortRef.current = null;
+ setIsRunning(false);
+ }, []);
+
const hasPromptKey = fileResult?.keyMapping.promptKey != null;
const hasAssignedModel = models.some((m) => m.modelURN !== null);
const hasPrompts = promptRows.length > 0;
+ /**
+ * Per-column averages across all completed responses. `tokensPerSec` is
+ * weighted (sum tokens / sum seconds) rather than a mean-of-means so short
+ * responses don't over-influence the rate. Returns null for columns with
+ * zero completed responses so the footer can render an em-dash.
+ */
+ const averagesByModelId = useMemo(() => {
+ const result: Record = {};
+ models.forEach((m) => {
+ let totalMs = 0;
+ let totalTokens = 0;
+ let count = 0;
+ promptRows.forEach((row) => {
+ const r = row.responses[m.id];
+ if (!r) return;
+ totalMs += r.stats.totalMs;
+ totalTokens += r.stats.completionTokens;
+ count += 1;
+ });
+ if (count === 0) {
+ result[m.id] = null;
+ return;
+ }
+ result[m.id] = {
+ totalMs: totalMs / count,
+ completionTokens: totalTokens / count,
+ tokensPerSec: totalMs > 0 ? totalTokens / (totalMs / 1000) : 0,
+ count,
+ };
+ });
+ return result;
+ }, [models, promptRows]);
+
+ const anyAverages = Object.values(averagesByModelId).some((a) => a !== null);
+
// Notify parent when readiness changes. "Ready" means the table is active
// (file is loaded and has a valid prompt key mapped).
const isReady = !!fileResult && hasPromptKey;
@@ -208,149 +400,373 @@ export const ModelComparePrompts: FC = ({
onReadyChange?.(isReady);
}, [isReady, onReadyChange]);
+ // Abort any active run on unmount (e.g. tab switch, navigation).
+ useEffect(() => {
+ return () => {
+ runAbortRef.current?.abort();
+ };
+ }, []);
+
// Drive the prompt table from parsed preview rows + sampling controls (no separate file preview).
useEffect(() => {
if (!fileResult?.keyMapping.promptKey || !fileResult.parsedRows?.length) return;
runIdRef.current += 1;
+ runAbortRef.current?.abort();
setPromptRows(buildPromptRowsFromParsedRows(fileResult, sampleSize, sampleMethod));
}, [fileResult, sampleSize, sampleMethod]);
+ // Auto-select the agent's matching sample when the user lands on Run Prompts
+ // via the agent overlay. Tracks the last-auto-selected agent in a ref so we
+ // don't re-fire after the user clears the picker or picks a different file.
+ const autoSelectedAgentRef = useRef(null);
+ useEffect(() => {
+ if (!agentName) {
+ autoSelectedAgentRef.current = null;
+ return;
+ }
+ if (autoSelectedAgentRef.current === agentName) return;
+ const match = SAMPLE_DATASETS.find((s) => s.id === agentName);
+ if (!match) return;
+ autoSelectedAgentRef.current = agentName;
+ setPickerValue(match.id);
+ setUploadedFileName(null);
+ setParseError(null);
+ handleFileChange(match.build());
+ // We intentionally re-run only on `agentName` change. Including
+ // `handleFileChange` (or the various setters) would re-fire this effect
+ // every time the parent re-renders and produce a seed loop — the agentRef
+ // guard above would still no-op the work, but the effect would still run
+ // and we want the dependencies to read true.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [agentName]);
+
+ /**
+ * Single picker handler. Three branches:
+ * - sample id → synthesize the result via `sample.build()` (in-memory)
+ * - upload sentinel → click the hidden native file input
+ * - uploaded sentinel → no-op (it's the displayed value after a successful upload)
+ */
+ const handleDatasetSelect = useCallback(
+ (value: string) => {
+ if (!value) return;
+ if (value === UPLOADED_FILE_VALUE) return;
+ if (value === FILESET_PICKER_VALUE) {
+ setIsFilesetPickerOpen(true);
+ setPickerSelectKey((k) => k + 1);
+ return;
+ }
+ const sample = SAMPLE_DATASETS.find((s) => s.id === value);
+ if (!sample) return;
+ setParseError(null);
+ setUploadedFileName(null);
+ setPickerValue(value);
+ handleFileChange(sample.build());
+ },
+ [handleFileChange]
+ );
+
+ const handleFilesetPickerSubmit = useCallback(
+ async (data: SubmitUploadType) => {
+ if (data.type !== 'dataset') return;
+ setIsFilesetPickerOpen(false);
+ setParseError(null);
+ try {
+ // `data.url` is a `fileset://` URI, not an HTTP URL — download via the
+ // SDK using the dataset's workspace/name and the file path.
+ const response = await filesDownloadFile(
+ data.dataset.workspace,
+ data.dataset.name,
+ data.path
+ );
+ if (!response) {
+ setParseError('Failed to download file');
+ return;
+ }
+ const text = await response.text();
+ const filename = data.path.split('/').pop() ?? 'dataset.json';
+ const file = new File([text], filename);
+ const result = await parseUploadedFile(file);
+ if ('error' in result) {
+ setParseError(result.error);
+ return;
+ }
+ setUploadedFileName(`${data.dataset.name}/${data.path}`);
+ setPickerValue(UPLOADED_FILE_VALUE);
+ handleFileChange(result);
+ } catch (err) {
+ setParseError(err instanceof Error ? err.message : 'Failed to load file');
+ }
+ },
+ [handleFileChange]
+ );
+
+ const datasetItems = useMemo(() => {
+ const items: { value: string; children: string }[] = SAMPLE_DATASETS.map((s) => ({
+ value: s.id,
+ children: s.label,
+ }));
+ if (uploadedFileName) {
+ items.push({ value: UPLOADED_FILE_VALUE, children: uploadedFileName });
+ }
+ items.push({ value: FILESET_PICKER_VALUE, children: 'Select from dataset file...' });
+ return items;
+ }, [uploadedFileName]);
+
return (
-
-
-
-
-
- {fileResult && hasPromptKey && (
-
-
-
- )}
+
{/* Results table fills remaining height; this is the main vertical scroll region. */}
-
-
-
-
- {models.map((m) => (
-
- ))}
-
-
-
- |
-
- Prompts
- {fileResult && hasPromptKey && (
- |
+ );
+ })}
+
+ {hasPrompts && anyAverages && (
+
+
+ |
+ Average
+ |
+ {models.map((m, idx) => {
+ const avg = averagesByModelId[m.id];
+ return (
+
+ {avg ? (
+
+ ) : (
+
+ —
+
+ )}
+ |
+ );
+ })}
+
+
+ )}
+
+
+ {onAddModel && (
+
+ )}
+
setIsFilesetPickerOpen(false)}
+ onSubmit={handleFilesetPickerSubmit}
+ />
+
{
@@ -359,7 +775,8 @@ export const ModelComparePrompts: FC = ({
slotHeading={expandedCell?.title ?? 'Cell Content'}
className="w-[90vw] max-w-[1000px]"
slotFooter={
-
+
+ {expandedCell?.stats ? : }
setExpandedCell(null)}>
Close
@@ -381,9 +798,11 @@ const ExpandableCell: FC<{
content: string;
title: string;
onExpand: (state: ExpandedCellState) => void;
-}> = ({ content, title, onExpand }) => {
+ footer?: React.ReactNode;
+ boldContent?: boolean;
+}> = ({ content, title, onExpand, footer, boldContent }) => {
return (
-
+
onExpand({ title, content })}
className="absolute right-1 top-1 z-10 cursor-pointer rounded bg-surface-base/80 p-1 opacity-0 hover:bg-surface-sunken group-hover:opacity-100"
@@ -392,23 +811,26 @@ const ExpandableCell: FC<{
-
+
{content}
+ {footer &&
{footer}
}
);
};
/** Thin wrapper around ModelSelectV2 for table header use */
const ModelColumnSelect: FC<{
- models: ModelEntity[];
+ modelGroups: ModelWorkspaceGroup[];
isLoadingModels: boolean;
value: string | null;
disabled?: boolean;
onChange: (ref: string) => void;
-}> = ({ models, isLoadingModels, value, disabled, onChange }) => {
- const modelGroups = useMemo(() => groupModelsByWorkspace(models, { sort: true }), [models]);
+}> = ({ modelGroups, isLoadingModels, value, disabled, onChange }) => {
const selectedModel: ModelSelection | null = value ? { model: value } : null;
const handleValueChange = useCallback(
@@ -425,7 +847,6 @@ const ModelColumnSelect: FC<{
groups={modelGroups}
loading={isLoadingModels}
disabled={disabled}
- placeholder={isLoadingModels ? 'Loading models...' : 'Select model...'}
hideAdapters
fullWidth
/>
diff --git a/web/packages/studio/src/components/chat/AgentContextBanner.tsx b/web/packages/studio/src/components/chat/AgentContextBanner.tsx
new file mode 100644
index 0000000000..6da9c9783b
--- /dev/null
+++ b/web/packages/studio/src/components/chat/AgentContextBanner.tsx
@@ -0,0 +1,28 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { Banner } from '@nvidia/foundations-react-core';
+import type { FC } from 'react';
+
+interface AgentContextBannerProps {
+ agentName: string;
+ baselineModelUrn: string | null;
+}
+
+/**
+ * Uses Kaizen's `Banner` with `info` status so the styling is design-system
+ * native — no hand-rolled blue. The Apply action lives in the page-level CTA
+ * cluster, not on the banner itself, to avoid two adjacent CTAs and to keep
+ * the banner purely informational.
+ */
+export const AgentContextBanner: FC
= ({
+ agentName,
+ baselineModelUrn,
+}) => {
+ return (
+
+ Testing models for agent {agentName}. Baseline is locked to{' '}
+ {baselineModelUrn ?? '—'}.
+
+ );
+};
diff --git a/web/packages/studio/src/components/chat/AgentPicker.tsx b/web/packages/studio/src/components/chat/AgentPicker.tsx
new file mode 100644
index 0000000000..78d5f48138
--- /dev/null
+++ b/web/packages/studio/src/components/chat/AgentPicker.tsx
@@ -0,0 +1,47 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { useAgentsListAgents } from '@nemo/sdk/generated/agents/api';
+import { Select } from '@nvidia/foundations-react-core';
+import { useMemo, type FC } from 'react';
+
+interface AgentPickerProps {
+ workspace: string;
+ /** Currently selected agent name from `?agent=`, or null. */
+ value: string | null;
+ /** Pass null to clear the selection. */
+ onChange: (next: string | null) => void;
+ disabled?: boolean;
+}
+
+const NO_AGENT_VALUE = '__none__';
+
+/**
+ * Lists deployed agents in the workspace and writes the selection back via
+ * `onChange` (which the route maps to `setSearchParams`). The "(no agent)"
+ * option clears the overlay and falls back to plain Chat.
+ */
+export const AgentPicker: FC = ({ workspace, value, onChange, disabled }) => {
+ const { data, isLoading } = useAgentsListAgents(workspace, undefined, {
+ query: { enabled: !!workspace },
+ });
+
+ const items = useMemo(() => {
+ const agents = (data?.data ?? []).filter((a) => !!a.name);
+ return [
+ { value: NO_AGENT_VALUE, children: '(no agent — plain Chat)' },
+ ...agents.map((a) => ({ value: a.name as string, children: a.name as string })),
+ ];
+ }, [data]);
+
+ return (
+