| author | |
| committer | |
| log | 143341b0bc4e3e81066ab2cd86ddd199574bfd83 |
| tree | 0c6da3eb97a23ca48d15110f91b1a454a5b04472 |
| parent | 2394998aa2e1903844e79e44de30e09d2820d3f1 |
| signature | Signed by PGP key B5690EEEBB952194 |
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) => { |
| 195 | 195 | ||
| 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"; |
| 205 | import { useNowMinute } from "../hooks/useNowMinute"; | 205 | import { useNowMinute } from "../hooks/useNowMinute"; |
| 206 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; | 206 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; |
| 207 | import { useThreadActions } from "../hooks/useThreadActions"; | ||
| 207 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; | 208 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; |
| 208 | import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; | 209 | import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; |
| 209 | import { getTerminalFocusOwner } from "../lib/terminalFocus"; | 210 | import { 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; |
| 4930 | 4932 | ||
| 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 | }); |
| 189 | 194 | ||
| 195 | describe("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 | |||
| 190 | describe("split/new/close terminal shortcuts", () => { | 216 | describe("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, |
| 53 | so add one in **Settings** → **Keybindings** if you want to use it. | 53 | so add one in **Settings** → **Keybindings** if you want to use it. |
| 54 | 54 | ||
| 55 | `thread.settle` settles the active thread or restores it when it is already settled. Its default | ||
| 56 | shortcut is `mod+shift+s`, and it does not run while the terminal has focus. | ||
| 57 | |||
| 55 | The command palette searches active thread titles, projects, branches, user messages, and final | 58 | The command palette searches active thread titles, projects, branches, user messages, and final |
| 56 | agent responses across connected environments. Message matches show one labeled excerpt while | 59 | agent responses across connected environments. Message matches show one labeled excerpt while |
| 57 | keeping the thread's project, branch, and machine context visible. Message search begins after two | 60 | keeping 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 | ); |
| 112 | 119 |
packages/contracts/src/keybindings.ts+1| ... | @@ -37,6 +37,7 @@ export type ModelPickerJumpKeybindingCommand = | ... | @@ -37,6 +37,7 @@ export type ModelPickerJumpKeybindingCommand = |
| 37 | export const THREAD_KEYBINDING_COMMANDS = [ | 37 | export 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; |
| 42 | export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; | 43 | export 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, |