| author | |
| committer | |
| log | 09e8de9c655ae85410bf6b00446f272a01da81c7 |
| tree | a37eec09e807b2ed3f1bc5f39900033ec46b4cbf |
| parent | 95834d68aa475a8afe4b2b14eaedffb35b590dab |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | }); |
| 910 | 925 | ||
| 911 | describe("resolveComposerProviderSelection", () => { | 926 | describe("resolveComposerProviderSelection", () => { |
| ... | @@ -1266,6 +1281,41 @@ describe("resolveComposerInteractionMode", () => { | ... | @@ -1266,6 +1281,41 @@ describe("resolveComposerInteractionMode", () => { |
| 1266 | }); | 1281 | }); |
| 1267 | }); | 1282 | }); |
| 1268 | 1283 | ||
| 1284 | describe("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 | |||
| 1269 | describe("deriveComposerSendState", () => { | 1319 | describe("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 | } |
| 509 | 509 | ||
| 510 | export 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 | |||
| 510 | export function reconcileMountedTerminalThreadIds(input: { | 520 | export 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" |
| 239 | import { useThreadActions } from "../hooks/useThreadActions"; | 239 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 240 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; | 240 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; |
| 241 | import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; | 241 | import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; |
| 242 | import { isPreviewFocused } from "../lib/previewFocus"; | ||
| 242 | import { getTerminalFocusOwner } from "../lib/terminalFocus"; | 243 | import { getTerminalFocusOwner } from "../lib/terminalFocus"; |
| 243 | import { | 244 | import { |
| 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 | ); |
| 3412 | 3414 | ||
| 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 | }; |
| 5882 | 5907 | ||
| ... | @@ -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 | } |
| 6049 | 6074 | ||
| 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 | }; |
| 6953 | 6991 | ||
| 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; | 2 | import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; |
| 3 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; | ||
| 3 | 4 | ||
| 4 | import { | 5 | import { |
| 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 | }); |
| 151 | 152 | ||
| 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); |
| 166 | 167 | ||
| 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 | }); |
| 171 | 180 |
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 | }); |
| 739 | 739 | ||
| 740 | describe("resolveShortcutCommand", () => { | 740 | describe("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" }]); |
| 743 | 779 |
docs/user/keybindings.md+3| ... | @@ -53,6 +53,9 @@ a shortcut. | ... | @@ -53,6 +53,9 @@ a shortcut. |
| 53 | 53 | ||
| 54 | ## Commands with special behavior | 54 | ## Commands with special behavior |
| 55 | 55 | ||
| 56 | `thread.stop` interrupts the running turn in the focused thread. It has no default | ||
| 57 | shortcut; 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 your | 60 | `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 | ); |
| 126 | 132 |
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]; |
| 36 | 36 | ||
| 37 | const THREAD_KEYBINDING_COMMANDS = [ | 37 | const THREAD_KEYBINDING_COMMANDS = [ |
| 38 | "thread.stop", | ||
| 38 | "thread.previous", | 39 | "thread.previous", |
| 39 | "thread.next", | 40 | "thread.next", |
| 40 | "thread.copyReference", | 41 | "thread.copyReference", |