Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
}) => (
<div
data-testid="assistant-chat-thread"
data-show-running-indicator={String(showRunningIndicator)}
>
{composerOverride}
</div>
),
}));

const WORKSPACE = 'default';
const CHAT_PATH = generatePath(ROUTES.workspace.claudeCodeChat, { workspace: WORKSPACE });

const makeStudioNavigationRequest = (
overrides?: Partial<StudioNavigationRequest>
): 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: <ClaudeCodeChatThread chat={makeChat(studioNavigationRequest)} />,
},
{ path: ROUTES.workspace.guardrails, element: <div data-testid="guardrails-route" /> },
],
{ initialEntries: [CHAT_PATH] }
);

return render(
<TestProviders>
<RouterProvider router={router} />
</TestProviders>
);
};

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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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]',
Expand All @@ -37,6 +41,7 @@ export const ClaudeCodeChatThread: FC<ClaudeCodeChatThreadProps> = ({
scrollToBottomSignal,
}) => {
const workspace = useWorkspaceFromPath();
const navigate = useNavigate();
const chatViewportRef = useRef<HTMLDivElement>(null);
const {
decisionChoices,
Expand All @@ -47,11 +52,33 @@ export const ClaudeCodeChatThread: FC<ClaudeCodeChatThreadProps> = ({
inputStatus,
resolveInputRequest,
resolveDecisionRequest,
resolveStudioNavigationRequest,
runtime,
skipInputRequest,
skipDecisionRequest,
studioNavigationRequest,
studioNavigationStatus,
} = chat;

const studioNavigationChoices = useMemo<readonly AgentDecisionChoice[]>(
() =>
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;
Expand Down Expand Up @@ -85,10 +112,26 @@ export const ClaudeCodeChatThread: FC<ClaudeCodeChatThreadProps> = ({
[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;
Expand All @@ -114,14 +157,26 @@ export const ClaudeCodeChatThread: FC<ClaudeCodeChatThreadProps> = ({
}}
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 ? (
<AgentDecisionInput
request={{
id: studioNavigationRequest.id,
title: 'Studio UI available',
description: `${studioNavigationRequest.suggestion.description} Open it now or continue with Claude Code.`,
}}
choices={studioNavigationChoices}
defaultChoiceId="open-ui"
status={studioNavigationStatus}
onSubmit={handleStudioNavigationSubmit}
/>
) : decisionRequest ? (
<AgentDecisionInput
request={decisionRequest}
choices={decisionChoices}
Expand Down
Loading
Loading