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 {
3232 branchMismatchKey,
3333 buildExpiredTerminalContextToastCopy,
3434 buildLoadingThreadFromShell,
35 buildRunningThreadTurnInterruptInput,
3536 buildThreadTurnInterruptInput,
3637 createLocalDispatchSnapshot,
3738 deriveComposerSendState,
......@@ -906,6 +907,20 @@ describe("buildThreadTurnInterruptInput", () => {
906907 threadId,
907908 });
908909 });
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 });
909924});
910925
911926describe("resolveComposerProviderSelection", () => {
......@@ -1266,6 +1281,41 @@ describe("resolveComposerInteractionMode", () => {
12661281 });
12671282});
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
12691319describe("deriveComposerSendState", () => {
12701320 it("treats expired terminal pills as non-sendable content", () => {
12711321 const state = deriveComposerSendState({
apps/web/src/components/ChatView.logic.ts+10
......@@ -507,6 +507,16 @@ export function getAntigravitySendBlockReason(
507507 return null;
508508}
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
510520export function reconcileMountedTerminalThreadIds(input: {
511521 currentThreadIds: ReadonlyArray<string>;
512522 openThreadIds: ReadonlyArray<string>;
apps/web/src/components/ChatView.tsx+38-15
......@@ -239,6 +239,7 @@ import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"
239239import { useThreadActions } from "../hooks/useThreadActions";
240240import { resolveAppModelSelectionForInstance } from "../modelSelection";
241241import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm";
242import { isPreviewFocused } from "../lib/previewFocus";
242243import { getTerminalFocusOwner } from "../lib/terminalFocus";
243244import {
244245 preventRepeatedTerminalCloseShortcut,
......@@ -361,6 +362,7 @@ import {
361362 buildExpiredTerminalContextToastCopy,
362363 buildLocalDraftThread,
363364 buildLoadingThreadFromShell,
365 buildRunningThreadTurnInterruptInput,
364366 buildThreadTurnInterruptInput,
365367 collectUserMessageBlobPreviewUrls,
366368 createLocalDispatchSnapshot,
......@@ -3410,6 +3412,27 @@ export default function ChatView(props: ChatViewProps) {
34103412 [activeServerThread, draftId, routeThreadKey, routeThreadRef],
34113413 );
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
34133436 const focusComposer = useCallback(() => {
34143437 composerRef.current?.focusAtEnd();
34153438 }, [composerRef]);
......@@ -5877,6 +5900,8 @@ export default function ChatView(props: ChatViewProps) {
58775900 const shortcutContext = {
58785901 terminalFocus: terminalFocusOwner !== null,
58795902 terminalOpen: Boolean(terminalUiState.terminalOpen),
5903 previewFocus: isPreviewFocused(),
5904 previewOpen: previewPanelOpen,
58805905 modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,
58815906 };
58825907
......@@ -6047,6 +6072,16 @@ export default function ChatView(props: ChatViewProps) {
60476072 return;
60486073 }
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
60506085 const scriptId = projectScriptIdFromCommand(command);
60516086 if (!scriptId || !activeProject) return;
60526087 const script = activeProjectScripts.find((entry) => entry.id === scriptId);
......@@ -6065,6 +6100,7 @@ export default function ChatView(props: ChatViewProps) {
60656100 activeThreadRef,
60666101 activeThreadPinned,
60676102 activeThreadSettled,
6103 canInterruptRunningThread,
60686104 terminalUiState.terminalOpen,
60696105 terminalUiState.activeTerminalId,
60706106 activeThreadId,
......@@ -6079,6 +6115,7 @@ export default function ChatView(props: ChatViewProps) {
60796115 keybindings,
60806116 handleUnsettleActiveThread,
60816117 isServerThread,
6118 onInterrupt,
60826119 onToggleDiff,
60836120 pinThread,
60846121 settleThread,
......@@ -6086,6 +6123,7 @@ export default function ChatView(props: ChatViewProps) {
60866123 supportsSettlement,
60876124 confirmAndUnpinThread,
60886125 copyActiveThreadReference,
6126 previewPanelOpen,
60896127 toggleRightPanel,
60906128 toggleRightPanelMaximized,
60916129 toggleTerminalVisibility,
......@@ -6951,21 +6989,6 @@ export default function ChatView(props: ChatViewProps) {
69516989 }
69526990 };
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
69696992 const onRespondToApproval = useCallback(
69706993 async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => {
69716994 if (!activeThreadId) return;
apps/web/src/components/settings/KeybindingsSettings.logic.test.ts+11-2
......@@ -1,5 +1,6 @@
11import { describe, expect, it } from "vite-plus/test";
22import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
3import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
34
45import {
56 buildKeybindingRows,
......@@ -149,7 +150,7 @@ describe("KeybindingsSettings.logic", () => {
149150 expect(options).not.toContain("customModeActive");
150151 });
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", () => {
153154 const options = buildKeybindingCommandOptions([
154155 {
155156 command: "script.setup-db.run",
......@@ -165,7 +166,15 @@ describe("KeybindingsSettings.logic", () => {
165166 ] satisfies ResolvedKeybindingsConfig);
166167
167168 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,
169178 );
170179 });
171180
apps/web/src/keybindings.test.ts+36
......@@ -738,6 +738,42 @@ describe("cross-command precedence", () => {
738738});
739739
740740describe("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
741777 it("returns dynamic script commands", () => {
742778 const keybindings = compile([{ shortcut: modShortcut("r"), command: "script.setup.run" }]);
743779
docs/user/keybindings.md+3
......@@ -53,6 +53,9 @@ a shortcut.
5353
5454## 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
5659`chat.new` may ask you to choose a project when there is more than one.
5760`chat.newLocal` skips that chooser. Both use your
5861[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", () =>
121121 when: "!terminalFocus",
122122 });
123123 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");
124130 }),
125131);
126132
packages/contracts/src/keybindings.ts+1
......@@ -35,6 +35,7 @@ export type ModelPickerJumpKeybindingCommand =
3535 (typeof MODEL_PICKER_JUMP_KEYBINDING_COMMANDS)[number];
3636
3737const THREAD_KEYBINDING_COMMANDS = [
38 "thread.stop",
3839 "thread.previous",
3940 "thread.next",
4041 "thread.copyReference",