diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 24a137d933fade32efe47ee75c67fb7ae4c5387a..1f05604934e41cfb967f0dfe723e06342757aa58 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -195,6 +195,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); + assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s"); assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cb1cf698535a1d32493a397ba378aeb023d9bce9..ec977766ef4b18c9f8d531a1eea1528cd81e0c1b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -204,6 +204,7 @@ import { } from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useThreadActions } from "../hooks/useThreadActions"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; @@ -1257,6 +1258,7 @@ function ChatViewContent(props: ChatViewProps) { const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null; const threadDetailLoading = threadSyncPhase === "loading"; const handleNewThread = useNewThreadHandler(); + const { settleThread } = useThreadActions(); const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], @@ -4928,6 +4930,29 @@ function ChatViewContent(props: ChatViewProps) { }); if (!command) return; + if (command === "thread.settle") { + event.preventDefault(); + event.stopPropagation(); + if (!isServerThread || !activeThreadRef || !supportsSettlement) return; + if (activeThreadSettled) { + void handleUnsettleActiveThread(); + return; + } + + void settleThread(activeThreadRef).then((result) => { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + return; + } + if (command === "terminal.toggle") { event.preventDefault(); event.stopPropagation(); @@ -5031,6 +5056,8 @@ function ChatViewContent(props: ChatViewProps) { activeProject, activeRightPanelSurface, addTerminalSurface, + activeThreadRef, + activeThreadSettled, terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, @@ -5042,7 +5069,11 @@ function ChatViewContent(props: ChatViewProps) { splitTerminal, splitPanelTerminal, keybindings, + handleUnsettleActiveThread, + isServerThread, onToggleDiff, + settleThread, + supportsSettlement, toggleRightPanel, toggleRightPanelMaximized, toggleTerminalVisibility, diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 7d07610c643038c56b61b230cbaedf785f4684dd..b6b98d7389b3884da3e47711bb20067e8f4778b0 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -142,6 +142,11 @@ const DEFAULT_BINDINGS = compile([ { shortcut: modShortcut("o"), command: "editor.openFavorite" }, { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" }, + { + shortcut: modShortcut("s", { shiftKey: true }), + command: "thread.settle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("1"), command: "thread.jump.1" }, { shortcut: modShortcut("2"), command: "thread.jump.2" }, { shortcut: modShortcut("3"), command: "thread.jump.3" }, @@ -187,6 +192,27 @@ describe("isTerminalToggleShortcut", () => { }); }); +describe("settle thread shortcut", () => { + it("resolves outside the terminal", () => { + assert.equal( + resolveShortcutCommand(event({ key: "s", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "thread.settle", + ); + }); + + it("does not intercept the terminal", () => { + assert.isNull( + resolveShortcutCommand(event({ key: "s", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "Win32", + context: { terminalFocus: true }, + }), + ); + }); +}); + describe("split/new/close terminal shortcuts", () => { it("requires terminalFocus for default split/new/close bindings", () => { assert.isFalse( diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 0c4ca077f5ded137e3539a5ac5dc93e1f341bc1a..6bf89d091f7355b78cc1216115bb1359af501220 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -52,6 +52,9 @@ successful pick; its hover glow and badge preview the element and color family t `rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut, so add one in **Settings** → **Keybindings** if you want to use it. +`thread.settle` settles the active thread or restores it when it is already settled. Its default +shortcut is `mod+shift+s`, and it does not run while the terminal has focus. + 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 71d8624a8aeabe569e157cf0a80d86da3466a63e..9bc9af9d97fd8c688afd62460135c3be76ed0897 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -107,6 +107,13 @@ it.effect("parses keybinding rules", () => command: "thread.previous", }); assert.strictEqual(parsedThreadPrevious.command, "thread.previous"); + + const parsedThreadSettle = yield* decode(KeybindingRule, { + key: "mod+shift+s", + command: "thread.settle", + when: "!terminalFocus", + }); + assert.strictEqual(parsedThreadSettle.command, "thread.settle"); }), ); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 19276c41e7b6951d5b3b2981eae4c2a352dd4303..bb80ca5287f090160b639e4541745d301333d47b 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -37,6 +37,7 @@ export type ModelPickerJumpKeybindingCommand = export const THREAD_KEYBINDING_COMMANDS = [ "thread.previous", "thread.next", + "thread.settle", ...THREAD_JUMP_KEYBINDING_COMMANDS, ] as const; export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 158a9ffb1ac994e87572d8fd5f8e083049bc63b3..1e6e4f5f39ef11b439d7c6be02e9dd1b1eb55397 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -46,6 +46,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, + { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" }, ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ key: `mod+${index + 1}`, command,