diff --git a/web/packages/common/src/components/AssistantChat/AssistantChatThread.tsx b/web/packages/common/src/components/AssistantChat/AssistantChatThread.tsx index 89b850b6fc..edc1d43d94 100644 --- a/web/packages/common/src/components/AssistantChat/AssistantChatThread.tsx +++ b/web/packages/common/src/components/AssistantChat/AssistantChatThread.tsx @@ -13,10 +13,12 @@ import { Button, Flex, Skeleton, + Stack, Text, TextArea, Tooltip, } from '@nvidia/foundations-react-core'; +import cn from 'classnames'; import { Check, Copy, Pencil, RefreshCw, RotateCcw, Send, Square, X } from 'lucide-react'; import { ChatEmptyState } from '../Chat/ChatEmptyState'; @@ -30,6 +32,9 @@ interface AssistantChatThreadProps { slotHeading?: string; slotSubheading?: string; }; + contentClassName?: string; + composerContainerClassName?: string; + viewportClassName?: string; } const AssistantChatTextPart: TextMessagePartComponent = ({ text }) => ( @@ -165,8 +170,25 @@ const UserEditComposer = () => ( ); -const AssistantComposer = ({ disabled, placeholder, onReset }: AssistantChatThreadProps) => ( - +type AssistantComposerProps = Pick< + AssistantChatThreadProps, + 'disabled' | 'placeholder' | 'onReset' +> & { + className?: string; +}; + +const AssistantComposer = ({ + disabled, + placeholder, + onReset, + className, +}: AssistantComposerProps) => ( + ( - - - + + + + + - - + Scroll to bottom - + + + ); diff --git a/web/packages/studio/src/routes/DashboardLandingRoute/index.spec.tsx b/web/packages/studio/src/routes/DashboardLandingRoute/index.spec.tsx index 7567f9af77..ebbfe7d6ab 100644 --- a/web/packages/studio/src/routes/DashboardLandingRoute/index.spec.tsx +++ b/web/packages/studio/src/routes/DashboardLandingRoute/index.spec.tsx @@ -8,6 +8,16 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createMemoryRouter, generatePath, RouterProvider, useLocation } from 'react-router'; +vi.mock('@studio/routes/agents/ClaudeCodeChatRoute/api', async (importOriginal) => { + const actual = + await importOriginal(); + + return { + ...actual, + listClaudeCodeHistorySessions: vi.fn(async () => []), + }; +}); + const workspace = 'default'; const CHAT_ROUTE_TEST_ID = 'chat-route'; diff --git a/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx b/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx index 64cf1c538a..dafda100af 100644 --- a/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx +++ b/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx @@ -1,10 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { GradientBackground } from '@nemo/common/src/components/GradientBackground'; 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 { ClaudeCodeLayout } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeLayout'; import type { ClaudeCodeChatRouteState } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; import { getClaudeCodeChatRoute } from '@studio/routes/utils'; import { GitBranch, Hammer, Search, Send, Terminal } from 'lucide-react'; @@ -138,28 +140,32 @@ export const DashboardLandingRoute: FC = () => { ); return ( - -
- - - - What would you like to do? - - + + + +
+ + + + What would you like to do? + + - + - - {PROMPT_SUGGESTIONS.map((suggestion) => ( - handlePromptSelect(suggestion.prompt)} - /> - ))} - - -
-
+ + {PROMPT_SUGGESTIONS.map((suggestion) => ( + handlePromptSelect(suggestion.prompt)} + /> + ))} + +
+
+ +
+ ); }; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.tsx new file mode 100644 index 0000000000..86b1dc92d6 --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.tsx @@ -0,0 +1,256 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + Banner, + Button, + Flex, + Skeleton, + Stack, + Text, + Tooltip, +} from '@nvidia/foundations-react-core'; +import { Empty } from '@studio/components/Empty'; +import { + CLAUDE_CODE_HISTORY_SESSIONS_QUERY_KEY, + listClaudeCodeHistorySessions, +} from '@studio/routes/agents/ClaudeCodeChatRoute/api'; +import type { ClaudeCodeHistorySession } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import { useLocalStorage } from '@studio/util/hooks/useLocalStorage'; +import { CLAUDE_CODE_HISTORY_OPEN_KEY } from '@studio/util/localStorage'; +import { useQuery } from '@tanstack/react-query'; +import cn from 'classnames'; +import { + History, + MessageSquare, + MessageSquarePlus, + PanelRightClose, + PanelRightOpen, + RefreshCw, + Wrench, +} from 'lucide-react'; +import { type FC } from 'react'; + +interface ClaudeCodeHistoryPanelProps { + activeSessionId?: string; + onNewChat: () => void; + onSelectSession: (sessionId: string) => void; +} + +const getCompactRelativeTime = (mtime: number): string => { + const elapsedMs = Math.max(Date.now() - mtime * 1000, 0); + const minuteMs = 60 * 1000; + const hourMs = 60 * minuteMs; + const dayMs = 24 * hourMs; + + if (elapsedMs < minuteMs) return 'now'; + if (elapsedMs < hourMs) return `${Math.floor(elapsedMs / minuteMs)}m`; + if (elapsedMs < dayMs) return `${Math.floor(elapsedMs / hourMs)}h`; + + const days = Math.floor(elapsedMs / dayMs); + if (days < 31) return `${days}d`; + + return new Date(mtime * 1000).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + }); +}; + +const HistoryPanelSkeleton = () => ( + + + + + +); + +const ToolCallSummary = ({ toolCalls }: { toolCalls: string[] }) => { + if (!toolCalls.length) return null; + + return ( + + + + {toolCalls.join(', ')} + + + ); +}; + +const HistorySessionButton = ({ + active, + onSelect, + session, +}: { + active: boolean; + onSelect: () => void; + session: ClaudeCodeHistorySession; +}) => ( + +); + +interface HistoryPanelContentsProps extends ClaudeCodeHistoryPanelProps { + collapseLabel: string; + onCollapse: () => void; +} + +const HistoryPanelContents = ({ + activeSessionId, + collapseLabel, + onCollapse, + onNewChat, + onSelectSession, +}: HistoryPanelContentsProps) => { + const { + data: sessions = [], + error, + isLoading, + refetch, + } = useQuery({ + queryKey: CLAUDE_CODE_HISTORY_SESSIONS_QUERY_KEY, + queryFn: listClaudeCodeHistorySessions, + }); + + return ( + <> + + + + + Claude history + + + + + + + + + + + +
+ +
+ {error && ( +
+ + Could not load Claude history. + +
+ )} + {isLoading ? ( + + ) : sessions.length ? ( +
+ {sessions.map((session) => ( + onSelectSession(session.session_id)} + /> + ))} +
+ ) : !error ? ( + + + + ) : null} + + ); +}; + +export const ClaudeCodeHistoryPanel: FC = (props) => { + const [historyOpen, setHistoryOpen] = useLocalStorage(CLAUDE_CODE_HISTORY_OPEN_KEY, 'true'); + const isOpen = historyOpen !== 'false'; + const toggleLabel = isOpen ? 'Collapse Claude history' : 'Expand Claude history'; + + if (!isOpen) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeLayout.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeLayout.tsx new file mode 100644 index 0000000000..862320a988 --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeLayout.tsx @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Flex } from '@nvidia/foundations-react-core'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { ClaudeCodeHistoryPanel } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel'; +import { getClaudeCodeChatRouteForSession } from '@studio/routes/agents/ClaudeCodeChatRoute/util'; +import { getClaudeCodeChatRoute } from '@studio/routes/utils'; +import { type FC, type ReactNode, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; + +interface ClaudeCodeLayoutProps { + activeSessionId?: string; + children: ReactNode; +} + +export const ClaudeCodeLayout: FC = ({ activeSessionId, children }) => { + const workspace = useWorkspaceFromPath(); + const navigate = useNavigate(); + + const handleNewChat = useCallback(() => { + navigate(getClaudeCodeChatRoute(workspace)); + }, [navigate, workspace]); + + const handleSelectSession = useCallback( + (sessionId: string) => { + navigate(getClaudeCodeChatRouteForSession(workspace, sessionId)); + }, + [navigate, workspace] + ); + + return ( + + {children} + + + ); +}; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts index 0312690669..6f81df1dc9 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts @@ -3,7 +3,13 @@ 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'; +import type { + ClaudeCodeAssistantHistoryPart, + ClaudeCodeHistorySession, + ClaudeCodeSessionHistory, + ClaudeCodeSessionHistoryItem, + ClaudeCodeStreamHandlers, +} from '@studio/routes/agents/ClaudeCodeChatRoute/types'; const CLAUDE_CODE_API_BASE_PATH = '/apis/studio/v2/coding-agents'; @@ -13,6 +19,15 @@ const isRecord = (value: unknown): value is Record => const claudeCodeApiUrl = (path: string): string => `${PLATFORM_BASE_URL}${CLAUDE_CODE_API_BASE_PATH}${path}`; +export const CLAUDE_CODE_HISTORY_SESSIONS_QUERY_KEY = [ + 'claude-code', + 'history', + 'sessions', +] as const; + +export const getClaudeCodeSessionHistoryQueryKey = (sessionId: string) => + ['claude-code', 'history', 'session', sessionId] as const; + const getResponseErrorMessage = async (response: Response, fallback: string): Promise => { const text = await response.text(); if (!text) return fallback; @@ -46,6 +61,113 @@ export const createClaudeCodeSession = async (): Promise => { return body.session_id; }; +const getString = (value: unknown): string => (typeof value === 'string' ? value : ''); + +const getNumber = (value: unknown): number => + typeof value === 'number' && Number.isFinite(value) ? value : 0; + +const getStringArray = (value: unknown): string[] => + Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; + +const parseHistorySession = (value: unknown): ClaudeCodeHistorySession | undefined => { + if (!isRecord(value)) return undefined; + const sessionId = getString(value.session_id); + if (!sessionId) return undefined; + + return { + session_id: sessionId, + mtime: getNumber(value.mtime), + first_prompt: getString(value.first_prompt), + message_count: getNumber(value.message_count), + token_count: getNumber(value.token_count), + tool_call_count: getNumber(value.tool_call_count), + tool_calls: getStringArray(value.tool_calls), + }; +}; + +const parseAssistantPart = (value: unknown): ClaudeCodeAssistantHistoryPart | undefined => { + if (!isRecord(value)) return undefined; + + if (value.type === 'text') { + const text = getString(value.text); + return text ? { type: 'text', text } : undefined; + } + + if (value.type === 'thinking') { + const thinking = getString(value.thinking); + return thinking ? { type: 'thinking', thinking } : undefined; + } + + if (value.type === 'tool_use') { + return { + type: 'tool_use', + name: getString(value.name) || 'tool', + input: isRecord(value.input) ? value.input : {}, + }; + } + + return undefined; +}; + +const parseSessionHistoryItem = (value: unknown): ClaudeCodeSessionHistoryItem | undefined => { + if (!isRecord(value)) return undefined; + + if (value.kind === 'user') { + const text = getString(value.text); + return text ? { kind: 'user', text } : undefined; + } + + if (value.kind === 'assistant' && Array.isArray(value.parts)) { + const parts = value.parts + .map(parseAssistantPart) + .filter((part): part is ClaudeCodeAssistantHistoryPart => part !== undefined); + return parts.length ? { kind: 'assistant', parts } : undefined; + } + + return undefined; +}; + +export const listClaudeCodeHistorySessions = async (): Promise => { + const response = await fetch(claudeCodeApiUrl('/history/sessions')); + + if (!response.ok) { + throw new Error(await getResponseErrorMessage(response, 'Failed to load Claude Code history')); + } + + const body = (await response.json()) as unknown; + if (!Array.isArray(body)) return []; + + return body + .map(parseHistorySession) + .filter((session): session is ClaudeCodeHistorySession => session !== undefined); +}; + +export const getClaudeCodeSessionHistory = async ( + sessionId: string +): Promise => { + const response = await fetch( + claudeCodeApiUrl(`/history/sessions/${encodeURIComponent(sessionId)}`) + ); + + if (!response.ok) { + throw new Error(await getResponseErrorMessage(response, 'Failed to load Claude Code session')); + } + + const body = (await response.json()) as unknown; + if (!isRecord(body)) { + throw new Error('Claude Code session history response was not an object'); + } + + return { + session_id: getString(body.session_id) || sessionId, + items: Array.isArray(body.items) + ? body.items + .map(parseSessionHistoryItem) + .filter((item): item is ClaudeCodeSessionHistoryItem => item !== undefined) + : [], + }; +}; + const getStreamErrorMessage = (payload: unknown): string => { if (!isRecord(payload)) return 'Claude Code stream failed'; if (typeof payload.stderr === 'string' && payload.stderr) return payload.stderr; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/index.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/index.tsx index f4de7a7e35..49ba4bf0e5 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/index.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/index.tsx @@ -4,14 +4,24 @@ 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 { Banner, Stack, Text } 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 { + getClaudeCodeSessionHistory, + getClaudeCodeSessionHistoryQueryKey, +} from '@studio/routes/agents/ClaudeCodeChatRoute/api'; +import { ClaudeCodeLayout } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeLayout'; 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 { + getClaudeCodeHistoryMessages, + getSelectedClaudeCodeSessionId, +} from '@studio/routes/agents/ClaudeCodeChatRoute/util'; +import { getClaudeCodeChatRoute, getWorkspaceDashboardRoute } from '@studio/routes/utils'; +import { useQuery } from '@tanstack/react-query'; +import { type FC, useCallback, useEffect, useMemo, useRef } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; const getInitialPrompt = (state: unknown): string | undefined => { @@ -24,16 +34,70 @@ const getInitialPrompt = (state: unknown): string | undefined => { return trimmedPrompt || undefined; }; -export const ClaudeCodeChatRoute: FC = () => { +interface ClaudeCodeChatSurfaceProps { + initialMessages?: ReturnType; + initialPrompt?: string; + initialSessionId?: string; +} + +const CHAT_VIEWPORT_SCROLLBAR_CLASS = [ + '[scrollbar-width:thin]', + '[scrollbar-color:var(--border-color-interaction-base)_transparent]', + '[&::-webkit-scrollbar]:w-2', + '[&::-webkit-scrollbar-corner]:bg-transparent', + '[&::-webkit-scrollbar-track]:bg-transparent', + '[&::-webkit-scrollbar-thumb]:rounded-full', + '[&::-webkit-scrollbar-thumb]:bg-[var(--border-color-interaction-base)]', + '[&::-webkit-scrollbar-thumb:hover]:bg-[var(--border-color-interaction-strong)]', +].join(' '); + +const ClaudeCodeChatLoadingState = ({ selectedSessionId }: { selectedSessionId?: string }) => ( + + + + + Loading chat... + + + + +); + +const ClaudeCodeChatErrorState = ({ selectedSessionId }: { selectedSessionId?: string }) => ( + + + + + Could not load Claude Code session. + + + + +); + +const ClaudeCodeChatSurface: FC = ({ + initialMessages = [], + initialPrompt, + initialSessionId, +}) => { const workspace = useWorkspaceFromPath(); const location = useLocation(); const navigate = useNavigate(); const toast = useToast(); const consumedInitialPromptRef = useRef(undefined); - const { handleReset, runtime, submitPrompt } = useClaudeCodeChatRuntime({ + const { handleReset, runtime, sessionId, submitPrompt } = useClaudeCodeChatRuntime({ + initialMessages, + initialSessionId, onError: (error) => toast.error(error.message), }); - const initialPrompt = getInitialPrompt(location.state); + const activeSessionId = initialSessionId ?? sessionId ?? undefined; + + const handleChatReset = useCallback(() => { + handleReset(); + if (initialSessionId) { + navigate(getClaudeCodeChatRoute(workspace), { replace: true }); + } + }, [handleReset, initialSessionId, navigate, workspace]); useBreadcrumbs({ items: [ @@ -46,26 +110,63 @@ export const ClaudeCodeChatRoute: FC = () => { if (!initialPrompt || consumedInitialPromptRef.current === initialPrompt) return; consumedInitialPromptRef.current = initialPrompt; - navigate(location.pathname, { replace: true, state: null }); + navigate(`${location.pathname}${location.search}`, { replace: true, state: null }); void submitPrompt(initialPrompt); - }, [initialPrompt, location.pathname, navigate, submitPrompt]); + }, [initialPrompt, location.pathname, location.search, navigate, submitPrompt]); return ( - - - - - - + + + + + + + + - - + + + ); +}; + +export const ClaudeCodeChatRoute: FC = () => { + const location = useLocation(); + const selectedSessionId = getSelectedClaudeCodeSessionId(location.search); + const initialPrompt = getInitialPrompt(location.state); + const sessionHistoryQuery = useQuery({ + queryKey: getClaudeCodeSessionHistoryQueryKey(selectedSessionId ?? ''), + queryFn: () => getClaudeCodeSessionHistory(selectedSessionId ?? ''), + enabled: !!selectedSessionId, + }); + const initialMessages = useMemo( + () => getClaudeCodeHistoryMessages(sessionHistoryQuery.data), + [sessionHistoryQuery.data] + ); + + if (selectedSessionId && sessionHistoryQuery.isLoading) { + return ; + } + + if (selectedSessionId && sessionHistoryQuery.isError) { + return ; + } + + return ( + ); }; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/types.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/types.ts index 8f50f781db..91aaed98f8 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/types.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/types.ts @@ -10,3 +10,53 @@ export interface ClaudeCodeStreamHandlers { export interface ClaudeCodeChatRouteState { initialPrompt?: string; } + +export interface ClaudeCodeHistorySession { + session_id: string; + mtime: number; + first_prompt: string; + message_count: number; + token_count: number; + tool_call_count: number; + tool_calls: string[]; +} + +export interface ClaudeCodeUserHistoryItem { + kind: 'user'; + text: string; +} + +export interface ClaudeCodeAssistantTextPart { + type: 'text'; + text: string; +} + +export interface ClaudeCodeAssistantThinkingPart { + type: 'thinking'; + thinking: string; +} + +export interface ClaudeCodeAssistantToolUsePart { + type: 'tool_use'; + name: string; + input: Record; +} + +export type ClaudeCodeAssistantHistoryPart = + | ClaudeCodeAssistantTextPart + | ClaudeCodeAssistantThinkingPart + | ClaudeCodeAssistantToolUsePart; + +export interface ClaudeCodeAssistantHistoryItem { + kind: 'assistant'; + parts: ClaudeCodeAssistantHistoryPart[]; +} + +export type ClaudeCodeSessionHistoryItem = + | ClaudeCodeUserHistoryItem + | ClaudeCodeAssistantHistoryItem; + +export interface ClaudeCodeSessionHistory { + session_id: string; + items: ClaudeCodeSessionHistoryItem[]; +} diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts index 6e2e484def..7112c46a38 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts @@ -1,21 +1,31 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { ThreadMessageLike } from '@assistant-ui/react'; import { CANCELLED_STATUS, COMPLETE_STATUS, } from '@nemo/common/src/components/AssistantChat/constants'; import { + CLAUDE_CODE_HISTORY_SESSIONS_QUERY_KEY, createClaudeCodeSession, streamClaudeCodeMessage, } from '@studio/routes/agents/ClaudeCodeChatRoute/api'; import { getAssistantTextFromClaudeEvent } from '@studio/routes/agents/ClaudeCodeChatRoute/stream'; import { useCustomAssistantChatRuntime } from '@studio/routes/agents/ClaudeCodeChatRoute/useCustomAssistantChatRuntime'; +import { useQueryClient } from '@tanstack/react-query'; import { useCallback, useRef, useState } from 'react'; -export const useClaudeCodeChatRuntime = (options?: { onError?: (error: Error) => void }) => { - const [sessionId, setSessionId] = useState(null); - const sessionIdRef = useRef(null); +interface UseClaudeCodeChatRuntimeOptions { + initialMessages?: readonly ThreadMessageLike[]; + initialSessionId?: string; + onError?: (error: Error) => void; +} + +export const useClaudeCodeChatRuntime = (options?: UseClaudeCodeChatRuntimeOptions) => { + const queryClient = useQueryClient(); + const [sessionId, setSessionId] = useState(options?.initialSessionId ?? null); + const sessionIdRef = useRef(options?.initialSessionId ?? null); const ensureSessionId = useCallback(async (): Promise => { if (sessionIdRef.current) return sessionIdRef.current; @@ -32,6 +42,7 @@ export const useClaudeCodeChatRuntime = (options?: { onError?: (error: Error) => runtime, submitPrompt, } = useCustomAssistantChatRuntime({ + initialMessages: options?.initialMessages, onError: options?.onError, onRun: async ({ prompt, signal, appendAssistantText, isCurrentRun }) => { const activeSessionId = await ensureSessionId(); @@ -56,6 +67,7 @@ export const useClaudeCodeChatRuntime = (options?: { onError?: (error: Error) => }, }, }); + void queryClient.invalidateQueries({ queryKey: CLAUDE_CODE_HISTORY_SESSIONS_QUERY_KEY }); return { status: doneReceived ? COMPLETE_STATUS : CANCELLED_STATUS }; }, diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useCustomAssistantChatRuntime.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useCustomAssistantChatRuntime.ts index 76a78b9d49..1e42de0fc6 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useCustomAssistantChatRuntime.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useCustomAssistantChatRuntime.ts @@ -33,6 +33,7 @@ export interface CustomAssistantRunResult { } interface UseCustomAssistantChatRuntimeOptions { + initialMessages?: readonly ThreadMessageLike[]; onRun: (context: CustomAssistantRunContext) => Promise; onError?: (error: Error) => void; } @@ -41,12 +42,13 @@ const isAbortError = (error: unknown): boolean => error instanceof DOMException && error.name === 'AbortError'; export const useCustomAssistantChatRuntime = ({ + initialMessages = [], onRun, onError, }: UseCustomAssistantChatRuntimeOptions) => { - const [messages, setMessages] = useState([]); + const [messages, setMessages] = useState(initialMessages); const [isRunning, setIsRunning] = useState(false); - const messagesRef = useRef([]); + const messagesRef = useRef(initialMessages); const abortControllerRef = useRef(null); const setThreadMessages = useCallback((nextMessages: readonly ThreadMessageLike[]) => { diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/util.spec.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/util.spec.ts new file mode 100644 index 0000000000..d1c0e94151 --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/util.spec.ts @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ClaudeCodeSessionHistory } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import { + getClaudeCodeChatRouteForSession, + getClaudeCodeHistoryMessages, + getSelectedClaudeCodeSessionId, +} from '@studio/routes/agents/ClaudeCodeChatRoute/util'; +import { getClaudeCodeChatRoute } from '@studio/routes/utils'; + +describe('Claude Code utilities', () => { + it('builds and reads selected session URLs', () => { + const workspace = 'default'; + const sessionId = '2dc6e5a6-acd7-43bf-b128-c9fd5cf6eb9a'; + + expect(getClaudeCodeChatRouteForSession(workspace, sessionId)).toBe( + `${getClaudeCodeChatRoute(workspace)}?session=${sessionId}` + ); + expect(getSelectedClaudeCodeSessionId(`?session=${sessionId}`)).toBe(sessionId); + expect(getSelectedClaudeCodeSessionId('?session=')).toBeUndefined(); + }); + + it('converts stored transcript items to assistant-ui messages', () => { + const history: ClaudeCodeSessionHistory = { + session_id: '2dc6e5a6-acd7-43bf-b128-c9fd5cf6eb9a', + items: [ + { kind: 'user', text: 'check the repo' }, + { + kind: 'assistant', + parts: [ + { type: 'thinking', thinking: 'checking' }, + { type: 'text', text: 'I found the route.' }, + { type: 'tool_use', name: 'Bash', input: { command: 'pwd' } }, + ], + }, + ], + }; + + expect(getClaudeCodeHistoryMessages(history)).toEqual([ + { + id: '2dc6e5a6-acd7-43bf-b128-c9fd5cf6eb9a-0', + role: 'user', + content: [{ type: 'text', text: 'check the repo' }], + }, + { + id: '2dc6e5a6-acd7-43bf-b128-c9fd5cf6eb9a-1', + role: 'assistant', + content: [{ type: 'text', text: 'I found the route.\n\nUsing Bash...' }], + status: { type: 'complete', reason: 'stop' }, + }, + ]); + }); +}); diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/util.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/util.ts new file mode 100644 index 0000000000..548cc0753f --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/util.ts @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ThreadMessageLike } from '@assistant-ui/react'; +import { COMPLETE_STATUS } from '@nemo/common/src/components/AssistantChat/constants'; +import type { + ClaudeCodeAssistantHistoryPart, + ClaudeCodeSessionHistory, +} from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import { getClaudeCodeChatRoute } from '@studio/routes/utils'; + +export const CLAUDE_CODE_SESSION_SEARCH_PARAM = 'session'; + +export const getClaudeCodeChatRouteForSession = (workspace: string, sessionId: string): string => { + const searchParams = new URLSearchParams({ + [CLAUDE_CODE_SESSION_SEARCH_PARAM]: sessionId, + }); + return `${getClaudeCodeChatRoute(workspace)}?${searchParams.toString()}`; +}; + +export const getSelectedClaudeCodeSessionId = (search: string): string | undefined => { + const sessionId = new URLSearchParams(search).get(CLAUDE_CODE_SESSION_SEARCH_PARAM)?.trim(); + return sessionId || undefined; +}; + +const getAssistantPartText = (part: ClaudeCodeAssistantHistoryPart): string => { + if (part.type === 'text') return part.text; + if (part.type === 'tool_use') return `\n\nUsing ${part.name || 'tool'}...`; + return ''; +}; + +export const getClaudeCodeHistoryMessages = ( + history: ClaudeCodeSessionHistory | undefined +): readonly ThreadMessageLike[] => { + if (!history) return []; + + return history.items + .map((item, index): ThreadMessageLike | undefined => { + if (item.kind === 'user') { + return { + id: `${history.session_id}-${index}`, + role: 'user', + content: [{ type: 'text', text: item.text }], + }; + } + + const text = item.parts.map(getAssistantPartText).join('').trim(); + if (!text) return undefined; + + return { + id: `${history.session_id}-${index}`, + role: 'assistant', + content: [{ type: 'text', text }], + status: COMPLETE_STATUS, + }; + }) + .filter((message): message is ThreadMessageLike => message !== undefined); +}; diff --git a/web/packages/studio/src/util/localStorage.ts b/web/packages/studio/src/util/localStorage.ts index 705a5d6947..dae36f040b 100644 --- a/web/packages/studio/src/util/localStorage.ts +++ b/web/packages/studio/src/util/localStorage.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 export const SIDE_NAV_OPEN_KEY = 'side-nav-open'; +export const CLAUDE_CODE_HISTORY_OPEN_KEY = 'claude-code-history-open'; export const WORKSPACE_DROPDOWN_RECENT_KEY = 'workspace-dropdown-recent'; export const UI_THEME = 'ui-theme'; export const SELECTED_WORKSPACE_KEY = 'selected-workspace';