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 {
229229} from "@t3tools/client-runtime/state/subagentRuntime";
230230import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar";
231231import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
232import { isEditableFocused } from "../lib/editableFocus";
233import { undoLatestThreadAction } from "../hooks/showUndoToast";
232234import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
233235import {
234236 AlarmClockIcon,
......@@ -6647,11 +6649,12 @@ export default function ChatView(props: ChatViewProps) {
66476649 }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]);
66486650
66496651 const getShortcutContext = useCallback(
6650 () => ({
6652 (eventTarget: EventTarget | null = document.activeElement) => ({
66516653 terminalFocus: getTerminalFocusOwner() !== null,
66526654 terminalOpen: Boolean(terminalUiState.terminalOpen),
66536655 previewFocus: isPreviewFocused(),
66546656 previewOpen: previewPanelOpen,
6657 editableFocus: isEditableFocused(eventTarget),
66556658 modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,
66566659 isWeb: !isElectron,
66576660 isDesktop: isElectron,
......@@ -6679,7 +6682,7 @@ export default function ChatView(props: ChatViewProps) {
66796682 if (event.defaultPrevented && terminalFocusOwner === null) {
66806683 return;
66816684 }
6682 const shortcutContext = getShortcutContext();
6685 const shortcutContext = getShortcutContext(event.target);
66836686
66846687 if (
66856688 !shortcutContext.terminalFocus &&
......@@ -6728,6 +6731,17 @@ export default function ChatView(props: ChatViewProps) {
67286731 return;
67296732 }
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
67316745 if (command === "thread.pin") {
67326746 event.preventDefault();
67336747 event.stopPropagation();
apps/web/src/components/Sidebar.tsx+8-23
......@@ -208,12 +208,7 @@ import {
208208 type TerminalStatusIndicator,
209209 useLinkedThreadPullRequest,
210210} from "./ThreadStatusIndicators";
211import {
212 resolveSnoozePresets,
213 snoozeWakeDescription,
214 snoozeWakeLabel,
215 type SnoozePreset,
216} from "./Sidebar.snooze";
211import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze";
217212import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";
218213import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch";
219214import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
......@@ -3082,7 +3077,9 @@ export default function Sidebar() {
30823077 settlingThreadKeysRef.current.add(threadKey);
30833078 try {
30843079 const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys);
3085 const result = await settleThread(threadRef);
3080 const result = await settleThread(threadRef, {
3081 undoToast: opts.coSettlingKeys === undefined,
3082 });
30863083 if (result._tag === "Failure") {
30873084 // Never navigate away from a thread that did not settle.
30883085 if (!isAtomCommandInterrupted(result)) {
......@@ -3734,7 +3731,9 @@ export default function Sidebar() {
37343731 // Snoozing the open thread moves you forward, same as settle —
37353732 // both park the thread you're done with for now.
37363733 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 });
37383737 if (result._tag === "Failure") {
37393738 // Never navigate away from a thread that did not snooze.
37403739 return isAtomCommandInterrupted(result)
......@@ -3780,23 +3779,9 @@ export default function Sidebar() {
37803779 );
37813780 return;
37823781 }
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 );
37973782 })();
37983783 },
3799 [attemptUnsnooze, performSnooze, timestampFormat],
3784 [performSnooze],
38003785 );
38013786
38023787 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";
33import { afterEach, describe, expect, it, vi } from "vite-plus/test";
44
55import { toastManager } from "../components/ui/toast";
6import { showUndoToast } from "./showUndoToast";
6import { showUndoToast, undoLatestThreadAction } from "./showUndoToast";
77import * as ThreadUndo from "./threadUndo";
88
99afterEach(() => vi.restoreAllMocks());
......@@ -93,3 +93,35 @@ describe("showUndoToast", () => {
9393 expect(add).toHaveBeenCalledOnce();
9494 });
9595});
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 {
77import { stackedThreadToast, toastManager } from "../components/ui/toast";
88import 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
1024/** Shows a single-use Undo while its thread action still owns the claim. */
1125export function showUndoToast({
1226 title,
......@@ -23,6 +37,7 @@ export function showUndoToast({
2337}) {
2438 if (!claim.isCurrent()) return;
2539 let undoStarted = false;
40 let toastId: string | undefined;
2641 const reportFailure = (error: unknown) => {
2742 toastManager.add(
2843 stackedThreadToast({
......@@ -32,7 +47,26 @@ export function showUndoToast({
3247 }),
3348 );
3449 };
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({
3670 ...stackedThreadToast({
3771 type: "success",
3872 title,
......@@ -41,21 +75,13 @@ export function showUndoToast({
4175 actionProps: {
4276 children: "Undo",
4377 onClick: async () => {
44 if (undoStarted || !claim.isCurrent()) return;
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 }
78 await run();
5679 },
5780 },
5881 }),
59 onClose: claim.finish,
82 onClose: () => {
83 claim.finish();
84 forget();
85 },
6086 });
6187}
apps/web/src/hooks/useThreadActionMenu.ts+3-23
......@@ -11,7 +11,7 @@ import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";
1111import { useRouter } from "@tanstack/react-router";
1212import { useCallback, useMemo } from "react";
1313
14import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze";
14import { resolveSnoozePresets } from "../components/Sidebar.snooze";
1515import {
1616 buildThreadActionMenuItems,
1717 type ThreadActionMenuId,
......@@ -162,29 +162,9 @@ export function useThreadActionMenu(input: {
162162 : snoozePresets.find((candidate) => `snooze:${candidate.id}` === action);
163163 if (!preset) return;
164164 const result = await snoozeThread(threadRef, preset.snoozedUntil);
165 if (result._tag === "Failure") {
166 if (!isAtomCommandInterrupted(result)) {
167 failureToast("Failed to snooze thread", squashAtomCommandFailure(result));
168 }
169 return;
165 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
166 failureToast("Failed to snooze thread", squashAtomCommandFailure(result));
170167 }
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 );
188168 return;
189169 }
190170 const reportFailure = async (
apps/web/src/hooks/useThreadActions.ts+168-68
......@@ -15,6 +15,7 @@ import { useRouter } from "@tanstack/react-router";
1515import { useCallback, useMemo, useRef } from "react";
1616
1717import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic";
18import { snoozeWakeDescription } from "../components/Sidebar.snooze";
1819import { useComposerDraftStore } from "../composerDraftStore";
1920import { terminalEnvironment } from "../state/terminal";
2021import { appAtomRegistry } from "../rpc/atomRegistry";
......@@ -222,6 +223,7 @@ export function useThreadActions() {
222223 const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder);
223224 const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete);
224225 const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin);
226 const timestampFormat = useClientSettings((settings) => settings.timestampFormat);
225227 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
226228 const clearProjectDraftThreadById = useComposerDraftStore(
227229 (store) => store.clearProjectDraftThreadById,
......@@ -252,6 +254,30 @@ export function useThreadActions() {
252254 return resolveThreadRouteRef(currentRouteParams);
253255 }, [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
255281 const archiveThread = useCallback(
256282 async (target: ScopedThreadRef, opts: { onArchived?: () => void } = {}) => {
257283 const resolved = resolveThreadTarget(target);
......@@ -272,11 +298,13 @@ export function useThreadActions() {
272298 const shouldNavigateToDraft =
273299 currentRouteThreadRef?.threadId === threadRef.threadId &&
274300 currentRouteThreadRef.environmentId === threadRef.environmentId;
301 const action = ThreadUndo.begin("archive", scopedThreadKey(threadRef));
275302 const archiveResult = await archiveThreadMutation({
276303 environmentId: threadRef.environmentId,
277304 input: { threadId: threadRef.threadId },
278305 });
279306 if (archiveResult._tag === "Failure") {
307 action.finish();
280308 return archiveResult;
281309 }
282310 const wokeAt = threadWokeAt(thread, { now: new Date().toISOString() });
......@@ -285,6 +313,14 @@ export function useThreadActions() {
285313 }
286314 refreshArchivedThreadsForEnvironment(threadRef.environmentId);
287315 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
289325 if (shouldNavigateToDraft) {
290326 const navigationResult = await settlePromise(() =>
......@@ -298,21 +334,13 @@ export function useThreadActions() {
298334
299335 return archiveResult;
300336 },
301 [archiveThreadMutation, getCurrentRouteThreadRef, markThreadVisited, resolveThreadTarget],
302 );
303
304 const unarchiveThread = useCallback(
305 async (target: ScopedThreadRef) => {
306 const result = await unarchiveThreadMutation({
307 environmentId: target.environmentId,
308 input: { threadId: target.threadId },
309 });
310 if (result._tag === "Success") {
311 refreshArchivedThreadsForEnvironment(target.environmentId);
312 }
313 return result;
314 },
315 [unarchiveThreadMutation],
337 [
338 archiveThreadMutation,
339 getCurrentRouteThreadRef,
340 markThreadVisited,
341 resolveThreadTarget,
342 unarchiveThread,
343 ],
316344 );
317345
318346 const deleteThread = useCallback(
......@@ -508,38 +536,6 @@ export function useThreadActions() {
508536 ],
509537 );
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
543539 const unsettleThread = useCallback(
544540 async (target: ScopedThreadRef) => {
545541 if (!readEnvironmentSupportsSettlement(target.environmentId)) {
......@@ -552,6 +548,7 @@ export function useThreadActions() {
552548 ),
553549 );
554550 }
551 ThreadUndo.invalidate("settle", scopedThreadKey(target));
555552 // reason "user" pins the thread active: auto-settle (PR merged /
556553 // inactivity) stays suppressed until real activity clears the pin.
557554 return unsettleThreadMutation({
......@@ -631,6 +628,89 @@ export function useThreadActions() {
631628 [pinThread, unpinThreadMutation],
632629 );
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
634714 const confirmAndUnpinThread = useCallback(
635715 async (target: ScopedThreadRef) => {
636716 const localApi = readLocalApi();
......@@ -695,8 +775,34 @@ export function useThreadActions() {
695775 [reorderActiveThreadMutation],
696776 );
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
698799 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 ) => {
700806 // Version skew: never send the command to a server that predates it.
701807 if (!readEnvironmentSupportsSnooze(target.environmentId)) {
702808 return AsyncResult.failure(
......@@ -722,32 +828,26 @@ export function useThreadActions() {
722828 ),
723829 );
724830 }
725 return snoozeThreadMutation({
831 const action = ThreadUndo.begin("snooze", scopedThreadKey(target));
832 const result = await snoozeThreadMutation({
726833 environmentId: target.environmentId,
727834 input: { threadId: target.threadId, snoozedUntil },
728835 });
729 },
730 [resolveThreadTarget, snoozeThreadMutation],
731 );
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 );
836 if (result._tag !== "Success" || opts.undoToast === false) {
837 action.finish();
838 return result;
744839 }
745 return unsnoozeThreadMutation({
746 environmentId: target.environmentId,
747 input: { threadId: target.threadId, reason: "user" },
840 // Snooze hides the row, so the toast is the only confirmation.
841 showUndoToast({
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",
748847 });
848 return result;
749849 },
750 [unsnoozeThreadMutation],
850 [resolveThreadTarget, snoozeThreadMutation, timestampFormat, unsnoozeThread],
751851 );
752852
753853 const confirmAndDeleteThread = useCallback(
apps/web/src/hooks/useThreadActions.undo.test.ts+188-24
......@@ -1,69 +1,233 @@
11import { 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
44import { useThreadActions } from "./useThreadActions";
55import { threadEnvironment } from "../state/threads";
66import { 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}));
922vi.mock("react", async (original) => ({
1023 ...(await original<typeof import("react")>()),
1124 useCallback: (callback: unknown) => callback,
1225 useMemo: (create: () => unknown) => create(),
1326 useRef: (value: unknown) => ({ current: value }),
1427}));
15vi.mock("@tanstack/react-router", () => ({ useRouter: () => ({}) }));
28vi.mock("@tanstack/react-router", () => ({ useRouter: () => router }));
1629vi.mock("./useSettings", () => ({ useClientSettings: () => false }));
1730vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() }));
1831vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() }));
1932vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() }));
2033vi.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}));
2144vi.mock("../state/entities", async (original) => ({
2245 ...(await original<typeof import("../state/entities")>()),
2346 readEnvironmentSupportsPinning: () => true,
2447 readEnvironmentSupportsPinReorder: () => true,
25 readThreadShell: () => ({ title: "Thread", pinOrderKey: "a0" }),
48 readEnvironmentSupportsSettlement: () => true,
49 readEnvironmentSupportsSnooze: () => true,
50 readThreadShell: () => threadShell,
2651}));
2752vi.mock("../state/use-atom-command", () => ({
28 useAtomCommand: (command: unknown) =>
29 command === threadEnvironment.pin
30 ? commands.pin
31 : command === threadEnvironment.unpin
32 ? commands.unpin
33 : vi.fn(),
53 useAtomCommand: (command: unknown) => {
54 switch (command) {
55 case threadEnvironment.pin:
56 return commands.pin;
57 case threadEnvironment.unpin:
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 },
3475}));
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});
36101afterEach(() => vi.restoreAllMocks());
37102
38103describe("unpin Undo", () => {
39104 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 });
42105 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
43106 vi.spyOn(toastManager, "close").mockImplementation(() => {});
44107 const sidebar = useThreadActions();
45108 const header = useThreadActions();
46 const target = {
47 environmentId: EnvironmentId.make("undo-env"),
48 threadId: ThreadId.make("thread"),
49 };
50109 await sidebar.unpinThread(target);
51 const staleUndo = add.mock.calls[0]?.[0].actionProps?.onClick;
110 const staleUndo = undoOf(add, 0);
52111 await header.pinThread(target, { orderKey: "a1" });
53112 await header.unpinThread(target);
54 const latestUndo = add.mock.calls[1]?.[0].actionProps?.onClick;
55 expect(staleUndo).toBeTypeOf("function");
56 expect(latestUndo).toBeTypeOf("function");
57 const event = {} as Parameters<NonNullable<typeof staleUndo>>[0];
58 staleUndo?.(event);
113 const latestUndo = undoOf(add, 1);
114 await staleUndo();
59115 expect(commands.pin).toHaveBeenCalledTimes(1);
60 latestUndo?.(event);
116 await latestUndo();
61117 expect(commands.pin).toHaveBeenCalledTimes(2);
62118 expect(commands.pin).toHaveBeenLastCalledWith({
63119 environmentId: target.environmentId,
64120 input: { threadId: target.threadId, orderKey: "a0" },
65121 });
66 latestUndo?.(event);
122 await latestUndo();
67123 expect(commands.pin).toHaveBeenCalledTimes(2);
68124 });
69125});
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", () => {
226226 });
227227});
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
229256describe("copy thread reference shortcut", () => {
230257 it("resolves Cmd+Shift+C on macOS and Ctrl+Shift+C elsewhere", () => {
231258 assert.equal(
apps/web/src/keybindings.ts+4
......@@ -36,6 +36,9 @@ export interface ShortcutMatchContext {
3636 previewOpen: boolean;
3737 isWeb: boolean;
3838 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;
3942 [key: string]: boolean;
4043}
4144
......@@ -149,6 +152,7 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc
149152 previewOpen: false,
150153 isWeb: !isElectron,
151154 isDesktop: isElectron,
155 editableFocus: false,
152156 ...options?.context,
153157 };
154158}
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`.
8484## When conditions
8585
8686Available context keys are `terminalFocus`, `terminalOpen`, `previewFocus`,
87`previewOpen`, `modelPickerOpen`, `isWeb`, and `isDesktop`. `isWeb` is true in a
88browser tab. `isDesktop` is true in the desktop app. Unknown keys evaluate to
89`false`.
87`previewOpen`, `modelPickerOpen`, `editableFocus`, `isWeb`, and `isDesktop`.
88`editableFocus` is true while a text field, the composer, or another editor has
89the keyboard. `isWeb` is true in a browser tab. `isDesktop` is true in the
90desktop app. Unknown keys evaluate to `false`.
9091
9192`mod+1` through `mod+9` jump to the first nine threads, and to models while the
9293model picker is open. Those defaults use `isDesktop` so they do not steal the
......@@ -110,6 +111,11 @@ a shortcut.
110111`thread.stop` interrupts the running turn in the focused thread. It has no default
111112shortcut; 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
113119`chat.new` may ask you to choose a project when there is more than one.
114120`chat.newLocal` skips that chooser. Both use your
115121[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
2929
3030Pin 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
3438On web and desktop, you can also drag files from your computer onto any thread row:
3539the 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 = [
4242 "thread.copyReference",
4343 "thread.settle",
4444 "thread.pin",
45 "thread.undo",
4546 ...THREAD_JUMP_KEYBINDING_COMMANDS,
4647] as const;
4748export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number];
packages/shared/src/keybindings.ts+1
......@@ -62,6 +62,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
6262 { key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" },
6363 { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" },
6464 { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" },
65 { key: "mod+z", command: "thread.undo", when: "!terminalFocus && !editableFocus" },
6566 ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({
6667 key: `mod+${index + 1}`,
6768 command,