From 7c49725c35b6527d9497835b4d5fdd23298cbc58 Mon Sep 17 00:00:00 2001 From: Danielle Ali <44468613+dmariali@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:28:01 -0400 Subject: [PATCH 1/2] feat(studio): Coding agent - show Studio route option if one exists Signed-off-by: Danielle Ali <44468613+dmariali@users.noreply.github.com> --- .../ClaudeCodeChatThread.test.tsx | 136 +++++++ .../ClaudeCodeChatThread.tsx | 67 +++- .../studioUiNavigationSuggestions.test.ts | 69 ++++ .../studioUiNavigationSuggestions.ts | 331 ++++++++++++++++++ .../useClaudeCodeChatRuntime.test.ts | 82 ++++- .../useClaudeCodeChatRuntime.ts | 115 +++++- .../useCustomAssistantChatRuntime.test.ts | 60 ++++ .../useCustomAssistantChatRuntime.ts | 26 ++ 8 files changed, 872 insertions(+), 14 deletions(-) create mode 100644 web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.test.tsx create mode 100644 web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/studioUiNavigationSuggestions.test.ts create mode 100644 web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/studioUiNavigationSuggestions.ts diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.test.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.test.tsx new file mode 100644 index 0000000000..0f545887a5 --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.test.tsx @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ROUTES } from '@studio/constants/routes'; +import { ClaudeCodeChatThread } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread'; +import type { + ClaudeCodeChatRuntime, + StudioNavigationRequest, +} from '@studio/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime'; +import { TestProviders } from '@studio/tests/util/TestProviders'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactNode } from 'react'; +import { createMemoryRouter, generatePath, RouterProvider } from 'react-router-dom'; + +const mocks = vi.hoisted(() => ({ + resolveStudioNavigationRequest: vi.fn(), +})); + +vi.mock('@assistant-ui/react', () => ({ + AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock('@nemo/common/src/components/AssistantChat/AssistantChatThread', () => ({ + AssistantChatThread: ({ + composerOverride, + showRunningIndicator, + }: { + composerOverride?: ReactNode; + showRunningIndicator?: boolean; + }) => ( +
+ {composerOverride} +
+ ), +})); + +const WORKSPACE = 'default'; +const CHAT_PATH = generatePath(ROUTES.workspace.claudeCodeChat, { workspace: WORKSPACE }); + +const makeStudioNavigationRequest = ( + overrides?: Partial +): StudioNavigationRequest => ({ + id: 'guardrails:1', + prompt: 'Add guardrails to an agent', + suggestion: { + id: 'guardrails', + title: 'Open Guardrails', + description: 'Studio has a UI for managing NeMo Guardrails configurations.', + href: '/workspaces/default/guardrails', + }, + ...overrides, +}); + +const makeChat = (studioNavigationRequest: StudioNavigationRequest | null) => + ({ + artifacts: { selections: [], files: [], links: [], tools: [] }, + decisionChoices: [], + decisionRequest: null, + decisionStatus: 'pending', + handleReset: vi.fn(), + inputRequest: null, + inputStatus: 'pending', + isRunning: false, + loadSession: vi.fn(), + resolveDecisionRequest: vi.fn(), + resolveInputRequest: vi.fn(), + resolveStudioNavigationRequest: mocks.resolveStudioNavigationRequest, + runtime: {}, + sessionId: null, + skipDecisionRequest: vi.fn(), + skipInputRequest: vi.fn(), + studioNavigationRequest, + studioNavigationStatus: 'pending', + submitPrompt: vi.fn(), + }) as unknown as ClaudeCodeChatRuntime; + +const renderThread = (studioNavigationRequest = makeStudioNavigationRequest()) => { + const router = createMemoryRouter( + [ + { + path: ROUTES.workspace.claudeCodeChat, + element: , + }, + { path: ROUTES.workspace.guardrails, element:
}, + ], + { initialEntries: [CHAT_PATH] } + ); + + return render( + + + + ); +}; + +describe('ClaudeCodeChatThread Studio UI navigation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('offers the matching Studio UI before continuing with Claude Code', () => { + renderThread(); + + expect(screen.getByText('Studio UI available')).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /1\.\s+Open Guardrails/i })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /2\.\s+Continue in chat/i })).toBeInTheDocument(); + expect(screen.getByTestId('assistant-chat-thread')).toHaveAttribute( + 'data-show-running-indicator', + 'false' + ); + }); + + it('resolves with navigate and opens the Studio route', async () => { + const user = userEvent.setup(); + renderThread(); + + await user.click(screen.getByRole('option', { name: /1\.\s+Open Guardrails/i })); + + expect(mocks.resolveStudioNavigationRequest).toHaveBeenCalledWith('navigate'); + expect(await screen.findByTestId('guardrails-route')).toBeInTheDocument(); + }); + + it('resolves with continue when the user keeps chatting', async () => { + const user = userEvent.setup(); + renderThread(); + + await user.click(screen.getByRole('option', { name: /2\.\s+Continue in chat/i })); + + expect(mocks.resolveStudioNavigationRequest).toHaveBeenCalledWith('continue'); + expect(screen.queryByTestId('guardrails-route')).not.toBeInTheDocument(); + }); +}); diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.tsx index c18553fd1d..6924dfd02e 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.tsx @@ -4,13 +4,17 @@ import { AssistantRuntimeProvider } from '@assistant-ui/react'; import { AssistantChatThread } from '@nemo/common/src/components/AssistantChat/AssistantChatThread'; import { type AgentBlockingInputSubmission } from '@studio/components/agents/AgentBlockingInput'; -import { AgentDecisionInput } from '@studio/components/agents/AgentDecisionInput'; +import { + AgentDecisionInput, + type AgentDecisionChoice, +} from '@studio/components/agents/AgentDecisionInput'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { BlockingInputComposer } from '@studio/routes/agents/ClaudeCodeChatRoute/BlockingInputComposer'; import { ClaudeCodeStudioLink } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeStudioLink'; import { ClaudeCodeToolCallPart } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeToolCallPart'; import type { ClaudeCodeChatRuntime } from '@studio/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime'; -import { type FC, useCallback, useLayoutEffect, useRef } from 'react'; +import { type FC, useCallback, useLayoutEffect, useMemo, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; const CHAT_VIEWPORT_SCROLLBAR_CLASS = [ '[scrollbar-width:thin]', @@ -37,6 +41,7 @@ export const ClaudeCodeChatThread: FC = ({ scrollToBottomSignal, }) => { const workspace = useWorkspaceFromPath(); + const navigate = useNavigate(); const chatViewportRef = useRef(null); const { decisionChoices, @@ -47,11 +52,33 @@ export const ClaudeCodeChatThread: FC = ({ inputStatus, resolveInputRequest, resolveDecisionRequest, + resolveStudioNavigationRequest, runtime, skipInputRequest, skipDecisionRequest, + studioNavigationRequest, + studioNavigationStatus, } = chat; + const studioNavigationChoices = useMemo( + () => + studioNavigationRequest + ? [ + { + id: 'open-ui', + label: studioNavigationRequest.suggestion.title, + description: 'Use the guided Studio UI for this workflow.', + }, + { + id: 'continue-chat', + label: 'Continue in chat', + description: 'Keep working with Claude Code in this conversation.', + }, + ] + : [], + [studioNavigationRequest] + ); + const scrollViewportToBottom = useCallback(() => { const viewport = chatViewportRef.current; if (!viewport) return undefined; @@ -85,10 +112,26 @@ export const ClaudeCodeChatThread: FC = ({ [resolveInputRequest] ); + const handleStudioNavigationSubmit = useCallback( + (choice: AgentDecisionChoice) => { + const request = studioNavigationRequest; + if (!request) return; + + if (choice.id === 'open-ui') { + resolveStudioNavigationRequest('navigate'); + navigate(request.suggestion.href); + return; + } + + resolveStudioNavigationRequest('continue'); + }, + [navigate, resolveStudioNavigationRequest, studioNavigationRequest] + ); + useLayoutEffect(() => { - if (!decisionRequest && !inputRequest) return undefined; + if (!studioNavigationRequest && !decisionRequest && !inputRequest) return undefined; return scrollViewportToBottom(); - }, [decisionRequest, inputRequest, scrollViewportToBottom]); + }, [decisionRequest, inputRequest, scrollViewportToBottom, studioNavigationRequest]); useLayoutEffect(() => { if (scrollToBottomSignal === undefined) return undefined; @@ -114,14 +157,26 @@ export const ClaudeCodeChatThread: FC = ({ }} placeholder="Ask Claude Code to work in this workspace" onReset={handleChatReset} - showRunningIndicator={!decisionRequest && !inputRequest} + showRunningIndicator={!studioNavigationRequest && !decisionRequest && !inputRequest} messageContentProps={{ markdownLinkComponent: ClaudeCodeStudioLink }} emptyState={{ slotHeading: 'Start a Claude Code session', slotSubheading: 'Ask Claude Code to work in this workspace.', }} composerOverride={ - decisionRequest ? ( + studioNavigationRequest ? ( + + ) : decisionRequest ? ( { + beforeEach(() => { + mockFeatureFlags({ + agentsEnabled: true, + customizerEnabled: true, + dataDesignerEnabled: true, + datasetsEnabled: true, + deploymentsEnabled: true, + evaluatorEnabled: true, + guardrailsEnabled: true, + inferenceProviderEnabled: true, + modelCompareEnabled: true, + safeSynthesizerEnabled: true, + secretsEnabled: true, + settingsEnabled: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns the matching Studio destination for a product workflow', () => { + expect(getStudioUiNavigationSuggestion('Add guardrails to an agent', workspace)).toMatchObject({ + id: 'guardrails', + href: '/workspaces/default/guardrails', + title: 'Open Guardrails', + }); + + expect( + getStudioUiNavigationSuggestion('Generate a synthetic dataset for fine tuning', workspace) + ).toMatchObject({ + id: 'safe-synthesizer-new', + href: '/workspaces/default/safe-synthesizer/new', + }); + }); + + it('prefers agent-specific evaluation routes over general model evaluations', () => { + expect(getStudioUiNavigationSuggestion('Evaluate an agent', workspace)).toMatchObject({ + id: 'agent-evaluations', + href: '/workspaces/default/agents/evaluations', + }); + }); + + it('returns undefined when the matching feature is disabled', () => { + mockFeatureFlags({ guardrailsEnabled: false }); + + expect(getStudioUiNavigationSuggestion('Create guardrails for this agent', workspace)).toBe( + undefined + ); + }); + + it('does not interrupt ordinary coding-agent prompts', () => { + expect(getStudioUiNavigationSuggestion('Review the current working tree', workspace)).toBe( + undefined + ); + expect(getStudioUiNavigationSuggestion('Fix the settings page component', workspace)).toBe( + undefined + ); + }); +}); diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/studioUiNavigationSuggestions.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/studioUiNavigationSuggestions.ts new file mode 100644 index 0000000000..3a2cf5c31f --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/studioUiNavigationSuggestions.ts @@ -0,0 +1,331 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { featureFlags } from '@studio/constants/featureFlags'; +import type { FeatureFlags } from '@studio/constants/featureFlags/featureFlags'; +import { + getAgentEvaluationsListRoute, + getAgentMonitorRoute, + getAgentOptimizationsRoute, + getAgentsListRoute, + getDataDesignerJobListRoute, + getEvaluationResultsRoute, + getGuardrailsRoute, + getIntakeRoute, + getModelCompareRoute, + getNewDataDesignerJobRoute, + getNewFilesetRoute, + getNewSafeSynthesizerRoute, + getPromptTuningFormRoute, + getSecretsRoute, + getWorkspaceBaseModelsRoute, + getWorkspaceCustomizationJobListRoute, + getWorkspaceDeploymentsRoute, + getWorkspaceInferenceProvidersRoute, + getWorkspaceJobsRoute, + getWorkspaceMembersRoute, + getWorkspaceNewCustomizationJobRoute, + getWorkspaceSafeSynthesizerRoute, + getWorkspaceSettingsRoute, +} from '@studio/routes/utils'; + +type FeatureFlagName = keyof FeatureFlags; + +export interface StudioUiNavigationSuggestion { + id: string; + title: string; + description: string; + href: string; +} + +interface StudioUiDestination { + id: string; + title: string; + description: string; + getHref: (workspace: string) => string; + patterns: readonly RegExp[]; + requiredFeatureFlags?: readonly FeatureFlagName[]; +} + +const STUDIO_UI_DESTINATIONS: readonly StudioUiDestination[] = [ + { + id: 'safe-synthesizer-new', + title: 'Open Safe Synthesizer', + description: 'Studio has a guided UI for generating safe synthetic datasets.', + getHref: getNewSafeSynthesizerRoute, + requiredFeatureFlags: ['safeSynthesizerEnabled'], + patterns: [ + /\bsafe[-\s]?synthesizer\b/i, + /\bsynthetic (data|dataset|datasets)\b/i, + /\bsynthesi[sz]e (data|dataset|datasets)\b/i, + ], + }, + { + id: 'agent-evaluations', + title: 'Open Agent Evaluations', + description: 'Studio has a UI for submitting and reviewing agent evaluation jobs.', + getHref: getAgentEvaluationsListRoute, + requiredFeatureFlags: ['agentsEnabled'], + patterns: [ + /\bagent (eval|evaluation|evaluations)\b/i, + /\bevaluat(e|ing|ion)s? (an? )?agent\b/i, + /\brun (an? )?(eval|evaluation) (for|on) (an? )?agent\b/i, + ], + }, + { + id: 'agent-optimizations', + title: 'Open Agent Suggestions', + description: 'Studio has a UI for generating optimization suggestions for deployed agents.', + getHref: getAgentOptimizationsRoute, + requiredFeatureFlags: ['agentsEnabled'], + patterns: [ + /\boptimi[sz]e (an? )?agent\b/i, + /\b(agent|agents).*\b(cheaper|faster|smaller|right[-\s]?size)\b/i, + /\bmodel sizing\b/i, + /\bsuggestions? for (an? )?agent\b/i, + ], + }, + { + id: 'agent-monitor', + title: 'Open Agent Monitor', + description: 'Studio has a monitor UI for agent telemetry, logs, and token usage.', + getHref: getAgentMonitorRoute, + requiredFeatureFlags: ['agentsEnabled'], + patterns: [ + /\bmonitor (an? )?agent\b/i, + /\bagent (monitor|telemetry|logs|traces|usage)\b/i, + /\btoken usage\b/i, + ], + }, + { + id: 'guardrails', + title: 'Open Guardrails', + description: 'Studio has a UI for managing NeMo Guardrails configurations.', + getHref: getGuardrailsRoute, + requiredFeatureFlags: ['guardrailsEnabled'], + patterns: [ + /\bguardrails?\b/i, + /\bcontent safety\b/i, + /\bjailbreak\b/i, + /\bpii (redaction|guard|protection)\b/i, + ], + }, + { + id: 'data-designer-new', + title: 'Open Data Designer', + description: 'Studio has a Data Designer UI for creating and transforming datasets.', + getHref: getNewDataDesignerJobRoute, + requiredFeatureFlags: ['dataDesignerEnabled'], + patterns: [ + /\bdata designer\b/i, + /\bgenerate (data|dataset|datasets)\b/i, + /\bcreate (a )?(dataset|datasets)\b/i, + /\btransform (a )?(dataset|datasets)\b/i, + ], + }, + { + id: 'fileset-new', + title: 'Open Fileset Upload', + description: 'Studio has a UI for creating filesets and uploading files.', + getHref: getNewFilesetRoute, + requiredFeatureFlags: ['datasetsEnabled'], + patterns: [ + /\bcreate (a )?fileset\b/i, + /\bnew fileset\b/i, + /\bupload (a )?(file|files|dataset|datasets)\b/i, + /\bimport (a )?(file|files|dataset|datasets)\b/i, + ], + }, + { + id: 'inference-providers', + title: 'Open Inference Providers', + description: 'Studio has a UI for adding and managing inference providers.', + getHref: getWorkspaceInferenceProvidersRoute, + requiredFeatureFlags: ['inferenceProviderEnabled'], + patterns: [ + /\binference provider\b/i, + /\bmodel provider\b/i, + /\b(add|create|configure|connect|manage|register) (an? )?(provider|inference endpoint)\b/i, + /\b(connect|configure) (openai|nvidia|nim|build)\b/i, + ], + }, + { + id: 'secrets', + title: 'Open Secrets', + description: 'Studio has a UI for creating and managing workspace secrets.', + getHref: getSecretsRoute, + requiredFeatureFlags: ['secretsEnabled'], + patterns: [ + /\b(add|create|manage|store|update) (an? )?(secret|secrets)\b/i, + /\b(add|create|manage|store|update) (an? )?(api key|credential|credentials|token)\b/i, + /\bworkspace secret(s)?\b/i, + ], + }, + { + id: 'prompt-tuning', + title: 'Open Prompt Tuning', + description: 'Studio has a UI for creating prompt-tuned models.', + getHref: getPromptTuningFormRoute, + requiredFeatureFlags: ['customizerEnabled'], + patterns: [/\bprompt[-\s]?tun(e|ing)\b/i], + }, + { + id: 'fine-tuning', + title: 'Open Fine-Tuning', + description: 'Studio has a UI for creating fine-tuned custom models.', + getHref: getWorkspaceNewCustomizationJobRoute, + requiredFeatureFlags: ['customizerEnabled'], + patterns: [ + /\bfine[-\s]?tun(e|ing)\b/i, + /\bfinetun(e|ing)\b/i, + /\btrain (a )?(model|custom model)\b/i, + /\bcustomi[sz]e (a )?model\b/i, + ], + }, + { + id: 'model-playground', + title: 'Open Playground', + description: 'Studio has a playground UI for chatting with and comparing models.', + getHref: getModelCompareRoute, + requiredFeatureFlags: ['modelCompareEnabled'], + patterns: [ + /\bplayground\b/i, + /\bcompare (models|model responses)\b/i, + /\bchat with (a )?model\b/i, + ], + }, + { + id: 'model-deployments', + title: 'Open Deployments', + description: 'Studio has a UI for managing model deployments.', + getHref: getWorkspaceDeploymentsRoute, + requiredFeatureFlags: ['deploymentsEnabled'], + patterns: [ + /\bmodel deployments?\b/i, + /\bdeploy (a )?model\b/i, + /\bserve (a )?model\b/i, + /\bmodel endpoint\b/i, + ], + }, + { + id: 'base-models', + title: 'Open Base Models', + description: 'Studio has a UI for browsing base models and model details.', + getHref: getWorkspaceBaseModelsRoute, + requiredFeatureFlags: ['baseModelsEnabled'], + patterns: [ + /\bbase models?\b/i, + /\bmodel catalog\b/i, + /\bbrowse models?\b/i, + /\blist models?\b/i, + ], + }, + { + id: 'custom-models', + title: 'Open Custom Models', + description: 'Studio has a UI for managing custom models and customization jobs.', + getHref: getWorkspaceCustomizationJobListRoute, + requiredFeatureFlags: ['customizerEnabled'], + patterns: [/\bcustom models?\b/i, /\bcustomization jobs?\b/i], + }, + { + id: 'agents', + title: 'Open Agents', + description: 'Studio has a UI for viewing agents and managing their deployments.', + getHref: getAgentsListRoute, + requiredFeatureFlags: ['agentsEnabled'], + patterns: [ + /\bmanage agents?\b/i, + /\bview agents?\b/i, + /\bcreate example agent\b/i, + /\bclone (an? )?agent\b/i, + /\bchat with (an? )?agent\b/i, + /\bdeploy (an? )?agent\b/i, + ], + }, + { + id: 'evaluations', + title: 'Open Evaluations', + description: 'Studio has a UI for reviewing model evaluation results.', + getHref: getEvaluationResultsRoute, + requiredFeatureFlags: ['evaluatorEnabled'], + patterns: [ + /\bevaluat(e|ing|ion)s? (a )?model\b/i, + /\bmodel (eval|evaluation|evaluations)\b/i, + /\bevaluation results?\b/i, + ], + }, + { + id: 'safe-synthesizer', + title: 'Open Safe Synthesizer', + description: 'Studio has a UI for monitoring safe synthetic data jobs.', + getHref: getWorkspaceSafeSynthesizerRoute, + requiredFeatureFlags: ['safeSynthesizerEnabled'], + patterns: [/\bsafe synth(esizer)? jobs?\b/i], + }, + { + id: 'data-designer', + title: 'Open Data Designer', + description: 'Studio has a UI for managing Data Designer jobs.', + getHref: getDataDesignerJobListRoute, + requiredFeatureFlags: ['dataDesignerEnabled'], + patterns: [/\bdata designer jobs?\b/i], + }, + { + id: 'jobs', + title: 'Open Jobs', + description: 'Studio has a UI for viewing workspace jobs.', + getHref: getWorkspaceJobsRoute, + requiredFeatureFlags: ['jobsEnabled'], + patterns: [/\bworkspace jobs?\b/i, /\bjob history\b/i], + }, + { + id: 'annotation', + title: 'Open Annotation', + description: 'Studio has a UI for inspecting intake traces and annotations.', + getHref: getIntakeRoute, + requiredFeatureFlags: ['intakeEnabled'], + patterns: [/\bannotation\b/i, /\bintake traces?\b/i, /\btrace review\b/i], + }, + { + id: 'members', + title: 'Open Members', + description: 'Studio has a UI for managing workspace members.', + getHref: getWorkspaceMembersRoute, + requiredFeatureFlags: ['membersEnabled'], + patterns: [/\bworkspace members?\b/i, /\badd (a )?member\b/i, /\buser access\b/i], + }, + { + id: 'settings', + title: 'Open Settings', + description: 'Studio has a UI for workspace settings.', + getHref: getWorkspaceSettingsRoute, + requiredFeatureFlags: ['settingsEnabled'], + patterns: [/\bworkspace settings?\b/i, /\b(open|change|manage|update) settings\b/i], + }, +]; + +const isDestinationEnabled = (destination: StudioUiDestination): boolean => + destination.requiredFeatureFlags?.every((flag) => featureFlags[flag] !== false) ?? true; + +export const getStudioUiNavigationSuggestion = ( + prompt: string, + workspace: string +): StudioUiNavigationSuggestion | undefined => { + const trimmedPrompt = prompt.trim(); + if (!trimmedPrompt) return undefined; + + for (const destination of STUDIO_UI_DESTINATIONS) { + if (!isDestinationEnabled(destination)) continue; + if (!destination.patterns.some((pattern) => pattern.test(trimmedPrompt))) continue; + + return { + id: destination.id, + title: destination.title, + description: destination.description, + href: destination.getHref(workspace), + }; + } + + return undefined; +}; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.test.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.test.ts index 5e2e0ccae8..6ce536f5df 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.test.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.test.ts @@ -2,12 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import { useClaudeCodeChatRuntime } from '@studio/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime'; +import { mockFeatureFlags } from '@studio/tests/util/mockFeatureFlags'; import { act, renderHook, waitFor } from '@testing-library/react'; const mocks = vi.hoisted(() => ({ appendUserMessage: vi.fn(), createClaudeCodeSession: vi.fn(), invalidateQueries: vi.fn(), + prepareForUserInput: vi.fn(), resolveClaudeCodeInput: vi.fn(), resolveClaudeCodePermission: vi.fn(), streamClaudeCodeMessage: vi.fn(), @@ -24,8 +26,10 @@ vi.mock('@studio/routes/agents/ClaudeCodeChatRoute/api', () => ({ vi.mock('@studio/routes/agents/ClaudeCodeChatRoute/useCustomAssistantChatRuntime', () => ({ useCustomAssistantChatRuntime: ({ + onBeforeRun, onRun, }: { + onBeforeRun?: (context: unknown) => Promise<'continue' | 'cancel' | void>; onRun: (context: unknown) => Promise; }) => ({ appendUserMessage: mocks.appendUserMessage, @@ -35,14 +39,18 @@ vi.mock('@studio/routes/agents/ClaudeCodeChatRoute/useCustomAssistantChatRuntime replaceMessages: vi.fn(), runtime: {}, submitPrompt: async (prompt: string) => { - await onRun({ + const context = { prompt, signal: new AbortController().signal, appendAssistantParts: vi.fn(), appendAssistantText: vi.fn(), - prepareForUserInput: vi.fn(), + prepareForUserInput: mocks.prepareForUserInput, isCurrentRun: () => true, - }); + }; + const beforeRunResult = await onBeforeRun?.(context); + if (beforeRunResult !== 'cancel') { + await onRun(context); + } await mocks.submitPrompt(prompt); }, }), @@ -70,6 +78,10 @@ describe('useClaudeCodeChatRuntime', () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('exposes the latest streamed coding-agent model without promoting it to selected model', async () => { mocks.createClaudeCodeSession.mockResolvedValue('session-1'); mocks.streamClaudeCodeMessage.mockImplementation( @@ -153,6 +165,66 @@ describe('useClaudeCodeChatRuntime', () => { await waitFor(() => expect(result.current.artifacts.coding_agent_model).toBeUndefined()); }); + it('pauses for a matching Studio UI suggestion before creating a Claude Code session', async () => { + let submitPromise!: Promise; + mockFeatureFlags({ guardrailsEnabled: true }); + mocks.createClaudeCodeSession.mockResolvedValue('session-1'); + mocks.streamClaudeCodeMessage.mockResolvedValue(undefined); + + const { result } = renderUseClaudeCodeChatRuntime({ workspace: 'default' }); + + act(() => { + submitPromise = result.current.submitPrompt('Add guardrails to an agent'); + }); + + await waitFor(() => + expect(result.current.studioNavigationRequest).toEqual( + expect.objectContaining({ + prompt: 'Add guardrails to an agent', + suggestion: expect.objectContaining({ + id: 'guardrails', + href: '/workspaces/default/guardrails', + }), + }) + ) + ); + + expect(mocks.prepareForUserInput).toHaveBeenCalled(); + expect(mocks.createClaudeCodeSession).not.toHaveBeenCalled(); + + await act(async () => { + result.current.resolveStudioNavigationRequest('continue'); + await submitPromise; + }); + + expect(mocks.createClaudeCodeSession).toHaveBeenCalledTimes(1); + expect(result.current.studioNavigationRequest).toBeNull(); + }); + + it('does not start Claude Code when the user chooses the Studio UI', async () => { + let submitPromise!: Promise; + mockFeatureFlags({ guardrailsEnabled: true }); + + const { result } = renderUseClaudeCodeChatRuntime({ workspace: 'default' }); + + act(() => { + submitPromise = result.current.submitPrompt('Add guardrails to an agent'); + }); + + await waitFor(() => + expect(result.current.studioNavigationRequest?.suggestion.id).toBe('guardrails') + ); + + await act(async () => { + result.current.resolveStudioNavigationRequest('navigate'); + await submitPromise; + }); + + expect(mocks.createClaudeCodeSession).not.toHaveBeenCalled(); + expect(mocks.streamClaudeCodeMessage).not.toHaveBeenCalled(); + expect(result.current.studioNavigationRequest).toBeNull(); + }); + it('does not append denial text when permission resolution fails', async () => { const onError = vi.fn(); let finishStream!: () => void; @@ -332,7 +404,7 @@ describe('useClaudeCodeChatRuntime', () => { const { result } = renderUseClaudeCodeChatRuntime(); act(() => { - submitPromise = result.current.submitPrompt('Evaluate an agent'); + submitPromise = result.current.submitPrompt('Pick an agent for this workflow'); }); await waitFor(() => expect(result.current.inputRequest).toEqual( @@ -394,7 +466,7 @@ describe('useClaudeCodeChatRuntime', () => { const { result } = renderUseClaudeCodeChatRuntime(); act(() => { - submitPromise = result.current.submitPrompt('Evaluate an agent'); + submitPromise = result.current.submitPrompt('Pick an agent for this workflow'); }); await waitFor(() => expect(result.current.inputRequest).toEqual( diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts index 29f8d19501..3994023ef5 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts @@ -25,6 +25,10 @@ import { updateClaudeCodeChatArtifactsFromSelections, } from '@studio/routes/agents/ClaudeCodeChatRoute/artifacts'; import { getAssistantPartsFromClaudeEvent } from '@studio/routes/agents/ClaudeCodeChatRoute/stream'; +import { + getStudioUiNavigationSuggestion, + type StudioUiNavigationSuggestion, +} from '@studio/routes/agents/ClaudeCodeChatRoute/studioUiNavigationSuggestions'; import type { ClaudeCodeChatArtifacts, ClaudeCodeInputDecision, @@ -78,6 +82,14 @@ interface AskUserQuestionDecisionState { type ActiveDecisionState = PermissionDecisionState | AskUserQuestionDecisionState; +export type StudioNavigationDecision = 'continue' | 'navigate' | 'cancel'; + +export interface StudioNavigationRequest { + id: string; + prompt: string; + suggestion: StudioUiNavigationSuggestion; +} + const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; @@ -237,9 +249,16 @@ export const useClaudeCodeChatRuntime = (options?: UseClaudeCodeChatRuntimeOptio const [decisionStatus, setDecisionStatus] = useState('pending'); const [inputRequest, setInputRequest] = useState(null); const [inputStatus, setInputStatus] = useState('pending'); + const [studioNavigationRequest, setStudioNavigationRequest] = + useState(null); + const [studioNavigationStatus, setStudioNavigationStatus] = + useState('pending'); const sessionIdRef = useRef(options?.initialSessionId ?? null); const permissionRequestRef = useRef(null); const inputRequestRef = useRef(null); + const studioNavigationResolverRef = useRef<((decision: StudioNavigationDecision) => void) | null>( + null + ); const activeDecisionRef = useRef(null); const initialArtifactsRef = useRef( options?.initialArtifacts @@ -283,6 +302,20 @@ export const useClaudeCodeChatRuntime = (options?: UseClaudeCodeChatRuntimeOptio setInputStatus('pending'); }, []); + const clearStudioNavigationRequest = useCallback(() => { + studioNavigationResolverRef.current = null; + setStudioNavigationRequest(null); + setStudioNavigationStatus('pending'); + }, []); + + const resolveStudioNavigationRequest = useCallback((decision: StudioNavigationDecision) => { + const resolve = studioNavigationResolverRef.current; + if (!resolve) return; + + setStudioNavigationStatus('submitting'); + resolve(decision); + }, []); + const setAskUserQuestionDecision = useCallback( (request: ClaudeCodePermissionRequest, state: AskUserQuestionDecisionState) => { const question = state.questions[state.questionIndex]; @@ -336,12 +369,63 @@ export const useClaudeCodeChatRuntime = (options?: UseClaudeCodeChatRuntimeOptio const handleInputRequest = useCallback( (request: ClaudeCodeInputRequest) => { clearPermissionRequest(); + clearStudioNavigationRequest(); inputRequestRef.current = request; setInputStatus('pending'); setInputRequest(request); }, - [clearPermissionRequest] + [clearPermissionRequest, clearStudioNavigationRequest] + ); + + const requestStudioNavigationDecision = useCallback( + async ({ + prompt, + signal, + prepareForUserInput, + isCurrentRun, + }: { + prompt: string; + signal: AbortSignal; + prepareForUserInput: () => void; + isCurrentRun: () => boolean; + }): Promise<'continue' | 'cancel'> => { + if (!workspace) return 'continue'; + + const suggestion = getStudioUiNavigationSuggestion(prompt, workspace); + if (!suggestion) return 'continue'; + + clearPermissionRequest(); + clearInputRequest(); + prepareForUserInput(); + + let resolveDecision: (decision: StudioNavigationDecision) => void = () => undefined; + const decisionPromise = new Promise((resolve) => { + resolveDecision = resolve; + }); + studioNavigationResolverRef.current = resolveDecision; + setStudioNavigationStatus('pending'); + setStudioNavigationRequest({ + id: `${suggestion.id}:${Date.now()}`, + prompt, + suggestion, + }); + + const handleAbort = () => resolveDecision('cancel'); + signal.addEventListener('abort', handleAbort, { once: true }); + + try { + const decision = await decisionPromise; + if (!isCurrentRun()) return 'cancel'; + return decision === 'continue' ? 'continue' : 'cancel'; + } finally { + signal.removeEventListener('abort', handleAbort); + if (studioNavigationResolverRef.current === resolveDecision) { + clearStudioNavigationRequest(); + } + } + }, + [clearInputRequest, clearPermissionRequest, clearStudioNavigationRequest, workspace] ); const { @@ -353,10 +437,12 @@ export const useClaudeCodeChatRuntime = (options?: UseClaudeCodeChatRuntimeOptio submitPrompt, } = useCustomAssistantChatRuntime({ initialMessages: options?.initialMessages, + onBeforeRun: requestStudioNavigationDecision, onError, onRun: async ({ prompt, signal, appendAssistantParts, prepareForUserInput, isCurrentRun }) => { clearPermissionRequest(); clearInputRequest(); + clearStudioNavigationRequest(); const activeSessionId = await ensureSessionId(); let doneReceived = false; @@ -559,8 +645,18 @@ export const useClaudeCodeChatRuntime = (options?: UseClaudeCodeChatRuntimeOptio setArtifacts(createWorkspaceArtifacts(undefined, workspace)); clearPermissionRequest(); clearInputRequest(); + resolveStudioNavigationRequest('cancel'); + clearStudioNavigationRequest(); resetThread(); - }, [clearInputRequest, clearPermissionRequest, onSessionIdChange, resetThread, workspace]); + }, [ + clearInputRequest, + clearPermissionRequest, + clearStudioNavigationRequest, + onSessionIdChange, + resetThread, + resolveStudioNavigationRequest, + workspace, + ]); const loadSession = useCallback( ({ @@ -574,9 +670,19 @@ export const useClaudeCodeChatRuntime = (options?: UseClaudeCodeChatRuntimeOptio setArtifacts(createWorkspaceArtifacts(nextArtifacts, workspace)); clearPermissionRequest(); clearInputRequest(); + resolveStudioNavigationRequest('cancel'); + clearStudioNavigationRequest(); replaceMessages(messages); }, - [clearInputRequest, clearPermissionRequest, onSessionIdChange, replaceMessages, workspace] + [ + clearInputRequest, + clearPermissionRequest, + clearStudioNavigationRequest, + onSessionIdChange, + replaceMessages, + resolveStudioNavigationRequest, + workspace, + ] ); return { @@ -591,10 +697,13 @@ export const useClaudeCodeChatRuntime = (options?: UseClaudeCodeChatRuntimeOptio loadSession, resolveInputRequest, resolveDecisionRequest, + resolveStudioNavigationRequest, runtime, sessionId, skipInputRequest, skipDecisionRequest, + studioNavigationRequest, + studioNavigationStatus, submitPrompt, }; }; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useCustomAssistantChatRuntime.test.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useCustomAssistantChatRuntime.test.ts index 5c412397c4..b73ff2e711 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useCustomAssistantChatRuntime.test.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useCustomAssistantChatRuntime.test.ts @@ -8,6 +8,7 @@ import { CLAUDE_CODE_SUBTLE_TOOL_GROUP_NAME, } from '@studio/routes/agents/ClaudeCodeChatRoute/toolParts'; import { + type CustomAssistantBeforeRunContext, type CustomAssistantRunContext, useCustomAssistantChatRuntime, } from '@studio/routes/agents/ClaudeCodeChatRoute/useCustomAssistantChatRuntime'; @@ -45,6 +46,65 @@ describe('useCustomAssistantChatRuntime', () => { mocks.useExternalStoreRuntime.mockClear(); }); + it('pauses before running and resumes when the before-run hook continues', async () => { + let continueRun!: () => void; + const onBeforeRun = vi.fn(async (context: CustomAssistantBeforeRunContext) => { + context.prepareForUserInput(); + await new Promise((resolve) => { + continueRun = resolve; + }); + return 'continue' as const; + }); + const onRun = vi.fn(async (context: CustomAssistantRunContext) => { + context.appendAssistantText('Continuing in chat.'); + }); + const { result } = renderHook(() => useCustomAssistantChatRuntime({ onBeforeRun, onRun })); + + act(() => { + void result.current.submitPrompt('Add guardrails'); + }); + + await waitFor(() => { + expect(onBeforeRun).toHaveBeenCalledWith( + expect.objectContaining({ prompt: 'Add guardrails' }) + ); + expect(onRun).not.toHaveBeenCalled(); + expect(getMockRuntime(result.current.runtime).messages.map(getMessageText)).toEqual([ + 'Add guardrails', + ]); + }); + + await act(async () => { + continueRun(); + }); + + await waitFor(() => { + expect(onRun).toHaveBeenCalled(); + expect(getMockRuntime(result.current.runtime).messages.map(getMessageText)).toEqual([ + 'Add guardrails', + 'Continuing in chat.', + ]); + }); + }); + + it('does not run when the before-run hook cancels', async () => { + const onBeforeRun = vi.fn((context: CustomAssistantBeforeRunContext) => { + context.prepareForUserInput(); + return 'cancel' as const; + }); + const onRun = vi.fn(); + const { result } = renderHook(() => useCustomAssistantChatRuntime({ onBeforeRun, onRun })); + + await act(async () => { + await result.current.submitPrompt('Open guardrails'); + }); + + expect(onRun).not.toHaveBeenCalled(); + expect(getMockRuntime(result.current.runtime).messages.map(getMessageText)).toEqual([ + 'Open guardrails', + ]); + }); + it('shows user interventions between agent messages', async () => { let runContext: CustomAssistantRunContext | undefined; const onRun = vi.fn(async (context: CustomAssistantRunContext) => { diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useCustomAssistantChatRuntime.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useCustomAssistantChatRuntime.ts index 01212c7952..8e2e3b3ff1 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useCustomAssistantChatRuntime.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useCustomAssistantChatRuntime.ts @@ -40,8 +40,20 @@ export interface CustomAssistantRunResult { text?: string; } +export type CustomAssistantBeforeRunResult = 'continue' | 'cancel'; + +export interface CustomAssistantBeforeRunContext { + prompt: string; + signal: AbortSignal; + prepareForUserInput: () => void; + isCurrentRun: () => boolean; +} + interface UseCustomAssistantChatRuntimeOptions { initialMessages?: readonly ThreadMessageLike[]; + onBeforeRun?: ( + context: CustomAssistantBeforeRunContext + ) => Promise | CustomAssistantBeforeRunResult | void; onRun: (context: CustomAssistantRunContext) => Promise; onError?: (error: Error) => void; } @@ -107,6 +119,7 @@ const completeClaudeCodeAssistantContent = ( export const useCustomAssistantChatRuntime = ({ initialMessages = [], + onBeforeRun, onRun, onError, }: UseCustomAssistantChatRuntimeOptions) => { @@ -242,6 +255,18 @@ export const useCustomAssistantChatRuntime = ({ }; try { + const beforeRunResult = await onBeforeRun?.({ + prompt, + signal: runController.signal, + prepareForUserInput, + isCurrentRun, + }); + + if (beforeRunResult === 'cancel' || runController.signal.aborted || !isCurrentRun()) { + completeActiveAssistantMessage(CANCELLED_STATUS); + return; + } + const result = await onRun({ prompt, signal: runController.signal, @@ -287,6 +312,7 @@ export const useCustomAssistantChatRuntime = ({ }, [ completeAssistantMessageContent, + onBeforeRun, onError, onRun, setThreadMessages, From dcc0dbf86bf25226d1bdd9b60ad487b70680955e Mon Sep 17 00:00:00 2001 From: Danielle Ali <44468613+dmariali@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:54:17 -0400 Subject: [PATCH 2/2] harden the suggestion logic! Signed-off-by: Danielle Ali <44468613+dmariali@users.noreply.github.com> --- .../studioUiNavigationSuggestions.test.ts | 88 +++++++++++++++++-- .../studioUiNavigationSuggestions.ts | 43 ++++++--- 2 files changed, 115 insertions(+), 16 deletions(-) diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/studioUiNavigationSuggestions.test.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/studioUiNavigationSuggestions.test.ts index acf256f5c5..5b3987ff5a 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/studioUiNavigationSuggestions.test.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/studioUiNavigationSuggestions.test.ts @@ -17,6 +17,8 @@ describe('getStudioUiNavigationSuggestion', () => { evaluatorEnabled: true, guardrailsEnabled: true, inferenceProviderEnabled: true, + intakeEnabled: true, + jobsEnabled: true, modelCompareEnabled: true, safeSynthesizerEnabled: true, secretsEnabled: true, @@ -43,6 +45,67 @@ describe('getStudioUiNavigationSuggestion', () => { }); }); + it('keeps navigation shortcuts when prompts include Studio product context', () => { + const cases = [ + { + prompt: 'Review model sizing for an agent', + id: 'agent-optimizations', + }, + { + prompt: 'Show agent token usage', + id: 'agent-monitor', + }, + { + prompt: 'Manage workspace secrets', + id: 'secrets', + }, + { + prompt: 'Open model playground', + id: 'model-playground', + }, + { + prompt: 'Show workspace job history', + id: 'jobs', + }, + { + prompt: 'Review intake annotations', + id: 'annotation', + }, + { + prompt: 'Open workspace settings', + id: 'settings', + }, + { + prompt: 'Use the guardrails-plugin skill to debug guardrail middleware', + id: 'guardrails', + }, + { + prompt: 'Use the inference skill to configure inference in this workspace', + id: 'inference-providers', + }, + { + prompt: 'Use the nemo-build-agent skill to build an agent', + id: 'agents', + }, + { + prompt: 'Use the nemo-evaluator skill to review eval history', + id: 'evaluations', + }, + { + prompt: 'Use the safe-synthesizer skill to generate safety data', + id: 'safe-synthesizer-new', + }, + { + prompt: 'Create a data generation workflow', + id: 'data-designer-new', + }, + ]; + + for (const { prompt, id } of cases) { + expect(getStudioUiNavigationSuggestion(prompt, workspace)).toMatchObject({ id }); + } + }); + it('prefers agent-specific evaluation routes over general model evaluations', () => { expect(getStudioUiNavigationSuggestion('Evaluate an agent', workspace)).toMatchObject({ id: 'agent-evaluations', @@ -59,11 +122,24 @@ describe('getStudioUiNavigationSuggestion', () => { }); it('does not interrupt ordinary coding-agent prompts', () => { - expect(getStudioUiNavigationSuggestion('Review the current working tree', workspace)).toBe( - undefined - ); - expect(getStudioUiNavigationSuggestion('Fix the settings page component', workspace)).toBe( - undefined - ); + const prompts = [ + 'Review the current working tree', + 'Fix the settings page component', + 'Update settings page component', + 'Analyze token usage in this parser', + 'Add annotation support to the chart', + 'Open the playground component', + 'Create token validation helpers', + 'Tune model sizing calculations in this file', + 'Review job history component', + 'Build an agent class for this test helper', + 'Configure inference in this TypeScript module', + 'Generate synthetic test data in fixtures', + 'Review evaluator plugin imports', + ]; + + for (const prompt of prompts) { + expect(getStudioUiNavigationSuggestion(prompt, workspace)).toBe(undefined); + } }); }); diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/studioUiNavigationSuggestions.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/studioUiNavigationSuggestions.ts index 3a2cf5c31f..1d099f5939 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/studioUiNavigationSuggestions.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/studioUiNavigationSuggestions.ts @@ -58,6 +58,8 @@ const STUDIO_UI_DESTINATIONS: readonly StudioUiDestination[] = [ /\bsafe[-\s]?synthesizer\b/i, /\bsynthetic (data|dataset|datasets)\b/i, /\bsynthesi[sz]e (data|dataset|datasets)\b/i, + /\bgenerate (safety[-\s]?focused|safe|synthetic) (data|dataset|datasets)\b/i, + /\bsafety data\b/i, ], }, { @@ -70,6 +72,7 @@ const STUDIO_UI_DESTINATIONS: readonly StudioUiDestination[] = [ /\bagent (eval|evaluation|evaluations)\b/i, /\bevaluat(e|ing|ion)s? (an? )?agent\b/i, /\brun (an? )?(eval|evaluation) (for|on) (an? )?agent\b/i, + /\b(agent|agents).*\b(eval|evaluation|evaluations) jobs?\b/i, ], }, { @@ -81,7 +84,8 @@ const STUDIO_UI_DESTINATIONS: readonly StudioUiDestination[] = [ patterns: [ /\boptimi[sz]e (an? )?agent\b/i, /\b(agent|agents).*\b(cheaper|faster|smaller|right[-\s]?size)\b/i, - /\bmodel sizing\b/i, + /\b(agent|agents).*\bmodel sizing\b/i, + /\bmodel sizing (for|on|of) (an? )?agent\b/i, /\bsuggestions? for (an? )?agent\b/i, ], }, @@ -94,7 +98,8 @@ const STUDIO_UI_DESTINATIONS: readonly StudioUiDestination[] = [ patterns: [ /\bmonitor (an? )?agent\b/i, /\bagent (monitor|telemetry|logs|traces|usage)\b/i, - /\btoken usage\b/i, + /\b(agent|agents).*\btoken usage\b/i, + /\btoken usage (for|on|of) (an? )?agent\b/i, ], }, { @@ -108,6 +113,7 @@ const STUDIO_UI_DESTINATIONS: readonly StudioUiDestination[] = [ /\bcontent safety\b/i, /\bjailbreak\b/i, /\bpii (redaction|guard|protection)\b/i, + /\bguardrail middleware\b/i, ], }, { @@ -118,9 +124,10 @@ const STUDIO_UI_DESTINATIONS: readonly StudioUiDestination[] = [ requiredFeatureFlags: ['dataDesignerEnabled'], patterns: [ /\bdata designer\b/i, - /\bgenerate (data|dataset|datasets)\b/i, + /\bgenerate (synthetic )?(data|dataset|datasets)\b/i, /\bcreate (a )?(dataset|datasets)\b/i, /\btransform (a )?(dataset|datasets)\b/i, + /\bdata generation (workflow|pipeline)\b/i, ], }, { @@ -145,6 +152,8 @@ const STUDIO_UI_DESTINATIONS: readonly StudioUiDestination[] = [ patterns: [ /\binference provider\b/i, /\bmodel provider\b/i, + /\bconfigure inference (for|in) (this )?workspace\b/i, + /\bconfigure (a )?ne?mo inference\b/i, /\b(add|create|configure|connect|manage|register) (an? )?(provider|inference endpoint)\b/i, /\b(connect|configure) (openai|nvidia|nim|build)\b/i, ], @@ -156,9 +165,9 @@ const STUDIO_UI_DESTINATIONS: readonly StudioUiDestination[] = [ getHref: getSecretsRoute, requiredFeatureFlags: ['secretsEnabled'], patterns: [ - /\b(add|create|manage|store|update) (an? )?(secret|secrets)\b/i, - /\b(add|create|manage|store|update) (an? )?(api key|credential|credentials|token)\b/i, - /\bworkspace secret(s)?\b/i, + /\b(add|create|manage|store|update) (an? )?(workspace )?(secret|secrets)\b/i, + /\b(add|create|manage|store|update) (an? )?(api key|credential|credentials|token) (secret|secrets|in (the )?workspace|for (this )?workspace)\b/i, + /\bworkspace (api key|credential|credentials|token|secret|secrets)\b/i, ], }, { @@ -189,7 +198,8 @@ const STUDIO_UI_DESTINATIONS: readonly StudioUiDestination[] = [ getHref: getModelCompareRoute, requiredFeatureFlags: ['modelCompareEnabled'], patterns: [ - /\bplayground\b/i, + /\bmodel playground\b/i, + /\bopen (the )?playground (for|with) (a )?model\b/i, /\bcompare (models|model responses)\b/i, /\bchat with (a )?model\b/i, ], @@ -237,10 +247,12 @@ const STUDIO_UI_DESTINATIONS: readonly StudioUiDestination[] = [ patterns: [ /\bmanage agents?\b/i, /\bview agents?\b/i, + /\b(build|create) (an? )?agent\b(?!\s+(class|component|helper|test|function|module))\b/i, /\bcreate example agent\b/i, /\bclone (an? )?agent\b/i, /\bchat with (an? )?agent\b/i, /\bdeploy (an? )?agent\b/i, + /\btry (a )?deployed agent\b/i, ], }, { @@ -253,6 +265,10 @@ const STUDIO_UI_DESTINATIONS: readonly StudioUiDestination[] = [ /\bevaluat(e|ing|ion)s? (a )?model\b/i, /\bmodel (eval|evaluation|evaluations)\b/i, /\bevaluation results?\b/i, + /\b(eval|evaluation) history\b/i, + /\bnemo[-\s]?evaluator\b/i, + /\bevaluator (jobs?|sdk specs?)\b/i, + /\buse (the )?evaluator plugin\b/i, ], }, { @@ -277,7 +293,7 @@ const STUDIO_UI_DESTINATIONS: readonly StudioUiDestination[] = [ description: 'Studio has a UI for viewing workspace jobs.', getHref: getWorkspaceJobsRoute, requiredFeatureFlags: ['jobsEnabled'], - patterns: [/\bworkspace jobs?\b/i, /\bjob history\b/i], + patterns: [/\bworkspace jobs?\b/i, /\bworkspace job history\b/i], }, { id: 'annotation', @@ -285,7 +301,11 @@ const STUDIO_UI_DESTINATIONS: readonly StudioUiDestination[] = [ description: 'Studio has a UI for inspecting intake traces and annotations.', getHref: getIntakeRoute, requiredFeatureFlags: ['intakeEnabled'], - patterns: [/\bannotation\b/i, /\bintake traces?\b/i, /\btrace review\b/i], + patterns: [ + /\bintake (annotation|annotations|traces?|trace review)\b/i, + /\b(annotation|annotations) (for|in|on) (intake|trace|traces)\b/i, + /\b(trace|traces).*\b(annotation|annotations|review)\b/i, + ], }, { id: 'members', @@ -301,7 +321,10 @@ const STUDIO_UI_DESTINATIONS: readonly StudioUiDestination[] = [ description: 'Studio has a UI for workspace settings.', getHref: getWorkspaceSettingsRoute, requiredFeatureFlags: ['settingsEnabled'], - patterns: [/\bworkspace settings?\b/i, /\b(open|change|manage|update) settings\b/i], + patterns: [ + /\bworkspace settings?\b/i, + /\b(open|change|manage|update) (the )?settings (for|in|of) (this )?workspace\b/i, + ], }, ];