From 3d23ab9aa1f743aac3c5be8ac669abcde30fbd63 Mon Sep 17 00:00:00 2001 From: Danielle Ali <44468613+dmariali@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:28:56 -0400 Subject: [PATCH 1/5] feat(studio): add Chat Artifacts panel Signed-off-by: Danielle Ali <44468613+dmariali@users.noreply.github.com> --- .../.injected-calculator-eval-28my_bch.yaml | 30 ++ .../studio/src/nmp/studio/coding_agents.py | 357 +++++++++++++++++- .../studio/tests/unit/test_coding_agents.py | 157 +++++++- .../ClaudeCodeHistoryPanel.tsx | 271 +++++++++++-- .../ClaudeCodeChatRoute/ClaudeCodeLayout.tsx | 9 +- .../routes/agents/ClaudeCodeChatRoute/api.ts | 90 ++++- .../ClaudeCodeChatRoute/artifacts.spec.ts | 149 ++++++++ .../agents/ClaudeCodeChatRoute/artifacts.ts | 304 +++++++++++++++ .../agents/ClaudeCodeChatRoute/index.tsx | 12 +- .../agents/ClaudeCodeChatRoute/types.ts | 31 ++ .../useClaudeCodeChatRuntime.spec.ts | 55 +++ .../useClaudeCodeChatRuntime.ts | 47 ++- .../agents/ClaudeCodeChatRoute/util.spec.ts | 2 + 13 files changed, 1462 insertions(+), 52 deletions(-) create mode 100644 plugins/nemo-agents/examples/calculator-agent/src/calculator_agent/.injected-calculator-eval-28my_bch.yaml create mode 100644 web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/artifacts.spec.ts create mode 100644 web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/artifacts.ts diff --git a/plugins/nemo-agents/examples/calculator-agent/src/calculator_agent/.injected-calculator-eval-28my_bch.yaml b/plugins/nemo-agents/examples/calculator-agent/src/calculator_agent/.injected-calculator-eval-28my_bch.yaml new file mode 100644 index 0000000000..8a54ee1d93 --- /dev/null +++ b/plugins/nemo-agents/examples/calculator-agent/src/calculator_agent/.injected-calculator-eval-28my_bch.yaml @@ -0,0 +1,30 @@ +eval: + evaluators: + accuracy: + _type: tunable_rag_evaluator + default_score_weights: + correctness: 0.3 + coverage: 0.5 + relevance: 0.2 + default_scoring: true + judge_llm_prompt: 'You are an evaluator. Score whether the generated answer + correctly addresses the question compared to the expected answer description. + Rules: - Score is a float between 0.0 and 1.0. - 1.0 means the answer fully + satisfies the expected answer criteria. - Provide a 1-2 sentence reasoning. + + ' + llm_name: judge_llm + general: + dataset: + _type: json + file_path: calculator-eval-data.json + max_concurrency: 1 + output_dir: eval/calculator +llms: + judge_llm: + _type: openai + api_key: not-used + base_url: http://127.0.0.1:8080/apis/inference-gateway/v2/workspaces/default/openai/-/v1 + max_tokens: 1024 + model_name: meta-llama-3-1-70b-instruct + temperature: 0.0 diff --git a/services/studio/src/nmp/studio/coding_agents.py b/services/studio/src/nmp/studio/coding_agents.py index 9c61826e06..745e7d541e 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 @@ -53,6 +54,41 @@ class PermissionDecision(BaseModel): updated_input: dict[str, Any] | None = None +class ChatSelectionArtifactResponse(BaseModel): + """A user selection captured during the chat.""" + + label: str + value: str + + +class ChatFileArtifactResponse(BaseModel): + """A file touched by the local coding agent.""" + + action: str + path: str + + +class ChatLinkArtifactResponse(BaseModel): + """A Studio link requested by the local coding agent.""" + + label: str + destination: str | None = None + + +class ChatArtifactsResponse(BaseModel): + """Structured chat metadata shown in Studio's artifacts pane.""" + + agent: str | None = None + model: str | None = None + model_source: str | None = None + coding_agent_model: str | None = None + workspace: str | None = None + selections: list[ChatSelectionArtifactResponse] = Field(default_factory=list) + files: list[ChatFileArtifactResponse] = Field(default_factory=list) + links: list[ChatLinkArtifactResponse] = Field(default_factory=list) + tools: list[str] = Field(default_factory=list) + + class HistorySessionResponse(BaseModel): """Summary of a Claude session stored on disk.""" @@ -63,6 +99,7 @@ class HistorySessionResponse(BaseModel): token_count: int tool_call_count: int tool_calls: list[str] + chat_artifacts: ChatArtifactsResponse class SessionHistoryResponse(BaseModel): @@ -70,6 +107,7 @@ class SessionHistoryResponse(BaseModel): session_id: str items: list[dict[str, Any]] + chat_artifacts: ChatArtifactsResponse _initialized_sessions: set[str] = set() @@ -86,6 +124,7 @@ class HistorySummary: token_count: int = 0 tool_call_count: int = 0 tool_calls: list[str] = dataclass_field(default_factory=list) + chat_artifacts: ChatArtifactsResponse = dataclass_field(default_factory=ChatArtifactsResponse) _APPROVAL_TOOL = { @@ -133,11 +172,57 @@ def _project_history_dir() -> Path: "output_tokens", ) +_ANSWER_PAIR_RE = re.compile(r'"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)"') +_INLINE_CODE_VALUE_RE = re.compile(r"(`+)(?P.*?)\1", re.DOTALL) +_FILE_CHANGE_TOOL_ACTIONS = { + "Edit": "Edited", + "MultiEdit": "Edited", + "Write": "Wrote", +} +_STUDIO_CONTEXT_WORKSPACE_RE = re.compile(r"^Current Studio workspace:\s*(?P.+)$", re.MULTILINE) +_SPEC_HEADINGS = { + "behavior", + "change scope", + "evaluation setup", + "framework", + "harness", + "model", + "name", + "open questions", + "purpose", + "role", + "scope", + "signals", + "success criteria", + "tools", +} + def _int_metric(value: Any) -> int: return value if isinstance(value, int) and not isinstance(value, bool) else 0 +def _string_value(value: Any) -> str | None: + if not isinstance(value, str): + return None + stripped = value.strip() + return stripped or None + + +def _append_unique_string(items: list[str], value: str) -> None: + if value not in items: + items.append(value) + + +def _clean_artifact_value(value: str) -> str: + stripped = value.strip() + match = _INLINE_CODE_VALUE_RE.search(stripped) + if not match: + return stripped + unwrapped = match.group("value").strip() + return unwrapped or stripped + + def _usage_token_count(usage: Any) -> int: if not isinstance(usage, dict): return 0 @@ -163,17 +248,243 @@ def _usage_identity(entry: dict[str, Any], message: dict[str, Any]) -> tuple[str def _append_tool_call(summary: HistorySummary, tool_name: str) -> None: summary.tool_call_count += 1 - if tool_name not in summary.tool_calls: - summary.tool_calls.append(tool_name) + _append_unique_string(summary.tool_calls, tool_name) + _append_unique_string(summary.chat_artifacts.tools, tool_name) + + +def _set_coding_agent_model(artifacts: ChatArtifactsResponse, model: str | None) -> None: + if not model: + return + artifacts.coding_agent_model = model + + +def _set_spec_model(artifacts: ChatArtifactsResponse, model: str) -> None: + artifacts.model = _clean_artifact_value(model) + artifacts.model_source = "spec" + + +def _set_selection_artifact(artifacts: ChatArtifactsResponse, label: str, value: str) -> None: + cleaned_value = _clean_artifact_value(value) + if label == "Agent": + artifacts.agent = cleaned_value + return + if label == "Model": + artifacts.model = cleaned_value + artifacts.model_source = "selection" + return + + for index, selection in enumerate(artifacts.selections): + if selection.label == label: + artifacts.selections[index] = ChatSelectionArtifactResponse( + label=label, + value=cleaned_value, + ) + return + artifacts.selections.append(ChatSelectionArtifactResponse(label=label, value=cleaned_value)) + + +def _selection_label(question: str, header: str | None = None) -> str: + combined = f"{header or ''} {question}".lower() + if "agent" in combined: + return "Agent" + if "model" in combined: + return "Model" + if "deployment" in combined: + return "Deployment" + if "fileset" in combined: + return "Fileset" + if "dataset" in combined: + return "Dataset" + if "provider" in combined: + return "Provider" + + label = header or question.strip().rstrip("?") + return label[:40] if len(label) > 40 else label + + +def _decode_answer_pair_value(value: str) -> str: + try: + decoded = json.loads(f'"{value}"') + except json.JSONDecodeError: + return value.replace('\\"', '"').replace("\\\\", "\\") + return decoded if isinstance(decoded, str) else value + + +def _record_answer_selections( + artifacts: ChatArtifactsResponse, + text: str, + question_labels: dict[str, str] | None = None, +) -> None: + for match in _ANSWER_PAIR_RE.finditer(text): + question = _decode_answer_pair_value(match.group(1)).strip() + answer = _decode_answer_pair_value(match.group(2)).strip() + if not question or not answer: + continue + label = question_labels.get(question) if question_labels else None + _set_selection_artifact(artifacts, label or _selection_label(question), answer) + + +def _ask_user_question_labels(input_value: Any) -> dict[str, str]: + if not isinstance(input_value, dict): + return {} + + questions = input_value.get("questions") + if not isinstance(questions, list): + question = _string_value(input_value.get("question")) + if not question: + return {} + return {question: _selection_label(question, _string_value(input_value.get("header")))} + + labels: dict[str, str] = {} + for question_value in questions: + if not isinstance(question_value, dict): + continue + question = _string_value(question_value.get("question")) + if not question: + continue + labels[question] = _selection_label(question, _string_value(question_value.get("header"))) + return labels + + +def _upsert_file_artifact(artifacts: ChatArtifactsResponse, action: str, path: str) -> None: + for index, file_artifact in enumerate(artifacts.files): + if file_artifact.path == path: + artifacts.files[index] = ChatFileArtifactResponse(action=action, path=path) + return + artifacts.files.append(ChatFileArtifactResponse(action=action, path=path)) + + +def _append_link_artifact(artifacts: ChatArtifactsResponse, input_value: Any) -> None: + if not isinstance(input_value, dict): + return + destination = _string_value(input_value.get("destination")) + label = _string_value(input_value.get("label")) or destination + if not label: + return + + for link in artifacts.links: + if link.label == label and link.destination == destination: + return + artifacts.links.append(ChatLinkArtifactResponse(label=label, destination=destination)) + + +def _normalize_spec_line(line: str) -> str: + normalized = line.strip() + normalized = re.sub(r"^#{1,6}\s+", "", normalized) + normalized = re.sub(r"^\s*[-*]\s+", "", normalized) + return normalized.replace("**", "").strip() + + +def _normalize_heading(line: str) -> str: + return _normalize_spec_line(line).removesuffix(":").strip().lower() + + +def _inline_spec_value(text: str, label: str) -> str | None: + prefix = f"{label.lower()}:" + for line in text.splitlines(): + normalized = _normalize_spec_line(line) + if not normalized.lower().startswith(prefix): + continue + return _string_value(normalized[len(prefix) :]) + return None + + +def _clean_spec_value(value: str) -> str: + normalized = _normalize_spec_line(value) + without_parenthetical = re.sub(r"\s+\([^)]*\)\s*$", "", normalized).strip() + return _clean_artifact_value(without_parenthetical or normalized) + + +def _section_spec_value(text: str, heading: str) -> str | None: + lines = text.splitlines() + target_heading = heading.lower() + for index, line in enumerate(lines): + if _normalize_heading(line) != target_heading: + continue + for value_line in lines[index + 1 :]: + normalized = _normalize_spec_line(value_line) + if not normalized: + continue + if _normalize_heading(normalized) in _SPEC_HEADINGS: + return None + return _clean_spec_value(normalized) + return None + + +def _record_spec_text_artifacts(artifacts: ChatArtifactsResponse, text: str) -> None: + agent_name = _inline_spec_value(text, "Name") or _inline_spec_value(text, "Draft Spec") + if agent_name: + artifacts.agent = _clean_spec_value(agent_name) + + model = _section_spec_value(text, "Model") or _inline_spec_value(text, "Model") + if model: + _set_spec_model(artifacts, _clean_spec_value(model)) + + +def _record_tool_artifacts( + artifacts: ChatArtifactsResponse, + tool_name: str, + input_value: Any, + tool_use_id: str | None, + question_labels_by_tool_use_id: dict[str, dict[str, str]], +) -> None: + if tool_name == "AskUserQuestion" and tool_use_id: + labels = _ask_user_question_labels(input_value) + if labels: + question_labels_by_tool_use_id[tool_use_id] = labels + + action = _FILE_CHANGE_TOOL_ACTIONS.get(tool_name) + if action and isinstance(input_value, dict): + path = _string_value(input_value.get("file_path")) or _string_value(input_value.get("path")) + if path: + _upsert_file_artifact(artifacts, action, path) + + if tool_name == "studio_link" or tool_name.endswith("__studio_link"): + _append_link_artifact(artifacts, input_value) + + +def _record_workspace_artifact(artifacts: ChatArtifactsResponse, content: str) -> None: + if artifacts.workspace: + return + match = _STUDIO_CONTEXT_WORKSPACE_RE.search(content) + if match: + artifacts.workspace = match.group("workspace").strip() + + +def _record_user_tool_result_artifacts( + artifacts: ChatArtifactsResponse, + content: Any, + question_labels_by_tool_use_id: dict[str, dict[str, str]], +) -> None: + if not isinstance(content, list): + return + + for part in content: + if not isinstance(part, dict) or part.get("type") != "tool_result": + continue + result_text = _string_value(part.get("content")) + if not result_text: + continue + tool_use_id = _string_value(part.get("tool_use_id")) + labels = question_labels_by_tool_use_id.get(tool_use_id or "") + _record_answer_selections(artifacts, result_text, labels) def _record_assistant_tool_calls( summary: HistorySummary, message: dict[str, Any], seen_tool_use_ids: set[str], + question_labels_by_tool_use_id: dict[str, dict[str, str]], ) -> None: for part in message.get("content") or []: - if not isinstance(part, dict) or part.get("type") != "tool_use": + if not isinstance(part, dict): + continue + if part.get("type") == "text": + text = _string_value(part.get("text")) + if text: + _record_spec_text_artifacts(summary.chat_artifacts, text) + continue + if part.get("type") != "tool_use": continue tool_use_id = part.get("id") if isinstance(tool_use_id, str): @@ -181,13 +492,22 @@ def _record_assistant_tool_calls( continue seen_tool_use_ids.add(tool_use_id) tool_name = part.get("name") - _append_tool_call(summary, tool_name if isinstance(tool_name, str) and tool_name else "tool") + tool_name = tool_name if isinstance(tool_name, str) and tool_name else "tool" + _append_tool_call(summary, tool_name) + _record_tool_artifacts( + summary.chat_artifacts, + tool_name, + part.get("input") or {}, + tool_use_id if isinstance(tool_use_id, str) else None, + question_labels_by_tool_use_id, + ) def _summarize_history_session(path: Path) -> HistorySummary: summary = HistorySummary() seen_usage_events: set[tuple[str, str]] = set() seen_tool_use_ids: set[str] = set() + question_labels_by_tool_use_id: dict[str, dict[str, str]] = {} try: with path.open("r", encoding="utf-8", errors="replace") as fh: for line in fh: @@ -205,6 +525,7 @@ def _summarize_history_session(path: Path) -> HistorySummary: message = entry.get("message") if isinstance(message, dict): + _set_coding_agent_model(summary.chat_artifacts, _string_value(message.get("model"))) usage_identity = _usage_identity(entry, message) if usage_identity is None or usage_identity not in seen_usage_events: summary.token_count += _usage_token_count(message.get("usage")) @@ -215,14 +536,26 @@ def _summarize_history_session(path: Path) -> HistorySummary: entry_type = entry.get("type") if entry_type == "assistant" and isinstance(message, dict): - _record_assistant_tool_calls(summary, message, seen_tool_use_ids) + _record_assistant_tool_calls( + summary, + message, + seen_tool_use_ids, + question_labels_by_tool_use_id, + ) elif entry_type == "user" and isinstance(message, dict): content = message.get("content") - if not isinstance(content, str): - continue - summary.message_count += 1 - if summary.first_prompt is None: - summary.first_prompt = content + if isinstance(content, str): + _record_workspace_artifact(summary.chat_artifacts, content) + _record_answer_selections(summary.chat_artifacts, content) + summary.message_count += 1 + if summary.first_prompt is None: + summary.first_prompt = content + else: + _record_user_tool_result_artifacts( + summary.chat_artifacts, + content, + question_labels_by_tool_use_id, + ) except OSError: return HistorySummary() return summary @@ -294,6 +627,7 @@ def list_history_sessions() -> list[HistorySessionResponse]: token_count=summary.token_count, tool_call_count=summary.tool_call_count, tool_calls=summary.tool_calls, + chat_artifacts=summary.chat_artifacts, ) ) sessions.sort(key=lambda session: session.mtime, reverse=True) @@ -309,6 +643,7 @@ def get_session_history(session_id: str) -> SessionHistoryResponse: raise HTTPException(status_code=404, detail="no such session history") items: list[dict[str, Any]] = [] + summary = _summarize_history_session(path) try: with path.open("r", encoding="utf-8", errors="replace") as fh: for line in fh: @@ -336,7 +671,7 @@ def get_session_history(session_id: str) -> SessionHistoryResponse: raise HTTPException(status_code=500, detail=str(exc)) from exc _initialized_sessions.add(sid) - return SessionHistoryResponse(session_id=sid, items=items) + return SessionHistoryResponse(session_id=sid, items=items, chat_artifacts=summary.chat_artifacts) def _mcp_url(request: Request, session_id: str) -> str: diff --git a/services/studio/tests/unit/test_coding_agents.py b/services/studio/tests/unit/test_coding_agents.py index 778e2dd697..fd26d42236 100644 --- a/services/studio/tests/unit/test_coding_agents.py +++ b/services/studio/tests/unit/test_coding_agents.py @@ -79,6 +79,7 @@ def test_list_and_get_history_sessions( "type": "assistant", "message": { "id": "msg_1", + "model": "claude-sonnet-4-5", "content": [ {"type": "thinking", "thinking": "checking"}, {"type": "text", "text": "done"}, @@ -99,6 +100,88 @@ def test_list_and_get_history_sessions( "requestId": "req_1", } ), + json.dumps( + { + "type": "assistant", + "message": { + "id": "msg_2", + "model": "claude-sonnet-4-6", + "content": [ + { + "type": "tool_use", + "id": "toolu_write", + "name": "Write", + "input": {"file_path": "agents/beach-finder.yml", "content": "name: beach-finder"}, + }, + { + "type": "tool_use", + "id": "toolu_link", + "name": "mcp__nemo_studio__studio_link", + "input": {"destination": "agents", "label": "Agents"}, + }, + { + "type": "tool_use", + "id": "toolu_question", + "name": "AskUserQuestion", + "input": { + "questions": [ + { + "question": "Which agent should be used?", + "header": "Agent", + "options": [{"label": "beach-finder"}], + } + ] + }, + }, + ], + }, + "requestId": "req_2", + } + ), + json.dumps( + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_question", + "content": ( + 'Your question has been answered: "Which agent should be used?"=' + '"beach-finder". You can now continue with this answer in mind.' + ), + } + ] + }, + } + ), + json.dumps( + { + "type": "assistant", + "message": { + "id": "msg_3", + "model": "claude-sonnet-4-6", + "content": [ + { + "type": "text", + "text": "\n".join( + [ + "Draft Spec: `cat-identifier`", + "Name: `cat-identifier`", + "", + "Model", + "`cloud, nvidia/llama-3.3-nemotron-super-49b-v1` - default, good reasoning", + "", + "Framework", + "langgraph-nat", + ] + ), + } + ], + }, + "requestId": "req_3", + } + ), json.dumps( { "type": "user", @@ -133,8 +216,19 @@ def test_list_and_get_history_sessions( "first_prompt": "first prompt", "message_count": 1, "token_count": 30, - "tool_call_count": 1, - "tool_calls": ["Bash"], + "tool_call_count": 4, + "tool_calls": ["Bash", "Write", "mcp__nemo_studio__studio_link", "AskUserQuestion"], + "chat_artifacts": { + "agent": "cat-identifier", + "model": "cloud, nvidia/llama-3.3-nemotron-super-49b-v1", + "model_source": "spec", + "coding_agent_model": "claude-sonnet-4-6", + "workspace": None, + "selections": [], + "files": [{"action": "Wrote", "path": "agents/beach-finder.yml"}], + "links": [{"label": "Agents", "destination": "agents"}], + "tools": ["Bash", "Write", "mcp__nemo_studio__studio_link", "AskUserQuestion"], + }, } ] @@ -153,7 +247,66 @@ def test_list_and_get_history_sessions( {"type": "tool_use", "name": "Bash", "input": {"command": "pwd"}}, ], }, + { + "kind": "assistant", + "parts": [ + { + "type": "tool_use", + "name": "Write", + "input": {"file_path": "agents/beach-finder.yml", "content": "name: beach-finder"}, + }, + { + "type": "tool_use", + "name": "mcp__nemo_studio__studio_link", + "input": {"destination": "agents", "label": "Agents"}, + }, + { + "type": "tool_use", + "name": "AskUserQuestion", + "input": { + "questions": [ + { + "question": "Which agent should be used?", + "header": "Agent", + "options": [{"label": "beach-finder"}], + } + ] + }, + }, + ], + }, + { + "kind": "assistant", + "parts": [ + { + "type": "text", + "text": "\n".join( + [ + "Draft Spec: `cat-identifier`", + "Name: `cat-identifier`", + "", + "Model", + "`cloud, nvidia/llama-3.3-nemotron-super-49b-v1` - default, good reasoning", + "", + "Framework", + "langgraph-nat", + ] + ), + } + ], + }, ], + "chat_artifacts": { + "agent": "cat-identifier", + "model": "cloud, nvidia/llama-3.3-nemotron-super-49b-v1", + "model_source": "spec", + "coding_agent_model": "claude-sonnet-4-6", + "workspace": None, + "selections": [], + "files": [{"action": "Wrote", "path": "agents/beach-finder.yml"}], + "links": [{"label": "Agents", "destination": "agents"}], + "tools": ["Bash", "Write", "mcp__nemo_studio__studio_link", "AskUserQuestion"], + }, } assert session_id in coding_agents._initialized_sessions diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.tsx index 27ece8194d..ef277a1271 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.tsx @@ -15,24 +15,38 @@ import { CLAUDE_CODE_HISTORY_SESSIONS_QUERY_KEY, listClaudeCodeHistorySessions, } from '@studio/routes/agents/ClaudeCodeChatRoute/api'; -import type { ClaudeCodeHistorySession } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import { cleanClaudeCodeArtifactText } from '@studio/routes/agents/ClaudeCodeChatRoute/artifacts'; +import type { + ClaudeCodeChatArtifacts, + ClaudeCodeChatFileArtifact, + ClaudeCodeChatLinkArtifact, + ClaudeCodeChatSelectionArtifact, + 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 { + Bot, + Boxes, + Cpu, + FileCode2, History, + Link2, MessageSquare, MessageSquarePlus, PanelRightClose, PanelRightOpen, RefreshCw, + Sparkles, Wrench, } from 'lucide-react'; -import { type FC } from 'react'; +import { type FC, type ReactNode } from 'react'; interface ClaudeCodeHistoryPanelProps { activeSessionId?: string; + artifacts?: ClaudeCodeChatArtifacts; onNewChat: () => void; onSelectSession: (sessionId: string) => void; } @@ -77,6 +91,228 @@ const ToolCallSummary = ({ toolCalls }: { toolCalls: string[] }) => { ); }; +const ArtifactChip = ({ children }: { children: ReactNode }) => { + const content = typeof children === 'string' ? cleanClaudeCodeArtifactText(children) : children; + + return ( + + + {content} + + + ); +}; + +const ArtifactRow = ({ + icon, + label, + value, +}: { + icon: ReactNode; + label: string; + value?: string; +}) => { + if (!value) return null; + + return ( + + + {icon} + + + + {label}: + + {value} + + + ); +}; + +const ArtifactSection = ({ + background, + children, + icon, + title, +}: { + background?: boolean; + children: ReactNode; + icon: ReactNode; + title: string; +}) => ( + + + {icon} + + {title} + + + {children} + +); + +const getFileLabel = (file: ClaudeCodeChatFileArtifact): string => { + const parts = file.path.split('/'); + return parts[parts.length - 1] || file.path; +}; + +const FileArtifacts = ({ files }: { files: ClaudeCodeChatFileArtifact[] }) => { + if (!files.length) return null; + + return ( + } title="Files"> + + {files.slice(0, 6).map((file) => ( + + + {file.action} + + + {getFileLabel(file)} + + + ))} + + + ); +}; + +const LinkArtifacts = ({ links }: { links: ClaudeCodeChatLinkArtifact[] }) => { + if (!links.length) return null; + + return ( + } title="Studio links"> + + {links.slice(0, 6).map((link) => ( + + {link.label} + + ))} + + + ); +}; + +const SelectionArtifacts = ({ selections }: { selections: ClaudeCodeChatSelectionArtifact[] }) => { + if (!selections.length) return null; + + return ( + } title="Selections"> + + {selections.slice(0, 6).map((selection) => ( + } + label={selection.label} + value={selection.value} + /> + ))} + + + ); +}; + +const ToolArtifacts = ({ tools }: { tools: string[] }) => { + if (!tools.length) return null; + + return ( + } title="Tools"> + + {tools.slice(0, 8).map((tool) => ( + {tool} + ))} + + + ); +}; + +const getSelectedArtifactModel = (artifacts: ClaudeCodeChatArtifacts): string | undefined => + artifacts.model_source === 'selection' || artifacts.model_source === 'spec' + ? artifacts.model + : undefined; + +const hasArtifacts = (artifacts?: ClaudeCodeChatArtifacts): artifacts is ClaudeCodeChatArtifacts => + !!artifacts && + !!( + artifacts.agent || + getSelectedArtifactModel(artifacts) || + artifacts.workspace || + artifacts.selections.length || + artifacts.files.length || + artifacts.links.length || + artifacts.tools.length + ); + +const ClaudeCodeArtifactsPane = ({ + artifacts, + collapseLabel, + onCollapse, +}: { + artifacts?: ClaudeCodeChatArtifacts; + collapseLabel: string; + onCollapse: () => void; +}) => { + const selectedModel = artifacts ? getSelectedArtifactModel(artifacts) : undefined; + + return ( +
+ + + + + Chat artifacts + + + + + + + {hasArtifacts(artifacts) ? ( + + + } label="Agent" value={artifacts.agent} /> + } label="Model" value={selectedModel} /> + } label="Workspace" value={artifacts.workspace} /> + + + + + + + ) : ( + + + + )} +
+ ); +}; + const HistorySessionButton = ({ active, onSelect, @@ -124,18 +360,11 @@ const HistorySessionButton = ({ ); -interface HistoryPanelContentsProps extends ClaudeCodeHistoryPanelProps { - collapseLabel: string; - onCollapse: () => void; -} - const HistoryPanelContents = ({ activeSessionId, - collapseLabel, - onCollapse, onNewChat, onSelectSession, -}: HistoryPanelContentsProps) => { +}: ClaudeCodeHistoryPanelProps) => { const { data: sessions = [], error, @@ -147,7 +376,7 @@ const HistoryPanelContents = ({ }); return ( - <> +
- - -
@@ -224,7 +442,7 @@ const HistoryPanelContents = ({ ) : null} - +
); }; @@ -252,12 +470,13 @@ export const ClaudeCodeHistoryPanel: FC = (props) = } return ( - ); }; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeLayout.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeLayout.tsx index c773e079ce..295b5aa402 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeLayout.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeLayout.tsx @@ -4,6 +4,7 @@ import { Flex } from '@nvidia/foundations-react-core'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { ClaudeCodeHistoryPanel } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel'; +import type { ClaudeCodeChatArtifacts } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; import { getClaudeCodeChatRouteForSession } from '@studio/routes/agents/ClaudeCodeChatRoute/util'; import { getWorkspaceDashboardRoute } from '@studio/routes/utils'; import { type FC, type ReactNode, useCallback } from 'react'; @@ -11,10 +12,15 @@ import { useNavigate } from 'react-router-dom'; interface ClaudeCodeLayoutProps { activeSessionId?: string; + artifacts?: ClaudeCodeChatArtifacts; children: ReactNode; } -export const ClaudeCodeLayout: FC = ({ activeSessionId, children }) => { +export const ClaudeCodeLayout: FC = ({ + activeSessionId, + artifacts, + children, +}) => { const workspace = useWorkspaceFromPath(); const navigate = useNavigate(); @@ -34,6 +40,7 @@ export const ClaudeCodeLayout: FC = ({ activeSessionId, c {children} diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts index 4d8ae9e5dd..7eff61d622 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts @@ -2,9 +2,19 @@ // SPDX-License-Identifier: Apache-2.0 import { PLATFORM_BASE_URL } from '@studio/constants/environment'; +import { + cleanClaudeCodeArtifactText, + createEmptyClaudeCodeChatArtifacts, + updateClaudeCodeChatArtifactsFromHistoryItems, +} from '@studio/routes/agents/ClaudeCodeChatRoute/artifacts'; import { parseJsonObject, parseSseChunk } from '@studio/routes/agents/ClaudeCodeChatRoute/stream'; import type { ClaudeCodeAssistantHistoryPart, + ClaudeCodeChatArtifacts, + ClaudeCodeChatFileArtifact, + ClaudeCodeChatLinkArtifact, + ClaudeCodeChatModelSource, + ClaudeCodeChatSelectionArtifact, ClaudeCodeHistorySession, ClaudeCodePermissionDecision, ClaudeCodePermissionRequest, @@ -71,6 +81,69 @@ const getNumber = (value: unknown): number => const getStringArray = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; +const getOptionalString = (value: unknown): string | undefined => { + const text = getString(value).trim(); + return text || undefined; +}; + +const getOptionalArtifactString = (value: unknown): string | undefined => { + const text = getOptionalString(value); + return text ? cleanClaudeCodeArtifactText(text) : undefined; +}; + +const parseModelSource = (value: unknown): ClaudeCodeChatModelSource | undefined => { + if (value === 'coding_agent' || value === 'selection' || value === 'spec') return value; + return undefined; +}; + +const parseSelectionArtifact = (value: unknown): ClaudeCodeChatSelectionArtifact | undefined => { + if (!isRecord(value)) return undefined; + const label = getOptionalString(value.label); + const artifactValue = getOptionalArtifactString(value.value); + if (!label || !artifactValue) return undefined; + return { label, value: artifactValue }; +}; + +const parseFileArtifact = (value: unknown): ClaudeCodeChatFileArtifact | undefined => { + if (!isRecord(value)) return undefined; + const action = getOptionalString(value.action); + const path = getOptionalString(value.path); + if (!action || !path) return undefined; + return { action, path }; +}; + +const parseLinkArtifact = (value: unknown): ClaudeCodeChatLinkArtifact | undefined => { + if (!isRecord(value)) return undefined; + const label = getOptionalString(value.label); + if (!label) return undefined; + return { label, destination: getOptionalString(value.destination) }; +}; + +const parseArray = (value: unknown, parseItem: (item: unknown) => T | undefined): T[] => + Array.isArray(value) ? value.map(parseItem).filter((item): item is T => item !== undefined) : []; + +const parseChatArtifacts = (value: unknown): ClaudeCodeChatArtifacts => { + if (!isRecord(value)) return createEmptyClaudeCodeChatArtifacts(); + + const modelSource = parseModelSource(value.model_source); + const model = getOptionalArtifactString(value.model); + const codingAgentModel = + getOptionalArtifactString(value.coding_agent_model) || + (modelSource === 'coding_agent' ? model : undefined); + + return { + agent: getOptionalArtifactString(value.agent), + model: modelSource === 'coding_agent' ? undefined : model, + model_source: modelSource === 'coding_agent' ? undefined : modelSource, + coding_agent_model: codingAgentModel, + workspace: getOptionalArtifactString(value.workspace), + selections: parseArray(value.selections, parseSelectionArtifact), + files: parseArray(value.files, parseFileArtifact), + links: parseArray(value.links, parseLinkArtifact), + tools: getStringArray(value.tools).map(cleanClaudeCodeArtifactText), + }; +}; + const parseHistorySession = (value: unknown): ClaudeCodeHistorySession | undefined => { if (!isRecord(value)) return undefined; const sessionId = getString(value.session_id); @@ -84,6 +157,7 @@ const parseHistorySession = (value: unknown): ClaudeCodeHistorySession | undefin token_count: getNumber(value.token_count), tool_call_count: getNumber(value.tool_call_count), tool_calls: getStringArray(value.tool_calls), + chat_artifacts: parseChatArtifacts(value.chat_artifacts), }; }; @@ -155,13 +229,19 @@ export const getClaudeCodeSessionHistory = async ( throw new Error('Claude Code session history response was not an object'); } + const items = Array.isArray(body.items) + ? body.items + .map(parseSessionHistoryItem) + .filter((item): item is ClaudeCodeSessionHistoryItem => item !== undefined) + : []; + return { session_id: getString(body.session_id) || sessionId, - items: Array.isArray(body.items) - ? body.items - .map(parseSessionHistoryItem) - .filter((item): item is ClaudeCodeSessionHistoryItem => item !== undefined) - : [], + items, + chat_artifacts: updateClaudeCodeChatArtifactsFromHistoryItems( + parseChatArtifacts(body.chat_artifacts), + items + ), }; }; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/artifacts.spec.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/artifacts.spec.ts new file mode 100644 index 0000000000..b7e7fb5448 --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/artifacts.spec.ts @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + createEmptyClaudeCodeChatArtifacts, + updateClaudeCodeChatArtifactsFromEvent, + updateClaudeCodeChatArtifactsFromHistoryItems, + updateClaudeCodeChatArtifactsFromSelections, +} from '@studio/routes/agents/ClaudeCodeChatRoute/artifacts'; + +describe('Claude Code chat artifacts', () => { + it('keeps the latest streamed coding-agent model', () => { + const initial = createEmptyClaudeCodeChatArtifacts(); + const first = updateClaudeCodeChatArtifactsFromEvent(initial, { + type: 'assistant', + message: { model: 'claude-sonnet-4-5', content: [] }, + }); + const updated = updateClaudeCodeChatArtifactsFromEvent(first, { + type: 'assistant', + message: { model: 'claude-sonnet-4-6', content: [] }, + }); + + expect(updated.model).toBeUndefined(); + expect(updated.model_source).toBeUndefined(); + expect(updated.coding_agent_model).toBe('claude-sonnet-4-6'); + }); + + it('promotes agent and selected model answers while preserving coding-agent model', () => { + const withCodingModel = updateClaudeCodeChatArtifactsFromEvent( + createEmptyClaudeCodeChatArtifacts(), + { + type: 'assistant', + message: { model: 'claude-sonnet-4-6', content: [] }, + } + ); + + const withSelections = updateClaudeCodeChatArtifactsFromSelections( + withCodingModel, + [ + { header: 'Agent', question: 'Which agent should be used?' }, + { header: 'Model', question: 'Which inference provider and model should be used?' }, + { header: 'Dataset type', question: 'What kind of dataset do you want to generate?' }, + ], + { + 'Which agent should be used?': 'beach-finder', + 'Which inference provider and model should be used?': + 'nvidia-build - meta/llama-3.3-70b-instruct', + 'What kind of dataset do you want to generate?': 'Text classification', + } + ); + + expect(withSelections.agent).toBe('beach-finder'); + expect(withSelections.model).toBe('nvidia-build - meta/llama-3.3-70b-instruct'); + expect(withSelections.model_source).toBe('selection'); + expect(withSelections.coding_agent_model).toBe('claude-sonnet-4-6'); + expect(withSelections.selections).toEqual([{ label: 'Dataset', value: 'Text classification' }]); + }); + + it('collects relevant tool artifacts from streamed events', () => { + const artifacts = updateClaudeCodeChatArtifactsFromEvent(createEmptyClaudeCodeChatArtifacts(), { + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + name: 'Write', + input: { file_path: 'agents/beach-finder.yml' }, + }, + { + type: 'tool_use', + name: 'mcp__nemo_studio__studio_link', + input: { destination: 'agents', label: 'Agents' }, + }, + ], + }, + }); + + expect(artifacts.files).toEqual([{ action: 'Wrote', path: 'agents/beach-finder.yml' }]); + expect(artifacts.links).toEqual([{ label: 'Agents', destination: 'agents' }]); + expect(artifacts.tools).toEqual(['Write', 'mcp__nemo_studio__studio_link']); + }); + + it('promotes draft spec name and model over the coding-agent model', () => { + const withCodingModel = updateClaudeCodeChatArtifactsFromEvent( + createEmptyClaudeCodeChatArtifacts(), + { + type: 'assistant', + message: { model: 'claude-sonnet-4-6', content: [] }, + } + ); + + const withSpecModel = updateClaudeCodeChatArtifactsFromEvent(withCodingModel, { + type: 'assistant', + message: { + content: [ + { + type: 'text', + text: [ + 'Draft Spec: `cat-identifier`', + 'Name: `cat-identifier`', + '', + 'Model', + '`cloud, nvidia/llama-3.3-nemotron-super-49b-v1` - default, good reasoning', + '', + 'Framework', + 'langgraph-nat', + ].join('\n'), + }, + ], + }, + }); + const afterCodeModelUpdate = updateClaudeCodeChatArtifactsFromEvent(withSpecModel, { + type: 'assistant', + message: { model: 'claude-opus-4-6', content: [] }, + }); + + expect(afterCodeModelUpdate.agent).toBe('cat-identifier'); + expect(afterCodeModelUpdate.model).toBe('cloud, nvidia/llama-3.3-nemotron-super-49b-v1'); + expect(afterCodeModelUpdate.model_source).toBe('spec'); + expect(afterCodeModelUpdate.coding_agent_model).toBe('claude-opus-4-6'); + }); + + it('derives spec artifacts from loaded transcript items', () => { + const artifacts = updateClaudeCodeChatArtifactsFromHistoryItems( + createEmptyClaudeCodeChatArtifacts(), + [ + { kind: 'user', text: 'draft a cat identifier' }, + { + kind: 'assistant', + parts: [ + { + type: 'text', + text: [ + 'Name: `cat-identifier`', + '', + 'Model', + '`cloud, nvidia/llama-3.3-nemotron-super-49b-v1`', + ].join('\n'), + }, + ], + }, + ] + ); + + expect(artifacts.agent).toBe('cat-identifier'); + expect(artifacts.model).toBe('cloud, nvidia/llama-3.3-nemotron-super-49b-v1'); + expect(artifacts.model_source).toBe('spec'); + }); +}); diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/artifacts.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/artifacts.ts new file mode 100644 index 0000000000..ac6f583c54 --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/artifacts.ts @@ -0,0 +1,304 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ClaudeCodeChatArtifacts, + ClaudeCodeChatFileArtifact, + ClaudeCodeChatLinkArtifact, + ClaudeCodeChatSelectionArtifact, + ClaudeCodeSessionHistoryItem, +} from '@studio/routes/agents/ClaudeCodeChatRoute/types'; + +interface ClaudeCodeArtifactQuestion { + header?: string; + question: string; +} + +const FILE_CHANGE_TOOL_ACTIONS = new Map([ + ['Edit', 'Edited'], + ['MultiEdit', 'Edited'], + ['Write', 'Wrote'], +]); + +const STUDIO_CONTEXT_WORKSPACE_RE = /^Current Studio workspace:\s*(?.+)$/m; +const SPEC_HEADINGS = new Set([ + 'behavior', + 'change scope', + 'evaluation setup', + 'framework', + 'harness', + 'model', + 'name', + 'open questions', + 'purpose', + 'role', + 'scope', + 'signals', + 'success criteria', + 'tools', +]); + +export const createEmptyClaudeCodeChatArtifacts = (): ClaudeCodeChatArtifacts => ({ + selections: [], + files: [], + links: [], + tools: [], +}); + +export const cleanClaudeCodeArtifactText = (value: string): string => { + const trimmed = value.trim(); + const inlineCodeMatch = trimmed.match(/(`+)([\s\S]*?)\1/); + if (!inlineCodeMatch) return trimmed; + + const unwrapped = inlineCodeMatch[2]?.trim(); + return unwrapped || trimmed; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const getString = (value: unknown): string | undefined => { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +}; + +const cloneArtifacts = (artifacts: ClaudeCodeChatArtifacts): ClaudeCodeChatArtifacts => ({ + agent: artifacts.agent, + model: artifacts.model, + model_source: artifacts.model_source, + coding_agent_model: artifacts.coding_agent_model, + workspace: artifacts.workspace, + selections: [...artifacts.selections], + files: [...artifacts.files], + links: [...artifacts.links], + tools: [...artifacts.tools], +}); + +const pushUnique = (items: string[], value: string) => { + if (!items.includes(value)) items.push(value); +}; + +const inferSelectionLabel = (question: string, header?: string): string => { + const combined = `${header ?? ''} ${question}`.toLowerCase(); + if (combined.includes('agent')) return 'Agent'; + if (combined.includes('model')) return 'Model'; + if (combined.includes('deployment')) return 'Deployment'; + if (combined.includes('fileset')) return 'Fileset'; + if (combined.includes('dataset')) return 'Dataset'; + if (combined.includes('provider')) return 'Provider'; + + const label = header?.trim() || question.trim().replace(/\?$/, ''); + return label.length > 40 ? label.slice(0, 40) : label; +}; + +const setCodingAgentModel = (artifacts: ClaudeCodeChatArtifacts, model: string) => { + artifacts.coding_agent_model = model; +}; + +const setSpecModel = (artifacts: ClaudeCodeChatArtifacts, model: string) => { + artifacts.model = model; + artifacts.model_source = 'spec'; +}; + +const setSelection = ( + artifacts: ClaudeCodeChatArtifacts, + selection: ClaudeCodeChatSelectionArtifact +) => { + const cleanedSelection: ClaudeCodeChatSelectionArtifact = { + ...selection, + value: cleanClaudeCodeArtifactText(selection.value), + }; + + if (cleanedSelection.label === 'Agent') { + artifacts.agent = cleanedSelection.value; + return; + } + if (cleanedSelection.label === 'Model') { + artifacts.model = cleanedSelection.value; + artifacts.model_source = 'selection'; + return; + } + + const existingIndex = artifacts.selections.findIndex( + (item) => item.label === cleanedSelection.label + ); + if (existingIndex >= 0) { + artifacts.selections[existingIndex] = cleanedSelection; + return; + } + artifacts.selections.push(cleanedSelection); +}; + +const upsertFile = (artifacts: ClaudeCodeChatArtifacts, file: ClaudeCodeChatFileArtifact) => { + const existingIndex = artifacts.files.findIndex((item) => item.path === file.path); + if (existingIndex >= 0) { + artifacts.files[existingIndex] = file; + return; + } + artifacts.files.push(file); +}; + +const appendLink = (artifacts: ClaudeCodeChatArtifacts, link: ClaudeCodeChatLinkArtifact) => { + if ( + artifacts.links.some( + (item) => item.label === link.label && item.destination === link.destination + ) + ) { + return; + } + artifacts.links.push(link); +}; + +const normalizeSpecLine = (line: string): string => + line + .trim() + .replace(/^#{1,6}\s+/, '') + .replace(/^\s*[-*]\s+/, '') + .replace(/\*\*/g, '') + .trim(); + +const normalizeHeading = (line: string): string => + normalizeSpecLine(line).replace(/:$/, '').trim().toLowerCase(); + +const getInlineSpecValue = (text: string, label: string): string | undefined => { + const prefix = `${label.toLowerCase()}:`; + + for (const line of text.split('\n')) { + const normalized = normalizeSpecLine(line); + if (!normalized.toLowerCase().startsWith(prefix)) continue; + + return getString(normalized.slice(prefix.length)); + } + + return undefined; +}; + +const cleanSpecValue = (value: string): string => { + const normalized = normalizeSpecLine(value); + const withoutParenthetical = normalized.replace(/\s+\([^)]*\)\s*$/, '').trim(); + return cleanClaudeCodeArtifactText(withoutParenthetical || normalized); +}; + +const getSectionSpecValue = (text: string, heading: string): string | undefined => { + const lines = text.split('\n'); + const targetHeading = heading.toLowerCase(); + + for (let index = 0; index < lines.length; index += 1) { + if (normalizeHeading(lines[index] ?? '') !== targetHeading) continue; + + for (let valueIndex = index + 1; valueIndex < lines.length; valueIndex += 1) { + const normalized = normalizeSpecLine(lines[valueIndex] ?? ''); + if (!normalized) continue; + if (SPEC_HEADINGS.has(normalizeHeading(normalized))) return undefined; + return cleanSpecValue(normalized); + } + } + + return undefined; +}; + +const recordSpecTextArtifacts = (artifacts: ClaudeCodeChatArtifacts, text: string) => { + const agentName = getInlineSpecValue(text, 'Name') ?? getInlineSpecValue(text, 'Draft Spec'); + if (agentName) artifacts.agent = cleanSpecValue(agentName); + + const specModel = getSectionSpecValue(text, 'Model') ?? getInlineSpecValue(text, 'Model'); + if (specModel) setSpecModel(artifacts, cleanSpecValue(specModel)); +}; + +const recordToolArtifacts = ( + artifacts: ClaudeCodeChatArtifacts, + toolName: string, + input: unknown +) => { + pushUnique(artifacts.tools, toolName); + + const action = FILE_CHANGE_TOOL_ACTIONS.get(toolName); + if (action && isRecord(input)) { + const path = getString(input.file_path) ?? getString(input.path); + if (path) upsertFile(artifacts, { action, path }); + } + + if ((toolName === 'studio_link' || toolName.endsWith('__studio_link')) && isRecord(input)) { + const destination = getString(input.destination); + const label = getString(input.label) ?? destination; + if (label) appendLink(artifacts, { label, destination }); + } +}; + +export const updateClaudeCodeChatArtifactsFromEvent = ( + current: ClaudeCodeChatArtifacts, + event: unknown +): ClaudeCodeChatArtifacts => { + if (!isRecord(event)) return current; + + const next = cloneArtifacts(current); + const message = isRecord(event.message) ? event.message : undefined; + const model = getString(message?.model); + if (model) setCodingAgentModel(next, model); + + const content = message?.content; + if (!Array.isArray(content)) return next; + + for (const part of content) { + if (!isRecord(part)) continue; + + if (part.type === 'text') { + const text = getString(part.text); + if (text) recordSpecTextArtifacts(next, text); + continue; + } + + if (part.type !== 'tool_use') continue; + const toolName = getString(part.name) ?? 'tool'; + recordToolArtifacts(next, toolName, part.input); + } + + return next; +}; + +export const updateClaudeCodeChatArtifactsFromSelections = ( + current: ClaudeCodeChatArtifacts, + questions: readonly ClaudeCodeArtifactQuestion[], + answers: Record +): ClaudeCodeChatArtifacts => { + const next = cloneArtifacts(current); + + for (const question of questions) { + const answer = getString(answers[question.question]); + if (!answer) continue; + setSelection(next, { + label: inferSelectionLabel(question.question, question.header), + value: answer, + }); + } + + return next; +}; + +export const updateClaudeCodeChatArtifactsFromUserText = ( + current: ClaudeCodeChatArtifacts, + text: string +): ClaudeCodeChatArtifacts => { + const next = cloneArtifacts(current); + const workspace = text.match(STUDIO_CONTEXT_WORKSPACE_RE)?.groups?.workspace?.trim(); + if (workspace && !next.workspace) next.workspace = workspace; + return next; +}; + +export const updateClaudeCodeChatArtifactsFromHistoryItems = ( + current: ClaudeCodeChatArtifacts, + items: readonly ClaudeCodeSessionHistoryItem[] +): ClaudeCodeChatArtifacts => + items.reduce((artifacts, item) => { + if (item.kind === 'user') { + return updateClaudeCodeChatArtifactsFromUserText(artifacts, item.text); + } + + return updateClaudeCodeChatArtifactsFromEvent(artifacts, { + type: 'assistant', + message: { + content: item.parts, + }, + }); + }, current); diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/index.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/index.tsx index d51583c8bf..d1ec46e867 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/index.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/index.tsx @@ -15,7 +15,10 @@ import { } from '@studio/routes/agents/ClaudeCodeChatRoute/api'; import { ClaudeCodeLayout } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeLayout'; import { ClaudeCodeToolCallPart } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeToolCallPart'; -import type { ClaudeCodeChatRouteState } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import type { + ClaudeCodeChatArtifacts, + ClaudeCodeChatRouteState, +} from '@studio/routes/agents/ClaudeCodeChatRoute/types'; import { useClaudeCodeChatRuntime } from '@studio/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime'; import { getClaudeCodeHistoryMessages, @@ -37,6 +40,7 @@ const getInitialPrompt = (state: unknown): string | undefined => { }; interface ClaudeCodeChatSurfaceProps { + initialArtifacts?: ClaudeCodeChatArtifacts; initialMessages?: ReturnType; initialPrompt?: string; initialSessionId?: string; @@ -78,6 +82,7 @@ const ClaudeCodeChatErrorState = ({ selectedSessionId }: { selectedSessionId?: s ); const ClaudeCodeChatSurface: FC = ({ + initialArtifacts, initialMessages = [], initialPrompt, initialSessionId, @@ -89,6 +94,7 @@ const ClaudeCodeChatSurface: FC = ({ const consumedInitialPromptRef = useRef(undefined); const chatViewportRef = useRef(null); const { + artifacts, decisionChoices, decisionRequest, decisionStatus, @@ -99,6 +105,7 @@ const ClaudeCodeChatSurface: FC = ({ skipDecisionRequest, submitPrompt, } = useClaudeCodeChatRuntime({ + initialArtifacts, initialMessages, initialSessionId, onError: (error) => toast.error(error.message), @@ -141,7 +148,7 @@ const ClaudeCodeChatSurface: FC = ({ }, [decisionRequest]); return ( - + @@ -210,6 +217,7 @@ export const ClaudeCodeChatRoute: FC = () => { return ( { vi.clearAllMocks(); }); + it('exposes the latest streamed coding-agent model without promoting it to selected model', async () => { + mocks.createClaudeCodeSession.mockResolvedValue('session-1'); + mocks.streamClaudeCodeMessage.mockImplementation( + async ({ handlers }: { handlers: { onClaudeEvent: (event: unknown) => void } }) => { + handlers.onClaudeEvent({ + type: 'assistant', + message: { model: 'claude-sonnet-4-5', content: [] }, + }); + handlers.onClaudeEvent({ + type: 'assistant', + message: { model: 'claude-sonnet-4-6', content: [] }, + }); + } + ); + + const { result } = renderUseClaudeCodeChatRuntime(); + + await act(async () => { + await result.current.submitPrompt('List files'); + }); + + await waitFor(() => + expect(result.current.artifacts.coding_agent_model).toBe('claude-sonnet-4-6') + ); + expect(result.current.artifacts.model).toBeUndefined(); + expect(result.current.artifacts.model_source).toBeUndefined(); + }); + + it('syncs artifacts when historical session metadata arrives after mount', async () => { + const { rerender, result } = renderHook( + ({ model }: { model?: string }) => + useClaudeCodeChatRuntime({ + initialArtifacts: model + ? { + coding_agent_model: model, + selections: [], + files: [], + links: [], + tools: [], + } + : undefined, + }), + { initialProps: {} } + ); + + expect(result.current.artifacts.model).toBeUndefined(); + + rerender({ model: 'claude-sonnet-4-6' }); + + await waitFor(() => + expect(result.current.artifacts.coding_agent_model).toBe('claude-sonnet-4-6') + ); + expect(result.current.artifacts.model).toBeUndefined(); + }); + it('does not append denial text when permission resolution fails', async () => { const onError = vi.fn(); let finishStream!: () => void; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts index f6bf67bb35..65b5442000 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts @@ -18,11 +18,19 @@ import { resolveClaudeCodePermission, streamClaudeCodeMessage, } from '@studio/routes/agents/ClaudeCodeChatRoute/api'; +import { + createEmptyClaudeCodeChatArtifacts, + updateClaudeCodeChatArtifactsFromEvent, + updateClaudeCodeChatArtifactsFromSelections, +} from '@studio/routes/agents/ClaudeCodeChatRoute/artifacts'; import { getAssistantPartsFromClaudeEvent } from '@studio/routes/agents/ClaudeCodeChatRoute/stream'; -import type { ClaudeCodePermissionRequest } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import type { + ClaudeCodeChatArtifacts, + ClaudeCodePermissionRequest, +} from '@studio/routes/agents/ClaudeCodeChatRoute/types'; import { useCustomAssistantChatRuntime } from '@studio/routes/agents/ClaudeCodeChatRoute/useCustomAssistantChatRuntime'; import { useQueryClient } from '@tanstack/react-query'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; const ASK_USER_QUESTION_TOOL_NAME = 'AskUserQuestion'; const CUSTOM_INSTRUCTION_LABEL = 'No, and tell the Agent what to do'; @@ -182,7 +190,11 @@ const formatAskUserQuestionDisplayText = ( .filter(isDefined) .join('\n\n'); +const getArtifactsSignature = (artifacts: ClaudeCodeChatArtifacts | undefined): string => + artifacts ? JSON.stringify(artifacts) : ''; + interface UseClaudeCodeChatRuntimeOptions { + initialArtifacts?: ClaudeCodeChatArtifacts; initialMessages?: readonly ThreadMessageLike[]; initialSessionId?: string; onError?: (error: Error) => void; @@ -191,14 +203,29 @@ interface UseClaudeCodeChatRuntimeOptions { export const useClaudeCodeChatRuntime = (options?: UseClaudeCodeChatRuntimeOptions) => { const queryClient = useQueryClient(); const [sessionId, setSessionId] = useState(options?.initialSessionId ?? null); + const [artifacts, setArtifacts] = useState( + options?.initialArtifacts ?? createEmptyClaudeCodeChatArtifacts() + ); const [decisionRequest, setDecisionRequest] = useState(null); const [decisionChoices, setDecisionChoices] = useState([]); const [decisionStatus, setDecisionStatus] = useState('pending'); const sessionIdRef = useRef(options?.initialSessionId ?? null); const permissionRequestRef = useRef(null); const activeDecisionRef = useRef(null); + const initialArtifactsRef = useRef( + options?.initialArtifacts + ); + const initialArtifactsSignature = getArtifactsSignature(options?.initialArtifacts); const onError = options?.onError; + initialArtifactsRef.current = options?.initialArtifacts; + + useEffect(() => { + const nextArtifacts = initialArtifactsRef.current; + if (!nextArtifacts) return; + setArtifacts(nextArtifacts); + }, [initialArtifactsSignature]); + const ensureSessionId = useCallback(async (): Promise => { if (sessionIdRef.current) return sessionIdRef.current; @@ -288,6 +315,7 @@ export const useClaudeCodeChatRuntime = (options?: UseClaudeCodeChatRuntimeOptio onClaudeEvent: (event) => { if (signal.aborted || !isCurrentRun()) return; + setArtifacts((current) => updateClaudeCodeChatArtifactsFromEvent(current, event)); appendAssistantParts(getAssistantPartsFromClaudeEvent(event)); }, onPermissionRequest: (request) => { @@ -321,10 +349,10 @@ export const useClaudeCodeChatRuntime = (options?: UseClaudeCodeChatRuntimeOptio approved: boolean; displayText?: string; reason?: string; - }) => { + }): Promise => { const activeSessionId = sessionIdRef.current; const activeRequest = permissionRequestRef.current; - if (!activeSessionId || !activeRequest) return; + if (!activeSessionId || !activeRequest) return false; const trimmedReason = reason?.trim(); const trimmedDisplayText = displayText?.trim(); @@ -343,11 +371,13 @@ export const useClaudeCodeChatRuntime = (options?: UseClaudeCodeChatRuntimeOptio appendUserMessage(trimmedDisplayText); } clearApprovalRequest(); + return true; } catch (error: unknown) { setDecisionStatus('pending'); const errorMessage = error instanceof Error ? error.message : 'Failed to resolve Claude Code permission'; onError?.(new Error(errorMessage)); + return false; } }, [appendUserMessage, clearApprovalRequest, onError] @@ -377,11 +407,16 @@ export const useClaudeCodeChatRuntime = (options?: UseClaudeCodeChatRuntimeOptio return; } - await submitActiveDecision({ + const submitted = await submitActiveDecision({ approved: false, reason: formatAskUserQuestionReason(state.questions, answers), displayText: formatAskUserQuestionDisplayText(state.questions, answers), }); + if (submitted) { + setArtifacts((current) => + updateClaudeCodeChatArtifactsFromSelections(current, state.questions, answers) + ); + } }, [setAskUserQuestionDecision, submitActiveDecision] ); @@ -413,11 +448,13 @@ export const useClaudeCodeChatRuntime = (options?: UseClaudeCodeChatRuntimeOptio const handleReset = useCallback(() => { sessionIdRef.current = null; setSessionId(null); + setArtifacts(createEmptyClaudeCodeChatArtifacts()); clearApprovalRequest(); resetThread(); }, [clearApprovalRequest, resetThread]); return { + artifacts, decisionChoices, decisionRequest, decisionStatus, diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/util.spec.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/util.spec.ts index 19a48e76f9..c63d409ed6 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/util.spec.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/util.spec.ts @@ -25,6 +25,7 @@ describe('Claude Code utilities', () => { it('converts stored transcript items to assistant-ui messages', () => { const history: ClaudeCodeSessionHistory = { session_id: '2dc6e5a6-acd7-43bf-b128-c9fd5cf6eb9a', + chat_artifacts: { selections: [], files: [], links: [], tools: [] }, items: [ { kind: 'user', text: 'check the repo' }, { @@ -135,6 +136,7 @@ describe('Claude Code utilities', () => { it('combines consecutive tool-only assistant transcript items', () => { const history: ClaudeCodeSessionHistory = { session_id: 'session-1', + chat_artifacts: { selections: [], files: [], links: [], tools: [] }, items: [ { kind: 'user', text: 'map the repo' }, { From fe0f575765dac6fe2c9c27fd3bd994e59f70834d Mon Sep 17 00:00:00 2001 From: Danielle Ali <44468613+dmariali@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:00:18 -0400 Subject: [PATCH 2/5] wip Signed-off-by: Danielle Ali <44468613+dmariali@users.noreply.github.com> --- .../studio/src/nmp/studio/coding_agents.py | 55 +++++- .../studio/tests/unit/test_coding_agents.py | 16 +- .../ClaudeCodeHistoryPanel.tsx | 34 +++- .../ClaudeCodeStudioLink.tsx | 8 +- .../ClaudeCodeToolCallPart.spec.tsx | 38 ++++- .../ClaudeCodeToolCallPart.tsx | 75 ++++++++- .../agents/ClaudeCodeChatRoute/api.spec.ts | 33 ++++ .../routes/agents/ClaudeCodeChatRoute/api.ts | 6 +- .../ClaudeCodeChatRoute/artifacts.spec.ts | 45 +++-- .../agents/ClaudeCodeChatRoute/artifacts.ts | 157 ++++++++++++++++-- .../agents/ClaudeCodeChatRoute/index.tsx | 1 + .../agents/ClaudeCodeChatRoute/toolParts.ts | 86 +++++++++- .../agents/ClaudeCodeChatRoute/types.ts | 1 + .../useClaudeCodeChatRuntime.ts | 24 ++- .../useCustomAssistantChatRuntime.spec.ts | 123 +++++++++++++- .../useCustomAssistantChatRuntime.ts | 32 +++- .../agents/ClaudeCodeChatRoute/util.spec.ts | 23 ++- .../routes/agents/ClaudeCodeChatRoute/util.ts | 7 +- 18 files changed, 691 insertions(+), 73 deletions(-) diff --git a/services/studio/src/nmp/studio/coding_agents.py b/services/studio/src/nmp/studio/coding_agents.py index 7ef69c84d7..727ab06641 100644 --- a/services/studio/src/nmp/studio/coding_agents.py +++ b/services/studio/src/nmp/studio/coding_agents.py @@ -81,6 +81,7 @@ class ChatLinkArtifactResponse(BaseModel): label: str destination: str | None = None + href: str | None = None class ChatArtifactsResponse(BaseModel): @@ -367,6 +368,7 @@ def _project_history_dir() -> Path: _ANSWER_PAIR_RE = re.compile(r'"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)"') _INLINE_CODE_VALUE_RE = re.compile(r"(`+)(?P.*?)\1", re.DOTALL) +_MARKDOWN_LINK_RE = re.compile(r"\[(?P