Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="breadcrumbs" />,
Expand All @@ -19,6 +20,10 @@ vi.mock('@studio/routes/PageLayout/ThemeSwitch', () => ({
ThemeSwitch: () => <div data-testid="theme-switch" />,
}));

vi.mock('@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeTopBarChat', () => ({
ClaudeCodeTopBarChat: () => <div data-testid="code-agent-top-bar-chat" />,
}));

vi.mock('@studio/constants/environment', async (importOriginal) => {
const actual = await importOriginal<typeof import('@studio/constants/environment')>();
return {
Expand Down Expand Up @@ -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(
<MemoryRouter>
<MemoryRouter initialEntries={[initialPath]}>
<GlobalNav sideNav={() => <div data-testid="side-nav">Side Nav Content</div>} />
</MemoryRouter>
);
// 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', () => {
Expand Down Expand Up @@ -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();
});
});
});
11 changes: 10 additions & 1 deletion web/packages/studio/src/components/Layouts/GlobalNav/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
Expand All @@ -24,6 +26,12 @@ interface Props {
export const GlobalNav: FC<Props> = ({ 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;
Comment thread
htolentino-nvidia marked this conversation as resolved.

const toggleLabel = expanded ? 'Collapse sidebar' : 'Expand sidebar';
const ToggleSidebarButton = (
Expand Down Expand Up @@ -71,6 +79,7 @@ export const GlobalNav: FC<Props> = ({ sideNav }) => {
<WelcomeTour />
</Suspense>
)}
{shouldMountClaudeCodeTopBarChat && <ClaudeCodeTopBarChat />}
<ThemeSwitch />
<span data-tour="nav-user">
<UserPopover />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -59,6 +60,7 @@ const renderRoute = () => {

describe('DashboardLandingRoute', () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
mockFeatureFlags({
agentsEnabled: true,
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
},
Expand Down
27 changes: 23 additions & 4 deletions web/packages/studio/src/routes/PageLayout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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 (
<div
className={`min-h-screen relative grid size-full text-primary grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_1fr] ${gridAreas}`}
>
// 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 = (
<>
<GlobalNav sideNav={hideSideNav ? undefined : sideNav} />
<div className="bg-surface-sunken transition-colors relative h-full max-h-[calc(100vh-var(--nv-app-bar-height))] overflow-y-auto [grid-area:content]">
<WorkspaceGuard>
<Outlet />
</WorkspaceGuard>
</div>
</>
);

return (
<div
className={`min-h-screen relative grid size-full text-primary grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_1fr] ${gridAreas}`}
>
{CODING_AGENT_STUDIO_ENABLED && workspace ? (
<ClaudeCodeChatProvider key={workspace} workspace={workspace}>
{layout}
</ClaudeCodeChatProvider>
) : (
Comment thread
coderabbitai[bot] marked this conversation as resolved.
layout
)}
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('lucide-react')>()),
CircleCheck: ({ className, ref }: { className?: string; ref?: React.Ref<SVGSVGElement> }) => (
<svg ref={ref} data-testid="check-circle-icon" className={className} />
),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ClaudeCodeChatThreadProps> = ({
chat,
mode = 'full',
onReset,
scrollToBottomSignal,
}) => {
const workspace = useWorkspaceFromPath();
const chatViewportRef = useRef<HTMLDivElement>(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 (
<AssistantRuntimeProvider runtime={runtime}>
<AssistantChatThread
contentClassName={
mode === 'compact' ? 'w-full px-density-lg' : 'mx-auto w-full max-w-180 px-density-2xl'
}
composerContainerClassName={
mode === 'compact' ? 'w-full px-density-lg' : 'mx-auto w-full max-w-180 px-density-2xl'
}
viewportClassName={CHAT_VIEWPORT_SCROLLBAR_CLASS}
hideAssistantMessageActions
toolCallPartComponent={ClaudeCodeToolCallPart}
attributes={{
ThreadViewport: {
ref: chatViewportRef,
},
}}
placeholder="Ask Claude Code to work in this workspace"
onReset={handleChatReset}
showRunningIndicator={!decisionRequest && !inputRequest}
messageContentProps={{ markdownLinkComponent: ClaudeCodeStudioLink }}
emptyState={{
slotHeading: 'Start a Claude Code session',
slotSubheading: 'Ask Claude Code to work in this workspace.',
}}
composerOverride={
decisionRequest ? (
<AgentDecisionInput
request={decisionRequest}
choices={decisionChoices}
defaultChoiceId={decisionChoices[0]?.id}
status={decisionStatus}
onSubmit={resolveDecisionRequest}
onSkip={skipDecisionRequest}
/>
) : inputRequest ? (
<BlockingInputComposer
inputRequest={inputRequest}
inputStatus={inputStatus}
workspace={workspace}
onSubmit={handleInputSubmit}
onSkip={skipInputRequest}
/>
) : undefined
}
/>
</AssistantRuntimeProvider>
);
};
Loading