authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-20 22:26:38-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 22:26:38-07:00
log5781b5240bd5d2e21c651f6b228975ac40cbd67b
treeded6bf03a7c32ef9d7daa1a4754941362700d0ff
parent6b0a04ade021588dd9f1afbfa0e75cccdb7b2da4
signature Signed by PGP key B5690EEEBB952194

feat(web): undo settle, snooze and archive, with a mod+z shortcut (#12848)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

14 files changed, 520 insertions(+), 159 deletions(-)

apps/web/src/components/ChatView.tsx+16-2
...@@ -229,6 +229,8 @@ import {...@@ -229,6 +229,8 @@ import {
229} from "@t3tools/client-runtime/state/subagentRuntime";229} from "@t3tools/client-runtime/state/subagentRuntime";
230import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar";230import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar";
231import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";231import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
232import { isEditableFocused } from "../lib/editableFocus";
233import { undoLatestThreadAction } from "../hooks/showUndoToast";
232import ThreadTerminalDrawer from "./ThreadTerminalDrawer";234import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
233import {235import {
234 AlarmClockIcon,236 AlarmClockIcon,
...@@ -6647,11 +6649,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -6647,11 +6649,12 @@ export default function ChatView(props: ChatViewProps) {
6647 }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]);6649 }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]);
66486650
6649 const getShortcutContext = useCallback(6651 const getShortcutContext = useCallback(
6650 () => ({6652 (eventTarget: EventTarget | null = document.activeElement) => ({
6651 terminalFocus: getTerminalFocusOwner() !== null,6653 terminalFocus: getTerminalFocusOwner() !== null,
6652 terminalOpen: Boolean(terminalUiState.terminalOpen),6654 terminalOpen: Boolean(terminalUiState.terminalOpen),
6653 previewFocus: isPreviewFocused(),6655 previewFocus: isPreviewFocused(),
6654 previewOpen: previewPanelOpen,6656 previewOpen: previewPanelOpen,
6657 editableFocus: isEditableFocused(eventTarget),
6655 modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,6658 modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,
6656 isWeb: !isElectron,6659 isWeb: !isElectron,
6657 isDesktop: isElectron,6660 isDesktop: isElectron,
...@@ -6679,7 +6682,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -6679,7 +6682,7 @@ export default function ChatView(props: ChatViewProps) {
6679 if (event.defaultPrevented && terminalFocusOwner === null) {6682 if (event.defaultPrevented && terminalFocusOwner === null) {
6680 return;6683 return;
6681 }6684 }
6682 const shortcutContext = getShortcutContext();6685 const shortcutContext = getShortcutContext(event.target);
66836686
6684 if (6687 if (
6685 !shortcutContext.terminalFocus &&6688 !shortcutContext.terminalFocus &&
...@@ -6728,6 +6731,17 @@ export default function ChatView(props: ChatViewProps) {...@@ -6728,6 +6731,17 @@ export default function ChatView(props: ChatViewProps) {
6728 return;6731 return;
6729 }6732 }
67306733
6734 if (command === "thread.undo") {
6735 // Only claim the chord when there is an Undo to run; otherwise the
6736 // page keeps its native behavior for the key.
6737 if (event.repeat) return;
6738 if (undoLatestThreadAction()) {
6739 event.preventDefault();
6740 event.stopPropagation();
6741 }
6742 return;
6743 }
6744
6731 if (command === "thread.pin") {6745 if (command === "thread.pin") {
6732 event.preventDefault();6746 event.preventDefault();
6733 event.stopPropagation();6747 event.stopPropagation();
apps/web/src/components/Sidebar.tsx+8-23
...@@ -208,12 +208,7 @@ import {...@@ -208,12 +208,7 @@ import {
208 type TerminalStatusIndicator,208 type TerminalStatusIndicator,
209 useLinkedThreadPullRequest,209 useLinkedThreadPullRequest,
210} from "./ThreadStatusIndicators";210} from "./ThreadStatusIndicators";
211import {211import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze";
212 resolveSnoozePresets,
213 snoozeWakeDescription,
214 snoozeWakeLabel,
215 type SnoozePreset,
216} from "./Sidebar.snooze";
217import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";212import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";
218import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch";213import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch";
219import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";214import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
...@@ -3082,7 +3077,9 @@ export default function Sidebar() {...@@ -3082,7 +3077,9 @@ export default function Sidebar() {
3082 settlingThreadKeysRef.current.add(threadKey);3077 settlingThreadKeysRef.current.add(threadKey);
3083 try {3078 try {
3084 const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys);3079 const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys);
3085 const result = await settleThread(threadRef);3080 const result = await settleThread(threadRef, {
3081 undoToast: opts.coSettlingKeys === undefined,
3082 });
3086 if (result._tag === "Failure") {3083 if (result._tag === "Failure") {
3087 // Never navigate away from a thread that did not settle.3084 // Never navigate away from a thread that did not settle.
3088 if (!isAtomCommandInterrupted(result)) {3085 if (!isAtomCommandInterrupted(result)) {
...@@ -3734,7 +3731,9 @@ export default function Sidebar() {...@@ -3734,7 +3731,9 @@ export default function Sidebar() {
3734 // Snoozing the open thread moves you forward, same as settle —3731 // Snoozing the open thread moves you forward, same as settle —
3735 // both park the thread you're done with for now.3732 // both park the thread you're done with for now.
3736 const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys);3733 const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys);
3737 const result = await snoozeThread(threadRef, preset.snoozedUntil);3734 const result = await snoozeThread(threadRef, preset.snoozedUntil, {
3735 undoToast: opts.coSnoozingKeys === undefined,
3736 });
3738 if (result._tag === "Failure") {3737 if (result._tag === "Failure") {
3739 // Never navigate away from a thread that did not snooze.3738 // Never navigate away from a thread that did not snooze.
3740 return isAtomCommandInterrupted(result)3739 return isAtomCommandInterrupted(result)
...@@ -3780,23 +3779,9 @@ export default function Sidebar() {...@@ -3780,23 +3779,9 @@ export default function Sidebar() {
3780 );3779 );
3781 return;3780 return;
3782 }3781 }
3783 if (outcome.status !== "success") return;
3784 // Snooze hides the row, so the toast is the only confirmation —
3785 // and the Undo is the escape hatch for a mis-click.
3786 toastManager.add(
3787 stackedThreadToast({
3788 type: "success",
3789 title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`,
3790 timeout: 5_000,
3791 actionProps: {
3792 children: "Undo",
3793 onClick: () => attemptUnsnooze(threadRef),
3794 },
3795 }),
3796 );
3797 })();3782 })();
3798 },3783 },
3799 [attemptUnsnooze, performSnooze, timestampFormat],3784 [performSnooze],
3800 );3785 );
38013786
3802 const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);3787 const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);
apps/web/src/hooks/showUndoToast.test.ts+33-1
...@@ -3,7 +3,7 @@ import * as Cause from "effect/Cause";...@@ -3,7 +3,7 @@ import * as Cause from "effect/Cause";
3import { afterEach, describe, expect, it, vi } from "vite-plus/test";3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
44
5import { toastManager } from "../components/ui/toast";5import { toastManager } from "../components/ui/toast";
6import { showUndoToast } from "./showUndoToast";6import { showUndoToast, undoLatestThreadAction } from "./showUndoToast";
7import * as ThreadUndo from "./threadUndo";7import * as ThreadUndo from "./threadUndo";
88
9afterEach(() => vi.restoreAllMocks());9afterEach(() => vi.restoreAllMocks());
...@@ -93,3 +93,35 @@ describe("showUndoToast", () => {...@@ -93,3 +93,35 @@ describe("showUndoToast", () => {
93 expect(add).toHaveBeenCalledOnce();93 expect(add).toHaveBeenCalledOnce();
94 });94 });
95});95});
96
97describe("undoLatestThreadAction", () => {
98 it("runs the newest live Undo once and then reports nothing to undo", () => {
99 const { options } = setup();
100 const older = vi.fn(async () => AsyncResult.success(undefined));
101 const newer = vi.fn(async () => AsyncResult.success(undefined));
102 showUndoToast({ ...options, undo: older, claim: ThreadUndo.begin("settle", "env/a") });
103 showUndoToast({ ...options, undo: newer, claim: ThreadUndo.begin("snooze", "env/b") });
104 expect(undoLatestThreadAction()).toBe(true);
105 expect(newer).toHaveBeenCalledOnce();
106 expect(older).not.toHaveBeenCalled();
107 expect(undoLatestThreadAction()).toBe(true);
108 expect(older).toHaveBeenCalledOnce();
109 expect(undoLatestThreadAction()).toBe(false);
110 });
111
112 it("skips a superseded toast and a closed toast", () => {
113 const { add, options } = setup();
114 const superseded = vi.fn(async () => AsyncResult.success(undefined));
115 const closed = vi.fn(async () => AsyncResult.success(undefined));
116 const live = vi.fn(async () => AsyncResult.success(undefined));
117 showUndoToast({ ...options, undo: live, claim: ThreadUndo.begin("archive", "env/live") });
118 showUndoToast({ ...options, undo: closed, claim: ThreadUndo.begin("archive", "env/closed") });
119 add.mock.calls[1]?.[0].onClose?.();
120 showUndoToast({ ...options, undo: superseded, claim: ThreadUndo.begin("pin", "env/stale") });
121 ThreadUndo.invalidate("pin", "env/stale");
122 expect(undoLatestThreadAction()).toBe(true);
123 expect(superseded).not.toHaveBeenCalled();
124 expect(closed).not.toHaveBeenCalled();
125 expect(live).toHaveBeenCalledOnce();
126 });
127});
apps/web/src/hooks/showUndoToast.ts+40-14
...@@ -7,6 +7,20 @@ import {...@@ -7,6 +7,20 @@ import {
7import { stackedThreadToast, toastManager } from "../components/ui/toast";7import { stackedThreadToast, toastManager } from "../components/ui/toast";
8import type * as ThreadUndo from "./threadUndo";8import type * as ThreadUndo from "./threadUndo";
99
10// Undo toasts still on screen, oldest first, so the `thread.undo` shortcut
11// mirrors the newest toast's button without knowing which action it was.
12const liveUndos: Array<() => Promise<void> | null> = [];
13
14/** Runs the newest Undo whose claim still holds; false when nothing is left to undo. */
15export function undoLatestThreadAction(): boolean {
16 // A superseded entry drops itself when tried, so keep going until one
17 // runs or the list is empty.
18 while (liveUndos.length > 0) {
19 if (liveUndos[liveUndos.length - 1]?.() !== null) return true;
20 }
21 return false;
22}
23
10/** Shows a single-use Undo while its thread action still owns the claim. */24/** Shows a single-use Undo while its thread action still owns the claim. */
11export function showUndoToast({25export function showUndoToast({
12 title,26 title,
...@@ -23,6 +37,7 @@ export function showUndoToast({...@@ -23,6 +37,7 @@ export function showUndoToast({
23}) {37}) {
24 if (!claim.isCurrent()) return;38 if (!claim.isCurrent()) return;
25 let undoStarted = false;39 let undoStarted = false;
40 let toastId: string | undefined;
26 const reportFailure = (error: unknown) => {41 const reportFailure = (error: unknown) => {
27 toastManager.add(42 toastManager.add(
28 stackedThreadToast({43 stackedThreadToast({
...@@ -32,7 +47,26 @@ export function showUndoToast({...@@ -32,7 +47,26 @@ export function showUndoToast({
32 }),47 }),
33 );48 );
34 };49 };
35 const toastId = toastManager.add({50 const forget = () => {
51 const index = liveUndos.indexOf(run);
52 if (index !== -1) liveUndos.splice(index, 1);
53 };
54 const run = () => {
55 forget();
56 if (undoStarted || !claim.isCurrent()) return null;
57 undoStarted = true;
58 claim.finish();
59 if (toastId !== undefined) toastManager.close(toastId);
60 return undo()
61 .then((result) => {
62 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
63 reportFailure(squashAtomCommandFailure(result));
64 }
65 })
66 .catch(reportFailure);
67 };
68 liveUndos.push(run);
69 toastId = toastManager.add({
36 ...stackedThreadToast({70 ...stackedThreadToast({
37 type: "success",71 type: "success",
38 title,72 title,
...@@ -41,21 +75,13 @@ export function showUndoToast({...@@ -41,21 +75,13 @@ export function showUndoToast({
41 actionProps: {75 actionProps: {
42 children: "Undo",76 children: "Undo",
43 onClick: async () => {77 onClick: async () => {
44 if (undoStarted || !claim.isCurrent()) return;78 await run();
45 undoStarted = true;
46 claim.finish();
47 toastManager.close(toastId);
48 try {
49 const result = await undo();
50 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
51 reportFailure(squashAtomCommandFailure(result));
52 }
53 } catch (error) {
54 reportFailure(error);
55 }
56 },79 },
57 },80 },
58 }),81 }),
59 onClose: claim.finish,82 onClose: () => {
83 claim.finish();
84 forget();
85 },
60 });86 });
61}87}
apps/web/src/hooks/useThreadActionMenu.ts+3-23
...@@ -11,7 +11,7 @@ import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";...@@ -11,7 +11,7 @@ import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";
11import { useRouter } from "@tanstack/react-router";11import { useRouter } from "@tanstack/react-router";
12import { useCallback, useMemo } from "react";12import { useCallback, useMemo } from "react";
1313
14import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze";14import { resolveSnoozePresets } from "../components/Sidebar.snooze";
15import {15import {
16 buildThreadActionMenuItems,16 buildThreadActionMenuItems,
17 type ThreadActionMenuId,17 type ThreadActionMenuId,
...@@ -162,29 +162,9 @@ export function useThreadActionMenu(input: {...@@ -162,29 +162,9 @@ export function useThreadActionMenu(input: {
162 : snoozePresets.find((candidate) => `snooze:${candidate.id}` === action);162 : snoozePresets.find((candidate) => `snooze:${candidate.id}` === action);
163 if (!preset) return;163 if (!preset) return;
164 const result = await snoozeThread(threadRef, preset.snoozedUntil);164 const result = await snoozeThread(threadRef, preset.snoozedUntil);
165 if (result._tag === "Failure") {165 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
166 if (!isAtomCommandInterrupted(result)) {166 failureToast("Failed to snooze thread", squashAtomCommandFailure(result));
167 failureToast("Failed to snooze thread", squashAtomCommandFailure(result));
168 }
169 return;
170 }167 }
171 toastManager.add(
172 stackedThreadToast({
173 type: "success",
174 title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`,
175 timeout: 5_000,
176 actionProps: {
177 children: "Undo",
178 onClick: () => {
179 void unsnoozeThread(threadRef).then((undone) => {
180 if (undone._tag === "Failure" && !isAtomCommandInterrupted(undone)) {
181 failureToast("Failed to wake thread", squashAtomCommandFailure(undone));
182 }
183 });
184 },
185 },
186 }),
187 );
188 return;168 return;
189 }169 }
190 const reportFailure = async (170 const reportFailure = async (
apps/web/src/hooks/useThreadActions.ts+168-68
...@@ -15,6 +15,7 @@ import { useRouter } from "@tanstack/react-router";...@@ -15,6 +15,7 @@ import { useRouter } from "@tanstack/react-router";
15import { useCallback, useMemo, useRef } from "react";15import { useCallback, useMemo, useRef } from "react";
1616
17import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic";17import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic";
18import { snoozeWakeDescription } from "../components/Sidebar.snooze";
18import { useComposerDraftStore } from "../composerDraftStore";19import { useComposerDraftStore } from "../composerDraftStore";
19import { terminalEnvironment } from "../state/terminal";20import { terminalEnvironment } from "../state/terminal";
20import { appAtomRegistry } from "../rpc/atomRegistry";21import { appAtomRegistry } from "../rpc/atomRegistry";
...@@ -222,6 +223,7 @@ export function useThreadActions() {...@@ -222,6 +223,7 @@ export function useThreadActions() {
222 const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder);223 const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder);
223 const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete);224 const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete);
224 const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin);225 const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin);
226 const timestampFormat = useClientSettings((settings) => settings.timestampFormat);
225 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);227 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
226 const clearProjectDraftThreadById = useComposerDraftStore(228 const clearProjectDraftThreadById = useComposerDraftStore(
227 (store) => store.clearProjectDraftThreadById,229 (store) => store.clearProjectDraftThreadById,
...@@ -252,6 +254,30 @@ export function useThreadActions() {...@@ -252,6 +254,30 @@ export function useThreadActions() {
252 return resolveThreadRouteRef(currentRouteParams);254 return resolveThreadRouteRef(currentRouteParams);
253 }, [router]);255 }, [router]);
254256
257 const unarchiveThread = useCallback(
258 async (target: ScopedThreadRef, opts: { navigate?: boolean } = {}) => {
259 ThreadUndo.invalidate("archive", scopedThreadKey(target));
260 const result = await unarchiveThreadMutation({
261 environmentId: target.environmentId,
262 input: { threadId: target.threadId },
263 });
264 if (result._tag === "Failure") {
265 return result;
266 }
267 refreshArchivedThreadsForEnvironment(target.environmentId);
268 if (opts.navigate) {
269 return settlePromise(() =>
270 router.navigate({
271 to: "/$environmentId/$threadId",
272 params: buildThreadRouteParams(target),
273 }),
274 );
275 }
276 return result;
277 },
278 [router, unarchiveThreadMutation],
279 );
280
255 const archiveThread = useCallback(281 const archiveThread = useCallback(
256 async (target: ScopedThreadRef, opts: { onArchived?: () => void } = {}) => {282 async (target: ScopedThreadRef, opts: { onArchived?: () => void } = {}) => {
257 const resolved = resolveThreadTarget(target);283 const resolved = resolveThreadTarget(target);
...@@ -272,11 +298,13 @@ export function useThreadActions() {...@@ -272,11 +298,13 @@ export function useThreadActions() {
272 const shouldNavigateToDraft =298 const shouldNavigateToDraft =
273 currentRouteThreadRef?.threadId === threadRef.threadId &&299 currentRouteThreadRef?.threadId === threadRef.threadId &&
274 currentRouteThreadRef.environmentId === threadRef.environmentId;300 currentRouteThreadRef.environmentId === threadRef.environmentId;
301 const action = ThreadUndo.begin("archive", scopedThreadKey(threadRef));
275 const archiveResult = await archiveThreadMutation({302 const archiveResult = await archiveThreadMutation({
276 environmentId: threadRef.environmentId,303 environmentId: threadRef.environmentId,
277 input: { threadId: threadRef.threadId },304 input: { threadId: threadRef.threadId },
278 });305 });
279 if (archiveResult._tag === "Failure") {306 if (archiveResult._tag === "Failure") {
307 action.finish();
280 return archiveResult;308 return archiveResult;
281 }309 }
282 const wokeAt = threadWokeAt(thread, { now: new Date().toISOString() });310 const wokeAt = threadWokeAt(thread, { now: new Date().toISOString() });
...@@ -285,6 +313,14 @@ export function useThreadActions() {...@@ -285,6 +313,14 @@ export function useThreadActions() {
285 }313 }
286 refreshArchivedThreadsForEnvironment(threadRef.environmentId);314 refreshArchivedThreadsForEnvironment(threadRef.environmentId);
287 opts.onArchived?.();315 opts.onArchived?.();
316 showUndoToast({
317 title: "Thread archived",
318 description: thread.title,
319 claim: action,
320 // Undo also brings the reader back when archiving moved them to a draft.
321 undo: () => unarchiveThread(threadRef, { navigate: shouldNavigateToDraft }),
322 failureTitle: "Failed to undo archive",
323 });
288324
289 if (shouldNavigateToDraft) {325 if (shouldNavigateToDraft) {
290 const navigationResult = await settlePromise(() =>326 const navigationResult = await settlePromise(() =>
...@@ -298,21 +334,13 @@ export function useThreadActions() {...@@ -298,21 +334,13 @@ export function useThreadActions() {
298334
299 return archiveResult;335 return archiveResult;
300 },336 },
301 [archiveThreadMutation, getCurrentRouteThreadRef, markThreadVisited, resolveThreadTarget],337 [
302 );338 archiveThreadMutation,
303339 getCurrentRouteThreadRef,
304 const unarchiveThread = useCallback(340 markThreadVisited,
305 async (target: ScopedThreadRef) => {341 resolveThreadTarget,
306 const result = await unarchiveThreadMutation({342 unarchiveThread,
307 environmentId: target.environmentId,343 ],
308 input: { threadId: target.threadId },
309 });
310 if (result._tag === "Success") {
311 refreshArchivedThreadsForEnvironment(target.environmentId);
312 }
313 return result;
314 },
315 [unarchiveThreadMutation],
316 );344 );
317345
318 const deleteThread = useCallback(346 const deleteThread = useCallback(
...@@ -508,38 +536,6 @@ export function useThreadActions() {...@@ -508,38 +536,6 @@ export function useThreadActions() {
508 ],536 ],
509 );537 );
510538
511 const settleThread = useCallback(
512 async (target: ScopedThreadRef) => {
513 // Version skew: never send the command to a server that predates it —
514 // the raw protocol rejection would read as a random failure.
515 if (!readEnvironmentSupportsSettlement(target.environmentId)) {
516 return AsyncResult.failure(
517 Cause.fail(
518 new ThreadSettlementUnsupportedError({
519 environmentId: target.environmentId,
520 threadId: target.threadId,
521 }),
522 ),
523 );
524 }
525 const resolved = resolveThreadTarget(target);
526 const wokeAt = resolved
527 ? threadWokeAt(resolved.thread, { now: new Date().toISOString() })
528 : null;
529 // Settle is a high-frequency lifecycle action and stays silent — no
530 // toast.
531 const result = await settleThreadMutation({
532 environmentId: target.environmentId,
533 input: { threadId: target.threadId },
534 });
535 if (result._tag === "Success" && wokeAt !== null) {
536 markThreadVisited(scopedThreadKey(target), wokeAt);
537 }
538 return result;
539 },
540 [markThreadVisited, resolveThreadTarget, settleThreadMutation],
541 );
542
543 const unsettleThread = useCallback(539 const unsettleThread = useCallback(
544 async (target: ScopedThreadRef) => {540 async (target: ScopedThreadRef) => {
545 if (!readEnvironmentSupportsSettlement(target.environmentId)) {541 if (!readEnvironmentSupportsSettlement(target.environmentId)) {
...@@ -552,6 +548,7 @@ export function useThreadActions() {...@@ -552,6 +548,7 @@ export function useThreadActions() {
552 ),548 ),
553 );549 );
554 }550 }
551 ThreadUndo.invalidate("settle", scopedThreadKey(target));
555 // reason "user" pins the thread active: auto-settle (PR merged /552 // reason "user" pins the thread active: auto-settle (PR merged /
556 // inactivity) stays suppressed until real activity clears the pin.553 // inactivity) stays suppressed until real activity clears the pin.
557 return unsettleThreadMutation({554 return unsettleThreadMutation({
...@@ -631,6 +628,89 @@ export function useThreadActions() {...@@ -631,6 +628,89 @@ export function useThreadActions() {
631 [pinThread, unpinThreadMutation],628 [pinThread, unpinThreadMutation],
632 );629 );
633630
631 const settleThread = useCallback(
632 async (
633 target: ScopedThreadRef,
634 // Batch callers settle a selection at once and stay silent as before.
635 opts: { undoToast?: boolean } = {},
636 ) => {
637 // Version skew: never send the command to a server that predates it —
638 // the raw protocol rejection would read as a random failure.
639 if (!readEnvironmentSupportsSettlement(target.environmentId)) {
640 return AsyncResult.failure(
641 Cause.fail(
642 new ThreadSettlementUnsupportedError({
643 environmentId: target.environmentId,
644 threadId: target.threadId,
645 }),
646 ),
647 );
648 }
649 const resolved = resolveThreadTarget(target);
650 const wokeAt = resolved
651 ? threadWokeAt(resolved.thread, { now: new Date().toISOString() })
652 : null;
653 // Settling also drops the pin and the snooze server-side, so Undo
654 // has to put those back as well.
655 const pinOrderKey = resolved?.thread.pinnedAt != null ? resolved.thread.pinOrderKey : null;
656 const wasPinned = resolved?.thread.pinnedAt != null;
657 const snoozedUntil = resolved?.thread.snoozedUntil ?? null;
658 // An older unpin/snooze Undo would re-pin or re-snooze, and the server
659 // treats either as a promotion that un-settles; settling supersedes them.
660 ThreadUndo.invalidate("pin", scopedThreadKey(target));
661 ThreadUndo.invalidate("snooze", scopedThreadKey(target));
662 const action = ThreadUndo.begin("settle", scopedThreadKey(target));
663 const result = await settleThreadMutation({
664 environmentId: target.environmentId,
665 input: { threadId: target.threadId },
666 });
667 if (result._tag !== "Success") {
668 action.finish();
669 return result;
670 }
671 if (wokeAt !== null) {
672 markThreadVisited(scopedThreadKey(target), wokeAt);
673 }
674 if (opts.undoToast === false) {
675 action.finish();
676 return result;
677 }
678 showUndoToast({
679 title: "Thread settled",
680 description: resolved?.thread.title,
681 claim: action,
682 undo: async () => {
683 const unsettled = await unsettleThread(target);
684 if (unsettled._tag !== "Success") return unsettled;
685 if (wasPinned) {
686 const pinned = await pinThread(
687 target,
688 pinOrderKey == null ? {} : { orderKey: pinOrderKey },
689 );
690 if (pinned._tag !== "Success") return pinned;
691 }
692 if (snoozedUntil !== null) {
693 return snoozeThreadMutation({
694 environmentId: target.environmentId,
695 input: { threadId: target.threadId, snoozedUntil },
696 });
697 }
698 return unsettled;
699 },
700 failureTitle: "Failed to undo settle",
701 });
702 return result;
703 },
704 [
705 markThreadVisited,
706 pinThread,
707 resolveThreadTarget,
708 settleThreadMutation,
709 snoozeThreadMutation,
710 unsettleThread,
711 ],
712 );
713
634 const confirmAndUnpinThread = useCallback(714 const confirmAndUnpinThread = useCallback(
635 async (target: ScopedThreadRef) => {715 async (target: ScopedThreadRef) => {
636 const localApi = readLocalApi();716 const localApi = readLocalApi();
...@@ -695,8 +775,34 @@ export function useThreadActions() {...@@ -695,8 +775,34 @@ export function useThreadActions() {
695 [reorderActiveThreadMutation],775 [reorderActiveThreadMutation],
696 );776 );
697777
778 const unsnoozeThread = useCallback(
779 async (target: ScopedThreadRef) => {
780 if (!readEnvironmentSupportsSnooze(target.environmentId)) {
781 return AsyncResult.failure(
782 Cause.fail(
783 new ThreadSnoozeUnsupportedError({
784 environmentId: target.environmentId,
785 threadId: target.threadId,
786 }),
787 ),
788 );
789 }
790 ThreadUndo.invalidate("snooze", scopedThreadKey(target));
791 return unsnoozeThreadMutation({
792 environmentId: target.environmentId,
793 input: { threadId: target.threadId, reason: "user" },
794 });
795 },
796 [unsnoozeThreadMutation],
797 );
798
698 const snoozeThread = useCallback(799 const snoozeThread = useCallback(
699 async (target: ScopedThreadRef, snoozedUntil: string) => {800 async (
801 target: ScopedThreadRef,
802 snoozedUntil: string,
803 // Batch callers report one toast for the whole selection instead.
804 opts: { undoToast?: boolean } = {},
805 ) => {
700 // Version skew: never send the command to a server that predates it.806 // Version skew: never send the command to a server that predates it.
701 if (!readEnvironmentSupportsSnooze(target.environmentId)) {807 if (!readEnvironmentSupportsSnooze(target.environmentId)) {
702 return AsyncResult.failure(808 return AsyncResult.failure(
...@@ -722,32 +828,26 @@ export function useThreadActions() {...@@ -722,32 +828,26 @@ export function useThreadActions() {
722 ),828 ),
723 );829 );
724 }830 }
725 return snoozeThreadMutation({831 const action = ThreadUndo.begin("snooze", scopedThreadKey(target));
832 const result = await snoozeThreadMutation({
726 environmentId: target.environmentId,833 environmentId: target.environmentId,
727 input: { threadId: target.threadId, snoozedUntil },834 input: { threadId: target.threadId, snoozedUntil },
728 });835 });
729 },836 if (result._tag !== "Success" || opts.undoToast === false) {
730 [resolveThreadTarget, snoozeThreadMutation],837 action.finish();
731 );838 return result;
732
733 const unsnoozeThread = useCallback(
734 async (target: ScopedThreadRef) => {
735 if (!readEnvironmentSupportsSnooze(target.environmentId)) {
736 return AsyncResult.failure(
737 Cause.fail(
738 new ThreadSnoozeUnsupportedError({
739 environmentId: target.environmentId,
740 threadId: target.threadId,
741 }),
742 ),
743 );
744 }839 }
745 return unsnoozeThreadMutation({840 // Snooze hides the row, so the toast is the only confirmation.
746 environmentId: target.environmentId,841 showUndoToast({
747 input: { threadId: target.threadId, reason: "user" },842 title: `Snoozed until ${snoozeWakeDescription(snoozedUntil, new Date(), timestampFormat)}`,
843 description: resolved?.thread.title,
844 claim: action,
845 undo: () => unsnoozeThread(target),
846 failureTitle: "Failed to wake thread",
748 });847 });
848 return result;
749 },849 },
750 [unsnoozeThreadMutation],850 [resolveThreadTarget, snoozeThreadMutation, timestampFormat, unsnoozeThread],
751 );851 );
752852
753 const confirmAndDeleteThread = useCallback(853 const confirmAndDeleteThread = useCallback(
apps/web/src/hooks/useThreadActions.undo.test.ts+188-24
...@@ -1,69 +1,233 @@...@@ -1,69 +1,233 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
33
4import { useThreadActions } from "./useThreadActions";4import { useThreadActions } from "./useThreadActions";
5import { threadEnvironment } from "../state/threads";5import { threadEnvironment } from "../state/threads";
6import { toastManager } from "../components/ui/toast";6import { toastManager } from "../components/ui/toast";
77
8const commands = vi.hoisted(() => ({ pin: vi.fn(), unpin: vi.fn() }));8const commands = vi.hoisted(() => ({
9 pin: vi.fn(),
10 unpin: vi.fn(),
11 archive: vi.fn(),
12 unarchive: vi.fn(),
13 settle: vi.fn(),
14 unsettle: vi.fn(),
15 snooze: vi.fn(),
16 unsnooze: vi.fn(),
17}));
18const router = vi.hoisted(() => ({
19 navigate: vi.fn(async () => {}),
20 state: { matches: [{ params: {} as Record<string, string> }] },
21}));
9vi.mock("react", async (original) => ({22vi.mock("react", async (original) => ({
10 ...(await original<typeof import("react")>()),23 ...(await original<typeof import("react")>()),
11 useCallback: (callback: unknown) => callback,24 useCallback: (callback: unknown) => callback,
12 useMemo: (create: () => unknown) => create(),25 useMemo: (create: () => unknown) => create(),
13 useRef: (value: unknown) => ({ current: value }),26 useRef: (value: unknown) => ({ current: value }),
14}));27}));
15vi.mock("@tanstack/react-router", () => ({ useRouter: () => ({}) }));28vi.mock("@tanstack/react-router", () => ({ useRouter: () => router }));
16vi.mock("./useSettings", () => ({ useClientSettings: () => false }));29vi.mock("./useSettings", () => ({ useClientSettings: () => false }));
17vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() }));30vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() }));
18vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() }));31vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() }));
19vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() }));32vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() }));
20vi.mock("../uiStateStore", () => ({ useUiStateStore: () => vi.fn() }));33vi.mock("../uiStateStore", () => ({ useUiStateStore: () => vi.fn() }));
34vi.mock("../lib/archivedThreadsState", () => ({ refreshArchivedThreadsForEnvironment: vi.fn() }));
35const threadShell = vi.hoisted(() => ({
36 title: "Thread",
37 pinOrderKey: "a0",
38 pinnedAt: null as string | null,
39 snoozedUntil: null as string | null,
40 projectId: "project",
41 environmentId: "undo-env",
42 session: null,
43}));
21vi.mock("../state/entities", async (original) => ({44vi.mock("../state/entities", async (original) => ({
22 ...(await original<typeof import("../state/entities")>()),45 ...(await original<typeof import("../state/entities")>()),
23 readEnvironmentSupportsPinning: () => true,46 readEnvironmentSupportsPinning: () => true,
24 readEnvironmentSupportsPinReorder: () => true,47 readEnvironmentSupportsPinReorder: () => true,
25 readThreadShell: () => ({ title: "Thread", pinOrderKey: "a0" }),48 readEnvironmentSupportsSettlement: () => true,
49 readEnvironmentSupportsSnooze: () => true,
50 readThreadShell: () => threadShell,
26}));51}));
27vi.mock("../state/use-atom-command", () => ({52vi.mock("../state/use-atom-command", () => ({
28 useAtomCommand: (command: unknown) =>53 useAtomCommand: (command: unknown) => {
29 command === threadEnvironment.pin54 switch (command) {
30 ? commands.pin55 case threadEnvironment.pin:
31 : command === threadEnvironment.unpin56 return commands.pin;
32 ? commands.unpin57 case threadEnvironment.unpin:
33 : vi.fn(),58 return commands.unpin;
59 case threadEnvironment.archive:
60 return commands.archive;
61 case threadEnvironment.unarchive:
62 return commands.unarchive;
63 case threadEnvironment.settle:
64 return commands.settle;
65 case threadEnvironment.unsettle:
66 return commands.unsettle;
67 case threadEnvironment.snooze:
68 return commands.snooze;
69 case threadEnvironment.unsnooze:
70 return commands.unsnooze;
71 default:
72 return vi.fn();
73 }
74 },
34}));75}));
3576
77const target = {
78 environmentId: EnvironmentId.make("undo-env"),
79 threadId: ThreadId.make("thread"),
80};
81const event = {} as Parameters<NonNullable<React.ComponentProps<"button">["onClick"]>>[0];
82
83function undoOf(
84 add: { mock: { calls: Array<[Parameters<typeof toastManager.add>[0]]> } },
85 index: number,
86) {
87 const onClick = add.mock.calls[index]?.[0].actionProps?.onClick;
88 expect(onClick).toBeTypeOf("function");
89 return () => onClick?.(event);
90}
91
92beforeEach(() => {
93 for (const command of Object.values(commands)) {
94 command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined });
95 }
96 router.navigate.mockClear();
97 router.state.matches[0]!.params = {};
98 threadShell.pinnedAt = null;
99 threadShell.snoozedUntil = null;
100});
36afterEach(() => vi.restoreAllMocks());101afterEach(() => vi.restoreAllMocks());
37102
38describe("unpin Undo", () => {103describe("unpin Undo", () => {
39 it("ignores an old toast across hook instances and still restores the latest unpin", async () => {104 it("ignores an old toast across hook instances and still restores the latest unpin", async () => {
40 commands.pin.mockResolvedValue({ _tag: "Success", value: undefined });
41 commands.unpin.mockResolvedValue({ _tag: "Success", value: undefined });
42 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");105 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
43 vi.spyOn(toastManager, "close").mockImplementation(() => {});106 vi.spyOn(toastManager, "close").mockImplementation(() => {});
44 const sidebar = useThreadActions();107 const sidebar = useThreadActions();
45 const header = useThreadActions();108 const header = useThreadActions();
46 const target = {
47 environmentId: EnvironmentId.make("undo-env"),
48 threadId: ThreadId.make("thread"),
49 };
50 await sidebar.unpinThread(target);109 await sidebar.unpinThread(target);
51 const staleUndo = add.mock.calls[0]?.[0].actionProps?.onClick;110 const staleUndo = undoOf(add, 0);
52 await header.pinThread(target, { orderKey: "a1" });111 await header.pinThread(target, { orderKey: "a1" });
53 await header.unpinThread(target);112 await header.unpinThread(target);
54 const latestUndo = add.mock.calls[1]?.[0].actionProps?.onClick;113 const latestUndo = undoOf(add, 1);
55 expect(staleUndo).toBeTypeOf("function");114 await staleUndo();
56 expect(latestUndo).toBeTypeOf("function");
57 const event = {} as Parameters<NonNullable<typeof staleUndo>>[0];
58 staleUndo?.(event);
59 expect(commands.pin).toHaveBeenCalledTimes(1);115 expect(commands.pin).toHaveBeenCalledTimes(1);
60 latestUndo?.(event);116 await latestUndo();
61 expect(commands.pin).toHaveBeenCalledTimes(2);117 expect(commands.pin).toHaveBeenCalledTimes(2);
62 expect(commands.pin).toHaveBeenLastCalledWith({118 expect(commands.pin).toHaveBeenLastCalledWith({
63 environmentId: target.environmentId,119 environmentId: target.environmentId,
64 input: { threadId: target.threadId, orderKey: "a0" },120 input: { threadId: target.threadId, orderKey: "a0" },
65 });121 });
66 latestUndo?.(event);122 await latestUndo();
67 expect(commands.pin).toHaveBeenCalledTimes(2);123 expect(commands.pin).toHaveBeenCalledTimes(2);
68 });124 });
69});125});
126
127describe("archive Undo", () => {
128 it("unarchives and returns to the thread when archiving left it", async () => {
129 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
130 vi.spyOn(toastManager, "close").mockImplementation(() => {});
131 router.state.matches[0]!.params = {
132 environmentId: target.environmentId,
133 threadId: target.threadId,
134 };
135 const actions = useThreadActions();
136 await actions.archiveThread(target);
137 expect(add).toHaveBeenCalledWith(expect.objectContaining({ title: "Thread archived" }));
138 await undoOf(add, 0)();
139 expect(commands.unarchive).toHaveBeenCalledExactlyOnceWith({
140 environmentId: target.environmentId,
141 input: { threadId: target.threadId },
142 });
143 expect(router.navigate).toHaveBeenCalledWith(
144 expect.objectContaining({
145 to: "/$environmentId/$threadId",
146 params: { environmentId: target.environmentId, threadId: target.threadId },
147 }),
148 );
149 });
150
151 it("stays put when the archived thread was not open", async () => {
152 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
153 vi.spyOn(toastManager, "close").mockImplementation(() => {});
154 const actions = useThreadActions();
155 await actions.archiveThread(target);
156 await undoOf(add, 0)();
157 expect(commands.unarchive).toHaveBeenCalledOnce();
158 expect(router.navigate).not.toHaveBeenCalled();
159 });
160
161 it("shows no Undo when the archive failed", async () => {
162 commands.archive.mockResolvedValue({ _tag: "Failure", cause: new Error("nope") });
163 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
164 await useThreadActions().archiveThread(target);
165 expect(add).not.toHaveBeenCalled();
166 });
167});
168
169describe("settle and snooze Undo", () => {
170 it("un-settles from the toast and expires the Undo after a manual un-settle", async () => {
171 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
172 vi.spyOn(toastManager, "close").mockImplementation(() => {});
173 const actions = useThreadActions();
174 await actions.settleThread(target);
175 expect(add).toHaveBeenCalledWith(expect.objectContaining({ title: "Thread settled" }));
176 const undo = undoOf(add, 0);
177 await actions.unsettleThread(target);
178 await undo();
179 expect(commands.unsettle).toHaveBeenCalledOnce();
180 });
181
182 it("re-pins and re-snoozes a thread that settling had cleared", async () => {
183 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
184 vi.spyOn(toastManager, "close").mockImplementation(() => {});
185 const snoozedUntil = "2030-01-01T09:00:00.000Z";
186 threadShell.pinnedAt = "2026-01-01T00:00:00.000Z";
187 threadShell.snoozedUntil = snoozedUntil;
188 const actions = useThreadActions();
189 await actions.settleThread(target);
190 await undoOf(add, 0)();
191 expect(commands.unsettle).toHaveBeenCalledOnce();
192 expect(commands.pin).toHaveBeenCalledExactlyOnceWith({
193 environmentId: target.environmentId,
194 input: { threadId: target.threadId, orderKey: "a0" },
195 });
196 expect(commands.snooze).toHaveBeenCalledExactlyOnceWith({
197 environmentId: target.environmentId,
198 input: { threadId: target.threadId, snoozedUntil },
199 });
200 });
201
202 it("expires an older unpin Undo when the thread is settled", async () => {
203 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
204 vi.spyOn(toastManager, "close").mockImplementation(() => {});
205 const actions = useThreadActions();
206 await actions.unpinThread(target);
207 const staleUnpinUndo = undoOf(add, 0);
208 await actions.settleThread(target);
209 await staleUnpinUndo();
210 expect(commands.pin).not.toHaveBeenCalled();
211 });
212
213 it("stays silent for batch settles", async () => {
214 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
215 await useThreadActions().settleThread(target, { undoToast: false });
216 expect(add).not.toHaveBeenCalled();
217 });
218
219 it("wakes the thread from the snooze toast", async () => {
220 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
221 vi.spyOn(toastManager, "close").mockImplementation(() => {});
222 const actions = useThreadActions();
223 await actions.snoozeThread(target, new Date(Date.now() + 60_000).toISOString());
224 expect(add).toHaveBeenCalledWith(
225 expect.objectContaining({ title: expect.stringMatching(/^Snoozed until /) }),
226 );
227 await undoOf(add, 0)();
228 expect(commands.unsnooze).toHaveBeenCalledExactlyOnceWith({
229 environmentId: target.environmentId,
230 input: { threadId: target.threadId, reason: "user" },
231 });
232 });
233});
apps/web/src/keybindings.test.ts+27
...@@ -226,6 +226,33 @@ describe("settle thread shortcut", () => {...@@ -226,6 +226,33 @@ describe("settle thread shortcut", () => {
226 });226 });
227});227});
228228
229describe("thread undo shortcut", () => {
230 it("resolves mod+z with nothing editable focused", () => {
231 assert.equal(
232 resolveShortcutCommand(event({ key: "z", metaKey: true }), DEFAULT_RESOLVED_KEYBINDINGS, {
233 platform: "MacIntel",
234 context: { terminalFocus: false, editableFocus: false },
235 }),
236 "thread.undo",
237 );
238 });
239
240 it("leaves native undo alone inside text fields and terminals", () => {
241 assert.isNull(
242 resolveShortcutCommand(event({ key: "z", ctrlKey: true }), DEFAULT_RESOLVED_KEYBINDINGS, {
243 platform: "Win32",
244 context: { editableFocus: true },
245 }),
246 );
247 assert.isNull(
248 resolveShortcutCommand(event({ key: "z", ctrlKey: true }), DEFAULT_RESOLVED_KEYBINDINGS, {
249 platform: "Win32",
250 context: { terminalFocus: true },
251 }),
252 );
253 });
254});
255
229describe("copy thread reference shortcut", () => {256describe("copy thread reference shortcut", () => {
230 it("resolves Cmd+Shift+C on macOS and Ctrl+Shift+C elsewhere", () => {257 it("resolves Cmd+Shift+C on macOS and Ctrl+Shift+C elsewhere", () => {
231 assert.equal(258 assert.equal(
apps/web/src/keybindings.ts+4
...@@ -36,6 +36,9 @@ export interface ShortcutMatchContext {...@@ -36,6 +36,9 @@ export interface ShortcutMatchContext {
36 previewOpen: boolean;36 previewOpen: boolean;
37 isWeb: boolean;37 isWeb: boolean;
38 isDesktop: boolean;38 isDesktop: boolean;
39 /** A text field, textarea, select or rich-text editor owns the keyboard.
40 Optional: only chords that collide with native editing consult it. */
41 editableFocus?: boolean;
39 [key: string]: boolean;42 [key: string]: boolean;
40}43}
4144
...@@ -149,6 +152,7 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc...@@ -149,6 +152,7 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc
149 previewOpen: false,152 previewOpen: false,
150 isWeb: !isElectron,153 isWeb: !isElectron,
151 isDesktop: isElectron,154 isDesktop: isElectron,
155 editableFocus: false,
152 ...options?.context,156 ...options?.context,
153 };157 };
154}158}
apps/web/src/lib/editableFocus.ts created+17
...@@ -0,0 +1,17 @@
1const EDITABLE_SELECTOR = [
2 "input",
3 "textarea",
4 "select",
5 '[contenteditable=""]',
6 '[contenteditable="true"]',
7 '[contenteditable="plaintext-only"]',
8 '[role="textbox"]',
9].join(",");
10
11/**
12 * Whether a text-editing element owns the keyboard. Shortcuts that share
13 * their chord with native editing (mod+z) must yield when this is true.
14 */
15export function isEditableFocused(target: EventTarget | null = document.activeElement): boolean {
16 return target instanceof Element && target.closest(EDITABLE_SELECTOR) !== null;
17}
docs/user/keybindings.md+9-3
...@@ -84,9 +84,10 @@ Join modifiers and a key with `+`, such as `mod+shift+d` or `ctrl+l`....@@ -84,9 +84,10 @@ Join modifiers and a key with `+`, such as `mod+shift+d` or `ctrl+l`.
84## When conditions84## When conditions
8585
86Available context keys are `terminalFocus`, `terminalOpen`, `previewFocus`,86Available context keys are `terminalFocus`, `terminalOpen`, `previewFocus`,
87`previewOpen`, `modelPickerOpen`, `isWeb`, and `isDesktop`. `isWeb` is true in a87`previewOpen`, `modelPickerOpen`, `editableFocus`, `isWeb`, and `isDesktop`.
88browser tab. `isDesktop` is true in the desktop app. Unknown keys evaluate to88`editableFocus` is true while a text field, the composer, or another editor has
89`false`.89the keyboard. `isWeb` is true in a browser tab. `isDesktop` is true in the
90desktop app. Unknown keys evaluate to `false`.
9091
91`mod+1` through `mod+9` jump to the first nine threads, and to models while the92`mod+1` through `mod+9` jump to the first nine threads, and to models while the
92model picker is open. Those defaults use `isDesktop` so they do not steal the93model picker is open. Those defaults use `isDesktop` so they do not steal the
...@@ -110,6 +111,11 @@ a shortcut....@@ -110,6 +111,11 @@ a shortcut.
110`thread.stop` interrupts the running turn in the focused thread. It has no default111`thread.stop` interrupts the running turn in the focused thread. It has no default
111shortcut; assign one in **Settings → Keybindings**.112shortcut; assign one in **Settings → Keybindings**.
112113
114`thread.undo` (`mod+z` by default) reverses the most recent thread action that is
115still offering **Undo** in a notification, such as an unpin, settle, snooze, or
116archive. Its default rule skips text fields and terminals so native undo keeps
117working there.
118
113`chat.new` may ask you to choose a project when there is more than one.119`chat.new` may ask you to choose a project when there is more than one.
114`chat.newLocal` skips that chooser. Both use your120`chat.newLocal` skips that chooser. Both use your
115[new-thread defaults](./thread-sidebar.md#start-a-thread).121[new-thread defaults](./thread-sidebar.md#start-a-thread).
docs/user/thread-sidebar.md+5-1
...@@ -29,7 +29,11 @@ and worktree while you stay in the new thread composer. This requires a Git proj...@@ -29,7 +29,11 @@ and worktree while you stay in the new thread composer. This requires a Git proj
2929
30Pin a thread from its menu to keep it above your active work.30Pin a thread from its menu to keep it above your active work.
3131
32On web and desktop, unpinning offers Undo for five seconds to restore the thread's pinned position.32On web and desktop, unpinning, settling, snoozing, and archiving a thread each show
33a notification with **Undo** for five seconds. Undo restores the thread's previous
34state, including its pinned position, and reopens an archived thread you were
35viewing. `mod+z` triggers the most recent Undo when no text field is focused; see
36[Keybindings](./keybindings.md#commands-with-special-behavior).
3337
34On web and desktop, you can also drag files from your computer onto any thread row:38On web and desktop, you can also drag files from your computer onto any thread row:
35the thread opens and the files are attached in its composer, ready for39the thread opens and the files are attached in its composer, ready for
packages/contracts/src/keybindings.ts+1
...@@ -42,6 +42,7 @@ const THREAD_KEYBINDING_COMMANDS = [...@@ -42,6 +42,7 @@ const THREAD_KEYBINDING_COMMANDS = [
42 "thread.copyReference",42 "thread.copyReference",
43 "thread.settle",43 "thread.settle",
44 "thread.pin",44 "thread.pin",
45 "thread.undo",
45 ...THREAD_JUMP_KEYBINDING_COMMANDS,46 ...THREAD_JUMP_KEYBINDING_COMMANDS,
46] as const;47] as const;
47export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number];48export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number];
packages/shared/src/keybindings.ts+1
...@@ -62,6 +62,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [...@@ -62,6 +62,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
62 { key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" },62 { key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" },
63 { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" },63 { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" },
64 { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" },64 { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" },
65 { key: "mod+z", command: "thread.undo", when: "!terminalFocus && !editableFocus" },
65 ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({66 ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({
66 key: `mod+${index + 1}`,67 key: `mod+${index + 1}`,
67 command,68 command,