From 8eba29bdd4d5234e3c99ae136d98693ee67c7203 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, 27 Jul 2026 05:05:04 +0800 Subject: [PATCH] feat(desktop): put the sidebar width under the user's control and trim the nav chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things about the left column, all reported from the same screenshot. The brand mark beside the wordmark was clutter. Expanded, the sidebar already says "Claude Code Haha" in the headline face, and the 32px seal next to it repeats what the words carry. Removing it outright emptied the header on the 72px rail, though — the copy there is width-clamped to zero by `.sidebar-copy--hidden`, so nothing was left to identify the app. The mark now renders only when collapsed, at `sm`: two C's and the seal bar, without the cursor arrow that reads as a stray orange wedge at that size. BrandSeal already sheds parts as it shrinks, so this is the size ladder doing its job rather than a new variant. The settings rail was 260px for a column whose longest label is two words. It is now 220px. 200px was the first choice and it was wrong: measured in a real browser against the live stylesheet, the Japanese "コンピューター操作" needs 122px of text box and 200px leaves 115px, so it truncated. The threshold sits at 210px; 220px keeps ten pixels of headroom over the worst locale. The sidebar itself is now resizable. An 8px handle on its right edge drives the width between 240 and 480px, persisted to localStorage, with a double click back to 300 and arrow-key steps for the keyboard. Dragging left past 240 pins there until the pointer crosses 180, which collapses to the rail; coming back out past 200 re-opens it. The 20px gap between those two thresholds is hysteresis — with one boundary the sidebar flickers open and closed on any tremor of the hand. A drag that ends in a collapse deliberately does not commit its width, so re-opening from the toggle restores the size the user chose rather than whatever value the pointer happened to sweep through. The live width travels as a CSS variable written imperatively onto the shell, never through React state. Sidebar re-renders during a streaming turn would otherwise land between drag frames and fight the pointer, which is the failure that made the pet animation stutter. The store is written once, when the drag settles. That variable exposed a bug the unit tests could not see. `#sidebar-shell` sits behind AppShell's startup gate, so it mounts a render later than the hook. With a plain object ref, `shellRef.current` is still null on the single pass the width effect ever runs, and because neither dependency changes afterwards the effect never fires again — the remembered width never reaches the DOM and every launch silently falls back to the stylesheet's 300px. Only visible by loading the real app: the harness in a test mounts the shell and the hook together. Fixed with a callback ref, and `useSidebarResize.test.tsx` now models the gate; that case was confirmed to fail against the object-ref version. Verified in a browser against the dev server: drag 300→420 persists as "420", crossing 150 settles the shell at the 72px rail while localStorage still holds the chosen width, and the truncation thresholds above were measured by substituting each locale's longest label into a live tab. --- .../src/__tests__/generalSettings.test.tsx | 2 +- desktop/src/components/layout/AppShell.tsx | 16 ++ .../src/components/layout/Sidebar.test.tsx | 19 ++ desktop/src/components/layout/Sidebar.tsx | 7 +- desktop/src/hooks/useSidebarResize.test.tsx | 229 ++++++++++++++++++ desktop/src/hooks/useSidebarResize.ts | 146 +++++++++++ desktop/src/i18n/locales/en.ts | 1 + desktop/src/i18n/locales/jp.ts | 1 + desktop/src/i18n/locales/kr.ts | 1 + desktop/src/i18n/locales/zh-TW.ts | 1 + desktop/src/i18n/locales/zh.ts | 1 + desktop/src/pages/Settings.tsx | 5 +- desktop/src/stores/uiStore.ts | 26 ++ desktop/src/theme/globals.css | 53 ++++ 14 files changed, 505 insertions(+), 3 deletions(-) create mode 100644 desktop/src/hooks/useSidebarResize.test.tsx create mode 100644 desktop/src/hooks/useSidebarResize.ts diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index ee24a0a8..78fa66b8 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -431,7 +431,7 @@ describe('Settings > General tab', () => { expect(activeItem).toHaveAttribute('aria-current', 'page') const rail = activeItem.parentElement?.parentElement - expect(rail?.className).toContain('w-[260px]') + expect(rail?.className).toContain('w-[220px]') }) it('marks the pure white appearance theme as selected', () => { diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index 88be340e..ecb9a673 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -9,6 +9,7 @@ import { useSettingsStore } from '../../stores/settingsStore' import { useUIStore, type SettingsTab } from '../../stores/uiStore' import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts' import { useElectronWindowDragRegions } from '../../hooks/useElectronWindowDragRegions' +import { useSidebarResize } from '../../hooks/useSidebarResize' import { H5ConnectionRequiredError, initializeDesktopServerUrl, @@ -57,7 +58,9 @@ export function AppShell() { ? sessions.find((session) => session.id === activeTabId) ?? null : null const wasMobileShellRef = useRef(false) + const sidebarWidth = useUIStore((s) => s.sidebarWidth) const effectiveSidebarOpen = isMobileShell ? mobileSidebarOpen : sidebarOpen + const sidebarResize = useSidebarResize(!isMobileShell) const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) const isActiveChatTab = isChatTab(activeTab) const mobileSessionTitle = activeSession?.title || activeTab?.title || t('session.untitled') @@ -292,6 +295,7 @@ export function AppShell() { ) : null}