From dea80e9f8119730b3bce0cd65231198706152f71 Mon Sep 17 00:00:00 2001 From: Danielle Ali <44468613+dmariali@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:29:20 -0400 Subject: [PATCH 1/9] feat(studio)- add copilot chat history Signed-off-by: Danielle Ali <44468613+dmariali@users.noreply.github.com> --- agents/nemo-studio-copilot/Dockerfile | 6 + .../src/nemo_studio_copilot/register.py | 2 +- .../tests/test_nemo_studio_copilot.py | 15 + services/studio/src/nmp/studio/copilot.py | 226 ++++++++++---- services/studio/src/nmp/studio/entities.py | 28 ++ services/studio/src/nmp/studio/service.py | 2 +- services/studio/tests/unit/test_copilot.py | 292 ++++++++++++++++-- .../ClaudeCodeHistoryPanel.test.tsx | 63 +++- .../ClaudeCodeChatRoute/ClaudeCodeLayout.tsx | 1 + .../agents/ClaudeCodeChatRoute/api.test.ts | 47 +++ .../routes/agents/ClaudeCodeChatRoute/api.ts | 55 +++- .../context/ClaudeCodeChatProvider.test.tsx | 21 +- .../context/ClaudeCodeChatProvider.tsx | 19 +- .../historyPanel/HistoryPanelContents.tsx | 38 ++- .../historyPanel/HistorySessionButton.tsx | 89 ++++-- .../ClaudeCodeChatRoute/historyPanel/types.ts | 1 + .../useClaudeCodeChatRuntime.test.ts | 7 +- .../useClaudeCodeChatRuntime.ts | 10 +- 18 files changed, 767 insertions(+), 155 deletions(-) create mode 100644 services/studio/src/nmp/studio/entities.py diff --git a/agents/nemo-studio-copilot/Dockerfile b/agents/nemo-studio-copilot/Dockerfile index e62cbacd19..35c2df4c38 100644 --- a/agents/nemo-studio-copilot/Dockerfile +++ b/agents/nemo-studio-copilot/Dockerfile @@ -55,6 +55,12 @@ LABEL org.opencontainers.image.title="nemo-studio-copilot" \ ENV NAT_CONFIG_FILE=/workspace/src/nemo_studio_copilot/nemo-studio-copilot.yml +# Authenticated Kubernetes deployments inject a loopback auth-proxy sidecar on +# this port. NMP_BASE_URL is supplied by the deployment runtime, so use the +# agent-specific legacy override for SDK calls and allow local runtimes to +# replace it when needed. +ENV NEMO_BASE_URL=http://127.0.0.1:8090 + ENV PATH="/workspace/.venv/bin:$PATH" # Some modern base images (notably Ubuntu 24.04 "noble" and the NVIDIA base diff --git a/agents/nemo-studio-copilot/src/nemo_studio_copilot/register.py b/agents/nemo-studio-copilot/src/nemo_studio_copilot/register.py index 46439b566d..d54aa3fc92 100644 --- a/agents/nemo-studio-copilot/src/nemo_studio_copilot/register.py +++ b/agents/nemo-studio-copilot/src/nemo_studio_copilot/register.py @@ -221,7 +221,7 @@ def _delete_fileset(name: str) -> str: def _get_client() -> NeMoPlatform: global _client if _client is None: - base_url = os.environ.get("NMP_BASE_URL") or os.environ.get("NEMO_BASE_URL") + base_url = os.environ.get("NEMO_BASE_URL") or os.environ.get("NMP_BASE_URL") kwargs: dict[str, Any] = {} if base_url: kwargs["base_url"] = base_url diff --git a/agents/nemo-studio-copilot/tests/test_nemo_studio_copilot.py b/agents/nemo-studio-copilot/tests/test_nemo_studio_copilot.py index 8bc6deefd1..96f11e6d7d 100644 --- a/agents/nemo-studio-copilot/tests/test_nemo_studio_copilot.py +++ b/agents/nemo-studio-copilot/tests/test_nemo_studio_copilot.py @@ -184,6 +184,21 @@ def test_sdk_uses_deployment_platform_base_url(self, monkeypatch): workspace="developer-workspace", ) + def test_sdk_prefers_agent_base_url_override(self, monkeypatch): + monkeypatch.setenv("NMP_BASE_URL", "http://platform-gateway:8080") + monkeypatch.setenv("NEMO_BASE_URL", "http://127.0.0.1:8090") + + with ( + patch("nemo_studio_copilot.register._client", None), + patch("nemo_studio_copilot.register.NeMoPlatform") as platform_client, + ): + _get_client() + + platform_client.assert_called_once_with( + base_url="http://127.0.0.1:8090", + workspace="default", + ) + def test_sdk_defaults_to_default_workspace(self, monkeypatch): monkeypatch.delenv("NMP_WORKSPACE", raising=False) diff --git a/services/studio/src/nmp/studio/copilot.py b/services/studio/src/nmp/studio/copilot.py index be2caf4270..6b63095350 100644 --- a/services/studio/src/nmp/studio/copilot.py +++ b/services/studio/src/nmp/studio/copilot.py @@ -9,7 +9,6 @@ import os import re import shutil -import time import uuid from collections.abc import AsyncIterator, Awaitable, Mapping from dataclasses import dataclass @@ -19,9 +18,11 @@ from urllib.parse import quote, urlencode, urlparse import httpx -from fastapi import APIRouter, FastAPI, HTTPException, Request +from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request from fastapi.responses import JSONResponse, Response, StreamingResponse +from nmp.common.entities.client import EntityClient, EntityConflictError, EntityNotFoundError, EntityStoreError from nmp.common.entities.constants import NAME_PATTERN +from nmp.common.service.dependencies import get_entity_client from nmp.studio import studio_links from nmp.studio.copilot_artifacts import ( ChatArtifactsResponse, @@ -51,6 +52,7 @@ permission_prompt_tool, ) from nmp.studio.copilot_skills import ClaudeSkillResponse, DuplicateSkillError, list_claude_skill_responses +from nmp.studio.entities import CopilotConversation, CopilotMessage from pydantic import BaseModel, ConfigDict, Field from starlette.routing import NoMatchFound @@ -113,7 +115,7 @@ class AgentInputDecision(BaseModel): class HistorySessionResponse(BaseModel): - """Summary of a Claude session stored on disk.""" + """Summary of a persisted Copilot or legacy Claude session.""" session_id: str mtime: float @@ -127,7 +129,7 @@ class HistorySessionResponse(BaseModel): class SessionHistoryResponse(BaseModel): - """Claude session history normalized for Studio chat replay.""" + """Copilot session history normalized for Studio chat replay.""" session_id: str items: list[dict[str, Any]] @@ -138,35 +140,13 @@ class SessionHistoryResponse(BaseModel): _session_streams: dict[str, asyncio.Queue[tuple[str, Any]]] = {} _pending_permissions: dict[str, tuple[str, asyncio.Future[dict[str, Any]]]] = {} _pending_agent_inputs: dict[str, tuple[str, asyncio.Future[dict[str, Any]]]] = {} -_session_conversations: dict[str, list[dict[str, str]]] = {} -_session_mtimes: dict[str, float] = {} _AGENT_INPUT_RESPONSE_RESERVED_KEYS = frozenset({"message", "status"}) -def _evict_oldest_sessions(*, protected_session_ids: set[str] | None = None) -> None: - """Evict least-recently-updated inactive sessions from in-memory history.""" - protected = (protected_session_ids or set()) | set(_session_streams) - while len(_session_conversations) > MAX_RETAINED_SESSIONS: - candidates = set(_session_conversations) - protected - if not candidates: - break - oldest_session_id = min( - candidates, - key=lambda session_id: (_session_mtimes.get(session_id, 0), session_id), - ) - _session_conversations.pop(oldest_session_id, None) - _session_mtimes.pop(oldest_session_id, None) - _initialized_sessions.discard(oldest_session_id) - - for session_id in set(_session_mtimes) - set(_session_conversations): - _session_mtimes.pop(session_id, None) - - -def _retain_recent_turns(conversation: list[dict[str, str]]) -> None: - """Keep only the most recent complete user/assistant turns.""" +def _recent_conversation_messages(conversation: list[CopilotMessage]) -> list[CopilotMessage]: + """Bound model context without truncating the persisted chat history.""" max_messages = MAX_RETAINED_TURNS_PER_SESSION * 2 - if len(conversation) > max_messages: - del conversation[:-max_messages] + return conversation[-max_messages:] @dataclass @@ -221,6 +201,40 @@ def _validate_session_id(session_id: str) -> str: raise HTTPException(status_code=400, detail="session_id must be a UUID") from exc +def _conversation_name(session_id: str) -> str: + """Return the Entity Store name for a Studio session UUID.""" + return f"copilot-{session_id}" + + +def _request_principal_id(request: Request) -> str: + """Return the end-user principal, including service-on-behalf-of requests.""" + return ( + request.headers.get("x-nmp-principal-on-behalf-of") or request.headers.get("x-nmp-principal-id") or "local-user" + ) + + +async def _get_owned_conversation( + entity_store: EntityClient, + *, + session_id: str, + workspace: str, + owner_id: str, +) -> CopilotConversation: + """Load a conversation and enforce per-user ownership within a workspace.""" + try: + conversation = await entity_store.get( + CopilotConversation, + _conversation_name(session_id), + workspace=workspace, + ) + except EntityNotFoundError as exc: + raise HTTPException(status_code=404, detail="no such session history") from exc + if conversation.owner_id != owner_id: + # Do not reveal whether another user's conversation exists. + raise HTTPException(status_code=404, detail="no such session history") + return conversation + + def _trimmed_string(value: Any) -> str | None: if not isinstance(value, str): return None @@ -684,36 +698,59 @@ def _extract_assistant_parts(content: Any) -> list[dict[str, Any]]: @router.post("/sessions", response_model=NewSessionResponse) -def create_session() -> NewSessionResponse: - """Create a new local copilot session.""" +async def create_session( + request: Request, + workspace: str = "default", + entity_store: EntityClient = Depends(get_entity_client), +) -> NewSessionResponse: + """Create a durable, user-owned Copilot session.""" + workspace = _validated_workspace_or_default(workspace) session_id = str(uuid.uuid4()) - _session_conversations[session_id] = [] - _session_mtimes[session_id] = time.time() - _evict_oldest_sessions(protected_session_ids={session_id}) + await entity_store.create( + CopilotConversation( + name=_conversation_name(session_id), + workspace=workspace, + session_id=session_id, + owner_id=_request_principal_id(request), + ) + ) return NewSessionResponse(session_id=session_id) @router.get("/history/sessions", response_model=list[HistorySessionResponse]) -def list_history_sessions() -> list[HistorySessionResponse]: - """List active retained NeMo Copilot sessions.""" - _evict_oldest_sessions() +async def list_history_sessions( + request: Request, + workspace: str = "default", + entity_store: EntityClient = Depends(get_entity_client), +) -> list[HistorySessionResponse]: + """List the current user's durable NeMo Copilot sessions.""" + workspace = _validated_workspace_or_default(workspace) + owner_id = _request_principal_id(request) + result = await entity_store.list( + CopilotConversation, + workspace=workspace, + filter_obj={"owner_id": owner_id}, + sort="-updated_at", + page_size=MAX_RETAINED_SESSIONS, + ) sessions: list[HistorySessionResponse] = [] - for session_id, messages in _session_conversations.items(): - user_messages = [item["content"] for item in messages if item.get("role") == "user"] + for conversation in result.data: + user_messages = [message.content for message in conversation.messages if message.role == "user"] if not user_messages: continue first_prompt = user_messages[0] + modified_at = conversation.updated_at or conversation.created_at sessions.append( HistorySessionResponse( - session_id=session_id, - mtime=_session_mtimes.get(session_id, 0), + session_id=conversation.session_id, + mtime=modified_at.timestamp() if modified_at else 0, title=first_prompt.splitlines()[0][:80], first_prompt=first_prompt, message_count=len(user_messages), token_count=0, tool_call_count=0, tool_calls=[], - chat_artifacts=ChatArtifactsResponse(), + chat_artifacts=conversation.chat_artifacts, ) ) sessions.sort(key=lambda session: session.mtime, reverse=True) @@ -795,27 +832,41 @@ def _history_user_interaction_texts( @router.get("/history/sessions/{session_id}", response_model=SessionHistoryResponse) -def get_session_history(session_id: str) -> SessionHistoryResponse: +async def get_session_history( + session_id: str, + request: Request, + workspace: str = "default", + entity_store: EntityClient = Depends(get_entity_client), +) -> SessionHistoryResponse: """Load a NeMo Copilot session or legacy Claude history for replay.""" sid = _validate_session_id(session_id) - conversation = _session_conversations.get(sid) + workspace = _validated_workspace_or_default(workspace) + try: + conversation = await entity_store.get( + CopilotConversation, + _conversation_name(sid), + workspace=workspace, + ) + except EntityNotFoundError: + conversation = None if conversation is not None: + if conversation.owner_id != _request_principal_id(request): + raise HTTPException(status_code=404, detail="no such session history") items: list[dict[str, Any]] = [] - for message in conversation: - if message.get("role") == "user": - items.append({"kind": "user", "text": message["content"]}) - elif message.get("role") == "assistant": + for message in conversation.messages: + if message.role == "user": + items.append({"kind": "user", "text": message.content}) + elif message.role == "assistant": items.append( { "kind": "assistant", - "parts": [{"type": "text", "text": message["content"]}], + "parts": [{"type": "text", "text": message.content}], } ) - _initialized_sessions.add(sid) return SessionHistoryResponse( session_id=sid, items=items, - chat_artifacts=ChatArtifactsResponse(), + chat_artifacts=conversation.chat_artifacts, ) path = _project_history_dir() / f"{sid}.jsonl" @@ -870,6 +921,38 @@ def get_session_history(session_id: str) -> SessionHistoryResponse: return SessionHistoryResponse(session_id=sid, items=items, chat_artifacts=summary.chat_artifacts) +@router.delete("/history/sessions/{session_id}", status_code=204) +async def delete_session_history( + session_id: str, + request: Request, + workspace: str = "default", + entity_store: EntityClient = Depends(get_entity_client), +) -> Response: + """Delete the current user's persisted Copilot conversation.""" + sid = _validate_session_id(session_id) + workspace = _validated_workspace_or_default(workspace) + if sid in _session_streams: + raise HTTPException(status_code=409, detail="cannot delete a session while it is running") + conversation = await _get_owned_conversation( + entity_store, + session_id=sid, + workspace=workspace, + owner_id=_request_principal_id(request), + ) + try: + await entity_store.delete( + CopilotConversation, + conversation.name, + workspace=workspace, + expected_db_version=conversation.db_version, + ) + except EntityNotFoundError as exc: + raise HTTPException(status_code=404, detail="no such session history") from exc + except EntityConflictError as exc: + raise HTTPException(status_code=409, detail="session changed; refresh history and try again") from exc + return Response(status_code=204) + + @router.get("/skills", response_model=list[ClaudeSkillResponse]) def list_claude_skills() -> list[ClaudeSkillResponse]: """List NeMo skills that the repo's Claude Code installer exposes.""" @@ -1337,6 +1420,8 @@ async def _stream_copilot( agent_url: str, headers: Mapping[str, str], studio_system_prompt: str, + conversation: CopilotConversation, + entity_store: EntityClient, ) -> AsyncIterator[str]: """Invoke the deployed NeMo Copilot while preserving Studio's blocking UI event protocol.""" if session_id in _session_streams: @@ -1348,7 +1433,6 @@ async def _stream_copilot( queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue() _session_streams[session_id] = queue - conversation = _session_conversations.setdefault(session_id, []) contextual_message = "\n\n".join( [ "", @@ -1358,7 +1442,10 @@ async def _stream_copilot( message, ] ) - request_messages = [*conversation, {"role": "user", "content": contextual_message}] + request_messages = [ + *(persisted_message.model_dump() for persisted_message in _recent_conversation_messages(conversation.messages)), + {"role": "user", "content": contextual_message}, + ] invocation = asyncio.create_task( _invoke_copilot( agent_url, @@ -1394,15 +1481,14 @@ async def _stream_copilot( queued_event = asyncio.create_task(queue.get()) assistant_text, model = await invocation - conversation.extend( + conversation.messages.extend( [ - {"role": "user", "content": message}, - {"role": "assistant", "content": assistant_text}, + CopilotMessage(role="user", content=message), + CopilotMessage(role="assistant", content=assistant_text), ] ) - _retain_recent_turns(conversation) - _session_mtimes[session_id] = time.time() - _initialized_sessions.add(session_id) + record_copilot_model(conversation.chat_artifacts, model) + await entity_store.update(conversation) yield _sse( json.dumps( { @@ -1425,20 +1511,36 @@ async def _stream_copilot( json.dumps({"message": _copilot_error_detail(exc)}), event="error", ) + except EntityStoreError: + logger.exception("Failed to persist NeMo Copilot session %s", session_id) + yield _sse( + json.dumps({"message": "NeMo Copilot could not save this conversation."}), + event="error", + ) finally: _session_streams.pop(session_id, None) if not invocation.done(): invocation.cancel() if not queued_event.done(): queued_event.cancel() - _evict_oldest_sessions() @router.post("/sessions/{session_id}/messages") -async def send_message(session_id: str, body: MessageRequest, request: Request) -> StreamingResponse: +async def send_message( + session_id: str, + body: MessageRequest, + request: Request, + entity_store: EntityClient = Depends(get_entity_client), +) -> StreamingResponse: """Send a message to the deployed NeMo Copilot and stream Studio events.""" sid = _validate_session_id(session_id) workspace = _validated_workspace_or_default(body.workspace) + conversation = await _get_owned_conversation( + entity_store, + session_id=sid, + workspace=workspace, + owner_id=_request_principal_id(request), + ) agent_url = _studio_copilot_url(workspace) studio_base_url = _studio_base_url_from_request(body, request) studio_pathname = _studio_pathname_from_request(body, request) @@ -1457,6 +1559,8 @@ async def send_message(session_id: str, body: MessageRequest, request: Request) agent_url, _copilot_request_headers(request, agent_url), system_prompt, + conversation, + entity_store, ), media_type="text/event-stream", headers={"Cache-Control": "no-store"}, diff --git a/services/studio/src/nmp/studio/entities.py b/services/studio/src/nmp/studio/entities.py new file mode 100644 index 0000000000..42dd14b450 --- /dev/null +++ b/services/studio/src/nmp/studio/entities.py @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Entity Store models owned by the Studio service.""" + +from typing import ClassVar, Literal + +from nmp.common.entities.client import EntityBase +from nmp.studio.copilot_artifacts import ChatArtifactsResponse +from pydantic import BaseModel, Field + + +class CopilotMessage(BaseModel): + """One user or assistant message in a persisted Copilot conversation.""" + + role: Literal["user", "assistant"] + content: str + + +class CopilotConversation(EntityBase): + """A workspace-scoped, user-owned NeMo Copilot conversation.""" + + __entity_type__: ClassVar[str] = "copilot_conversation" + + session_id: str = Field(description="Stable Studio session UUID exposed to the UI.") + owner_id: str = Field(description="Principal that owns and may read this conversation.") + messages: list[CopilotMessage] = Field(default_factory=list) + chat_artifacts: ChatArtifactsResponse = Field(default_factory=ChatArtifactsResponse) diff --git a/services/studio/src/nmp/studio/service.py b/services/studio/src/nmp/studio/service.py index 6cfc9d68b6..0c2b205542 100644 --- a/services/studio/src/nmp/studio/service.py +++ b/services/studio/src/nmp/studio/service.py @@ -51,7 +51,7 @@ class StudioService(Service[StudioConfig]): - env_replacements: Runtime values to inject into the UI bundle (cached) """ - dependencies: ClassVar[list[str]] = [] + dependencies: ClassVar[list[str]] = ["entities", "auth"] def __init__(self): """Initialize the studio service.""" diff --git a/services/studio/tests/unit/test_copilot.py b/services/studio/tests/unit/test_copilot.py index 4939f2caab..ba380339bb 100644 --- a/services/studio/tests/unit/test_copilot.py +++ b/services/studio/tests/unit/test_copilot.py @@ -8,18 +8,87 @@ import logging import re import uuid +from datetime import UTC, datetime from pathlib import Path +from types import SimpleNamespace from typing import Any import httpx import pytest from fastapi import FastAPI, HTTPException, Request from fastapi.testclient import TestClient +from nmp.common.entities.client import EntityNotFoundError +from nmp.common.service.dependencies import get_entity_client from nmp.studio import copilot, copilot_artifacts, copilot_skills, studio_links from nmp.studio.config import StudioConfig +from nmp.studio.entities import CopilotConversation, CopilotMessage from nmp.studio.service import StudioService +class FakeEntityStore: + """Small async EntityClient fake for Copilot route tests.""" + + def __init__(self) -> None: + self.entities: dict[tuple[str, str], CopilotConversation] = {} + + async def create(self, entity: CopilotConversation) -> CopilotConversation: + now = datetime.now(UTC) + entity._created_at = now + entity._updated_at = now + self.entities[(entity.workspace, entity.name)] = entity + return entity + + async def get( + self, + entity_type: type[CopilotConversation], + name: str, + *, + workspace: str | None = None, + ) -> CopilotConversation: + del entity_type + try: + return self.entities[(workspace or "default", name)] + except KeyError as exc: + raise EntityNotFoundError(name) from exc + + async def list( + self, + entity_type: type[CopilotConversation], + *, + workspace: str = "default", + filter_obj: dict[str, Any] | None = None, + **_: Any, + ) -> SimpleNamespace: + del entity_type + owner_id = filter_obj.get("owner_id") if filter_obj else None + data = [ + entity + for (entity_workspace, _), entity in self.entities.items() + if entity_workspace == workspace and (owner_id is None or entity.owner_id == owner_id) + ] + data.sort(key=lambda entity: entity.updated_at or datetime.min.replace(tzinfo=UTC), reverse=True) + return SimpleNamespace(data=data) + + async def update(self, entity: CopilotConversation) -> CopilotConversation: + entity._updated_at = datetime.now(UTC) + self.entities[(entity.workspace, entity.name)] = entity + return entity + + async def delete( + self, + entity_type: type[CopilotConversation], + name: str, + *, + workspace: str | None = None, + expected_db_version: int | None = None, + ) -> None: + del entity_type, expected_db_version + try: + del self.entities[(workspace or "default", name)] + except KeyError as exc: + raise EntityNotFoundError(name) from exc + + @pytest.fixture(autouse=True) def reset_copilot_state(): """Reset module-level bridge state between tests.""" @@ -27,20 +96,22 @@ def reset_copilot_state(): copilot._session_streams.clear() copilot._pending_permissions.clear() copilot._pending_agent_inputs.clear() - copilot._session_conversations.clear() - copilot._session_mtimes.clear() yield copilot._initialized_sessions.clear() copilot._session_streams.clear() copilot._pending_permissions.clear() copilot._pending_agent_inputs.clear() - copilot._session_conversations.clear() - copilot._session_mtimes.clear() @pytest.fixture -def service_client() -> TestClient: +def entity_store() -> FakeEntityStore: + return FakeEntityStore() + + +@pytest.fixture +def service_client(entity_store: FakeEntityStore) -> TestClient: service = StudioService() + service.app.dependency_overrides[get_entity_client] = lambda: entity_store return TestClient(service.app) @@ -232,43 +303,58 @@ def test_create_session_returns_uuid(service_client: TestClient): uuid.UUID(response.json()["session_id"]) -def test_create_session_evicts_least_recently_updated_session( +def test_create_session_persists_workspace_and_owner( service_client: TestClient, - monkeypatch: pytest.MonkeyPatch, + entity_store: FakeEntityStore, ): - monkeypatch.setattr(copilot, "MAX_RETAINED_SESSIONS", 2) - first_session_id = service_client.post("/v2/copilot/sessions").json()["session_id"] - copilot._session_mtimes[first_session_id] = 1 - second_session_id = service_client.post("/v2/copilot/sessions").json()["session_id"] - copilot._session_mtimes[second_session_id] = 2 - - third_session_id = service_client.post("/v2/copilot/sessions").json()["session_id"] + response = service_client.post( + "/v2/copilot/sessions?workspace=team-a", + headers={"X-NMP-Principal-Id": "alice@example.com"}, + ) - assert set(copilot._session_conversations) == {second_session_id, third_session_id} - assert set(copilot._session_mtimes) == {second_session_id, third_session_id} + session_id = response.json()["session_id"] + persisted = entity_store.entities[("team-a", f"copilot-{session_id}")] + assert persisted.owner_id == "alice@example.com" + assert persisted.messages == [] -def test_retain_recent_turns_caps_complete_user_assistant_pairs(monkeypatch: pytest.MonkeyPatch): +def test_recent_conversation_messages_caps_model_context_without_mutating_history( + monkeypatch: pytest.MonkeyPatch, +): monkeypatch.setattr(copilot, "MAX_RETAINED_TURNS_PER_SESSION", 2) - conversation = [{"role": role, "content": f"{role}-{turn}"} for turn in range(3) for role in ("user", "assistant")] + conversation = [ + CopilotMessage(role=role, content=f"{role}-{turn}") for turn in range(3) for role in ("user", "assistant") + ] - copilot._retain_recent_turns(conversation) + recent = copilot._recent_conversation_messages(conversation) - assert conversation == [ + assert [message.model_dump() for message in recent] == [ {"role": "user", "content": "user-1"}, {"role": "assistant", "content": "assistant-1"}, {"role": "user", "content": "user-2"}, {"role": "assistant", "content": "assistant-2"}, ] + assert len(conversation) == 6 -def test_list_history_sessions_includes_retained_conversation(service_client: TestClient): +def test_list_history_sessions_includes_persisted_conversation( + service_client: TestClient, + entity_store: FakeEntityStore, +): session_id = str(uuid.uuid4()) - copilot._session_conversations[session_id] = [ - {"role": "user", "content": "Help me build an agent"}, - {"role": "assistant", "content": "What should it do?"}, - ] - copilot._session_mtimes[session_id] = 42 + conversation = CopilotConversation( + name=f"copilot-{session_id}", + workspace="default", + session_id=session_id, + owner_id="local-user", + messages=[ + CopilotMessage(role="user", content="Help me build an agent"), + CopilotMessage(role="assistant", content="What should it do?"), + ], + ) + conversation._created_at = datetime.fromtimestamp(40, UTC) + conversation._updated_at = datetime.fromtimestamp(42, UTC) + entity_store.entities[("default", conversation.name)] = conversation response = service_client.get("/v2/copilot/history/sessions") @@ -299,6 +385,132 @@ def test_list_history_sessions_includes_retained_conversation(service_client: Te ] +def test_history_is_scoped_to_workspace_and_owner( + service_client: TestClient, + entity_store: FakeEntityStore, +): + alice_id = service_client.post( + "/v2/copilot/sessions?workspace=team-a", + headers={"X-NMP-Principal-Id": "alice@example.com"}, + ).json()["session_id"] + bob_id = service_client.post( + "/v2/copilot/sessions?workspace=team-a", + headers={"X-NMP-Principal-Id": "bob@example.com"}, + ).json()["session_id"] + entity_store.entities[("team-a", f"copilot-{alice_id}")].messages = [ + CopilotMessage(role="user", content="Alice's private prompt"), + CopilotMessage(role="assistant", content="Alice's answer"), + ] + entity_store.entities[("team-a", f"copilot-{bob_id}")].messages = [ + CopilotMessage(role="user", content="Bob's private prompt"), + CopilotMessage(role="assistant", content="Bob's answer"), + ] + + response = service_client.get( + "/v2/copilot/history/sessions?workspace=team-a", + headers={"X-NMP-Principal-Id": "alice@example.com"}, + ) + + assert response.status_code == 200 + assert [session["session_id"] for session in response.json()] == [alice_id] + forbidden = service_client.get( + f"/v2/copilot/history/sessions/{bob_id}?workspace=team-a", + headers={"X-NMP-Principal-Id": "alice@example.com"}, + ) + assert forbidden.status_code == 404 + + +def test_delete_history_enforces_owner_and_removes_conversation( + service_client: TestClient, + entity_store: FakeEntityStore, +): + session_id = service_client.post( + "/v2/copilot/sessions?workspace=team-a", + headers={"X-NMP-Principal-Id": "alice@example.com"}, + ).json()["session_id"] + + forbidden = service_client.delete( + f"/v2/copilot/history/sessions/{session_id}?workspace=team-a", + headers={"X-NMP-Principal-Id": "bob@example.com"}, + ) + assert forbidden.status_code == 404 + assert ("team-a", f"copilot-{session_id}") in entity_store.entities + + deleted = service_client.delete( + f"/v2/copilot/history/sessions/{session_id}?workspace=team-a", + headers={"X-NMP-Principal-Id": "alice@example.com"}, + ) + assert deleted.status_code == 204 + assert ("team-a", f"copilot-{session_id}") not in entity_store.entities + + +def test_delete_history_rejects_active_session( + service_client: TestClient, + entity_store: FakeEntityStore, +): + session_id = service_client.post("/v2/copilot/sessions").json()["session_id"] + copilot._session_streams[session_id] = asyncio.Queue() + + response = service_client.delete(f"/v2/copilot/history/sessions/{session_id}") + + assert response.status_code == 409 + assert ("default", f"copilot-{session_id}") in entity_store.entities + + +def test_copilot_turn_is_persisted_and_reused_as_context( + service_client: TestClient, + entity_store: FakeEntityStore, + monkeypatch: pytest.MonkeyPatch, +): + session_id = service_client.post("/v2/copilot/sessions").json()["session_id"] + invocations: list[list[dict[str, str]]] = [] + + async def fake_invoke( + agent_url: str, + headers: dict[str, str], + messages: list[dict[str, str]], + studio_session_id: str, + ) -> tuple[str, str]: + del agent_url, headers + assert studio_session_id == session_id + invocations.append(messages) + return f"answer-{len(invocations)}", "nvidia/copilot-model" + + monkeypatch.setattr(copilot, "_invoke_copilot", fake_invoke) + + first = service_client.post( + f"/v2/copilot/sessions/{session_id}/messages", + json={"message": "first question", "workspace": "default"}, + ) + second = service_client.post( + f"/v2/copilot/sessions/{session_id}/messages", + json={"message": "second question", "workspace": "default"}, + ) + + assert first.status_code == 200 + assert second.status_code == 200 + assert "event: done" in first.text + assert "event: done" in second.text + persisted = entity_store.entities[("default", f"copilot-{session_id}")] + assert [message.model_dump() for message in persisted.messages] == [ + {"role": "user", "content": "first question"}, + {"role": "assistant", "content": "answer-1"}, + {"role": "user", "content": "second question"}, + {"role": "assistant", "content": "answer-2"}, + ] + assert invocations[1][:2] == [ + {"role": "user", "content": "first question"}, + {"role": "assistant", "content": "answer-1"}, + ] + history = service_client.get(f"/v2/copilot/history/sessions/{session_id}") + assert [item["kind"] for item in history.json()["items"]] == [ + "user", + "assistant", + "user", + "assistant", + ] + + def test_build_claude_argv_uses_new_session_then_resume_flag(): session_id = str(uuid.uuid4()) @@ -1684,8 +1896,17 @@ def test_platform_route_stream_uses_deployed_copilot(monkeypatch: pytest.MonkeyP app = FastAPI() app.include_router(service.app.router, prefix="/apis/studio") service.configure_app(app) - client = TestClient(app) session_id = str(uuid.uuid4()) + entity_store = FakeEntityStore() + conversation = CopilotConversation( + name=f"copilot-{session_id}", + workspace="default", + session_id=session_id, + owner_id="local-user", + ) + entity_store.entities[("default", conversation.name)] = conversation + app.dependency_overrides[get_entity_client] = lambda: entity_store + client = TestClient(app) captured: dict[str, Any] = {} async def fake_stream( @@ -1694,7 +1915,10 @@ async def fake_stream( agent_url: str, headers: dict[str, str], studio_system_prompt: str, + conversation: CopilotConversation, + entity_store: FakeEntityStore, ): + del conversation, entity_store captured.update( { "session_id": session_id, @@ -1776,8 +2000,17 @@ def test_platform_route_stream_infers_studio_url_from_browser_headers(monkeypatc app = FastAPI() app.include_router(service.app.router, prefix="/apis/studio") service.configure_app(app) - client = TestClient(app) session_id = str(uuid.uuid4()) + entity_store = FakeEntityStore() + conversation = CopilotConversation( + name=f"copilot-{session_id}", + workspace="default", + session_id=session_id, + owner_id="local-user", + ) + entity_store.entities[("default", conversation.name)] = conversation + app.dependency_overrides[get_entity_client] = lambda: entity_store + client = TestClient(app) captured: dict[str, Any] = {} async def fake_stream( @@ -1786,7 +2019,10 @@ async def fake_stream( agent_url: str, headers: dict[str, str], studio_system_prompt: str, + conversation: CopilotConversation, + entity_store: FakeEntityStore, ): + del conversation, entity_store captured.update( { "session_id": session_id, 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 e7a795fb17..a70c273b93 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.test.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.test.tsx @@ -3,16 +3,24 @@ import { ClaudeCodeHistoryPanel } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel'; import { render, screen } from '@studio/tests/util/render'; +import { waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; const mocks = vi.hoisted(() => ({ + deleteClaudeCodeSessionHistory: vi.fn(), listClaudeCodeHistorySessions: vi.fn(), listClaudeCodeSkills: vi.fn(), })); vi.mock('@studio/routes/agents/ClaudeCodeChatRoute/api', () => ({ - CLAUDE_CODE_HISTORY_SESSIONS_QUERY_KEY: ['claude-code', 'history', 'sessions'], CLAUDE_CODE_SKILLS_QUERY_KEY: ['claude-code', 'skills'], + deleteClaudeCodeSessionHistory: mocks.deleteClaudeCodeSessionHistory, + getClaudeCodeHistorySessionsQueryKey: (workspace: string) => [ + 'claude-code', + 'history', + 'sessions', + workspace, + ], listClaudeCodeHistorySessions: mocks.listClaudeCodeHistorySessions, listClaudeCodeSkills: mocks.listClaudeCodeSkills, })); @@ -22,6 +30,7 @@ describe('ClaudeCodeHistoryPanel', () => { localStorage.clear(); vi.clearAllMocks(); mocks.listClaudeCodeHistorySessions.mockResolvedValue([]); + mocks.deleteClaudeCodeSessionHistory.mockResolvedValue(undefined); mocks.listClaudeCodeSkills.mockResolvedValue([ { name: 'inference', @@ -114,7 +123,9 @@ describe('ClaudeCodeHistoryPanel', () => { await user.click(screen.getByRole('button', { name: 'New chat' })); expect(onNewChat).toHaveBeenCalledTimes(1); - await user.click(screen.getByRole('button', { name: /Review the latest agent work/ })); + await user.click( + screen.getByRole('button', { name: 'Open chat Review the latest agent work' }) + ); expect(onSelectSession).toHaveBeenCalledWith('session-1'); unmount(); @@ -166,13 +177,59 @@ describe('ClaudeCodeHistoryPanel', () => { await user.click(screen.getByRole('button', { name: 'Expand All Chats' })); const sessionButton = await screen.findByRole('button', { - name: 'Create Spam Detector Agent now', + name: 'Open chat Create Spam Detector Agent', }); expect(sessionButton).toHaveAttribute('title', expect.stringContaining(firstPrompt)); expect(screen.queryByText(firstPrompt)).not.toBeInTheDocument(); }); + it('confirms deletion and starts a new chat when deleting the active session', async () => { + const user = userEvent.setup(); + const onNewChat = vi.fn(); + mocks.listClaudeCodeHistorySessions.mockResolvedValue([ + { + session_id: 'session-1', + mtime: Date.now() / 1000, + title: 'Private agent work', + first_prompt: 'Help me with private agent work', + message_count: 1, + token_count: 0, + tool_call_count: 0, + tool_calls: [], + chat_artifacts: { + selections: [], + files: [], + links: [], + jobs: [], + tools: [], + }, + }, + ]); + + render( + + ); + await user.click(screen.getByRole('button', { name: 'Expand All Chats' })); + await user.click( + await screen.findByRole('button', { name: 'Delete chat Private agent work' }) + ); + + expect(screen.getByRole('dialog', { name: 'Delete chat?' })).toBeInTheDocument(); + expect(screen.getByText('Delete “Private agent work”? This chat cannot be recovered.')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Delete' })); + + await waitFor(() => + expect(mocks.deleteClaudeCodeSessionHistory).toHaveBeenCalledWith('session-1', 'team-a') + ); + expect(onNewChat).toHaveBeenCalledTimes(1); + }); + it('renders job artifacts as Studio links', () => { render( = ({ hideArtifacts={hideArtifacts} onNewChat={handleNewChat} onSelectSession={handleSelectSession} + workspace={workspace} /> ); 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 93caa5770a..4b0e3c5e91 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.test.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.test.ts @@ -3,6 +3,9 @@ import { BASE_URL } from '@studio/constants/environment'; import { + ClaudeCodeSessionNotFoundError, + createClaudeCodeSession, + deleteClaudeCodeSessionHistory, getClaudeCodeSessionHistory, listClaudeCodeHistorySessions, listClaudeCodeSkills, @@ -22,6 +25,50 @@ describe('Claude Code API helpers', () => { vi.unstubAllGlobals(); }); + it('scopes session creation and history requests to the active workspace', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ session_id: 'session-1' }), { status: 200 }) + ) + .mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ session_id: 'session-1', items: [], chat_artifacts: {} }), { + status: 200, + }) + ) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + vi.stubGlobal('fetch', fetchMock); + + await createClaudeCodeSession('team-a'); + await listClaudeCodeHistorySessions('team-a'); + await getClaudeCodeSessionHistory('session-1', 'team-a'); + await deleteClaudeCodeSessionHistory('session-1', 'team-a'); + + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + expect.stringContaining('/sessions?workspace=team-a'), + expect.stringContaining('/history/sessions?workspace=team-a'), + expect.stringContaining('/history/sessions/session-1?workspace=team-a'), + expect.stringContaining('/history/sessions/session-1?workspace=team-a'), + ]); + expect(fetchMock.mock.calls[3]?.[1]).toEqual({ method: 'DELETE' }); + }); + + it('identifies a missing session history response', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ detail: 'no such session history' }), { status: 404 }) + ) + ); + + await expect(getClaudeCodeSessionHistory('missing-session')).rejects.toBeInstanceOf( + ClaudeCodeSessionNotFoundError + ); + }); + it('reads model-generated titles from history sessions', async () => { vi.stubGlobal( 'fetch', diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts index 8a0632f898..cf77cddf1f 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts @@ -29,6 +29,13 @@ import type { const COPILOT_API_BASE_PATH = '/apis/studio/v2/copilot'; +export class ClaudeCodeSessionNotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = 'ClaudeCodeSessionNotFoundError'; + } +} + const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; @@ -48,11 +55,8 @@ const getStudioPathname = (): string | undefined => { return window.location.pathname; }; -export const CLAUDE_CODE_HISTORY_SESSIONS_QUERY_KEY = [ - 'claude-code', - 'history', - 'sessions', -] as const; +export const getClaudeCodeHistorySessionsQueryKey = (workspace: string) => + ['claude-code', 'history', 'sessions', workspace] as const; export const CLAUDE_CODE_SKILLS_QUERY_KEY = ['claude-code', 'skills'] as const; @@ -73,8 +77,9 @@ const getResponseErrorMessage = async (response: Response, fallback: string): Pr return text; }; -export const createClaudeCodeSession = async (): Promise => { - const response = await fetch(claudeCodeApiUrl('/sessions'), { +export const createClaudeCodeSession = async (workspace = 'default'): Promise => { + const params = new URLSearchParams({ workspace }); + const response = await fetch(claudeCodeApiUrl(`/sessions?${params.toString()}`), { method: 'POST', }); @@ -254,8 +259,11 @@ const parseSessionHistoryItem = (value: unknown): ClaudeCodeSessionHistoryItem | return undefined; }; -export const listClaudeCodeHistorySessions = async (): Promise => { - const response = await fetch(claudeCodeApiUrl('/history/sessions')); +export const listClaudeCodeHistorySessions = async ( + workspace = 'default' +): Promise => { + const params = new URLSearchParams({ workspace }); + const response = await fetch(claudeCodeApiUrl(`/history/sessions?${params.toString()}`)); if (!response.ok) { throw new Error(await getResponseErrorMessage(response, 'Failed to load NeMo Copilot history')); @@ -285,14 +293,20 @@ export const listClaudeCodeSkills = async (): Promise => { }; export const getClaudeCodeSessionHistory = async ( - sessionId: string + sessionId: string, + workspace = 'default' ): Promise => { + const params = new URLSearchParams({ workspace }); const response = await fetch( - claudeCodeApiUrl(`/history/sessions/${encodeURIComponent(sessionId)}`) + claudeCodeApiUrl(`/history/sessions/${encodeURIComponent(sessionId)}?${params.toString()}`) ); if (!response.ok) { - throw new Error(await getResponseErrorMessage(response, 'Failed to load NeMo Copilot session')); + const message = await getResponseErrorMessage(response, 'Failed to load NeMo Copilot session'); + if (response.status === 404) { + throw new ClaudeCodeSessionNotFoundError(message); + } + throw new Error(message); } const body = (await response.json()) as unknown; @@ -316,6 +330,23 @@ export const getClaudeCodeSessionHistory = async ( }; }; +export const deleteClaudeCodeSessionHistory = async ( + sessionId: string, + workspace = 'default' +): Promise => { + const params = new URLSearchParams({ workspace }); + const response = await fetch( + claudeCodeApiUrl(`/history/sessions/${encodeURIComponent(sessionId)}?${params.toString()}`), + { method: 'DELETE' } + ); + + if (!response.ok) { + throw new Error( + await getResponseErrorMessage(response, 'Failed to delete NeMo Copilot session') + ); + } +}; + const getStreamErrorMessage = (payload: unknown): string => { if (!isRecord(payload)) return 'NeMo Copilot stream failed'; if (typeof payload.stderr === 'string' && payload.stderr) return payload.stderr; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/context/ClaudeCodeChatProvider.test.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/context/ClaudeCodeChatProvider.test.tsx index 42f75a4e96..0225ce37bb 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/context/ClaudeCodeChatProvider.test.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/context/ClaudeCodeChatProvider.test.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { getClaudeCodeActiveSessionStorageKey } from '@studio/routes/agents/ClaudeCodeChatRoute/activeSessionStorage'; +import { ClaudeCodeSessionNotFoundError } from '@studio/routes/agents/ClaudeCodeChatRoute/api'; import { ClaudeCodeChatProvider } from '@studio/routes/agents/ClaudeCodeChatRoute/context/ClaudeCodeChatProvider'; import { useClaudeCodeChatContext } from '@studio/routes/agents/ClaudeCodeChatRoute/context/useClaudeCodeChatContext'; import { render, screen, waitFor } from '@testing-library/react'; @@ -14,13 +15,15 @@ const mocks = vi.hoisted(() => ({ handleReset: vi.fn(), getClaudeCodeSessionHistory: vi.fn(), sessionId: null as string | null, + toastError: vi.fn(), })); vi.mock('@nemo/common/src/providers/toast/useToast', () => ({ - useToast: () => ({ error: vi.fn() }), + useToast: () => ({ error: mocks.toastError }), })); -vi.mock('@studio/routes/agents/ClaudeCodeChatRoute/api', () => ({ +vi.mock('@studio/routes/agents/ClaudeCodeChatRoute/api', async (importOriginal) => ({ + ...(await importOriginal()), getClaudeCodeSessionHistory: mocks.getClaudeCodeSessionHistory, })); @@ -124,4 +127,18 @@ describe('ClaudeCodeChatProvider', () => { ) ); }); + + it('forgets a stored active session when its history no longer exists', async () => { + const storageKey = getClaudeCodeActiveSessionStorageKey(WORKSPACE); + localStorage.setItem(storageKey, 'missing-session'); + mocks.getClaudeCodeSessionHistory.mockRejectedValue( + new ClaudeCodeSessionNotFoundError('no such session history') + ); + + renderProvider(
); + + await waitFor(() => expect(localStorage.getItem(storageKey)).toBeNull()); + expect(mocks.applySession).not.toHaveBeenCalled(); + expect(mocks.toastError).not.toHaveBeenCalled(); + }); }); diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/context/ClaudeCodeChatProvider.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/context/ClaudeCodeChatProvider.tsx index fe066349d6..d2c5d88001 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/context/ClaudeCodeChatProvider.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/context/ClaudeCodeChatProvider.tsx @@ -6,7 +6,10 @@ import { readStoredActiveSessionId, writeStoredActiveSessionId, } from '@studio/routes/agents/ClaudeCodeChatRoute/activeSessionStorage'; -import { getClaudeCodeSessionHistory } from '@studio/routes/agents/ClaudeCodeChatRoute/api'; +import { + ClaudeCodeSessionNotFoundError, + getClaudeCodeSessionHistory, +} from '@studio/routes/agents/ClaudeCodeChatRoute/api'; import { ClaudeCodeChatContext, type ClaudeCodeChatLoadStatus, @@ -53,7 +56,7 @@ export const ClaudeCodeChatProvider: FC = ({ const { handleReset, loadSession: applySession, sessionId } = chat; const loadSession = useCallback( - async (nextSessionId: string) => { + async (nextSessionId: string, forgetIfMissing = false) => { const trimmedSessionId = nextSessionId.trim(); if (!trimmedSessionId || trimmedSessionId === sessionId) return; @@ -61,7 +64,7 @@ export const ClaudeCodeChatProvider: FC = ({ setLoadStatus('loading'); try { - const history = await getClaudeCodeSessionHistory(trimmedSessionId); + const history = await getClaudeCodeSessionHistory(trimmedSessionId, workspace); // Ignore a stale fetch if a newer session was requested meanwhile. if (requestedSessionIdRef.current !== trimmedSessionId) return; @@ -73,13 +76,19 @@ export const ClaudeCodeChatProvider: FC = ({ setLoadStatus('idle'); } catch (error: unknown) { if (requestedSessionIdRef.current !== trimmedSessionId) return; + if (forgetIfMissing && error instanceof ClaudeCodeSessionNotFoundError) { + requestedSessionIdRef.current = null; + writeStoredActiveSessionId(workspace, null); + setLoadStatus('idle'); + return; + } setLoadStatus('error'); toast.error( error instanceof Error ? error.message : 'Could not load NeMo Copilot session.' ); } }, - [applySession, sessionId, toast] + [applySession, sessionId, toast, workspace] ); // Starting a new chat must cancel any in-flight session load, otherwise a @@ -97,7 +106,7 @@ export const ClaudeCodeChatProvider: FC = ({ hasHydratedRef.current = true; const storedSessionId = readStoredActiveSessionId(workspace); - if (storedSessionId) void loadSession(storedSessionId); + if (storedSessionId) void loadSession(storedSessionId, true); }, [loadSession, workspace]); const value = useMemo( diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistoryPanelContents.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistoryPanelContents.tsx index bd3dfc1ba1..dee2b54af3 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistoryPanelContents.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistoryPanelContents.tsx @@ -2,33 +2,51 @@ // SPDX-License-Identifier: Apache-2.0 import { Banner, Button, Flex, Text, Tooltip } from '@nvidia/foundations-react-core'; +import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal'; import { Empty } from '@studio/components/Empty'; import { - CLAUDE_CODE_HISTORY_SESSIONS_QUERY_KEY, + deleteClaudeCodeSessionHistory, + getClaudeCodeHistorySessionsQueryKey, listClaudeCodeHistorySessions, } from '@studio/routes/agents/ClaudeCodeChatRoute/api'; +import { getHistorySessionTitle } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/helpers'; import { HistoryPanelSkeleton } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/HistoryPanelSkeletons'; import { HistorySessionButton } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/HistorySessionButton'; import type { ClaudeCodeHistoryPanelProps } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/types'; -import { useQuery } from '@tanstack/react-query'; +import type { ClaudeCodeHistorySession } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { MessageSquarePlus, RefreshCw } from 'lucide-react'; +import { useState } from 'react'; export const HistoryPanelContents = ({ activeSessionId, onNewChat, onSelectSession, + workspace = 'default', }: ClaudeCodeHistoryPanelProps) => { + const queryClient = useQueryClient(); + const [sessionToDelete, setSessionToDelete] = useState(null); + const historyQueryKey = getClaudeCodeHistorySessionsQueryKey(workspace); const { data: sessions = [], error, isLoading, refetch, } = useQuery({ - queryKey: CLAUDE_CODE_HISTORY_SESSIONS_QUERY_KEY, - queryFn: listClaudeCodeHistorySessions, + queryKey: historyQueryKey, + queryFn: () => listClaudeCodeHistorySessions(workspace), refetchOnMount: 'always', }); + const handleDelete = async (): Promise => { + if (!sessionToDelete) return false; + const deletedSessionId = sessionToDelete.session_id; + await deleteClaudeCodeSessionHistory(deletedSessionId, workspace); + await queryClient.invalidateQueries({ queryKey: historyQueryKey }); + if (deletedSessionId === activeSessionId) onNewChat(); + return true; + }; + return ( <>
@@ -74,6 +92,7 @@ export const HistoryPanelContents = ({ key={session.session_id} active={session.session_id === activeSessionId} session={session} + onDelete={() => setSessionToDelete(session)} onSelect={() => onSelectSession(session.session_id)} /> ))} @@ -83,6 +102,17 @@ export const HistoryPanelContents = ({ ) : null} + {sessionToDelete && ( + setSessionToDelete(null)} + onDelete={handleDelete} + /> + )} ); }; 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 0c2b2ee15e..9815eb12a9 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistorySessionButton.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistorySessionButton.tsx @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { Flex, Stack, Text } from '@nvidia/foundations-react-core'; +import { Button, Flex, Stack, Text, Tooltip } from '@nvidia/foundations-react-core'; import { ToolCallSummary } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/ArtifactSections'; import { getCompactRelativeTime, @@ -9,17 +9,19 @@ import { } 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'; +import { MessageSquare, Trash2 } from 'lucide-react'; import React from 'react'; interface HistorySessionButtonProps { active: boolean; + onDelete: () => void; onSelect: () => void; session: ClaudeCodeHistorySession; } export const HistorySessionButton = ({ active, + onDelete, onSelect, session, }: HistorySessionButtonProps): React.JSX.Element => { @@ -29,41 +31,62 @@ export const HistorySessionButton = ({ const tooltip = prompt ? `${timestamp}\n\n${prompt}` : timestamp; return ( - + {session.tool_calls.length > 0 && ( +
+ +
+ )} + + + + + +
); }; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/types.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/types.ts index 0e2d42934f..bd4ecb964a 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/types.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/types.ts @@ -9,4 +9,5 @@ export interface ClaudeCodeHistoryPanelProps { hideArtifacts?: boolean; onNewChat: () => void; onSelectSession: (sessionId: string) => void; + workspace?: string; } diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.test.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.test.ts index ab2421fae1..3d50561b7d 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.test.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.test.ts @@ -17,8 +17,13 @@ const mocks = vi.hoisted(() => ({ })); vi.mock('@studio/routes/agents/ClaudeCodeChatRoute/api', () => ({ - CLAUDE_CODE_HISTORY_SESSIONS_QUERY_KEY: ['claude-code', 'history', 'sessions'], createClaudeCodeSession: mocks.createClaudeCodeSession, + getClaudeCodeHistorySessionsQueryKey: (workspace: string) => [ + 'claude-code', + 'history', + 'sessions', + workspace, + ], resolveClaudeCodeInput: mocks.resolveClaudeCodeInput, resolveClaudeCodePermission: mocks.resolveClaudeCodePermission, streamClaudeCodeMessage: mocks.streamClaudeCodeMessage, diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts index e4c6f9d1a4..72e2a81ccc 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts @@ -13,8 +13,8 @@ import type { AgentDecisionSubmission, } from '@studio/components/agents/AgentDecisionInput'; import { - CLAUDE_CODE_HISTORY_SESSIONS_QUERY_KEY, createClaudeCodeSession, + getClaudeCodeHistorySessionsQueryKey, resolveClaudeCodeInput, resolveClaudeCodePermission, streamClaudeCodeMessage, @@ -448,12 +448,12 @@ export const useClaudeCodeChatRuntime = (options?: UseClaudeCodeChatRuntimeOptio if (sessionId) return sessionId; // Only one run is active at a time (UI prevents concurrent submissions), // so no race between concurrent callers here. - const nextSessionId = await createClaudeCodeSession(); + const nextSessionId = await createClaudeCodeSession(workspace); sessionIdRef.current = nextSessionId; setSessionId(nextSessionId); onSessionIdChange?.(nextSessionId); return nextSessionId; - }, [sessionId, onSessionIdChange]); + }, [sessionId, onSessionIdChange, workspace]); // Accepts an optional decision to cancel a pending navigation promise before // clearing, avoiding a spurious 'submitting' status on programmatic cancels. @@ -592,7 +592,9 @@ export const useClaudeCodeChatRuntime = (options?: UseClaudeCodeChatRuntimeOptio }, }, }); - void queryClient.invalidateQueries({ queryKey: CLAUDE_CODE_HISTORY_SESSIONS_QUERY_KEY }); + void queryClient.invalidateQueries({ + queryKey: getClaudeCodeHistorySessionsQueryKey(workspace ?? 'default'), + }); if (!doneReceived && !signal.aborted) { throw new Error( 'Connection to NeMo Copilot was interrupted. Your response may still be processing — check History to see the result.' From 0e7976eaf76e3b34f1458bc56d4ec30b55b947cf Mon Sep 17 00:00:00 2001 From: Danielle Ali <44468613+dmariali@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:36:25 -0400 Subject: [PATCH 2/9] test(studio): adapt copilot stream test to persisted history Signed-off-by: Danielle Ali <44468613+dmariali@users.noreply.github.com> --- services/studio/tests/unit/test_copilot.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/services/studio/tests/unit/test_copilot.py b/services/studio/tests/unit/test_copilot.py index 70f9665176..ff88ba9884 100644 --- a/services/studio/tests/unit/test_copilot.py +++ b/services/studio/tests/unit/test_copilot.py @@ -2294,6 +2294,14 @@ def test_tool_use_stream_event_strips_internal_session_id(): @pytest.mark.asyncio async def test_stream_copilot_flushes_tool_events_before_final_response(monkeypatch: pytest.MonkeyPatch): session_id = str(uuid.uuid4()) + entity_store = FakeEntityStore() + conversation = CopilotConversation( + name=f"copilot-{session_id}", + workspace="default", + session_id=session_id, + owner_id="local-user", + ) + await entity_store.create(conversation) async def fake_invoke(agent_url, headers, messages, studio_session_id): queue = copilot._session_streams[studio_session_id] @@ -2306,7 +2314,16 @@ async def fake_invoke(agent_url, headers, messages, studio_session_id): monkeypatch.setattr(copilot, "_invoke_copilot", fake_invoke) frames = [ - frame async for frame in copilot._stream_copilot(session_id, "hello", "https://agent.test/x", {}, "sys prompt") + frame + async for frame in copilot._stream_copilot( + session_id, + "hello", + "https://agent.test/x", + {}, + "sys prompt", + conversation, + entity_store, + ) ] body = "".join(frames) @@ -2317,6 +2334,7 @@ async def fake_invoke(agent_url, headers, messages, studio_session_id): # Both tool-use events survive and are emitted before the final assistant message. assert first_tool < final assert second_tool < final + assert [message.content for message in conversation.messages] == ["hello", "final answer"] def test_copilot_request_payload_keeps_session_outside_model_messages(): From 665a17fe6e7125b104893c7923445ed51d5d7497 Mon Sep 17 00:00:00 2001 From: Danielle Ali <44468613+dmariali@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:50:45 -0400 Subject: [PATCH 3/9] style(studio): format copilot history components Signed-off-by: Danielle Ali <44468613+dmariali@users.noreply.github.com> --- .../ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.test.tsx | 8 ++++---- .../historyPanel/HistorySessionButton.tsx | 6 +----- 2 files changed, 5 insertions(+), 9 deletions(-) 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 a70c273b93..7543390338 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.test.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.test.tsx @@ -216,12 +216,12 @@ describe('ClaudeCodeHistoryPanel', () => { /> ); await user.click(screen.getByRole('button', { name: 'Expand All Chats' })); - await user.click( - await screen.findByRole('button', { name: 'Delete chat Private agent work' }) - ); + await user.click(await screen.findByRole('button', { name: 'Delete chat Private agent work' })); expect(screen.getByRole('dialog', { name: 'Delete chat?' })).toBeInTheDocument(); - expect(screen.getByText('Delete “Private agent work”? This chat cannot be recovered.')).toBeInTheDocument(); + expect( + screen.getByText('Delete “Private agent work”? This chat cannot be recovered.') + ).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Delete' })); await waitFor(() => 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 9815eb12a9..8c70281472 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistorySessionButton.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistorySessionButton.tsx @@ -59,11 +59,7 @@ export const HistorySessionButton = ({ {sessionTitle} - + {getCompactRelativeTime(session.mtime)} From 3a926e59d8077e0a2cdf814af2fbc6abfd1074fc Mon Sep 17 00:00:00 2001 From: Danielle Ali <44468613+dmariali@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:55:21 -0400 Subject: [PATCH 4/9] fix(copilot): secure loopback SDK default Signed-off-by: Danielle Ali <44468613+dmariali@users.noreply.github.com> --- agents/nemo-studio-copilot/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/nemo-studio-copilot/Dockerfile b/agents/nemo-studio-copilot/Dockerfile index 35c2df4c38..c725a5b968 100644 --- a/agents/nemo-studio-copilot/Dockerfile +++ b/agents/nemo-studio-copilot/Dockerfile @@ -59,7 +59,7 @@ ENV NAT_CONFIG_FILE=/workspace/src/nemo_studio_copilot/nemo-studio-copilot.yml # this port. NMP_BASE_URL is supplied by the deployment runtime, so use the # agent-specific legacy override for SDK calls and allow local runtimes to # replace it when needed. -ENV NEMO_BASE_URL=http://127.0.0.1:8090 +ENV NEMO_BASE_URL=https://127.0.0.1:8090 ENV PATH="/workspace/.venv/bin:$PATH" From ca1937f4fbec90c897c6c28789711bb4507dae5d Mon Sep 17 00:00:00 2001 From: Danielle Ali <44468613+dmariali@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:56:12 -0400 Subject: [PATCH 5/9] test(copilot): isolate default workspace assertion Signed-off-by: Danielle Ali <44468613+dmariali@users.noreply.github.com> --- agents/nemo-studio-copilot/tests/test_nemo_studio_copilot.py | 1 + 1 file changed, 1 insertion(+) diff --git a/agents/nemo-studio-copilot/tests/test_nemo_studio_copilot.py b/agents/nemo-studio-copilot/tests/test_nemo_studio_copilot.py index 2d5603b95b..6d91ed3840 100644 --- a/agents/nemo-studio-copilot/tests/test_nemo_studio_copilot.py +++ b/agents/nemo-studio-copilot/tests/test_nemo_studio_copilot.py @@ -187,6 +187,7 @@ def test_sdk_uses_deployment_platform_base_url(self, monkeypatch): def test_sdk_prefers_agent_base_url_override(self, monkeypatch): monkeypatch.setenv("NMP_BASE_URL", "http://platform-gateway:8080") monkeypatch.setenv("NEMO_BASE_URL", "http://127.0.0.1:8090") + monkeypatch.delenv("NMP_WORKSPACE", raising=False) with ( patch("nemo_studio_copilot.register._client", None), From cf537c5c64b51a41d6a32dec818230184c82856b Mon Sep 17 00:00:00 2001 From: Danielle Ali <44468613+dmariali@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:58:41 -0400 Subject: [PATCH 6/9] fix(studio): retry conflicted copilot history updates Signed-off-by: Danielle Ali <44468613+dmariali@users.noreply.github.com> --- services/studio/src/nmp/studio/copilot.py | 35 +++++++++--- services/studio/tests/unit/test_copilot.py | 66 +++++++++++++++++++++- 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/services/studio/src/nmp/studio/copilot.py b/services/studio/src/nmp/studio/copilot.py index 3c4d032d04..86ec839d82 100644 --- a/services/studio/src/nmp/studio/copilot.py +++ b/services/studio/src/nmp/studio/copilot.py @@ -157,6 +157,21 @@ def _recent_conversation_messages(conversation: list[CopilotMessage]) -> list[Co return conversation[-max_messages:] +def _append_conversation_turn( + conversation: CopilotConversation, + user_message: str, + assistant_message: str, + model: str, +) -> None: + conversation.messages.extend( + [ + CopilotMessage(role="user", content=user_message), + CopilotMessage(role="assistant", content=assistant_message), + ] + ) + record_copilot_model(conversation.chat_artifacts, model) + + @dataclass class HistorySummary: """Aggregated metadata from a Claude session history file.""" @@ -1689,14 +1704,18 @@ async def _stream_copilot( yield rendered assistant_text, model = await invocation - conversation.messages.extend( - [ - CopilotMessage(role="user", content=message), - CopilotMessage(role="assistant", content=assistant_text), - ] - ) - record_copilot_model(conversation.chat_artifacts, model) - await entity_store.update(conversation) + _append_conversation_turn(conversation, message, assistant_text, model) + try: + await entity_store.update(conversation) + except EntityConflictError: + logger.info("Reloading conflicted NeMo Copilot session %s before retrying", session_id) + latest_conversation = await entity_store.get( + CopilotConversation, + conversation.name, + workspace=conversation.workspace, + ) + _append_conversation_turn(latest_conversation, message, assistant_text, model) + await entity_store.update(latest_conversation) yield _sse( json.dumps( { diff --git a/services/studio/tests/unit/test_copilot.py b/services/studio/tests/unit/test_copilot.py index ff88ba9884..5ddf03ece6 100644 --- a/services/studio/tests/unit/test_copilot.py +++ b/services/studio/tests/unit/test_copilot.py @@ -19,7 +19,7 @@ from fastapi import FastAPI, HTTPException, Request from fastapi.responses import StreamingResponse from fastapi.testclient import TestClient -from nmp.common.entities.client import EntityNotFoundError +from nmp.common.entities.client import EntityConflictError, EntityNotFoundError from nmp.common.service.dependencies import get_entity_client from nmp.studio import copilot, copilot_artifacts, copilot_skills, studio_links from nmp.studio.config import StudioConfig @@ -2337,6 +2337,70 @@ async def fake_invoke(agent_url, headers, messages, studio_session_id): assert [message.content for message in conversation.messages] == ["hello", "final answer"] +@pytest.mark.asyncio +async def test_stream_copilot_retries_conflicted_conversation_update(monkeypatch: pytest.MonkeyPatch): + session_id = str(uuid.uuid4()) + conversation = CopilotConversation( + name=f"copilot-{session_id}", + workspace="default", + session_id=session_id, + owner_id="local-user", + ) + concurrent_conversation = conversation.model_copy(deep=True) + concurrent_conversation.messages.extend( + [ + CopilotMessage(role="user", content="remote question"), + CopilotMessage(role="assistant", content="remote answer"), + ] + ) + + class ConflictingEntityStore(FakeEntityStore): + def __init__(self) -> None: + super().__init__() + self.update_calls = 0 + + async def update(self, entity: CopilotConversation) -> CopilotConversation: + self.update_calls += 1 + if self.update_calls == 1: + self.entities[(concurrent_conversation.workspace, concurrent_conversation.name)] = ( + concurrent_conversation + ) + raise EntityConflictError("conversation was updated by another replica") + return await super().update(entity) + + entity_store = ConflictingEntityStore() + await entity_store.create(conversation) + + async def fake_invoke(agent_url, headers, messages, studio_session_id): + return "local answer", "model-x" + + monkeypatch.setattr(copilot, "_invoke_copilot", fake_invoke) + + frames = [ + frame + async for frame in copilot._stream_copilot( + session_id, + "local question", + "https://agent.test/x", + {}, + "sys prompt", + conversation, + entity_store, + ) + ] + + assert "event: done" in "".join(frames) + assert entity_store.update_calls == 2 + persisted = entity_store.entities[("default", conversation.name)] + assert [message.content for message in persisted.messages] == [ + "remote question", + "remote answer", + "local question", + "local answer", + ] + assert persisted.chat_artifacts.copilot_model == "model-x" + + def test_copilot_request_payload_keeps_session_outside_model_messages(): messages = [{"role": "user", "content": "hello"}] session_id = str(uuid.uuid4()) From f4e2ffaa510d072f6f1c93ae89de5028dbb59861 Mon Sep 17 00:00:00 2001 From: Danielle Ali <44468613+dmariali@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:06:48 -0400 Subject: [PATCH 7/9] refactor(studio): rename Claude Code chat to Copilot Signed-off-by: Danielle Ali <44468613+dmariali@users.noreply.github.com> --- .../Layouts/GlobalNav/index.test.tsx | 4 +- .../components/Layouts/GlobalNav/index.tsx | 22 +-- .../DashboardLandingRoute/index.test.tsx | 12 +- .../routes/DashboardLandingRoute/index.tsx | 12 +- .../skillActionSuggestions.ts | 6 +- .../skillActionTemplates.test.ts | 4 +- .../skillDisplayName.test.ts | 4 +- .../DashboardLandingRoute/skillDisplayName.ts | 6 +- .../studio/src/routes/PageLayout/index.tsx | 6 +- .../context/useClaudeCodeChatContext.ts | 28 --- .../agents/ClaudeCodeChatRoute/types.ts | 138 ------------- .../BlockingInputComposer.test.tsx | 12 +- .../BlockingInputComposer.tsx | 6 +- .../CopilotChatThread.test.tsx} | 12 +- .../CopilotChatThread.tsx} | 18 +- .../CopilotHistoryPanel.test.tsx} | 58 +++--- .../CopilotHistoryPanel.tsx} | 25 ++- .../CopilotLayout.tsx} | 14 +- .../CopilotStudioLink.test.tsx} | 10 +- .../CopilotStudioLink.tsx} | 10 +- .../CopilotStudioLinkTarget.ts} | 0 .../CopilotToolCallPart.test.tsx} | 185 +++++++++--------- .../CopilotToolCallPart.tsx} | 70 +++---- .../CopilotTopBarChat.test.tsx} | 14 +- .../CopilotTopBarChat.tsx} | 18 +- .../JobProgressToolCall.test.tsx | 8 +- .../JobProgressToolCall.tsx | 8 +- .../activeSessionStorage.ts | 10 +- .../api.test.ts | 84 ++++---- .../api.ts | 173 ++++++++-------- .../artifacts.test.ts | 74 ++++--- .../artifacts.ts | 101 +++++----- .../blockingInputRequest.ts | 6 +- .../context/CopilotChatProvider.test.tsx} | 42 ++-- .../context/CopilotChatProvider.tsx} | 37 ++-- .../context/useCopilotChatContext.ts | 28 +++ .../historyPanel/ArtifactSections.tsx | 40 ++-- .../historyPanel/CopilotArtifactsPane.tsx} | 10 +- .../historyPanel/FloatingPanel.tsx | 0 .../historyPanel/HistoryPanelContents.tsx | 28 +-- .../historyPanel/HistoryPanelSkeletons.tsx | 0 .../historyPanel/HistorySessionButton.tsx | 8 +- .../historyPanel/SkillCard.tsx | 4 +- .../historyPanel/SkillsPanelContents.tsx | 14 +- .../historyPanel/helpers.test.ts | 14 +- .../historyPanel/helpers.ts | 20 +- .../historyPanel/types.ts | 6 +- .../index.test.tsx | 45 ++--- .../index.tsx | 46 ++--- .../jobProgressConsts.ts | 4 +- .../stream.test.ts | 50 ++--- .../stream.ts | 28 +-- .../studioUiNavigationSuggestions.test.ts | 2 +- .../studioUiNavigationSuggestions.ts | 0 .../toolCall/CollapsedThinkingToolCall.tsx | 6 +- .../toolCall/FileChangeToolCallCard.tsx | 12 +- .../toolCall/SubtleToolCallRow.tsx | 32 +-- .../toolCall/constants.ts | 0 .../toolCall/helpers.ts | 36 ++-- .../toolCall/types.ts | 0 .../toolParts.test.ts | 54 ++--- .../toolParts.ts | 174 ++++++++-------- .../routes/agents/CopilotChatRoute/types.ts | 134 +++++++++++++ .../useCopilotChatRuntime.test.ts} | 142 +++++++------- .../useCopilotChatRuntime.ts} | 112 +++++------ .../useCustomAssistantChatRuntime.test.ts | 18 +- .../useCustomAssistantChatRuntime.ts | 20 +- .../util.test.ts | 70 +++---- .../util.ts | 40 ++-- .../utils/jobProgress.test.ts | 4 +- .../utils/jobProgress.ts | 2 +- .../src/routes/groups/dashboardRoutes.tsx | 8 +- web/packages/studio/src/util/localStorage.ts | 7 +- 73 files changed, 1202 insertions(+), 1253 deletions(-) delete mode 100644 web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/context/useClaudeCodeChatContext.ts delete mode 100644 web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/types.ts rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/BlockingInputComposer.test.tsx (88%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/BlockingInputComposer.tsx (92%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute/ClaudeCodeChatThread.test.tsx => CopilotChatRoute/CopilotChatThread.test.tsx} (91%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute/ClaudeCodeChatThread.tsx => CopilotChatRoute/CopilotChatThread.tsx} (90%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.test.tsx => CopilotChatRoute/CopilotHistoryPanel.test.tsx} (87%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.tsx => CopilotChatRoute/CopilotHistoryPanel.tsx} (74%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute/ClaudeCodeLayout.tsx => CopilotChatRoute/CopilotLayout.tsx} (81%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute/ClaudeCodeStudioLink.test.tsx => CopilotChatRoute/CopilotStudioLink.test.tsx} (89%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute/ClaudeCodeStudioLink.tsx => CopilotChatRoute/CopilotStudioLink.tsx} (83%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute/ClaudeCodeStudioLinkTarget.ts => CopilotChatRoute/CopilotStudioLinkTarget.ts} (100%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute/ClaudeCodeToolCallPart.test.tsx => CopilotChatRoute/CopilotToolCallPart.test.tsx} (73%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute/ClaudeCodeToolCallPart.tsx => CopilotChatRoute/CopilotToolCallPart.tsx} (73%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute/ClaudeCodeTopBarChat.test.tsx => CopilotChatRoute/CopilotTopBarChat.test.tsx} (91%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute/ClaudeCodeTopBarChat.tsx => CopilotChatRoute/CopilotTopBarChat.tsx} (92%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/JobProgressToolCall.test.tsx (89%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/JobProgressToolCall.tsx (93%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/activeSessionStorage.ts (71%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/api.test.ts (90%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/api.ts (73%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/artifacts.test.ts (80%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/artifacts.ts (86%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/blockingInputRequest.ts (83%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute/context/ClaudeCodeChatProvider.test.tsx => CopilotChatRoute/context/CopilotChatProvider.test.tsx} (68%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute/context/ClaudeCodeChatProvider.tsx => CopilotChatRoute/context/CopilotChatProvider.tsx} (74%) create mode 100644 web/packages/studio/src/routes/agents/CopilotChatRoute/context/useCopilotChatContext.ts rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/historyPanel/ArtifactSections.tsx (81%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute/historyPanel/ClaudeCodeArtifactsPane.tsx => CopilotChatRoute/historyPanel/CopilotArtifactsPane.tsx} (92%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/historyPanel/FloatingPanel.tsx (100%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/historyPanel/HistoryPanelContents.tsx (76%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/historyPanel/HistoryPanelSkeletons.tsx (100%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/historyPanel/HistorySessionButton.tsx (89%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/historyPanel/SkillCard.tsx (89%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/historyPanel/SkillsPanelContents.tsx (83%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/historyPanel/helpers.test.ts (84%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/historyPanel/helpers.ts (91%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/historyPanel/types.ts (61%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/index.test.tsx (76%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/index.tsx (75%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/jobProgressConsts.ts (87%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/stream.test.ts (83%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/stream.ts (80%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/studioUiNavigationSuggestions.test.ts (98%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/studioUiNavigationSuggestions.ts (100%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/toolCall/CollapsedThinkingToolCall.tsx (89%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/toolCall/FileChangeToolCallCard.tsx (87%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/toolCall/SubtleToolCallRow.tsx (85%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/toolCall/constants.ts (100%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/toolCall/helpers.ts (91%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/toolCall/types.ts (100%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/toolParts.test.ts (85%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/toolParts.ts (78%) create mode 100644 web/packages/studio/src/routes/agents/CopilotChatRoute/types.ts rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute/useClaudeCodeChatRuntime.test.ts => CopilotChatRoute/useCopilotChatRuntime.test.ts} (83%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts => CopilotChatRoute/useCopilotChatRuntime.ts} (89%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/useCustomAssistantChatRuntime.test.ts (96%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/useCustomAssistantChatRuntime.ts (96%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/util.test.ts (86%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/util.ts (76%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/utils/jobProgress.test.ts (96%) rename web/packages/studio/src/routes/agents/{ClaudeCodeChatRoute => CopilotChatRoute}/utils/jobProgress.ts (97%) diff --git a/web/packages/studio/src/components/Layouts/GlobalNav/index.test.tsx b/web/packages/studio/src/components/Layouts/GlobalNav/index.test.tsx index da5147090e..09a9c8372a 100644 --- a/web/packages/studio/src/components/Layouts/GlobalNav/index.test.tsx +++ b/web/packages/studio/src/components/Layouts/GlobalNav/index.test.tsx @@ -20,8 +20,8 @@ vi.mock('@studio/routes/PageLayout/ThemeSwitch', () => ({ ThemeSwitch: () =>
, })); -vi.mock('@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeTopBarChat', () => ({ - ClaudeCodeTopBarChat: () =>
, +vi.mock('@studio/routes/agents/CopilotChatRoute/CopilotTopBarChat', () => ({ + CopilotTopBarChat: () =>
, })); vi.mock('@studio/constants/environment', async (importOriginal) => { diff --git a/web/packages/studio/src/components/Layouts/GlobalNav/index.tsx b/web/packages/studio/src/components/Layouts/GlobalNav/index.tsx index 3314e65811..3550621901 100644 --- a/web/packages/studio/src/components/Layouts/GlobalNav/index.tsx +++ b/web/packages/studio/src/components/Layouts/GlobalNav/index.tsx @@ -7,7 +7,7 @@ import { UserPopover } from '@studio/components/UserPopover'; import { TOUR_ENABLED } from '@studio/constants/environment'; import { ROUTES } from '@studio/constants/routes'; import { useWorkspaceFromPathIfExists } from '@studio/hooks/useWorkspaceFromPath'; -import { ClaudeCodeTopBarChat } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeTopBarChat'; +import { CopilotTopBarChat } from '@studio/routes/agents/CopilotChatRoute/CopilotTopBarChat'; import { ThemeSwitch } from '@studio/routes/PageLayout/ThemeSwitch'; import { getWorkspaceDetailsDefaultRoute } from '@studio/routes/utils'; import { useSidebarState } from '@studio/util/hooks/useSidebarState'; @@ -25,18 +25,18 @@ interface Props { interface GlobalNavContentProps extends Props { isDashboardRoute: boolean; - isClaudeCodeChatRoute: boolean; + isCopilotChatRoute: boolean; } const GlobalNavContent: FC = ({ sideNav, isDashboardRoute, - isClaudeCodeChatRoute, + isCopilotChatRoute, }) => { const workspace = useWorkspaceFromPathIfExists(); - const { expanded, toggle } = useSidebarState(!isClaudeCodeChatRoute); - const shouldMountClaudeCodeTopBarChat = !isDashboardRoute && !isClaudeCodeChatRoute; - const sidebarBackground = isClaudeCodeChatRoute + const { expanded, toggle } = useSidebarState(!isCopilotChatRoute); + const shouldMountCopilotTopBarChat = !isDashboardRoute && !isCopilotChatRoute; + const sidebarBackground = isCopilotChatRoute ? 'bg-surface-sunken dark:bg-surface-base' : 'bg-surface-navigation'; @@ -86,7 +86,7 @@ const GlobalNavContent: FC = ({ )} - {shouldMountClaudeCodeTopBarChat && } + {shouldMountCopilotTopBarChat && } @@ -96,7 +96,7 @@ const GlobalNavContent: FC = ({ /> {sideNav && (
{sideNav(!expanded)} @@ -110,15 +110,15 @@ export const GlobalNav: FC = ({ sideNav }) => { const location = useLocation(); const isDashboardRoute = matchPath({ path: ROUTES.workspace.dashboard, end: true }, location.pathname) !== null; - const isClaudeCodeChatRoute = + const isCopilotChatRoute = matchPath({ path: ROUTES.workspace.copilotChat, end: true }, location.pathname) !== null; return ( ); }; diff --git a/web/packages/studio/src/routes/DashboardLandingRoute/index.test.tsx b/web/packages/studio/src/routes/DashboardLandingRoute/index.test.tsx index 8a233494c8..42d91bb156 100644 --- a/web/packages/studio/src/routes/DashboardLandingRoute/index.test.tsx +++ b/web/packages/studio/src/routes/DashboardLandingRoute/index.test.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { ROUTES } from '@studio/constants/routes'; -import { getClaudeCodeActiveSessionStorageKey } from '@studio/routes/agents/ClaudeCodeChatRoute/activeSessionStorage'; +import { getCopilotActiveSessionStorageKey } from '@studio/routes/agents/CopilotChatRoute/activeSessionStorage'; import { DashboardLandingRoute } from '@studio/routes/DashboardLandingRoute'; import { mockFeatureFlags } from '@studio/tests/util/mockFeatureFlags'; import { TestProviders } from '@studio/tests/util/TestProviders'; @@ -10,13 +10,13 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createMemoryRouter, generatePath, RouterProvider, useLocation } from 'react-router'; -vi.mock('@studio/routes/agents/ClaudeCodeChatRoute/api', async (importOriginal) => { +vi.mock('@studio/routes/agents/CopilotChatRoute/api', async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal(); return { ...actual, - listClaudeCodeHistorySessions: vi.fn(async () => []), + listCopilotHistorySessions: vi.fn(async () => []), }; }); @@ -121,7 +121,7 @@ describe('DashboardLandingRoute', () => { it('clears the active NeMo Copilot session before starting from the landing composer', async () => { const user = userEvent.setup(); - localStorage.setItem(getClaudeCodeActiveSessionStorageKey(workspace), 'session-existing'); + localStorage.setItem(getCopilotActiveSessionStorageKey(workspace), 'session-existing'); renderRoute(); await user.type( @@ -130,7 +130,7 @@ describe('DashboardLandingRoute', () => { ); await user.click(screen.getByRole('button', { name: 'Send message' })); - expect(localStorage.getItem(getClaudeCodeActiveSessionStorageKey(workspace))).toBeNull(); + expect(localStorage.getItem(getCopilotActiveSessionStorageKey(workspace))).toBeNull(); }); it('submits the landing composer when Enter is pressed', async () => { diff --git a/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx b/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx index 9110f84f0b..3496e00055 100644 --- a/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx +++ b/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx @@ -6,9 +6,9 @@ import { Button, Flex, Text, TextArea, Tooltip } from '@nvidia/foundations-react import { AccessibleTitle } from '@studio/components/AccessibleTitle'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; -import { writeStoredActiveSessionId } from '@studio/routes/agents/ClaudeCodeChatRoute/activeSessionStorage'; -import { ClaudeCodeLayout } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeLayout'; -import type { ClaudeCodeChatRouteState } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import { writeStoredActiveSessionId } from '@studio/routes/agents/CopilotChatRoute/activeSessionStorage'; +import { CopilotLayout } from '@studio/routes/agents/CopilotChatRoute/CopilotLayout'; +import type { CopilotChatRouteState } from '@studio/routes/agents/CopilotChatRoute/types'; import { getCopilotChatRoute } from '@studio/routes/utils'; import { Send, Terminal } from 'lucide-react'; import { @@ -105,14 +105,14 @@ export const DashboardLandingRoute: FC = () => { const handleSubmit = useCallback( (prompt: string) => { writeStoredActiveSessionId(workspace, null); - const state: ClaudeCodeChatRouteState = { initialPrompt: prompt }; + const state: CopilotChatRouteState = { initialPrompt: prompt }; navigate(getCopilotChatRoute(workspace), { state }); }, [navigate, workspace] ); return ( - +
@@ -128,6 +128,6 @@ export const DashboardLandingRoute: FC = () => {
-
+ ); }; diff --git a/web/packages/studio/src/routes/DashboardLandingRoute/skillActionSuggestions.ts b/web/packages/studio/src/routes/DashboardLandingRoute/skillActionSuggestions.ts index 2b2df3489d..90d2ff56d3 100644 --- a/web/packages/studio/src/routes/DashboardLandingRoute/skillActionSuggestions.ts +++ b/web/packages/studio/src/routes/DashboardLandingRoute/skillActionSuggestions.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { featureFlags } from '@studio/constants/featureFlags'; -import type { ClaudeCodeSkill } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import type { CopilotSkill } from '@studio/routes/agents/CopilotChatRoute/types'; import { SKILL_ACTION_TEMPLATES, type SkillActionSuggestion, @@ -19,7 +19,7 @@ export type { const isSkillActionTemplateName = (skillName: string): skillName is SkillActionTemplateName => Object.prototype.hasOwnProperty.call(SKILL_ACTION_TEMPLATES, skillName); -const getSkillActionTemplate = (skill: ClaudeCodeSkill): SkillActionTemplate | undefined => { +const getSkillActionTemplate = (skill: CopilotSkill): SkillActionTemplate | undefined => { for (const lookupKey of getSkillLookupKeys(skill)) { if (isSkillActionTemplateName(lookupKey)) { return SKILL_ACTION_TEMPLATES[lookupKey]; @@ -32,7 +32,7 @@ const getSkillActionTemplate = (skill: ClaudeCodeSkill): SkillActionTemplate | u export const isSkillActionEnabled = (template: SkillActionTemplate) => template.requiredFeatureFlags?.every((flag) => featureFlags[flag] !== false) ?? true; -export const getSkillActionSuggestions = (skills: ClaudeCodeSkill[]): SkillActionSuggestion[] => { +export const getSkillActionSuggestions = (skills: CopilotSkill[]): SkillActionSuggestion[] => { const seenSkills = new Set(); const suggestions: SkillActionSuggestion[] = []; diff --git a/web/packages/studio/src/routes/DashboardLandingRoute/skillActionTemplates.test.ts b/web/packages/studio/src/routes/DashboardLandingRoute/skillActionTemplates.test.ts index 0abf746f41..64aff9267d 100644 --- a/web/packages/studio/src/routes/DashboardLandingRoute/skillActionTemplates.test.ts +++ b/web/packages/studio/src/routes/DashboardLandingRoute/skillActionTemplates.test.ts @@ -1,11 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { ClaudeCodeSkill } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import type { CopilotSkill } from '@studio/routes/agents/CopilotChatRoute/types'; import { getSkillActionSuggestions } from '@studio/routes/DashboardLandingRoute/skillActionSuggestions'; import { mockFeatureFlags } from '@studio/tests/util/mockFeatureFlags'; -const skill = (overrides: Partial): ClaudeCodeSkill => ({ +const skill = (overrides: Partial): CopilotSkill => ({ name: 'inference', claude_name: 'nemo-inference', description: 'Use NeMo Platform inference.', diff --git a/web/packages/studio/src/routes/DashboardLandingRoute/skillDisplayName.test.ts b/web/packages/studio/src/routes/DashboardLandingRoute/skillDisplayName.test.ts index bf02d2eb48..e0513dcf09 100644 --- a/web/packages/studio/src/routes/DashboardLandingRoute/skillDisplayName.test.ts +++ b/web/packages/studio/src/routes/DashboardLandingRoute/skillDisplayName.test.ts @@ -1,13 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { ClaudeCodeSkill } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import type { CopilotSkill } from '@studio/routes/agents/CopilotChatRoute/types'; import { getSkillDisplayName, getSkillLookupKeys, } from '@studio/routes/DashboardLandingRoute/skillDisplayName'; -const skill = (overrides: Partial): ClaudeCodeSkill => ({ +const skill = (overrides: Partial): CopilotSkill => ({ name: 'inference', claude_name: 'nemo-inference', description: 'Use NeMo Platform inference.', diff --git a/web/packages/studio/src/routes/DashboardLandingRoute/skillDisplayName.ts b/web/packages/studio/src/routes/DashboardLandingRoute/skillDisplayName.ts index 5588f9ce94..a9698eae13 100644 --- a/web/packages/studio/src/routes/DashboardLandingRoute/skillDisplayName.ts +++ b/web/packages/studio/src/routes/DashboardLandingRoute/skillDisplayName.ts @@ -1,13 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { ClaudeCodeSkill } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import type { CopilotSkill } from '@studio/routes/agents/CopilotChatRoute/types'; const titleCaseSkillSegment = (segment: string): string => segment ? segment.charAt(0).toUpperCase() + segment.slice(1) : segment; /** Strip repeated ``nemo-`` prefixes before title-casing skill folder names. */ -export const getSkillLookupKeys = (skill: ClaudeCodeSkill): string[] => { +export const getSkillLookupKeys = (skill: CopilotSkill): string[] => { const keys = new Set(); for (const rawName of [skill.name, skill.claude_name]) { @@ -22,7 +22,7 @@ export const getSkillLookupKeys = (skill: ClaudeCodeSkill): string[] => { return [...keys]; }; -export const getSkillDisplayName = (skill: ClaudeCodeSkill): string => { +export const getSkillDisplayName = (skill: CopilotSkill): string => { let name = skill.name; while (name.startsWith('nemo-')) { name = name.slice(5); diff --git a/web/packages/studio/src/routes/PageLayout/index.tsx b/web/packages/studio/src/routes/PageLayout/index.tsx index 55e80656e4..507bb197dd 100644 --- a/web/packages/studio/src/routes/PageLayout/index.tsx +++ b/web/packages/studio/src/routes/PageLayout/index.tsx @@ -7,7 +7,7 @@ import { useWorkspaceFromPathIfExists } from '@studio/hooks/useWorkspaceFromPath import { useAuthAutoLogin } from '@studio/providers/auth'; import { useAuthTokenStatus } from '@studio/providers/auth/useAuthTokenStatus'; import { useSelectedWorkspace } from '@studio/providers/workspace'; -import { ClaudeCodeChatProvider } from '@studio/routes/agents/ClaudeCodeChatRoute/context/ClaudeCodeChatProvider'; +import { CopilotChatProvider } from '@studio/routes/agents/CopilotChatRoute/context/CopilotChatProvider'; import { WorkspaceGuard } from '@studio/routes/RootLayout/WorkspaceGuard'; import { ReactNode } from 'react'; import { Outlet } from 'react-router'; @@ -48,9 +48,9 @@ export const PageLayout = ({ sideNav }: { sideNav?: (collapsed: boolean) => Reac className={`min-h-screen relative grid size-full text-primary grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_1fr] ${gridAreas}`} > {COPILOT_STUDIO_ENABLED && workspace ? ( - + {layout} - + ) : ( layout )} diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/context/useClaudeCodeChatContext.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/context/useClaudeCodeChatContext.ts deleted file mode 100644 index aca966cd53..0000000000 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/context/useClaudeCodeChatContext.ts +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { ClaudeCodeChatRuntime } from '@studio/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime'; -import { createContext, useContext } from 'react'; - -export type ClaudeCodeChatLoadStatus = 'idle' | 'loading' | 'error'; - -export interface ClaudeCodeChatContextValue { - /** The single chat runtime shared by the full chat route and the pop-out. */ - chat: ClaudeCodeChatRuntime; - /** Status of the most recent `loadSession` fetch. */ - loadStatus: ClaudeCodeChatLoadStatus; - /** Fetch a session's history and load it into the shared runtime. */ - loadSession: (sessionId: string) => void; - /** Reset the shared runtime to a fresh, empty chat. */ - startNewChat: () => void; -} - -export const ClaudeCodeChatContext = createContext(null); - -export const useClaudeCodeChatContext = (): ClaudeCodeChatContextValue => { - const context = useContext(ClaudeCodeChatContext); - if (!context) { - throw new Error('useClaudeCodeChatContext must be used within a ClaudeCodeChatProvider'); - } - return context; -}; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/types.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/types.ts deleted file mode 100644 index 69b42e0ef7..0000000000 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/types.ts +++ /dev/null @@ -1,138 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -export interface ClaudeCodeStreamHandlers { - onClaudeEvent: (event: unknown) => void; - onInputRequest: (request: ClaudeCodeInputRequest) => void; - onPermissionRequest: (request: ClaudeCodePermissionRequest) => void; - onInputExpired?: (requestId: string) => void; - onPermissionExpired?: (requestId: string) => void; - onDone: () => void; - onError: (error: Error) => void; -} - -export interface ClaudeCodePermissionRequest { - requestId: string; - toolName: string; - input: Record; - toolUseId?: string; -} - -export interface ClaudeCodePermissionDecision { - approved: boolean; - reason?: string; - updatedInput?: Record; -} - -export type ClaudeCodeInputRequestKind = 'agent' | 'eval_config' | 'dataset_file' | 'model'; - -export interface ClaudeCodeInputRequest { - requestId: string; - kind: ClaudeCodeInputRequestKind; - input: Record; -} - -export interface ClaudeCodeInputDecision { - skipped?: boolean; - value?: Record; -} - -export interface ClaudeCodeChatRouteState { - initialPrompt?: string; -} - -export interface ClaudeCodeChatSelectionArtifact { - label: string; - value: string; -} - -export interface ClaudeCodeChatFileArtifact { - action: string; - path: string; -} - -export interface ClaudeCodeChatLinkArtifact { - label: string; - destination?: string; - href?: string; -} - -export interface ClaudeCodeChatJobArtifact { - name: string; - job_type?: string; - source?: string; - href?: string; -} - -export type ClaudeCodeChatModelSource = 'copilot' | 'selection' | 'spec'; - -export interface ClaudeCodeChatArtifacts { - agent?: string; - model?: string; - model_source?: ClaudeCodeChatModelSource; - copilot_model?: string; - workspace?: string; - selections: ClaudeCodeChatSelectionArtifact[]; - files: ClaudeCodeChatFileArtifact[]; - links: ClaudeCodeChatLinkArtifact[]; - jobs: ClaudeCodeChatJobArtifact[]; - tools: string[]; -} - -export interface ClaudeCodeHistorySession { - session_id: string; - mtime: number; - title?: string; - first_prompt: string; - message_count: number; - token_count: number; - tool_call_count: number; - tool_calls: string[]; - chat_artifacts: ClaudeCodeChatArtifacts; -} - -export interface ClaudeCodeSkill { - name: string; - claude_name: string; - description: string; - source: string; - source_path?: string | null; - install_path: string; - installed: boolean; -} - -export interface ClaudeCodeUserHistoryItem { - kind: 'user'; - text: string; -} - -export interface ClaudeCodeAssistantTextPart { - type: 'text'; - text: string; -} - -export interface ClaudeCodeAssistantToolUsePart { - type: 'tool_use'; - id?: string; - name: string; - input: Record; -} - -export type ClaudeCodeAssistantHistoryPart = - | ClaudeCodeAssistantTextPart - | ClaudeCodeAssistantToolUsePart; - -export interface ClaudeCodeAssistantHistoryItem { - kind: 'assistant'; - parts: ClaudeCodeAssistantHistoryPart[]; -} - -export type ClaudeCodeSessionHistoryItem = - | ClaudeCodeUserHistoryItem - | ClaudeCodeAssistantHistoryItem; - -export interface ClaudeCodeSessionHistory { - session_id: string; - items: ClaudeCodeSessionHistoryItem[]; - chat_artifacts: ClaudeCodeChatArtifacts; -} diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/BlockingInputComposer.test.tsx b/web/packages/studio/src/routes/agents/CopilotChatRoute/BlockingInputComposer.test.tsx similarity index 88% rename from web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/BlockingInputComposer.test.tsx rename to web/packages/studio/src/routes/agents/CopilotChatRoute/BlockingInputComposer.test.tsx index 4d26533e5d..afec96235f 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/BlockingInputComposer.test.tsx +++ b/web/packages/studio/src/routes/agents/CopilotChatRoute/BlockingInputComposer.test.tsx @@ -1,16 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { getBlockingInputRequest } from '@studio/routes/agents/ClaudeCodeChatRoute/blockingInputRequest'; +import { getBlockingInputRequest } from '@studio/routes/agents/CopilotChatRoute/blockingInputRequest'; import type { - ClaudeCodeInputRequest, - ClaudeCodeInputRequestKind, -} from '@studio/routes/agents/ClaudeCodeChatRoute/types'; + CopilotInputRequest, + CopilotInputRequestKind, +} from '@studio/routes/agents/CopilotChatRoute/types'; const makeRequest = ( - kind: ClaudeCodeInputRequestKind, + kind: CopilotInputRequestKind, input: Record = {} -): ClaudeCodeInputRequest => ({ +): CopilotInputRequest => ({ requestId: 'request-1', kind, input, diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/BlockingInputComposer.tsx b/web/packages/studio/src/routes/agents/CopilotChatRoute/BlockingInputComposer.tsx similarity index 92% rename from web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/BlockingInputComposer.tsx rename to web/packages/studio/src/routes/agents/CopilotChatRoute/BlockingInputComposer.tsx index a80b716be2..8bdc730cd8 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/BlockingInputComposer.tsx +++ b/web/packages/studio/src/routes/agents/CopilotChatRoute/BlockingInputComposer.tsx @@ -9,12 +9,12 @@ import { type AgentBlockingInputStatus, type AgentBlockingInputSubmission, } from '@studio/components/agents/AgentBlockingInput'; -import { getBlockingInputRequest } from '@studio/routes/agents/ClaudeCodeChatRoute/blockingInputRequest'; -import type { ClaudeCodeInputRequest } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import { getBlockingInputRequest } from '@studio/routes/agents/CopilotChatRoute/blockingInputRequest'; +import type { CopilotInputRequest } from '@studio/routes/agents/CopilotChatRoute/types'; import { type FC } from 'react'; interface BlockingInputComposerProps { - readonly inputRequest: ClaudeCodeInputRequest; + readonly inputRequest: CopilotInputRequest; readonly inputStatus: AgentBlockingInputStatus; readonly workspace: string; readonly onSubmit: (submission: AgentBlockingInputSubmission) => Promise | void; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.test.tsx b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotChatThread.test.tsx similarity index 91% rename from web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.test.tsx rename to web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotChatThread.test.tsx index 7dbd25ba52..b077995533 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.test.tsx +++ b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotChatThread.test.tsx @@ -2,11 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { ROUTES } from '@studio/constants/routes'; -import { ClaudeCodeChatThread } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread'; +import { CopilotChatThread } from '@studio/routes/agents/CopilotChatRoute/CopilotChatThread'; import type { - ClaudeCodeChatRuntime, + CopilotChatRuntime, StudioNavigationRequest, -} from '@studio/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime'; +} from '@studio/routes/agents/CopilotChatRoute/useCopilotChatRuntime'; import { TestProviders } from '@studio/tests/util/TestProviders'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -79,14 +79,14 @@ const makeChat = (studioNavigationRequest: StudioNavigationRequest | null) => studioNavigationRequest, studioNavigationStatus: 'pending', submitPrompt: vi.fn(), - }) as unknown as ClaudeCodeChatRuntime; + }) as unknown as CopilotChatRuntime; const renderThread = (studioNavigationRequest = makeStudioNavigationRequest()) => { const router = createMemoryRouter( [ { path: ROUTES.workspace.copilotChat, - element: , + element: , }, { path: ROUTES.workspace.guardrails, element:
}, ], @@ -100,7 +100,7 @@ const renderThread = (studioNavigationRequest = makeStudioNavigationRequest()) = ); }; -describe('ClaudeCodeChatThread Studio UI navigation', () => { +describe('CopilotChatThread Studio UI navigation', () => { beforeEach(() => { vi.clearAllMocks(); }); diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.tsx b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotChatThread.tsx similarity index 90% rename from web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.tsx rename to web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotChatThread.tsx index 2a4a269b5b..ac5fcd100f 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.tsx +++ b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotChatThread.tsx @@ -9,14 +9,14 @@ import { type AgentDecisionChoice, } from '@studio/components/agents/AgentDecisionInput'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; -import { BlockingInputComposer } from '@studio/routes/agents/ClaudeCodeChatRoute/BlockingInputComposer'; -import { ClaudeCodeStudioLink } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeStudioLink'; -import { ClaudeCodeToolCallPart } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeToolCallPart'; -import type { ClaudeCodeChatRuntime } from '@studio/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime'; +import { BlockingInputComposer } from '@studio/routes/agents/CopilotChatRoute/BlockingInputComposer'; +import { CopilotStudioLink } from '@studio/routes/agents/CopilotChatRoute/CopilotStudioLink'; +import { CopilotToolCallPart } from '@studio/routes/agents/CopilotChatRoute/CopilotToolCallPart'; +import type { CopilotChatRuntime } from '@studio/routes/agents/CopilotChatRoute/useCopilotChatRuntime'; import { type FC, useCallback, useLayoutEffect, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router'; -const MESSAGE_CONTENT_PROPS = { markdownLinkComponent: ClaudeCodeStudioLink }; +const MESSAGE_CONTENT_PROPS = { markdownLinkComponent: CopilotStudioLink }; const EMPTY_STATE = { slotHeading: 'Start a NeMo Copilot session', @@ -34,14 +34,14 @@ const CHAT_VIEWPORT_SCROLLBAR_CLASS = [ '[&::-webkit-scrollbar-thumb:hover]:bg-[var(--border-color-interaction-strong)]', ].join(' '); -interface ClaudeCodeChatThreadProps { - chat: ClaudeCodeChatRuntime; +interface CopilotChatThreadProps { + chat: CopilotChatRuntime; mode?: 'full' | 'compact'; onReset?: () => void; scrollToBottomSignal?: number; } -export const ClaudeCodeChatThread: FC = ({ +export const CopilotChatThread: FC = ({ chat, mode = 'full', onReset, @@ -157,7 +157,7 @@ export const ClaudeCodeChatThread: FC = ({ } viewportClassName={CHAT_VIEWPORT_SCROLLBAR_CLASS} hideAssistantMessageActions - toolCallPartComponent={ClaudeCodeToolCallPart} + toolCallPartComponent={CopilotToolCallPart} attributes={{ ThreadViewport: { ref: chatViewportRef, diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.test.tsx b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotHistoryPanel.test.tsx similarity index 87% rename from web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.test.tsx rename to web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotHistoryPanel.test.tsx index 7543390338..286df59d7c 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.test.tsx +++ b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotHistoryPanel.test.tsx @@ -1,37 +1,37 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { ClaudeCodeHistoryPanel } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel'; +import { CopilotHistoryPanel } from '@studio/routes/agents/CopilotChatRoute/CopilotHistoryPanel'; import { render, screen } from '@studio/tests/util/render'; import { waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; const mocks = vi.hoisted(() => ({ - deleteClaudeCodeSessionHistory: vi.fn(), - listClaudeCodeHistorySessions: vi.fn(), - listClaudeCodeSkills: vi.fn(), + deleteCopilotSessionHistory: vi.fn(), + listCopilotHistorySessions: vi.fn(), + listCopilotSkills: vi.fn(), })); -vi.mock('@studio/routes/agents/ClaudeCodeChatRoute/api', () => ({ - CLAUDE_CODE_SKILLS_QUERY_KEY: ['claude-code', 'skills'], - deleteClaudeCodeSessionHistory: mocks.deleteClaudeCodeSessionHistory, - getClaudeCodeHistorySessionsQueryKey: (workspace: string) => [ - 'claude-code', +vi.mock('@studio/routes/agents/CopilotChatRoute/api', () => ({ + COPILOT_SKILLS_QUERY_KEY: ['copilot', 'skills'], + deleteCopilotSessionHistory: mocks.deleteCopilotSessionHistory, + getCopilotHistorySessionsQueryKey: (workspace: string) => [ + 'copilot', 'history', 'sessions', workspace, ], - listClaudeCodeHistorySessions: mocks.listClaudeCodeHistorySessions, - listClaudeCodeSkills: mocks.listClaudeCodeSkills, + listCopilotHistorySessions: mocks.listCopilotHistorySessions, + listCopilotSkills: mocks.listCopilotSkills, })); -describe('ClaudeCodeHistoryPanel', () => { +describe('CopilotHistoryPanel', () => { beforeEach(() => { localStorage.clear(); vi.clearAllMocks(); - mocks.listClaudeCodeHistorySessions.mockResolvedValue([]); - mocks.deleteClaudeCodeSessionHistory.mockResolvedValue(undefined); - mocks.listClaudeCodeSkills.mockResolvedValue([ + mocks.listCopilotHistorySessions.mockResolvedValue([]); + mocks.deleteCopilotSessionHistory.mockResolvedValue(undefined); + mocks.listCopilotSkills.mockResolvedValue([ { name: 'inference', claude_name: 'nemo-inference', @@ -47,7 +47,7 @@ describe('ClaudeCodeHistoryPanel', () => { it('starts history and skills collapsed and expands them independently', async () => { const user = userEvent.setup(); render( - { const user = userEvent.setup(); const onNewChat = vi.fn(); const onSelectSession = vi.fn(); - mocks.listClaudeCodeHistorySessions.mockResolvedValue([ + mocks.listCopilotHistorySessions.mockResolvedValue([ { session_id: 'session-1', mtime: Date.now() / 1000, @@ -108,7 +108,7 @@ describe('ClaudeCodeHistoryPanel', () => { ]); const { unmount } = render( - { unmount(); render( - { it('shows the summarized title while preserving the full first prompt in the tooltip', async () => { const user = userEvent.setup(); const firstPrompt = 'I want to create an agent that does spam detection for incoming email.'; - mocks.listClaudeCodeHistorySessions.mockResolvedValue([ + mocks.listCopilotHistorySessions.mockResolvedValue([ { session_id: 'session-1', mtime: Date.now() / 1000, @@ -167,7 +167,7 @@ describe('ClaudeCodeHistoryPanel', () => { ]); render( - { it('confirms deletion and starts a new chat when deleting the active session', async () => { const user = userEvent.setup(); const onNewChat = vi.fn(); - mocks.listClaudeCodeHistorySessions.mockResolvedValue([ + mocks.listCopilotHistorySessions.mockResolvedValue([ { session_id: 'session-1', mtime: Date.now() / 1000, @@ -208,7 +208,7 @@ describe('ClaudeCodeHistoryPanel', () => { ]); render( - { await user.click(screen.getByRole('button', { name: 'Delete' })); await waitFor(() => - expect(mocks.deleteClaudeCodeSessionHistory).toHaveBeenCalledWith('session-1', 'team-a') + expect(mocks.deleteCopilotSessionHistory).toHaveBeenCalledWith('session-1', 'team-a') ); expect(onNewChat).toHaveBeenCalledTimes(1); }); it('renders job artifacts as Studio links', () => { render( - { it('does not treat workspace metadata as a visible chat artifact', () => { render( - { it('omits empty artifact sections and their dividers', () => { render( - { it('ignores selections with whitespace-only values', () => { render( - { it('lists NeMo Copilot skills in the expanded skills block', async () => { const user = userEvent.setup(); render( - = ({ - hideArtifacts, - ...props -}) => { - const [historyOpen, setHistoryOpen] = useLocalStorage(CLAUDE_CODE_HISTORY_OPEN_KEY, 'true'); +export const CopilotHistoryPanel: FC = ({ hideArtifacts, ...props }) => { + const [historyOpen, setHistoryOpen] = useLocalStorage(COPILOT_HISTORY_OPEN_KEY, 'true'); const [openFloatingPanel, setOpenFloatingPanel, clearOpenFloatingPanel] = - useLocalStorage(CLAUDE_CODE_OPEN_FLOATING_PANEL_KEY); + useLocalStorage(COPILOT_OPEN_FLOATING_PANEL_KEY); const isOpen = historyOpen !== 'false'; const toggleLabel = isOpen ? 'Collapse NeMo Copilot history' : 'Expand NeMo Copilot history'; const handleFloatingPanelOpenChange = (panel: OpenFloatingPanel, open: boolean) => { @@ -55,7 +52,7 @@ export const ClaudeCodeHistoryPanel: FC = ({ return (