diff --git a/frontend/__tests__/sidebar.collapse.test.tsx b/frontend/__tests__/sidebar.collapse.test.tsx new file mode 100644 index 00000000..c052b487 --- /dev/null +++ b/frontend/__tests__/sidebar.collapse.test.tsx @@ -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; + +// Mock the Markdown component to avoid ES module issues +jest.mock('../components/Markdown', () => ({ + __esModule: true, + default: ({ text }: { text: string }) =>
{text}
, +})); + +// 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({ui}); +} + +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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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 {}; \ No newline at end of file diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 6d84c819..ca9be48a 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -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 { @@ -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; + } +} diff --git a/frontend/components/ChatSidebar.tsx b/frontend/components/ChatSidebar.tsx index 09e3185c..ad404738 100644 --- a/frontend/components/ChatSidebar.tsx +++ b/frontend/components/ChatSidebar.tsx @@ -1,4 +1,4 @@ -import { Trash2, Loader2, Plus, RefreshCw } from 'lucide-react'; +import { Trash2, Loader2, Plus, RefreshCw, ChevronLeft, ChevronRight } from 'lucide-react'; import type { ConversationMeta } from '../lib/chat'; interface ChatSidebarProps { @@ -6,11 +6,13 @@ interface ChatSidebarProps { nextCursor: string | null; loadingConversations: boolean; conversationId: string | null; + collapsed: boolean; onSelectConversation: (id: string) => void; onDeleteConversation: (id: string) => void; onLoadMore: () => void; onRefresh: () => void; onNewChat: () => void; + onToggleCollapse: () => void; } export function ChatSidebar({ @@ -18,85 +20,138 @@ export function ChatSidebar({ nextCursor, loadingConversations, conversationId, + collapsed, onSelectConversation, onDeleteConversation, onLoadMore, onRefresh, - onNewChat + onNewChat, + onToggleCollapse }: ChatSidebarProps) { return ( - ); } diff --git a/frontend/components/ChatV2.tsx b/frontend/components/ChatV2.tsx index 1ce8cab8..8724f0b3 100644 --- a/frontend/components/ChatV2.tsx +++ b/frontend/components/ChatV2.tsx @@ -26,6 +26,24 @@ export function ChatV2() { } catch (_) {} }, []); + // Keyboard shortcut for toggling sidebar (Ctrl/Cmd + \) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === '\\') { + e.preventDefault(); + actions.toggleSidebar(); + } + // Keyboard shortcut for toggling right sidebar (Ctrl/Cmd + Shift + \) + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === '\\') { + e.preventDefault(); + actions.toggleRightSidebar(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [actions]); + // Respond to URL changes (e.g., back/forward) to drive state useEffect(() => { if (!searchParams) return; @@ -122,11 +140,13 @@ export function ChatV2() { nextCursor={state.nextCursor} loadingConversations={state.loadingConversations} conversationId={state.conversationId} + collapsed={state.sidebarCollapsed} onSelectConversation={actions.selectConversation} onDeleteConversation={actions.deleteConversation} onLoadMore={actions.loadMoreConversations} onRefresh={actions.refreshConversations} onNewChat={actions.newChat} + onToggleCollapse={actions.toggleSidebar} /> )}
@@ -183,7 +203,7 @@ export function ChatV2() { onClose={() => setIsSettingsOpen(false)} />
- + ); } diff --git a/frontend/components/Markdown.tsx b/frontend/components/Markdown.tsx index 64ca350c..c8b836f3 100644 --- a/frontend/components/Markdown.tsx +++ b/frontend/components/Markdown.tsx @@ -3,6 +3,7 @@ import React from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeHighlight from "rehype-highlight"; +import { useTheme } from "../contexts/ThemeContext"; interface MarkdownProps { text: string; @@ -15,8 +16,14 @@ interface MarkdownProps { // - Secure by default (no raw HTML rendering) // - Accessible links opening in a new tab export const Markdown: React.FC = ({ text, className }) => { + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === 'dark'; + + // Note: Syntax highlighting theme is automatically handled by CSS + // based on the .dark class applied to the document root + return ( -
+
= ({ text, className }) => { {children} ), - code: function CodeRenderer(p) { - const { inline, className: cls, children } = p as any; - const hasLanguage = /\blanguage-/.test(cls || ""); - const isInline = inline ?? !hasLanguage; - const className = ["md-code", cls].filter(Boolean).join(" "); - - // Hooks must be called unconditionally + pre: function PreRenderer(p) { + const { children } = p as any; const preRef = React.useRef(null); const [copied, setCopied] = React.useState(false); @@ -65,43 +67,45 @@ export const Markdown: React.FC = ({ text, className }) => { } }; - if (isInline) { - return ( - - {children} - - ); - } - return ( -
-
- +
+                {/* copy button container (keeps button visually above content) */}
+                
+
+ +
-
-                  {children}
-                
-
+ {/* padded, scrollable code area */} +
+ {children} +
+ ); }, + code: function CodeRenderer({ className, children }: { className?: string; children?: React.ReactNode }) { + return {children}; + }, p: ({ children }) =>

{children}

, h1: ({ children }) =>

{children}

, h2: ({ children }) =>

{children}

, diff --git a/frontend/components/MessageList.tsx b/frontend/components/MessageList.tsx index 3009b55d..f76d67e0 100644 --- a/frontend/components/MessageList.tsx +++ b/frontend/components/MessageList.tsx @@ -105,7 +105,7 @@ export function MessageList({
)} -
+
{isEditing ? (
-
-
This prompt will be available to the model for the current session.
+ ); } diff --git a/frontend/hooks/useChatState.ts b/frontend/hooks/useChatState.ts index 2d624c4f..7c26ce37 100644 --- a/frontend/hooks/useChatState.ts +++ b/frontend/hooks/useChatState.ts @@ -31,6 +31,8 @@ export interface ChatState { nextCursor: string | null; historyEnabled: boolean; loadingConversations: boolean; + sidebarCollapsed: boolean; + rightSidebarCollapsed: boolean; // Message Editing editingMessageId: string | null; @@ -77,7 +79,11 @@ export type ChatAction = | { type: 'SAVE_EDIT_SUCCESS'; payload: { messageId: string; content: string; baseMessages: ChatMessage[] } } | { type: 'CLEAR_ERROR' } | { type: 'NEW_CHAT' } - | { type: 'SYNC_ASSISTANT'; payload: ChatMessage }; + | { type: 'SYNC_ASSISTANT'; payload: ChatMessage } + | { type: 'TOGGLE_SIDEBAR' } + | { type: 'SET_SIDEBAR_COLLAPSED'; payload: boolean } + | { type: 'TOGGLE_RIGHT_SIDEBAR' } + | { type: 'SET_RIGHT_SIDEBAR_COLLAPSED'; payload: boolean }; const initialState: ChatState = { status: 'idle', @@ -97,6 +103,8 @@ const initialState: ChatState = { nextCursor: null, historyEnabled: true, loadingConversations: false, + sidebarCollapsed: false, + rightSidebarCollapsed: false, editingMessageId: null, editingContent: '', error: null, @@ -407,6 +415,40 @@ function chatReducer(state: ChatState, action: ChatAction): ChatState { error: null, }; + case 'TOGGLE_SIDEBAR': + { + const newCollapsed = !state.sidebarCollapsed; + // Save to localStorage + try { + if (typeof window !== 'undefined') { + localStorage.setItem('sidebarCollapsed', String(newCollapsed)); + } + } catch (e) { + // ignore storage errors + } + return { ...state, sidebarCollapsed: newCollapsed }; + } + + case 'SET_SIDEBAR_COLLAPSED': + return { ...state, sidebarCollapsed: action.payload }; + + case 'TOGGLE_RIGHT_SIDEBAR': + { + const newCollapsed = !state.rightSidebarCollapsed; + // Save to localStorage + try { + if (typeof window !== 'undefined') { + localStorage.setItem('rightSidebarCollapsed', String(newCollapsed)); + } + } catch (e) { + // ignore storage errors + } + return { ...state, rightSidebarCollapsed: newCollapsed }; + } + + case 'SET_RIGHT_SIDEBAR_COLLAPSED': + return { ...state, rightSidebarCollapsed: action.payload }; + default: return state; } @@ -470,6 +512,20 @@ export function useChatState() { return () => clearTimeout(timer); }, [refreshConversations]); + // Load sidebar collapsed state from localStorage on mount + React.useEffect(() => { + try { + if (typeof window !== 'undefined') { + const collapsed = localStorage.getItem('sidebarCollapsed') === 'true'; + dispatch({ type: 'SET_SIDEBAR_COLLAPSED', payload: collapsed }); + const rightCollapsed = localStorage.getItem('rightSidebarCollapsed') === 'true'; + dispatch({ type: 'SET_RIGHT_SIDEBAR_COLLAPSED', payload: rightCollapsed }); + } + } catch (e) { + // ignore storage errors + } + }, []); + // Stream event handler const handleStreamEvent = useCallback((event: any) => { const assistantId = assistantMsgRef.current!.id; @@ -787,6 +843,14 @@ export function useChatState() { }, []), refreshConversations, + + toggleSidebar: useCallback(() => { + dispatch({ type: 'TOGGLE_SIDEBAR' }); + }, []), + + toggleRightSidebar: useCallback(() => { + dispatch({ type: 'TOGGLE_RIGHT_SIDEBAR' }); + }, []), }; return { state, actions };