From 0b49f65eea2dcab20250c5abd27ae16d6eb54845 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Sat, 20 Sep 2025 12:08:30 +0700 Subject: [PATCH 1/6] collapsible sidebar --- frontend/__tests__/sidebar.collapse.test.tsx | 228 +++++++++++++++++++ frontend/app/globals.css | 14 ++ frontend/components/ChatSidebar.tsx | 191 ++++++++++------ frontend/components/ChatV2.tsx | 15 ++ frontend/hooks/useChatState.ts | 39 +++- 5 files changed, 418 insertions(+), 69 deletions(-) create mode 100644 frontend/__tests__/sidebar.collapse.test.tsx 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..85f34517 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -163,3 +163,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..e3579160 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..c6aab43e 100644 --- a/frontend/components/ChatV2.tsx +++ b/frontend/components/ChatV2.tsx @@ -26,6 +26,19 @@ 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(); + } + }; + + 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 +135,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} /> )}
diff --git a/frontend/hooks/useChatState.ts b/frontend/hooks/useChatState.ts index 2d624c4f..7714f82a 100644 --- a/frontend/hooks/useChatState.ts +++ b/frontend/hooks/useChatState.ts @@ -31,6 +31,7 @@ export interface ChatState { nextCursor: string | null; historyEnabled: boolean; loadingConversations: boolean; + sidebarCollapsed: boolean; // Message Editing editingMessageId: string | null; @@ -77,7 +78,9 @@ 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 }; const initialState: ChatState = { status: 'idle', @@ -97,6 +100,7 @@ const initialState: ChatState = { nextCursor: null, historyEnabled: true, loadingConversations: false, + sidebarCollapsed: false, editingMessageId: null, editingContent: '', error: null, @@ -407,6 +411,23 @@ 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 }; + default: return state; } @@ -470,6 +491,18 @@ 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 }); + } + } catch (e) { + // ignore storage errors + } + }, []); + // Stream event handler const handleStreamEvent = useCallback((event: any) => { const assistantId = assistantMsgRef.current!.id; @@ -787,6 +820,10 @@ export function useChatState() { }, []), refreshConversations, + + toggleSidebar: useCallback(() => { + dispatch({ type: 'TOGGLE_SIDEBAR' }); + }, []), }; return { state, actions }; From 38e5e0dc7a811f708136b805a385048a5ee49127 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Sat, 20 Sep 2025 12:23:16 +0700 Subject: [PATCH 2/6] right sidebar collapsible --- frontend/components/ChatV2.tsx | 7 +++- frontend/components/RightSidebar.tsx | 52 ++++++++++++++++++++-------- frontend/hooks/useChatState.ts | 29 +++++++++++++++- 3 files changed, 72 insertions(+), 16 deletions(-) diff --git a/frontend/components/ChatV2.tsx b/frontend/components/ChatV2.tsx index c6aab43e..8724f0b3 100644 --- a/frontend/components/ChatV2.tsx +++ b/frontend/components/ChatV2.tsx @@ -33,6 +33,11 @@ export function ChatV2() { 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); @@ -198,7 +203,7 @@ export function ChatV2() { onClose={() => setIsSettingsOpen(false)} />
- + ); } diff --git a/frontend/components/RightSidebar.tsx b/frontend/components/RightSidebar.tsx index 4780ab50..179fec79 100644 --- a/frontend/components/RightSidebar.tsx +++ b/frontend/components/RightSidebar.tsx @@ -1,13 +1,16 @@ import React, { useCallback, useEffect } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; interface RightSidebarProps { systemPrompt: string; onSystemPromptChange: (v: string) => void; + collapsed?: boolean; + onToggleCollapse?: () => void; } const STORAGE_KEY = 'systemPrompt'; -export function RightSidebar({ systemPrompt, onSystemPromptChange }: RightSidebarProps) { +export function RightSidebar({ systemPrompt, onSystemPromptChange, collapsed = false, onToggleCollapse }: RightSidebarProps) { // Load saved prompt from localStorage on mount if the prop is empty useEffect(() => { if (typeof window === 'undefined') return; @@ -38,19 +41,40 @@ export function RightSidebar({ systemPrompt, onSystemPromptChange }: RightSideba ); return ( -