authorgravatar for jakeleventhal@me.comJake Leventhal <jakeleventhal@me.com> 2026-09-07 18:51:24-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 15:51:24-07:00
log09e8de9c655ae85410bf6b00446f272a01da81c7
treea37eec09e807b2ed3f1bc5f39900033ec46b4cbf
parent95834d68aa475a8afe4b2b14eaedffb35b590dab
signature Signed by PGP key B5690EEEBB952194

Add stop thread keybinding command (#4308)


8 files changed, 155 insertions(+), 17 deletions(-)

apps/web/src/components/ChatView.logic.test.ts+50
...@@ -32,6 +32,7 @@ import {...@@ -32,6 +32,7 @@ import {
32 branchMismatchKey,32 branchMismatchKey,
33 buildExpiredTerminalContextToastCopy,33 buildExpiredTerminalContextToastCopy,
34 buildLoadingThreadFromShell,34 buildLoadingThreadFromShell,
35 buildRunningThreadTurnInterruptInput,
35 buildThreadTurnInterruptInput,36 buildThreadTurnInterruptInput,
36 createLocalDispatchSnapshot,37 createLocalDispatchSnapshot,
37 deriveComposerSendState,38 deriveComposerSendState,
...@@ -906,6 +907,20 @@ describe("buildThreadTurnInterruptInput", () => {...@@ -906,6 +907,20 @@ describe("buildThreadTurnInterruptInput", () => {
906 threadId,907 threadId,
907 });908 });
908 });909 });
910
911 it("omits a turn id when a running session has not projected its active turn yet", () => {
912 expect(
913 buildThreadTurnInterruptInput(
914 makeThread({
915 session: {
916 ...readySession,
917 status: "running",
918 activeTurnId: null,
919 },
920 }),
921 ),
922 ).toEqual({ threadId });
923 });
909});924});
910925
911describe("resolveComposerProviderSelection", () => {926describe("resolveComposerProviderSelection", () => {
...@@ -1266,6 +1281,41 @@ describe("resolveComposerInteractionMode", () => {...@@ -1266,6 +1281,41 @@ describe("resolveComposerInteractionMode", () => {
1266 });1281 });
1267});1282});
12681283
1284describe("buildRunningThreadTurnInterruptInput", () => {
1285 it("targets only the active turn of a running thread", () => {
1286 const activeTurnId = TurnId.make("turn-running");
1287 const runningThread = makeThread({
1288 session: {
1289 ...readySession,
1290 status: "running",
1291 activeTurnId,
1292 },
1293 });
1294
1295 expect(buildRunningThreadTurnInterruptInput(runningThread, "running")).toEqual({
1296 threadId,
1297 turnId: activeTurnId,
1298 });
1299 expect(buildRunningThreadTurnInterruptInput(runningThread, "ready")).toBeNull();
1300 expect(
1301 buildRunningThreadTurnInterruptInput(makeThread({ session: readySession }), "ready"),
1302 ).toBeNull();
1303 expect(buildRunningThreadTurnInterruptInput(null, "disconnected")).toBeNull();
1304 });
1305
1306 it("targets a running thread before its active turn has been projected", () => {
1307 const runningThread = makeThread({
1308 session: {
1309 ...readySession,
1310 status: "running",
1311 activeTurnId: null,
1312 },
1313 });
1314
1315 expect(buildRunningThreadTurnInterruptInput(runningThread, "running")).toEqual({ threadId });
1316 });
1317});
1318
1269describe("deriveComposerSendState", () => {1319describe("deriveComposerSendState", () => {
1270 it("treats expired terminal pills as non-sendable content", () => {1320 it("treats expired terminal pills as non-sendable content", () => {
1271 const state = deriveComposerSendState({1321 const state = deriveComposerSendState({
apps/web/src/components/ChatView.logic.ts+10
...@@ -507,6 +507,16 @@ export function getAntigravitySendBlockReason(...@@ -507,6 +507,16 @@ export function getAntigravitySendBlockReason(
507 return null;507 return null;
508}508}
509509
510export function buildRunningThreadTurnInterruptInput(
511 thread: Pick<Thread, "id" | "session"> | null | undefined,
512 phase: SessionPhase,
513): { threadId: ThreadId; turnId?: TurnId } | null {
514 if (phase !== "running" || thread?.session?.status !== "running") {
515 return null;
516 }
517 return buildThreadTurnInterruptInput(thread);
518}
519
510export function reconcileMountedTerminalThreadIds(input: {520export function reconcileMountedTerminalThreadIds(input: {
511 currentThreadIds: ReadonlyArray<string>;521 currentThreadIds: ReadonlyArray<string>;
512 openThreadIds: ReadonlyArray<string>;522 openThreadIds: ReadonlyArray<string>;
apps/web/src/components/ChatView.tsx+38-15
...@@ -239,6 +239,7 @@ import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"...@@ -239,6 +239,7 @@ import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"
239import { useThreadActions } from "../hooks/useThreadActions";239import { useThreadActions } from "../hooks/useThreadActions";
240import { resolveAppModelSelectionForInstance } from "../modelSelection";240import { resolveAppModelSelectionForInstance } from "../modelSelection";
241import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm";241import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm";
242import { isPreviewFocused } from "../lib/previewFocus";
242import { getTerminalFocusOwner } from "../lib/terminalFocus";243import { getTerminalFocusOwner } from "../lib/terminalFocus";
243import {244import {
244 preventRepeatedTerminalCloseShortcut,245 preventRepeatedTerminalCloseShortcut,
...@@ -361,6 +362,7 @@ import {...@@ -361,6 +362,7 @@ import {
361 buildExpiredTerminalContextToastCopy,362 buildExpiredTerminalContextToastCopy,
362 buildLocalDraftThread,363 buildLocalDraftThread,
363 buildLoadingThreadFromShell,364 buildLoadingThreadFromShell,
365 buildRunningThreadTurnInterruptInput,
364 buildThreadTurnInterruptInput,366 buildThreadTurnInterruptInput,
365 collectUserMessageBlobPreviewUrls,367 collectUserMessageBlobPreviewUrls,
366 createLocalDispatchSnapshot,368 createLocalDispatchSnapshot,
...@@ -3410,6 +3412,27 @@ export default function ChatView(props: ChatViewProps) {...@@ -3410,6 +3412,27 @@ export default function ChatView(props: ChatViewProps) {
3410 [activeServerThread, draftId, routeThreadKey, routeThreadRef],3412 [activeServerThread, draftId, routeThreadKey, routeThreadRef],
3411 );3413 );
34123414
3415 const interruptContextRef = useRef({ activeThread, phase, setThreadError });
3416 interruptContextRef.current = { activeThread, phase, setThreadError };
3417 const onInterrupt = useCallback(async () => {
3418 const { activeThread, phase, setThreadError } = interruptContextRef.current;
3419 const input = buildRunningThreadTurnInterruptInput(activeThread, phase);
3420 if (!input || !activeThread) return;
3421 const result = await interruptThreadTurn({
3422 environmentId: activeThread.environmentId,
3423 input,
3424 });
3425 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
3426 const error = squashAtomCommandFailure(result);
3427 setThreadError(
3428 activeThread.id,
3429 error instanceof Error ? error.message : "Failed to interrupt the current turn.",
3430 );
3431 }
3432 }, [interruptThreadTurn]);
3433 const canInterruptRunningThread =
3434 buildRunningThreadTurnInterruptInput(activeThread, phase) !== null;
3435
3413 const focusComposer = useCallback(() => {3436 const focusComposer = useCallback(() => {
3414 composerRef.current?.focusAtEnd();3437 composerRef.current?.focusAtEnd();
3415 }, [composerRef]);3438 }, [composerRef]);
...@@ -5877,6 +5900,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -5877,6 +5900,8 @@ export default function ChatView(props: ChatViewProps) {
5877 const shortcutContext = {5900 const shortcutContext = {
5878 terminalFocus: terminalFocusOwner !== null,5901 terminalFocus: terminalFocusOwner !== null,
5879 terminalOpen: Boolean(terminalUiState.terminalOpen),5902 terminalOpen: Boolean(terminalUiState.terminalOpen),
5903 previewFocus: isPreviewFocused(),
5904 previewOpen: previewPanelOpen,
5880 modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,5905 modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,
5881 };5906 };
58825907
...@@ -6047,6 +6072,16 @@ export default function ChatView(props: ChatViewProps) {...@@ -6047,6 +6072,16 @@ export default function ChatView(props: ChatViewProps) {
6047 return;6072 return;
6048 }6073 }
60496074
6075 if (command === "thread.stop") {
6076 // An unavailable command should not shadow contextual shortcuts such as Escape to close a dialog.
6077 if (!canInterruptRunningThread) return;
6078 event.preventDefault();
6079 event.stopPropagation();
6080 if (event.repeat) return;
6081 void onInterrupt();
6082 return;
6083 }
6084
6050 const scriptId = projectScriptIdFromCommand(command);6085 const scriptId = projectScriptIdFromCommand(command);
6051 if (!scriptId || !activeProject) return;6086 if (!scriptId || !activeProject) return;
6052 const script = activeProjectScripts.find((entry) => entry.id === scriptId);6087 const script = activeProjectScripts.find((entry) => entry.id === scriptId);
...@@ -6065,6 +6100,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -6065,6 +6100,7 @@ export default function ChatView(props: ChatViewProps) {
6065 activeThreadRef,6100 activeThreadRef,
6066 activeThreadPinned,6101 activeThreadPinned,
6067 activeThreadSettled,6102 activeThreadSettled,
6103 canInterruptRunningThread,
6068 terminalUiState.terminalOpen,6104 terminalUiState.terminalOpen,
6069 terminalUiState.activeTerminalId,6105 terminalUiState.activeTerminalId,
6070 activeThreadId,6106 activeThreadId,
...@@ -6079,6 +6115,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -6079,6 +6115,7 @@ export default function ChatView(props: ChatViewProps) {
6079 keybindings,6115 keybindings,
6080 handleUnsettleActiveThread,6116 handleUnsettleActiveThread,
6081 isServerThread,6117 isServerThread,
6118 onInterrupt,
6082 onToggleDiff,6119 onToggleDiff,
6083 pinThread,6120 pinThread,
6084 settleThread,6121 settleThread,
...@@ -6086,6 +6123,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -6086,6 +6123,7 @@ export default function ChatView(props: ChatViewProps) {
6086 supportsSettlement,6123 supportsSettlement,
6087 confirmAndUnpinThread,6124 confirmAndUnpinThread,
6088 copyActiveThreadReference,6125 copyActiveThreadReference,
6126 previewPanelOpen,
6089 toggleRightPanel,6127 toggleRightPanel,
6090 toggleRightPanelMaximized,6128 toggleRightPanelMaximized,
6091 toggleTerminalVisibility,6129 toggleTerminalVisibility,
...@@ -6951,21 +6989,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -6951,21 +6989,6 @@ export default function ChatView(props: ChatViewProps) {
6951 }6989 }
6952 };6990 };
69536991
6954 const onInterrupt = async () => {
6955 if (!activeThread) return;
6956 const result = await interruptThreadTurn({
6957 environmentId,
6958 input: buildThreadTurnInterruptInput(activeThread),
6959 });
6960 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
6961 const error = squashAtomCommandFailure(result);
6962 setThreadError(
6963 activeThread.id,
6964 error instanceof Error ? error.message : "Failed to interrupt the current turn.",
6965 );
6966 }
6967 };
6968
6969 const onRespondToApproval = useCallback(6992 const onRespondToApproval = useCallback(
6970 async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => {6993 async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => {
6971 if (!activeThreadId) return;6994 if (!activeThreadId) return;
apps/web/src/components/settings/KeybindingsSettings.logic.test.ts+11-2
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";2import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
3import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
34
4import {5import {
5 buildKeybindingRows,6 buildKeybindingRows,
...@@ -149,7 +150,7 @@ describe("KeybindingsSettings.logic", () => {...@@ -149,7 +150,7 @@ describe("KeybindingsSettings.logic", () => {
149 expect(options).not.toContain("customModeActive");150 expect(options).not.toContain("customModeActive");
150 });151 });
151152
152 it("builds command options from built-in commands and resolved project bindings", () => {153 it("builds command options from all static commands and resolved project bindings", () => {
153 const options = buildKeybindingCommandOptions([154 const options = buildKeybindingCommandOptions([
154 {155 {
155 command: "script.setup-db.run",156 command: "script.setup-db.run",
...@@ -165,7 +166,15 @@ describe("KeybindingsSettings.logic", () => {...@@ -165,7 +166,15 @@ describe("KeybindingsSettings.logic", () => {
165 ] satisfies ResolvedKeybindingsConfig);166 ] satisfies ResolvedKeybindingsConfig);
166167
167 expect(options).toEqual(168 expect(options).toEqual(
168 expect.arrayContaining(["chat.new", "rightPanel.toggleMaximized", "script.setup-db.run"]),169 expect.arrayContaining([
170 "chat.new",
171 "rightPanel.toggleMaximized",
172 "thread.stop",
173 "script.setup-db.run",
174 ]),
175 );
176 expect(DEFAULT_RESOLVED_KEYBINDINGS.some((binding) => binding.command === "thread.stop")).toBe(
177 false,
169 );178 );
170 });179 });
171180
apps/web/src/keybindings.test.ts+36
...@@ -738,6 +738,42 @@ describe("cross-command precedence", () => {...@@ -738,6 +738,42 @@ describe("cross-command precedence", () => {
738});738});
739739
740describe("resolveShortcutCommand", () => {740describe("resolveShortcutCommand", () => {
741 it("resolves a custom stop-thread shortcut", () => {
742 const keybindings = compile([{ shortcut: modShortcut("escape"), command: "thread.stop" }]);
743
744 assert.strictEqual(
745 resolveShortcutCommand(event({ key: "Escape", metaKey: true }), keybindings, {
746 platform: "MacIntel",
747 }),
748 "thread.stop",
749 );
750 });
751
752 it("honors preview conditions for a stop-thread shortcut", () => {
753 const keybindings = compile([
754 {
755 shortcut: modShortcut("escape"),
756 command: "thread.stop",
757 whenAst: whenIdentifier("previewFocus"),
758 },
759 ]);
760 const input = event({ key: "Escape", metaKey: true });
761
762 assert.isNull(
763 resolveShortcutCommand(input, keybindings, {
764 platform: "MacIntel",
765 context: { previewFocus: false },
766 }),
767 );
768 assert.strictEqual(
769 resolveShortcutCommand(input, keybindings, {
770 platform: "MacIntel",
771 context: { previewFocus: true },
772 }),
773 "thread.stop",
774 );
775 });
776
741 it("returns dynamic script commands", () => {777 it("returns dynamic script commands", () => {
742 const keybindings = compile([{ shortcut: modShortcut("r"), command: "script.setup.run" }]);778 const keybindings = compile([{ shortcut: modShortcut("r"), command: "script.setup.run" }]);
743779
docs/user/keybindings.md+3
...@@ -53,6 +53,9 @@ a shortcut....@@ -53,6 +53,9 @@ a shortcut.
5353
54## Commands with special behavior54## Commands with special behavior
5555
56`thread.stop` interrupts the running turn in the focused thread. It has no default
57shortcut; assign one in **Settings → Keybindings**.
58
56`chat.new` may ask you to choose a project when there is more than one.59`chat.new` may ask you to choose a project when there is more than one.
57`chat.newLocal` skips that chooser. Both use your60`chat.newLocal` skips that chooser. Both use your
58[new-thread defaults](./thread-sidebar.md#start-a-thread).61[new-thread defaults](./thread-sidebar.md#start-a-thread).
packages/contracts/src/keybindings.test.ts+6
...@@ -121,6 +121,12 @@ it.effect("parses keybinding rules", () =>...@@ -121,6 +121,12 @@ it.effect("parses keybinding rules", () =>
121 when: "!terminalFocus",121 when: "!terminalFocus",
122 });122 });
123 assert.strictEqual(parsedThreadCopyReference.command, "thread.copyReference");123 assert.strictEqual(parsedThreadCopyReference.command, "thread.copyReference");
124
125 const parsedThreadStop = yield* decode(KeybindingRule, {
126 key: "mod+escape",
127 command: "thread.stop",
128 });
129 assert.strictEqual(parsedThreadStop.command, "thread.stop");
124 }),130 }),
125);131);
126132
packages/contracts/src/keybindings.ts+1
...@@ -35,6 +35,7 @@ export type ModelPickerJumpKeybindingCommand =...@@ -35,6 +35,7 @@ export type ModelPickerJumpKeybindingCommand =
35 (typeof MODEL_PICKER_JUMP_KEYBINDING_COMMANDS)[number];35 (typeof MODEL_PICKER_JUMP_KEYBINDING_COMMANDS)[number];
3636
37const THREAD_KEYBINDING_COMMANDS = [37const THREAD_KEYBINDING_COMMANDS = [
38 "thread.stop",
38 "thread.previous",39 "thread.previous",
39 "thread.next",40 "thread.next",
40 "thread.copyReference",41 "thread.copyReference",