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/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}`, }); 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..c11260b281 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,13 +1,58 @@ +import { ThreadId } from "@t3tools/contracts"; import { useEffect, type ReactNode } from "react"; -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useParams } from "@tanstack/react-router"; +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 } 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; +function AppSidebarKeyboardShortcuts() { + const { toggleSidebar } = useSidebar(); + const keybindings = useServerKeybindings(); + 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 +74,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { return ( + { expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe( true, ); + expect(getServerConfig()?.keybindings).toEqual(fixture.serverConfig.keybindings); }, { timeout: 8_000, interval: 16 }, ); @@ -885,6 +887,36 @@ function dispatchChatNewShortcut(): void { ); } +function dispatchSidebarToggleShortcut(): void { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "b", + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); +} + +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, @@ -2487,6 +2519,63 @@ 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"); + + await triggerSidebarToggleShortcutUntilState( + sidebarRoot, + "collapsed", + "Sidebar should collapse from the global sidebar.toggle shortcut.", + ); + + await triggerSidebarToggleShortcutUntilState( + sidebarRoot, + "expanded", + "Sidebar should expand from the global sidebar.toggle shortcut.", + ); + } 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",