diff --git a/services/studio/src/nmp/studio/coding_agents.py b/services/studio/src/nmp/studio/coding_agents.py index 8ab40fbd3af..a34f8cb3285 100644 --- a/services/studio/src/nmp/studio/coding_agents.py +++ b/services/studio/src/nmp/studio/coding_agents.py @@ -7,6 +7,7 @@ import json import logging import os +import re import shutil import uuid from collections.abc import AsyncIterator, Awaitable, Mapping @@ -104,6 +105,7 @@ class HistorySessionResponse(BaseModel): session_id: str mtime: float + title: str | None = None first_prompt: str message_count: int token_count: int @@ -131,6 +133,7 @@ class SessionHistoryResponse(BaseModel): class HistorySummary: """Aggregated metadata from a Claude session history file.""" + title: str | None = None first_prompt: str | None = None message_count: int = 0 token_count: int = 0 @@ -323,6 +326,7 @@ def _build_studio_system_prompt( f"Start the summary block on its own line with {STUDIO_MESSAGE_SUMMARY_START} and end it on its own line with {STUDIO_MESSAGE_SUMMARY_END}.", "Inside the summary block, use exactly these fields on separate lines:", f"{STUDIO_MESSAGE_SUMMARY_START}", + "title: ", "worked_for: ", "summary: ", "details_label: worked for ", @@ -440,6 +444,24 @@ def _append_tool_call(summary: HistorySummary, tool_name: str) -> None: record_tool_name(summary.chat_artifacts, tool_name) +def _studio_message_title(text: str) -> str | None: + summary_start = text.find(STUDIO_MESSAGE_SUMMARY_START) + if summary_start < 0: + return None + + summary_end = text.find(STUDIO_MESSAGE_SUMMARY_END, summary_start) + block = text[summary_start + len(STUDIO_MESSAGE_SUMMARY_START) : summary_end if summary_end >= 0 else None] + match = re.search( + r"(?:^|\s)title:\s*(.+?)(?=\s+(?:worked_for|summary|details_label):\s*|$)", + block, + flags=re.IGNORECASE | re.DOTALL, + ) + if not match: + return None + + return _trimmed_string(" ".join(match.group(1).split())) + + def _record_assistant_tool_calls( summary: HistorySummary, message: dict[str, Any], @@ -452,6 +474,7 @@ def _record_assistant_tool_calls( if part.get("type") == "text": text = string_value(part.get("text")) if text: + summary.title = _studio_message_title(text) or summary.title record_spec_text_artifacts(summary.chat_artifacts, text) continue if part.get("type") != "tool_use": @@ -595,6 +618,7 @@ def list_history_sessions() -> list[HistorySessionResponse]: HistorySessionResponse( session_id=history_file.stem, mtime=mtime, + title=summary.title, first_prompt=summary.first_prompt or "", message_count=summary.message_count, token_count=summary.token_count, diff --git a/services/studio/tests/unit/test_coding_agents.py b/services/studio/tests/unit/test_coding_agents.py index a497693204b..faa3bd679b8 100644 --- a/services/studio/tests/unit/test_coding_agents.py +++ b/services/studio/tests/unit/test_coding_agents.py @@ -320,6 +320,7 @@ def test_list_and_get_history_sessions( { "session_id": session_id, "mtime": history.stat().st_mtime, + "title": None, "first_prompt": "first prompt", "message_count": 1, "token_count": 30, @@ -695,6 +696,7 @@ def test_build_studio_system_prompt_includes_message_summary_contract(): assert "Required message-summary behavior:" in prompt assert coding_agents.STUDIO_MESSAGE_SUMMARY_START in prompt assert coding_agents.STUDIO_MESSAGE_SUMMARY_END in prompt + assert "title: " in prompt assert "summary: " in prompt @@ -712,6 +714,43 @@ def test_build_studio_system_prompt_includes_message_summary_contract(): assert "Do not omit the summary block because the message is short." in prompt +def test_history_summary_reads_model_generated_session_title(tmp_path: Path): + history = tmp_path / "session.jsonl" + history.write_text( + "\n".join( + [ + json.dumps({"type": "user", "message": {"content": "A long initial request"}}), + json.dumps( + { + "type": "assistant", + "message": { + "content": [ + { + "type": "text", + "text": "\n".join( + [ + coding_agents.STUDIO_MESSAGE_SUMMARY_START, + "title: Create Spam Detector Agent", + "worked_for: unknown", + "summary: Created the requested agent.", + "details_label: worked for unknown", + coding_agents.STUDIO_MESSAGE_SUMMARY_END, + ] + ), + } + ] + }, + } + ), + ] + ) + ) + + summary = coding_agents._summarize_history_session(history) + + assert summary.title == "Create Spam Detector Agent" + + def test_studio_link_destinations_cover_registered_workspace_routes(): repo_root = Path(__file__).resolve().parents[4] routes_index = (repo_root / "web/packages/studio/src/routes/index.tsx").read_text() diff --git a/web/packages/common/src/components/AssistantChat/AssistantChatMessageContent.tsx b/web/packages/common/src/components/AssistantChat/AssistantChatMessageContent.tsx index 57557e138f4..cbc0dd72f3f 100644 --- a/web/packages/common/src/components/AssistantChat/AssistantChatMessageContent.tsx +++ b/web/packages/common/src/components/AssistantChat/AssistantChatMessageContent.tsx @@ -6,21 +6,46 @@ import type { MessageRenderProps } from '@nemo/common/src/components/AssistantCh import { MessageContent } from '@nemo/common/src/components/Chat/MessageContent'; import { Banner } from '@nvidia/foundations-react-core'; +interface AssistantChatMessageContentProps extends MessageRenderProps { + contentSurfaceClassName?: string; +} + export const AssistantChatMessageContent = ({ + contentSurfaceClassName, messageContentProps, toolCallPartComponent, -}: MessageRenderProps) => ( +}: AssistantChatMessageContentProps) => ( <> , - Image: ({ image, filename }) => ( - {filename - ), + Text: ({ text }) => { + if (!text.trim()) return null; + + const content = ; + return contentSurfaceClassName ? ( +
+ {content} +
+ ) : ( + content + ); + }, + Image: ({ image, filename }) => { + const content = ( + {filename + ); + return contentSurfaceClassName ? ( +
+ {content} +
+ ) : ( + content + ); + }, tools: { Fallback: toolCallPartComponent }, }} /> diff --git a/web/packages/common/src/components/AssistantChat/AssistantMessage.tsx b/web/packages/common/src/components/AssistantChat/AssistantMessage.tsx index 9c97ec1e30d..1d00520b539 100644 --- a/web/packages/common/src/components/AssistantChat/AssistantMessage.tsx +++ b/web/packages/common/src/components/AssistantChat/AssistantMessage.tsx @@ -17,6 +17,9 @@ import type { MessageRenderProps } from '@nemo/common/src/components/AssistantCh import { Skeleton, Tooltip } from '@nvidia/foundations-react-core'; import { RefreshCw } from 'lucide-react'; +const ASSISTANT_MESSAGE_SURFACE_CLASS = + 'w-full max-w-full rounded-lg border border-base border-l-4 border-l-[var(--border-color-brand)] bg-surface-base px-density-lg py-density-md shadow ring-1 ring-black/5 dark:ring-white/10'; + export const AssistantMessage = ({ hideAssistantMessageActions, messageContentProps, @@ -26,10 +29,9 @@ export const AssistantMessage = ({ hideAssistantMessageActions?: boolean; showRunningIndicator?: boolean; }) => { - const isToolOnlyMessage = useAuiState((state) => { - const { parts } = state.message; - return parts.length > 0 && parts.every((part) => part.type === 'tool-call'); - }); + const hasRenderableContent = useAuiState((state) => + state.message.parts.some((part) => part.type !== 'text' || part.text.trim().length > 0) + ); return ( - {isToolOnlyMessage ? ( + {hasRenderableContent ? ( - ) : ( -
- -
- )} + ) : null} {showRunningIndicator ? ( diff --git a/web/packages/common/src/components/AssistantChat/index.test.tsx b/web/packages/common/src/components/AssistantChat/index.test.tsx index 930a29acd2e..e2ff0429516 100644 --- a/web/packages/common/src/components/AssistantChat/index.test.tsx +++ b/web/packages/common/src/components/AssistantChat/index.test.tsx @@ -112,10 +112,12 @@ const renderAssistantChat = (element: ReactElement) => const StaticAssistantChatThread = ({ hideAssistantMessageActions, + isRunning = false, messages: initialMessages = defaultStaticMessages, toolCallPartComponent, }: { hideAssistantMessageActions?: boolean; + isRunning?: boolean; messages?: readonly ThreadMessageLike[]; toolCallPartComponent?: ToolCallMessagePartComponent; }) => { @@ -123,7 +125,7 @@ const StaticAssistantChatThread = ({ const runtime = useExternalStoreRuntime({ messages, setMessages, - isRunning: false, + isRunning, onNew: async () => undefined, onEdit: async () => undefined, onReload: async () => undefined, @@ -295,6 +297,112 @@ describe('AssistantChat', () => { ).not.toBeInTheDocument(); }); + it('renders streamed text and tool-call parts as separate message blocks', () => { + renderAssistantChat( + ( +
{toolName}
+ )} + /> + ); + + const assistantMessage = screen.getByTestId('assistant-chat-message'); + const messageSurfaces = within(assistantMessage).getAllByTestId( + 'assistant-chat-message-surface' + ); + const toolCall = within(assistantMessage).getByTestId('assistant-chat-tool-pill'); + + expect(messageSurfaces).toHaveLength(2); + expect(messageSurfaces[0]).toHaveTextContent('I will inspect the repository.'); + expect(messageSurfaces[1]).toHaveTextContent('I found the relevant file.'); + expect(toolCall).toHaveTextContent('Read'); + for (const messageSurface of messageSurfaces) { + expect( + within(messageSurface).queryByTestId('assistant-chat-tool-pill') + ).not.toBeInTheDocument(); + } + }); + + it('shows the running skeleton without an empty assistant response surface', () => { + renderAssistantChat( + + ); + + const assistantMessage = screen.getByTestId('assistant-chat-message'); + + expect( + within(assistantMessage).queryByTestId('assistant-chat-message-surface') + ).not.toBeInTheDocument(); + expect( + within(assistantMessage).getByTestId('assistant-chat-running-indicator') + ).toBeInTheDocument(); + expect(within(assistantMessage).getByTestId('assistant-chat-skeleton')).toBeInTheDocument(); + }); + + it('hides an empty text surface while a tool is running', () => { + renderAssistantChat( + ( +
{toolName}
+ )} + /> + ); + + const assistantMessage = screen.getByTestId('assistant-chat-message'); + + expect(within(assistantMessage).getByTestId('assistant-chat-tool-pill')).toHaveTextContent( + 'Read' + ); + expect( + within(assistantMessage).queryByTestId('assistant-chat-message-surface') + ).not.toBeInTheDocument(); + expect(within(assistantMessage).getByTestId('assistant-chat-skeleton')).toBeInTheDocument(); + }); + it('offers an enabled add-image affordance for a vision model when enabled', () => { renderAssistantChat( diff --git a/web/packages/studio/src/index.css b/web/packages/studio/src/index.css index 018692294ae..04518cd9376 100644 --- a/web/packages/studio/src/index.css +++ b/web/packages/studio/src/index.css @@ -88,24 +88,3 @@ body { .nv-radio-group-item[data-disabled] { cursor: not-allowed; } - -@keyframes claude-code-tool-call-running-color { - 0%, - 100% { - color: var(--text-color-subtle); - } - - 50% { - color: var(--text-color-brand); - } -} - -.claude-code-tool-call-running { - color: var(--text-color-brand); -} - -@media (prefers-reduced-motion: no-preference) { - .claude-code-tool-call-running { - animation: claude-code-tool-call-running-color 1.6s ease-in-out infinite; - } -} diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.test.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.test.tsx index 92bf90fce61..2b6813aff24 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.test.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.test.tsx @@ -62,6 +62,13 @@ describe('ClaudeCodeHistoryPanel', () => { token_count: 100, tool_call_count: 1, tool_calls: ['Bash'], + chat_artifacts: { + selections: [], + files: [], + links: [], + jobs: [], + tools: [], + }, }, ]); @@ -83,6 +90,44 @@ describe('ClaudeCodeHistoryPanel', () => { expect(onSelectSession).toHaveBeenCalledWith('session-1'); }); + it('shows the summarized title while preserving the full first prompt in the tooltip', async () => { + const firstPrompt = 'I want to create an agent that does spam detection for incoming email.'; + mocks.listClaudeCodeHistorySessions.mockResolvedValue([ + { + session_id: 'session-1', + mtime: Date.now() / 1000, + title: 'Create Spam Detector Agent', + first_prompt: firstPrompt, + message_count: 2, + token_count: 100, + tool_call_count: 0, + tool_calls: [], + chat_artifacts: { + selections: [], + files: [], + links: [], + jobs: [], + tools: [], + }, + }, + ]); + + render( + + ); + + const sessionButton = await screen.findByRole('button', { + name: 'Create Spam Detector Agent now', + }); + + expect(sessionButton).toHaveAttribute('title', expect.stringContaining(firstPrompt)); + expect(screen.queryByText(firstPrompt)).not.toBeInTheDocument(); + }); + it('renders job artifacts as Studio links', () => { render( { expect(screen.queryByText('web/packages/studio/src/App.tsx')).not.toBeInTheDocument(); }); - it('animates subtle tool text while the tool call is running', () => { + it('renders running subtle tool text without special color or animation', () => { render( { /> ); - expect(screen.getByTestId('claude-code-tool-call-subtle')).toHaveClass( - 'claude-code-tool-call-running' - ); + expectSubtleToolBlock(screen.getByTestId('claude-code-tool-call-subtle')); }); it('renders Write as a file change summary with expandable content', async () => { @@ -451,7 +449,7 @@ describe('ClaudeCodeToolCallPart', () => { ); }); - it('animates file change text while the tool call is running', () => { + it('renders running file change text without special color or animation', () => { render( { /> ); - expect(screen.getByText('Wrote 1 file')).toHaveClass('claude-code-tool-call-running'); + expect(screen.getByText('Wrote 1 file')).toHaveClass('block'); + expect(screen.getByText('Wrote 1 file')).not.toHaveClass('claude-code-tool-call-running'); }); it('renders Edit as a file change summary with edited stats', () => { diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeToolCallPart.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeToolCallPart.tsx index 7de7360ee58..039f47509f5 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeToolCallPart.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeToolCallPart.tsx @@ -34,7 +34,6 @@ import { ChevronRight, ClipboardList } from 'lucide-react'; interface ClaudeCodeToolCallPartContentProps { readonly args: ClaudeCodeToolArgs; readonly argsText: string; - readonly isRunning?: boolean; readonly toolName: string; } @@ -105,7 +104,6 @@ const CollapsedStudioDetailsPartContent = ({ ); @@ -154,7 +152,6 @@ const ClaudeCodeToolCallPartContent = ({ args, argsText, toolName, - isRunning = false, }: ClaudeCodeToolCallPartContentProps) => { if (toolName === CLAUDE_CODE_COLLAPSED_STUDIO_DETAILS_TOOL_NAME) { return ; @@ -171,16 +168,13 @@ const ClaudeCodeToolCallPartContent = ({ if (toolName === CLAUDE_CODE_SUBTLE_TOOL_GROUP_NAME) { const subtleActions = getSubtleToolGroupActions(args); - return subtleActions.length ? ( - - ) : null; + return subtleActions.length ? : null; } const subtleMessage = getSubtleToolMessage(toolName, args); if (subtleMessage) { return ( ; + return ; } } @@ -209,7 +203,6 @@ const ClaudeCodeToolCallPartContent = ({ ); return ( = ({ args, argsText, - status, toolName, -}) => ( - -); +}) => ; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.test.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.test.ts index dccf6cc3dcf..50ebbc1a816 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.test.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.test.ts @@ -4,6 +4,7 @@ import { BASE_URL } from '@studio/constants/environment'; import { getClaudeCodeSessionHistory, + listClaudeCodeHistorySessions, listClaudeCodeSkills, resolveClaudeCodeInput, resolveClaudeCodePermission, @@ -21,6 +22,34 @@ describe('Claude Code API helpers', () => { vi.unstubAllGlobals(); }); + it('reads model-generated titles from history sessions', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify([ + { + session_id: 'session-1', + mtime: 123, + title: 'Create Spam Detector Agent', + first_prompt: 'I want to create an agent that does spam detection.', + message_count: 2, + token_count: 10, + tool_call_count: 0, + tool_calls: [], + chat_artifacts: {}, + }, + ]), + { status: 200 } + ) + ) + ); + + const sessions = await listClaudeCodeHistorySessions(); + + expect(sessions[0]?.title).toBe('Create Spam Detector Agent'); + }); + it('posts messages with the active workspace', async () => { const fetchMock = vi .fn() diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts index 8493561f93b..3ee2d2e5f09 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts @@ -188,6 +188,7 @@ const parseHistorySession = (value: unknown): ClaudeCodeHistorySession | undefin return { session_id: sessionId, mtime: getNumber(value.mtime), + title: getOptionalString(value.title), first_prompt: getString(value.first_prompt), message_count: getNumber(value.message_count), token_count: getNumber(value.token_count), diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistorySessionButton.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistorySessionButton.tsx index 067478a4c9d..0c2b2ee15e2 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistorySessionButton.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistorySessionButton.tsx @@ -3,7 +3,10 @@ import { Flex, Stack, Text } from '@nvidia/foundations-react-core'; import { ToolCallSummary } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/ArtifactSections'; -import { getCompactRelativeTime } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/helpers'; +import { + getCompactRelativeTime, + getHistorySessionTitle, +} from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/helpers'; import type { ClaudeCodeHistorySession } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; import cn from 'classnames'; import { MessageSquare } from 'lucide-react'; @@ -19,44 +22,48 @@ export const HistorySessionButton = ({ active, onSelect, session, -}: HistorySessionButtonProps): React.JSX.Element => ( - -); + onClick={onSelect} + > + + + + + + + + {sessionTitle} + + + {getCompactRelativeTime(session.mtime)} + + + + {session.tool_calls.length > 0 && ( +
+ +
+ )} +
+ + ); +}; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/helpers.test.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/helpers.test.ts new file mode 100644 index 00000000000..493891315e3 --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/helpers.test.ts @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getHistorySessionTitle } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/helpers'; +import type { + ClaudeCodeChatArtifacts, + ClaudeCodeHistorySession, +} from '@studio/routes/agents/ClaudeCodeChatRoute/types'; + +const EMPTY_ARTIFACTS: ClaudeCodeChatArtifacts = { + selections: [], + files: [], + links: [], + jobs: [], + tools: [], +}; + +const makeSession = ({ + chat_artifacts = EMPTY_ARTIFACTS, + first_prompt = '', + title, +}: { + chat_artifacts?: ClaudeCodeChatArtifacts; + first_prompt?: string; + title?: string; +}): ClaudeCodeHistorySession => ({ + session_id: 'session-1', + mtime: 0, + title, + first_prompt, + message_count: first_prompt ? 1 : 0, + token_count: 0, + tool_call_count: 0, + tool_calls: [], + chat_artifacts, +}); + +describe('getHistorySessionTitle', () => { + it('prefers a model-generated title over the first prompt', () => { + expect( + getHistorySessionTitle( + makeSession({ + title: 'Create Spam Detector Agent', + first_prompt: 'I want to create an agent that does spam detection for incoming email.', + }) + ) + ).toBe('Create Spam Detector Agent'); + }); + + it('creates an action-oriented legacy title for an agent creation prompt', () => { + expect( + getHistorySessionTitle( + makeSession({ + first_prompt: 'I want to create an agent that does spam detection.', + }) + ) + ).toBe('Create spam detector agent'); + }); + + it('turns a long contextual prompt into the latest actionable request', () => { + expect( + getHistorySessionTitle( + makeSession({ + first_prompt: + 'Reviewers scan dozens of saved runs every morning. The cards take up too much room. Can we show compact outcome labels for faster triage?', + }) + ) + ).toBe('Show compact outcome labels for faster triage'); + }); + + it('falls back to artifacts when no prompt is available', () => { + expect( + getHistorySessionTitle( + makeSession({ + chat_artifacts: { ...EMPTY_ARTIFACTS, agent: 'beach-finder' }, + }) + ) + ).toBe('Agent beach-finder'); + }); +}); diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/helpers.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/helpers.ts index 6d7d1d87bbf..8fe4b59c1a2 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/helpers.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/helpers.ts @@ -5,8 +5,33 @@ import type { ClaudeCodePanelTab } from '@studio/routes/agents/ClaudeCodeChatRou import type { ClaudeCodeChatArtifacts, ClaudeCodeChatFileArtifact, + ClaudeCodeHistorySession, } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +const MAX_HISTORY_TITLE_WORDS = 8; +const MAX_HISTORY_TITLE_CHARS = 72; + +const ACTIONABLE_OPENERS = [ + /^(?:please\s+)?(?:can|could|would|will|should)\s+(?:you|we)\b/i, + /^is\s+it\s+possible\b/i, + /^(?:i|we)\s+(?:need|want)\s+to\b/i, + /^i(?:'d| would)\s+like(?:\s+you)?\s+to\b/i, + /^help(?:\s+(?:me|us))?\b/i, + /^let'?s\b/i, +]; + +const REQUEST_PREFIXES = [ + /^(?:please\s+)?(?:can|could|would|will|should)\s+(?:you|we)\s+/i, + /^is\s+it\s+possible(?:\s+for\s+(?:us|you|me))?\s+to\s+/i, + /^(?:i|we)\s+(?:need|want)\s+to\s+/i, + /^i(?:'d| would)\s+like(?:\s+you)?\s+to\s+/i, + /^help(?:\s+(?:me|us))?(?:\s+to)?\s+/i, + /^let'?s\s+/i, +]; + +const INLINE_REQUEST_PREFIX = + /(?:\b(?:can|could|would|will|should)\s+(?:you|we)\s+|\bis\s+it\s+possible(?:\s+for\s+(?:us|you|me))?\s+to\s+|\b(?:i|we)\s+(?:need|want)\s+to\s+|\bi(?:'d| would)\s+like(?:\s+you)?\s+to\s+|\bhelp(?:\s+(?:me|us))?(?:\s+to)?\s+)/gi; + export const isClaudeCodePanelTab = (value: string): value is ClaudeCodePanelTab => value === 'history' || value === 'skills'; @@ -34,6 +59,134 @@ export const getFileLabel = (file: ClaudeCodeChatFileArtifact): string => { return parts[parts.length - 1] || file.path; }; +const getCleanTitleText = (value: string): string => + value + .replace(/\([^()]*\)/g, ' ') + .replace(/[`*_#>]+/g, '') + .replace(/\s+/g, ' ') + .trim(); + +const splitPromptSentences = (prompt: string): string[] => + (prompt.match(/[^.!?]+[.!?]*/g) ?? [prompt]) + .map((sentence) => + getCleanTitleText(sentence) + .replace(/[.!?]+$/g, '') + .trim() + ) + .filter(Boolean); + +const isActionableSentence = (sentence: string): boolean => + ACTIONABLE_OPENERS.some((pattern) => pattern.test(sentence)); + +const getPromptTitleSource = (prompt: string): string => { + const sentences = splitPromptSentences(prompt); + + for (let index = sentences.length - 1; index >= 0; index -= 1) { + if (isActionableSentence(sentences[index])) return sentences[index]; + } + + return sentences[0] ?? getCleanTitleText(prompt); +}; + +const stripRequestPrefix = (text: string): string => { + let title = text; + + for (const pattern of REQUEST_PREFIXES) { + title = title.replace(pattern, ''); + } + + return title.replace(/^please\s+/i, '').replace(/^give\s+(?:it|them|this|that)\s+/i, 'give '); +}; + +const capitalizeTitle = (title: string): string => + title ? `${title.charAt(0).toLocaleUpperCase()}${title.slice(1)}` : title; + +const limitTitleLength = (title: string): string => { + const words = title.split(/\s+/).filter(Boolean); + let limited = words.slice(0, MAX_HISTORY_TITLE_WORDS).join(' '); + + if (limited.length > MAX_HISTORY_TITLE_CHARS) { + limited = `${limited.slice(0, MAX_HISTORY_TITLE_CHARS - 3).trimEnd()}...`; + } + + return limited; +}; + +const getLatestRequestClause = (prompt: string): string | undefined => { + const matches = Array.from(prompt.matchAll(INLINE_REQUEST_PREFIX)); + const lastMatch = matches.at(-1); + if (!lastMatch || lastMatch.index === undefined) return undefined; + + const request = prompt.slice(lastMatch.index + lastMatch[0].length); + return splitPromptSentences(request)[0]; +}; + +const getAgentCreationTitle = (prompt: string): string | undefined => { + const normalizedPrompt = getCleanTitleText(prompt); + const createAgentMatch = normalizedPrompt.match( + /\b(?:create|build|make)\s+(?:me\s+)?(?:an?\s+)?agent\s+(?:that|which)\s+(?:can\s+|will\s+)?(?:does\s+)?(.+?)(?:[.!?]|$)/i + ); + if (!createAgentMatch?.[1]) return undefined; + + const purpose = createAgentMatch[1] + .replace(/\b(\w+)\s+detection\b/i, '$1 detector') + .replace(/\bdetection\b/i, 'detector') + .replace(/[,:;\s-]+$/g, '') + .trim(); + return purpose ? limitTitleLength(capitalizeTitle(`Create ${purpose} agent`)) : undefined; +}; + +const getSmartPromptHistoryTitle = (prompt: string): string | undefined => { + const agentCreationTitle = getAgentCreationTitle(prompt); + if (agentCreationTitle) return agentCreationTitle; + + const cleanPrompt = getCleanTitleText(prompt); + const requestClause = getLatestRequestClause(cleanPrompt); + if (!requestClause) return undefined; + + const hiddenSubject = requestClause.match( + /^(?:please\s+)?(?:make\s+sure|ensure(?:\s+that)?)\s+(.+?)\s+(?:doesn't|does\s+not|isn't|is\s+not)\s+(?:show|appear|render)\b/i + )?.[1]; + if (hiddenSubject) { + return limitTitleLength(capitalizeTitle(`Hide ${hiddenSubject}`)); + } + + const title = capitalizeTitle( + stripRequestPrefix(requestClause) + .replace(/^(?:please\s+)?(?:make\s+sure|ensure(?:\s+that)?)\s+/i, '') + .replace(/[,:;\s-]+$/g, '') + .trim() + ); + return title ? limitTitleLength(title) : undefined; +}; + +const getPromptHistoryTitle = (prompt: string): string | undefined => { + const source = getPromptTitleSource(prompt); + const title = capitalizeTitle( + stripRequestPrefix(source) + .replace(/[,:;\s-]+$/g, '') + .trim() + ); + return title ? limitTitleLength(title) : undefined; +}; + +const getArtifactHistoryTitle = (artifacts: ClaudeCodeChatArtifacts): string | undefined => { + if (artifacts.agent) return `Agent ${getCleanTitleText(artifacts.agent)}`; + if (artifacts.jobs.length) return `Job ${getCleanTitleText(artifacts.jobs[0].name)}`; + if (artifacts.files.length) { + const file = artifacts.files[0]; + return `${getCleanTitleText(file.action)} ${getFileLabel(file)}`; + } + return undefined; +}; + +export const getHistorySessionTitle = (session: ClaudeCodeHistorySession): string => + (session.title ? getCleanTitleText(session.title) : undefined) ?? + getSmartPromptHistoryTitle(session.first_prompt) ?? + getArtifactHistoryTitle(session.chat_artifacts) ?? + getPromptHistoryTitle(session.first_prompt) ?? + 'Claude Code session'; + export const getSelectedArtifactModel = (artifacts: ClaudeCodeChatArtifacts): string | undefined => artifacts.model_source === 'selection' || artifacts.model_source === 'spec' ? artifacts.model diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/FileChangeToolCallCard.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/FileChangeToolCallCard.tsx index d41d5f6d20b..c89dcdaca32 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/FileChangeToolCallCard.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/FileChangeToolCallCard.tsx @@ -6,13 +6,10 @@ import { CODE_BLOCK_SURFACE_CLASS, FILE_CHANGE_ADDITION_CLASS, FILE_CHANGE_DELETION_CLASS, - RUNNING_TOOL_CALL_CLASS, } from '@studio/routes/agents/ClaudeCodeChatRoute/toolCall/constants'; -import cn from 'classnames'; import { ChevronRight, FilePenLine, FilePlus2 } from 'lucide-react'; interface FileChangeToolCallCardProps { - readonly isRunning?: boolean; readonly summary: { readonly action: 'Edited' | 'Wrote'; readonly additions: number; @@ -22,10 +19,7 @@ interface FileChangeToolCallCardProps { }; } -export const FileChangeToolCallCard = ({ - isRunning = false, - summary, -}: FileChangeToolCallCardProps) => { +export const FileChangeToolCallCard = ({ summary }: FileChangeToolCallCardProps) => { const Icon = summary.action === 'Wrote' ? FilePlus2 : FilePenLine; return ( @@ -39,10 +33,7 @@ export const FileChangeToolCallCard = ({
- + {summary.action} 1 file diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/SubtleToolCallRow.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/SubtleToolCallRow.tsx index 77be1c3ff66..b3a5468cdb4 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/SubtleToolCallRow.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/SubtleToolCallRow.tsx @@ -2,24 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 import { Text } from '@nvidia/foundations-react-core'; -import { RUNNING_TOOL_CALL_CLASS } from '@studio/routes/agents/ClaudeCodeChatRoute/toolCall/constants'; import { summarizeRepeatedSubtleToolActions } from '@studio/routes/agents/ClaudeCodeChatRoute/toolCall/helpers'; import type { SubtleToolAction } from '@studio/routes/agents/ClaudeCodeChatRoute/toolCall/types'; -import cn from 'classnames'; import { ChevronRight } from 'lucide-react'; interface SubtleToolCallRowProps { readonly actions: readonly SubtleToolAction[]; - readonly isRunning?: boolean; } -export const SubtleToolCallRow = ({ actions, isRunning = false }: SubtleToolCallRowProps) => ( +export const SubtleToolCallRow = ({ actions }: SubtleToolCallRowProps) => (
action.title ?? action.message).join(' | ')} > diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/constants.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/constants.ts index 31831e8ce73..03a159ae8ad 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/constants.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/constants.ts @@ -47,7 +47,6 @@ export const CODE_BLOCK_SURFACE_CLASS = 'bg-gray-050 dark:bg-gray-900'; export const FILE_CHANGE_ADDITION_CLASS = 'text-feedback-success'; export const FILE_CHANGE_DELETION_CLASS = 'text-feedback-danger'; export const SUBTLE_MESSAGE_MAX_LENGTH = 160; -export const RUNNING_TOOL_CALL_CLASS = 'claude-code-tool-call-running'; export const SUBTLE_TOOL_ICONS: Record = { AskUserQuestion: CircleHelp, diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolParts.test.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolParts.test.ts index b1d64d225da..7ef3329d16b 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolParts.test.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolParts.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { ThreadAssistantMessagePart } from '@assistant-ui/react'; +import { CLAUDE_CODE_JOB_PROGRESS_MCP_TOOL_NAME } from '@studio/routes/agents/ClaudeCodeChatRoute/jobProgressConsts'; import { CLAUDE_CODE_COLLAPSED_STUDIO_DETAILS_TOOL_NAME, getClaudeCodeCompletedMessageParts, @@ -266,6 +267,76 @@ describe('Claude Code tool parts', () => { ]); }); + it('keeps tables and prominent custom components visible with the final summary', () => { + const bashPart: ThreadAssistantMessagePart = { + type: 'tool-call', + toolCallId: 'toolu_bash', + toolName: 'Bash', + args: { command: 'inspect results' }, + argsText: '{"command":"inspect results"}', + }; + const jobPart: ThreadAssistantMessagePart = { + type: 'tool-call', + toolCallId: 'toolu_job', + toolName: CLAUDE_CODE_JOB_PROGRESS_MCP_TOOL_NAME, + args: { job_name: 'spam-detector-eval' }, + argsText: '{"job_name":"spam-detector-eval"}', + }; + const fileChangePart: ThreadAssistantMessagePart = { + type: 'tool-call', + toolCallId: 'toolu_write', + toolName: 'Write', + args: { file_path: 'agents/spam-detector.yml', content: 'workflow: spam-detector' }, + argsText: '{"file_path":"agents/spam-detector.yml","content":"workflow: spam-detector"}', + }; + const markdownTable = [ + '| Metric | Result |', + '| --- | ---: |', + '| Precision | 0.98 |', + '| Recall | 0.96 |', + ].join('\n'); + const parts: readonly ThreadAssistantMessagePart[] = [ + { + type: 'text', + text: `I inspected the evaluation output.\n\n${markdownTable}\n\nThe detailed logs are available.`, + }, + bashPart, + jobPart, + fileChangePart, + { + type: 'text', + text: [ + STUDIO_MESSAGE_SUMMARY_START, + 'title: Evaluate Spam Detector', + 'worked_for: 20s', + 'summary: The spam detector evaluation completed successfully.', + 'details_label: worked for 20s', + STUDIO_MESSAGE_SUMMARY_END, + ].join('\n'), + }, + ]; + + expect(getClaudeCodeCompletedMessageParts(parts)).toMatchObject([ + { + type: 'tool-call', + toolName: CLAUDE_CODE_COLLAPSED_STUDIO_DETAILS_TOOL_NAME, + args: { + parts: [ + { + type: 'text', + text: 'I inspected the evaluation output.\n\nThe detailed logs are available.', + }, + { type: 'tool-call', toolName: 'Bash' }, + ], + }, + }, + { type: 'text', text: markdownTable }, + { type: 'tool-call', toolName: CLAUDE_CODE_JOB_PROGRESS_MCP_TOOL_NAME }, + { type: 'tool-call', toolName: 'Write' }, + { type: 'text', text: 'The spam detector evaluation completed successfully.' }, + ]); + }); + it('includes links from collapsed details at the bottom of the summary', () => { const parts: readonly ThreadAssistantMessagePart[] = [ { diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolParts.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolParts.ts index e7ae4721f71..0afc97e7759 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolParts.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolParts.ts @@ -100,6 +100,7 @@ interface ClaudeCodeCompletedMessageOptions { interface StudioSummaryBlock { readonly detailsLabel: string; readonly detailParts: readonly ThreadAssistantMessagePart[]; + readonly finalParts: readonly ThreadAssistantMessagePart[]; readonly summaryText: string; } @@ -195,14 +196,15 @@ const getStudioSummaryFields = ( readonly summaryText?: string; readonly workedFor?: string; } => { - const fields: Partial> = {}; + type StudioSummaryField = 'details_label' | 'summary' | 'title' | 'worked_for'; + const fields: Partial> = {}; const fieldMatches = Array.from( - blockText.matchAll(/(?:^|\s)(worked_for|summary|details_label):\s*/gi) + blockText.matchAll(/(?:^|\s)(title|worked_for|summary|details_label):\s*/gi) ); for (const [index, match] of fieldMatches.entries()) { - const field = match[1]!.toLowerCase() as 'details_label' | 'summary' | 'worked_for'; + const field = match[1]!.toLowerCase() as StudioSummaryField; const valueStart = (match.index ?? 0) + match[0].length; const valueEnd = fieldMatches[index + 1]?.index ?? blockText.length; fields[field] = [blockText.slice(valueStart, valueEnd)]; @@ -386,6 +388,85 @@ const splitTrailingQuestion = ( }; }; +const MARKDOWN_TABLE_DELIMITER_ROW = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/; + +const extractMarkdownTableParts = ( + text: string +): { + readonly remainingText: string; + readonly tableParts: readonly ThreadAssistantMessagePart[]; +} => { + const lines = text.split('\n'); + const tableLines = new Set(); + const tableParts: ThreadAssistantMessagePart[] = []; + + for (let index = 1; index < lines.length; index += 1) { + if (!MARKDOWN_TABLE_DELIMITER_ROW.test(lines[index]!) || !lines[index - 1]!.includes('|')) { + continue; + } + + const tableStart = index - 1; + let tableEnd = index; + while (tableEnd + 1 < lines.length && lines[tableEnd + 1]!.trim().includes('|')) { + tableEnd += 1; + } + + for (let tableLine = tableStart; tableLine <= tableEnd; tableLine += 1) { + tableLines.add(tableLine); + } + tableParts.push({ + type: 'text', + text: lines + .slice(tableStart, tableEnd + 1) + .join('\n') + .trim(), + }); + index = tableEnd; + } + + const remainingText = lines + .filter((_, index) => !tableLines.has(index)) + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + return { remainingText, tableParts }; +}; + +const isFinalSummaryToolPart = (part: ThreadAssistantMessagePart): boolean => + part.type === 'tool-call' && + part.toolName !== CLAUDE_CODE_COLLAPSED_THINKING_TOOL_NAME && + part.toolName !== CLAUDE_CODE_COLLAPSED_STUDIO_DETAILS_TOOL_NAME && + part.toolName !== CLAUDE_CODE_SUBTLE_TOOL_GROUP_NAME && + !isClaudeCodeSubtleToolCallPart(part); + +const partitionStudioSummaryParts = ( + parts: readonly ThreadAssistantMessagePart[] +): { + readonly detailParts: readonly ThreadAssistantMessagePart[]; + readonly finalParts: readonly ThreadAssistantMessagePart[]; +} => { + const detailParts: ThreadAssistantMessagePart[] = []; + const finalParts: ThreadAssistantMessagePart[] = []; + + for (const part of parts) { + if (part.type === 'text') { + const { remainingText, tableParts } = extractMarkdownTableParts(part.text); + if (remainingText) detailParts.push({ ...part, text: remainingText }); + finalParts.push(...tableParts); + continue; + } + + if (isFinalSummaryToolPart(part)) { + finalParts.push(part); + } else { + detailParts.push(part); + } + } + + return { detailParts, finalParts }; +}; + const getTruncatedStudioSummaryText = ( detailParts: readonly ThreadAssistantMessagePart[], question: string | undefined @@ -439,12 +520,14 @@ const getStudioSummaryBlock = ( const summarySource = summarySourceParts.join('\n'); const summaryEndIndex = summarySource.indexOf(STUDIO_MESSAGE_SUMMARY_END); if (summaryEndIndex < 0) { + const partitionedParts = partitionStudioSummaryParts(detailParts); return { detailsLabel: getStudioDetailsLabel({ elapsedMs: options.elapsedMs, summaryFields: getStudioSummaryFields(''), }), - detailParts, + detailParts: partitionedParts.detailParts, + finalParts: partitionedParts.finalParts, summaryText: getTruncatedStudioSummaryText(detailParts, question), }; } @@ -464,13 +547,15 @@ const getStudioSummaryBlock = ( ? `${baseSummaryText}\n\n${question}` : baseSummaryText; const summaryText = appendDetailLinksToSummary(summaryTextWithQuestion, detailParts); + const partitionedParts = partitionStudioSummaryParts(detailParts); return { detailsLabel: getStudioDetailsLabel({ elapsedMs: options.elapsedMs, summaryFields, }), - detailParts, + detailParts: partitionedParts.detailParts, + finalParts: partitionedParts.finalParts, summaryText, }; } @@ -526,6 +611,7 @@ export const getClaudeCodeCompletedMessageParts = ( parts: studioSummaryBlock.detailParts, }); if (collapsedDetailsPart) completedParts.push(collapsedDetailsPart); + completedParts.push(...studioSummaryBlock.finalParts); completedParts.push({ type: 'text', text: studioSummaryBlock.summaryText }); return completedParts; } diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/types.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/types.ts index 6a682b66a77..1a0622539ae 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/types.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/types.ts @@ -82,6 +82,7 @@ export interface ClaudeCodeChatArtifacts { export interface ClaudeCodeHistorySession { session_id: string; mtime: number; + title?: string; first_prompt: string; message_count: number; token_count: number;