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) => {...@@ -195,6 +195,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
195195
196 assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+[");196 assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+[");
197 assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]");197 assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]");
198 assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s");
198 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");199 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
199 assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");200 assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
200 assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");201 assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");
apps/web/src/components/ChatView.tsx+31
...@@ -204,6 +204,7 @@ import {...@@ -204,6 +204,7 @@ import {
204} from "../hooks/useSettings";204} from "../hooks/useSettings";
205import { useNowMinute } from "../hooks/useNowMinute";205import { useNowMinute } from "../hooks/useNowMinute";
206import { useNewThreadHandler } from "../hooks/useHandleNewThread";206import { useNewThreadHandler } from "../hooks/useHandleNewThread";
207import { useThreadActions } from "../hooks/useThreadActions";
207import { resolveAppModelSelectionForInstance } from "../modelSelection";208import { resolveAppModelSelectionForInstance } from "../modelSelection";
208import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm";209import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm";
209import { getTerminalFocusOwner } from "../lib/terminalFocus";210import { getTerminalFocusOwner } from "../lib/terminalFocus";
...@@ -1257,6 +1258,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1257,6 +1258,7 @@ function ChatViewContent(props: ChatViewProps) {
1257 const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null;1258 const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null;
1258 const threadDetailLoading = threadSyncPhase === "loading";1259 const threadDetailLoading = threadSyncPhase === "loading";
1259 const handleNewThread = useNewThreadHandler();1260 const handleNewThread = useNewThreadHandler();
1261 const { settleThread } = useThreadActions();
1260 const routeThreadRef = useMemo(1262 const routeThreadRef = useMemo(
1261 () => scopeThreadRef(environmentId, threadId),1263 () => scopeThreadRef(environmentId, threadId),
1262 [environmentId, threadId],1264 [environmentId, threadId],
...@@ -4928,6 +4930,29 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4928,6 +4930,29 @@ function ChatViewContent(props: ChatViewProps) {
4928 });4930 });
4929 if (!command) return;4931 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
4931 if (command === "terminal.toggle") {4956 if (command === "terminal.toggle") {
4932 event.preventDefault();4957 event.preventDefault();
4933 event.stopPropagation();4958 event.stopPropagation();
...@@ -5031,6 +5056,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5031,6 +5056,8 @@ function ChatViewContent(props: ChatViewProps) {
5031 activeProject,5056 activeProject,
5032 activeRightPanelSurface,5057 activeRightPanelSurface,
5033 addTerminalSurface,5058 addTerminalSurface,
5059 activeThreadRef,
5060 activeThreadSettled,
5034 terminalUiState.terminalOpen,5061 terminalUiState.terminalOpen,
5035 terminalUiState.activeTerminalId,5062 terminalUiState.activeTerminalId,
5036 activeThreadId,5063 activeThreadId,
...@@ -5042,7 +5069,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5042,7 +5069,11 @@ function ChatViewContent(props: ChatViewProps) {
5042 splitTerminal,5069 splitTerminal,
5043 splitPanelTerminal,5070 splitPanelTerminal,
5044 keybindings,5071 keybindings,
5072 handleUnsettleActiveThread,
5073 isServerThread,
5045 onToggleDiff,5074 onToggleDiff,
5075 settleThread,
5076 supportsSettlement,
5046 toggleRightPanel,5077 toggleRightPanel,
5047 toggleRightPanelMaximized,5078 toggleRightPanelMaximized,
5048 toggleTerminalVisibility,5079 toggleTerminalVisibility,
apps/web/src/keybindings.test.ts+26
...@@ -142,6 +142,11 @@ const DEFAULT_BINDINGS = compile([...@@ -142,6 +142,11 @@ const DEFAULT_BINDINGS = compile([
142 { shortcut: modShortcut("o"), command: "editor.openFavorite" },142 { shortcut: modShortcut("o"), command: "editor.openFavorite" },
143 { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" },143 { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" },
144 { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" },144 { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" },
145 {
146 shortcut: modShortcut("s", { shiftKey: true }),
147 command: "thread.settle",
148 whenAst: whenNot(whenIdentifier("terminalFocus")),
149 },
145 { shortcut: modShortcut("1"), command: "thread.jump.1" },150 { shortcut: modShortcut("1"), command: "thread.jump.1" },
146 { shortcut: modShortcut("2"), command: "thread.jump.2" },151 { shortcut: modShortcut("2"), command: "thread.jump.2" },
147 { shortcut: modShortcut("3"), command: "thread.jump.3" },152 { shortcut: modShortcut("3"), command: "thread.jump.3" },
...@@ -187,6 +192,27 @@ describe("isTerminalToggleShortcut", () => {...@@ -187,6 +192,27 @@ describe("isTerminalToggleShortcut", () => {
187 });192 });
188});193});
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
190describe("split/new/close terminal shortcuts", () => {216describe("split/new/close terminal shortcuts", () => {
191 it("requires terminalFocus for default split/new/close bindings", () => {217 it("requires terminalFocus for default split/new/close bindings", () => {
192 assert.isFalse(218 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...@@ -52,6 +52,9 @@ successful pick; its hover glow and badge preview the element and color family t
52`rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut,52`rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut,
53so add one in **Settings** → **Keybindings** if you want to use it.53so 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
55The command palette searches active thread titles, projects, branches, user messages, and final58The command palette searches active thread titles, projects, branches, user messages, and final
56agent responses across connected environments. Message matches show one labeled excerpt while59agent responses across connected environments. Message matches show one labeled excerpt while
57keeping the thread's project, branch, and machine context visible. Message search begins after two60keeping 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", () =>...@@ -107,6 +107,13 @@ it.effect("parses keybinding rules", () =>
107 command: "thread.previous",107 command: "thread.previous",
108 });108 });
109 assert.strictEqual(parsedThreadPrevious.command, "thread.previous");109 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");
110 }),117 }),
111);118);
112119
packages/contracts/src/keybindings.ts+1
...@@ -37,6 +37,7 @@ export type ModelPickerJumpKeybindingCommand =...@@ -37,6 +37,7 @@ export type ModelPickerJumpKeybindingCommand =
37export const THREAD_KEYBINDING_COMMANDS = [37export const THREAD_KEYBINDING_COMMANDS = [
38 "thread.previous",38 "thread.previous",
39 "thread.next",39 "thread.next",
40 "thread.settle",
40 ...THREAD_JUMP_KEYBINDING_COMMANDS,41 ...THREAD_JUMP_KEYBINDING_COMMANDS,
41] as const;42] as const;
42export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number];43export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number];
packages/shared/src/keybindings.ts+1
...@@ -46,6 +46,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [...@@ -46,6 +46,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
46 { key: "mod+o", command: "editor.openFavorite" },46 { key: "mod+o", command: "editor.openFavorite" },
47 { key: "mod+shift+[", command: "thread.previous" },47 { key: "mod+shift+[", command: "thread.previous" },
48 { key: "mod+shift+]", command: "thread.next" },48 { key: "mod+shift+]", command: "thread.next" },
49 { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" },
49 ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({50 ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({
50 key: `mod+${index + 1}`,51 key: `mod+${index + 1}`,
51 command,52 command,