Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
Expand Up @@ -6,7 +6,7 @@ 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 { generatePath, MemoryRouter } from 'react-router-dom';
import { createMemoryRouter, generatePath, MemoryRouter, RouterProvider } from 'react-router-dom';

vi.mock('@studio/components/Breadcrumbs', () => ({
Breadcrumbs: () => <div data-testid="breadcrumbs" />,
Expand Down Expand Up @@ -89,6 +89,62 @@ describe('GlobalNav', () => {
expect(screen.getByText('NeMo Studio')).toBeInTheDocument();
});

it('starts collapsed on the Code Agent route when no preference is saved', async () => {
createMatchMediaMock(true);

await renderGlobalNav(
generatePath(ROUTES.workspace.claudeCodeChat, { workspace: 'test-workspace' })
);

expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeInTheDocument();
expect(screen.queryByText('NeMo Studio')).not.toBeInTheDocument();
});

it('respects a saved expanded preference on the Code Agent route', async () => {
localStorage.setItem(SIDE_NAV_OPEN_KEY, JSON.stringify('true'));
createMatchMediaMock(true);

await renderGlobalNav(
generatePath(ROUTES.workspace.claudeCodeChat, { workspace: 'test-workspace' })
);

expect(screen.getByRole('button', { name: 'Collapse sidebar' })).toBeInTheDocument();
expect(screen.getByText('NeMo Studio')).toBeInTheDocument();
});

it('uses the Code Agent default during in-app navigation', async () => {
createMatchMediaMock(true);
const { GlobalNav } = await import('@studio/components/Layouts/GlobalNav/index');
const router = createMemoryRouter(
[
{
path: '*',
element: (
<GlobalNav sideNav={() => <div data-testid="side-nav">Side Nav Content</div>} />
),
},
],
{ initialEntries: ['/workspaces/test-workspace/jobs'] }
);
render(<RouterProvider router={router} />);

expect(screen.getByRole('button', { name: 'Collapse sidebar' })).toBeInTheDocument();

await act(async () => {
await router.navigate(
generatePath(ROUTES.workspace.claudeCodeChat, { workspace: 'test-workspace' })
);
});

expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeInTheDocument();

await act(async () => {
await router.navigate('/workspaces/test-workspace/jobs');
});

expect(screen.getByRole('button', { name: 'Collapse sidebar' })).toBeInTheDocument();
});

it('auto-collapses sidebar when initial viewport is narrow', async () => {
createMatchMediaMock(false);
await renderGlobalNav();
Expand Down
42 changes: 33 additions & 9 deletions web/packages/studio/src/components/Layouts/GlobalNav/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,22 @@ interface Props {
sideNav?: (collapsed: boolean) => ReactNode;
}

export const GlobalNav: FC<Props> = ({ sideNav }) => {
const { expanded, toggle } = useSidebarState();
interface GlobalNavContentProps extends Props {
isDashboardRoute: boolean;
isClaudeCodeChatRoute: boolean;
}

const GlobalNavContent: FC<GlobalNavContentProps> = ({
sideNav,
isDashboardRoute,
isClaudeCodeChatRoute,
}) => {
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 { expanded, toggle } = useSidebarState(!isClaudeCodeChatRoute);
const shouldMountClaudeCodeTopBarChat = !isDashboardRoute && !isClaudeCodeChatRoute;
const sidebarBackground = isClaudeCodeChatRoute
? 'bg-surface-sunken dark:bg-surface-base'
: 'bg-surface-navigation';

const toggleLabel = expanded ? 'Collapse sidebar' : 'Expand sidebar';
const ToggleSidebarButton = (
Expand All @@ -51,7 +58,7 @@ export const GlobalNav: FC<Props> = ({ sideNav }) => {
return (
<>
<Flex
className={`[grid-area:logobar] bg-surface-navigation transition-colors border-r border-b border-base ${expanded ? 'pl-4 pr-2' : ''}`}
className={`[grid-area:logobar] ${sidebarBackground} transition-colors border-r border-b border-base ${expanded ? 'pl-4 pr-2' : ''}`}
align="center"
gap="density-md"
justify={expanded ? 'between' : 'center'}
Expand Down Expand Up @@ -89,7 +96,7 @@ export const GlobalNav: FC<Props> = ({ sideNav }) => {
/>
{sideNav && (
<div
className="h-full max-h-[calc(100vh-var(--nv-app-bar-height))] overflow-y-auto [grid-area:sidebar]"
className={`h-full max-h-[calc(100vh-var(--nv-app-bar-height))] overflow-y-auto [grid-area:sidebar] ${isClaudeCodeChatRoute ? `${sidebarBackground} [&_.nv-vertical-nav-root]:bg-transparent!` : ''}`}
Comment thread
dmariali marked this conversation as resolved.
data-tour="sidebar"
>
{sideNav(!expanded)}
Expand All @@ -98,3 +105,20 @@ export const GlobalNav: FC<Props> = ({ sideNav }) => {
</>
);
};

export const GlobalNav: FC<Props> = ({ sideNav }) => {
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;

return (
<GlobalNavContent
key={isClaudeCodeChatRoute ? 'code-agent' : 'default'}
sideNav={sideNav}
isDashboardRoute={isDashboardRoute}
isClaudeCodeChatRoute={isClaudeCodeChatRoute}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ describe('ClaudeCodeHistoryPanel', () => {
]);
});

it('renders history and skills segmented controls', () => {
it('starts history and skills collapsed and expands them independently', async () => {
const user = userEvent.setup();
render(
<ClaudeCodeHistoryPanel
activeSessionId="session-1"
Expand All @@ -44,9 +45,34 @@ describe('ClaudeCodeHistoryPanel', () => {
/>
);

expect(screen.getByRole('radio', { name: 'History' })).toBeChecked();
expect(screen.getByRole('radio', { name: 'Skills' })).not.toBeChecked();
const historyButton = screen.getByRole('button', { name: 'Expand All Chats' });
const skillsButton = screen.getByRole('button', { name: 'Expand Skills' });
expect(historyButton).toHaveAttribute('aria-expanded', 'false');
expect(skillsButton).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('button', { name: 'New chat' })).not.toBeInTheDocument();

await user.click(historyButton);

expect(screen.getByRole('button', { name: 'Collapse All Chats' })).toHaveAttribute(
'aria-expanded',
'true'
);
expect(skillsButton).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button', { name: 'New chat' })).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'All Chats' })).toHaveClass('min-h-0', 'flex-1');
expect(screen.getByRole('region', { name: 'Skills' })).toHaveClass('shrink-0');

await user.click(skillsButton);

expect(screen.getByRole('button', { name: 'Expand All Chats' })).toHaveAttribute(
'aria-expanded',
'false'
);
expect(screen.getByRole('button', { name: 'Collapse Skills' })).toHaveAttribute(
'aria-expanded',
'true'
);
expect(screen.queryByRole('button', { name: 'New chat' })).not.toBeInTheDocument();
});

it('renders history sessions and keeps selection working', async () => {
Expand All @@ -72,14 +98,16 @@ describe('ClaudeCodeHistoryPanel', () => {
},
]);

render(
const { unmount } = render(
<ClaudeCodeHistoryPanel
activeSessionId="session-1"
onNewChat={onNewChat}
onSelectSession={onSelectSession}
/>
);

await user.click(screen.getByRole('button', { name: 'Expand All Chats' }));

expect(await screen.findByText('Review the latest agent work')).toBeInTheDocument();
expect(screen.getByText('Bash')).toBeInTheDocument();

Expand All @@ -88,9 +116,24 @@ describe('ClaudeCodeHistoryPanel', () => {

await user.click(screen.getByRole('button', { name: /Review the latest agent work/ }));
expect(onSelectSession).toHaveBeenCalledWith('session-1');

unmount();
render(
<ClaudeCodeHistoryPanel
activeSessionId="session-1"
onNewChat={onNewChat}
onSelectSession={onSelectSession}
/>
);

expect(screen.getByRole('button', { name: 'Collapse All Chats' })).toHaveAttribute(
'aria-expanded',
'true'
);
});

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([
{
Expand Down Expand Up @@ -120,6 +163,8 @@ describe('ClaudeCodeHistoryPanel', () => {
/>
);

await user.click(screen.getByRole('button', { name: 'Expand All Chats' }));

const sessionButton = await screen.findByRole('button', {
name: 'Create Spam Detector Agent now',
});
Expand All @@ -134,8 +179,8 @@ describe('ClaudeCodeHistoryPanel', () => {
activeSessionId="session-1"
artifacts={{
workspace: 'default',
selections: [],
files: [],
selections: [{ label: 'Environment', value: 'production' }],
files: [{ action: 'Wrote', path: 'agents/beach-finder.yml' }],
links: [],
jobs: [
{
Expand All @@ -144,15 +189,25 @@ describe('ClaudeCodeHistoryPanel', () => {
source: 'evaluator',
},
],
tools: [],
tools: ['Bash'],
}}
onNewChat={vi.fn()}
onSelectSession={vi.fn()}
/>
);

expect(screen.getByText('Jobs')).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Chat artifacts' })).toHaveClass(
'overflow-hidden',
'rounded',
'border',
'shrink-0',
'bg-surface-base',
'dark:bg-surface-raised'
);
expect(screen.queryByText('Workspace')).not.toBeInTheDocument();
expect(screen.getByText('beach-finder.yml')).toBeInTheDocument();
expect(screen.getAllByRole('separator')).toHaveLength(3);
expect(screen.getByRole('link', { name: /agent-eval-1/ })).toHaveAttribute(
'href',
'/workspaces/default/agents/evaluations/agent-eval-1'
Expand Down Expand Up @@ -180,7 +235,29 @@ describe('ClaudeCodeHistoryPanel', () => {
expect(screen.getByText('No artifacts yet')).toBeInTheDocument();
});

it('lists Claude Code skills in the skills tab', async () => {
it('omits empty artifact sections and their dividers', () => {
render(
<ClaudeCodeHistoryPanel
activeSessionId="session-1"
artifacts={{
selections: [{ label: 'Environment', value: ' ' }],
files: [],
links: [],
jobs: [{ name: 'agent-eval-1' }],
tools: ['Bash'],
}}
onNewChat={vi.fn()}
onSelectSession={vi.fn()}
/>
);

expect(screen.queryByText('Selections')).not.toBeInTheDocument();
expect(screen.getByText('Jobs')).toBeInTheDocument();
expect(screen.getByText('Tools')).toBeInTheDocument();
expect(screen.getAllByRole('separator')).toHaveLength(1);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('lists Claude Code skills in the expanded skills block', async () => {
const user = userEvent.setup();
render(
<ClaudeCodeHistoryPanel
Expand All @@ -190,7 +267,7 @@ describe('ClaudeCodeHistoryPanel', () => {
/>
);

await user.click(screen.getByRole('radio', { name: 'Skills' }));
await user.click(screen.getByRole('button', { name: 'Expand Skills' }));

expect(await screen.findByText('Inference')).toBeInTheDocument();
expect(screen.getByText('Use NeMo Platform inference.')).toBeInTheDocument();
Expand Down
Loading
Loading