From 1342d56f4cf51d7e4ef1c527ee79b12df98a0705 Mon Sep 17 00:00:00 2001 From: nemo-machina-0 <277245390+nemo-machina-0@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:56:32 -0500 Subject: [PATCH 1/3] feat(web): allow shortcuts for archive and delete --- .../components/chat/useThreadLifecycle.tsx | 44 ++++++++++++++++++- .../settings/KeybindingsSettings.tsx | 9 +++- .../web/src/hooks/useThreadActionMenu.test.ts | 4 +- apps/web/src/hooks/useThreadActionMenu.ts | 28 ++---------- apps/web/src/hooks/useThreadActions.ts | 21 +++++++++ apps/web/src/keybindings.test.ts | 23 ++++++++++ docs/user/keybindings.md | 5 +++ packages/contracts/src/keybindings.test.ts | 13 ++++++ packages/contracts/src/keybindings.ts | 2 + 9 files changed, 121 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index b9f8a42d289c3..29dcf8f92a35d 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.tsx @@ -174,7 +174,14 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { } = input; const navigate = useNavigate(); const router = useRouter(); - const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); + const { + settleThread, + pinThread, + confirmAndUnpinThread, + confirmAndArchiveThread, + confirmAndDeleteThread, + } = useThreadActions(); + const destructiveShortcutPending = useRef(false); const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { @@ -648,6 +655,41 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { ); }; const shortcuts = { + "thread.archive": ({ event }) => { + if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false; + if (!activeThreadShell || !activeThreadRef || !serverConfigLoaded) return; + if (event.repeat || destructiveShortcutPending.current) return; + destructiveShortcutPending.current = true; + let didArchive = false; + void confirmAndArchiveThread(activeThreadRef, { + onArchived: () => { + didArchive = true; + }, + }) + .then((result) => + toastThreadActionFailure( + didArchive ? "Thread archived, but navigation failed" : "Failed to archive thread", + )(result), + ) + .finally(() => { + destructiveShortcutPending.current = false; + }); + }, + "thread.delete": ({ event }) => { + if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false; + if (!activeThreadShell || !activeThreadRef || !serverConfigLoaded) return; + if (event.repeat || destructiveShortcutPending.current) return; + destructiveShortcutPending.current = true; + const target = activeThreadRef; + void confirmAndDeleteThread(target) + .then((result) => { + if (readThreadShell(target) !== null) + toastThreadActionFailure("Failed to delete thread")(result); + }) + .finally(() => { + destructiveShortcutPending.current = false; + }); + }, "thread.settle": () => { if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false; if (!activeThreadShell || !activeThreadRef || !serverConfigLoaded) return; diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index cb00bca7e9cae..d1826341c515f 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -1089,7 +1089,14 @@ function useNewKeybindingDraft({ return { commandDraft, - setCommandDraft, + setCommandDraft: (command: KeybindingCommand) => { + setCommandDraft(command); + if ((command === "thread.archive" || command === "thread.delete") && !whenDraft) { + setDraft({ + whenDraft: { type: "not", node: { type: "identifier", name: "terminalFocus" } }, + }); + } + }, keyDraft, whenDraft, whenDraftExpression, diff --git a/apps/web/src/hooks/useThreadActionMenu.test.ts b/apps/web/src/hooks/useThreadActionMenu.test.ts index 3c8255b04c088..3320f94cbb91c 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.ts +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -130,8 +130,8 @@ vi.mock("./useThreadActions", () => ({ "unsnoozeThread", "pinThread", "confirmAndUnpinThread", - "archiveThread", - "deleteThread", + "confirmAndArchiveThread", + "confirmAndDeleteThread", ].map((action) => [ action, async () => { diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index d22b1d9cf6c4f..74d52980d5a51 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -83,8 +83,8 @@ export function useThreadActionMenu(input: { pinThread, confirmAndUnpinThread, setThreadAutoSettle, - archiveThread, - deleteThread, + confirmAndArchiveThread, + confirmAndDeleteThread, markThreadUnread, turnThreadIntoProject, } = useThreadActions(); @@ -92,8 +92,6 @@ export function useThreadActionMenu(input: { reportFailure: false, }); const handleNewThread = useNewThreadHandler(); - const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); - const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const timestampFormat = useClientSettings((s) => s.timestampFormat); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { @@ -304,14 +302,8 @@ export function useThreadActionMenu(input: { copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; case "archive": { - if (confirmThreadArchive) { - const confirmed = await settlePromise(() => - api.dialogs.confirm(`Archive thread "${thread.title}"?`), - ); - if (confirmed._tag === "Failure" || !confirmed.value) return; - } let didArchive = false; - const result = await archiveThread(threadRef, { + const result = await confirmAndArchiveThread(threadRef, { onArchived: () => { didArchive = true; }, @@ -325,19 +317,7 @@ export function useThreadActionMenu(input: { return; } case "delete": { - if (confirmThreadDelete) { - const confirmed = await settlePromise(() => - api.dialogs.confirm( - [ - `Delete thread "${thread.title}"?`, - "This permanently clears conversation history for this thread.", - ].join("\n"), - { variant: "destructive" }, - ), - ); - if (confirmed._tag === "Failure" || !confirmed.value) return; - } - const deleted = await deleteThread(threadRef); + const deleted = await confirmAndDeleteThread(threadRef); if ( deleted._tag === "Failure" && !isAtomCommandInterrupted(deleted) && diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 904b94ebfc5c5..8884ad10c77ae 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -330,6 +330,7 @@ export function useThreadActions() { }); const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); + const confirmThreadArchive = useClientSettings((settings) => settings.confirmThreadArchive); const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const clearProjectDraftThreadById = useComposerDraftStore( @@ -921,6 +922,25 @@ export function useThreadActions() { (result) => result._tag === "Success", ); + const confirmAndArchiveThread = async ( + target: ScopedThreadRef, + opts: { onArchived?: () => void } = {}, + ) => { + const permissionFailure = threadOperationFailure(target); + if (permissionFailure) return permissionFailure; + const localApi = readLocalApi(); + const resolved = resolveThreadTarget(target); + if (!resolved) return AsyncResult.success(undefined); + if (confirmThreadArchive && localApi) { + const confirmation = await settlePromise(() => + localApi.dialogs.confirm(`Archive thread "${resolved.thread.title}"?`), + ); + if (confirmation._tag === "Failure") return confirmation; + if (!confirmation.value) return AsyncResult.success(undefined); + } + return archiveThread(target, opts); + }; + const confirmAndDeleteThread = async (target: ScopedThreadRef) => { const permissionFailure = threadOperationFailure(target); if (permissionFailure) return permissionFailure; @@ -951,6 +971,7 @@ export function useThreadActions() { return { archiveThread, + confirmAndArchiveThread, unarchiveThread, deleteThread, confirmAndDeleteThread, diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 1f0a096fccaec..0d42455e4a84d 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -248,6 +248,29 @@ describe("settle thread shortcut", () => { }); }); +describe.each(["thread.archive", "thread.delete"] as const)("%s shortcut", (command) => { + it("is unbound by default and leaves terminal input alone when bound", () => { + assert.isFalse(DEFAULT_RESOLVED_KEYBINDINGS.some((binding) => binding.command === command)); + const bindings = compileResolvedKeybindingsConfig([ + { key: "mod+shift+s", command, when: "!terminalFocus" }, + ]); + const shortcut = event({ key: "s", ctrlKey: true, shiftKey: true }); + assert.isNull( + resolveShortcutCommand(shortcut, bindings, { + platform: "Win32", + context: { terminalFocus: true }, + }), + ); + assert.equal( + resolveShortcutCommand(shortcut, bindings, { + platform: "Win32", + context: { terminalFocus: false }, + }), + command, + ); + }); +}); + describe("thread undo shortcut", () => { it("leaves native undo alone inside text fields and terminals", () => { assert.isNull( diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 3654c0b3b04cd..c2ec64f136bb1 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -153,6 +153,11 @@ a shortcut. `thread.stop` interrupts the running turn in the focused thread. It has no default shortcut; assign one in **Settings → Keybindings**. +`thread.archive` and `thread.delete` act on the current saved thread. Assign a +shortcut in **Settings → Keybindings**; neither has one by default. They respect +the thread confirmation settings, and deletion permanently clears the conversation. +New bindings default to `!terminalFocus`, which you can change in Settings. + `thread.undo` (`mod+z` by default) reverses the actions shown in the notice at the bottom of the sidebar, such as unpin, settle, snooze, archive, or discarding a draft. Consecutive diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 0b887507859fc..ee308b34c6fb2 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -37,6 +37,19 @@ it.effect("drops resolved rules with commands this build does not know", () => }), ); +it.effect("accepts custom archive and delete thread bindings", () => + Effect.gen(function* () { + const parsed = yield* decode(ResolvedKeybindingsConfig, [ + { command: "thread.archive", shortcut }, + { command: "thread.delete", shortcut }, + ]); + assert.deepEqual( + parsed.map((rule) => rule.command), + ["thread.archive", "thread.delete"], + ); + }), +); + it.effect("drops malformed resolved rule entries", () => Effect.gen(function* () { const parsed = yield* decode(ResolvedKeybindingsConfig, [ diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 714e0a1d486a1..e247fbe09312c 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -42,6 +42,8 @@ const THREAD_KEYBINDING_COMMANDS = [ "thread.next", "thread.copyReference", "thread.settle", + "thread.archive", + "thread.delete", "thread.pin", "thread.undo", ...THREAD_JUMP_KEYBINDING_COMMANDS, From 335ecdfd6d2328a38d5590419ab4ed920475ee13 Mon Sep 17 00:00:00 2001 From: nemo-machina-0 <277245390+nemo-machina-0@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:46:59 -0500 Subject: [PATCH 2/3] fix(web): clear automatic shortcut condition on command change --- .../KeybindingsSettings.browser.test.tsx | 36 +++++++++++++++++++ .../settings/KeybindingsSettings.tsx | 14 ++++++-- docs/user/keybindings.md | 4 ++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.browser.test.tsx b/apps/web/src/components/settings/KeybindingsSettings.browser.test.tsx index fb17e32e163dc..01aed6e9bffc8 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.browser.test.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.browser.test.tsx @@ -125,3 +125,39 @@ it("re-enables Save when a When editor closes on an expression it could not pars await expect.element(expression).toHaveValue("terminalFocus"); await expect.element(screen.getByRole("button", { name: "Save" })).toBeEnabled(); }); + +it("clears only the automatic terminal guard when changing commands", async () => { + await using screen = await render( + + + , + ); + await screen.getByRole("button", { name: "Add keybinding" }).click(); + const select = screen.getByRole("combobox"); + const newWhenText = () => + select + .element() + .closest('[data-slot="settings-row"]') + ?.querySelector('[aria-label^="Edit when clause"]')?.textContent; + await select.click(); + await page.getByRole("option", { name: "Thread: Archive" }).click(); + await expect + .element(screen.getByRole("button", { name: "Edit when clause for Thread: Archive" })) + .toHaveTextContent("!terminalFocus"); + + await select.click(); + await page.getByRole("option", { name: "Terminal: Toggle" }).click(); + await vi.waitFor(() => expect(newWhenText()).toContain("Always")); + + await select.click(); + await page.getByRole("option", { name: "Thread: Delete" }).click(); + const when = screen.getByRole("button", { name: "Edit when clause for Thread: Delete" }); + await when.click(); + const expression = screen.getByRole("textbox", { name: "When expression" }); + await userEvent.clear(expression); + await userEvent.type(expression, "editorFocus"); + await userEvent.keyboard("{Escape}"); + await select.click(); + await page.getByRole("option", { name: "Terminal: Toggle" }).click(); + await vi.waitFor(() => expect(newWhenText()).toContain("editorFocus")); +}); diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index d1826341c515f..4f9019ece29cd 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -1045,6 +1045,7 @@ function useNewKeybindingDraft({ onSave: (input: ServerUpsertKeybindingInput) => void; }) { const [commandDraft, setCommandDraft] = useState(""); + const [hasAutomaticWhen, setHasAutomaticWhen] = useState(false); const [draft, setDraft] = useReducer(keybindingRowDraftReducer, { keyDraft: "", whenDraft: undefined, @@ -1091,10 +1092,15 @@ function useNewKeybindingDraft({ commandDraft, setCommandDraft: (command: KeybindingCommand) => { setCommandDraft(command); - if ((command === "thread.archive" || command === "thread.delete") && !whenDraft) { + const needsTerminalGuard = command === "thread.archive" || command === "thread.delete"; + if (needsTerminalGuard && !whenDraft) { setDraft({ whenDraft: { type: "not", node: { type: "identifier", name: "terminalFocus" } }, }); + setHasAutomaticWhen(true); + } else if (!needsTerminalGuard && hasAutomaticWhen) { + setDraft({ whenDraft: undefined }); + setHasAutomaticWhen(false); } }, keyDraft, @@ -1105,6 +1111,10 @@ function useNewKeybindingDraft({ commandLabelText, canSave, setDraft, + setWhenDraft: (whenDraft: KeybindingWhenNode | undefined) => { + setHasAutomaticWhen(false); + setDraft({ whenDraft }); + }, save, captureKeybinding, }; @@ -1189,7 +1199,7 @@ function NewKeybindingWhen({ expression={draft.whenDraftExpression} value={draft.whenDraft} variables={variables} - onChange={(whenDraft) => draft.setDraft({ whenDraft })} + onChange={draft.setWhenDraft} onValidityChange={(isWhenDraftValid) => draft.setDraft({ isWhenDraftValid })} /> ); diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index c2ec64f136bb1..9c35193275d5f 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -156,7 +156,9 @@ shortcut; assign one in **Settings → Keybindings**. `thread.archive` and `thread.delete` act on the current saved thread. Assign a shortcut in **Settings → Keybindings**; neither has one by default. They respect the thread confirmation settings, and deletion permanently clears the conversation. -New bindings default to `!terminalFocus`, which you can change in Settings. +Bindings created in Settings default to `!terminalFocus`, which you can change. +When editing `keybindings.json` directly, add `"when": "!terminalFocus"` to +exclude terminal focus; a binding without `when` also matches in the terminal. `thread.undo` (`mod+z` by default) reverses the actions shown in the notice at the bottom of the sidebar, such as unpin, settle, snooze, archive, or discarding a From 0f8783049236df824c0799040f9b29d85c5aa45f Mon Sep 17 00:00:00 2001 From: nemo-machina-0 <277245390+nemo-machina-0@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:33:50 -0500 Subject: [PATCH 3/3] fix(web): preserve cleared shortcut conditions --- .../KeybindingsSettings.browser.test.tsx | 20 +++++++++++++++++++ .../settings/KeybindingsSettings.tsx | 4 +++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.browser.test.tsx b/apps/web/src/components/settings/KeybindingsSettings.browser.test.tsx index 01aed6e9bffc8..1af42de40ce24 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.browser.test.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.browser.test.tsx @@ -161,3 +161,23 @@ it("clears only the automatic terminal guard when changing commands", async () = await page.getByRole("option", { name: "Terminal: Toggle" }).click(); await vi.waitFor(() => expect(newWhenText()).toContain("editorFocus")); }); + +it("preserves an explicitly cleared condition when changing commands", async () => { + await using screen = await render( + + + , + ); + await screen.getByRole("button", { name: "Add keybinding" }).click(); + const select = screen.getByRole("combobox"); + await select.click(); + await page.getByRole("option", { name: "Thread: Archive" }).click(); + await screen.getByRole("button", { name: "Edit when clause for Thread: Archive" }).click(); + await userEvent.clear(screen.getByRole("textbox", { name: "When expression" })); + await userEvent.keyboard("{Escape}"); + await select.click(); + await page.getByRole("option", { name: "Thread: Delete" }).click(); + await expect + .element(screen.getByRole("button", { name: "Edit when clause for Thread: Delete" })) + .toHaveTextContent("Always"); +}); diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 4f9019ece29cd..ca50da84485ea 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -1046,6 +1046,7 @@ function useNewKeybindingDraft({ }) { const [commandDraft, setCommandDraft] = useState(""); const [hasAutomaticWhen, setHasAutomaticWhen] = useState(false); + const [hasEditedWhen, setHasEditedWhen] = useState(false); const [draft, setDraft] = useReducer(keybindingRowDraftReducer, { keyDraft: "", whenDraft: undefined, @@ -1093,7 +1094,7 @@ function useNewKeybindingDraft({ setCommandDraft: (command: KeybindingCommand) => { setCommandDraft(command); const needsTerminalGuard = command === "thread.archive" || command === "thread.delete"; - if (needsTerminalGuard && !whenDraft) { + if (needsTerminalGuard && !whenDraft && !hasEditedWhen) { setDraft({ whenDraft: { type: "not", node: { type: "identifier", name: "terminalFocus" } }, }); @@ -1113,6 +1114,7 @@ function useNewKeybindingDraft({ setDraft, setWhenDraft: (whenDraft: KeybindingWhenNode | undefined) => { setHasAutomaticWhen(false); + setHasEditedWhen(true); setDraft({ whenDraft }); }, save,