authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-24 21:17:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 21:17:27-07:00
log143341b0bc4e3e81066ab2cd86ddd199574bfd83
tree0c6da3eb97a23ca48d15110f91b1a454a5b04472
parent2394998aa2e1903844e79e44de30e09d2820d3f1
signature Signed by PGP key B5690EEEBB952194

feat(web): settle and restore threads with a keyboard shortcut (#8089)


7 files changed, 70 insertions(+), 0 deletions(-)

apps/server/src/keybindings.test.ts+1
......@@ -195,6 +195,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
195195
196196 assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+[");
197197 assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]");
198 assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s");
198199 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
199200 assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
200201 assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");
apps/web/src/components/ChatView.tsx+31
......@@ -204,6 +204,7 @@ import {
204204} from "../hooks/useSettings";
205205import { useNowMinute } from "../hooks/useNowMinute";
206206import { useNewThreadHandler } from "../hooks/useHandleNewThread";
207import { useThreadActions } from "../hooks/useThreadActions";
207208import { resolveAppModelSelectionForInstance } from "../modelSelection";
208209import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm";
209210import { getTerminalFocusOwner } from "../lib/terminalFocus";
......@@ -1257,6 +1258,7 @@ function ChatViewContent(props: ChatViewProps) {
12571258 const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null;
12581259 const threadDetailLoading = threadSyncPhase === "loading";
12591260 const handleNewThread = useNewThreadHandler();
1261 const { settleThread } = useThreadActions();
12601262 const routeThreadRef = useMemo(
12611263 () => scopeThreadRef(environmentId, threadId),
12621264 [environmentId, threadId],
......@@ -4928,6 +4930,29 @@ function ChatViewContent(props: ChatViewProps) {
49284930 });
49294931 if (!command) return;
49304932
4933 if (command === "thread.settle") {
4934 event.preventDefault();
4935 event.stopPropagation();
4936 if (!isServerThread || !activeThreadRef || !supportsSettlement) return;
4937 if (activeThreadSettled) {
4938 void handleUnsettleActiveThread();
4939 return;
4940 }
4941
4942 void settleThread(activeThreadRef).then((result) => {
4943 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
4944 const error = squashAtomCommandFailure(result);
4945 toastManager.add(
4946 stackedThreadToast({
4947 type: "error",
4948 title: "Failed to settle thread",
4949 description: error instanceof Error ? error.message : "An error occurred.",
4950 }),
4951 );
4952 });
4953 return;
4954 }
4955
49314956 if (command === "terminal.toggle") {
49324957 event.preventDefault();
49334958 event.stopPropagation();
......@@ -5031,6 +5056,8 @@ function ChatViewContent(props: ChatViewProps) {
50315056 activeProject,
50325057 activeRightPanelSurface,
50335058 addTerminalSurface,
5059 activeThreadRef,
5060 activeThreadSettled,
50345061 terminalUiState.terminalOpen,
50355062 terminalUiState.activeTerminalId,
50365063 activeThreadId,
......@@ -5042,7 +5069,11 @@ function ChatViewContent(props: ChatViewProps) {
50425069 splitTerminal,
50435070 splitPanelTerminal,
50445071 keybindings,
5072 handleUnsettleActiveThread,
5073 isServerThread,
50455074 onToggleDiff,
5075 settleThread,
5076 supportsSettlement,
50465077 toggleRightPanel,
50475078 toggleRightPanelMaximized,
50485079 toggleTerminalVisibility,
apps/web/src/keybindings.test.ts+26
......@@ -142,6 +142,11 @@ const DEFAULT_BINDINGS = compile([
142142 { shortcut: modShortcut("o"), command: "editor.openFavorite" },
143143 { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" },
144144 { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" },
145 {
146 shortcut: modShortcut("s", { shiftKey: true }),
147 command: "thread.settle",
148 whenAst: whenNot(whenIdentifier("terminalFocus")),
149 },
145150 { shortcut: modShortcut("1"), command: "thread.jump.1" },
146151 { shortcut: modShortcut("2"), command: "thread.jump.2" },
147152 { shortcut: modShortcut("3"), command: "thread.jump.3" },
......@@ -187,6 +192,27 @@ describe("isTerminalToggleShortcut", () => {
187192 });
188193});
189194
195describe("settle thread shortcut", () => {
196 it("resolves outside the terminal", () => {
197 assert.equal(
198 resolveShortcutCommand(event({ key: "s", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
199 platform: "MacIntel",
200 context: { terminalFocus: false },
201 }),
202 "thread.settle",
203 );
204 });
205
206 it("does not intercept the terminal", () => {
207 assert.isNull(
208 resolveShortcutCommand(event({ key: "s", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
209 platform: "Win32",
210 context: { terminalFocus: true },
211 }),
212 );
213 });
214});
215
190216describe("split/new/close terminal shortcuts", () => {
191217 it("requires terminalFocus for default split/new/close bindings", () => {
192218 assert.isFalse(
docs/user/keybindings.md+3
......@@ -52,6 +52,9 @@ successful pick; its hover glow and badge preview the element and color family t
5252`rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut,
5353so add one in **Settings** → **Keybindings** if you want to use it.
5454
55`thread.settle` settles the active thread or restores it when it is already settled. Its default
56shortcut is `mod+shift+s`, and it does not run while the terminal has focus.
57
5558The command palette searches active thread titles, projects, branches, user messages, and final
5659agent responses across connected environments. Message matches show one labeled excerpt while
5760keeping the thread's project, branch, and machine context visible. Message search begins after two
packages/contracts/src/keybindings.test.ts+7
......@@ -107,6 +107,13 @@ it.effect("parses keybinding rules", () =>
107107 command: "thread.previous",
108108 });
109109 assert.strictEqual(parsedThreadPrevious.command, "thread.previous");
110
111 const parsedThreadSettle = yield* decode(KeybindingRule, {
112 key: "mod+shift+s",
113 command: "thread.settle",
114 when: "!terminalFocus",
115 });
116 assert.strictEqual(parsedThreadSettle.command, "thread.settle");
110117 }),
111118);
112119
packages/contracts/src/keybindings.ts+1
......@@ -37,6 +37,7 @@ export type ModelPickerJumpKeybindingCommand =
3737export const THREAD_KEYBINDING_COMMANDS = [
3838 "thread.previous",
3939 "thread.next",
40 "thread.settle",
4041 ...THREAD_JUMP_KEYBINDING_COMMANDS,
4142] as const;
4243export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number];
packages/shared/src/keybindings.ts+1
......@@ -46,6 +46,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
4646 { key: "mod+o", command: "editor.openFavorite" },
4747 { key: "mod+shift+[", command: "thread.previous" },
4848 { key: "mod+shift+]", command: "thread.next" },
49 { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" },
4950 ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({
5051 key: `mod+${index + 1}`,
5152 command,