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 {
230230import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar";
231231import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
232232import { isEditableFocused } from "../lib/editableFocus";
233import { undoLatestThreadAction } from "../hooks/showUndoToast";
234233import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
235234import {
236235 AlarmClockIcon,
......@@ -6731,17 +6730,6 @@ export default function ChatView(props: ChatViewProps) {
67316730 return;
67326731 }
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
67456733 if (command === "thread.pin") {
67466734 event.preventDefault();
67476735 event.stopPropagation();
apps/web/src/components/Sidebar.tsx+7-33
......@@ -3077,9 +3077,7 @@ export default function Sidebar() {
30773077 settlingThreadKeysRef.current.add(threadKey);
30783078 try {
30793079 const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys);
3080 const result = await settleThread(threadRef, {
3081 undoToast: opts.coSettlingKeys === undefined,
3082 });
3080 const result = await settleThread(threadRef);
30833081 if (result._tag === "Failure") {
30843082 // Never navigate away from a thread that did not settle.
30853083 if (!isAtomCommandInterrupted(result)) {
......@@ -3731,9 +3729,7 @@ export default function Sidebar() {
37313729 // Snoozing the open thread moves you forward, same as settle —
37323730 // both park the thread you're done with for now.
37333731 const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys);
3734 const result = await snoozeThread(threadRef, preset.snoozedUntil, {
3735 undoToast: opts.coSnoozingKeys === undefined,
3736 });
3732 const result = await snoozeThread(threadRef, preset.snoozedUntil);
37373733 if (result._tag === "Failure") {
37383734 // Never navigate away from a thread that did not snooze.
37393735 return isAtomCommandInterrupted(result)
......@@ -3887,35 +3883,15 @@ export default function Sidebar() {
38873883 outcome.status === "failure" ? [outcome.error] : [],
38883884 );
38893885
3890 if (snoozedThreadRefs.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) {
3886 if (failures.length > 0) {
39143887 const firstError = failures[0];
39153888 toastManager.add(
39163889 stackedThreadToast({
39173890 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",
39193895 description:
39203896 firstError instanceof Error ? firstError.message : "An error occurred.",
39213897 }),
......@@ -4019,7 +3995,6 @@ export default function Sidebar() {
40193995 },
40203996 [
40213997 attemptSettle,
4022 attemptSnooze,
40233998 attemptUnpin,
40243999 clearSelection,
40254000 confirmThreadDelete,
......@@ -4028,7 +4003,6 @@ export default function Sidebar() {
40284003 performSnooze,
40294004 removeFromSelection,
40304005 serverConfigs,
4031 attemptUnsnooze,
40324006 updateThreadMetadata,
40334007 timestampFormat,
40344008 ],
apps/web/src/components/sidebar/SidebarChrome.tsx+2
......@@ -26,6 +26,7 @@ import {
2626} from "../ui/sidebar";
2727import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2828import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences";
29import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice";
2930import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";
3031import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";
3132import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
......@@ -221,6 +222,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
221222export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
222223 return (
223224 <SidebarFooter className="px-[var(--sidebar-content-inset)] py-1">
225 <SidebarThreadUndoNotice />
224226 <SidebarProviderUpdatePill />
225227 <SidebarUpdateArchitectureWarning />
226228 <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
1111 variants: {
1212 variant: {
1313 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",
1416 error:
1517 "border-error/32 bg-error-surface text-error-foreground [&_[data-slot=alert-description]]:text-error-foreground/80 [&_svg]:text-error",
1618 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 @@
11// Shared across hook instances so sidebar, header and menu actions invalidate each other.
22const 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
416/** Claims one kind of thread action; a later claim of that kind expires its Undo. */
517export function begin(kind: string, threadKey: string) {
618 const key = JSON.stringify([kind, threadKey]);
719 const token = Symbol();
820 currentActions.set(key, token);
21 notify();
922 const isCurrent = () => currentActions.get(key) === token;
1023 return {
1124 isCurrent,
1225 finish: () => {
13 if (isCurrent()) currentActions.delete(key);
26 if (isCurrent()) {
27 currentActions.delete(key);
28 notify();
29 }
1430 },
1531 };
1632}
......@@ -18,4 +34,5 @@ export function begin(kind: string, threadKey: string) {
1834/** Expires only this action kind, leaving unrelated thread actions intact. */
1935export function invalidate(kind: string, threadKey: string) {
2036 currentActions.delete(JSON.stringify([kind, threadKey]));
37 notify();
2138}
apps/web/src/hooks/useThreadActions.ts+14-33
......@@ -15,7 +15,6 @@ 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";
1918import { useComposerDraftStore } from "../composerDraftStore";
2019import { terminalEnvironment } from "../state/terminal";
2120import { appAtomRegistry } from "../rpc/atomRegistry";
......@@ -44,7 +43,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "
4443import { stackedThreadToast, toastManager } from "../components/ui/toast";
4544import { useClientSettings } from "./useSettings";
4645import * as ThreadUndo from "./threadUndo";
47import { showUndoToast } from "./showUndoToast";
46import { showThreadUndoNotice } from "./showThreadUndoNotice";
4847import { useAtomCommand } from "../state/use-atom-command";
4948
5049export class ThreadArchiveBlockedError extends Schema.TaggedError<ThreadArchiveBlockedError>()(
......@@ -223,7 +222,6 @@ export function useThreadActions() {
223222 const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder);
224223 const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete);
225224 const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin);
226 const timestampFormat = useClientSettings((settings) => settings.timestampFormat);
227225 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
228226 const clearProjectDraftThreadById = useComposerDraftStore(
229227 (store) => store.clearProjectDraftThreadById,
......@@ -313,9 +311,8 @@ export function useThreadActions() {
313311 }
314312 refreshArchivedThreadsForEnvironment(threadRef.environmentId);
315313 opts.onArchived?.();
316 showUndoToast({
317 title: "Thread archived",
318 description: thread.title,
314 showThreadUndoNotice({
315 action: "Archived",
319316 claim: action,
320317 // Undo also brings the reader back when archiving moved them to a draft.
321318 undo: () => unarchiveThread(threadRef, { navigate: shouldNavigateToDraft }),
......@@ -613,9 +610,8 @@ export function useThreadActions() {
613610 input: { threadId: target.threadId },
614611 });
615612 if (result._tag === "Success" && action.isCurrent()) {
616 showUndoToast({
617 title: "Thread unpinned",
618 description: thread?.title,
613 showThreadUndoNotice({
614 action: "Unpinned",
619615 claim: action,
620616 undo: () => pinThread(target, orderKey === undefined ? {} : { orderKey }),
621617 failureTitle: "Failed to undo unpin",
......@@ -629,11 +625,7 @@ export function useThreadActions() {
629625 );
630626
631627 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 ) => {
628 async (target: ScopedThreadRef) => {
637629 // Version skew: never send the command to a server that predates it —
638630 // the raw protocol rejection would read as a random failure.
639631 if (!readEnvironmentSupportsSettlement(target.environmentId)) {
......@@ -671,13 +663,8 @@ export function useThreadActions() {
671663 if (wokeAt !== null) {
672664 markThreadVisited(scopedThreadKey(target), wokeAt);
673665 }
674 if (opts.undoToast === false) {
675 action.finish();
676 return result;
677 }
678 showUndoToast({
679 title: "Thread settled",
680 description: resolved?.thread.title,
666 showThreadUndoNotice({
667 action: "Settled",
681668 claim: action,
682669 undo: async () => {
683670 const unsettled = await unsettleThread(target);
......@@ -797,12 +784,7 @@ export function useThreadActions() {
797784 );
798785
799786 const snoozeThread = useCallback(
800 async (
801 target: ScopedThreadRef,
802 snoozedUntil: string,
803 // Batch callers report one toast for the whole selection instead.
804 opts: { undoToast?: boolean } = {},
805 ) => {
787 async (target: ScopedThreadRef, snoozedUntil: string) => {
806788 // Version skew: never send the command to a server that predates it.
807789 if (!readEnvironmentSupportsSnooze(target.environmentId)) {
808790 return AsyncResult.failure(
......@@ -833,21 +815,20 @@ export function useThreadActions() {
833815 environmentId: target.environmentId,
834816 input: { threadId: target.threadId, snoozedUntil },
835817 });
836 if (result._tag !== "Success" || opts.undoToast === false) {
818 if (result._tag !== "Success") {
837819 action.finish();
838820 return result;
839821 }
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,
822 // Snooze hides the row, so keep its confirmation in the sidebar.
823 showThreadUndoNotice({
824 action: "Snoozed",
844825 claim: action,
845826 undo: () => unsnoozeThread(target),
846827 failureTitle: "Failed to wake thread",
847828 });
848829 return result;
849830 },
850 [resolveThreadTarget, snoozeThreadMutation, timestampFormat, unsnoozeThread],
831 [resolveThreadTarget, snoozeThreadMutation, unsnoozeThread],
851832 );
852833
853834 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"
44import { useThreadActions } from "./useThreadActions";
55import { threadEnvironment } from "../state/threads";
66import { toastManager } from "../components/ui/toast";
7import { useThreadUndoNotice } from "./showThreadUndoNotice";
78
89const commands = vi.hoisted(() => ({
910 pin: vi.fn(),
......@@ -78,18 +79,14 @@ const target = {
7879 environmentId: EnvironmentId.make("undo-env"),
7980 threadId: ThreadId.make("thread"),
8081};
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);
82function currentUndo() {
83 const notice = useThreadUndoNotice.getState().notice;
84 expect(notice).not.toBeNull();
85 return notice!.undo;
9086}
9187
9288beforeEach(() => {
89 vi.useFakeTimers();
9390 for (const command of Object.values(commands)) {
9491 command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined });
9592 }
......@@ -98,19 +95,21 @@ beforeEach(() => {
9895 threadShell.pinnedAt = null;
9996 threadShell.snoozedUntil = null;
10097});
101afterEach(() => vi.restoreAllMocks());
98afterEach(() => {
99 vi.runAllTimers();
100 vi.useRealTimers();
101 vi.restoreAllMocks();
102});
102103
103104describe("unpin Undo", () => {
104 it("ignores an old toast 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(() => {});
105 it("ignores an old notice across hook instances and still restores the latest unpin", async () => {
107106 const sidebar = useThreadActions();
108107 const header = useThreadActions();
109108 await sidebar.unpinThread(target);
110 const staleUndo = undoOf(add, 0);
109 const staleUndo = currentUndo();
111110 await header.pinThread(target, { orderKey: "a1" });
112111 await header.unpinThread(target);
113 const latestUndo = undoOf(add, 1);
112 const latestUndo = currentUndo();
114113 await staleUndo();
115114 expect(commands.pin).toHaveBeenCalledTimes(1);
116115 await latestUndo();
......@@ -127,15 +126,15 @@ describe("unpin Undo", () => {
127126describe("archive Undo", () => {
128127 it("unarchives and returns to the thread when archiving left it", async () => {
129128 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
130 vi.spyOn(toastManager, "close").mockImplementation(() => {});
131129 router.state.matches[0]!.params = {
132130 environmentId: target.environmentId,
133131 threadId: target.threadId,
134132 };
135133 const actions = useThreadActions();
136134 await actions.archiveThread(target);
137 expect(add).toHaveBeenCalledWith(expect.objectContaining({ title: "Thread archived" }));
138 await undoOf(add, 0)();
135 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Archived", count: 1 });
136 expect(add).not.toHaveBeenCalled();
137 await currentUndo()();
139138 expect(commands.unarchive).toHaveBeenCalledExactlyOnceWith({
140139 environmentId: target.environmentId,
141140 input: { threadId: target.threadId },
......@@ -149,11 +148,9 @@ describe("archive Undo", () => {
149148 });
150149
151150 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(() => {});
154151 const actions = useThreadActions();
155152 await actions.archiveThread(target);
156 await undoOf(add, 0)();
153 await currentUndo()();
157154 expect(commands.unarchive).toHaveBeenCalledOnce();
158155 expect(router.navigate).not.toHaveBeenCalled();
159156 });
......@@ -167,27 +164,25 @@ describe("archive Undo", () => {
167164});
168165
169166describe("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 () => {
171168 const add = vi.spyOn(toastManager, "add").mockReturnValue("toast");
172 vi.spyOn(toastManager, "close").mockImplementation(() => {});
173169 const actions = useThreadActions();
174170 await actions.settleThread(target);
175 expect(add).toHaveBeenCalledWith(expect.objectContaining({ title: "Thread settled" }));
176 const undo = undoOf(add, 0);
171 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Settled", count: 1 });
172 expect(add).not.toHaveBeenCalled();
173 const undo = currentUndo();
177174 await actions.unsettleThread(target);
178175 await undo();
179176 expect(commands.unsettle).toHaveBeenCalledOnce();
180177 });
181178
182179 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(() => {});
185180 const snoozedUntil = "2030-01-01T09:00:00.000Z";
186181 threadShell.pinnedAt = "2026-01-01T00:00:00.000Z";
187182 threadShell.snoozedUntil = snoozedUntil;
188183 const actions = useThreadActions();
189184 await actions.settleThread(target);
190 await undoOf(add, 0)();
185 await currentUndo()();
191186 expect(commands.unsettle).toHaveBeenCalledOnce();
192187 expect(commands.pin).toHaveBeenCalledExactlyOnceWith({
193188 environmentId: target.environmentId,
......@@ -200,31 +195,21 @@ describe("settle and snooze Undo", () => {
200195 });
201196
202197 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(() => {});
205198 const actions = useThreadActions();
206199 await actions.unpinThread(target);
207 const staleUnpinUndo = undoOf(add, 0);
200 const staleUnpinUndo = currentUndo();
208201 await actions.settleThread(target);
209202 await staleUnpinUndo();
210203 expect(commands.pin).not.toHaveBeenCalled();
211204 });
212205
213 it("stays silent for batch settles", async () => {
206 it("wakes the thread from the snooze notice", async () => {
214207 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(() => {});
222208 const actions = useThreadActions();
223209 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)();
210 expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Snoozed", count: 1 });
211 expect(add).not.toHaveBeenCalled();
212 await currentUndo()();
228213 expect(commands.unsnooze).toHaveBeenCalledExactlyOnceWith({
229214 environmentId: target.environmentId,
230215 input: { threadId: target.threadId, reason: "user" },
apps/web/src/routes/_chat.tsx+14
......@@ -16,6 +16,9 @@ import { useHandleNewThread } from "../hooks/useHandleNewThread";
1616import { startNewThreadFromContext } from "../lib/chatThreadActions";
1717import { isPreviewFocused } from "../lib/previewFocus";
1818import { isTerminalFocused } from "../lib/terminalFocus";
19import { isEditableFocused } from "../lib/editableFocus";
20import { isModelPickerOpen } from "../modelPickerVisibility";
21import { undoLatestThreadAction } from "../hooks/showThreadUndoNotice";
1922import { resolveShortcutCommand } from "../keybindings";
2023import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
2124import { isPreviewSupportedInRuntime } from "../previewStateStore";
......@@ -66,6 +69,8 @@ function ChatRouteGlobalShortcuts() {
6669 terminalOpen,
6770 previewFocus: isPreviewFocused(),
6871 previewOpen,
72 editableFocus: isEditableFocused(event.target),
73 modelPickerOpen: isModelPickerOpen(),
6974 },
7075 });
7176
......@@ -73,6 +78,15 @@ function ChatRouteGlobalShortcuts() {
7378 return;
7479 }
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
7690 if (event.key === "Escape" && selectedThreadKeysSize > 0) {
7791 event.preventDefault();
7892 clearSelection();
docs/user/keybindings.md+5-4
......@@ -111,10 +111,11 @@ a shortcut.
111111`thread.stop` interrupts the running turn in the focused thread. It has no default
112112shortcut; assign one in **Settings → Keybindings**.
113113
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.
114`thread.undo` (`mod+z` by default) reverses the actions shown in the notice at the
115bottom of the sidebar, such as unpin, settle, snooze, or archive. Consecutive
116actions of the same kind undo together. The notice remains available for five
117seconds after the latest action. The default shortcut skips text fields and
118terminals so native undo keeps working there.
118119
119120`chat.new` may ask you to choose a project when there is more than one.
120121`chat.newLocal` skips that chooser. Both use your