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,