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/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx
index d98f30a1e5..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 9ef221e262..b4a91bb274 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -3504,6 +3504,8 @@ export default function ChatView(props: ChatViewProps) {
isElectron
? cn(
"drag-region flex h-[52px] items-center px-3 sm:px-5 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 027688a284..0734bbe138 100644
--- a/apps/web/src/components/CommandPalette.tsx
+++ b/apps/web/src/components/CommandPalette.tsx
@@ -23,6 +23,7 @@ import {
FolderPlusIcon,
LinkIcon,
MessageSquareIcon,
+ PanelLeftIcon,
SettingsIcon,
SquarePenIcon,
} from "lucide-react";
@@ -115,6 +116,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 { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";
import type { ChatComposerHandle } from "./chat/ChatComposer";
@@ -392,6 +394,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);
@@ -1050,6 +1053,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 32206be708..e6efa71f2b 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 58617589df..443c7d75ff 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -14,6 +14,7 @@ import { APP_DISPLAY_NAME } from "../branding";
import { AppSidebarLayout } from "../components/AppSidebarLayout";
import { CommandPalette } from "../components/CommandPalette";
import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
+import { SidebarProvider } from "../components/ui/sidebar";
import {
SlowRpcAckToastCoordinator,
WebSocketConnectionCoordinator,
@@ -120,11 +121,13 @@ function RootRouteView() {
}
const appShell = (
-
-
-
-
-
+
+
+
+
+
+
+
);
return (
diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx
index da22d7e602..7eb54c7b2d 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 ec98e7ee36..8cbf7ca6a4 100644
--- a/apps/web/src/routes/settings.tsx
+++ b/apps/web/src/routes/settings.tsx
@@ -78,7 +78,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",
diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts
index c67ad784fe..aec05496bb 100644
--- a/packages/shared/src/keybindings.ts
+++ b/packages/shared/src/keybindings.ts
@@ -25,6 +25,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" },