From aedc760b1ee08852d8f2c6176c5f7624c0284990 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 13 Aug 2026 09:54:00 +0200 Subject: [PATCH 1/2] feat(web): add settle thread shortcut --- apps/server/src/keybindings.test.ts | 1 + apps/web/src/keybindings.test.ts | 25 ++++++++++++++ apps/web/src/routes/_chat.tsx | 40 +++++++++++++++++++++- docs/user/keybindings.md | 2 ++ packages/contracts/src/keybindings.test.ts | 6 ++++ packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 7 files changed, 75 insertions(+), 1 deletion(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index b674688f0410..cf78de9540f2 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -201,6 +201,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("themeEditor.toggle"), "mod+alt+shift+t"); assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p"); assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); + assert.equal(defaultsByCommand.get("thread.settle"), "mod+e"); assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 11aa97dc8e86..62ddeea11841 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -140,6 +140,11 @@ const DEFAULT_BINDINGS = compile([ { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, + { + shortcut: modShortcut("e"), + command: "thread.settle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" }, { shortcut: modShortcut("1"), command: "thread.jump.1" }, @@ -365,6 +370,10 @@ describe("shortcutLabelForCommand", () => { shortcutLabelForCommand(DEFAULT_BINDINGS, "thread.previous", "Linux"), "Ctrl+Shift+[", ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "thread.settle", "MacIntel"), + "⌘E", + ); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.jump.3", { platform: "MacIntel", @@ -480,6 +489,22 @@ describe("model picker navigation helpers", () => { }); describe("chat/editor shortcuts", () => { + it("matches thread.settle outside terminal focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "e", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "thread.settle", + ); + assert.isNull( + resolveShortcutCommand(event({ key: "e", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: true }, + }), + ); + }); + it("matches chat.new shortcut", () => { assert.isTrue( isChatNewShortcut(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index e084e22c2cbb..df57b1aa3b6a 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,6 +1,11 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; -import { useEffect, useMemo } from "react"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useEffect, useMemo, useRef } from "react"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings"; @@ -11,6 +16,7 @@ import { selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useThreadActions } from "../hooks/useThreadActions"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; @@ -27,6 +33,8 @@ function ChatRouteGlobalShortcuts() { const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); + const { settleThread } = useThreadActions(); + const settlingThreadKeysRef = useRef(new Set()); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const legacySidebarEnabled = useLegacySidebarEnabled(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -108,6 +116,35 @@ function ChatRouteGlobalShortcuts() { return; } + if (command === "thread.settle") { + if (routeThreadRef === null) return; + event.preventDefault(); + event.stopPropagation(); + if (event.repeat) return; + + const threadKey = scopedThreadKey(routeThreadRef); + if (settlingThreadKeysRef.current.has(threadKey)) return; + settlingThreadKeysRef.current.add(threadKey); + void (async () => { + try { + const result = await settleThread(routeThreadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + } finally { + settlingThreadKeysRef.current.delete(threadKey); + } + })(); + return; + } + if (command === "preview.toggle") { event.preventDefault(); event.stopPropagation(); @@ -167,6 +204,7 @@ function ChatRouteGlobalShortcuts() { projectGroupCount, routeThreadRef, selectedThreadKeysSize, + settleThread, legacySidebarEnabled, terminalOpen, ]); diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index f7f6facbe594..f87d80b57474 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -37,6 +37,8 @@ Examples: `mod+j`, `mod+shift+d`, `ctrl+l`, `cmd+k`. Commands are IDs like `terminal.toggle`, `commandPalette.toggle`, `preview.refresh`, and `chat.new`. Project scripts are addressable as `script.{id}.run`, for example `script.test.run`. +`thread.settle` settles the currently open thread and defaults to `mod+e` outside terminal focus. + `filePicker.toggle` opens file search for the active project and defaults to `mod+p`. `projectSearch.toggle` searches inside the active project's files and defaults to `mod+shift+f`. Repeating either shortcut closes that search, and switching shortcuts replaces the open search. diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 342e44678938..b7059aa43d59 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -101,6 +101,12 @@ it.effect("parses keybinding rules", () => command: "thread.previous", }); assert.strictEqual(parsedThreadPrevious.command, "thread.previous"); + + const parsedThreadSettle = yield* decode(KeybindingRule, { + key: "mod+e", + command: "thread.settle", + }); + assert.strictEqual(parsedThreadSettle.command, "thread.settle"); }), ); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 3fcbf6ef5fdb..c28246a8428e 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -35,6 +35,7 @@ export type ModelPickerJumpKeybindingCommand = (typeof MODEL_PICKER_JUMP_KEYBINDING_COMMANDS)[number]; export const THREAD_KEYBINDING_COMMANDS = [ + "thread.settle", "thread.previous", "thread.next", ...THREAD_JUMP_KEYBINDING_COMMANDS, diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 158a9ffb1ac9..6a402d0a9817 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -44,6 +44,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, + { key: "mod+e", command: "thread.settle", when: "!terminalFocus" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ From edb01daf122f88797583dd79c9bd7bf823a42ca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Slov=C3=A1k?= Date: Tue, 18 Aug 2026 10:52:58 +0200 Subject: [PATCH 2/2] fix(web): claim settle shortcut without active thread --- apps/web/src/lib/settleThreadShortcut.test.ts | 44 +++++++++++++++++++ apps/web/src/lib/settleThreadShortcut.ts | 17 +++++++ apps/web/src/routes/_chat.tsx | 11 +++-- 3 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/lib/settleThreadShortcut.test.ts create mode 100644 apps/web/src/lib/settleThreadShortcut.ts diff --git a/apps/web/src/lib/settleThreadShortcut.test.ts b/apps/web/src/lib/settleThreadShortcut.test.ts new file mode 100644 index 000000000000..94343a36dd22 --- /dev/null +++ b/apps/web/src/lib/settleThreadShortcut.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { claimSettleThreadShortcut } from "./settleThreadShortcut"; + +const routeThreadRef = { + environmentId: EnvironmentId.make("environment-id"), + threadId: ThreadId.make("thread-id"), +}; + +function shortcutEvent(repeat = false) { + return { + repeat, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; +} + +describe("settle thread shortcut", () => { + it("claims the shortcut when there is no route thread", () => { + const event = shortcutEvent(); + + expect(claimSettleThreadShortcut(event, null)).toBeNull(); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(event.stopPropagation).toHaveBeenCalledOnce(); + }); + + it("returns the route thread for a non-repeated shortcut", () => { + const event = shortcutEvent(); + + expect(claimSettleThreadShortcut(event, routeThreadRef)).toBe(routeThreadRef); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(event.stopPropagation).toHaveBeenCalledOnce(); + }); + + it("claims repeated shortcuts without returning the route thread", () => { + const event = shortcutEvent(true); + + expect(claimSettleThreadShortcut(event, routeThreadRef)).toBeNull(); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(event.stopPropagation).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/lib/settleThreadShortcut.ts b/apps/web/src/lib/settleThreadShortcut.ts new file mode 100644 index 000000000000..4b3ee735df6c --- /dev/null +++ b/apps/web/src/lib/settleThreadShortcut.ts @@ -0,0 +1,17 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +export interface SettleThreadShortcutEvent { + readonly repeat: boolean; + readonly preventDefault: () => void; + readonly stopPropagation: () => void; +} + +export function claimSettleThreadShortcut( + event: SettleThreadShortcutEvent, + routeThreadRef: ScopedThreadRef | null, +): ScopedThreadRef | null { + event.preventDefault(); + event.stopPropagation(); + if (event.repeat) return null; + return routeThreadRef; +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index df57b1aa3b6a..2f65e1261c83 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -19,6 +19,7 @@ import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useThreadActions } from "../hooks/useThreadActions"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { isPreviewFocused } from "../lib/previewFocus"; +import { claimSettleThreadShortcut } from "../lib/settleThreadShortcut"; import { isTerminalFocused } from "../lib/terminalFocus"; import { resolveShortcutCommand } from "../keybindings"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; @@ -117,17 +118,15 @@ function ChatRouteGlobalShortcuts() { } if (command === "thread.settle") { - if (routeThreadRef === null) return; - event.preventDefault(); - event.stopPropagation(); - if (event.repeat) return; + const threadRef = claimSettleThreadShortcut(event, routeThreadRef); + if (threadRef === null) return; - const threadKey = scopedThreadKey(routeThreadRef); + const threadKey = scopedThreadKey(threadRef); if (settlingThreadKeysRef.current.has(threadKey)) return; settlingThreadKeysRef.current.add(threadKey); void (async () => { try { - const result = await settleThread(routeThreadRef); + const result = await settleThread(threadRef); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add(