diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index b57c13032c..e8a31ef44a 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -24,6 +24,7 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" }, + { "key": "mod+b", "command": "sidebar.toggle", "when": "!terminalFocus" }, { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, @@ -52,6 +53,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.new`: create new terminal (in focused terminal context by default) - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) - `commandPalette.toggle`: open or close the global command palette +- `sidebar.toggle`: open or close the thread sidebar - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) - `editor.openFavorite`: open current project/worktree in the last-used editor diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 165b2edeb0..d62cef9956 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -64,6 +64,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, + { key: "mod+b", command: "sidebar.toggle", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index b1ce57235a..bdb062db9a 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -2,7 +2,7 @@ import { useEffect, type ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; import ThreadSidebar from "./Sidebar"; -import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; +import { Sidebar, SidebarRail } from "./ui/sidebar"; import { clearShortcutModifierState, syncShortcutModifierStateFromKeyboardEvent, @@ -54,7 +54,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate]); return ( - + <> {children} - + ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0c76059b6a..a18664d723 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3229,6 +3229,8 @@ export default function ChatView(props: ChatViewProps) { isElectron ? cn( "drag-region flex h-[52px] items-center wco:h-[env(titlebar-area-height)]", + "transition-[padding-left] duration-200 ease-linear", + "group-data-[state=collapsed]/sidebar-wrapper:pl-[90px] group-data-[state=collapsed]/sidebar-wrapper:wco:pl-[calc(env(titlebar-area-x)+1em)]", reserveTitleBarControlInset && "wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", ) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 2b587afc51..fcc3adeeaf 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -17,6 +17,7 @@ import { FolderIcon, FolderPlusIcon, MessageSquareIcon, + PanelLeftIcon, SettingsIcon, SquarePenIcon, } from "lucide-react"; @@ -103,6 +104,7 @@ import { import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; import { stackedThreadToast, toastManager } from "./ui/toast"; +import { useSidebar } from "./ui/sidebar"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; @@ -204,6 +206,7 @@ function CommandPaletteDialog() { function OpenCommandPaletteDialog() { const navigate = useNavigate(); + const { toggleSidebar } = useSidebar(); const setOpen = useCommandPaletteStore((store) => store.setOpen); const openIntent = useCommandPaletteStore((store) => store.openIntent); const clearOpenIntent = useCommandPaletteStore((store) => store.clearOpenIntent); @@ -697,6 +700,18 @@ function OpenCommandPaletteDialog() { }); } + actionItems.push({ + kind: "action", + value: "action:toggle-sidebar", + searchTerms: ["sidebar", "toggle", "collapse", "hide", "show"], + title: "Toggle sidebar", + icon: , + shortcutCommand: "sidebar.toggle", + run: async () => { + toggleSidebar(); + }, + }); + actionItems.push({ kind: "action", value: "action:settings", diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index cd1f76ed2c..ecedc1cd20 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -11,7 +11,11 @@ export function NoActiveThreadState() { className={cn( "border-b border-border px-3 sm:px-5", isElectron - ? "drag-region flex h-[52px] items-center wco:h-[env(titlebar-area-height)]" + ? cn( + "drag-region flex h-[52px] items-center wco:h-[env(titlebar-area-height)]", + "transition-[padding-left] duration-200 ease-linear", + "group-data-[state=collapsed]/sidebar-wrapper:pl-[90px] group-data-[state=collapsed]/sidebar-wrapper:wco:pl-[calc(env(titlebar-area-x)+1em)]", + ) : "py-2 sm:py-3", )} > diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index cfa29e950b..e4525dd155 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -155,6 +155,7 @@ function SidebarProvider({ className, )} data-slot="sidebar-wrapper" + data-state={state} style={ { "--sidebar-width": SIDEBAR_WIDTH, diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 85c14fa0ab..1b8c50d389 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -110,6 +110,11 @@ const DEFAULT_BINDINGS = compile([ command: "commandPalette.toggle", whenAst: whenNot(whenIdentifier("terminalFocus")), }, + { + shortcut: modShortcut("b"), + command: "sidebar.toggle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("m", { shiftKey: true }), command: "modelPicker.toggle", @@ -291,6 +296,14 @@ describe("shortcutLabelForCommand", () => { shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), "⌘K", ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "sidebar.toggle", "MacIntel"), + "⌘B", + ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "sidebar.toggle", "Linux"), + "Ctrl+B", + ); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"), "Ctrl+Shift+M", @@ -478,6 +491,30 @@ describe("chat/editor shortcuts", () => { ); }); + it("matches sidebar.toggle shortcut outside terminal focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "b", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "sidebar.toggle", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "b", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + 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/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 87e8667901..30bb335532 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -13,6 +13,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { APP_DISPLAY_NAME } from "../branding"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; +import { SidebarProvider } from "../components/ui/sidebar"; import { SlowRpcAckToastCoordinator, WebSocketConnectionCoordinator, @@ -104,11 +105,13 @@ function RootRouteView() { - - - - - + + + + + + + diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index fb8191f448..53e03e962d 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -12,6 +12,7 @@ import { resolveShortcutCommand } from "../keybindings"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; +import { useSidebar } from "~/components/ui/sidebar"; import { useSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "~/rpc/serverState"; @@ -27,6 +28,7 @@ function ChatRouteGlobalShortcuts() { : false, ); const appSettings = useSettings(); + const { toggleSidebar } = useSidebar(); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { @@ -94,6 +96,32 @@ function ChatRouteGlobalShortcuts() { appSettings.defaultThreadEnvMode, ]); + // Sidebar toggle runs on capture phase so it wins over in-editor handlers + // (Lexical claims mod+b for bold and calls preventDefault, which would + // otherwise trip the `event.defaultPrevented` guard above). + useEffect(() => { + const onWindowKeyDownCapture = (event: KeyboardEvent) => { + if (useCommandPaletteStore.getState().open) { + return; + } + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen, + }, + }); + if (command !== "sidebar.toggle") return; + event.preventDefault(); + event.stopPropagation(); + toggleSidebar(); + }; + + window.addEventListener("keydown", onWindowKeyDownCapture, true); + return () => { + window.removeEventListener("keydown", onWindowKeyDownCapture, true); + }; + }, [keybindings, terminalOpen, toggleSidebar]); + return null; } diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 64b5e7bc68..675d68dae0 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -62,7 +62,7 @@ function SettingsContentLayout() { )} {isElectron && ( -
+
Settings diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index d3b85d1cda..63afd51f9f 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -54,6 +54,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "terminal.close", "diff.toggle", "commandPalette.toggle", + "sidebar.toggle", "chat.new", "chat.newLocal", "editor.openFavorite",