Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion apps/web/src/components/chat/useThreadLifecycle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,59 @@ 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(
<RegistryProvider>
<KeybindingsSettingsPanel />
</RegistryProvider>,
);
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"));
});

it("preserves an explicitly cleared condition when changing commands", async () => {
await using screen = await render(
<RegistryProvider>
<KeybindingsSettingsPanel />
</RegistryProvider>,
);
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");
});
23 changes: 21 additions & 2 deletions apps/web/src/components/settings/KeybindingsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,8 @@ function useNewKeybindingDraft({
onSave: (input: ServerUpsertKeybindingInput) => void;
}) {
const [commandDraft, setCommandDraft] = useState<KeybindingCommand | "">("");
const [hasAutomaticWhen, setHasAutomaticWhen] = useState(false);
const [hasEditedWhen, setHasEditedWhen] = useState(false);
const [draft, setDraft] = useReducer(keybindingRowDraftReducer, {
keyDraft: "",
whenDraft: undefined,
Expand Down Expand Up @@ -1089,7 +1091,19 @@ function useNewKeybindingDraft({

return {
commandDraft,
setCommandDraft,
setCommandDraft: (command: KeybindingCommand) => {
setCommandDraft(command);
const needsTerminalGuard = command === "thread.archive" || command === "thread.delete";
if (needsTerminalGuard && !whenDraft && !hasEditedWhen) {
setDraft({
whenDraft: { type: "not", node: { type: "identifier", name: "terminalFocus" } },
});
setHasAutomaticWhen(true);
} else if (!needsTerminalGuard && hasAutomaticWhen) {
setDraft({ whenDraft: undefined });
setHasAutomaticWhen(false);
}
},
keyDraft,
whenDraft,
whenDraftExpression,
Expand All @@ -1098,6 +1112,11 @@ function useNewKeybindingDraft({
commandLabelText,
canSave,
setDraft,
setWhenDraft: (whenDraft: KeybindingWhenNode | undefined) => {
setHasAutomaticWhen(false);
setHasEditedWhen(true);
setDraft({ whenDraft });
},
save,
captureKeybinding,
};
Expand Down Expand Up @@ -1182,7 +1201,7 @@ function NewKeybindingWhen({
expression={draft.whenDraftExpression}
value={draft.whenDraft}
variables={variables}
onChange={(whenDraft) => draft.setDraft({ whenDraft })}
onChange={draft.setWhenDraft}
onValidityChange={(isWhenDraftValid) => draft.setDraft({ isWhenDraftValid })}
/>
);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/hooks/useThreadActionMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,8 @@ vi.mock("./useThreadActions", () => ({
"unsnoozeThread",
"pinThread",
"confirmAndUnpinThread",
"archiveThread",
"deleteThread",
"confirmAndArchiveThread",
"confirmAndDeleteThread",
].map((action) => [
action,
async () => {
Expand Down
28 changes: 4 additions & 24 deletions apps/web/src/hooks/useThreadActionMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,17 +83,15 @@ export function useThreadActionMenu(input: {
pinThread,
confirmAndUnpinThread,
setThreadAutoSettle,
archiveThread,
deleteThread,
confirmAndArchiveThread,
confirmAndDeleteThread,
markThreadUnread,
turnThreadIntoProject,
} = useThreadActions();
const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
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 }) => {
Expand Down Expand Up @@ -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;
},
Expand All @@ -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) &&
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/hooks/useThreadActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -951,6 +971,7 @@ export function useThreadActions() {

return {
archiveThread,
confirmAndArchiveThread,
unarchiveThread,
deleteThread,
confirmAndDeleteThread,
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions docs/user/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ 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.
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
draft. Consecutive
Expand Down
13 changes: 13 additions & 0 deletions packages/contracts/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, [
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading