From d1cb618ef66ed04df53555b7bdcf4342ffbe8614 Mon Sep 17 00:00:00 2001 From: David Durika Date: Mon, 10 Aug 2026 08:46:36 +0200 Subject: [PATCH 1/2] feat(web): stop the running turn from the keyboard via chat.interrupt --- apps/web/src/components/ChatView.tsx | 41 ++++++++++++------- .../KeybindingsSettings.logic.test.ts | 4 +- .../settings/KeybindingsSettings.logic.ts | 5 ++- docs/user/keybindings.md | 5 +++ packages/contracts/src/keybindings.test.ts | 6 +++ packages/contracts/src/keybindings.ts | 3 +- 6 files changed, 46 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 880bf8a507fe..fea8777f04c7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4310,6 +4310,20 @@ function ChatViewContent(props: ChatViewProps) { } } }, [activeThread, environmentId, interruptThreadTurn, setThreadError]); + const onInterrupt = useCallback(async () => { + if (!activeThread) return; + const result = await interruptThreadTurn({ + environmentId, + input: buildThreadTurnInterruptInput(activeThread), + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThread.id, + error instanceof Error ? error.message : "Failed to interrupt the current turn.", + ); + } + }, [activeThread, environmentId, interruptThreadTurn, setThreadError]); const backgroundLivenessBannerItem = useMemo(() => { if (activeBackgroundLiveness === null || !activeThread) { return null; @@ -4692,6 +4706,16 @@ function ChatViewContent(props: ChatViewProps) { return; } + if (command === "chat.interrupt") { + // Claims the key only while a turn is running, so a binding like + // plain Escape keeps its usual meaning the rest of the time. + if (phase !== "running") return; + event.preventDefault(); + event.stopPropagation(); + void onInterrupt(); + return; + } + const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; const script = activeProject.scripts.find((entry) => entry.id === scriptId); @@ -4717,7 +4741,9 @@ function ChatViewContent(props: ChatViewProps) { splitTerminal, splitPanelTerminal, keybindings, + onInterrupt, onToggleDiff, + phase, toggleRightPanel, toggleTerminalVisibility, composerRef, @@ -5236,21 +5262,6 @@ function ChatViewContent(props: ChatViewProps) { } }; - const onInterrupt = async () => { - if (!activeThread) return; - const result = await interruptThreadTurn({ - environmentId, - input: buildThreadTurnInterruptInput(activeThread), - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThread.id, - error instanceof Error ? error.message : "Failed to interrupt the current turn.", - ); - } - }; - const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { if (!activeThreadId) return; diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 22a91b7c1504..2660ea7d7e99 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -150,7 +150,9 @@ describe("KeybindingsSettings.logic", () => { }, ] satisfies ResolvedKeybindingsConfig); - expect(options).toEqual(expect.arrayContaining(["chat.new", "script.setup-db.run"])); + expect(options).toEqual( + expect.arrayContaining(["chat.new", "chat.interrupt", "script.setup-db.run"]), + ); }); it("reports unknown when variables without rejecting parseable expressions", () => { diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index da54e86e42e1..e94031255fed 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -4,6 +4,7 @@ import { type KeybindingWhenNode, type ResolvedKeybindingRule, type ResolvedKeybindingsConfig, + STATIC_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; import { DEFAULT_RESOLVED_KEYBINDINGS, @@ -255,7 +256,9 @@ export function buildWhenVariableOptions(): ReadonlyArray { export function buildKeybindingCommandOptions( keybindings: ResolvedKeybindingsConfig, ): ReadonlyArray { - const commands = new Set(); + // Static commands are listed directly so commands that ship without a + // default binding (like chat.interrupt) stay bindable from the UI. + const commands = new Set(STATIC_KEYBINDING_COMMANDS); for (const binding of DEFAULT_RESOLVED_KEYBINDINGS) { commands.add(binding.command); } diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index f7f6facbe594..ccd928238323 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -47,6 +47,11 @@ Use **Inspect** to pick an element in the app and reveal its color token. Inspec successful pick; its hover glow and badge preview the element and token that click will select. **Cancel** or `Escape` exits Inspect and clears its selection and spotlight. +`chat.interrupt` stops the active thread's running turn, same as the composer's stop button. It +ships without a default shortcut; bind one in **Settings** → **Keybindings**. The shortcut only +takes effect while a turn is running, so a key you bind keeps its normal behavior the rest of the +time. + The command palette searches active thread titles, projects, branches, user messages, and final agent responses across connected environments. Message matches show one labeled excerpt while keeping the thread's project, branch, and machine context visible. Message search begins after two diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 342e44678938..2447baa8b0e0 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -84,6 +84,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedLocal.command, "chat.newLocal"); + const parsedInterrupt = yield* decode(KeybindingRule, { + key: "mod+shift+c", + command: "chat.interrupt", + }); + assert.strictEqual(parsedInterrupt.command, "chat.interrupt"); + const parsedModelPickerToggle = yield* decode(KeybindingRule, { key: "mod+shift+m", command: "modelPicker.toggle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 3fcbf6ef5fdb..50e174c053e1 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -47,7 +47,7 @@ export const MODEL_PICKER_KEYBINDING_COMMANDS = [ ] as const; export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number]; -const STATIC_KEYBINDING_COMMANDS = [ +export const STATIC_KEYBINDING_COMMANDS = [ "sidebar.toggle", "terminal.toggle", "terminal.split", @@ -69,6 +69,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "composer.stash", "chat.new", "chat.newLocal", + "chat.interrupt", "editor.openFavorite", ...MODEL_PICKER_KEYBINDING_COMMANDS, ...THREAD_KEYBINDING_COMMANDS, From 1535189be6be47f117730be3e15869930b08f4b1 Mon Sep 17 00:00:00 2001 From: David Durika Date: Mon, 10 Aug 2026 09:32:17 +0200 Subject: [PATCH 2/2] fix(web): chat.interrupt outranks type-to-focus while a turn runs --- apps/web/src/components/ChatView.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fea8777f04c7..07e938b0e35c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4607,7 +4607,18 @@ function ChatViewContent(props: ChatViewProps) { modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, }; + const command = resolveShortcutCommand(event, keybindings, { + context: shortcutContext, + }); + + // chat.interrupt outranks type-to-focus while a turn is running: a + // printable-key binding would otherwise be typed into the composer + // instead of stopping the turn. When idle, the key keeps its usual + // meaning, so bindings like plain Escape stay safe. + const interruptClaimsKey = command === "chat.interrupt" && phase === "running"; + if ( + !interruptClaimsKey && !shortcutContext.terminalFocus && !shortcutContext.modelPickerOpen && shouldTypeToFocusComposer(event) @@ -4619,9 +4630,6 @@ function ChatViewContent(props: ChatViewProps) { } } - const command = resolveShortcutCommand(event, keybindings, { - context: shortcutContext, - }); if (!command) return; if (command === "terminal.toggle") { @@ -4707,9 +4715,7 @@ function ChatViewContent(props: ChatViewProps) { } if (command === "chat.interrupt") { - // Claims the key only while a turn is running, so a binding like - // plain Escape keeps its usual meaning the rest of the time. - if (phase !== "running") return; + if (!interruptClaimsKey) return; event.preventDefault(); event.stopPropagation(); void onInterrupt();