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 938f009c67..9a6e6e64a1 100644 --- a/web/packages/studio/src/components/Layouts/GlobalNav/index.test.tsx +++ b/web/packages/studio/src/components/Layouts/GlobalNav/index.test.tsx @@ -1,11 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { ROUTES } from '@studio/constants/routes'; import { mockUseParams } from '@studio/tests/util/mockUseParams'; import { SIDE_NAV_OPEN_KEY } from '@studio/util/localStorage'; import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router-dom'; +import { generatePath, MemoryRouter } from 'react-router-dom'; vi.mock('@studio/components/Breadcrumbs', () => ({ Breadcrumbs: () =>
, @@ -19,6 +20,10 @@ vi.mock('@studio/routes/PageLayout/ThemeSwitch', () => ({ ThemeSwitch: () =>
, })); +vi.mock('@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeTopBarChat', () => ({ + ClaudeCodeTopBarChat: () =>
, +})); + vi.mock('@studio/constants/environment', async (importOriginal) => { const actual = await importOriginal(); return { @@ -57,15 +62,15 @@ const createMatchMediaMock = (initialMatches: boolean) => { return { mql, matchMediaFn, fireChange }; }; -const renderGlobalNav = async () => { +const renderGlobalNav = async (initialPath = '/workspaces/test-workspace/jobs') => { const { GlobalNav } = await import('@studio/components/Layouts/GlobalNav/index'); render( - +
Side Nav Content
} />
); // Wait for Suspense / lazy components to settle before the test proceeds - await screen.findByRole('button', { name: /(Collapse|Expand) sidebar/i }); + await screen.findAllByRole('button', { name: /(Collapse|Expand) sidebar/i }); }; describe('GlobalNav', () => { @@ -172,4 +177,34 @@ describe('GlobalNav', () => { expect(sideNavSpy).toHaveBeenCalledWith(false); }); }); + + describe('Code Agent top bar chat', () => { + it('mounts outside the dashboard and full Code Agent routes', async () => { + createMatchMediaMock(true); + + await renderGlobalNav('/workspaces/test-workspace/jobs'); + + expect(screen.getByTestId('code-agent-top-bar-chat')).toBeInTheDocument(); + }); + + it('does not mount on the dashboard route', async () => { + createMatchMediaMock(true); + + await renderGlobalNav( + generatePath(ROUTES.workspace.dashboard, { workspace: 'test-workspace' }) + ); + + expect(screen.queryByTestId('code-agent-top-bar-chat')).not.toBeInTheDocument(); + }); + + it('does not mount on the full Code Agent route', async () => { + createMatchMediaMock(true); + + await renderGlobalNav( + generatePath(ROUTES.workspace.claudeCodeChat, { workspace: 'test-workspace' }) + ); + + expect(screen.queryByTestId('code-agent-top-bar-chat')).not.toBeInTheDocument(); + }); + }); }); diff --git a/web/packages/studio/src/components/Layouts/GlobalNav/index.tsx b/web/packages/studio/src/components/Layouts/GlobalNav/index.tsx index 94a668da0c..5885cb32c6 100644 --- a/web/packages/studio/src/components/Layouts/GlobalNav/index.tsx +++ b/web/packages/studio/src/components/Layouts/GlobalNav/index.tsx @@ -5,13 +5,15 @@ import { AppBar, Button, Flex, Stack, Text, Tooltip } from '@nvidia/foundations- import { Breadcrumbs } from '@studio/components/Breadcrumbs'; 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 { ThemeSwitch } from '@studio/routes/PageLayout/ThemeSwitch'; import { getWorkspaceDetailsDefaultRoute } from '@studio/routes/utils'; import { useSidebarState } from '@studio/util/hooks/useSidebarState'; import { PanelLeftClose, PanelLeftOpen } from 'lucide-react'; import { lazy, Suspense, type FC, type ReactNode } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, matchPath, useLocation } from 'react-router-dom'; const WelcomeTour = lazy(() => import('@studio/components/WelcomeTour').then((m) => ({ default: m.WelcomeTour })) @@ -24,6 +26,12 @@ interface Props { export const GlobalNav: FC = ({ sideNav }) => { const { expanded, toggle } = useSidebarState(); const workspace = useWorkspaceFromPathIfExists(); + const location = useLocation(); + const isDashboardRoute = + matchPath({ path: ROUTES.workspace.dashboard, end: true }, location.pathname) !== null; + const isClaudeCodeChatRoute = + matchPath({ path: ROUTES.workspace.claudeCodeChat, end: true }, location.pathname) !== null; + const shouldMountClaudeCodeTopBarChat = !isDashboardRoute && !isClaudeCodeChatRoute; const toggleLabel = expanded ? 'Collapse sidebar' : 'Expand sidebar'; const ToggleSidebarButton = ( @@ -71,6 +79,7 @@ export const GlobalNav: FC = ({ sideNav }) => { )} + {shouldMountClaudeCodeTopBarChat && } diff --git a/web/packages/studio/src/routes/DashboardLandingRoute/index.test.tsx b/web/packages/studio/src/routes/DashboardLandingRoute/index.test.tsx index 7da013ee10..01900ba2f5 100644 --- a/web/packages/studio/src/routes/DashboardLandingRoute/index.test.tsx +++ b/web/packages/studio/src/routes/DashboardLandingRoute/index.test.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { ROUTES } from '@studio/constants/routes'; +import { getClaudeCodeActiveSessionStorageKey } from '@studio/routes/agents/ClaudeCodeChatRoute/activeSessionStorage'; import { DashboardLandingRoute } from '@studio/routes/DashboardLandingRoute'; import { mockFeatureFlags } from '@studio/tests/util/mockFeatureFlags'; import { TestProviders } from '@studio/tests/util/TestProviders'; @@ -59,6 +60,7 @@ const renderRoute = () => { describe('DashboardLandingRoute', () => { beforeEach(() => { + localStorage.clear(); vi.clearAllMocks(); mockFeatureFlags({ agentsEnabled: true, @@ -332,6 +334,17 @@ describe('DashboardLandingRoute', () => { ); }); + it('clears the active Claude Code session before starting from the landing composer', async () => { + const user = userEvent.setup(); + localStorage.setItem(getClaudeCodeActiveSessionStorageKey(workspace), 'session-existing'); + renderRoute(); + + await user.type(await screen.findByRole('textbox', { name: 'Message Claude' }), 'Check repo'); + await user.click(screen.getByRole('button', { name: 'Send message' })); + + expect(localStorage.getItem(getClaudeCodeActiveSessionStorageKey(workspace))).toBeNull(); + }); + it('submits the landing composer when Enter is pressed', async () => { const user = userEvent.setup(); renderRoute(); diff --git a/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx b/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx index a8ee3e3879..9e6233429e 100644 --- a/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx +++ b/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx @@ -6,6 +6,7 @@ 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 { CLAUDE_CODE_SKILLS_QUERY_KEY, listClaudeCodeSkills, @@ -153,6 +154,7 @@ export const DashboardLandingRoute: FC = () => { const handleSubmit = useCallback( (prompt: string) => { + writeStoredActiveSessionId(workspace, null); const state: ClaudeCodeChatRouteState = { initialPrompt: prompt }; navigate(getClaudeCodeChatRoute(workspace), { state }); }, diff --git a/web/packages/studio/src/routes/PageLayout/index.tsx b/web/packages/studio/src/routes/PageLayout/index.tsx index 5858a4106b..e74fd5bb0e 100644 --- a/web/packages/studio/src/routes/PageLayout/index.tsx +++ b/web/packages/studio/src/routes/PageLayout/index.tsx @@ -2,9 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { GlobalNav } from '@studio/components/Layouts/GlobalNav'; +import { CODING_AGENT_STUDIO_ENABLED } from '@studio/constants/environment'; +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 { WorkspaceGuard } from '@studio/routes/RootLayout/WorkspaceGuard'; import { ReactNode } from 'react'; import { Outlet } from 'react-router-dom'; @@ -13,6 +16,7 @@ export const PageLayout = ({ sideNav }: { sideNav?: (collapsed: boolean) => Reac const { isAuthPending } = useAuthAutoLogin(); const { isTokenActive } = useAuthTokenStatus(); const { selectedWorkspace, isWorkspaceUnauthorized, isWorkspaceLoading } = useSelectedWorkspace(); + const workspace = useWorkspaceFromPathIfExists(); if (isAuthPending) { return null; @@ -25,16 +29,31 @@ export const PageLayout = ({ sideNav }: { sideNav?: (collapsed: boolean) => Reac ? "[grid-template-areas:'logobar_navbar''content_content']" : "[grid-template-areas:'logobar_navbar''sidebar_content']"; - return ( -
+ // Rendered as direct grid children; the provider adds no DOM of its own, so + // it can wrap both the nav (pop-out chat) and the outlet (full chat) while + // keeping a single shared chat runtime alive across workspace navigation. + const layout = ( + <>
+ + ); + + return ( +
+ {CODING_AGENT_STUDIO_ENABLED && workspace ? ( + + {layout} + + ) : ( + layout + )}
); }; diff --git a/web/packages/studio/src/routes/SafeSynthesizerJobReportRoute/components/ScorePanels/DataPrivacyPanel.test.tsx b/web/packages/studio/src/routes/SafeSynthesizerJobReportRoute/components/ScorePanels/DataPrivacyPanel.test.tsx index 3407ce0c51..ac5972ece7 100644 --- a/web/packages/studio/src/routes/SafeSynthesizerJobReportRoute/components/ScorePanels/DataPrivacyPanel.test.tsx +++ b/web/packages/studio/src/routes/SafeSynthesizerJobReportRoute/components/ScorePanels/DataPrivacyPanel.test.tsx @@ -31,8 +31,11 @@ vi.mock('@nemo/common/src/components/Dial', () => ({ ), })); -// Mock lucide-react icons (React 19: ref is a plain prop, no forwardRef needed) -vi.mock('lucide-react', () => ({ +// Mock lucide-react icons (React 19: ref is a plain prop, no forwardRef needed). +// Spread the real module so other icons used by rendered children (e.g. the +// DataView SearchBar's `Search`) keep working, and only stub the asserted ones. +vi.mock('lucide-react', async (importOriginal) => ({ + ...(await importOriginal()), CircleCheck: ({ className, ref }: { className?: string; ref?: React.Ref }) => ( ), diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.tsx new file mode 100644 index 0000000000..c18553fd1d --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.tsx @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AssistantRuntimeProvider } from '@assistant-ui/react'; +import { AssistantChatThread } from '@nemo/common/src/components/AssistantChat/AssistantChatThread'; +import { type AgentBlockingInputSubmission } from '@studio/components/agents/AgentBlockingInput'; +import { AgentDecisionInput } 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 { type FC, useCallback, useLayoutEffect, useRef } from 'react'; + +const CHAT_VIEWPORT_SCROLLBAR_CLASS = [ + '[scrollbar-width:thin]', + '[scrollbar-color:var(--border-color-interaction-base)_transparent]', + '[&::-webkit-scrollbar]:w-2', + '[&::-webkit-scrollbar-corner]:bg-transparent', + '[&::-webkit-scrollbar-track]:bg-transparent', + '[&::-webkit-scrollbar-thumb]:rounded-full', + '[&::-webkit-scrollbar-thumb]:bg-[var(--border-color-interaction-base)]', + '[&::-webkit-scrollbar-thumb:hover]:bg-[var(--border-color-interaction-strong)]', +].join(' '); + +interface ClaudeCodeChatThreadProps { + chat: ClaudeCodeChatRuntime; + mode?: 'full' | 'compact'; + onReset?: () => void; + scrollToBottomSignal?: number; +} + +export const ClaudeCodeChatThread: FC = ({ + chat, + mode = 'full', + onReset, + scrollToBottomSignal, +}) => { + const workspace = useWorkspaceFromPath(); + const chatViewportRef = useRef(null); + const { + decisionChoices, + decisionRequest, + decisionStatus, + handleReset, + inputRequest, + inputStatus, + resolveInputRequest, + resolveDecisionRequest, + runtime, + skipInputRequest, + skipDecisionRequest, + } = chat; + + const scrollViewportToBottom = useCallback(() => { + const viewport = chatViewportRef.current; + if (!viewport) return undefined; + + let secondFrame = 0; + const firstFrame = window.requestAnimationFrame(() => { + viewport.scrollTop = viewport.scrollHeight; + secondFrame = window.requestAnimationFrame(() => { + viewport.scrollTop = viewport.scrollHeight; + }); + }); + + return () => { + window.cancelAnimationFrame(firstFrame); + if (secondFrame) window.cancelAnimationFrame(secondFrame); + }; + }, []); + + const handleChatReset = useCallback(() => { + handleReset(); + onReset?.(); + }, [handleReset, onReset]); + + const handleInputSubmit = useCallback( + async (submission: AgentBlockingInputSubmission) => { + await resolveInputRequest({ + decision: { value: submission.value }, + displayText: submission.displayText, + }); + }, + [resolveInputRequest] + ); + + useLayoutEffect(() => { + if (!decisionRequest && !inputRequest) return undefined; + return scrollViewportToBottom(); + }, [decisionRequest, inputRequest, scrollViewportToBottom]); + + useLayoutEffect(() => { + if (scrollToBottomSignal === undefined) return undefined; + return scrollViewportToBottom(); + }, [scrollToBottomSignal, scrollViewportToBottom]); + + return ( + + + ) : inputRequest ? ( + + ) : undefined + } + /> + + ); +}; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeLayout.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeLayout.tsx index 295b5aa402..305c2c6d1c 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeLayout.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeLayout.tsx @@ -14,22 +14,27 @@ interface ClaudeCodeLayoutProps { activeSessionId?: string; artifacts?: ClaudeCodeChatArtifacts; children: ReactNode; + onNewChat?: () => void; } export const ClaudeCodeLayout: FC = ({ activeSessionId, artifacts, children, + onNewChat, }) => { const workspace = useWorkspaceFromPath(); const navigate = useNavigate(); const handleNewChat = useCallback(() => { + onNewChat?.(); navigate(getWorkspaceDashboardRoute(workspace)); - }, [navigate, workspace]); + }, [navigate, onNewChat, workspace]); const handleSelectSession = useCallback( (sessionId: string) => { + // Navigating to the session URL drives the shared runtime to load it + // (which also persists it as the active session via onSessionIdChange). navigate(getClaudeCodeChatRouteForSession(workspace, sessionId)); }, [navigate, workspace] diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeTopBarChat.test.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeTopBarChat.test.tsx new file mode 100644 index 0000000000..fd9949a50a --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeTopBarChat.test.tsx @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ClaudeCodeTopBarChat } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeTopBarChat'; +import { mockUseParams } from '@studio/tests/util/mockUseParams'; +import { TestProviders } from '@studio/tests/util/TestProviders'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; + +const mocks = vi.hoisted(() => ({ + chat: { + decisionRequest: null, + inputRequest: null, + isRunning: false, + } as { decisionRequest: unknown; inputRequest: unknown; isRunning: boolean }, + startNewChat: vi.fn(), +})); + +vi.mock('@studio/constants/environment', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + CODING_AGENT_STUDIO_ENABLED: true, + }; +}); + +vi.mock('@studio/routes/agents/ClaudeCodeChatRoute/context/useClaudeCodeChatContext', () => ({ + useClaudeCodeChatContext: () => ({ + chat: mocks.chat, + loadStatus: 'idle', + loadSession: vi.fn(), + startNewChat: mocks.startNewChat, + }), +})); + +vi.mock('@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread', () => ({ + ClaudeCodeChatThread: () => ( + + ), +})); + +const WORKSPACE = 'default'; + +const LocationProbe = () => { + const location = useLocation(); + return
{location.pathname}
; +}; + +const getTopBarChatElement = (initialPath = `/workspaces/${WORKSPACE}/jobs`) => ( + + + + + } /> + + + +); + +const renderTopBarChat = (initialPath = `/workspaces/${WORKSPACE}/jobs`) => + render(getTopBarChatElement(initialPath)); + +describe('ClaudeCodeTopBarChat', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseParams({ workspace: WORKSPACE }); + mocks.chat.isRunning = false; + mocks.chat.decisionRequest = null; + mocks.chat.inputRequest = null; + }); + + it('opens and closes the compact chat from the top bar icon', async () => { + renderTopBarChat(); + const user = userEvent.setup(); + const trigger = screen.getByRole('button', { name: 'Open Code Agent chat' }); + + await user.click(trigger); + expect(await screen.findByTestId('compact-chat-thread')).toBeVisible(); + + await user.click(screen.getByRole('button', { name: 'Close Code Agent chat' })); + await waitFor(() => expect(screen.getByTestId('compact-chat-thread')).not.toBeVisible()); + }); + + it('darkens the screen with a backdrop and closes the chat when it is clicked', async () => { + renderTopBarChat(); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Open Code Agent chat' })); + const backdrop = await screen.findByTestId('code-agent-chat-backdrop'); + expect(backdrop).toBeVisible(); + + await user.click(backdrop); + + await waitFor(() => + expect(screen.queryByTestId('code-agent-chat-backdrop')).not.toBeInTheDocument() + ); + }); + + it('navigates to the main chat from the popout header', async () => { + renderTopBarChat(); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Open Code Agent chat' })); + await user.click(await screen.findByRole('button', { name: 'Open in main chat' })); + + await waitFor(() => expect(screen.getByTestId('pathname').textContent).toContain('code-agent')); + }); + + it('starts a new compact chat from the popout header', async () => { + renderTopBarChat(); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Open Code Agent chat' })); + await user.click(await screen.findByRole('button', { name: /New/i })); + + expect(mocks.startNewChat).toHaveBeenCalledOnce(); + }); + + it('closes the compact chat when a chat link is clicked', async () => { + renderTopBarChat(); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Open Code Agent chat' })); + expect(await screen.findByTestId('compact-chat-thread')).toBeVisible(); + + await user.click(screen.getByRole('link', { name: 'Job details' })); + await waitFor(() => expect(screen.getByTestId('compact-chat-thread')).not.toBeVisible()); + }); + + it('shows a thinking indicator while the agent is running', () => { + mocks.chat.isRunning = true; + + renderTopBarChat(); + + expect(screen.getAllByTestId('code-agent-thinking-dot')).toHaveLength(3); + expect(screen.queryByTestId('code-agent-unread-indicator')).not.toBeInTheDocument(); + }); + + it('surfaces an unread badge after a response finishes while closed', () => { + const view = renderTopBarChat(); + + expect(screen.queryByTestId('code-agent-unread-indicator')).not.toBeInTheDocument(); + + mocks.chat.isRunning = true; + view.rerender(getTopBarChatElement()); + mocks.chat.isRunning = false; + view.rerender(getTopBarChatElement()); + + expect(screen.getByTestId('code-agent-unread-indicator')).toBeInTheDocument(); + }); + + it('shows the attention badge instead of the thinking dots while awaiting user input', () => { + mocks.chat.isRunning = true; + mocks.chat.inputRequest = { requestId: 'request-1', kind: 'dataset_file', input: {} }; + + renderTopBarChat(); + + expect(screen.getByTestId('code-agent-unread-indicator')).toBeInTheDocument(); + expect(screen.queryByTestId('code-agent-thinking-indicator')).not.toBeInTheDocument(); + }); +}); diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeTopBarChat.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeTopBarChat.tsx new file mode 100644 index 0000000000..e946b3ede4 --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeTopBarChat.tsx @@ -0,0 +1,207 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Button, Flex, Popover, Stack, Tooltip } from '@nvidia/foundations-react-core'; +import { CODING_AGENT_STUDIO_ENABLED } from '@studio/constants/environment'; +import { useWorkspaceFromPathIfExists } from '@studio/hooks/useWorkspaceFromPath'; +import { ClaudeCodeChatThread } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread'; +import { useClaudeCodeChatContext } from '@studio/routes/agents/ClaudeCodeChatRoute/context/useClaudeCodeChatContext'; +import { getClaudeCodeChatRouteForSession } from '@studio/routes/agents/ClaudeCodeChatRoute/util'; +import { getClaudeCodeChatRoute } from '@studio/routes/utils'; +import { Maximize2, Plus, Terminal, X } from 'lucide-react'; +import { type FC, type MouseEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useNavigate } from 'react-router-dom'; + +const OPEN_LABEL = 'Open Code Agent chat'; +const CLOSE_LABEL = 'Close Code Agent chat'; + +const TopBarChatIcon = () => ; + +/** + * The top-bar pop-out is a thin view of the shared chat runtime (owned by + * ClaudeCodeChatProvider). Because the runtime lives above the routes, opening + * the pop-out mid-run shows the live stream and thinking/awaiting-input state. + */ +const ClaudeCodeTopBarChatPopout: FC<{ workspace: string }> = ({ workspace }) => { + const navigate = useNavigate(); + const { chat, startNewChat } = useClaudeCodeChatContext(); + const { decisionRequest, inputRequest, isRunning, sessionId } = chat; + const [isOpen, setIsOpen] = useState(false); + const [hasUnreadResponse, setHasUnreadResponse] = useState(false); + const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0); + + // While the agent is blocked on a permission/input request the stream stays + // open (isRunning is still true), but it is waiting on the user rather than + // thinking — surface the attention badge instead of the loading dots. + const isAwaitingUserInput = !!decisionRequest || !!inputRequest; + + // Surface an unread badge when the agent finishes responding while the + // pop-out is closed, and clear it the moment the user opens the chat. + const wasRunningRef = useRef(false); + useEffect(() => { + if (isOpen) { + setHasUnreadResponse(false); + } else if (wasRunningRef.current && !isRunning) { + setHasUnreadResponse(true); + } + wasRunningRef.current = isRunning; + }, [isOpen, isRunning]); + + // The popover dismisses on Esc / outside click; just mirror that here. + const handleOpenChange = useCallback((open: boolean) => { + if (!open) setIsOpen(false); + }, []); + + // The controlled popover treats this trigger as "outside" its content, so a + // pointer-down would dismiss it right before the click re-opens it. Stop that + // dismissal while open and let the click own the toggle. + const handleTriggerPointerDown = useCallback( + (event: MouseEvent) => { + if (isOpen) event.stopPropagation(); + }, + [isOpen] + ); + + const handleTriggerClick = useCallback( + (event: MouseEvent) => { + event.preventDefault(); + if (isOpen) { + setIsOpen(false); + return; + } + setIsOpen(true); + setScrollToBottomSignal((signal) => signal + 1); + }, + [isOpen] + ); + + // Close the pop-out when the user follows an in-chat link to another route. + // This runs on the bubble phase (not capture) so the link's own react-router + // navigation happens first — closing the popover out from under the anchor + // mid-click breaks client-side navigation and triggers a full reload. + const handlePopoverLinkClick = useCallback((event: MouseEvent) => { + if (event.target instanceof Element && event.target.closest('a[href]')) { + setIsOpen(false); + } + }, []); + + // The full chat route shares this runtime, so it opens on the same live + // session (continuing any in-flight run). + const handleOpenFullChat = useCallback(() => { + setIsOpen(false); + navigate( + sessionId + ? getClaudeCodeChatRouteForSession(workspace, sessionId) + : getClaudeCodeChatRoute(workspace) + ); + }, [navigate, sessionId, workspace]); + + return ( + <> + {isOpen && + typeof document !== 'undefined' && + createPortal( +