From 67a1a860366359c3c3defe6ceba4b320a488bedc Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 26 Aug 2026 10:58:51 -0600 Subject: [PATCH 1/2] Make the sidebar resizable, scrollable, and icon-tabbed Closes #123. - Add a resize handle on the sidebar's left edge: drag with the pointer, or focus it and use arrow keys / Home / End (ARIA window splitter with aria-valuenow announcements). Width is clamped to 180px-60vw and persisted to localStorage. - Give every sidebar tab an icon. Labels show alongside icons when the bar has room and collapse to icon-only (with tooltips) when squished; the label text stays in the accessibility tree either way. - Fix mobile sidebar clipping: tab panels now cap their own height and scroll top-to-bottom instead of being cut off by the aside's max-height. - Import sidebar.css, which was never actually imported anywhere - all its rules were dead. Neutralize the rules that would have conflicted now that they're live (double panel padding, display:flex overriding the hidden attribute on the collapsed sidebar). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0175eEy1tMaz2woc7cqjwCGe --- src/App.tsx | 51 +++++++++++++--- src/components/sidebar.css | 65 +++++++++++++++++++- src/components/sidebar.tsx | 120 +++++++++++++++++++++++++++++++++++-- src/components/tabs.css | 38 ++++++++++++ src/components/tabs.tsx | 11 +++- 5 files changed, 271 insertions(+), 14 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1c8db332..d1e18fc8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,7 @@ import PreferencesDialog, { type PreferencesDialogRef, } from "./components/PreferencesDialog"; import LinkPickerDialog from "./components/LinkPickerDialog"; -import Sidebar, { type SidebarRef } from "./components/sidebar"; +import Sidebar, { clampSidebarWidth, type SidebarRef } from "./components/sidebar"; import Statusbar from "./components/statusbar"; import Toolbar from "./components/toolbar"; import WasmGuest from "./components/WasmGuest"; @@ -27,6 +27,11 @@ import { autoLogService, createAutoLogSessionDraft, } from "./logging/AutoLogService"; +import { + loadStoredValue, + saveStoredValue, + type LocalStorageSchema, +} from "./persistence"; import { usePreferences } from "./stores/preferencesStore"; import { useRoomStore } from "./stores/roomStore"; import { useConnectionStore } from "./stores/connectionStore"; @@ -36,6 +41,19 @@ import { ensurePushSubscription } from "./webpush"; const WINDOW_TITLE = "Mongoose Client"; const DOUBLE_PRESS_WINDOW_MS = 500; +// User-chosen sidebar width in px; absent until the user first resizes. +const sidebarWidthSchema: LocalStorageSchema = { + key: "sidebarWidth", + version: 1, + migrate: (data) => + typeof data === "number" && Number.isFinite(data) ? data : undefined, +}; + +function loadSidebarWidth(): number | null { + const stored = loadStoredValue(sidebarWidthSchema, null); + return stored === null ? null : clampSidebarWidth(stored); +} + function setWindowSubtitle(subtitle?: string) { document.title = subtitle ? `${WINDOW_TITLE} - ${subtitle}` : WINDOW_TITLE; } @@ -92,6 +110,18 @@ function App() { const [client, setClient] = useState(null); const [showSidebar, setShowSidebar] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth); + + // Persist the user-chosen sidebar width, debounced so drags don't + // write localStorage on every pointer move + useEffect(() => { + if (sidebarWidth === null) return; + const timer = window.setTimeout( + () => saveStoredValue(sidebarWidthSchema, sidebarWidth), + 250, + ); + return () => window.clearTimeout(timer); + }, [sidebarWidth]); const [hostState, setHostState] = useState({ roomId: null, guestCount: 0, @@ -110,8 +140,8 @@ function App() { const clientInitialized = useRef(false); const hapticsRuntimeRef = useRef(null); - const midiEnabled = usePreferences((state) => state.midi.enabled); - const hapticsEnabled = usePreferences((state) => state.haptics.enabled); + const midiEnabled = usePreferences((state) => state.midi.enabled); + const hapticsEnabled = usePreferences((state) => state.haptics.enabled); const connected = useConnectionStore((state) => state.connected); const sessionReady = useConnectionStore((state) => state.sessionReady); useFileTransferNotifications(client); @@ -335,7 +365,7 @@ function App() { }, [handleAppKeyDown]); useEffect(() => { - if (!midiEnabled) return; + if (!midiEnabled) return; let cancelled = false; import("./VirtualMidiService") @@ -356,7 +386,7 @@ function App() { return () => { cancelled = true; }; - }, [midiEnabled]); + }, [midiEnabled]); // Window subtitle tracks the current room from the room store. On disconnect // the client resets the store, which clears roomInfo and so clears the subtitle. @@ -373,8 +403,8 @@ function App() { }, [client, roomInfo]); useEffect(() => { - hapticsRuntimeRef.current?.setEnabled(hapticsEnabled); - }, [hapticsEnabled]); + hapticsRuntimeRef.current?.setEnabled(hapticsEnabled); + }, [hapticsEnabled]); const handleCommand = useCallback( (text: string) => { @@ -435,6 +465,11 @@ function App() { {client && (
setSidebarCollapsed(!sidebarCollapsed)} + width={sidebarWidth} + onWidthChange={setSidebarWidth} />