authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-21 18:52:08-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-21 18:52:08-07:00
log9a609a4e444ba739d6fcd607769d68b679b0bcc5
treec456239998607e6b12d04068171ddddb83da3351
parent0141bc2bf5fcf52a563240a6bce4b58050496db5
signature Signed by PGP key B5690EEEBB952194

fix(web): show thread undo notice in the sidebar (#12972)


14 files changed, 378 insertions(+), 340 deletions(-)

apps/web/src/components/ChatView.tsx-12
...@@ -230,7 +230,6 @@ import {...@@ -230,7 +230,6 @@ import {
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";232import { isEditableFocused } from "../lib/editableFocus";
233import { undoLatestThreadAction } from "../hooks/showUndoToast";
234import ThreadTerminalDrawer from "./ThreadTerminalDrawer";233import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
235import {234import {
236 AlarmClockIcon,235 AlarmClockIcon,
...@@ -6731,17 +6730,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -6731,17 +6730,6 @@ export default function ChatView(props: ChatViewProps) {
6731 return;6730 return;
6732 }6731 }
67336732
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
6745 if (command === "thread.pin") {6733 if (command === "thread.pin") {
6746 event.preventDefault();6734 event.preventDefault();
6747 event.stopPropagation();6735 event.stopPropagation();
apps/web/src/components/Sidebar.tsx+7-33
...@@ -3077,9 +3077,7 @@ export default function Sidebar() {...@@ -3077,9 +3077,7 @@ export default function Sidebar() {
3077 settlingThreadKeysRef.current.add(threadKey);3077 settlingThreadKeysRef.current.add(threadKey);
3078 try {3078 try {
3079 const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys);3079 const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys);
3080 const result = await settleThread(threadRef, {3080 const result = await settleThread(threadRef);
3081 undoToast: opts.coSettlingKeys === undefined,
3082 });
3083 if (result._tag === "Failure") {3081 if (result._tag === "Failure") {
3084 // Never navigate away from a thread that did not settle.3082 // Never navigate away from a thread that did not settle.
3085 if (!isAtomCommandInterrupted(result)) {3083 if (!isAtomCommandInterrupted(result)) {
...@@ -3731,9 +3729,7 @@ export default function Sidebar() {...@@ -3731,9 +3729,7 @@ export default function Sidebar() {
3731 // Snoozing the open thread moves you forward, same as settle —3729 // Snoozing the open thread moves you forward, same as settle —
3732 // both park the thread you're done with for now.3730 // both park the thread you're done with for now.
3733 const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys);3731 const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys);
3734 const result = await snoozeThread(threadRef, preset.snoozedUntil, {3732 const result = await snoozeThread(threadRef, preset.snoozedUntil);
3735 undoToast: opts.coSnoozingKeys === undefined,
3736 });
3737 if (result._tag === "Failure") {3733 if (result._tag === "Failure") {
3738 // Never navigate away from a thread that did not snooze.3734 // Never navigate away from a thread that did not snooze.
3739 return isAtomCommandInterrupted(result)3735 return isAtomCommandInterrupted(result)
...@@ -3887,35 +3883,15 @@ export default function Sidebar() {...@@ -3887,35 +3883,15 @@ export default function Sidebar() {
3887 outcome.status === "failure" ? [outcome.error] : [],3883 outcome.status === "failure" ? [outcome.error] : [],
3888 );3884 );
38893885
3890 if (snoozedThreadRefs.length > 0) {3886 if (failures.length > 0) {
3891 const snoozedCount = snoozedThreadRefs.length;
3892 const failedCount = failures.length;
3893 toastManager.add(
3894 stackedThreadToast({
3895 type: failedCount > 0 ? "warning" : "success",
3896 title:
3897 failedCount > 0
3898 ? `Snoozed ${snoozedCount} of ${selectedThreads.length} threads`
3899 : `Snoozed ${snoozedCount} thread${snoozedCount === 1 ? "" : "s"}`,
3900 description:
3901 failedCount > 0
3902 ? `${failedCount} thread${failedCount === 1 ? "" : "s"} couldn't be snoozed.`
3903 : undefined,
3904 timeout: 5_000,
3905 actionProps: {
3906 children: "Undo",
3907 onClick: () => {
3908 for (const threadRef of snoozedThreadRefs) attemptUnsnooze(threadRef);
3909 },
3910 },
3911 }),
3912 );
3913 } else if (failures.length > 0) {
3914 const firstError = failures[0];3887 const firstError = failures[0];
3915 toastManager.add(3888 toastManager.add(
3916 stackedThreadToast({3889 stackedThreadToast({
3917 type: "error",3890 type: "error",
3918 title: "Failed to snooze threads",3891 title:
3892 snoozedThreadRefs.length > 0
3893 ? `Failed to snooze ${failures.length} thread${failures.length === 1 ? "" : "s"}`
3894 : "Failed to snooze threads",
3919 description:3895 description:
3920 firstError instanceof Error ? firstError.message : "An error occurred.",3896 firstError instanceof Error ? firstError.message : "An error occurred.",
3921 }),3897 }),
...@@ -4019,7 +3995,6 @@ export default function Sidebar() {...@@ -4019,7 +3995,6 @@ export default function Sidebar() {
4019 },3995 },
4020 [3996 [
4021 attemptSettle,3997 attemptSettle,
4022 attemptSnooze,
4023 attemptUnpin,3998 attemptUnpin,
4024 clearSelection,3999 clearSelection,
4025 confirmThreadDelete,4000 confirmThreadDelete,
...@@ -4028,7 +4003,6 @@ export default function Sidebar() {...@@ -4028,7 +4003,6 @@ export default function Sidebar() {
4028 performSnooze,4003 performSnooze,
4029 removeFromSelection,4004 removeFromSelection,
4030 serverConfigs,4005 serverConfigs,
4031 attemptUnsnooze,
4032 updateThreadMetadata,4006 updateThreadMetadata,
4033 timestampFormat,4007 timestampFormat,
4034 ],4008 ],
apps/web/src/components/sidebar/SidebarChrome.tsx+2
...@@ -26,6 +26,7 @@ import {...@@ -26,6 +26,7 @@ import {
26} from "../ui/sidebar";26} from "../ui/sidebar";
27import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";27import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
28import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences";28import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences";
29import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice";
29import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";30import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";
30import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";31import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";
31import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";32import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
...@@ -221,6 +222,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {...@@ -221,6 +222,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
221export const SidebarChromeFooter = memo(function SidebarChromeFooter() {222export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
222 return (223 return (
223 <SidebarFooter className="px-[var(--sidebar-content-inset)] py-1">224 <SidebarFooter className="px-[var(--sidebar-content-inset)] py-1">
225 <SidebarThreadUndoNotice />
224 <SidebarProviderUpdatePill />226 <SidebarProviderUpdatePill />
225 <SidebarUpdateArchitectureWarning />227 <SidebarUpdateArchitectureWarning />
226 <SidebarUtilityMenu />228 <SidebarUtilityMenu />
apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx created+30
...@@ -0,0 +1,30 @@
1import { useAtomValue } from "@effect/atom-react";
2
3import { undoLatestThreadAction, useThreadUndoNotice } from "../../hooks/showThreadUndoNotice";
4import { shortcutLabelForCommand } from "../../keybindings";
5import { primaryServerKeybindingsAtom } from "../../state/server";
6import { Alert, AlertDescription } from "../ui/alert";
7import { InlineButton } from "../ui/button";
8
9export function SidebarThreadUndoNotice() {
10 const notice = useThreadUndoNotice((state) => state.notice);
11 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
12
13 if (!notice) return null;
14 const shortcut = shortcutLabelForCommand(keybindings, "thread.undo");
15
16 return (
17 <Alert role="status" variant="sidebar">
18 <AlertDescription>
19 {notice.action} {notice.count} thread{notice.count === 1 ? "" : "s"},{" "}
20 <InlineButton
21 underline
22 onClick={undoLatestThreadAction}
23 className="hover:text-sidebar-foreground"
24 >
25 {shortcut ? `${shortcut} to undo` : "Undo"}
26 </InlineButton>
27 </AlertDescription>
28 </Alert>
29 );
30}
apps/web/src/components/ui/alert.tsx+2
...@@ -11,6 +11,8 @@ const alertVariants = cva("relative rounded-xl border px-3.5 py-3 text-card-fore...@@ -11,6 +11,8 @@ const alertVariants = cva("relative rounded-xl border px-3.5 py-3 text-card-fore
11 variants: {11 variants: {
12 variant: {12 variant: {
13 default: "bg-transparent dark:bg-input/32 [&_svg]:text-muted-foreground",13 default: "bg-transparent dark:bg-input/32 [&_svg]:text-muted-foreground",
14 sidebar:
15 "rounded-lg border-sidebar-border bg-sidebar-control-surface px-2 py-1.5 text-[11px] leading-4 [&_[data-slot=alert-description]]:block [&_[data-slot=alert-description]]:text-sidebar-muted-foreground",
14 error:16 error:
15 "border-error/32 bg-error-surface text-error-foreground [&_[data-slot=alert-description]]:text-error-foreground/80 [&_svg]:text-error",17 "border-error/32 bg-error-surface text-error-foreground [&_[data-slot=alert-description]]:text-error-foreground/80 [&_svg]:text-error",
16 info: "border-info/32 bg-info/4 [&_svg]:text-info",18 info: "border-info/32 bg-info/4 [&_svg]:text-info",
apps/web/src/hooks/showThreadUndoNotice.test.ts created+152
...@@ -0,0 +1,152 @@
1import { AsyncResult } from "effect/unstable/reactivity";
2import * as Cause from "effect/Cause";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { toastManager } from "../components/ui/toast";
6import {
7 showThreadUndoNotice,
8 undoLatestThreadAction,
9 useThreadUndoNotice,
10} from "./showThreadUndoNotice";
11import * as ThreadUndo from "./threadUndo";
12
13beforeEach(() => vi.useFakeTimers());
14afterEach(() => {
15 vi.runAllTimers();
16 vi.useRealTimers();
17 vi.restoreAllMocks();
18});
19
20function setup() {
21 const add = vi.spyOn(toastManager, "add").mockReturnValue("error-toast");
22 const undo = vi.fn(async () => AsyncResult.success(undefined));
23 const claim = ThreadUndo.begin("pin", "env/thread");
24 const options = { action: "Unpinned" as const, failureTitle: "Restore failed", undo, claim };
25 return { add, undo, claim, options };
26}
27
28function notice() {
29 const value = useThreadUndoNotice.getState().notice;
30 if (!value) throw new Error("Undo notice is missing");
31 return value;
32}
33
34describe("thread undo notice", () => {
35 it("aggregates consecutive actions without success toasts and restores the group once", async () => {
36 const { add, undo, options } = setup();
37 showThreadUndoNotice({
38 ...options,
39 action: "Settled",
40 claim: ThreadUndo.begin("settle", "env/a"),
41 });
42 showThreadUndoNotice({
43 ...options,
44 action: "Settled",
45 claim: ThreadUndo.begin("settle", "env/b"),
46 });
47 expect(notice()).toMatchObject({ action: "Settled", count: 2 });
48 expect(add).not.toHaveBeenCalled();
49 const group = notice();
50 await group.undo();
51 await group.undo();
52 expect(undo).toHaveBeenCalledTimes(2);
53 expect(undoLatestThreadAction()).toBe(false);
54 });
55
56 it("drops invalidated claims immediately and rejects a captured stale undo", async () => {
57 const { undo, options } = setup();
58 showThreadUndoNotice(options);
59 const stale = notice();
60 ThreadUndo.invalidate("pin", "env/thread");
61 expect(useThreadUndoNotice.getState().notice).toBeNull();
62 showThreadUndoNotice({ ...options, claim: ThreadUndo.begin("pin", "env/thread") });
63 await stale.undo();
64 expect(undo).not.toHaveBeenCalled();
65 await notice().undo();
66 expect(undo).toHaveBeenCalledOnce();
67 });
68
69 it("does not show a notice for a late completion after a newer action", () => {
70 const { add, options } = setup();
71 ThreadUndo.invalidate("pin", "env/thread");
72 showThreadUndoNotice(options);
73 expect(useThreadUndoNotice.getState().notice).toBeNull();
74 expect(add).not.toHaveBeenCalled();
75 });
76
77 it("keeps the group available until five seconds after the latest action", async () => {
78 const { undo, claim, options } = setup();
79 showThreadUndoNotice(options);
80 vi.advanceTimersByTime(4_000);
81 const second = ThreadUndo.begin("pin", "env/second");
82 showThreadUndoNotice({ ...options, claim: second });
83 const group = notice();
84 vi.advanceTimersByTime(4_999);
85 expect(notice().count).toBe(2);
86 vi.advanceTimersByTime(1);
87 expect(useThreadUndoNotice.getState().notice).toBeNull();
88 expect(claim.isCurrent()).toBe(false);
89 expect(second.isCurrent()).toBe(false);
90 await group.undo();
91 expect(undo).not.toHaveBeenCalled();
92 });
93
94 it("reports a failed restore and releases its claim", async () => {
95 const { add, claim, options } = setup();
96 showThreadUndoNotice({
97 ...options,
98 undo: async () => AsyncResult.failure(Cause.fail(new Error("offline"))),
99 });
100 await notice().undo();
101 expect(add).toHaveBeenLastCalledWith(
102 expect.objectContaining({ type: "error", title: "Restore failed", description: "offline" }),
103 );
104 expect(claim.isCurrent()).toBe(false);
105 });
106
107 it("reports a rejected restore promise", async () => {
108 const { add, options } = setup();
109 showThreadUndoNotice({
110 ...options,
111 undo: async () => {
112 throw new Error("disconnected");
113 },
114 });
115 await notice().undo();
116 expect(add).toHaveBeenLastCalledWith(
117 expect.objectContaining({ type: "error", description: "disconnected" }),
118 );
119 });
120
121 it("does not report interrupted restores as errors", async () => {
122 const { add, options } = setup();
123 showThreadUndoNotice({ ...options, undo: async () => AsyncResult.failure(Cause.interrupt()) });
124 await notice().undo();
125 expect(add).not.toHaveBeenCalled();
126 });
127
128 it("undoes the latest kind first, then reveals the preceding group", async () => {
129 const { options } = setup();
130 const older = vi.fn(async () => AsyncResult.success(undefined));
131 const newer = vi.fn(async () => AsyncResult.success(undefined));
132 showThreadUndoNotice({
133 ...options,
134 action: "Settled",
135 undo: older,
136 claim: ThreadUndo.begin("settle", "env/a"),
137 });
138 showThreadUndoNotice({
139 ...options,
140 action: "Snoozed",
141 undo: newer,
142 claim: ThreadUndo.begin("snooze", "env/b"),
143 });
144 expect(undoLatestThreadAction()).toBe(true);
145 expect(newer).toHaveBeenCalledOnce();
146 expect(older).not.toHaveBeenCalled();
147 expect(notice().action).toBe("Settled");
148 await notice().undo();
149 expect(older).toHaveBeenCalledOnce();
150 expect(undoLatestThreadAction()).toBe(false);
151 });
152});
apps/web/src/hooks/showThreadUndoNotice.ts created+106
...@@ -0,0 +1,106 @@
1import {
2 type AtomCommandResult,
3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
6import { create } from "zustand";
7
8import { stackedThreadToast, toastManager } from "../components/ui/toast";
9import * as ThreadUndo from "./threadUndo";
10
11type UndoOptions = {
12 action: "Settled" | "Snoozed" | "Unpinned" | "Archived";
13 undo: () => Promise<AtomCommandResult<unknown, unknown>>;
14 failureTitle: string;
15 claim: ReturnType<typeof ThreadUndo.begin>;
16};
17
18type UndoNotice = {
19 action: UndoOptions["action"];
20 count: number;
21 undo: () => Promise<void>;
22};
23
24export const useThreadUndoNotice = create<{ notice: UndoNotice | null }>(() => ({ notice: null }));
25
26// Shared across sidebar, header and menu actions. Consecutive actions of the
27// same kind share one notice and can be restored together.
28let liveUndos: UndoOptions[] = [];
29let expiry: ReturnType<typeof setTimeout> | undefined;
30
31function refreshNotice() {
32 liveUndos = liveUndos.filter(({ claim }) => claim.isCurrent());
33 const latest = liveUndos.at(-1);
34 if (!latest) {
35 clearTimeout(expiry);
36 useThreadUndoNotice.setState({ notice: null });
37 return;
38 }
39 const group: UndoOptions[] = [];
40 for (let index = liveUndos.length - 1; index >= 0; index--) {
41 const entry = liveUndos[index]!;
42 if (entry.action !== latest.action) break;
43 group.push(entry);
44 }
45 useThreadUndoNotice.setState({
46 notice: {
47 action: latest.action,
48 count: group.length,
49 undo: async () => {
50 const current = group.filter(
51 (entry) => liveUndos.includes(entry) && entry.claim.isCurrent(),
52 );
53 liveUndos = liveUndos.filter((entry) => !group.includes(entry));
54 // Consume every claim before awaiting, so repeated clicks or shortcuts
55 // cannot restore the same group twice.
56 for (const entry of current) entry.claim.finish();
57 refreshNotice();
58 await Promise.all(
59 current.map(async ({ undo, failureTitle }) => {
60 const reportFailure = (error: unknown) => {
61 toastManager.add(
62 stackedThreadToast({
63 type: "error",
64 title: failureTitle,
65 description: error instanceof Error ? error.message : "An error occurred.",
66 }),
67 );
68 };
69 try {
70 const result = await undo();
71 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
72 reportFailure(squashAtomCommandFailure(result));
73 }
74 } catch (error) {
75 reportFailure(error);
76 }
77 }),
78 );
79 },
80 },
81 });
82}
83
84ThreadUndo.subscribe(refreshNotice);
85
86/** Runs the group displayed in the sidebar; false when nothing is left to undo. */
87export function undoLatestThreadAction(): boolean {
88 const notice = useThreadUndoNotice.getState().notice;
89 if (!notice) return false;
90 void notice.undo();
91 return true;
92}
93
94/** Shows one compact confirmation for the currently undoable thread actions. */
95export function showThreadUndoNotice(options: UndoOptions) {
96 if (!options.claim.isCurrent()) return;
97 liveUndos.push(options);
98 refreshNotice();
99 clearTimeout(expiry);
100 expiry = setTimeout(() => {
101 const expired = liveUndos;
102 liveUndos = [];
103 for (const { claim } of expired) claim.finish();
104 refreshNotice();
105 }, 5_000);
106}
apps/web/src/hooks/showUndoToast.test.ts deleted-127
...@@ -1,127 +0,0 @@
1import { AsyncResult } from "effect/unstable/reactivity";
2import * as Cause from "effect/Cause";
3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { toastManager } from "../components/ui/toast";
6import { showUndoToast, undoLatestThreadAction } from "./showUndoToast";
7import * as ThreadUndo from "./threadUndo";
8
9afterEach(() => vi.restoreAllMocks());
10
11function setup() {
12 const add = vi.spyOn(toastManager, "add").mockReturnValue("undo-toast");
13 const close = vi.spyOn(toastManager, "close").mockImplementation(() => {});
14 const undo = vi.fn(async () => AsyncResult.success(undefined));
15 const claim = ThreadUndo.begin("pin", "env/thread");
16 const options = {
17 title: "Thread unpinned",
18 description: "Thread",
19 failureTitle: "Restore failed",
20 undo,
21 claim,
22 };
23 return { add, close, undo, claim, options };
24}
25
26function click(add: ReturnType<typeof setup>["add"], index = 0) {
27 const handler = add.mock.calls[index]?.[0].actionProps?.onClick;
28 if (!handler) throw new Error("Undo action is missing");
29 return handler({} as Parameters<typeof handler>[0]);
30}
31
32describe("showUndoToast", () => {
33 it("ignores a stale toast and lets the latest action run only once", async () => {
34 const { add, close, undo, options } = setup();
35 showUndoToast(options);
36 ThreadUndo.invalidate("pin", "env/thread");
37 showUndoToast({ ...options, claim: ThreadUndo.begin("pin", "env/thread") });
38 await click(add);
39 expect(undo).not.toHaveBeenCalled();
40 await click(add, 1);
41 await click(add, 1);
42 expect(undo).toHaveBeenCalledOnce();
43 expect(close).toHaveBeenCalledExactlyOnceWith("undo-toast");
44 });
45
46 it("releases the claim on close and rejects a later click", async () => {
47 const { add, undo, claim, options } = setup();
48 showUndoToast(options);
49 add.mock.calls[0]?.[0].onClose?.();
50 expect(claim.isCurrent()).toBe(false);
51 await click(add);
52 expect(undo).not.toHaveBeenCalled();
53 });
54
55 it("does not show a toast for a late completion after a newer action", () => {
56 const { add, options } = setup();
57 ThreadUndo.invalidate("pin", "env/thread");
58 showUndoToast(options);
59 expect(add).not.toHaveBeenCalled();
60 });
61
62 it("reports a failed restore and releases its claim", async () => {
63 const { add, claim, options } = setup();
64 showUndoToast({
65 ...options,
66 undo: async () => AsyncResult.failure(Cause.fail(new Error("offline"))),
67 });
68 await click(add);
69 expect(add).toHaveBeenLastCalledWith(
70 expect.objectContaining({ type: "error", title: "Restore failed", description: "offline" }),
71 );
72 expect(claim.isCurrent()).toBe(false);
73 });
74
75 it("reports a rejected restore promise", async () => {
76 const { add, options } = setup();
77 showUndoToast({
78 ...options,
79 undo: async () => {
80 throw new Error("disconnected");
81 },
82 });
83 await click(add);
84 expect(add).toHaveBeenLastCalledWith(
85 expect.objectContaining({ type: "error", description: "disconnected" }),
86 );
87 });
88
89 it("does not report interrupted restores as errors", async () => {
90 const { add, options } = setup();
91 showUndoToast({ ...options, undo: async () => AsyncResult.failure(Cause.interrupt()) });
92 await click(add);
93 expect(add).toHaveBeenCalledOnce();
94 });
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 deleted-87
...@@ -1,87 +0,0 @@
1import {
2 type AtomCommandResult,
3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
6
7import { stackedThreadToast, toastManager } from "../components/ui/toast";
8import type * as ThreadUndo from "./threadUndo";
9
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
24/** Shows a single-use Undo while its thread action still owns the claim. */
25export function showUndoToast({
26 title,
27 description,
28 undo,
29 failureTitle,
30 claim,
31}: {
32 title: string;
33 description: string | undefined;
34 undo: () => Promise<AtomCommandResult<unknown, unknown>>;
35 failureTitle: string;
36 claim: ReturnType<typeof ThreadUndo.begin>;
37}) {
38 if (!claim.isCurrent()) return;
39 let undoStarted = false;
40 let toastId: string | undefined;
41 const reportFailure = (error: unknown) => {
42 toastManager.add(
43 stackedThreadToast({
44 type: "error",
45 title: failureTitle,
46 description: error instanceof Error ? error.message : "An error occurred.",
47 }),
48 );
49 };
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({
70 ...stackedThreadToast({
71 type: "success",
72 title,
73 description,
74 timeout: 5_000,
75 actionProps: {
76 children: "Undo",
77 onClick: async () => {
78 await run();
79 },
80 },
81 }),
82 onClose: () => {
83 claim.finish();
84 forget();
85 },
86 });
87}
apps/web/src/hooks/threadUndo.ts+18-1
...@@ -1,16 +1,32 @@...@@ -1,16 +1,32 @@
1// Shared across hook instances so sidebar, header and menu actions invalidate each other.1// Shared across hook instances so sidebar, header and menu actions invalidate each other.
2const currentActions = new Map<string, symbol>();2const currentActions = new Map<string, symbol>();
3const listeners = new Set<() => void>();
4
5export function subscribe(listener: () => void) {
6 listeners.add(listener);
7 return () => {
8 listeners.delete(listener);
9 };
10}
11
12function notify() {
13 for (const listener of listeners) listener();
14}
315
4/** Claims one kind of thread action; a later claim of that kind expires its Undo. */16/** Claims one kind of thread action; a later claim of that kind expires its Undo. */
5export function begin(kind: string, threadKey: string) {17export function begin(kind: string, threadKey: string) {
6 const key = JSON.stringify([kind, threadKey]);18 const key = JSON.stringify([kind, threadKey]);
7 const token = Symbol();19 const token = Symbol();
8 currentActions.set(key, token);20 currentActions.set(key, token);
21 notify();
9 const isCurrent = () => currentActions.get(key) === token;22 const isCurrent = () => currentActions.get(key) === token;
10 return {23 return {
11 isCurrent,24 isCurrent,
12 finish: () => {25 finish: () => {
13 if (isCurrent()) currentActions.delete(key);26 if (isCurrent()) {
27 currentActions.delete(key);
28 notify();
29 }
14 },30 },
15 };31 };
16}32}
...@@ -18,4 +34,5 @@ export function begin(kind: string, threadKey: string) {...@@ -18,4 +34,5 @@ export function begin(kind: string, threadKey: string) {
18/** Expires only this action kind, leaving unrelated thread actions intact. */34/** Expires only this action kind, leaving unrelated thread actions intact. */
19export function invalidate(kind: string, threadKey: string) {35export function invalidate(kind: string, threadKey: string) {
20 currentActions.delete(JSON.stringify([kind, threadKey]));36 currentActions.delete(JSON.stringify([kind, threadKey]));
37 notify();
21}38}
apps/web/src/hooks/useThreadActions.ts+14-33
...@@ -15,7 +15,6 @@ import { useRouter } from "@tanstack/react-router";...@@ -15,7 +15,6 @@ 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";
19import { useComposerDraftStore } from "../composerDraftStore";18import { useComposerDraftStore } from "../composerDraftStore";
20import { terminalEnvironment } from "../state/terminal";19import { terminalEnvironment } from "../state/terminal";
21import { appAtomRegistry } from "../rpc/atomRegistry";20import { appAtomRegistry } from "../rpc/atomRegistry";
...@@ -44,7 +43,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "...@@ -44,7 +43,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "
44import { stackedThreadToast, toastManager } from "../components/ui/toast";43import { stackedThreadToast, toastManager } from "../components/ui/toast";
45import { useClientSettings } from "./useSettings";44import { useClientSettings } from "./useSettings";
46import * as ThreadUndo from "./threadUndo";45import * as ThreadUndo from "./threadUndo";
47import { showUndoToast } from "./showUndoToast";46import { showThreadUndoNotice } from "./showThreadUndoNotice";
48import { useAtomCommand } from "../state/use-atom-command";47import { useAtomCommand } from "../state/use-atom-command";
4948
50export class ThreadArchiveBlockedError extends Schema.TaggedError<ThreadArchiveBlockedError>()(49export class ThreadArchiveBlockedError extends Schema.TaggedError<ThreadArchiveBlockedError>()(
...@@ -223,7 +222,6 @@ export function useThreadActions() {...@@ -223,7 +222,6 @@ export function useThreadActions() {
223 const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder);222 const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder);
224 const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete);223 const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete);
225 const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin);224 const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin);
226 const timestampFormat = useClientSettings((settings) => settings.timestampFormat);
227 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);225 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
228 const clearProjectDraftThreadById = useComposerDraftStore(226 const clearProjectDraftThreadById = useComposerDraftStore(
229 (store) => store.clearProjectDraftThreadById,227 (store) => store.clearProjectDraftThreadById,
...@@ -313,9 +311,8 @@ export function useThreadActions() {...@@ -313,9 +311,8 @@ export function useThreadActions() {
313 }311 }
314 refreshArchivedThreadsForEnvironment(threadRef.environmentId);312 refreshArchivedThreadsForEnvironment(threadRef.environmentId);
315 opts.onArchived?.();313 opts.onArchived?.();
316 showUndoToast({314 showThreadUndoNotice({
317 title: "Thread archived",315 action: "Archived",
318 description: thread.title,
319 claim: action,316 claim: action,
320 // Undo also brings the reader back when archiving moved them to a draft.317 // Undo also brings the reader back when archiving moved them to a draft.
321 undo: () => unarchiveThread(threadRef, { navigate: shouldNavigateToDraft }),318 undo: () => unarchiveThread(threadRef, { navigate: shouldNavigateToDraft }),
...@@ -613,9 +610,8 @@ export function useThreadActions() {...@@ -613,9 +610,8 @@ export function useThreadActions() {
613 input: { threadId: target.threadId },610 input: { threadId: target.threadId },
614 });611 });
615 if (result._tag === "Success" && action.isCurrent()) {612 if (result._tag === "Success" && action.isCurrent()) {
616 showUndoToast({613 showThreadUndoNotice({
617 title: "Thread unpinned",614 action: "Unpinned",
618 description: thread?.title,
619 claim: action,615 claim: action,
620 undo: () => pinThread(target, orderKey === undefined ? {} : { orderKey }),616 undo: () => pinThread(target, orderKey === undefined ? {} : { orderKey }),
621 failureTitle: "Failed to undo unpin",617 failureTitle: "Failed to undo unpin",
...@@ -629,11 +625,7 @@ export function useThreadActions() {...@@ -629,11 +625,7 @@ export function useThreadActions() {
629 );625 );
630626
631 const settleThread = useCallback(627 const settleThread = useCallback(
632 async (628 async (target: ScopedThreadRef) => {
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 —629 // Version skew: never send the command to a server that predates it —
638 // the raw protocol rejection would read as a random failure.630 // the raw protocol rejection would read as a random failure.
639 if (!readEnvironmentSupportsSettlement(target.environmentId)) {631 if (!readEnvironmentSupportsSettlement(target.environmentId)) {
...@@ -671,13 +663,8 @@ export function useThreadActions() {...@@ -671,13 +663,8 @@ export function useThreadActions() {
671 if (wokeAt !== null) {663 if (wokeAt !== null) {
672 markThreadVisited(scopedThreadKey(target), wokeAt);664 markThreadVisited(scopedThreadKey(target), wokeAt);
673 }665 }
674 if (opts.undoToast === false) {666 showThreadUndoNotice({
675 action.finish();667 action: "Settled",
676 return result;
677 }
678 showUndoToast({
679 title: "Thread settled",
680 description: resolved?.thread.title,
681 claim: action,668 claim: action,
682 undo: async () => {669 undo: async () => {
683 const unsettled = await unsettleThread(target);670 const unsettled = await unsettleThread(target);
...@@ -797,12 +784,7 @@ export function useThreadActions() {...@@ -797,12 +784,7 @@ export function useThreadActions() {
797 );784 );
798785
799 const snoozeThread = useCallback(786 const snoozeThread = useCallback(
800 async (787 async (target: ScopedThreadRef, snoozedUntil: string) => {
801 target: ScopedThreadRef,
802 snoozedUntil: string,
803 // Batch callers report one toast for the whole selection instead.
804 opts: { undoToast?: boolean } = {},
805 ) => {
806 // Version skew: never send the command to a server that predates it.788 // Version skew: never send the command to a server that predates it.
807 if (!readEnvironmentSupportsSnooze(target.environmentId)) {789 if (!readEnvironmentSupportsSnooze(target.environmentId)) {
808 return AsyncResult.failure(790 return AsyncResult.failure(
...@@ -833,21 +815,20 @@ export function useThreadActions() {...@@ -833,21 +815,20 @@ export function useThreadActions() {
833 environmentId: target.environmentId,815 environmentId: target.environmentId,
834 input: { threadId: target.threadId, snoozedUntil },816 input: { threadId: target.threadId, snoozedUntil },
835 });817 });
836 if (result._tag !== "Success" || opts.undoToast === false) {818 if (result._tag !== "Success") {
837 action.finish();819 action.finish();
838 return result;820 return result;
839 }821 }
840 // Snooze hides the row, so the toast is the only confirmation.822 // Snooze hides the row, so keep its confirmation in the sidebar.
841 showUndoToast({823 showThreadUndoNotice({
842 title: `Snoozed until ${snoozeWakeDescription(snoozedUntil, new Date(), timestampFormat)}`,824 action: "Snoozed",
843 description: resolved?.thread.title,
844 claim: action,825 claim: action,
845 undo: () => unsnoozeThread(target),826 undo: () => unsnoozeThread(target),
846 failureTitle: "Failed to wake thread",827 failureTitle: "Failed to wake thread",
847 });828 });
848 return result;829 return result;
849 },830 },
850 [resolveThreadTarget, snoozeThreadMutation, timestampFormat, unsnoozeThread],831 [resolveThreadTarget, snoozeThreadMutation, unsnoozeThread],
851 );832 );
852833
853 const confirmAndDeleteThread = useCallback(834 const confirmAndDeleteThread = useCallback(
apps/web/src/hooks/useThreadActions.undo.test.ts+28-43
...@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"...@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
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";
7import { useThreadUndoNotice } from "./showThreadUndoNotice";
78
8const commands = vi.hoisted(() => ({9const commands = vi.hoisted(() => ({
9 pin: vi.fn(),10 pin: vi.fn(),
...@@ -78,18 +79,14 @@ const target = {...@@ -78,18 +79,14 @@ const target = {
78 environmentId: EnvironmentId.make("undo-env"),79 environmentId: EnvironmentId.make("undo-env"),
79 threadId: ThreadId.make("thread"),80 threadId: ThreadId.make("thread"),
80};81};
81const event = {} as Parameters<NonNullable<React.ComponentProps<"button">["onClick"]>>[0];82function currentUndo() {
8283 const notice = useThreadUndoNotice.getState().notice;
83function undoOf(84 expect(notice).not.toBeNull();
84 add: { mock: { calls: Array<[Parameters<typeof toastManager.add>[0]]> } },85 return notice!.undo;
85 index: number,
86) {
87 const onClick = add.mock.calls[index]?.[0].actionProps?.onClick;
88 expect(onClick).toBeTypeOf("function");
89 return () => onClick?.(event);
90}86}
9187
92beforeEach(() => {88beforeEach(() => {
89 vi.useFakeTimers();
93 for (const command of Object.values(commands)) {90 for (const command of Object.values(commands)) {
94 command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined });91 command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined });
95 }92 }
...@@ -98,19 +95,21 @@ beforeEach(() => {...@@ -98,19 +95,21 @@ beforeEach(() => {
98 threadShell.pinnedAt = null;95 threadShell.pinnedAt = null;
99 threadShell.snoozedUntil = null;96 threadShell.snoozedUntil = null;
100});97});
101afterEach(() => vi.restoreAllMocks());98afterEach(() => {
99 vi.runAllTimers();
100 vi.useRealTimers();
101 vi.restoreAllMocks();
102});
102103
103describe("unpin Undo", () => {104describe("unpin Undo", () => {
104 it("ignores an old toast across hook instances and still restores the latest unpin", async () => {105 it("ignores an old notice across hook instances and still restores the latest unpin", async () => {
105 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
106 vi.spyOn(toastManager, "close").mockImplementation(() => {});
107 const sidebar = useThreadActions();106 const sidebar = useThreadActions();
108 const header = useThreadActions();107 const header = useThreadActions();
109 await sidebar.unpinThread(target);108 await sidebar.unpinThread(target);
110 const staleUndo = undoOf(add, 0);109 const staleUndo = currentUndo();
111 await header.pinThread(target, { orderKey: "a1" });110 await header.pinThread(target, { orderKey: "a1" });
112 await header.unpinThread(target);111 await header.unpinThread(target);
113 const latestUndo = undoOf(add, 1);112 const latestUndo = currentUndo();
114 await staleUndo();113 await staleUndo();
115 expect(commands.pin).toHaveBeenCalledTimes(1);114 expect(commands.pin).toHaveBeenCalledTimes(1);
116 await latestUndo();115 await latestUndo();
...@@ -127,15 +126,15 @@ describe("unpin Undo", () => {...@@ -127,15 +126,15 @@ describe("unpin Undo", () => {
127describe("archive Undo", () => {126describe("archive Undo", () => {
128 it("unarchives and returns to the thread when archiving left it", async () => {127 it("unarchives and returns to the thread when archiving left it", async () => {
129 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");128 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
130 vi.spyOn(toastManager, "close").mockImplementation(() => {});
131 router.state.matches[0]!.params = {129 router.state.matches[0]!.params = {
132 environmentId: target.environmentId,130 environmentId: target.environmentId,
133 threadId: target.threadId,131 threadId: target.threadId,
134 };132 };
135 const actions = useThreadActions();133 const actions = useThreadActions();
136 await actions.archiveThread(target);134 await actions.archiveThread(target);
137 expect(add).toHaveBeenCalledWith(expect.objectContaining({ title: "Thread archived" }));135 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Archived", count: 1 });
138 await undoOf(add, 0)();136 expect(add).not.toHaveBeenCalled();
137 await currentUndo()();
139 expect(commands.unarchive).toHaveBeenCalledExactlyOnceWith({138 expect(commands.unarchive).toHaveBeenCalledExactlyOnceWith({
140 environmentId: target.environmentId,139 environmentId: target.environmentId,
141 input: { threadId: target.threadId },140 input: { threadId: target.threadId },
...@@ -149,11 +148,9 @@ describe("archive Undo", () => {...@@ -149,11 +148,9 @@ describe("archive Undo", () => {
149 });148 });
150149
151 it("stays put when the archived thread was not open", async () => {150 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();151 const actions = useThreadActions();
155 await actions.archiveThread(target);152 await actions.archiveThread(target);
156 await undoOf(add, 0)();153 await currentUndo()();
157 expect(commands.unarchive).toHaveBeenCalledOnce();154 expect(commands.unarchive).toHaveBeenCalledOnce();
158 expect(router.navigate).not.toHaveBeenCalled();155 expect(router.navigate).not.toHaveBeenCalled();
159 });156 });
...@@ -167,27 +164,25 @@ describe("archive Undo", () => {...@@ -167,27 +164,25 @@ describe("archive Undo", () => {
167});164});
168165
169describe("settle and snooze Undo", () => {166describe("settle and snooze Undo", () => {
170 it("un-settles from the toast and expires the Undo after a manual un-settle", async () => {167 it("un-settles from the notice and expires the Undo after a manual un-settle", async () => {
171 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");168 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
172 vi.spyOn(toastManager, "close").mockImplementation(() => {});
173 const actions = useThreadActions();169 const actions = useThreadActions();
174 await actions.settleThread(target);170 await actions.settleThread(target);
175 expect(add).toHaveBeenCalledWith(expect.objectContaining({ title: "Thread settled" }));171 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Settled", count: 1 });
176 const undo = undoOf(add, 0);172 expect(add).not.toHaveBeenCalled();
173 const undo = currentUndo();
177 await actions.unsettleThread(target);174 await actions.unsettleThread(target);
178 await undo();175 await undo();
179 expect(commands.unsettle).toHaveBeenCalledOnce();176 expect(commands.unsettle).toHaveBeenCalledOnce();
180 });177 });
181178
182 it("re-pins and re-snoozes a thread that settling had cleared", async () => {179 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";180 const snoozedUntil = "2030-01-01T09:00:00.000Z";
186 threadShell.pinnedAt = "2026-01-01T00:00:00.000Z";181 threadShell.pinnedAt = "2026-01-01T00:00:00.000Z";
187 threadShell.snoozedUntil = snoozedUntil;182 threadShell.snoozedUntil = snoozedUntil;
188 const actions = useThreadActions();183 const actions = useThreadActions();
189 await actions.settleThread(target);184 await actions.settleThread(target);
190 await undoOf(add, 0)();185 await currentUndo()();
191 expect(commands.unsettle).toHaveBeenCalledOnce();186 expect(commands.unsettle).toHaveBeenCalledOnce();
192 expect(commands.pin).toHaveBeenCalledExactlyOnceWith({187 expect(commands.pin).toHaveBeenCalledExactlyOnceWith({
193 environmentId: target.environmentId,188 environmentId: target.environmentId,
...@@ -200,31 +195,21 @@ describe("settle and snooze Undo", () => {...@@ -200,31 +195,21 @@ describe("settle and snooze Undo", () => {
200 });195 });
201196
202 it("expires an older unpin Undo when the thread is settled", async () => {197 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();198 const actions = useThreadActions();
206 await actions.unpinThread(target);199 await actions.unpinThread(target);
207 const staleUnpinUndo = undoOf(add, 0);200 const staleUnpinUndo = currentUndo();
208 await actions.settleThread(target);201 await actions.settleThread(target);
209 await staleUnpinUndo();202 await staleUnpinUndo();
210 expect(commands.pin).not.toHaveBeenCalled();203 expect(commands.pin).not.toHaveBeenCalled();
211 });204 });
212205
213 it("stays silent for batch settles", async () => {206 it("wakes the thread from the snooze notice", async () => {
214 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");207 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();208 const actions = useThreadActions();
223 await actions.snoozeThread(target, new Date(Date.now() + 60_000).toISOString());209 await actions.snoozeThread(target, new Date(Date.now() + 60_000).toISOString());
224 expect(add).toHaveBeenCalledWith(210 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Snoozed", count: 1 });
225 expect.objectContaining({ title: expect.stringMatching(/^Snoozed until /) }),211 expect(add).not.toHaveBeenCalled();
226 );212 await currentUndo()();
227 await undoOf(add, 0)();
228 expect(commands.unsnooze).toHaveBeenCalledExactlyOnceWith({213 expect(commands.unsnooze).toHaveBeenCalledExactlyOnceWith({
229 environmentId: target.environmentId,214 environmentId: target.environmentId,
230 input: { threadId: target.threadId, reason: "user" },215 input: { threadId: target.threadId, reason: "user" },
apps/web/src/routes/_chat.tsx+14
...@@ -16,6 +16,9 @@ import { useHandleNewThread } from "../hooks/useHandleNewThread";...@@ -16,6 +16,9 @@ import { useHandleNewThread } from "../hooks/useHandleNewThread";
16import { startNewThreadFromContext } from "../lib/chatThreadActions";16import { startNewThreadFromContext } from "../lib/chatThreadActions";
17import { isPreviewFocused } from "../lib/previewFocus";17import { isPreviewFocused } from "../lib/previewFocus";
18import { isTerminalFocused } from "../lib/terminalFocus";18import { isTerminalFocused } from "../lib/terminalFocus";
19import { isEditableFocused } from "../lib/editableFocus";
20import { isModelPickerOpen } from "../modelPickerVisibility";
21import { undoLatestThreadAction } from "../hooks/showThreadUndoNotice";
19import { resolveShortcutCommand } from "../keybindings";22import { resolveShortcutCommand } from "../keybindings";
20import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";23import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
21import { isPreviewSupportedInRuntime } from "../previewStateStore";24import { isPreviewSupportedInRuntime } from "../previewStateStore";
...@@ -66,6 +69,8 @@ function ChatRouteGlobalShortcuts() {...@@ -66,6 +69,8 @@ function ChatRouteGlobalShortcuts() {
66 terminalOpen,69 terminalOpen,
67 previewFocus: isPreviewFocused(),70 previewFocus: isPreviewFocused(),
68 previewOpen,71 previewOpen,
72 editableFocus: isEditableFocused(event.target),
73 modelPickerOpen: isModelPickerOpen(),
69 },74 },
70 });75 });
7176
...@@ -73,6 +78,15 @@ function ChatRouteGlobalShortcuts() {...@@ -73,6 +78,15 @@ function ChatRouteGlobalShortcuts() {
73 return;78 return;
74 }79 }
7580
81 if (command === "thread.undo") {
82 if (event.repeat || isModelPickerOpen()) return;
83 if (undoLatestThreadAction()) {
84 event.preventDefault();
85 event.stopPropagation();
86 }
87 return;
88 }
89
76 if (event.key === "Escape" && selectedThreadKeysSize > 0) {90 if (event.key === "Escape" && selectedThreadKeysSize > 0) {
77 event.preventDefault();91 event.preventDefault();
78 clearSelection();92 clearSelection();
docs/user/keybindings.md+5-4
...@@ -111,10 +111,11 @@ a shortcut....@@ -111,10 +111,11 @@ a shortcut.
111`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
112shortcut; assign one in **Settings → Keybindings**.112shortcut; assign one in **Settings → Keybindings**.
113113
114`thread.undo` (`mod+z` by default) reverses the most recent thread action that is114`thread.undo` (`mod+z` by default) reverses the actions shown in the notice at the
115still offering **Undo** in a notification, such as an unpin, settle, snooze, or115bottom of the sidebar, such as unpin, settle, snooze, or archive. Consecutive
116archive. Its default rule skips text fields and terminals so native undo keeps116actions of the same kind undo together. The notice remains available for five
117working there.117seconds after the latest action. The default shortcut skips text fields and
118terminals so native undo keeps working there.
118119
119`chat.new` may ask you to choose a project when there is more than one.120`chat.new` may ask you to choose a project when there is more than one.
120`chat.newLocal` skips that chooser. Both use your121`chat.newLocal` skips that chooser. Both use your