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() {
- +