Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions web/packages/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"feature-flags": "tsx scripts/feature-flag-matrix.ts"
},
"dependencies": {
"@assistant-ui/react": "^0.12.28",
"@hookform/resolvers": "^4.1.3",
"@mui/x-charts": "catalog:",
"@mui/x-data-grid": "catalog:",
Expand Down
1 change: 1 addition & 0 deletions web/packages/studio/src/constants/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export const ROUTES = {
/** Workspace members and role-based access (Entities role bindings) */
members: `/workspaces/:${P.workspace}/members`,
agentsList: `/workspaces/:${P.workspace}/agents`,
claudeCodeChat: `/workspaces/:${P.workspace}/dashboard/code-agent`,
agentDetail: `/workspaces/:${P.workspace}/agents/:${P.agentName}`,
agentDeploymentsList: `/workspaces/:${P.workspace}/agent-deployments`,
agentDeploymentDetail: `/workspaces/:${P.workspace}/agent-deployments/:${P.agentDeploymentName}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,29 @@ import { DashboardLandingRoute } from '@studio/routes/DashboardLandingRoute';
import { TestProviders } from '@studio/tests/util/TestProviders';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createMemoryRouter, generatePath, RouterProvider } from 'react-router';
import { createMemoryRouter, generatePath, RouterProvider, useLocation } from 'react-router';

const workspace = 'default';
const CHAT_ROUTE_TEST_ID = 'chat-route';

const ChatRouteProbe = () => {
const location = useLocation();
const state = location.state as { initialPrompt?: string } | null;

return (
<div data-testid={CHAT_ROUTE_TEST_ID}>
{location.pathname}|{state?.initialPrompt}
</div>
);
};

const renderRoute = () => {
const route = generatePath(ROUTES.workspace.dashboard, { workspace });
const router = createMemoryRouter(
[{ path: ROUTES.workspace.dashboard, element: <DashboardLandingRoute /> }],
[
{ path: ROUTES.workspace.dashboard, element: <DashboardLandingRoute /> },
{ path: ROUTES.workspace.claudeCodeChat, element: <ChatRouteProbe /> },
],
{
initialEntries: [route],
}
Expand Down Expand Up @@ -63,4 +78,16 @@ describe('DashboardLandingRoute', () => {
expect(screen.getByRole('button', { name: 'Send message' })).toBeEnabled();
});
});

it('navigates to Claude Code chat with the submitted prompt', async () => {
const user = userEvent.setup();
renderRoute();

await user.type(await screen.findByRole('textbox', { name: 'Message Claude' }), 'Check repo');
await user.click(screen.getByRole('button', { name: 'Send message' }));

expect(await screen.findByTestId(CHAT_ROUTE_TEST_ID)).toHaveTextContent(
`${generatePath(ROUTES.workspace.claudeCodeChat, { workspace })}|Check repo`
);
});
});
22 changes: 21 additions & 1 deletion web/packages/studio/src/routes/DashboardLandingRoute/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@

import { Button, Card, Flex, Text, TextArea, Tooltip } from '@nvidia/foundations-react-core';
import { AccessibleTitle } from '@studio/components/AccessibleTitle';
import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs';
import type { ClaudeCodeChatRouteState } from '@studio/routes/agents/ClaudeCodeChatRoute/types';
import { getClaudeCodeChatRoute } from '@studio/routes/utils';
import { GitBranch, Hammer, Search, Send, Terminal } from 'lucide-react';
import {
type ChangeEvent,
Expand All @@ -13,6 +16,7 @@ import {
useCallback,
useState,
} from 'react';
import { useNavigate } from 'react-router-dom';

interface PromptSuggestion {
title: string;
Expand Down Expand Up @@ -63,12 +67,18 @@ const PromptCard = ({
const LandingComposer = ({
input,
onChange,
onSubmit,
}: {
input: string;
onChange: (value: string) => void;
onSubmit: (prompt: string) => void;
}) => {
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const prompt = input.trim();
if (!prompt) return;

onSubmit(prompt);
};

return (
Expand Down Expand Up @@ -107,6 +117,8 @@ const LandingComposer = ({
};

export const DashboardLandingRoute: FC = () => {
const workspace = useWorkspaceFromPath();
const navigate = useNavigate();
const [input, setInput] = useState('');

useBreadcrumbs({
Expand All @@ -117,6 +129,14 @@ export const DashboardLandingRoute: FC = () => {
setInput(prompt);
}, []);

const handleSubmit = useCallback(
(prompt: string) => {
const state: ClaudeCodeChatRouteState = { initialPrompt: prompt };
navigate(getClaudeCodeChatRoute(workspace), { state });
},
[navigate, workspace]
);

return (
<AccessibleTitle title="Dashboard">
<main className="flex h-full min-h-[calc(100vh-var(--nv-app-bar-height))] items-center justify-center bg-surface-sunken px-4 py-10 text-primary">
Expand All @@ -127,7 +147,7 @@ export const DashboardLandingRoute: FC = () => {
</Text>
</Flex>

<LandingComposer input={input} onChange={setInput} />
<LandingComposer input={input} onChange={setInput} onSubmit={handleSubmit} />

<Flex className="grid w-full grid-cols-1 gap-3 md:grid-cols-3">
{PROMPT_SUGGESTIONS.map((suggestion) => (
Expand Down
126 changes: 126 additions & 0 deletions web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { PLATFORM_BASE_URL } from '@studio/constants/environment';
import { parseJsonObject, parseSseChunk } from '@studio/routes/agents/ClaudeCodeChatRoute/stream';
import type { ClaudeCodeStreamHandlers } from '@studio/routes/agents/ClaudeCodeChatRoute/types';

const CLAUDE_CODE_API_BASE_PATH = '/apis/studio/v2/coding-agents';

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;

const claudeCodeApiUrl = (path: string): string =>
`${PLATFORM_BASE_URL}${CLAUDE_CODE_API_BASE_PATH}${path}`;

const getResponseErrorMessage = async (response: Response, fallback: string): Promise<string> => {
const text = await response.text();
if (!text) return fallback;

try {
const body = JSON.parse(text) as unknown;
if (isRecord(body) && typeof body.detail === 'string') return body.detail;
} catch {
return text;
}

return text;
};

export const createClaudeCodeSession = async (): Promise<string> => {
const response = await fetch(claudeCodeApiUrl('/sessions'), {
method: 'POST',
});

if (!response.ok) {
throw new Error(
await getResponseErrorMessage(response, 'Failed to create Claude Code session')
);
}

const body = (await response.json()) as unknown;
if (!isRecord(body) || typeof body.session_id !== 'string') {
throw new Error('Claude Code session response did not include a session id');
}

return body.session_id;
};

const getStreamErrorMessage = (payload: unknown): string => {
if (!isRecord(payload)) return 'Claude Code stream failed';
if (typeof payload.stderr === 'string' && payload.stderr) return payload.stderr;
if (typeof payload.detail === 'string' && payload.detail) return payload.detail;
if (typeof payload.message === 'string' && payload.message) return payload.message;
return 'Claude Code stream failed';
};

const handleSseEvent = (
event: { event?: string; data: string },
handlers: ClaudeCodeStreamHandlers
): boolean => {
if (event.event === 'done') {
handlers.onDone();
return true;
}

if (event.event === 'error') {
handlers.onError(new Error(getStreamErrorMessage(parseJsonObject(event.data))));
return false;
}

handlers.onClaudeEvent(parseJsonObject(event.data));
return true;
};

export const streamClaudeCodeMessage = async ({
sessionId,
message,
signal,
handlers,
}: {
sessionId: string;
message: string;
signal: AbortSignal;
handlers: ClaudeCodeStreamHandlers;
}): Promise<void> => {
const response = await fetch(claudeCodeApiUrl(`/sessions/${sessionId}/messages`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message }),
signal,
});

if (!response.ok) {
throw new Error(await getResponseErrorMessage(response, 'Failed to send Claude Code message'));
}
if (!response.body) {
throw new Error('Claude Code response did not include a stream');
}

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffered = '';

while (true) {
const { done, value } = await reader.read();
if (done) break;

buffered += decoder.decode(value, { stream: true });
const parsed = parseSseChunk(buffered);
buffered = parsed.rest;

for (const event of parsed.events) {
if (!handleSseEvent(event, handlers)) return;
}
}

buffered += decoder.decode();
if (buffered) {
const parsed = parseSseChunk(`${buffered}\n\n`);
for (const event of parsed.events) {
if (!handleSseEvent(event, handlers)) return;
}
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { AssistantRuntimeProvider } from '@assistant-ui/react';
import { AssistantChatThread } from '@nemo/common/src/components/AssistantChat/AssistantChatThread';
import { useToast } from '@nemo/common/src/providers/toast/useToast';
import { Stack } from '@nvidia/foundations-react-core';
import { AccessibleTitle } from '@studio/components/AccessibleTitle';
import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs';
import type { ClaudeCodeChatRouteState } from '@studio/routes/agents/ClaudeCodeChatRoute/types';
import { useClaudeCodeChatRuntime } from '@studio/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime';
import { getWorkspaceDashboardRoute } from '@studio/routes/utils';
import { type FC, useEffect, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';

const getInitialPrompt = (state: unknown): string | undefined => {
if (typeof state !== 'object' || state === null) return undefined;

const initialPrompt = (state as ClaudeCodeChatRouteState).initialPrompt;
if (typeof initialPrompt !== 'string') return undefined;

const trimmedPrompt = initialPrompt.trim();
return trimmedPrompt || undefined;
};

export const ClaudeCodeChatRoute: FC = () => {
const workspace = useWorkspaceFromPath();
const location = useLocation();
const navigate = useNavigate();
const toast = useToast();
const consumedInitialPromptRef = useRef<string | undefined>(undefined);
const { handleReset, runtime, submitPrompt } = useClaudeCodeChatRuntime({
onError: (error) => toast.error(error.message),
});
const initialPrompt = getInitialPrompt(location.state);

useBreadcrumbs({
items: [
{ slotLabel: 'Dashboard', href: getWorkspaceDashboardRoute(workspace) },
{ slotLabel: 'Code Agent' },
],
});

useEffect(() => {
if (!initialPrompt || consumedInitialPromptRef.current === initialPrompt) return;

consumedInitialPromptRef.current = initialPrompt;
navigate(location.pathname, { replace: true, state: null });
void submitPrompt(initialPrompt);
}, [initialPrompt, location.pathname, navigate, submitPrompt]);

return (
<AccessibleTitle title={`Code Agent chat for ${workspace}`}>
<Stack className="h-full" padding="density-2xl">
<Stack className="mx-auto min-h-0 w-full max-w-180 flex-1">
<AssistantRuntimeProvider runtime={runtime}>
<AssistantChatThread
placeholder="Ask Claude Code to work in this workspace"
onReset={handleReset}
emptyState={{
slotHeading: 'Start a Claude Code session',
slotSubheading: 'Ask Claude Code to work in this workspace.',
}}
/>
</AssistantRuntimeProvider>
</Stack>
</Stack>
</AccessibleTitle>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import {
getAssistantTextFromClaudeEvent,
parseSseChunk,
} from '@studio/routes/agents/ClaudeCodeChatRoute/stream';

describe('Claude Code stream utilities', () => {
it('parses SSE events and preserves incomplete trailing data', () => {
const parsed = parseSseChunk(
[
'data: {"type":"assistant"}',
'',
'event: custom_event',
'data: {"request_id":"req-1"}',
'',
'event: don',
].join('\n')
);

expect(parsed.events).toEqual([
{ event: undefined, data: '{"type":"assistant"}' },
{ event: 'custom_event', data: '{"request_id":"req-1"}' },
]);
expect(parsed.rest).toBe('event: don');
});

it('extracts assistant text and tool summaries from Claude Code events', () => {
expect(
getAssistantTextFromClaudeEvent({
type: 'assistant',
message: {
content: [
{ type: 'text', text: 'I can check that.' },
{ type: 'tool_use', name: 'Bash' },
],
},
})
).toBe('I can check that.\n\nUsing Bash...');
});
});
Loading
Loading