From e547d99fe0620dd095104b2f10bb8c8d62627590 Mon Sep 17 00:00:00 2001 From: nassimna Date: Wed, 1 Apr 2026 19:53:51 +0100 Subject: [PATCH 1/5] Add a sidebar toggle keyboard shortcut - Register `mod+b` as `sidebar.toggle` - Wire global handling in the web app and update shortcut helpers/tests - Document the new default binding --- KEYBINDINGS.md | 2 + apps/server/src/keybindings.test.ts | 14 ++-- apps/server/src/keybindings.ts | 1 + apps/web/src/components/AppSidebarLayout.tsx | 53 +++++++++++++- apps/web/src/components/ChatView.browser.tsx | 74 ++++++++++++++++++++ apps/web/src/keybindings.test.ts | 45 ++++++++++++ apps/web/src/keybindings.ts | 8 +++ packages/contracts/src/keybindings.test.ts | 13 +++- packages/contracts/src/keybindings.ts | 1 + 9 files changed, 200 insertions(+), 11 deletions(-) diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index 0c00fed4e7..a9a4f342be 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+n", "command": "chat.new", "when": "!terminalFocus" }, + { "key": "mod+b", "command": "sidebar.toggle", "when": "!terminalFocus" }, { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, { "key": "mod+o", "command": "editor.openFavorite" } @@ -51,6 +52,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) - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state +- `sidebar.toggle`: toggle the main thread/project sidebar (default `mod+b`, scoped to `!terminalFocus`) - `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 - `script.{id}.run`: run a project script by id (for example `script.test.run`) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 8eda0ca85d..a0523fb763 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -165,16 +165,18 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); - it.effect("ships configurable thread navigation defaults", () => + it.effect("ships configurable sidebar and thread navigation defaults", () => Effect.sync(() => { const defaultsByCommand = new Map( - DEFAULT_KEYBINDINGS.map((binding) => [binding.command, binding.key] as const), + DEFAULT_KEYBINDINGS.map((binding) => [binding.command, binding] as const), ); - assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); - assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); - assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); - assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); + assert.equal(defaultsByCommand.get("sidebar.toggle")?.key, "mod+b"); + assert.equal(defaultsByCommand.get("sidebar.toggle")?.when, "!terminalFocus"); + assert.equal(defaultsByCommand.get("thread.previous")?.key, "mod+shift+["); + assert.equal(defaultsByCommand.get("thread.next")?.key, "mod+shift+]"); + assert.equal(defaultsByCommand.get("thread.jump.1")?.key, "mod+1"); + assert.equal(defaultsByCommand.get("thread.jump.9")?.key, "mod+9"); }), ); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 086d795c0c..0c1d3d07f3 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -61,6 +61,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "mod+d", command: "diff.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 a2b27bb1e9..b2d93a3631 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,12 +1,60 @@ +import { ThreadId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { useQuery } from "@tanstack/react-query"; import { useEffect, type ReactNode } from "react"; -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { resolveShortcutCommand } from "../keybindings"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import ThreadSidebar from "./Sidebar"; -import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; +import { Sidebar, SidebarProvider, SidebarRail, useSidebar } from "./ui/sidebar"; const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; +const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; + +function AppSidebarKeyboardShortcuts() { + const { toggleSidebar } = useSidebar(); + const { data: serverConfig } = useQuery(serverConfigQueryOptions()); + const keybindings = serverConfig?.keybindings ?? EMPTY_KEYBINDINGS; + const routeThreadId = useParams({ + strict: false, + select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), + }); + const terminalOpen = useTerminalStateStore((state) => + routeThreadId + ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen + : false, + ); + + useEffect(() => { + const onWindowKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen, + }, + }); + if (command !== "sidebar.toggle") return; + if (event.repeat) return; + + event.preventDefault(); + event.stopPropagation(); + toggleSidebar(); + }; + + window.addEventListener("keydown", onWindowKeyDown); + return () => { + window.removeEventListener("keydown", onWindowKeyDown); + }; + }, [keybindings, terminalOpen, toggleSidebar]); + + return null; +} export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); @@ -29,6 +77,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { return ( + , predicate: (pathname: string) => boolean, @@ -2487,6 +2500,67 @@ describe("ChatView timeline estimator parity (full app)", () => { await mounted.cleanup(); } }); + + it("toggles the main left sidebar from the global sidebar.toggle shortcut", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-sidebar-shortcut-test" as MessageId, + targetText: "sidebar shortcut test", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "sidebar.toggle", + shortcut: { + key: "b", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + const sidebarRoot = await waitForElement( + () => document.querySelector('[data-slot="sidebar"][data-side="left"]'), + "Unable to find the main left sidebar root.", + ); + + expect(sidebarRoot.dataset.state).toBe("expanded"); + + dispatchSidebarToggleShortcut(); + await vi.waitFor( + () => { + expect(sidebarRoot.dataset.state).toBe("collapsed"); + }, + { timeout: 8_000, interval: 16 }, + ); + + dispatchSidebarToggleShortcut(); + await vi.waitFor( + () => { + expect(sidebarRoot.dataset.state).toBe("expanded"); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("creates a fresh draft after the previous draft thread is promoted", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index eba0bd3b46..063592fa41 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -12,6 +12,7 @@ import { isChatNewLocalShortcut, isDiffToggleShortcut, isOpenFavoriteEditorShortcut, + isSidebarToggleShortcut, isTerminalClearShortcut, isTerminalCloseShortcut, isTerminalNewShortcut, @@ -101,6 +102,11 @@ const DEFAULT_BINDINGS = compile([ command: "diff.toggle", whenAst: whenNot(whenIdentifier("terminalFocus")), }, + { + shortcut: modShortcut("b"), + command: "sidebar.toggle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, @@ -249,6 +255,14 @@ describe("shortcutLabelForCommand", () => { it("returns effective labels for non-terminal commands", () => { assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "sidebar.toggle", "MacIntel"), + "⌘B", + ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "sidebar.toggle", "Linux"), + "Ctrl+B", + ); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), "Ctrl+O", @@ -396,6 +410,21 @@ describe("chat/editor shortcuts", () => { }), ); }); + + it("matches sidebar.toggle outside terminal focus", () => { + assert.isTrue( + isSidebarToggleShortcut(event({ key: "b", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + ); + assert.isFalse( + isSidebarToggleShortcut(event({ key: "b", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: true }, + }), + ); + }); }); describe("cross-command precedence", () => { @@ -472,6 +501,22 @@ describe("resolveShortcutCommand", () => { ); }); + it("matches sidebar.toggle with the default when context", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "b", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "sidebar.toggle", + ); + assert.isNull( + resolveShortcutCommand(event({ key: "b", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: true }, + }), + ); + }); + it("matches bracket shortcuts using the physical key code", () => { assert.strictEqual( resolveShortcutCommand( diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 286454dc05..8b8fba6b32 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -322,6 +322,14 @@ export function isDiffToggleShortcut( return matchesCommandShortcut(event, keybindings, "diff.toggle", options); } +export function isSidebarToggleShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "sidebar.toggle", options); +} + export function isChatNewShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index c3a7d9f00e..1286fd3fe6 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -41,6 +41,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedDiffToggle.command, "diff.toggle"); + const parsedSidebarToggle = yield* decode(KeybindingRule, { + key: "mod+b", + command: "sidebar.toggle", + }); + assert.strictEqual(parsedSidebarToggle.command, "sidebar.toggle"); + const parsedLocal = yield* decode(KeybindingRule, { key: "mod+shift+n", command: "chat.newLocal", @@ -90,9 +96,9 @@ it.effect("parses keybindings array payload", () => it.effect("parses resolved keybinding rules", () => Effect.gen(function* () { const parsed = yield* decode(ResolvedKeybindingRule, { - command: "terminal.split", + command: "sidebar.toggle", shortcut: { - key: "d", + key: "b", metaKey: false, ctrlKey: false, shiftKey: false, @@ -108,7 +114,8 @@ it.effect("parses resolved keybinding rules", () => }, }, }); - assert.strictEqual(parsed.shortcut.key, "d"); + assert.strictEqual(parsed.command, "sidebar.toggle"); + assert.strictEqual(parsed.shortcut.key, "b"); }), ); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index b08fff8679..964e17b2d7 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -33,6 +33,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "terminal.new", "terminal.close", "diff.toggle", + "sidebar.toggle", "chat.new", "chat.newLocal", "editor.openFavorite", From 064d12fe61316ce5bee3798bc0949ccbf34fff91 Mon Sep 17 00:00:00 2001 From: nassimna Date: Wed, 1 Apr 2026 20:13:56 +0100 Subject: [PATCH 2/5] Fix sidebar shortcut server config access --- apps/web/src/components/AppSidebarLayout.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index b2d93a3631..c11260b281 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,11 +1,10 @@ -import { ThreadId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; -import { useQuery } from "@tanstack/react-query"; +import { ThreadId } from "@t3tools/contracts"; import { useEffect, type ReactNode } from "react"; import { useNavigate, useParams } from "@tanstack/react-router"; -import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { resolveShortcutCommand } from "../keybindings"; import { isTerminalFocused } from "../lib/terminalFocus"; +import { useServerKeybindings } from "../rpc/serverState"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import ThreadSidebar from "./Sidebar"; import { Sidebar, SidebarProvider, SidebarRail, useSidebar } from "./ui/sidebar"; @@ -13,12 +12,10 @@ import { Sidebar, SidebarProvider, SidebarRail, useSidebar } from "./ui/sidebar" const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; -const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; function AppSidebarKeyboardShortcuts() { const { toggleSidebar } = useSidebar(); - const { data: serverConfig } = useQuery(serverConfigQueryOptions()); - const keybindings = serverConfig?.keybindings ?? EMPTY_KEYBINDINGS; + const keybindings = useServerKeybindings(); const routeThreadId = useParams({ strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), From a1776f9b389e320fcfa78ab12b557ba5ebeb2486 Mon Sep 17 00:00:00 2001 From: nassimna Date: Wed, 1 Apr 2026 20:23:33 +0100 Subject: [PATCH 3/5] Stabilize sidebar shortcut browser test --- CLAUDE.md | 2 +- apps/web/src/components/ChatView.browser.tsx | 39 ++++++++++++++------ 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 47dc3e3d86..c317064255 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md \ No newline at end of file +AGENTS.md diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 9a7493c531..3ed9cba876 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -32,6 +32,7 @@ import { import { isMacPlatform } from "../lib/utils"; import { __resetNativeApiForTests } from "../nativeApi"; import { getRouter } from "../router"; +import { getServerConfig } from "../rpc/serverState"; import { useStore } from "../store"; import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; import { estimateTimelineMessageHeight } from "./timelineHeight"; @@ -865,6 +866,7 @@ async function waitForServerConfigToApply(): Promise { expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe( true, ); + expect(getServerConfig()?.keybindings).toEqual(fixture.serverConfig.keybindings); }, { timeout: 8_000, interval: 16 }, ); @@ -898,6 +900,23 @@ function dispatchSidebarToggleShortcut(): void { ); } +async function triggerSidebarToggleShortcutUntilState( + sidebarRoot: HTMLElement, + expectedState: "expanded" | "collapsed", + errorMessage: string, +): Promise { + const deadline = Date.now() + 8_000; + while (Date.now() < deadline) { + dispatchSidebarToggleShortcut(); + await waitForLayout(); + if (sidebarRoot.dataset.state === expectedState) { + return; + } + } + + throw new Error(`${errorMessage} Last state: ${sidebarRoot.dataset.state ?? ""}`); +} + async function triggerChatNewShortcutUntilPath( router: ReturnType, predicate: (pathname: string) => boolean, @@ -2541,20 +2560,16 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(sidebarRoot.dataset.state).toBe("expanded"); - dispatchSidebarToggleShortcut(); - await vi.waitFor( - () => { - expect(sidebarRoot.dataset.state).toBe("collapsed"); - }, - { timeout: 8_000, interval: 16 }, + await triggerSidebarToggleShortcutUntilState( + sidebarRoot, + "collapsed", + "Sidebar should collapse from the global sidebar.toggle shortcut.", ); - dispatchSidebarToggleShortcut(); - await vi.waitFor( - () => { - expect(sidebarRoot.dataset.state).toBe("expanded"); - }, - { timeout: 8_000, interval: 16 }, + await triggerSidebarToggleShortcutUntilState( + sidebarRoot, + "expanded", + "Sidebar should expand from the global sidebar.toggle shortcut.", ); } finally { await mounted.cleanup(); From 744a2ea0665b5648d9fc0a8a7988bbaed3e821f1 Mon Sep 17 00:00:00 2001 From: nassimna Date: Wed, 1 Apr 2026 20:24:13 +0100 Subject: [PATCH 4/5] Restore CLAUDE symlink newline --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index c317064255..47dc3e3d86 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md +AGENTS.md \ No newline at end of file From e4ae5922cb24442084b2e5e4301163a09ebaeb86 Mon Sep 17 00:00:00 2001 From: nassimna Date: Wed, 1 Apr 2026 20:29:54 +0100 Subject: [PATCH 5/5] Stabilize GitCore slashed remote test --- apps/server/src/git/Layers/GitCore.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 07892ec447..6ad94baf95 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -799,7 +799,21 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["checkout", defaultBranch]); yield* git(source, ["branch", "-D", featureBranch]); - yield* (yield* GitCore).checkoutBranch({ + const realGitCore = yield* GitCore; + const core = yield* makeIsolatedGitCore((input) => { + if (input.args[0] === "fetch") { + return Effect.succeed({ + code: 0, + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + } + return realGitCore.execute(input); + }); + + yield* core.checkoutBranch({ cwd: source, branch: `${remoteName}/${featureBranch}`, });