From 03112f715269c2ba39d5048ae7ddfc88efa57e26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 20 Apr 2026 18:13:49 +0800 Subject: [PATCH] Make desktop sidebar collapse recoverable without stealing focus The desktop shell now owns sidebar collapse state, the collapsed rail keeps a visible recovery affordance, and keyboard search expands the sidebar before focusing. The UI work also smooths the width transition so the main chat area can take over without the previous abrupt cutover. Constraint: The desktop app must keep a recovery path visible in narrow layouts on macOS and Windows Constraint: Existing sidebar behavior needed regression coverage before further UI work Rejected: Fully hide the sidebar at 0 width | recovery affordance became too fragile for this iteration Rejected: Keep boxed restore controls in the collapsed rail | visually heavy and easy to misplace Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep the collapsed rail width and the inner sidebar panel width in sync or expand controls will be clipped again Tested: cd desktop && bun run lint Tested: cd desktop && bun run test Sidebar.test.tsx Not-tested: Full desktop manual pass across every app screen after the final icon/layout tweaks --- desktop/src/components/layout/AppShell.tsx | 17 +- .../src/components/layout/Sidebar.test.tsx | 24 ++ desktop/src/components/layout/Sidebar.tsx | 353 +++++++++++------- desktop/src/hooks/useKeyboardShortcuts.ts | 11 +- desktop/src/i18n/locales/en.ts | 2 + desktop/src/i18n/locales/zh.ts | 2 + desktop/src/theme/globals.css | 148 ++++++++ 7 files changed, 416 insertions(+), 141 deletions(-) diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index 37e3e10e..44245885 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -14,6 +14,7 @@ import { useTranslation } from '../../i18n' export function AppShell() { const fetchSettings = useSettingsStore((s) => s.fetchAll) + const sidebarOpen = useUIStore((s) => s.sidebarOpen) const [ready, setReady] = useState(false) const [startupError, setStartupError] = useState(null) const t = useTranslation() @@ -94,9 +95,19 @@ export function AppShell() { } return ( -
- -
+
+
+ +
+
diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index a44f04ae..48bd620a 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -25,6 +25,8 @@ vi.mock('../../i18n', () => ({ 'sidebar.timeGroup.last30days': 'Last 30 Days', 'sidebar.timeGroup.older': 'Older', 'sidebar.missingDir': 'Missing', + 'sidebar.collapse': 'Collapse sidebar', + 'sidebar.expand': 'Expand sidebar', } return translations[key] ?? key @@ -70,6 +72,7 @@ describe('Sidebar', () => { disconnectSession, } as Partial>) useUIStore.setState({ + sidebarOpen: true, addToast, } as Partial>) }) @@ -155,4 +158,25 @@ describe('Sidebar', () => { expect(useTabStore.getState().tabs).toEqual([]) expect(useTabStore.getState().activeTabId).toBeNull() }) + + it('collapses into an icon rail and expands back', async () => { + render() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' })) + }) + + expect(useUIStore.getState().sidebarOpen).toBe(false) + expect(screen.queryByPlaceholderText('Search sessions')).not.toBeInTheDocument() + expect(screen.getByRole('complementary')).toHaveAttribute('data-state', 'closed') + expect(screen.getByTestId('sidebar-expand-button')).toHaveClass('sidebar-toggle-button--collapsed') + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Expand sidebar' })) + }) + + expect(useUIStore.getState().sidebarOpen).toBe(true) + expect(screen.getByPlaceholderText('Search sessions')).toBeInTheDocument() + expect(screen.getByRole('complementary')).toHaveAttribute('data-state', 'open') + }) }) diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 1eeef738..3599f2b2 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -22,6 +22,8 @@ export function Sidebar() { const deleteSession = useSessionStore((s) => s.deleteSession) const renameSession = useSessionStore((s) => s.renameSession) const addToast = useUIStore((s) => s.addToast) + const sidebarOpen = useUIStore((s) => s.sidebarOpen) + const toggleSidebar = useUIStore((s) => s.toggleSidebar) const activeTabId = useTabStore((s) => s.activeTabId) const closeTab = useTabStore((s) => s.closeTab) const disconnectSession = useChatStore((s) => s.disconnectSession) @@ -34,7 +36,11 @@ export function Sidebar() { fetchSessions() }, [fetchSessions]) - // Close context menu on click outside + useEffect(() => { + if (!contextMenu || sidebarOpen) return + setContextMenu(null) + }, [contextMenu, sidebarOpen]) + useEffect(() => { if (!contextMenu) return const close = () => setContextMenu(null) @@ -42,7 +48,6 @@ export function Sidebar() { return () => document.removeEventListener('click', close) }, [contextMenu]) - // Filter by selected projects, then by search query const filteredSessions = useMemo(() => { let result = sessions if (selectedProjects.length > 0) { @@ -55,7 +60,6 @@ export function Sidebar() { return result }, [sessions, selectedProjects, searchQuery]) - // Group by time const timeGroups = useMemo(() => groupByTime(filteredSessions), [filteredSessions]) const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => { @@ -103,7 +107,7 @@ export function Sidebar() { const t = useTranslation() - const TIME_GROUP_LABELS: Record = { + const timeGroupLabels: Record = { today: t('sidebar.timeGroup.today'), yesterday: t('sidebar.timeGroup.yesterday'), last7days: t('sidebar.timeGroup.last7days'), @@ -112,32 +116,56 @@ export function Sidebar() { } return ( -