From d56c5356b76cbeccb952c15cead63290dd1000ab Mon Sep 17 00:00:00 2001 From: sak0a Date: Sat, 27 Jun 2026 22:31:37 +0200 Subject: [PATCH] feat(sidebar): main sidebar toggle keybinding (Mod+B) Ports upstream t3code #3497. ryco already has a collapsible main sidebar (shadcn SidebarProvider with offcanvas collapse + SidebarRail), so this adds the missing pieces: a keybinding, persistence, and a desktop reopen affordance when collapsed. - contracts: register sidebar.toggle command; shared: default it to Mod+B (when !terminalFocus, distinct from existing bindings). Flows through the server keybinding seed and the settings keybindings UI. - AppSidebarLayout hosts a global handler inside SidebarProvider so the toggle works on every route (chat, settings, no-active-thread), not just the chat view. It resolves the configured command and ignores the shortcut while typing (so Mod+B can still bold in the composer). - Persist collapsed state: read the sidebar_state cookie the provider already writes to seed defaultOpen on reload. - Expose data-state on the sidebar wrapper and reveal the existing header SidebarTriggers on desktop when collapsed, so there is a reopen control without the sidebar rail (which is offscreen when collapsed). - keybindingCategories: add a Sidebar category + 'Toggle sidebar' label. - Tests: resolve/label Mod+B -> sidebar.toggle (web), fixture updated. Adapted to ryco's keybinding registry and shadcn sidebar; did not port upstream's titlebar/traffic-light inset polish (ryco's layout differs). --- apps/web/src/components/AppSidebarLayout.tsx | 44 ++++++++++++++++++- .../src/components/NoActiveThreadState.tsx | 2 +- apps/web/src/components/chat/ChatHeader.tsx | 2 +- apps/web/src/components/ui/sidebar.tsx | 1 + apps/web/src/keybindings.test.ts | 26 +++++++++++ apps/web/src/lib/keybindingCategories.ts | 2 + apps/web/src/routes/_chat.index.tsx | 2 +- apps/web/src/routes/_settings.diagnostics.tsx | 2 +- packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 10 files changed, 77 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index ce42ccb038c..4098fc3fedf 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,11 +1,14 @@ import { lazy, Suspense, useEffect, useState, type ReactNode } from "react"; import ThreadSidebar from "./Sidebar"; -import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; +import { Sidebar, SidebarProvider, SidebarRail, useSidebar } from "./ui/sidebar"; import { clearShortcutModifierState, syncShortcutModifierStateFromKeyboardEvent, } from "../shortcutModifierState"; +import { resolveShortcutCommand, shouldIgnoreGlobalNavigationShortcut } from "../keybindings"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { useServerKeybindings } from "../rpc/serverState"; import { useSettingsDialogStore } from "../settingsDialogStore"; const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; @@ -37,6 +40,42 @@ function LazySettingsDialogMount() { ); } +// The sidebar writes its open/closed state to the `sidebar_state` cookie; read +// it back here so the collapsed state persists across reloads (SidebarProvider +// only seeds its initial state from `defaultOpen`). +function readSidebarOpenPreference(): boolean { + if (typeof document === "undefined") return true; + const match = document.cookie.match(/(?:^|;\s*)sidebar_state=([^;]+)/); + return match ? match[1] !== "false" : true; +} + +// Global Mod+B (configurable as `sidebar.toggle`) handler. Lives inside the +// SidebarProvider so the toggle works on every route — chat, settings, and the +// no-active-thread state — not just inside the chat view's shortcut handler. +function SidebarToggleShortcut() { + const { toggleSidebar } = useSidebar(); + const keybindings = useServerKeybindings(); + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + const command = resolveShortcutCommand(event, keybindings, { + context: { terminalFocus: isTerminalFocused() }, + }); + if (command !== "sidebar.toggle") return; + // Don't steal the shortcut while typing (e.g. Mod+B for bold in the composer). + if (shouldIgnoreGlobalNavigationShortcut(event)) return; + event.preventDefault(); + event.stopPropagation(); + toggleSidebar(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [keybindings, toggleSidebar]); + + return null; +} + export function AppSidebarLayout({ children }: { children: ReactNode }) { const openSettings = useSettingsDialogStore((s) => s.openSettings); @@ -80,7 +119,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [openSettings]); return ( - + + ) : (
- + No active thread diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index c67ad91396f..65fbb3491d0 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -146,7 +146,7 @@ export const ChatHeader = memo(function ChatHeader(props: ChatHeaderProps) { return (
- + { shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), "⌘K", ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "sidebar.toggle", "MacIntel"), + "⌘B", + ); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"), "Ctrl+Shift+M", @@ -505,6 +514,23 @@ describe("chat/editor shortcuts", () => { ); }); + it("matches sidebar.toggle (Mod+B) outside terminal focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "b", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "sidebar.toggle", + ); + assert.notStrictEqual( + resolveShortcutCommand(event({ key: "b", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + "sidebar.toggle", + ); + }); + it("matches diff.toggle shortcut outside terminal focus", () => { assert.isTrue( isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/lib/keybindingCategories.ts b/apps/web/src/lib/keybindingCategories.ts index 571ad814abc..5996c49d866 100644 --- a/apps/web/src/lib/keybindingCategories.ts +++ b/apps/web/src/lib/keybindingCategories.ts @@ -11,6 +11,7 @@ export const KEYBINDING_CATEGORIES: Record = { workspace: { id: "workspace", label: "Workspace", sortWeight: 20 }, diff: { id: "diff", label: "Diff", sortWeight: 30 }, commandPalette: { id: "commandPalette", label: "Command palette", sortWeight: 40 }, + sidebar: { id: "sidebar", label: "Sidebar", sortWeight: 45 }, chat: { id: "chat", label: "Chat", sortWeight: 50 }, editor: { id: "editor", label: "Editor", sortWeight: 60 }, modelPicker: { id: "modelPicker", label: "Model picker", sortWeight: 70 }, @@ -34,6 +35,7 @@ const STATIC_COMMAND_META: Record
- + {APP_DISPLAY_NAME} diff --git a/apps/web/src/routes/_settings.diagnostics.tsx b/apps/web/src/routes/_settings.diagnostics.tsx index d65a8281a84..9dd96b9b5a7 100644 --- a/apps/web/src/routes/_settings.diagnostics.tsx +++ b/apps/web/src/routes/_settings.diagnostics.tsx @@ -14,7 +14,7 @@ function DiagnosticsRouteView() {
- +