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
228 changes: 228 additions & 0 deletions frontend/__tests__/sidebar.collapse.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ChatV2 as Chat } from '../components/ChatV2';
import { ThemeProvider } from '../contexts/ThemeContext';
import * as chatLib from '../lib/chat';

// Mock the chat library functions
jest.mock('../lib/chat');
const mockedChatLib = chatLib as jest.Mocked<typeof chatLib>;

// Mock the Markdown component to avoid ES module issues
jest.mock('../components/Markdown', () => ({
__esModule: true,
default: ({ text }: { text: string }) => <div data-testid="markdown">{text}</div>,
}));

// Mock clipboard API
Object.assign(navigator, {
clipboard: {
writeText: jest.fn(),
},
});

// Mock crypto.randomUUID
Object.defineProperty(global, 'crypto', {
value: {
randomUUID: jest.fn(() => 'mock-uuid-' + Math.random()),
},
});

// Mock localStorage
const mockLocalStorage = {
getItem: jest.fn(),
setItem: jest.fn(),
};
Object.defineProperty(window, 'localStorage', {
value: mockLocalStorage,
});

function renderWithProviders(ui: React.ReactElement) {
return render(<ThemeProvider>{ui}</ThemeProvider>);
}

beforeEach(() => {
jest.clearAllMocks();

// Setup chat functionality
mockedChatLib.listConversationsApi.mockResolvedValue({
items: [
{ id: 'conv-1', title: 'Test Conversation', model: 'gpt-4o', created_at: '2023-01-01' },
],
next_cursor: null,
});
mockedChatLib.sendChat.mockResolvedValue({
content: 'Mock response',
responseId: 'mock-response-id'
});
mockedChatLib.getToolSpecs.mockResolvedValue({ tools: [], available_tools: [] });
mockedChatLib.getConversationApi.mockResolvedValue({
id: 'mock-conv-id',
title: 'Mock Conversation',
model: 'test-model',
created_at: new Date().toISOString(),
messages: [],
next_after_seq: null,
});

// Mock localStorage to return false (expanded by default)
mockLocalStorage.getItem.mockReturnValue(null);
});

describe('Sidebar Collapse Functionality', () => {
// Provide a minimal matchMedia mock for JSDOM used in tests
beforeAll(() => {
if (typeof window.matchMedia !== 'function') {
// @ts-ignore
window.matchMedia = (query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
});
}
});

test('sidebar is expanded by default', async () => {
renderWithProviders(<Chat />);

await waitFor(() => {
expect(screen.getByText('Chat History')).toBeInTheDocument();
expect(screen.getByText('Test Conversation')).toBeInTheDocument();
});
});

test('sidebar can be collapsed and expanded', async () => {
const user = userEvent.setup();
renderWithProviders(<Chat />);

await waitFor(() => {
expect(screen.getByText('Chat History')).toBeInTheDocument();
});

// Find and click the collapse button
const collapseButton = screen.getByTitle('Collapse sidebar');
expect(collapseButton).toBeInTheDocument();

await user.click(collapseButton);

// After collapsing, the "Chat History" text should not be visible
await waitFor(() => {
expect(screen.queryByText('Chat History')).not.toBeInTheDocument();
});

// Find and click the expand button
const expandButton = screen.getByTitle('Expand sidebar');
expect(expandButton).toBeInTheDocument();

await user.click(expandButton);

// After expanding, the "Chat History" text should be visible again
await waitFor(() => {
expect(screen.getByText('Chat History')).toBeInTheDocument();
});
});

test('sidebar state is saved to localStorage', async () => {
const user = userEvent.setup();
renderWithProviders(<Chat />);

await waitFor(() => {
expect(screen.getByText('Chat History')).toBeInTheDocument();
});

// Click collapse button
const collapseButton = screen.getByTitle('Collapse sidebar');
await user.click(collapseButton);

// Verify localStorage.setItem was called with 'true'
expect(mockLocalStorage.setItem).toHaveBeenCalledWith('sidebarCollapsed', 'true');

// Click expand button
const expandButton = screen.getByTitle('Expand sidebar');
await user.click(expandButton);

// Verify localStorage.setItem was called with 'false'
expect(mockLocalStorage.setItem).toHaveBeenCalledWith('sidebarCollapsed', 'false');
});

test('sidebar loads collapsed state from localStorage', async () => {
// Mock localStorage to return 'true' (collapsed)
mockLocalStorage.getItem.mockReturnValue('true');

renderWithProviders(<Chat />);

await waitFor(() => {
// Should not show "Chat History" text when collapsed
expect(screen.queryByText('Chat History')).not.toBeInTheDocument();
// Should show expand button
expect(screen.getByTitle('Expand sidebar')).toBeInTheDocument();
});
});

test('keyboard shortcut Ctrl+\\ toggles sidebar', async () => {
const user = userEvent.setup();
renderWithProviders(<Chat />);

await waitFor(() => {
expect(screen.getByText('Chat History')).toBeInTheDocument();
});

// Press Ctrl+\ to collapse
await user.keyboard('{Control>}\\{/Control}');

await waitFor(() => {
expect(screen.queryByText('Chat History')).not.toBeInTheDocument();
});

// Press Ctrl+\ again to expand
await user.keyboard('{Control>}\\{/Control}');

await waitFor(() => {
expect(screen.getByText('Chat History')).toBeInTheDocument();
});
});

test('collapsed sidebar shows minimal UI with new chat and refresh buttons', async () => {
const user = userEvent.setup();
renderWithProviders(<Chat />);

await waitFor(() => {
expect(screen.getByText('Chat History')).toBeInTheDocument();
});

// Collapse the sidebar
const collapseButton = screen.getByTitle('Collapse sidebar');
await user.click(collapseButton);

await waitFor(() => {
// Should show minimal buttons
expect(screen.getByTitle('New Chat')).toBeInTheDocument();
expect(screen.getByTitle('Refresh conversations')).toBeInTheDocument();
// Should not show full "Chat History" header
expect(screen.queryByText('Chat History')).not.toBeInTheDocument();
});
});

test('collapsed sidebar shows conversation count', async () => {
const user = userEvent.setup();
renderWithProviders(<Chat />);

await waitFor(() => {
expect(screen.getByText('Chat History')).toBeInTheDocument();
});

// Collapse the sidebar
const collapseButton = screen.getByTitle('Collapse sidebar');
await user.click(collapseButton);

await waitFor(() => {
// Should show conversation count (1 conversation from our mock)
expect(screen.getByTitle('1 conversation')).toBeInTheDocument();
});
});
});

export {};
94 changes: 92 additions & 2 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
@@ -1,7 +1,83 @@
@import "tailwindcss";
/* Syntax highlighting themes (light and dark) - Using modern atom-one themes */
/* Syntax highlighting themes - Conditionally applied based on theme */
/* Light theme (default) */
@import "highlight.js/styles/atom-one-light.css";
@import "highlight.js/styles/atom-one-dark.css" (prefers-color-scheme: dark);

/* Override with dark theme when .dark class is present on root */
.dark .hljs {
color: #abb2bf;
background: transparent !important;
}

.dark .hljs-comment,
.dark .hljs-quote {
color: #5c6370;
font-style: italic;
}

.dark .hljs-doctag,
.dark .hljs-keyword,
.dark .hljs-formula {
color: #c678dd;
}

.dark .hljs-section,
.dark .hljs-name,
.dark .hljs-selector-tag,
.dark .hljs-deletion,
.dark .hljs-subst {
color: #e06c75;
}

.dark .hljs-literal {
color: #56b6c2;
}

.dark .hljs-string,
.dark .hljs-regexp,
.dark .hljs-addition,
.dark .hljs-attribute,
.dark .hljs-meta .hljs-string {
color: #98c379;
}

.dark .hljs-attr,
.dark .hljs-variable,
.dark .hljs-template-variable,
.dark .hljs-type,
.dark .hljs-selector-class,
.dark .hljs-selector-attr,
.dark .hljs-selector-pseudo,
.dark .hljs-number {
color: #d19a66;
}

.dark .hljs-symbol,
.dark .hljs-bullet,
.dark .hljs-link,
.dark .hljs-meta,
.dark .hljs-selector-id,
.dark .hljs-title {
color: #61dafb;
}

.dark .hljs-built_in,
.dark .hljs-title.class_,
.dark .hljs-class .hljs-title {
color: #e6c07b;
}

.dark .hljs-emphasis {
font-style: italic;
}

.dark .hljs-strong {
font-weight: bold;
}

.dark .hljs-link {
text-decoration: underline;
}

/* Define CSS custom properties for theme colors */
:root {
Expand Down Expand Up @@ -163,3 +239,17 @@ del {
.md-h2 { font-size: 1.25rem; }
.md-h3 { font-size: 1.125rem; }
}

/* Pulse animation for active conversation indicator */
.pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}

@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
Loading
Loading