From 645b037c9e994ca408bec4ab658db2966eac7a25 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 11:11:26 -0800 Subject: [PATCH 01/40] Add configurable terminal keybindings loaded from ~/.t3 - extend `server.getConfig` to return sanitized keybinding rules from `~/.t3/keybindings.json` - apply resolved bindings in the web app for toggle/split/new terminal shortcuts and labels - add contracts/docs and tests for config parsing, validation, and shortcut behavior --- KEYBINDINGS.md | 85 ++++ apps/server/src/wsServer.test.ts | 146 +++++- apps/server/src/wsServer.ts | 56 ++- apps/web/src/components/ChatView.tsx | 109 ++++- .../src/components/ThreadTerminalDrawer.tsx | 18 +- apps/web/src/terminal-shortcuts.test.ts | 155 ++++++- apps/web/src/terminal-shortcuts.ts | 431 +++++++++++++++++- apps/web/src/wsNativeApi.ts | 3 + packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 4 + packages/contracts/src/server.ts | 28 ++ 11 files changed, 992 insertions(+), 44 deletions(-) create mode 100644 KEYBINDINGS.md create mode 100644 packages/contracts/src/server.ts diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md new file mode 100644 index 0000000000000000000000000000000000000000..ae319a08c0d423abf1a1da23f5d881f68e3bd3ed --- /dev/null +++ b/KEYBINDINGS.md @@ -0,0 +1,85 @@ +# Keybindings + +T3 Code reads keybindings from: + +- `~/.t3/keybindings.json` + +The file must be a JSON array of rules: + +```json +[ + { "key": "mod+j", "command": "terminal.toggle" }, + { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, + { "key": "mod+shift+d", "command": "terminal.new", "when": "terminalFocus" } +] +``` + +## Rule Shape + +Each entry supports: + +- `key` (required): shortcut string, like `mod+j`, `ctrl+k`, `cmd+shift+d` +- `command` (required): action ID +- `when` (optional): boolean expression controlling when the shortcut is active + +Invalid rules are ignored. Invalid config files are ignored. Warnings are logged by the server. + +## Available Commands + +- `terminal.toggle`: open/close terminal drawer +- `terminal.split`: split terminal (in focused terminal context by default) +- `terminal.new`: create new terminal (in focused terminal context by default) + +## Key Syntax + +Supported modifiers: + +- `mod` (`cmd` on macOS, `ctrl` on non-macOS) +- `cmd` / `meta` +- `ctrl` / `control` +- `shift` +- `alt` / `option` + +Examples: + +- `mod+j` +- `mod+shift+d` +- `ctrl+l` +- `cmd+k` + +## `when` Conditions + +Currently available context keys: + +- `terminalFocus` +- `terminalOpen` + +Supported operators: + +- `!` (not) +- `&&` (and) +- `||` (or) +- parentheses: `(` `)` + +Examples: + +- `"when": "terminalFocus"` +- `"when": "terminalOpen && !terminalFocus"` +- `"when": "terminalFocus || terminalOpen"` + +Unknown condition keys evaluate to `false`. + +## Defaults + +Built-in defaults: + +- `terminal.toggle`: `mod+j` +- `terminal.split`: `mod+d` when `terminalFocus` +- `terminal.new`: `mod+shift+d` when `terminalFocus` + +If you define any rules for a command, the default rule for that command is removed. + +## Precedence + +- Rules are evaluated in array order. +- Later matching rules win over earlier matching rules for the same command. diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index baf70eaca2b367a99d7f2b3c218578f3ba1650c2..288de25288a889a92e1e840915f58af25a29de09 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -307,7 +307,151 @@ describe("WebSocket Server", () => { const response = await sendRequest(ws, WS_METHODS.serverGetConfig); expect(response.error).toBeUndefined(); - expect(response.result).toEqual({ cwd: "/my/workspace" }); + expect(response.result).toEqual({ + cwd: "/my/workspace", + keybindings: [], + }); + }); + + it("reads keybindings from ~/.t3/keybindings.json", async () => { + const fakeHome = makeTempDir("t3code-home-"); + const configDir = path.join(fakeHome, ".t3"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, "keybindings.json"), + JSON.stringify([ + { key: "cmd+j", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + ]), + "utf8", + ); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + server = createTestServer({ cwd: "/my/workspace" }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + keybindings: [ + { key: "cmd+j", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + ], + }); + }); + + it("warns and ignores invalid keybinding entries", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fakeHome = makeTempDir("t3code-home-invalid-entry-"); + const configDir = path.join(fakeHome, ".t3"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, "keybindings.json"), + JSON.stringify([ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+z", command: "invalid.command", when: "terminalFocus" }, + ]), + "utf8", + ); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + server = createTestServer({ cwd: "/my/workspace" }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + keybindings: [{ key: "mod+j", command: "terminal.toggle" }], + }); + expect( + warnSpy.mock.calls.some(([message]) => + String(message).includes("ignoring invalid keybinding entries"), + ), + ).toBe(true); + }); + + it("warns and ignores unsupported keybindings config format", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fakeHome = makeTempDir("t3code-home-unsupported-format-"); + const configDir = path.join(fakeHome, ".t3"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, "keybindings.json"), + JSON.stringify({ "terminal.toggle": "mod+j" }), + "utf8", + ); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + server = createTestServer({ cwd: "/my/workspace" }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + keybindings: [], + }); + expect( + warnSpy.mock.calls.some(([message]) => + String(message).includes("unsupported format; expected array"), + ), + ).toBe(true); + }); + + it("warns and ignores malformed keybindings config files", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fakeHome = makeTempDir("t3code-home-invalid-"); + const configDir = path.join(fakeHome, ".t3"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, "keybindings.json"), "{not-json", "utf8"); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + server = createTestServer({ cwd: "/my/workspace" }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + keybindings: [], + }); + expect( + warnSpy.mock.calls.some(([message]) => + String(message).includes("ignoring malformed keybindings config"), + ), + ).toBe(true); }); it("returns error for unknown methods", async () => { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 6b12b13917b164fbc128fc6dcd1b7018b4484df4..ddd4d53e765e4ce8aece7c43b3177796443d0172 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -9,6 +9,8 @@ import { EDITORS, WS_CHANNELS, WS_METHODS, + keybindingRuleSchema, + type KeybindingsConfig, type TerminalEvent, type WsPush, type WsRequest, @@ -68,6 +70,55 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { return undefined; } +function readKeybindingsConfig(logger: ReturnType): KeybindingsConfig { + const configPath = path.join(os.homedir(), ".t3", "keybindings.json"); + try { + const raw = fs.readFileSync(configPath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + logger.warn("ignoring keybindings config with unsupported format; expected array", { + path: configPath, + }); + return []; + } + + const sanitized: KeybindingsConfig = []; + let invalidEntries = 0; + for (const entry of parsed) { + const result = keybindingRuleSchema.safeParse(entry); + if (result.success) { + sanitized.push(result.data); + if (sanitized.length >= 256) { + logger.warn("truncating keybindings config to max entries", { + path: configPath, + maxEntries: 256, + }); + break; + } + continue; + } + invalidEntries += 1; + } + if (invalidEntries > 0) { + logger.warn("ignoring invalid keybinding entries", { + path: configPath, + invalidEntries, + totalEntries: parsed.length, + }); + } + return sanitized; + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return []; + } + logger.warn("ignoring malformed keybindings config", { + path: configPath, + error: error instanceof Error ? error.message : String(error), + }); + } + return []; +} + export function createServer(options: ServerOptions) { const { port, @@ -410,7 +461,10 @@ export function createServer(options: ServerOptions) { return undefined; case WS_METHODS.serverGetConfig: - return { cwd }; + return { + cwd, + keybindings: readKeybindingsConfig(logger), + }; default: throw new Error(`Unknown method: ${request.method}`); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f9649d2f9f7221cd08f994ce774fdc43e28a3e82..dd3667a60b05f7fe8aa597f587bf68fb1a1892fc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,10 +1,12 @@ import { + EDITORS, + type EditorId, + ModelSlug, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, - type ProviderSendTurnAttachmentInput, type ProviderApprovalDecision, - ModelSlug, + type ProviderSendTurnAttachmentInput, } from "@t3tools/contracts"; import { type ClipboardEvent, @@ -25,7 +27,6 @@ import { gitCreateWorktreeMutationOptions, } from "~/lib/gitReactQuery"; -import { EDITORS, type EditorId } from "@t3tools/contracts"; import { isElectron } from "../env"; import { buildBootstrapInput } from "../historyBootstrap"; import { @@ -50,7 +51,14 @@ import { useStore } from "../store"; import type { ChatImageAttachment } from "../types"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; -import { isTerminalToggleShortcut } from "../terminal-shortcuts"; +import { + DEFAULT_TERMINAL_KEYBINDINGS, + isTerminalNewShortcut, + isTerminalSplitShortcut, + isTerminalToggleShortcut, + resolveTerminalKeybindings, + shortcutLabelForCommand, +} from "../terminal-shortcuts"; import ChatMarkdown from "./ChatMarkdown"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { useNativeApi } from "../hooks/useNativeApi"; @@ -144,6 +152,7 @@ export default function ChatView() { const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); + const [terminalKeybindings, setTerminalKeybindings] = useState(DEFAULT_TERMINAL_KEYBINDINGS); const messagesScrollRef = useRef(null); const messagesEndRef = useRef(null); const shouldAutoScrollRef = useRef(true); @@ -256,12 +265,45 @@ export default function ChatView() { const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); // Default true while loading to avoid toolbar flicker. const isGitRepo = branchesQuery.data?.isRepo ?? true; + const splitTerminalShortcutLabel = useMemo( + () => shortcutLabelForCommand(terminalKeybindings, "terminal.split"), + [terminalKeybindings], + ); + const newTerminalShortcutLabel = useMemo( + () => shortcutLabelForCommand(terminalKeybindings, "terminal.new"), + [terminalKeybindings], + ); const envLocked = Boolean( activeThread && (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "closed")), ); + + useEffect(() => { + if (!api || !api.server || typeof api.server.getConfig !== "function") { + setTerminalKeybindings(DEFAULT_TERMINAL_KEYBINDINGS); + return; + } + + let disposed = false; + + void api.server + .getConfig() + .then((config) => { + if (disposed) return; + setTerminalKeybindings(resolveTerminalKeybindings(config.keybindings)); + }) + .catch(() => { + if (disposed) return; + setTerminalKeybindings(DEFAULT_TERMINAL_KEYBINDINGS); + }); + + return () => { + disposed = true; + }; + }, [api]); + const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { for (const image of images) { if (!image.previewUrl) continue; @@ -490,15 +532,64 @@ export default function ChatView() { }, [activeThread?.terminalOpen, activeThreadId, focusComposer]); useEffect(() => { + const isTerminalFocused = (): boolean => { + const activeElement = document.activeElement; + if (!(activeElement instanceof HTMLElement)) return false; + if (activeElement.classList.contains("xterm-helper-textarea")) return true; + return activeElement.closest(".thread-terminal-drawer .xterm") !== null; + }; + const handler = (event: globalThis.KeyboardEvent) => { - if (!activeThreadId) return; - if (!isTerminalToggleShortcut(event)) return; + if (!activeThreadId || event.defaultPrevented) return; + const shortcutContext = { + terminalFocus: isTerminalFocused(), + terminalOpen: Boolean(activeThread?.terminalOpen), + }; + + if (isTerminalToggleShortcut(event, terminalKeybindings, { context: shortcutContext })) { + event.preventDefault(); + event.stopPropagation(); + toggleTerminalVisibility(); + return; + } + + if (isTerminalSplitShortcut(event, terminalKeybindings, { context: shortcutContext })) { + event.preventDefault(); + event.stopPropagation(); + if (!activeThread?.terminalOpen) { + dispatch({ + type: "SET_THREAD_TERMINAL_OPEN", + threadId: activeThreadId, + open: true, + }); + } + splitTerminal(); + return; + } + + if (!isTerminalNewShortcut(event, terminalKeybindings, { context: shortcutContext })) return; event.preventDefault(); - toggleTerminalVisibility(); + event.stopPropagation(); + if (!activeThread?.terminalOpen) { + dispatch({ + type: "SET_THREAD_TERMINAL_OPEN", + threadId: activeThreadId, + open: true, + }); + } + createNewTerminal(); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [activeThreadId, toggleTerminalVisibility]); + }, [ + activeThread?.terminalOpen, + activeThreadId, + createNewTerminal, + dispatch, + splitTerminal, + terminalKeybindings, + toggleTerminalVisibility, + ]); const setThreadError = (threadId: string | null, error: string | null) => { if (!threadId) return; @@ -1350,6 +1441,8 @@ export default function ChatView() { focusRequestId={terminalFocusRequestId} onSplitTerminal={splitTerminal} onNewTerminal={createNewTerminal} + splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} + newShortcutLabel={newTerminalShortcutLabel ?? undefined} onActiveTerminalChange={activateTerminal} onCloseTerminal={closeTerminal} onHeightChange={(height) => diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index eaa8b97fdc4a1df39362ebbc90dda0b8dd434544..3373f913662c1ce58f88da2abbbd17fe6e700aec 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -416,6 +416,8 @@ interface ThreadTerminalDrawerProps { focusRequestId: number; onSplitTerminal: () => void; onNewTerminal: () => void; + splitShortcutLabel?: string | undefined; + newShortcutLabel?: string | undefined; onActiveTerminalChange: (terminalId: string) => void; onCloseTerminal: (terminalId: string) => void; onHeightChange: (height: number) => void; @@ -468,6 +470,8 @@ export default function ThreadTerminalDrawer({ focusRequestId, onSplitTerminal, onNewTerminal, + splitShortcutLabel, + newShortcutLabel, onActiveTerminalChange, onCloseTerminal, onHeightChange, @@ -694,7 +698,7 @@ export default function ThreadTerminalDrawer({ @@ -702,7 +706,7 @@ export default function ThreadTerminalDrawer({ @@ -779,14 +783,20 @@ export default function ThreadTerminalDrawer({ diff --git a/apps/web/src/terminal-shortcuts.test.ts b/apps/web/src/terminal-shortcuts.test.ts index bbc13f068d5a12aeebc1619875421abf31d27708..6de3d641a8e070fb855d1d674e54642f1782b187 100644 --- a/apps/web/src/terminal-shortcuts.test.ts +++ b/apps/web/src/terminal-shortcuts.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from "vitest"; import { + DEFAULT_TERMINAL_KEYBINDINGS, + formatShortcutLabel, isTerminalClearShortcut, + isTerminalNewShortcut, + isTerminalSplitShortcut, isTerminalToggleShortcut, + resolveTerminalKeybindings, + shortcutLabelForCommand, type ShortcutEventLike, } from "./terminal-shortcuts"; @@ -19,22 +25,140 @@ function event(overrides: Partial = {}): ShortcutEventLike { describe("isTerminalToggleShortcut", () => { it("matches Cmd+J on macOS", () => { - expect(isTerminalToggleShortcut(event({ metaKey: true }), "MacIntel")).toBe(true); + expect( + isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { + platform: "MacIntel", + }), + ).toBe(true); }); it("matches Ctrl+J on non-macOS", () => { - expect(isTerminalToggleShortcut(event({ ctrlKey: true }), "Win32")).toBe(true); + expect( + isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { + platform: "Win32", + }), + ).toBe(true); }); +}); - it("rejects wrong modifiers", () => { - expect(isTerminalToggleShortcut(event({ ctrlKey: true, shiftKey: true }), "Win32")).toBe(false); - expect(isTerminalToggleShortcut(event({ metaKey: true, altKey: true }), "MacIntel")).toBe( - false, - ); +describe("split/new terminal shortcuts", () => { + it("requires terminalFocus for default split/new bindings", () => { + expect( + isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + ).toBe(false); + expect( + isTerminalNewShortcut( + event({ key: "d", ctrlKey: true, shiftKey: true }), + DEFAULT_TERMINAL_KEYBINDINGS, + { + platform: "Linux", + context: { terminalFocus: false }, + }, + ), + ).toBe(false); }); - it("rejects non-j keys", () => { - expect(isTerminalToggleShortcut(event({ key: "k", ctrlKey: true }), "Linux")).toBe(false); + it("matches split/new when terminalFocus is true", () => { + expect( + isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + ).toBe(true); + expect( + isTerminalNewShortcut( + event({ key: "d", ctrlKey: true, shiftKey: true }), + DEFAULT_TERMINAL_KEYBINDINGS, + { + platform: "Linux", + context: { terminalFocus: true }, + }, + ), + ).toBe(true); + }); + + it("supports configured overrides with when expressions", () => { + const keybindings = resolveTerminalKeybindings([ + { + key: "mod+\\", + command: "terminal.split", + when: "terminalOpen && !terminalFocus", + }, + { + key: "mod+shift+n", + command: "terminal.new", + when: "terminalOpen && !terminalFocus", + }, + ]); + expect( + isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { + platform: "Win32", + context: { terminalOpen: true, terminalFocus: false }, + }), + ).toBe(true); + expect( + isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { + platform: "Win32", + context: { terminalOpen: false, terminalFocus: false }, + }), + ).toBe(false); + expect( + isTerminalNewShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), keybindings, { + platform: "Win32", + context: { terminalOpen: true, terminalFocus: false }, + }), + ).toBe(true); + }); +}); + +describe("resolveTerminalKeybindings", () => { + it("uses defaults when config is empty", () => { + expect(resolveTerminalKeybindings(undefined)).toEqual(DEFAULT_TERMINAL_KEYBINDINGS); + }); + + it("replaces defaults for commands configured by user", () => { + const resolved = resolveTerminalKeybindings([ + { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, + ]); + + expect(resolved).toEqual([ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, + ]); + }); + + it("ignores invalid configured values", () => { + expect( + resolveTerminalKeybindings([ + { key: " ", command: "terminal.toggle" }, + { key: "mod+d+f", command: "terminal.split" }, + ]), + ).toEqual(DEFAULT_TERMINAL_KEYBINDINGS); + }); +}); + +describe("shortcutLabelForCommand", () => { + it("returns the most recent binding label", () => { + const resolved = resolveTerminalKeybindings([ + { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" }, + ]); + + expect(shortcutLabelForCommand(resolved, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\"); + }); +}); + +describe("formatShortcutLabel", () => { + it("formats labels for macOS", () => { + expect(formatShortcutLabel("mod+shift+d", "MacIntel")).toBe("⇧⌘D"); + }); + + it("formats labels for non-macOS", () => { + expect(formatShortcutLabel("mod+shift+d", "Linux")).toBe("Ctrl+Shift+D"); }); }); @@ -47,17 +171,4 @@ describe("isTerminalClearShortcut", () => { it("matches Cmd+K on macOS", () => { expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")).toBe(true); }); - - it("rejects Cmd+K on non-macOS", () => { - expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "Win32")).toBe(false); - }); - - it("rejects wrong modifiers", () => { - expect( - isTerminalClearShortcut(event({ key: "l", ctrlKey: true, shiftKey: true }), "Linux"), - ).toBe(false); - expect( - isTerminalClearShortcut(event({ key: "k", metaKey: true, altKey: true }), "MacIntel"), - ).toBe(false); - }); }); diff --git a/apps/web/src/terminal-shortcuts.ts b/apps/web/src/terminal-shortcuts.ts index bc76a145ffc4a0fb8fd5bbb5c6edcef210a9f067..c876f8bc81c56fa46448db2fbd6387117e993b9b 100644 --- a/apps/web/src/terminal-shortcuts.ts +++ b/apps/web/src/terminal-shortcuts.ts @@ -1,3 +1,5 @@ +import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts"; + export interface ShortcutEventLike { key: string; metaKey: boolean; @@ -6,23 +8,436 @@ export interface ShortcutEventLike { altKey: boolean; } +export interface ShortcutMatchContext { + terminalFocus: boolean; + terminalOpen: boolean; + [key: string]: boolean; +} + +export interface ResolvedTerminalKeybinding { + key: string; + command: KeybindingCommand; + when?: string | undefined; +} + +export type ResolvedTerminalKeybindings = ResolvedTerminalKeybinding[]; + +export const DEFAULT_TERMINAL_KEYBINDINGS: ResolvedTerminalKeybindings = [ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, +]; + +interface ParsedShortcut { + key: string; + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; + modKey: boolean; +} + +interface ShortcutMatchOptions { + platform?: string; + context?: Partial; +} + +type WhenNode = + | { type: "identifier"; name: string } + | { type: "not"; node: WhenNode } + | { type: "and"; left: WhenNode; right: WhenNode } + | { type: "or"; left: WhenNode; right: WhenNode }; + +type WhenToken = + | { type: "identifier"; value: string } + | { type: "not" } + | { type: "and" } + | { type: "or" } + | { type: "lparen" } + | { type: "rparen" }; + +const WHEN_AST_CACHE = new Map(); + function isMacPlatform(platform: string): boolean { return /mac|iphone|ipad|ipod/i.test(platform); } +function normalizeKeyToken(token: string): string { + if (token === "space") return " "; + if (token === "esc") return "escape"; + return token; +} + +function parseShortcutValue(value: string): ParsedShortcut | null { + const tokens = value + .toLowerCase() + .split("+") + .map((token) => token.trim()) + .filter((token) => token.length > 0); + if (tokens.length === 0) return null; + + let key: string | null = null; + let metaKey = false; + let ctrlKey = false; + let shiftKey = false; + let altKey = false; + let modKey = false; + + for (const token of tokens) { + switch (token) { + case "cmd": + case "meta": + metaKey = true; + break; + case "ctrl": + case "control": + ctrlKey = true; + break; + case "shift": + shiftKey = true; + break; + case "alt": + case "option": + altKey = true; + break; + case "mod": + modKey = true; + break; + default: { + if (key !== null) { + return null; + } + key = normalizeKeyToken(token); + } + } + } + + if (key === null) return null; + + return { + key, + metaKey, + ctrlKey, + shiftKey, + altKey, + modKey, + }; +} + +function normalizeEventKey(key: string): string { + const normalized = key.toLowerCase(); + if (normalized === "esc") return "escape"; + return normalized; +} + +function matchesShortcutValue( + event: ShortcutEventLike, + shortcutValue: string, + platform = navigator.platform, +): boolean { + const parsed = parseShortcutValue(shortcutValue); + if (!parsed) return false; + + const key = normalizeEventKey(event.key); + if (key !== parsed.key) return false; + + const useMetaForMod = isMacPlatform(platform); + const expectedMeta = parsed.metaKey || (parsed.modKey && useMetaForMod); + const expectedCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod); + return ( + event.metaKey === expectedMeta && + event.ctrlKey === expectedCtrl && + event.shiftKey === parsed.shiftKey && + event.altKey === parsed.altKey + ); +} + +function tokenizeWhenExpression(expression: string): WhenToken[] | null { + const tokens: WhenToken[] = []; + let index = 0; + + while (index < expression.length) { + const current = expression[index]; + if (!current) break; + + if (/\s/.test(current)) { + index += 1; + continue; + } + if (expression.startsWith("&&", index)) { + tokens.push({ type: "and" }); + index += 2; + continue; + } + if (expression.startsWith("||", index)) { + tokens.push({ type: "or" }); + index += 2; + continue; + } + if (current === "!") { + tokens.push({ type: "not" }); + index += 1; + continue; + } + if (current === "(") { + tokens.push({ type: "lparen" }); + index += 1; + continue; + } + if (current === ")") { + tokens.push({ type: "rparen" }); + index += 1; + continue; + } + + const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index)); + if (!identifier) { + return null; + } + tokens.push({ type: "identifier", value: identifier[0] }); + index += identifier[0].length; + } + + return tokens; +} + +function parseWhenExpression(expression: string): WhenNode | null { + const tokens = tokenizeWhenExpression(expression); + if (!tokens || tokens.length === 0) return null; + let index = 0; + + const parsePrimary = (): WhenNode | null => { + const token = tokens[index]; + if (!token) return null; + + if (token.type === "identifier") { + index += 1; + return { type: "identifier", name: token.value }; + } + + if (token.type === "lparen") { + index += 1; + const expressionNode = parseOr(); + const closeToken = tokens[index]; + if (!expressionNode || !closeToken || closeToken.type !== "rparen") { + return null; + } + index += 1; + return expressionNode; + } + + return null; + }; + + const parseUnary = (): WhenNode | null => { + const token = tokens[index]; + if (token?.type === "not") { + index += 1; + const node = parseUnary(); + if (!node) return null; + return { type: "not", node }; + } + return parsePrimary(); + }; + + const parseAnd = (): WhenNode | null => { + let left = parseUnary(); + if (!left) return null; + + while (tokens[index]?.type === "and") { + index += 1; + const right = parseUnary(); + if (!right) return null; + left = { type: "and", left, right }; + } + + return left; + }; + + const parseOr = (): WhenNode | null => { + let left = parseAnd(); + if (!left) return null; + + while (tokens[index]?.type === "or") { + index += 1; + const right = parseAnd(); + if (!right) return null; + left = { type: "or", left, right }; + } + + return left; + }; + + const ast = parseOr(); + if (!ast || index !== tokens.length) return null; + return ast; +} + +function evaluateWhenNode(node: WhenNode, context: ShortcutMatchContext): boolean { + switch (node.type) { + case "identifier": + return Boolean(context[node.name]); + case "not": + return !evaluateWhenNode(node.node, context); + case "and": + return evaluateWhenNode(node.left, context) && evaluateWhenNode(node.right, context); + case "or": + return evaluateWhenNode(node.left, context) || evaluateWhenNode(node.right, context); + } +} + +function matchesWhenExpression(when: string | undefined, context: ShortcutMatchContext): boolean { + if (!when) return true; + const normalized = when.trim(); + if (normalized.length === 0) return true; + + if (!WHEN_AST_CACHE.has(normalized)) { + WHEN_AST_CACHE.set(normalized, parseWhenExpression(normalized)); + } + const ast = WHEN_AST_CACHE.get(normalized); + if (!ast) return false; + return evaluateWhenNode(ast, context); +} + +function resolvePlatform(options: ShortcutMatchOptions | undefined): string { + return options?.platform ?? navigator.platform; +} + +function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatchContext { + return { + terminalFocus: false, + terminalOpen: false, + ...options?.context, + }; +} + +function normalizeConfiguredKeybinding( + keybinding: KeybindingsConfig[number], +): ResolvedTerminalKeybinding | null { + const key = keybinding.key.trim(); + if (key.length === 0) return null; + if (!parseShortcutValue(key)) return null; + + const when = keybinding.when?.trim(); + return { + key, + command: keybinding.command, + ...(when ? { when } : {}), + }; +} + +function matchesCommandShortcut( + event: ShortcutEventLike, + keybindings: ResolvedTerminalKeybindings, + command: KeybindingCommand, + options?: ShortcutMatchOptions, +): boolean { + const platform = resolvePlatform(options); + const context = resolveContext(options); + + for (let index = keybindings.length - 1; index >= 0; index -= 1) { + const binding = keybindings[index]; + if (!binding || binding.command !== command) continue; + if (!matchesWhenExpression(binding.when, context)) continue; + if (!matchesShortcutValue(event, binding.key, platform)) continue; + return true; + } + return false; +} + +export function resolveTerminalKeybindings( + input: KeybindingsConfig | null | undefined, +): ResolvedTerminalKeybindings { + if (!input || input.length === 0) { + return [...DEFAULT_TERMINAL_KEYBINDINGS]; + } + + const normalizedInput = input + .map((binding) => normalizeConfiguredKeybinding(binding)) + .filter((binding): binding is ResolvedTerminalKeybinding => binding !== null); + if (normalizedInput.length === 0) { + return [...DEFAULT_TERMINAL_KEYBINDINGS]; + } + + const overriddenCommands = new Set(normalizedInput.map((binding) => binding.command)); + const retainedDefaults = DEFAULT_TERMINAL_KEYBINDINGS.filter( + (binding) => !overriddenCommands.has(binding.command), + ); + return [...retainedDefaults, ...normalizedInput]; +} + +function formatShortcutKeyLabel(key: string): string { + if (key === " ") return "Space"; + if (key.length === 1) return key.toUpperCase(); + if (key === "escape") return "Esc"; + if (key === "arrowup") return "Up"; + if (key === "arrowdown") return "Down"; + if (key === "arrowleft") return "Left"; + if (key === "arrowright") return "Right"; + return key.slice(0, 1).toUpperCase() + key.slice(1); +} + +export function formatShortcutLabel(shortcutValue: string, platform = navigator.platform): string { + const parsed = parseShortcutValue(shortcutValue); + if (!parsed) { + return shortcutValue; + } + + const keyLabel = formatShortcutKeyLabel(parsed.key); + const useMetaForMod = isMacPlatform(platform); + const showMeta = parsed.metaKey || (parsed.modKey && useMetaForMod); + const showCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod); + const showAlt = parsed.altKey; + const showShift = parsed.shiftKey; + + if (useMetaForMod) { + return `${showCtrl ? "\u2303" : ""}${showAlt ? "\u2325" : ""}${showShift ? "\u21e7" : ""}${showMeta ? "\u2318" : ""}${keyLabel}`; + } + + const parts: string[] = []; + if (showCtrl) parts.push("Ctrl"); + if (showAlt) parts.push("Alt"); + if (showShift) parts.push("Shift"); + if (showMeta) parts.push("Meta"); + parts.push(keyLabel); + return parts.join("+"); +} + +export function shortcutLabelForCommand( + keybindings: ResolvedTerminalKeybindings, + command: KeybindingCommand, + platform = navigator.platform, +): string | null { + for (let index = keybindings.length - 1; index >= 0; index -= 1) { + const binding = keybindings[index]; + if (!binding || binding.command !== command) continue; + return formatShortcutLabel(binding.key, platform); + } + return null; +} + export function isTerminalToggleShortcut( event: ShortcutEventLike, - platform = navigator.platform, + keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, + options?: ShortcutMatchOptions, ): boolean { - const key = event.key.toLowerCase(); - if (key !== "j") return false; - if (event.shiftKey || event.altKey) return false; + return matchesCommandShortcut(event, keybindings, "terminal.toggle", options); +} - if (isMacPlatform(platform)) { - return event.metaKey && !event.ctrlKey; - } +export function isTerminalSplitShortcut( + event: ShortcutEventLike, + keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "terminal.split", options); +} - return event.ctrlKey && !event.metaKey; +export function isTerminalNewShortcut( + event: ShortcutEventLike, + keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "terminal.new", options); } export function isTerminalClearShortcut( diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 57c381f2245adf4d93b762043acf4dc1ee039c95..e444cec0b9f1f40db101b8133094b72830d84ed8 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -139,6 +139,9 @@ export function createWsNativeApi(): NativeApi { return showContextMenuFallback(items, position); }, }, + server: { + getConfig: () => transport.request(WS_METHODS.serverGetConfig), + }, }; instance = { api, transport }; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 430f4fd84077d4e439b8f5165571e6be7c87baa3..f8f6d534028e48f720ac71c2b24abb4d43c57025 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -5,5 +5,6 @@ export * from "./terminal"; export * from "./provider"; export * from "./model"; export * from "./ws"; +export * from "./server"; export * from "./project"; export * from "./git"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4216172e7d821ba624def10bafa27ad983ff5714..3c1f8c265a0cf72eae3f7f61c6f61da4c3815fcd 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -31,6 +31,7 @@ import type { ProjectListResult, ProjectRemoveInput, } from "./project"; +import type { ServerConfig } from "./server"; import type { TerminalClearInput, TerminalCloseInput, @@ -113,4 +114,7 @@ export interface NativeApi { position?: { x: number; y: number }, ) => Promise; }; + server: { + getConfig: () => Promise; + }; } diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..d6c51dd28e699209afc67adbe13dd1966996299a --- /dev/null +++ b/packages/contracts/src/server.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +export const keybindingCommandSchema = z.enum([ + "terminal.toggle", + "terminal.split", + "terminal.new", +]); + +const keybindingValueSchema = z.string().trim().min(1).max(64); +const keybindingWhenSchema = z.string().trim().min(1).max(256); + +export const keybindingRuleSchema = z.object({ + key: keybindingValueSchema, + command: keybindingCommandSchema, + when: keybindingWhenSchema.optional(), +}); + +export const keybindingsConfigSchema = z.array(keybindingRuleSchema).max(256); + +export const serverConfigSchema = z.object({ + cwd: z.string().min(1), + keybindings: keybindingsConfigSchema.default([]), +}); + +export type KeybindingCommand = z.infer; +export type KeybindingRule = z.infer; +export type KeybindingsConfig = z.infer; +export type ServerConfig = z.infer; -- 2.54.0 From ace58035818652ebf400fe71e102de050950e3af Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 11:19:51 -0800 Subject: [PATCH 02/40] Rename terminal-shortcuts module to keybindings - Rename `terminal-shortcuts.ts` and its test to `keybindings` - Update ChatView and ThreadTerminalDrawer imports to use new module path --- apps/web/src/components/ChatView.tsx | 2 +- apps/web/src/components/ThreadTerminalDrawer.tsx | 2 +- .../web/src/{terminal-shortcuts.test.ts => keybindings.test.ts} | 2 +- apps/web/src/{terminal-shortcuts.ts => keybindings.ts} | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename apps/web/src/{terminal-shortcuts.test.ts => keybindings.test.ts} (99%) rename apps/web/src/{terminal-shortcuts.ts => keybindings.ts} (100%) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index dd3667a60b05f7fe8aa597f587bf68fb1a1892fc..35b99b011f143bd666da24d0e2c770f583681efd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -58,7 +58,7 @@ import { isTerminalToggleShortcut, resolveTerminalKeybindings, shortcutLabelForCommand, -} from "../terminal-shortcuts"; +} from "../keybindings"; import ChatMarkdown from "./ChatMarkdown"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { useNativeApi } from "../hooks/useNativeApi"; diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 3373f913662c1ce58f88da2abbbd17fe6e700aec..d9e544ab94debfb92999ead1bb34469706e2f4ee 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -18,7 +18,7 @@ import { preferredTerminalEditor, resolvePathLinkTarget, } from "../terminal-links"; -import { isTerminalClearShortcut } from "../terminal-shortcuts"; +import { isTerminalClearShortcut } from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, diff --git a/apps/web/src/terminal-shortcuts.test.ts b/apps/web/src/keybindings.test.ts similarity index 99% rename from apps/web/src/terminal-shortcuts.test.ts rename to apps/web/src/keybindings.test.ts index 6de3d641a8e070fb855d1d674e54642f1782b187..f71cb8f09b42a4c203bf962cf37518bbd3fada2e 100644 --- a/apps/web/src/terminal-shortcuts.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -10,7 +10,7 @@ import { resolveTerminalKeybindings, shortcutLabelForCommand, type ShortcutEventLike, -} from "./terminal-shortcuts"; +} from "./keybindings"; function event(overrides: Partial = {}): ShortcutEventLike { return { diff --git a/apps/web/src/terminal-shortcuts.ts b/apps/web/src/keybindings.ts similarity index 100% rename from apps/web/src/terminal-shortcuts.ts rename to apps/web/src/keybindings.ts -- 2.54.0 From 53c367420cac82f7c5baba55bad29432f77a9633 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 11:21:38 -0800 Subject: [PATCH 03/40] Align web keybinding types with shared contract aliases - Reuse `KeybindingRule` and `KeybindingsConfig` directly for resolved keybinding types - Update normalization helper to accept `KeybindingRule` for consistent typing --- apps/web/src/keybindings.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index c876f8bc81c56fa46448db2fbd6387117e993b9b..1a6d7b4886abfb1e036418a2251dab8347146b50 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -1,4 +1,4 @@ -import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts"; +import type { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/contracts"; export interface ShortcutEventLike { key: string; @@ -14,13 +14,8 @@ export interface ShortcutMatchContext { [key: string]: boolean; } -export interface ResolvedTerminalKeybinding { - key: string; - command: KeybindingCommand; - when?: string | undefined; -} - -export type ResolvedTerminalKeybindings = ResolvedTerminalKeybinding[]; +export type ResolvedTerminalKeybinding = KeybindingRule; +export type ResolvedTerminalKeybindings = KeybindingsConfig; export const DEFAULT_TERMINAL_KEYBINDINGS: ResolvedTerminalKeybindings = [ { key: "mod+j", command: "terminal.toggle" }, @@ -312,7 +307,7 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc } function normalizeConfiguredKeybinding( - keybinding: KeybindingsConfig[number], + keybinding: KeybindingRule, ): ResolvedTerminalKeybinding | null { const key = keybinding.key.trim(); if (key.length === 0) return null; -- 2.54.0 From 31aa32b83bb234ef7f2bdad6a898d06ea1d0552e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 11:24:14 -0800 Subject: [PATCH 04/40] Extract keybindings contracts into dedicated module - add `packages/contracts/src/keybindings.ts` with keybinding schemas and types - import keybindings config schema from `server.ts` - re-export keybindings contracts from `packages/contracts/src/index.ts` --- packages/contracts/src/index.ts | 1 + packages/contracts/src/keybindings.ts | 22 ++++++++++++++++++++++ packages/contracts/src/server.ts | 21 +-------------------- 3 files changed, 24 insertions(+), 20 deletions(-) create mode 100644 packages/contracts/src/keybindings.ts diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f8f6d534028e48f720ac71c2b24abb4d43c57025..5e705e418f4a6cb350162409f0308cc90d62ce4f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -5,6 +5,7 @@ export * from "./terminal"; export * from "./provider"; export * from "./model"; export * from "./ws"; +export * from "./keybindings"; export * from "./server"; export * from "./project"; export * from "./git"; diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e711da986ccebac9513901cd858ce2bf207281f --- /dev/null +++ b/packages/contracts/src/keybindings.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export const keybindingCommandSchema = z.enum([ + "terminal.toggle", + "terminal.split", + "terminal.new", +]); + +const keybindingValueSchema = z.string().trim().min(1).max(64); +const keybindingWhenSchema = z.string().trim().min(1).max(256); + +export const keybindingRuleSchema = z.object({ + key: keybindingValueSchema, + command: keybindingCommandSchema, + when: keybindingWhenSchema.optional(), +}); + +export const keybindingsConfigSchema = z.array(keybindingRuleSchema).max(256); + +export type KeybindingCommand = z.infer; +export type KeybindingRule = z.infer; +export type KeybindingsConfig = z.infer; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index d6c51dd28e699209afc67adbe13dd1966996299a..d3ed929fbafaa1dad1924862fb304c769dd1d4ec 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,28 +1,9 @@ import { z } from "zod"; - -export const keybindingCommandSchema = z.enum([ - "terminal.toggle", - "terminal.split", - "terminal.new", -]); - -const keybindingValueSchema = z.string().trim().min(1).max(64); -const keybindingWhenSchema = z.string().trim().min(1).max(256); - -export const keybindingRuleSchema = z.object({ - key: keybindingValueSchema, - command: keybindingCommandSchema, - when: keybindingWhenSchema.optional(), -}); - -export const keybindingsConfigSchema = z.array(keybindingRuleSchema).max(256); +import { keybindingsConfigSchema } from "./keybindings"; export const serverConfigSchema = z.object({ cwd: z.string().min(1), keybindings: keybindingsConfigSchema.default([]), }); -export type KeybindingCommand = z.infer; -export type KeybindingRule = z.infer; -export type KeybindingsConfig = z.infer; export type ServerConfig = z.infer; -- 2.54.0 From f1cd76c17af2fa9496729ef08056ae69e1f27742 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 11:30:27 -0800 Subject: [PATCH 05/40] Resolve terminal keybindings on server before sending config - Move default keybinding resolution/override merging into `server.getConfig` - Return server-resolved keybindings to the web client instead of resolving in UI - Update docs and tests to reflect server-side defaults, overrides, and truncation behavior --- KEYBINDINGS.md | 13 +++- apps/server/src/wsServer.test.ts | 24 ++++++-- apps/server/src/wsServer.ts | 53 ++++++++++++---- apps/web/src/components/ChatView.tsx | 11 ++-- apps/web/src/keybindings.test.ts | 90 ++++++++-------------------- apps/web/src/keybindings.ts | 51 ++-------------- 6 files changed, 108 insertions(+), 134 deletions(-) diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index ae319a08c0d423abf1a1da23f5d881f68e3bd3ed..bec5d27dc22a301e0ef54c8c1c601fb71dae21d1 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -4,6 +4,15 @@ T3 Code reads keybindings from: - `~/.t3/keybindings.json` +Schema source of truth: + +- `packages/contracts/src/keybindings.ts` +- `packages/contracts/src/server.ts` + +Server-side default resolution/merging: + +- `apps/server/src/wsServer.ts` + The file must be a JSON array of rules: ```json @@ -71,13 +80,13 @@ Unknown condition keys evaluate to `false`. ## Defaults -Built-in defaults: +Built-in defaults are resolved on the server and sent to the web client via `server.getConfig`: - `terminal.toggle`: `mod+j` - `terminal.split`: `mod+d` when `terminalFocus` - `terminal.new`: `mod+shift+d` when `terminalFocus` -If you define any rules for a command, the default rule for that command is removed. +If you define any rules for a command, the default rule for that command is removed on the server. ## Precedence diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 288de25288a889a92e1e840915f58af25a29de09..0b4726c51b59bdb6472403d4abcac3ce1ecb0bfa 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -309,7 +309,11 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", - keybindings: [], + keybindings: [ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + ], }); }); @@ -379,7 +383,11 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", - keybindings: [{ key: "mod+j", command: "terminal.toggle" }], + keybindings: [ + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + { key: "mod+j", command: "terminal.toggle" }, + ], }); expect( warnSpy.mock.calls.some(([message]) => @@ -414,7 +422,11 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", - keybindings: [], + keybindings: [ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + ], }); expect( warnSpy.mock.calls.some(([message]) => @@ -445,7 +457,11 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", - keybindings: [], + keybindings: [ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + ], }); expect( warnSpy.mock.calls.some(([message]) => diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index ddd4d53e765e4ce8aece7c43b3177796443d0172..640b3af76c5b5fda0e5c2725946eb94e04f33bff 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -70,6 +70,31 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { return undefined; } +const DEFAULT_KEYBINDINGS: KeybindingsConfig = [ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, +]; + +function mergeWithDefaultKeybindings(custom: KeybindingsConfig): KeybindingsConfig { + if (custom.length === 0) { + return [...DEFAULT_KEYBINDINGS]; + } + + const overriddenCommands = new Set(custom.map((binding) => binding.command)); + const retainedDefaults = DEFAULT_KEYBINDINGS.filter( + (binding) => !overriddenCommands.has(binding.command), + ); + const merged = [...retainedDefaults, ...custom]; + + if (merged.length <= 256) { + return merged; + } + + // Keep the latest rules when the config exceeds max size; later rules have higher precedence. + return merged.slice(-256); +} + function readKeybindingsConfig(logger: ReturnType): KeybindingsConfig { const configPath = path.join(os.homedir(), ".t3", "keybindings.json"); try { @@ -79,7 +104,7 @@ function readKeybindingsConfig(logger: ReturnType): Keybind logger.warn("ignoring keybindings config with unsupported format; expected array", { path: configPath, }); - return []; + return [...DEFAULT_KEYBINDINGS]; } const sanitized: KeybindingsConfig = []; @@ -88,13 +113,6 @@ function readKeybindingsConfig(logger: ReturnType): Keybind const result = keybindingRuleSchema.safeParse(entry); if (result.success) { sanitized.push(result.data); - if (sanitized.length >= 256) { - logger.warn("truncating keybindings config to max entries", { - path: configPath, - maxEntries: 256, - }); - break; - } continue; } invalidEntries += 1; @@ -106,17 +124,30 @@ function readKeybindingsConfig(logger: ReturnType): Keybind totalEntries: parsed.length, }); } - return sanitized; + const mergedBeforeCap = [ + ...DEFAULT_KEYBINDINGS.filter( + (binding) => !new Set(sanitized.map((entry) => entry.command)).has(binding.command), + ), + ...sanitized, + ]; + const merged = mergeWithDefaultKeybindings(sanitized); + if (mergedBeforeCap.length > 256) { + logger.warn("truncating merged keybindings config to max entries", { + path: configPath, + maxEntries: 256, + }); + } + return merged; } catch (error) { if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { - return []; + return [...DEFAULT_KEYBINDINGS]; } logger.warn("ignoring malformed keybindings config", { path: configPath, error: error instanceof Error ? error.message : String(error), }); } - return []; + return [...DEFAULT_KEYBINDINGS]; } export function createServer(options: ServerOptions) { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 35b99b011f143bd666da24d0e2c770f583681efd..30a039048733d95fd49f0991b5808c2636d22778 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -52,11 +52,10 @@ import type { ChatImageAttachment } from "../types"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { - DEFAULT_TERMINAL_KEYBINDINGS, isTerminalNewShortcut, isTerminalSplitShortcut, isTerminalToggleShortcut, - resolveTerminalKeybindings, + type ResolvedTerminalKeybindings, shortcutLabelForCommand, } from "../keybindings"; import ChatMarkdown from "./ChatMarkdown"; @@ -152,7 +151,7 @@ export default function ChatView() { const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); - const [terminalKeybindings, setTerminalKeybindings] = useState(DEFAULT_TERMINAL_KEYBINDINGS); + const [terminalKeybindings, setTerminalKeybindings] = useState([]); const messagesScrollRef = useRef(null); const messagesEndRef = useRef(null); const shouldAutoScrollRef = useRef(true); @@ -282,7 +281,7 @@ export default function ChatView() { useEffect(() => { if (!api || !api.server || typeof api.server.getConfig !== "function") { - setTerminalKeybindings(DEFAULT_TERMINAL_KEYBINDINGS); + setTerminalKeybindings([]); return; } @@ -292,11 +291,11 @@ export default function ChatView() { .getConfig() .then((config) => { if (disposed) return; - setTerminalKeybindings(resolveTerminalKeybindings(config.keybindings)); + setTerminalKeybindings(config.keybindings); }) .catch(() => { if (disposed) return; - setTerminalKeybindings(DEFAULT_TERMINAL_KEYBINDINGS); + setTerminalKeybindings([]); }); return () => { diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index f71cb8f09b42a4c203bf962cf37518bbd3fada2e..e6d42778c1428441d8b170112309c52141c83fe3 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1,13 +1,12 @@ import { describe, expect, it } from "vitest"; +import type { KeybindingsConfig } from "@t3tools/contracts"; import { - DEFAULT_TERMINAL_KEYBINDINGS, formatShortcutLabel, isTerminalClearShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, isTerminalToggleShortcut, - resolveTerminalKeybindings, shortcutLabelForCommand, type ShortcutEventLike, } from "./keybindings"; @@ -23,65 +22,55 @@ function event(overrides: Partial = {}): ShortcutEventLike { }; } +const DEFAULT_BINDINGS: KeybindingsConfig = [ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, +]; + describe("isTerminalToggleShortcut", () => { it("matches Cmd+J on macOS", () => { - expect( - isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { - platform: "MacIntel", - }), - ).toBe(true); + expect(isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel" })).toBe(true); }); it("matches Ctrl+J on non-macOS", () => { - expect( - isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { - platform: "Win32", - }), - ).toBe(true); + expect(isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Win32" })).toBe(true); }); }); describe("split/new terminal shortcuts", () => { it("requires terminalFocus for default split/new bindings", () => { expect( - isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { + isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", context: { terminalFocus: false }, }), ).toBe(false); expect( - isTerminalNewShortcut( - event({ key: "d", ctrlKey: true, shiftKey: true }), - DEFAULT_TERMINAL_KEYBINDINGS, - { - platform: "Linux", - context: { terminalFocus: false }, - }, - ), + isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: false }, + }), ).toBe(false); }); it("matches split/new when terminalFocus is true", () => { expect( - isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { + isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", context: { terminalFocus: true }, }), ).toBe(true); expect( - isTerminalNewShortcut( - event({ key: "d", ctrlKey: true, shiftKey: true }), - DEFAULT_TERMINAL_KEYBINDINGS, - { - platform: "Linux", - context: { terminalFocus: true }, - }, - ), + isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: true }, + }), ).toBe(true); }); - it("supports configured overrides with when expressions", () => { - const keybindings = resolveTerminalKeybindings([ + it("supports when expressions", () => { + const keybindings: KeybindingsConfig = [ { key: "mod+\\", command: "terminal.split", @@ -92,7 +81,8 @@ describe("split/new terminal shortcuts", () => { command: "terminal.new", when: "terminalOpen && !terminalFocus", }, - ]); + { key: "mod+j", command: "terminal.toggle" }, + ]; expect( isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { platform: "Win32", @@ -114,41 +104,13 @@ describe("split/new terminal shortcuts", () => { }); }); -describe("resolveTerminalKeybindings", () => { - it("uses defaults when config is empty", () => { - expect(resolveTerminalKeybindings(undefined)).toEqual(DEFAULT_TERMINAL_KEYBINDINGS); - }); - - it("replaces defaults for commands configured by user", () => { - const resolved = resolveTerminalKeybindings([ - { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, - ]); - - expect(resolved).toEqual([ - { key: "mod+j", command: "terminal.toggle" }, - { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, - { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, - ]); - }); - - it("ignores invalid configured values", () => { - expect( - resolveTerminalKeybindings([ - { key: " ", command: "terminal.toggle" }, - { key: "mod+d+f", command: "terminal.split" }, - ]), - ).toEqual(DEFAULT_TERMINAL_KEYBINDINGS); - }); -}); - describe("shortcutLabelForCommand", () => { it("returns the most recent binding label", () => { - const resolved = resolveTerminalKeybindings([ + const bindings: KeybindingsConfig = [ { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" }, - ]); - - expect(shortcutLabelForCommand(resolved, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\"); + ]; + expect(shortcutLabelForCommand(bindings, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\"); }); }); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 1a6d7b4886abfb1e036418a2251dab8347146b50..cea96b5a4ecb7b0b10d327be9f0f8c0ed2ca40ef 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -1,4 +1,4 @@ -import type { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/contracts"; +import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts"; export interface ShortcutEventLike { key: string; @@ -14,15 +14,8 @@ export interface ShortcutMatchContext { [key: string]: boolean; } -export type ResolvedTerminalKeybinding = KeybindingRule; export type ResolvedTerminalKeybindings = KeybindingsConfig; -export const DEFAULT_TERMINAL_KEYBINDINGS: ResolvedTerminalKeybindings = [ - { key: "mod+j", command: "terminal.toggle" }, - { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, - { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, -]; - interface ParsedShortcut { key: string; metaKey: boolean; @@ -306,21 +299,6 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc }; } -function normalizeConfiguredKeybinding( - keybinding: KeybindingRule, -): ResolvedTerminalKeybinding | null { - const key = keybinding.key.trim(); - if (key.length === 0) return null; - if (!parseShortcutValue(key)) return null; - - const when = keybinding.when?.trim(); - return { - key, - command: keybinding.command, - ...(when ? { when } : {}), - }; -} - function matchesCommandShortcut( event: ShortcutEventLike, keybindings: ResolvedTerminalKeybindings, @@ -340,27 +318,6 @@ function matchesCommandShortcut( return false; } -export function resolveTerminalKeybindings( - input: KeybindingsConfig | null | undefined, -): ResolvedTerminalKeybindings { - if (!input || input.length === 0) { - return [...DEFAULT_TERMINAL_KEYBINDINGS]; - } - - const normalizedInput = input - .map((binding) => normalizeConfiguredKeybinding(binding)) - .filter((binding): binding is ResolvedTerminalKeybinding => binding !== null); - if (normalizedInput.length === 0) { - return [...DEFAULT_TERMINAL_KEYBINDINGS]; - } - - const overriddenCommands = new Set(normalizedInput.map((binding) => binding.command)); - const retainedDefaults = DEFAULT_TERMINAL_KEYBINDINGS.filter( - (binding) => !overriddenCommands.has(binding.command), - ); - return [...retainedDefaults, ...normalizedInput]; -} - function formatShortcutKeyLabel(key: string): string { if (key === " ") return "Space"; if (key.length === 1) return key.toUpperCase(); @@ -413,7 +370,7 @@ export function shortcutLabelForCommand( export function isTerminalToggleShortcut( event: ShortcutEventLike, - keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, + keybindings: ResolvedTerminalKeybindings, options?: ShortcutMatchOptions, ): boolean { return matchesCommandShortcut(event, keybindings, "terminal.toggle", options); @@ -421,7 +378,7 @@ export function isTerminalToggleShortcut( export function isTerminalSplitShortcut( event: ShortcutEventLike, - keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, + keybindings: ResolvedTerminalKeybindings, options?: ShortcutMatchOptions, ): boolean { return matchesCommandShortcut(event, keybindings, "terminal.split", options); @@ -429,7 +386,7 @@ export function isTerminalSplitShortcut( export function isTerminalNewShortcut( event: ShortcutEventLike, - keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, + keybindings: ResolvedTerminalKeybindings, options?: ShortcutMatchOptions, ): boolean { return matchesCommandShortcut(event, keybindings, "terminal.new", options); -- 2.54.0 From ad6247577e9f8cf23e97caac862d0f130ac92547 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 11:34:10 -0800 Subject: [PATCH 06/40] Add source links to keybinding schema references - convert schema and server file references in `KEYBINDINGS.md` to clickable Markdown links --- KEYBINDINGS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index bec5d27dc22a301e0ef54c8c1c601fb71dae21d1..c37ad2cf0a929c19e768e43fd608fc8005cf002b 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -6,12 +6,12 @@ T3 Code reads keybindings from: Schema source of truth: -- `packages/contracts/src/keybindings.ts` -- `packages/contracts/src/server.ts` +- [`packages/contracts/src/keybindings.ts`](packages/contracts/src/keybindings.ts) +- [`packages/contracts/src/server.ts`](packages/contracts/src/server.ts) Server-side default resolution/merging: -- `apps/server/src/wsServer.ts` +- [`apps/server/src/wsServer.ts`](apps/server/src/wsServer.ts) The file must be a JSON array of rules: -- 2.54.0 From 61e9ba4b19fc4ed58bb59e23a2aaa995ee3197bd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 11:40:39 -0800 Subject: [PATCH 07/40] Load terminal keybindings via React Query server config helper - add `serverConfigQueryOptions` in new `serverReactQuery` utility - switch `ChatView` from manual `getConfig` effect/state to `useQuery` - default to empty keybindings while config is unavailable --- apps/web/src/components/ChatView.tsx | 34 +++++++--------------------- apps/web/src/lib/serverReactQuery.ts | 20 ++++++++++++++++ 2 files changed, 28 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/lib/serverReactQuery.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 30a039048733d95fd49f0991b5808c2636d22778..a11d28930946f491b34e6e6ab404189a5460bcaa 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7,6 +7,7 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ProviderApprovalDecision, type ProviderSendTurnAttachmentInput, + type KeybindingsConfig, } from "@t3tools/contracts"; import { type ClipboardEvent, @@ -26,6 +27,7 @@ import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions, } from "~/lib/gitReactQuery"; +import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; import { buildBootstrapInput } from "../historyBootstrap"; @@ -55,7 +57,6 @@ import { isTerminalNewShortcut, isTerminalSplitShortcut, isTerminalToggleShortcut, - type ResolvedTerminalKeybindings, shortcutLabelForCommand, } from "../keybindings"; import ChatMarkdown from "./ChatMarkdown"; @@ -90,6 +91,7 @@ const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; +const EMPTY_KEYBINDINGS: KeybindingsConfig = []; function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; @@ -151,7 +153,6 @@ export default function ChatView() { const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); - const [terminalKeybindings, setTerminalKeybindings] = useState([]); const messagesScrollRef = useRef(null); const messagesEndRef = useRef(null); const shouldAutoScrollRef = useRef(true); @@ -262,6 +263,11 @@ export default function ChatView() { } as const); const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); + const terminalKeybindingsQuery = useQuery({ + ...serverConfigQueryOptions(api), + select: (config) => config.keybindings, + }); + const terminalKeybindings = terminalKeybindingsQuery.data ?? EMPTY_KEYBINDINGS; // Default true while loading to avoid toolbar flicker. const isGitRepo = branchesQuery.data?.isRepo ?? true; const splitTerminalShortcutLabel = useMemo( @@ -279,30 +285,6 @@ export default function ChatView() { (activeThread.session !== null && activeThread.session.status !== "closed")), ); - useEffect(() => { - if (!api || !api.server || typeof api.server.getConfig !== "function") { - setTerminalKeybindings([]); - return; - } - - let disposed = false; - - void api.server - .getConfig() - .then((config) => { - if (disposed) return; - setTerminalKeybindings(config.keybindings); - }) - .catch(() => { - if (disposed) return; - setTerminalKeybindings([]); - }); - - return () => { - disposed = true; - }; - }, [api]); - const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { for (const image of images) { if (!image.previewUrl) continue; diff --git a/apps/web/src/lib/serverReactQuery.ts b/apps/web/src/lib/serverReactQuery.ts new file mode 100644 index 0000000000000000000000000000000000000000..2da9e3b0cd35eb6a1e193efd42aaa592445551ac --- /dev/null +++ b/apps/web/src/lib/serverReactQuery.ts @@ -0,0 +1,20 @@ +import type { NativeApi } from "@t3tools/contracts"; +import { queryOptions } from "@tanstack/react-query"; + +export const serverQueryKeys = { + all: ["server"] as const, + config: () => ["server", "config"] as const, +}; + +export function serverConfigQueryOptions(api: NativeApi | undefined) { + return queryOptions({ + queryKey: serverQueryKeys.config(), + queryFn: async () => { + if (!api || !api.server || typeof api.server.getConfig !== "function") { + throw new Error("Server config is unavailable."); + } + return api.server.getConfig(); + }, + enabled: !!api && !!api.server && typeof api.server.getConfig === "function", + }); +} -- 2.54.0 From ef4ce2e1ec3a6e08ccc392b802462519cbb706bc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 11:48:43 -0800 Subject: [PATCH 08/40] Add configurable shortcuts for new chat and open-in-editor - add `chat.new` and `editor.openFavorite` to keybinding contracts and server defaults - use shared keybinding matching in sidebar/chat view instead of hardcoded Cmd/Ctrl combos - document new commands/defaults and extend server/web keybinding tests --- KEYBINDINGS.md | 8 ++++- apps/server/src/wsServer.test.ts | 10 ++++++ apps/server/src/wsServer.ts | 2 ++ apps/web/src/components/ChatView.tsx | 52 ++++++++++++++------------- apps/web/src/components/Sidebar.tsx | 22 +++++++----- apps/web/src/keybindings.test.ts | 39 ++++++++++++++++++++ apps/web/src/keybindings.ts | 28 +++++++++++---- packages/contracts/src/keybindings.ts | 2 ++ 8 files changed, 122 insertions(+), 41 deletions(-) diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index c37ad2cf0a929c19e768e43fd608fc8005cf002b..8cae18ebc8f4cb37e931bb6e1e880bcacf40ff09 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -19,7 +19,9 @@ The file must be a JSON array of rules: [ { "key": "mod+j", "command": "terminal.toggle" }, { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, - { "key": "mod+shift+d", "command": "terminal.new", "when": "terminalFocus" } + { "key": "mod+shift+d", "command": "terminal.new", "when": "terminalFocus" }, + { "key": "mod+shift+o", "command": "chat.new" }, + { "key": "mod+o", "command": "editor.openFavorite" } ] ``` @@ -38,6 +40,8 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.toggle`: open/close terminal drawer - `terminal.split`: split terminal (in focused terminal context by default) - `terminal.new`: create new terminal (in focused terminal context by default) +- `chat.new`: create a new chat thread for the active project +- `editor.openFavorite`: open current project/worktree in the last-used editor ## Key Syntax @@ -85,6 +89,8 @@ Built-in defaults are resolved on the server and sent to the web client via `ser - `terminal.toggle`: `mod+j` - `terminal.split`: `mod+d` when `terminalFocus` - `terminal.new`: `mod+shift+d` when `terminalFocus` +- `chat.new`: `mod+shift+o` +- `editor.openFavorite`: `mod+o` If you define any rules for a command, the default rule for that command is removed on the server. diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 0b4726c51b59bdb6472403d4abcac3ce1ecb0bfa..a769193fb7f6f6eaae8d912badde10e88a5224fe 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -313,6 +313,8 @@ describe("WebSocket Server", () => { { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + { key: "mod+shift+o", command: "chat.new" }, + { key: "mod+o", command: "editor.openFavorite" }, ], }); }); @@ -347,6 +349,8 @@ describe("WebSocket Server", () => { expect(response.result).toEqual({ cwd: "/my/workspace", keybindings: [ + { key: "mod+shift+o", command: "chat.new" }, + { key: "mod+o", command: "editor.openFavorite" }, { key: "cmd+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, @@ -386,6 +390,8 @@ describe("WebSocket Server", () => { keybindings: [ { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + { key: "mod+shift+o", command: "chat.new" }, + { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+j", command: "terminal.toggle" }, ], }); @@ -426,6 +432,8 @@ describe("WebSocket Server", () => { { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + { key: "mod+shift+o", command: "chat.new" }, + { key: "mod+o", command: "editor.openFavorite" }, ], }); expect( @@ -461,6 +469,8 @@ describe("WebSocket Server", () => { { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + { key: "mod+shift+o", command: "chat.new" }, + { key: "mod+o", command: "editor.openFavorite" }, ], }); expect( diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 640b3af76c5b5fda0e5c2725946eb94e04f33bff..b22b906ae6400c730d85defaa7b4309552c336f7 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -74,6 +74,8 @@ const DEFAULT_KEYBINDINGS: KeybindingsConfig = [ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + { key: "mod+shift+o", command: "chat.new" }, + { key: "mod+o", command: "editor.openFavorite" }, ]; function mergeWithDefaultKeybindings(custom: KeybindingsConfig): KeybindingsConfig { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a11d28930946f491b34e6e6ab404189a5460bcaa..61b60f60508a22376f780800caa22d8d7901fde2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -54,6 +54,7 @@ import type { ChatImageAttachment } from "../types"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { + isOpenFavoriteEditorShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, isTerminalToggleShortcut, @@ -263,20 +264,20 @@ export default function ChatView() { } as const); const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); - const terminalKeybindingsQuery = useQuery({ + const keybindingsQuery = useQuery({ ...serverConfigQueryOptions(api), select: (config) => config.keybindings, }); - const terminalKeybindings = terminalKeybindingsQuery.data ?? EMPTY_KEYBINDINGS; + const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; // Default true while loading to avoid toolbar flicker. const isGitRepo = branchesQuery.data?.isRepo ?? true; const splitTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(terminalKeybindings, "terminal.split"), - [terminalKeybindings], + () => shortcutLabelForCommand(keybindings, "terminal.split"), + [keybindings], ); const newTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(terminalKeybindings, "terminal.new"), - [terminalKeybindings], + () => shortcutLabelForCommand(keybindings, "terminal.new"), + [keybindings], ); const envLocked = Boolean( @@ -527,14 +528,14 @@ export default function ChatView() { terminalOpen: Boolean(activeThread?.terminalOpen), }; - if (isTerminalToggleShortcut(event, terminalKeybindings, { context: shortcutContext })) { + if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) { event.preventDefault(); event.stopPropagation(); toggleTerminalVisibility(); return; } - if (isTerminalSplitShortcut(event, terminalKeybindings, { context: shortcutContext })) { + if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) { event.preventDefault(); event.stopPropagation(); if (!activeThread?.terminalOpen) { @@ -548,7 +549,7 @@ export default function ChatView() { return; } - if (!isTerminalNewShortcut(event, terminalKeybindings, { context: shortcutContext })) return; + if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return; event.preventDefault(); event.stopPropagation(); if (!activeThread?.terminalOpen) { @@ -568,7 +569,7 @@ export default function ChatView() { createNewTerminal, dispatch, splitTerminal, - terminalKeybindings, + keybindings, toggleTerminalVisibility, ]); @@ -937,7 +938,7 @@ export default function ChatView() {
{/* Open in editor */} - {activeProject && } + {activeProject && } {/* Git actions */} {activeProject && } @@ -1519,7 +1520,7 @@ function ReasoningEffortPicker(props: { ); } -function OpenInPicker() { +function OpenInPicker({ keybindings }: { keybindings: KeybindingsConfig }) { const [lastEditor, setLastEditor] = useState(() => { const stored = localStorage.getItem(LAST_EDITOR_KEY); return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; @@ -1560,20 +1561,23 @@ function OpenInPicker() { [api, activeProject, activeThread, lastEditor, setLastEditor], ); - // Cmd+O / Ctrl+O to open in last-used editor + const openFavoriteEditorShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "editor.openFavorite"), + [keybindings], + ); + useEffect(() => { const handler = (e: globalThis.KeyboardEvent) => { - if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) { - if (api && activeProject) { - e.preventDefault(); - const cwd = activeThread?.worktreePath ?? activeProject.cwd; - void api.shell.openInEditor(cwd, lastEditor); - } - } + if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; + if (!api || !activeProject) return; + + e.preventDefault(); + const cwd = activeThread?.worktreePath ?? activeProject.cwd; + void api.shell.openInEditor(cwd, lastEditor); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [api, activeProject, activeThread, lastEditor]); + }, [api, activeProject, activeThread, keybindings, lastEditor]); return ( @@ -1591,10 +1595,8 @@ function OpenInPicker() { openInEditor(value)}> ))} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e851468280cf090ff252b2633d664425556fd190..cb2c059ad83cba3f7e8c1d24676d2b08f2d33456 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,11 +1,13 @@ import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { KeybindingsConfig } from "@t3tools/contracts"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { DEFAULT_MODEL } from "../model-logic"; import { derivePendingApprovals } from "../session-logic"; import { useStore } from "../store"; +import { isChatNewShortcut } from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -14,9 +16,12 @@ import { } from "../types"; import { useNativeApi } from "../hooks/useNativeApi"; import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; +const EMPTY_KEYBINDINGS: KeybindingsConfig = []; + function formatRelativeTime(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); const minutes = Math.floor(diff / 60_000); @@ -112,6 +117,11 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null export default function Sidebar() { const { state, dispatch } = useStore(); const api = useNativeApi(); + const keybindingsQuery = useQuery({ + ...serverConfigQueryOptions(api), + select: (config) => config.keybindings, + }); + const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; const queryClient = useQueryClient(); const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ api, queryClient })); const { theme, setTheme } = useTheme(); @@ -324,13 +334,7 @@ export default function Sidebar() { useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { - const isNewThreadShortcut = - event.metaKey && - event.shiftKey && - !event.ctrlKey && - !event.altKey && - event.key.toLowerCase() === "o"; - if (!isNewThreadShortcut) return; + if (!isChatNewShortcut(event, keybindings)) return; const activeThread = state.threads.find((t) => t.id === state.activeThreadId); const projectId = activeThread?.projectId ?? state.projects[0]?.id; @@ -344,7 +348,7 @@ export default function Sidebar() { return () => { window.removeEventListener("keydown", onWindowKeyDown); }; - }, [handleNewThread, state.activeThreadId, state.projects, state.threads]); + }, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]); return (
{isGitRepo && ( - + )} {activeThread.terminalOpen && api && activeProject && ( -- 2.54.0 From 5656f294d05eaa6e1fcbe82b9749bf1af61793dc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 16:22:00 -0800 Subject: [PATCH 26/40] Simplify env mode change handler in ChatView - Remove unnecessary `useCallback` wrapping from `onEnvModeChange` - Keep env mode update and composer refocus behavior unchanged --- apps/web/src/components/ChatView.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8047803c9ccf5c38805ac2408baf921d2fb75a4c..3eaa42ff2d628175ae4f99d3b8176bc000d8493a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -910,13 +910,10 @@ export default function ChatView() { setSelectedEffort(effort); scheduleComposerFocus(); }; - const onEnvModeChange = useCallback( - (mode: "local" | "worktree") => { - setEnvMode(mode); - scheduleComposerFocus(); - }, - [scheduleComposerFocus], - ); + const onEnvModeChange = (mode: "local" | "worktree") => { + setEnvMode(mode); + scheduleComposerFocus(); + }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { -- 2.54.0 From b7fb0b8fed3c523cee60ebd90da8aa4ba5862767 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 16:23:11 -0800 Subject: [PATCH 27/40] Memoize ChatView selection handlers with useCallback - Wrap model, effort, and env mode handlers in `useCallback` - Stabilize callback identities while preserving composer focus behavior --- apps/web/src/components/ChatView.tsx | 43 +++++++++++++++++----------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3eaa42ff2d628175ae4f99d3b8176bc000d8493a..5114a24468f6291b1180b87ac2239b019a96d841 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -897,23 +897,32 @@ export default function ChatView() { } }; - const onModelSelect = (model: ModelSlug) => { - if (!activeThread) return; - dispatch({ - type: "SET_THREAD_MODEL", - threadId: activeThread.id, - model: resolveModelSlug(model), - }); - scheduleComposerFocus(); - }; - const onEffortSelect = (effort: ReasoningEffort) => { - setSelectedEffort(effort); - scheduleComposerFocus(); - }; - const onEnvModeChange = (mode: "local" | "worktree") => { - setEnvMode(mode); - scheduleComposerFocus(); - }; + const onModelSelect = useCallback( + (model: ModelSlug) => { + if (!activeThread) return; + dispatch({ + type: "SET_THREAD_MODEL", + threadId: activeThread.id, + model: resolveModelSlug(model), + }); + scheduleComposerFocus(); + }, + [activeThread, dispatch, scheduleComposerFocus], + ); + const onEffortSelect = useCallback( + (effort: ReasoningEffort) => { + setSelectedEffort(effort); + scheduleComposerFocus(); + }, + [scheduleComposerFocus], + ); + const onEnvModeChange = useCallback( + (mode: "local" | "worktree") => { + setEnvMode(mode); + scheduleComposerFocus(); + }, + [scheduleComposerFocus], + ); const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { -- 2.54.0 From be1c8c4656adad2592d718597018d0df120c58d3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 01:30:46 -0800 Subject: [PATCH 28/40] Delete projects from the sidebar context menu - Add project right-click Delete action in `Sidebar` and clean up sessions/terminals before removal - Add `DELETE_PROJECT` reducer action to remove the project, its threads, and reset active thread - Add reducer test coverage for project deletion behavior --- apps/web/src/components/Sidebar.tsx | 53 +++++++++++++++++++++++++++++ apps/web/src/store.test.ts | 45 ++++++++++++++++++++++++ apps/web/src/store.ts | 20 +++++++++++ 3 files changed, 118 insertions(+) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ce427ab462464259c2149e4d046376eef81d75ce..6f76988ca24af6fbcfe4ebf542aa1cc4b821153c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -333,6 +333,52 @@ export default function Sidebar() { [api, dispatch, removeWorktreeMutation, state.projects, state.threads], ); + const handleProjectContextMenu = useCallback( + async (projectId: string, position: { x: number; y: number }) => { + if (!api) return; + const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); + if (clicked !== "delete") return; + + const project = state.projects.find((entry) => entry.id === projectId); + if (!project) return; + + if (isElectron) { + try { + await api.projects.remove({ id: projectId }); + } catch { + return; + } + } + + const projectThreads = state.threads.filter((thread) => thread.projectId === projectId); + await Promise.all( + projectThreads.map(async (thread) => { + if (thread.session?.sessionId) { + try { + await api.providers.stopSession({ + sessionId: thread.session.sessionId, + }); + } catch { + // Session may already be stopped + } + } + + try { + await api.terminal.close({ + threadId: thread.id, + deleteHistory: true, + }); + } catch { + // Terminal may already be closed + } + }), + ); + + dispatch({ type: "DELETE_PROJECT", projectId }); + }, + [api, dispatch, state.projects, state.threads], + ); + useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { if (!isChatNewShortcut(event, keybindings)) return; @@ -413,6 +459,13 @@ export default function Sidebar() { type="button" className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-accent" onClick={() => dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })} + onContextMenu={(e) => { + e.preventDefault(); + void handleProjectContextMenu(project.id, { + x: e.clientX, + y: e.clientY, + }); + }} > {project.expanded ? "▼" : "▶"} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 176a6a73fc0d93a11d81b8586c58abe5750291b0..9bc2c3de6695057fdcf34f96e25d5d624b981b9b 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -486,6 +486,51 @@ describe("store reducer thread continuity", () => { expect(next.activeThreadId).toBe("thread-a"); }); + it("deletes a project and all of its threads", () => { + const state: AppState = { + projects: [ + { + id: "project-1", + name: "One", + cwd: "/tmp/one", + model: "gpt-5.3-codex", + expanded: true, + }, + { + id: "project-2", + name: "Two", + cwd: "/tmp/two", + model: "gpt-5.3-codex", + expanded: true, + }, + ], + threads: [ + makeThread({ + id: "thread-a", + projectId: "project-1", + }), + makeThread({ + id: "thread-b", + projectId: "project-2", + }), + ], + activeThreadId: "thread-a", + runtimeMode: "full-access", + diffOpen: false, + }; + + const next = reducer(state, { + type: "DELETE_PROJECT", + projectId: "project-1", + }); + + expect(next.projects).toHaveLength(1); + expect(next.projects[0]?.id).toBe("project-2"); + expect(next.threads).toHaveLength(1); + expect(next.threads[0]?.id).toBe("thread-b"); + expect(next.activeThreadId).toBe("thread-b"); + }); + it("marks the active thread as visited when selected", () => { const state = makeState( makeThread({ diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 3f944601b1cb61f923e671cfca63f1d3be033f09..2f0ad22b6f0d9e058b3d2420073e99a20f300ea3 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -28,6 +28,7 @@ type Action = | { type: "ADD_PROJECT"; project: Project } | { type: "SYNC_PROJECTS"; projects: Project[] } | { type: "TOGGLE_PROJECT"; projectId: string } + | { type: "DELETE_PROJECT"; projectId: string } | { type: "ADD_THREAD"; thread: Thread } | { type: "SET_ACTIVE_THREAD"; threadId: string } | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } @@ -473,6 +474,25 @@ export function reducer(state: AppState, action: Action): AppState { ), }; + case "DELETE_PROJECT": { + const projects = state.projects.filter((project) => project.id !== action.projectId); + if (projects.length === state.projects.length) { + return state; + } + + const threads = state.threads.filter((thread) => thread.projectId !== action.projectId); + const activeThreadId = threads.some((thread) => thread.id === state.activeThreadId) + ? state.activeThreadId + : (threads[0]?.id ?? null); + + return { + ...state, + projects, + threads, + activeThreadId, + }; + } + case "ADD_THREAD": { const nextThread = normalizeThreadTerminals({ ...action.thread, -- 2.54.0 From 56a09ad1aa6334101047aa1f7da9882e38b837bf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 14:19:35 -0800 Subject: [PATCH 29/40] Confirm project deletion and clean up orphaned worktrees - Prompt before deleting a project with thread and linked worktree counts - Best-effort remove orphaned worktrees when project threads are deleted - Show toast with error details if project deletion fails --- apps/web/src/components/Sidebar.tsx | 49 +++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 6f76988ca24af6fbcfe4ebf542aa1cc4b821153c..3044700b392094809049cc331475a0483b7821b6 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -17,6 +17,7 @@ import { import { useNativeApi } from "../hooks/useNativeApi"; import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { toastManager } from "./ui/toast"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; @@ -342,17 +343,59 @@ export default function Sidebar() { const project = state.projects.find((entry) => entry.id === projectId); if (!project) return; + const projectThreads = state.threads.filter((thread) => thread.projectId === projectId); + const linkedWorktreeCount = new Set( + projectThreads + .map((thread) => thread.worktreePath?.trim() ?? "") + .filter((worktreePath) => worktreePath.length > 0), + ).size; + const confirmed = await api.dialogs.confirm( + [ + `Delete project "${project.name}"?`, + `This will delete ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}.`, + linkedWorktreeCount > 0 + ? `Linked worktrees to remove: ${linkedWorktreeCount}.` + : "No linked worktrees detected.", + ].join("\n"), + ); + if (!confirmed) return; + if (isElectron) { try { await api.projects.remove({ id: projectId }); - } catch { + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error deleting project."; + console.error("Failed to remove project", { projectId, error }); + toastManager.add({ + type: "error", + title: `Failed to delete "${project.name}"`, + description: message, + }); return; } } - const projectThreads = state.threads.filter((thread) => thread.projectId === projectId); + const retainedThreads = state.threads.filter((thread) => thread.projectId !== projectId); + const removedWorktreePaths = new Set(); await Promise.all( projectThreads.map(async (thread) => { + const orphanedWorktreePath = getOrphanedWorktreePathForThread( + [thread, ...retainedThreads], + thread.id, + ); + if (orphanedWorktreePath && !removedWorktreePaths.has(orphanedWorktreePath)) { + removedWorktreePaths.add(orphanedWorktreePath); + try { + await removeWorktreeMutation.mutateAsync({ + cwd: project.cwd, + path: orphanedWorktreePath, + }); + } catch { + // Worktree deletion is best-effort and should not block project deletion. + } + } + if (thread.session?.sessionId) { try { await api.providers.stopSession({ @@ -376,7 +419,7 @@ export default function Sidebar() { dispatch({ type: "DELETE_PROJECT", projectId }); }, - [api, dispatch, state.projects, state.threads], + [api, dispatch, removeWorktreeMutation, state.projects, state.threads], ); useEffect(() => { -- 2.54.0 From 748ea2ae205122f8571287273599c5767bff1d55 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 14:28:10 -0800 Subject: [PATCH 30/40] Block deletion of projects that still have threads - Show warning toast when attempting to delete a non-empty project - Simplify confirmation copy to emphasize irreversible deletion - Remove thread/worktree/session cleanup from project delete flow --- apps/web/src/components/Sidebar.tsx | 65 +++++------------------------ 1 file changed, 11 insertions(+), 54 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 3044700b392094809049cc331475a0483b7821b6..60392d004292a620ba887a3789aa112c1bae9f23 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -344,19 +344,17 @@ export default function Sidebar() { if (!project) return; const projectThreads = state.threads.filter((thread) => thread.projectId === projectId); - const linkedWorktreeCount = new Set( - projectThreads - .map((thread) => thread.worktreePath?.trim() ?? "") - .filter((worktreePath) => worktreePath.length > 0), - ).size; + if (projectThreads.length > 0) { + toastManager.add({ + type: "warning", + title: "Project is not empty", + description: "Delete all threads in this project before deleting it.", + }); + return; + } + const confirmed = await api.dialogs.confirm( - [ - `Delete project "${project.name}"?`, - `This will delete ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}.`, - linkedWorktreeCount > 0 - ? `Linked worktrees to remove: ${linkedWorktreeCount}.` - : "No linked worktrees detected.", - ].join("\n"), + [`Delete project "${project.name}"?`, "This action cannot be undone."].join("\n"), ); if (!confirmed) return; @@ -376,50 +374,9 @@ export default function Sidebar() { } } - const retainedThreads = state.threads.filter((thread) => thread.projectId !== projectId); - const removedWorktreePaths = new Set(); - await Promise.all( - projectThreads.map(async (thread) => { - const orphanedWorktreePath = getOrphanedWorktreePathForThread( - [thread, ...retainedThreads], - thread.id, - ); - if (orphanedWorktreePath && !removedWorktreePaths.has(orphanedWorktreePath)) { - removedWorktreePaths.add(orphanedWorktreePath); - try { - await removeWorktreeMutation.mutateAsync({ - cwd: project.cwd, - path: orphanedWorktreePath, - }); - } catch { - // Worktree deletion is best-effort and should not block project deletion. - } - } - - if (thread.session?.sessionId) { - try { - await api.providers.stopSession({ - sessionId: thread.session.sessionId, - }); - } catch { - // Session may already be stopped - } - } - - try { - await api.terminal.close({ - threadId: thread.id, - deleteHistory: true, - }); - } catch { - // Terminal may already be closed - } - }), - ); - dispatch({ type: "DELETE_PROJECT", projectId }); }, - [api, dispatch, removeWorktreeMutation, state.projects, state.threads], + [api, dispatch, state.projects, state.threads], ); useEffect(() => { -- 2.54.0 From 765f4cd00bb05b4111f1279a8dac47b36665fccd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Feb 2026 16:19:51 -0800 Subject: [PATCH 31/40] Add terminal word and line navigation arrow shortcuts - handle platform-specific modifier+arrow combos in terminal input - reuse terminal write path for clear and navigation actions - add shortcut tests for macOS and non-macOS mappings --- .../src/components/ThreadTerminalDrawer.tsx | 21 +++++++-- apps/web/src/keybindings.test.ts | 47 +++++++++++++++++++ apps/web/src/keybindings.ts | 40 ++++++++++++++++ 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d9e544ab94debfb92999ead1bb34469706e2f4ee..d74ba49a0800f221529cdfa1abe582bbeafefa40 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -18,7 +18,10 @@ import { preferredTerminalEditor, resolvePathLinkTarget, } from "../terminal-links"; -import { isTerminalClearShortcut } from "../keybindings"; +import { + isTerminalClearShortcut, + terminalNavigationShortcutData, +} from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -152,24 +155,32 @@ function TerminalViewport({ terminalRef.current = terminal; fitAddonRef.current = fitAddon; - const sendClearShortcut = async () => { + const sendTerminalInput = async (data: string, fallbackError: string) => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; try { - await api.terminal.write({ threadId, terminalId, data: "\u000c" }); + await api.terminal.write({ threadId, terminalId, data }); } catch (error) { writeSystemMessage( activeTerminal, - error instanceof Error ? error.message : "Failed to clear terminal", + error instanceof Error ? error.message : fallbackError, ); } }; terminal.attachCustomKeyEventHandler((event) => { + const navigationData = terminalNavigationShortcutData(event); + if (navigationData !== null) { + event.preventDefault(); + event.stopPropagation(); + void sendTerminalInput(navigationData, "Failed to move cursor"); + return false; + } + if (!isTerminalClearShortcut(event)) return true; event.preventDefault(); event.stopPropagation(); - void sendClearShortcut(); + void sendTerminalInput("\u000c", "Failed to clear terminal"); return false; }); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 622f2dea0783313619f8b0d8ef294572fac840f2..a3c88dbd9194f0e142e04cd67f47f19f0a5f9617 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -15,6 +15,7 @@ import { isTerminalSplitShortcut, isTerminalToggleShortcut, shortcutLabelForCommand, + terminalNavigationShortcutData, type ShortcutEventLike, } from "./keybindings"; @@ -326,6 +327,52 @@ describe("isTerminalClearShortcut", () => { }); }); +describe("terminalNavigationShortcutData", () => { + it("maps Option+Arrow on macOS to word movement", () => { + assert.strictEqual( + terminalNavigationShortcutData(event({ key: "ArrowLeft", altKey: true }), "MacIntel"), + "\u001bb", + ); + assert.strictEqual( + terminalNavigationShortcutData(event({ key: "ArrowRight", altKey: true }), "MacIntel"), + "\u001bf", + ); + }); + + it("maps Cmd+Arrow on macOS to line movement", () => { + assert.strictEqual( + terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "MacIntel"), + "\u0001", + ); + assert.strictEqual( + terminalNavigationShortcutData(event({ key: "ArrowRight", metaKey: true }), "MacIntel"), + "\u0005", + ); + }); + + it("maps Ctrl+Arrow on non-macOS to word movement", () => { + assert.strictEqual( + terminalNavigationShortcutData(event({ key: "ArrowLeft", ctrlKey: true }), "Win32"), + "\u001bb", + ); + assert.strictEqual( + terminalNavigationShortcutData(event({ key: "ArrowRight", ctrlKey: true }), "Linux"), + "\u001bf", + ); + }); + + it("rejects unsupported combinations", () => { + assert.isNull( + terminalNavigationShortcutData( + event({ key: "ArrowLeft", shiftKey: true, altKey: true }), + "MacIntel", + ), + ); + assert.isNull(terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "Linux")); + assert.isNull(terminalNavigationShortcutData(event({ key: "a", altKey: true }), "MacIntel")); + }); +}); + describe("plus key parsing", () => { it("matches the plus key shortcut", () => { const plusBindings = compile([{ shortcut: modShortcut("+"), command: "terminal.toggle" }]); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 5b908344b888e33dda5914a99dfbecd579257a9f..8b7fed6e1dce039db181579df06e1a005f374aa6 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -25,6 +25,11 @@ interface ShortcutMatchOptions { context?: Partial; } +const TERMINAL_WORD_BACKWARD = "\u001bb"; +const TERMINAL_WORD_FORWARD = "\u001bf"; +const TERMINAL_LINE_START = "\u0001"; +const TERMINAL_LINE_END = "\u0005"; + function normalizeEventKey(key: string): string { const normalized = key.toLowerCase(); if (normalized === "esc") return "escape"; @@ -219,3 +224,38 @@ export function isTerminalClearShortcut( !event.shiftKey ); } + +export function terminalNavigationShortcutData( + event: ShortcutEventLike, + platform = navigator.platform, +): string | null { + if (event.shiftKey) return null; + + const key = normalizeEventKey(event.key); + if (key !== "arrowleft" && key !== "arrowright") { + return null; + } + + const moveWord = key === "arrowleft" ? TERMINAL_WORD_BACKWARD : TERMINAL_WORD_FORWARD; + const moveLine = key === "arrowleft" ? TERMINAL_LINE_START : TERMINAL_LINE_END; + + if (isMacPlatform(platform)) { + if (event.altKey && !event.metaKey && !event.ctrlKey) { + return moveWord; + } + if (event.metaKey && !event.altKey && !event.ctrlKey) { + return moveLine; + } + return null; + } + + if (event.ctrlKey && !event.metaKey && !event.altKey) { + return moveWord; + } + + if (event.altKey && !event.metaKey && !event.ctrlKey) { + return moveWord; + } + + return null; +} -- 2.54.0 From f864ef189d5def4cb2ae3f34cc461f13b3fc5a00 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 17:02:38 -0800 Subject: [PATCH 32/40] Ignore non-keydown terminal shortcuts - guard terminal clear/navigation shortcut handlers to only process `keydown` - add tests verifying `keyup` events are ignored --- apps/web/src/keybindings.test.ts | 13 +++++++++++++ apps/web/src/keybindings.ts | 9 +++++++++ 2 files changed, 22 insertions(+) diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index a3c88dbd9194f0e142e04cd67f47f19f0a5f9617..4ceb1b920ac8fc780477032c217513d3fcc37175 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -325,6 +325,10 @@ describe("isTerminalClearShortcut", () => { it("matches Cmd+K on macOS", () => { assert.isTrue(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")); }); + + it("ignores non-keydown events", () => { + assert.isFalse(isTerminalClearShortcut(event({ type: "keyup", key: "l", ctrlKey: true }), "Linux")); + }); }); describe("terminalNavigationShortcutData", () => { @@ -371,6 +375,15 @@ describe("terminalNavigationShortcutData", () => { assert.isNull(terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "Linux")); assert.isNull(terminalNavigationShortcutData(event({ key: "a", altKey: true }), "MacIntel")); }); + + it("ignores non-keydown events", () => { + assert.isNull( + terminalNavigationShortcutData( + event({ type: "keyup", key: "ArrowLeft", altKey: true }), + "MacIntel", + ), + ); + }); }); describe("plus key parsing", () => { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 8b7fed6e1dce039db181579df06e1a005f374aa6..4efcd6fee399372b7230c508e6d47e257796a031 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -7,6 +7,7 @@ import { import { isMacPlatform } from "./lib/utils"; export interface ShortcutEventLike { + type?: string; key: string; metaKey: boolean; ctrlKey: boolean; @@ -209,6 +210,10 @@ export function isTerminalClearShortcut( event: ShortcutEventLike, platform = navigator.platform, ): boolean { + if (event.type !== undefined && event.type !== "keydown") { + return false; + } + const key = event.key.toLowerCase(); if (key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { @@ -229,6 +234,10 @@ export function terminalNavigationShortcutData( event: ShortcutEventLike, platform = navigator.platform, ): string | null { + if (event.type !== undefined && event.type !== "keydown") { + return null; + } + if (event.shiftKey) return null; const key = normalizeEventKey(event.key); -- 2.54.0 From 38639010859b760c1770c717f737e5e725127f46 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 18:54:24 -0800 Subject: [PATCH 33/40] Auto-dismiss thread toasts after 10s of visible time - Add `dismissAfterVisibleMs` support to thread toasts - Pause/resume countdown based on tab visibility and window focus - Set Git action result toasts to dismiss after 10s when visible --- apps/web/src/components/GitActionsControl.tsx | 5 +- apps/web/src/components/ui/toast.tsx | 95 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 83c74e5f5807715970002a1ecfb30f2fb6d2adc7..e30ff30dfcdfc3ccb14666f7b5e7f0ee05ae9671 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -312,7 +312,10 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp title: resultToast.title, description: resultToast.description, timeout: 0, - data: threadToastData, + data: { + ...threadToastData, + dismissAfterVisibleMs: 10_000, + }, ...(shouldOfferPushCta ? { actionProps: { diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 6c9614a91a0034a10f764b873e5fd6de64b6dd67..19f9bb6b61905147b5831554d213ff5e128be775 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -1,6 +1,7 @@ "use client"; import { Toast } from "@base-ui/react/toast"; +import { useEffect } from "react"; import { CircleAlertIcon, CircleCheckIcon, @@ -16,10 +17,13 @@ import { useStore } from "~/store"; type ThreadToastData = { threadId?: string | null; tooltipStyle?: boolean; + dismissAfterVisibleMs?: number; }; const toastManager = Toast.createToastManager(); const anchoredToastManager = Toast.createToastManager(); +type ToastId = ReturnType; +const threadToastVisibleTimeoutRemainingMs = new Map(); const TOAST_ICONS = { error: CircleAlertIcon, @@ -50,6 +54,84 @@ function shouldRenderForActiveThread( return toastThreadId === activeThreadId; } +function ThreadToastVisibleAutoDismiss({ + toastId, + dismissAfterVisibleMs, +}: { + toastId: ToastId; + dismissAfterVisibleMs: number | undefined; +}) { + useEffect(() => { + if (!dismissAfterVisibleMs || dismissAfterVisibleMs <= 0) return; + if (typeof window === "undefined" || typeof document === "undefined") return; + + let remainingMs = threadToastVisibleTimeoutRemainingMs.get(toastId) ?? dismissAfterVisibleMs; + let startedAtMs: number | null = null; + let timeoutId: number | null = null; + let closed = false; + + const clearTimer = () => { + if (timeoutId === null) return; + window.clearTimeout(timeoutId); + timeoutId = null; + }; + + const closeToast = () => { + if (closed) return; + closed = true; + threadToastVisibleTimeoutRemainingMs.delete(toastId); + toastManager.close(toastId); + }; + + const pause = () => { + if (startedAtMs === null) return; + remainingMs = Math.max(0, remainingMs - (Date.now() - startedAtMs)); + startedAtMs = null; + clearTimer(); + threadToastVisibleTimeoutRemainingMs.set(toastId, remainingMs); + }; + + const start = () => { + if (closed || startedAtMs !== null) return; + if (remainingMs <= 0) { + closeToast(); + return; + } + startedAtMs = Date.now(); + clearTimer(); + timeoutId = window.setTimeout(() => { + remainingMs = 0; + startedAtMs = null; + closeToast(); + }, remainingMs); + }; + + const syncTimer = () => { + const shouldRun = document.visibilityState === "visible" && document.hasFocus(); + if (shouldRun) { + start(); + return; + } + pause(); + }; + + syncTimer(); + document.addEventListener("visibilitychange", syncTimer); + window.addEventListener("focus", syncTimer); + window.addEventListener("blur", syncTimer); + + return () => { + document.removeEventListener("visibilitychange", syncTimer); + window.removeEventListener("focus", syncTimer); + window.removeEventListener("blur", syncTimer); + pause(); + clearTimer(); + }; + }, [dismissAfterVisibleMs, toastId]); + + return null; +} + function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) { return ( @@ -65,6 +147,15 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { const activeThreadId = state.activeThreadId; const isTop = position.startsWith("top"); + useEffect(() => { + const activeToastIds = new Set(toasts.map((toast) => toast.id)); + for (const toastId of threadToastVisibleTimeoutRemainingMs.keys()) { + if (!activeToastIds.has(toastId)) { + threadToastVisibleTimeoutRemainingMs.delete(toastId); + } + } + }, [toasts]); + return ( +
{Icon && ( -- 2.54.0 From 05b47e748f6a57001b9cb6b4f99d883e05b6d171 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 19:54:27 -0800 Subject: [PATCH 34/40] Fix collapsed toast content visibility in filtered stacks - keep the front-most visible toast readable when thread filtering hides others - extract visibility rule into `toast.logic.ts` - add unit tests for collapsed-content visibility behavior --- .../web/src/components/ui/toast.logic.test.ts | 16 ++ apps/web/src/components/ui/toast.logic.ts | 9 + apps/web/src/components/ui/toast.tsx | 185 +++++++++--------- 3 files changed, 122 insertions(+), 88 deletions(-) create mode 100644 apps/web/src/components/ui/toast.logic.test.ts create mode 100644 apps/web/src/components/ui/toast.logic.ts diff --git a/apps/web/src/components/ui/toast.logic.test.ts b/apps/web/src/components/ui/toast.logic.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..def62c884fef72acc2fa2c3ba65e5339fdfeb012 --- /dev/null +++ b/apps/web/src/components/ui/toast.logic.test.ts @@ -0,0 +1,16 @@ +import { assert, describe, it } from "vitest"; +import { shouldHideCollapsedToastContent } from "./toast.logic"; + +describe("shouldHideCollapsedToastContent", () => { + it("keeps a single visible toast readable", () => { + assert.equal(shouldHideCollapsedToastContent(0, 1), false); + }); + + it("keeps the front-most toast readable in a visible stack", () => { + assert.equal(shouldHideCollapsedToastContent(0, 3), false); + }); + + it("hides non-front toasts until the stack is expanded", () => { + assert.equal(shouldHideCollapsedToastContent(1, 3), true); + }); +}); diff --git a/apps/web/src/components/ui/toast.logic.ts b/apps/web/src/components/ui/toast.logic.ts new file mode 100644 index 0000000000000000000000000000000000000000..500203033abba5f276bda21175cc8b9240dbd9d9 --- /dev/null +++ b/apps/web/src/components/ui/toast.logic.ts @@ -0,0 +1,9 @@ +export function shouldHideCollapsedToastContent( + visibleToastIndex: number, + visibleToastCount: number, +): boolean { + // Keep the front-most toast readable even if Base UI marks it as "behind" + // due to toasts hidden by thread filtering. + if (visibleToastCount <= 1) return false; + return visibleToastIndex > 0; +} diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 19f9bb6b61905147b5831554d213ff5e128be775..68bef3fbafd76efa9a558c5c26a1853cbd97eea4 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -13,6 +13,7 @@ import { import { cn } from "~/lib/utils"; import { buttonVariants } from "~/components/ui/button"; import { useStore } from "~/store"; +import { shouldHideCollapsedToastContent } from "./toast.logic"; type ThreadToastData = { threadId?: string | null; @@ -146,6 +147,9 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { const { toasts } = Toast.useToastManager(); const activeThreadId = state.activeThreadId; const isTop = position.startsWith("top"); + const visibleToasts = toasts.filter((toast) => + shouldRenderForActiveThread(toast.data, activeThreadId), + ); useEffect(() => { const activeToastIds = new Set(toasts.map((toast) => toast.id)); @@ -172,100 +176,105 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { data-position={position} data-slot="toast-viewport" > - {toasts - .filter((toast) => shouldRenderForActiveThread(toast.data, activeThreadId)) - .map((toast) => { - const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; + {visibleToasts.map((toast, visibleIndex) => { + const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; + const hideCollapsedContent = shouldHideCollapsedToastContent( + visibleIndex, + visibleToasts.length, + ); - return ( - + + - - -
- {Icon && ( -
- -
- )} - -
- - +
+ {Icon && ( +
+
+ )} + +
+ +
- {toast.actionProps && ( - - {toast.actionProps.children} - - )} - - - ); - })} +
+ {toast.actionProps && ( + + {toast.actionProps.children} + + )} + + + ); + })} ); -- 2.54.0 From 5242bbbaded1389ba117dae8089f6d7f505bc2a5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 19:57:30 -0800 Subject: [PATCH 35/40] Fix top-right toast enter and exit transforms - Add position-specific start transform for top-right toasts - Apply matching top-right end transform when dismissing without swipe --- apps/web/src/components/ui/toast.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 68bef3fbafd76efa9a558c5c26a1853cbd97eea4..7b4879e9f029b074bca0e4b887017c379f7a25c4 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -213,9 +213,11 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { // Starting and ending animations "data-[position*=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]", "data-[position*=bottom]:data-starting-style:transform-[translateY(calc(100%+var(--toast-inset)))]", + "data-[position*=top]:data-[position*=right]:data-starting-style:transform-[translateX(calc(100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", "data-ending-style:opacity-0", // Ending animations (direction-aware) "data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateY(calc(100%+var(--toast-inset)))]", + "data-[position*=top]:data-[position*=right]:data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateX(calc(100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", "data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", "data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", "data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]", -- 2.54.0 From c42d6a6a6489c89d56830cdba7fee752afef5c14 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 22:33:12 -0800 Subject: [PATCH 36/40] Add shortcuts for terminal close and chat with same Git state - Extend keybinding contracts/defaults with `terminal.close` and `chat.newSameGitState` - Wire web handlers for `mod+w` to close focused terminal and `mod+shift+n` to open a thread preserving branch/worktree - Update docs and keybinding tests across server, web, and contracts --- KEYBINDINGS.md | 4 ++ apps/server/src/keybindings.test.ts | 18 ++++++++- apps/server/src/keybindings.ts | 2 + apps/web/src/components/ChatView.tsx | 11 ++++++ apps/web/src/components/Sidebar.tsx | 29 ++++++++++---- apps/web/src/keybindings.test.ts | 45 +++++++++++++++++++++- apps/web/src/keybindings.ts | 16 ++++++++ packages/contracts/src/keybindings.test.ts | 6 +++ packages/contracts/src/keybindings.ts | 2 + 9 files changed, 123 insertions(+), 10 deletions(-) diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index e3be8baac540444f8581f49b3f4190e33b4086d5..7734d4ebe90179f5babb1724f2180587d36aadb9 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -22,7 +22,9 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+j", "command": "terminal.toggle" }, { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, + { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, { "key": "mod+shift+o", "command": "chat.new" }, + { "key": "mod+shift+n", "command": "chat.newSameGitState" }, { "key": "mod+o", "command": "editor.openFavorite" } ] ``` @@ -46,7 +48,9 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.toggle`: open/close terminal drawer - `terminal.split`: split terminal (in focused terminal context by default) - `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 for the active project +- `chat.newSameGitState`: create a new chat thread preserving the active thread's branch/worktree state - `editor.openFavorite`: open current project/worktree in the last-used editor ### Key Syntax diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index d761588e93872b3fa084d7cefde627f87a3728b5..18ecbabc03970e539c00a3e22c5652f89a6266bf 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -1,6 +1,10 @@ import { assert, describe, it } from "vitest"; -import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings"; +import { + DEFAULT_KEYBINDINGS, + compileResolvedKeybindingRule, + parseKeybindingShortcut, +} from "./keybindings"; describe("server keybindings", () => { it("parses shortcuts including plus key", () => { @@ -66,4 +70,16 @@ describe("server keybindings", () => { }), ); }); + + it("defines defaults for terminal.close and chat.newSameGitState", () => { + assert.deepInclude(DEFAULT_KEYBINDINGS, { + key: "mod+w", + command: "terminal.close", + when: "terminalFocus", + }); + assert.deepInclude(DEFAULT_KEYBINDINGS, { + key: "mod+shift+n", + command: "chat.newSameGitState", + }); + }); }); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index b9a0b4d7854f1b03b22b3c9e1eb65f51431d3222..5750b4f4bea65f97d48c2468831def0d8476b368 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -28,7 +28,9 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, + { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "mod+shift+o", command: "chat.new" }, + { key: "mod+shift+n", command: "chat.newSameGitState" }, { key: "mod+o", command: "editor.openFavorite" }, ]; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5114a24468f6291b1180b87ac2239b019a96d841..d308ec63f5d21607cdfa2be7bee2b59c09e28303 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -52,6 +52,7 @@ import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { isOpenFavoriteEditorShortcut, + isTerminalCloseShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, isTerminalToggleShortcut, @@ -554,6 +555,14 @@ export default function ChatView() { return; } + if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) { + event.preventDefault(); + event.stopPropagation(); + if (!activeThread?.terminalOpen) return; + closeTerminal(activeThread.activeTerminalId); + return; + } + if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return; event.preventDefault(); event.stopPropagation(); @@ -570,7 +579,9 @@ export default function ChatView() { return () => window.removeEventListener("keydown", handler); }, [ activeThread?.terminalOpen, + activeThread?.activeTerminalId, activeThreadId, + closeTerminal, createNewTerminal, dispatch, splitTerminal, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 60392d004292a620ba887a3789aa112c1bae9f23..2a3deef38b05c89bb07949449bb611b8140f776b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -7,7 +7,7 @@ import { useTheme } from "../hooks/useTheme"; import { DEFAULT_MODEL } from "../model-logic"; import { derivePendingApprovals } from "../session-logic"; import { useStore } from "../store"; -import { isChatNewShortcut } from "../keybindings"; +import { isChatNewSameGitStateShortcut, isChatNewShortcut } from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -140,7 +140,13 @@ export default function Sidebar() { }, [state.threads]); const handleNewThread = useCallback( - (projectId: string) => { + ( + projectId: string, + options?: { + branch?: string | null; + worktreePath?: string | null; + }, + ) => { dispatch({ type: "ADD_THREAD", thread: { @@ -166,8 +172,8 @@ export default function Sidebar() { events: [], error: null, createdAt: new Date().toISOString(), - branch: null, - worktreePath: null, + branch: options?.branch ?? null, + worktreePath: options?.worktreePath ?? null, }, }); }, @@ -381,12 +387,21 @@ export default function Sidebar() { useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { - if (!isChatNewShortcut(event, keybindings)) return; - const activeThread = state.threads.find((t) => t.id === state.activeThreadId); + if (isChatNewSameGitStateShortcut(event, keybindings)) { + const projectId = activeThread?.projectId ?? state.projects[0]?.id; + if (!projectId) return; + event.preventDefault(); + handleNewThread(projectId, { + branch: activeThread?.branch ?? null, + worktreePath: activeThread?.worktreePath ?? null, + }); + return; + } + + if (!isChatNewShortcut(event, keybindings)) return; const projectId = activeThread?.projectId ?? state.projects[0]?.id; if (!projectId) return; - event.preventDefault(); handleNewThread(projectId); }; diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 4ceb1b920ac8fc780477032c217513d3fcc37175..25df37f4316b54dd55753dcc57a91dade6c8d589 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -7,10 +7,12 @@ import { type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { + isChatNewSameGitStateShortcut, formatShortcutLabel, isChatNewShortcut, isOpenFavoriteEditorShortcut, isTerminalClearShortcut, + isTerminalCloseShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, isTerminalToggleShortcut, @@ -83,7 +85,13 @@ const DEFAULT_BINDINGS = compile([ command: "terminal.new", whenAst: whenIdentifier("terminalFocus"), }, + { + shortcut: modShortcut("w"), + command: "terminal.close", + whenAst: whenIdentifier("terminalFocus"), + }, { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, + { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newSameGitState" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, ]); @@ -101,8 +109,8 @@ describe("isTerminalToggleShortcut", () => { }); }); -describe("split/new terminal shortcuts", () => { - it("requires terminalFocus for default split/new bindings", () => { +describe("split/new/close terminal shortcuts", () => { + it("requires terminalFocus for default split/new/close bindings", () => { assert.isFalse( isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", @@ -115,6 +123,12 @@ describe("split/new terminal shortcuts", () => { context: { terminalFocus: false }, }), ); + assert.isFalse( + isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: false }, + }), + ); }); it("matches split/new when terminalFocus is true", () => { @@ -130,6 +144,12 @@ describe("split/new terminal shortcuts", () => { context: { terminalFocus: true }, }), ); + assert.isTrue( + isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: true }, + }), + ); }); it("supports when expressions", () => { @@ -225,6 +245,27 @@ describe("chat/editor shortcuts", () => { ); }); + it("matches chat.newSameGitState shortcut", () => { + assert.isTrue( + isChatNewSameGitStateShortcut( + event({ key: "n", metaKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + }, + ), + ); + assert.isTrue( + isChatNewSameGitStateShortcut( + event({ key: "n", ctrlKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "Linux", + }, + ), + ); + }); + it("matches editor.openFavorite shortcut", () => { assert.isTrue( isOpenFavoriteEditorShortcut(event({ key: "o", metaKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 4efcd6fee399372b7230c508e6d47e257796a031..070dcae67b1585f3c4b2a09287423c26c0c33306 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -190,6 +190,14 @@ export function isTerminalNewShortcut( return matchesCommandShortcut(event, keybindings, "terminal.new", options); } +export function isTerminalCloseShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "terminal.close", options); +} + export function isChatNewShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, @@ -198,6 +206,14 @@ export function isChatNewShortcut( return matchesCommandShortcut(event, keybindings, "chat.new", options); } +export function isChatNewSameGitStateShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "chat.newSameGitState", options); +} + export function isOpenFavoriteEditorShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 3427e581c2aadab9dc25f8f71edd7785c226caaa..7e782a3dbd9f40176795b832045f6d250b4e3c9b 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -14,6 +14,12 @@ describe("keybindings contracts", () => { command: "terminal.toggle", }); assert.strictEqual(parsed.command, "terminal.toggle"); + + const parsedClose = keybindingRuleSchema.parse({ + key: "mod+w", + command: "terminal.close", + }); + assert.strictEqual(parsedClose.command, "terminal.close"); }); it("rejects invalid command values", () => { diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 921fcbd1dd2bf6921117bebb42c18b33531ef7f0..62dc84062959d5a8a0e4d461b3f66502121a4cbf 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -4,7 +4,9 @@ export const keybindingCommandSchema = z.enum([ "terminal.toggle", "terminal.split", "terminal.new", + "terminal.close", "chat.new", + "chat.newSameGitState", "editor.openFavorite", ]); -- 2.54.0 From bc22a71f10acf5d56ebc6f7b13966a7fc8fdc504 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 23:02:35 -0800 Subject: [PATCH 37/40] Rename local chat shortcut and surface terminal close hints - Replace `chat.newSameGitState` with `chat.newLocal` across contracts, server/web defaults, docs, and tests - Make `chat.new` preserve branch/worktree state while `chat.newLocal` creates a local thread - Show terminal close shortcut labels in drawer action tooltips and tab close controls --- KEYBINDINGS.md | 6 +- apps/server/src/keybindings.test.ts | 4 +- apps/server/src/keybindings.ts | 2 +- apps/web/src/components/ChatView.tsx | 5 ++ apps/web/src/components/Sidebar.tsx | 14 ++-- .../src/components/ThreadTerminalDrawer.tsx | 80 ++++++++++--------- apps/web/src/keybindings.test.ts | 10 +-- apps/web/src/keybindings.ts | 4 +- packages/contracts/src/keybindings.test.ts | 6 ++ packages/contracts/src/keybindings.ts | 2 +- 10 files changed, 76 insertions(+), 57 deletions(-) diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index 7734d4ebe90179f5babb1724f2180587d36aadb9..16fce1a4d9f9016558ac4c7666b4390e68a72c87 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -24,7 +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+shift+o", "command": "chat.new" }, - { "key": "mod+shift+n", "command": "chat.newSameGitState" }, + { "key": "mod+shift+n", "command": "chat.newLocal" }, { "key": "mod+o", "command": "editor.openFavorite" } ] ``` @@ -49,8 +49,8 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.split`: split terminal (in focused terminal context by default) - `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 for the active project -- `chat.newSameGitState`: create a new chat thread preserving the active thread's branch/worktree state +- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state +- `chat.newLocal`: create a new local chat thread for the active project (no worktree context) - `editor.openFavorite`: open current project/worktree in the last-used editor ### Key Syntax diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 18ecbabc03970e539c00a3e22c5652f89a6266bf..364ebc4862cdf48d63f3a048fe0ea8460b0a7122 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -71,7 +71,7 @@ describe("server keybindings", () => { ); }); - it("defines defaults for terminal.close and chat.newSameGitState", () => { + it("defines defaults for terminal.close and chat.newLocal", () => { assert.deepInclude(DEFAULT_KEYBINDINGS, { key: "mod+w", command: "terminal.close", @@ -79,7 +79,7 @@ describe("server keybindings", () => { }); assert.deepInclude(DEFAULT_KEYBINDINGS, { key: "mod+shift+n", - command: "chat.newSameGitState", + command: "chat.newLocal", }); }); }); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 5750b4f4bea65f97d48c2468831def0d8476b368..a78e25b31b6a67d5d00e1c0b28e2064c3a67ac5a 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -30,7 +30,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "mod+shift+o", command: "chat.new" }, - { key: "mod+shift+n", command: "chat.newSameGitState" }, + { key: "mod+shift+n", command: "chat.newLocal" }, { key: "mod+o", command: "editor.openFavorite" }, ]; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d308ec63f5d21607cdfa2be7bee2b59c09e28303..ccbbd10d780745eb86650a560404ded6460a37c8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -279,6 +279,10 @@ export default function ChatView() { () => shortcutLabelForCommand(keybindings, "terminal.new"), [keybindings], ); + const closeTerminalShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "terminal.close"), + [keybindings], + ); const envLocked = Boolean( activeThread && @@ -1464,6 +1468,7 @@ export default function ChatView() { onNewTerminal={createNewTerminal} splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} newShortcutLabel={newTerminalShortcutLabel ?? undefined} + closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} onActiveTerminalChange={activateTerminal} onCloseTerminal={closeTerminal} onHeightChange={(height) => diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2a3deef38b05c89bb07949449bb611b8140f776b..2c29a6da4fef1e01c1bc0e2696def26ef5e29a8a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -7,7 +7,7 @@ import { useTheme } from "../hooks/useTheme"; import { DEFAULT_MODEL } from "../model-logic"; import { derivePendingApprovals } from "../session-logic"; import { useStore } from "../store"; -import { isChatNewSameGitStateShortcut, isChatNewShortcut } from "../keybindings"; +import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -388,14 +388,11 @@ export default function Sidebar() { useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { const activeThread = state.threads.find((t) => t.id === state.activeThreadId); - if (isChatNewSameGitStateShortcut(event, keybindings)) { + if (isChatNewLocalShortcut(event, keybindings)) { const projectId = activeThread?.projectId ?? state.projects[0]?.id; if (!projectId) return; event.preventDefault(); - handleNewThread(projectId, { - branch: activeThread?.branch ?? null, - worktreePath: activeThread?.worktreePath ?? null, - }); + handleNewThread(projectId); return; } @@ -403,7 +400,10 @@ export default function Sidebar() { const projectId = activeThread?.projectId ?? state.projects[0]?.id; if (!projectId) return; event.preventDefault(); - handleNewThread(projectId); + handleNewThread(projectId, { + branch: activeThread?.branch ?? null, + worktreePath: activeThread?.worktreePath ?? null, + }); }; window.addEventListener("keydown", onWindowKeyDown); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d74ba49a0800f221529cdfa1abe582bbeafefa40..1c35459a9b11fab296db05e6cf7485f008df48d1 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,5 +1,5 @@ import { FitAddon } from "@xterm/addon-fit"; -import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2 } from "lucide-react"; +import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; import { type NativeApi } from "@t3tools/contracts"; import { Terminal, type ITheme } from "@xterm/xterm"; import { @@ -18,10 +18,7 @@ import { preferredTerminalEditor, resolvePathLinkTarget, } from "../terminal-links"; -import { - isTerminalClearShortcut, - terminalNavigationShortcutData, -} from "../keybindings"; +import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -161,10 +158,7 @@ function TerminalViewport({ try { await api.terminal.write({ threadId, terminalId, data }); } catch (error) { - writeSystemMessage( - activeTerminal, - error instanceof Error ? error.message : fallbackError, - ); + writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError); } }; @@ -429,6 +423,7 @@ interface ThreadTerminalDrawerProps { onNewTerminal: () => void; splitShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; + closeShortcutLabel?: string | undefined; onActiveTerminalChange: (terminalId: string) => void; onCloseTerminal: (terminalId: string) => void; onHeightChange: (height: number) => void; @@ -444,15 +439,9 @@ interface TerminalActionButtonProps { function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) { return ( - - } + } > {children} @@ -483,6 +472,7 @@ export default function ThreadTerminalDrawer({ onNewTerminal, splitShortcutLabel, newShortcutLabel, + closeShortcutLabel, onActiveTerminalChange, onCloseTerminal, onHeightChange, @@ -598,6 +588,9 @@ export default function ThreadTerminalDrawer({ ), [normalizedTerminalIds], ); + const closeTerminalActionLabel = closeShortcutLabel + ? `Close Terminal (${closeShortcutLabel})` + : "Close Terminal"; useEffect(() => { onHeightChangeRef.current = onHeightChange; @@ -709,7 +702,9 @@ export default function ThreadTerminalDrawer({ @@ -725,7 +720,7 @@ export default function ThreadTerminalDrawer({ onCloseTerminal(resolvedActiveTerminalId)} - label="Close Terminal" + label={closeTerminalActionLabel} > @@ -805,16 +800,14 @@ export default function ThreadTerminalDrawer({ onCloseTerminal(resolvedActiveTerminalId)} - label="Close Terminal" + label={closeTerminalActionLabel} > @@ -852,6 +845,9 @@ export default function ThreadTerminalDrawer({ > {terminalGroup.terminalIds.map((terminalId) => { const isActive = terminalId === resolvedActiveTerminalId; + const closeTerminalLabel = `Close ${ + terminalLabelById.get(terminalId) ?? "terminal" + }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; return (
{normalizedTerminalIds.length > 1 && ( - + + onCloseTerminal(terminalId)} + aria-label={closeTerminalLabel} + /> + } + > + + + + {closeTerminalLabel} + + )}
); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 25df37f4316b54dd55753dcc57a91dade6c8d589..2a3b9c3244e4dbba13dc5252971c884c056e8324 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -7,9 +7,9 @@ import { type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { - isChatNewSameGitStateShortcut, formatShortcutLabel, isChatNewShortcut, + isChatNewLocalShortcut, isOpenFavoriteEditorShortcut, isTerminalClearShortcut, isTerminalCloseShortcut, @@ -91,7 +91,7 @@ const DEFAULT_BINDINGS = compile([ whenAst: whenIdentifier("terminalFocus"), }, { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, - { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newSameGitState" }, + { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, ]); @@ -245,9 +245,9 @@ describe("chat/editor shortcuts", () => { ); }); - it("matches chat.newSameGitState shortcut", () => { + it("matches chat.newLocal shortcut", () => { assert.isTrue( - isChatNewSameGitStateShortcut( + isChatNewLocalShortcut( event({ key: "n", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { @@ -256,7 +256,7 @@ describe("chat/editor shortcuts", () => { ), ); assert.isTrue( - isChatNewSameGitStateShortcut( + isChatNewLocalShortcut( event({ key: "n", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 070dcae67b1585f3c4b2a09287423c26c0c33306..859602e97cb6f9afb8e5a65b555cd8ebb1003f5d 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -206,12 +206,12 @@ export function isChatNewShortcut( return matchesCommandShortcut(event, keybindings, "chat.new", options); } -export function isChatNewSameGitStateShortcut( +export function isChatNewLocalShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, options?: ShortcutMatchOptions, ): boolean { - return matchesCommandShortcut(event, keybindings, "chat.newSameGitState", options); + return matchesCommandShortcut(event, keybindings, "chat.newLocal", options); } export function isOpenFavoriteEditorShortcut( diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 7e782a3dbd9f40176795b832045f6d250b4e3c9b..12726ea720b8f7e0e3cad46e9f50d49f53ea6baf 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -20,6 +20,12 @@ describe("keybindings contracts", () => { command: "terminal.close", }); assert.strictEqual(parsedClose.command, "terminal.close"); + + const parsedLocal = keybindingRuleSchema.parse({ + key: "mod+shift+n", + command: "chat.newLocal", + }); + assert.strictEqual(parsedLocal.command, "chat.newLocal"); }); it("rejects invalid command values", () => { diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 62dc84062959d5a8a0e4d461b3f66502121a4cbf..d9d8bfbf9eefd4228dfa9e8fdc1907d315c39f52 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -6,7 +6,7 @@ export const keybindingCommandSchema = z.enum([ "terminal.new", "terminal.close", "chat.new", - "chat.newSameGitState", + "chat.newLocal", "editor.openFavorite", ]); -- 2.54.0 From 228862ac564e8753e56286ee9936ce8352570768 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 23:06:28 -0800 Subject: [PATCH 38/40] doc --- KEYBINDINGS.md | 5 +++-- apps/server/src/keybindings.test.ts | 18 +----------------- apps/server/src/keybindings.ts | 5 +++-- 3 files changed, 7 insertions(+), 21 deletions(-) diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index 16fce1a4d9f9016558ac4c7666b4390e68a72c87..a44f1f5f4ff997d13508a4ed7d28a4993d7b9d19 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -23,8 +23,9 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, - { "key": "mod+shift+o", "command": "chat.new" }, - { "key": "mod+shift+n", "command": "chat.newLocal" }, + { "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" }, { "key": "mod+o", "command": "editor.openFavorite" } ] ``` diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 364ebc4862cdf48d63f3a048fe0ea8460b0a7122..d761588e93872b3fa084d7cefde627f87a3728b5 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -1,10 +1,6 @@ import { assert, describe, it } from "vitest"; -import { - DEFAULT_KEYBINDINGS, - compileResolvedKeybindingRule, - parseKeybindingShortcut, -} from "./keybindings"; +import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings"; describe("server keybindings", () => { it("parses shortcuts including plus key", () => { @@ -70,16 +66,4 @@ describe("server keybindings", () => { }), ); }); - - it("defines defaults for terminal.close and chat.newLocal", () => { - assert.deepInclude(DEFAULT_KEYBINDINGS, { - key: "mod+w", - command: "terminal.close", - when: "terminalFocus", - }); - assert.deepInclude(DEFAULT_KEYBINDINGS, { - key: "mod+shift+n", - command: "chat.newLocal", - }); - }); }); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index a78e25b31b6a67d5d00e1c0b28e2064c3a67ac5a..97e2de438ff79666a16cdbf60844a2fbbbaabd97 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -29,8 +29,9 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, - { key: "mod+shift+o", command: "chat.new" }, - { key: "mod+shift+n", command: "chat.newLocal" }, + { 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" }, { key: "mod+o", command: "editor.openFavorite" }, ]; -- 2.54.0 From 51dcb547edf34240a509ffb80f7486d382fd9445 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 16 Feb 2026 00:35:58 -0800 Subject: [PATCH 39/40] Refactor ChatView into memoized subcomponents - Extract header, error, approvals, and timeline into memoized components - Memoize `ChatMarkdown`, pickers, and handlers to reduce avoidable re-renders - Reduce running-time ticker frequency from 250ms to 1000ms --- apps/web/src/components/ChatMarkdown.tsx | 5 +- apps/web/src/components/ChatView.tsx | 741 +++++++++++++---------- 2 files changed, 432 insertions(+), 314 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 5104ddced5e66cfd9c8d77207f41406f36bdf69d..e16d09e2a3705389cb0008ad5727f25ff598f088 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,3 +1,4 @@ +import { memo } from "react"; import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import rehypeHighlight from "rehype-highlight"; @@ -13,7 +14,7 @@ const markdownComponents: Components = { }, }; -export default function ChatMarkdown({ text }: ChatMarkdownProps) { +function ChatMarkdown({ text }: ChatMarkdownProps) { return (
); } + +export default memo(ChatMarkdown); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ccbbd10d780745eb86650a560404ded6460a37c8..3a48ad40b220bcdc615f5a66dbdc05a7b74ff4d0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2,6 +2,7 @@ import { EDITORS, type EditorId, ModelSlug, + type NativeApi, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, @@ -15,6 +16,8 @@ import { type FormEvent, Fragment, type KeyboardEvent, + memo, + type RefObject, useCallback, useEffect, useLayoutEffect, @@ -40,6 +43,7 @@ import { derivePendingApprovals, derivePhase, deriveTimelineEntries, + type PendingApproval, deriveWorkLogEntries, formatDuration, formatElapsed, @@ -47,7 +51,7 @@ import { } from "../session-logic"; import { isScrollContainerNearBottom } from "../chat-scroll"; import { useStore } from "../store"; -import type { ChatImageAttachment } from "../types"; +import type { ChatImageAttachment, Thread } from "../types"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { @@ -497,7 +501,7 @@ export default function ChatView() { if (phase !== "running") return; const timer = window.setInterval(() => { setNowTick(Date.now()); - }, 250); + }, 1000); return () => { window.clearInterval(timer); }; @@ -889,28 +893,31 @@ export default function ChatView() { }); }; - const onRespondToApproval = async (requestId: string, decision: ProviderApprovalDecision) => { - if (!api || !activeThread?.session) return; + const onRespondToApproval = useCallback( + async (requestId: string, decision: ProviderApprovalDecision) => { + if (!api || !activeThread?.session) return; - setRespondingRequestIds((existing) => - existing.includes(requestId) ? existing : [...existing, requestId], - ); - try { - await api.providers.respondToRequest({ - sessionId: activeThread.session.sessionId, - requestId, - decision, - }); - } catch (err) { - dispatch({ - type: "SET_ERROR", - threadId: activeThread.id, - error: err instanceof Error ? err.message : "Failed to submit approval decision.", - }); - } finally { - setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); - } - }; + setRespondingRequestIds((existing) => + existing.includes(requestId) ? existing : [...existing, requestId], + ); + try { + await api.providers.respondToRequest({ + sessionId: activeThread.session.sessionId, + requestId, + decision, + }); + } catch (err) { + dispatch({ + type: "SET_ERROR", + threadId: activeThread.id, + error: err instanceof Error ? err.message : "Failed to submit approval decision.", + }); + } finally { + setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); + } + }, + [activeThread?.id, activeThread?.session, api, dispatch], + ); const onModelSelect = useCallback( (model: ModelSlug) => { @@ -945,6 +952,18 @@ export default function ChatView() { void onSend(e as unknown as FormEvent); } }; + const onToggleWorkGroup = useCallback((groupId: string) => { + setExpandedWorkGroups((existing) => ({ + ...existing, + [groupId]: !existing[groupId], + })); + }, []); + const onExpandTimelineImage = useCallback((image: ExpandedImagePreview) => { + setExpandedImage(image); + }, []); + const onToggleDiff = useCallback(() => { + dispatch({ type: "TOGGLE_DIFF" }); + }, [dispatch]); // Empty state: no active thread if (!activeThread) { @@ -970,101 +989,24 @@ export default function ChatView() {
-
-

{activeThread.title}

- {activeProject && {activeProject.name}} -
-
- {/* Open in editor */} - {activeProject && } - {/* Git actions */} - {activeProject && } - - {/* Diff toggle */} - -
+
{/* Error banner */} - {activeThread.error && ( -
- - - - {activeThread.error} - - -
- )} - - {pendingApprovals.length > 0 && ( -
- {pendingApprovals.map((approval) => { - const isResponding = respondingRequestIds.includes(approval.requestId); - - return ( - - - - {approval.requestKind === "command" - ? "Command approval requested" - : "File-change approval requested"} - - - {approval.detail} - - - - - - - - - ); - })} -
- )} + + {/* Messages */}
- {activeThread.messages.length === 0 && !isWorking ? ( -
-

- Send a message to start the conversation. -

-
- ) : ( -
- {timelineEntries.map((timelineEntry, index) => { - if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") { - return null; - } - - const showCompletionDivider = - timelineEntry.kind === "message" && - timelineEntry.message.role === "assistant" && - completionDividerBeforeEntryId === timelineEntry.id; - - if (timelineEntry.kind === "work") { - const groupedEntries = [timelineEntry.entry]; - let cursor = index + 1; - while (cursor < timelineEntries.length) { - const nextEntry = timelineEntries[cursor]; - if (!nextEntry || nextEntry.kind !== "work") break; - groupedEntries.push(nextEntry.entry); - cursor += 1; - } - - const groupId = timelineEntry.id; - const isExpanded = expandedWorkGroups[groupId] ?? false; - const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; - const visibleEntries = - hasOverflow && !isExpanded - ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) - : groupedEntries; - const hiddenCount = groupedEntries.length - visibleEntries.length; - const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); - const groupLabel = onlyToolEntries - ? groupedEntries.length === 1 - ? "Tool call" - : `Tool calls (${groupedEntries.length})` - : groupedEntries.length === 1 - ? "Work event" - : `Work log (${groupedEntries.length})`; - - return ( - -
-
-

- {groupLabel} -

- {hasOverflow && ( - - )} -
-
- {visibleEntries.map((workEntry) => ( -
- -

- {workEntry.detail ? ( - <> - {workEntry.label} - - {workEntry.detail} - - - ) : ( - workEntry.label - )} -

-
- ))} -
-
-
- ); - } - - if (timelineEntry.message.role === "user") { - const userImages = timelineEntry.message.attachments ?? []; - return ( - -
-
- {userImages.length > 0 && ( -
- {userImages.map((image) => ( -
- {image.previewUrl ? ( - {image.name} - setExpandedImage({ src: image.previewUrl!, name: image.name }) - } - /> - ) : ( -
- {image.name} -
- )} -
- ))} -
- )} - {timelineEntry.message.text && ( -
-                            {timelineEntry.message.text}
-                          
- )} -

- {formatTimestamp(timelineEntry.message.createdAt)} -

-
-
-
- ); - } - - return ( - - {showCompletionDivider && ( -
- - - {completionSummary ? `Response • ${completionSummary}` : "Response"} - - -
- )} -
- -

- {formatMessageMeta( - timelineEntry.message.createdAt, - timelineEntry.message.streaming - ? formatElapsed(timelineEntry.message.createdAt, nowIso) - : formatElapsed( - timelineEntry.message.createdAt, - assistantCompletionByItemId.get(timelineEntry.message.id), - ), - )} -

-
-
- ); - })} - {isWorking && ( -
- -
- - - - - -
-
- )} -
-
- )} +
{/* Input bar */} @@ -1519,7 +1282,355 @@ export default function ChatView() { ); } -function ModelPicker(props: { model: ModelSlug; onModelChange: (model: ModelSlug) => void }) { +interface ChatHeaderProps { + activeThreadTitle: string; + activeProjectName: string | undefined; + keybindings: ResolvedKeybindingsConfig; + api: NativeApi | undefined; + gitCwd: string | null; + diffOpen: boolean; + onToggleDiff: () => void; +} + +const ChatHeader = memo(function ChatHeader({ + activeThreadTitle, + activeProjectName, + keybindings, + api, + gitCwd, + diffOpen, + onToggleDiff, +}: ChatHeaderProps) { + return ( + <> +
+

{activeThreadTitle}

+ {activeProjectName && {activeProjectName}} +
+
+ {activeProjectName && } + {activeProjectName && } + +
+ + ); +}); + +const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: string | null }) { + if (!error) return null; + return ( +
+ + + + {error} + + +
+ ); +}); + +interface PendingApprovalsPanelProps { + pendingApprovals: PendingApproval[]; + respondingRequestIds: string[]; + onRespondToApproval: (requestId: string, decision: ProviderApprovalDecision) => Promise; +} + +const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ + pendingApprovals, + respondingRequestIds, + onRespondToApproval, +}: PendingApprovalsPanelProps) { + if (pendingApprovals.length === 0) return null; + return ( +
+ {pendingApprovals.map((approval) => { + const isResponding = respondingRequestIds.includes(approval.requestId); + + return ( + + + + {approval.requestKind === "command" + ? "Command approval requested" + : "File-change approval requested"} + + + {approval.detail} + + + + + + + + + ); + })} +
+ ); +}); + +interface MessagesTimelineProps { + activeThread: Thread; + isWorking: boolean; + timelineEntries: ReturnType; + completionDividerBeforeEntryId: string | null; + completionSummary: string | null; + assistantCompletionByItemId: Map; + nowIso: string; + expandedWorkGroups: Record; + onToggleWorkGroup: (groupId: string) => void; + onImageExpand: (image: ExpandedImagePreview) => void; + messagesEndRef: RefObject; +} + +const MessagesTimeline = memo(function MessagesTimeline({ + activeThread, + isWorking, + timelineEntries, + completionDividerBeforeEntryId, + completionSummary, + assistantCompletionByItemId, + nowIso, + expandedWorkGroups, + onToggleWorkGroup, + onImageExpand, + messagesEndRef, +}: MessagesTimelineProps) { + if (activeThread.messages.length === 0 && !isWorking) { + return ( +
+

Send a message to start the conversation.

+
+ ); + } + + return ( +
+ {timelineEntries.map((timelineEntry, index) => { + if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") { + return null; + } + + const showCompletionDivider = + timelineEntry.kind === "message" && + timelineEntry.message.role === "assistant" && + completionDividerBeforeEntryId === timelineEntry.id; + + if (timelineEntry.kind === "work") { + const groupedEntries = [timelineEntry.entry]; + let cursor = index + 1; + while (cursor < timelineEntries.length) { + const nextEntry = timelineEntries[cursor]; + if (!nextEntry || nextEntry.kind !== "work") break; + groupedEntries.push(nextEntry.entry); + cursor += 1; + } + + const groupId = timelineEntry.id; + const isExpanded = expandedWorkGroups[groupId] ?? false; + const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const visibleEntries = + hasOverflow && !isExpanded + ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) + : groupedEntries; + const hiddenCount = groupedEntries.length - visibleEntries.length; + const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); + const groupLabel = onlyToolEntries + ? groupedEntries.length === 1 + ? "Tool call" + : `Tool calls (${groupedEntries.length})` + : groupedEntries.length === 1 + ? "Work event" + : `Work log (${groupedEntries.length})`; + + return ( + +
+
+

+ {groupLabel} +

+ {hasOverflow && ( + + )} +
+
+ {visibleEntries.map((workEntry) => ( +
+ +

+ {workEntry.detail ? ( + <> + {workEntry.label} + + {workEntry.detail} + + + ) : ( + workEntry.label + )} +

+
+ ))} +
+
+
+ ); + } + + if (timelineEntry.message.role === "user") { + const userImages = timelineEntry.message.attachments ?? []; + return ( + +
+
+ {userImages.length > 0 && ( +
+ {userImages.map((image) => ( +
+ {image.previewUrl ? ( + {image.name} + onImageExpand({ src: image.previewUrl!, name: image.name }) + } + /> + ) : ( +
+ {image.name} +
+ )} +
+ ))} +
+ )} + {timelineEntry.message.text && ( +
+                      {timelineEntry.message.text}
+                    
+ )} +

+ {formatTimestamp(timelineEntry.message.createdAt)} +

+
+
+
+ ); + } + + return ( + + {showCompletionDivider && ( +
+ + + {completionSummary ? `Response • ${completionSummary}` : "Response"} + + +
+ )} +
+ +

+ {formatMessageMeta( + timelineEntry.message.createdAt, + timelineEntry.message.streaming + ? formatElapsed(timelineEntry.message.createdAt, nowIso) + : formatElapsed( + timelineEntry.message.createdAt, + assistantCompletionByItemId.get(timelineEntry.message.id), + ), + )} +

+
+
+ ); + })} + {isWorking && ( +
+ +
+ + + + + +
+
+ )} +
+
+ ); +}); + +const ModelPicker = memo(function ModelPicker(props: { + model: ModelSlug; + onModelChange: (model: ModelSlug) => void; +}) { return ( ); -} +}); -function ReasoningEffortPicker(props: { +const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: { effort: ReasoningEffort; onEffortChange: (effort: ReasoningEffort) => void; }) { @@ -1562,9 +1673,13 @@ function ReasoningEffortPicker(props: { ); -} +}); -function OpenInPicker({ keybindings }: { keybindings: ResolvedKeybindingsConfig }) { +const OpenInPicker = memo(function OpenInPicker({ + keybindings, +}: { + keybindings: ResolvedKeybindingsConfig; +}) { const [lastEditor, setLastEditor] = useState(() => { const stored = localStorage.getItem(LAST_EDITOR_KEY); return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; @@ -1648,4 +1763,4 @@ function OpenInPicker({ keybindings }: { keybindings: ResolvedKeybindingsConfig ); -} +}); -- 2.54.0 From 73fa2d66c79eb1351fbae55a81c61e1f7b88c619 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 16 Feb 2026 00:47:08 -0800 Subject: [PATCH 40/40] Use stable IDs for approval actions in ChatView - Derive `activeSessionId` and `activeThreadId` before approval callbacks - Avoid stale thread/session captures when submitting approval decisions - Pass `hasMessages` to `MessagesTimeline` instead of the full thread --- apps/web/src/components/ChatView.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3a48ad40b220bcdc615f5a66dbdc05a7b74ff4d0..382d30b53664880cbf5e3d48dc15a82606088085 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -51,7 +51,7 @@ import { } from "../session-logic"; import { isScrollContainerNearBottom } from "../chat-scroll"; import { useStore } from "../store"; -import type { ChatImageAttachment, Thread } from "../types"; +import type { ChatImageAttachment } from "../types"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { @@ -168,6 +168,7 @@ export default function ChatView() { const activeThread = state.threads.find((t) => t.id === state.activeThreadId); const activeThreadId = activeThread?.id ?? null; + const activeSessionId = activeThread?.session?.sessionId; const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); const selectedModel = resolveModelSlug( activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, @@ -895,28 +896,28 @@ export default function ChatView() { const onRespondToApproval = useCallback( async (requestId: string, decision: ProviderApprovalDecision) => { - if (!api || !activeThread?.session) return; + if (!api || !activeSessionId || !activeThreadId) return; setRespondingRequestIds((existing) => existing.includes(requestId) ? existing : [...existing, requestId], ); try { await api.providers.respondToRequest({ - sessionId: activeThread.session.sessionId, + sessionId: activeSessionId, requestId, decision, }); } catch (err) { dispatch({ type: "SET_ERROR", - threadId: activeThread.id, + threadId: activeThreadId, error: err instanceof Error ? err.message : "Failed to submit approval decision.", }); } finally { setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); } }, - [activeThread?.id, activeThread?.session, api, dispatch], + [activeSessionId, activeThreadId, api, dispatch], ); const onModelSelect = useCallback( @@ -1015,7 +1016,7 @@ export default function ChatView() { onScroll={onMessagesScroll} > 0} isWorking={isWorking} timelineEntries={timelineEntries} completionDividerBeforeEntryId={completionDividerBeforeEntryId} @@ -1413,7 +1414,7 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ }); interface MessagesTimelineProps { - activeThread: Thread; + hasMessages: boolean; isWorking: boolean; timelineEntries: ReturnType; completionDividerBeforeEntryId: string | null; @@ -1427,7 +1428,7 @@ interface MessagesTimelineProps { } const MessagesTimeline = memo(function MessagesTimeline({ - activeThread, + hasMessages, isWorking, timelineEntries, completionDividerBeforeEntryId, @@ -1439,7 +1440,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, messagesEndRef, }: MessagesTimelineProps) { - if (activeThread.messages.length === 0 && !isWorking) { + if (!hasMessages && !isWorking) { return (

Send a message to start the conversation.

-- 2.54.0