| author | |
| committer | |
| log | 9a609a4e444ba739d6fcd607769d68b679b0bcc5 |
| tree | c456239998607e6b12d04068171ddddb83da3351 |
| parent | 0141bc2bf5fcf52a563240a6bce4b58050496db5 |
| signature | Signed by PGP key B5690EEEBB952194 |
14 files changed, 378 insertions(+), 340 deletions(-)
apps/web/src/components/ChatView.tsx-12| ... | ... | @@ -230,7 +230,6 @@ import { |
| 230 | 230 | import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; |
| 231 | 231 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 232 | 232 | import { isEditableFocused } from "../lib/editableFocus"; |
| 233 | import { undoLatestThreadAction } from "../hooks/showUndoToast"; | |
| 234 | 233 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 235 | 234 | import { |
| 236 | 235 | AlarmClockIcon, |
| ... | ... | @@ -6731,17 +6730,6 @@ export default function ChatView(props: ChatViewProps) { |
| 6731 | 6730 | return; |
| 6732 | 6731 | } |
| 6733 | 6732 | |
| 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 | 6733 | if (command === "thread.pin") { |
| 6746 | 6734 | event.preventDefault(); |
| 6747 | 6735 | event.stopPropagation(); |
apps/web/src/components/Sidebar.tsx+7-33| ... | ... | @@ -3077,9 +3077,7 @@ export default function Sidebar() { |
| 3077 | 3077 | settlingThreadKeysRef.current.add(threadKey); |
| 3078 | 3078 | try { |
| 3079 | 3079 | const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); |
| 3080 | const result = await settleThread(threadRef, { | |
| 3081 | undoToast: opts.coSettlingKeys === undefined, | |
| 3082 | }); | |
| 3080 | const result = await settleThread(threadRef); | |
| 3083 | 3081 | if (result._tag === "Failure") { |
| 3084 | 3082 | // Never navigate away from a thread that did not settle. |
| 3085 | 3083 | if (!isAtomCommandInterrupted(result)) { |
| ... | ... | @@ -3731,9 +3729,7 @@ export default function Sidebar() { |
| 3731 | 3729 | // Snoozing the open thread moves you forward, same as settle — |
| 3732 | 3730 | // both park the thread you're done with for now. |
| 3733 | 3731 | 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); | |
| 3737 | 3733 | if (result._tag === "Failure") { |
| 3738 | 3734 | // Never navigate away from a thread that did not snooze. |
| 3739 | 3735 | return isAtomCommandInterrupted(result) |
| ... | ... | @@ -3887,35 +3883,15 @@ export default function Sidebar() { |
| 3887 | 3883 | outcome.status === "failure" ? [outcome.error] : [], |
| 3888 | 3884 | ); |
| 3889 | 3885 | |
| 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) { | |
| 3914 | 3887 | const firstError = failures[0]; |
| 3915 | 3888 | toastManager.add( |
| 3916 | 3889 | stackedThreadToast({ |
| 3917 | 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 | 3895 | description: |
| 3920 | 3896 | firstError instanceof Error ? firstError.message : "An error occurred.", |
| 3921 | 3897 | }), |
| ... | ... | @@ -4019,7 +3995,6 @@ export default function Sidebar() { |
| 4019 | 3995 | }, |
| 4020 | 3996 | [ |
| 4021 | 3997 | attemptSettle, |
| 4022 | attemptSnooze, | |
| 4023 | 3998 | attemptUnpin, |
| 4024 | 3999 | clearSelection, |
| 4025 | 4000 | confirmThreadDelete, |
| ... | ... | @@ -4028,7 +4003,6 @@ export default function Sidebar() { |
| 4028 | 4003 | performSnooze, |
| 4029 | 4004 | removeFromSelection, |
| 4030 | 4005 | serverConfigs, |
| 4031 | attemptUnsnooze, | |
| 4032 | 4006 | updateThreadMetadata, |
| 4033 | 4007 | timestampFormat, |
| 4034 | 4008 | ], |
apps/web/src/components/sidebar/SidebarChrome.tsx+2| ... | ... | @@ -26,6 +26,7 @@ import { |
| 26 | 26 | } from "../ui/sidebar"; |
| 27 | 27 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 28 | 28 | import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences"; |
| 29 | import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; | |
| 29 | 30 | import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; |
| 30 | 31 | import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; |
| 31 | 32 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; |
| ... | ... | @@ -221,6 +222,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { |
| 221 | 222 | export const SidebarChromeFooter = memo(function SidebarChromeFooter() { |
| 222 | 223 | return ( |
| 223 | 224 | <SidebarFooter className="px-[var(--sidebar-content-inset)] py-1"> |
| 225 | <SidebarThreadUndoNotice /> | |
| 224 | 226 | <SidebarProviderUpdatePill /> |
| 225 | 227 | <SidebarUpdateArchitectureWarning /> |
| 226 | 228 | <SidebarUtilityMenu /> |
apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx created+30| ... | ... | @@ -0,0 +1,30 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | ||
| 3 | import { undoLatestThreadAction, useThreadUndoNotice } from "../../hooks/showThreadUndoNotice"; | |
| 4 | import { shortcutLabelForCommand } from "../../keybindings"; | |
| 5 | import { primaryServerKeybindingsAtom } from "../../state/server"; | |
| 6 | import { Alert, AlertDescription } from "../ui/alert"; | |
| 7 | import { InlineButton } from "../ui/button"; | |
| 8 | ||
| 9 | export 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 | 11 | variants: { |
| 12 | 12 | variant: { |
| 13 | 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 | 16 | error: |
| 15 | 17 | "border-error/32 bg-error-surface text-error-foreground [&_[data-slot=alert-description]]:text-error-foreground/80 [&_svg]:text-error", |
| 16 | 18 | info: "border-info/32 bg-info/4 [&_svg]:text-info", |
apps/web/src/hooks/showThreadUndoNotice.test.ts created+152| ... | ... | @@ -0,0 +1,152 @@ |
| 1 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 2 | import * as Cause from "effect/Cause"; | |
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { toastManager } from "../components/ui/toast"; | |
| 6 | import { | |
| 7 | showThreadUndoNotice, | |
| 8 | undoLatestThreadAction, | |
| 9 | useThreadUndoNotice, | |
| 10 | } from "./showThreadUndoNotice"; | |
| 11 | import * as ThreadUndo from "./threadUndo"; | |
| 12 | ||
| 13 | beforeEach(() => vi.useFakeTimers()); | |
| 14 | afterEach(() => { | |
| 15 | vi.runAllTimers(); | |
| 16 | vi.useRealTimers(); | |
| 17 | vi.restoreAllMocks(); | |
| 18 | }); | |
| 19 | ||
| 20 | function 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 | ||
| 28 | function notice() { | |
| 29 | const value = useThreadUndoNotice.getState().notice; | |
| 30 | if (!value) throw new Error("Undo notice is missing"); | |
| 31 | return value; | |
| 32 | } | |
| 33 | ||
| 34 | describe("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 @@ |
| 1 | import { | |
| 2 | type AtomCommandResult, | |
| 3 | isAtomCommandInterrupted, | |
| 4 | squashAtomCommandFailure, | |
| 5 | } from "@t3tools/client-runtime/state/runtime"; | |
| 6 | import { create } from "zustand"; | |
| 7 | ||
| 8 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; | |
| 9 | import * as ThreadUndo from "./threadUndo"; | |
| 10 | ||
| 11 | type UndoOptions = { | |
| 12 | action: "Settled" | "Snoozed" | "Unpinned" | "Archived"; | |
| 13 | undo: () => Promise<AtomCommandResult<unknown, unknown>>; | |
| 14 | failureTitle: string; | |
| 15 | claim: ReturnType<typeof ThreadUndo.begin>; | |
| 16 | }; | |
| 17 | ||
| 18 | type UndoNotice = { | |
| 19 | action: UndoOptions["action"]; | |
| 20 | count: number; | |
| 21 | undo: () => Promise<void>; | |
| 22 | }; | |
| 23 | ||
| 24 | export 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. | |
| 28 | let liveUndos: UndoOptions[] = []; | |
| 29 | let expiry: ReturnType<typeof setTimeout> | undefined; | |
| 30 | ||
| 31 | function 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 | ||
| 84 | ThreadUndo.subscribe(refreshNotice); | |
| 85 | ||
| 86 | /** Runs the group displayed in the sidebar; false when nothing is left to undo. */ | |
| 87 | export 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. */ | |
| 95 | export 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 @@ |
| 1 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 2 | import * as Cause from "effect/Cause"; | |
| 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { toastManager } from "../components/ui/toast"; | |
| 6 | import { showUndoToast, undoLatestThreadAction } from "./showUndoToast"; | |
| 7 | import * as ThreadUndo from "./threadUndo"; | |
| 8 | ||
| 9 | afterEach(() => vi.restoreAllMocks()); | |
| 10 | ||
| 11 | function 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 | ||
| 26 | function 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 | ||
| 32 | describe("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 | ||
| 97 | describe("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 @@ |
| 1 | import { | |
| 2 | type AtomCommandResult, | |
| 3 | isAtomCommandInterrupted, | |
| 4 | squashAtomCommandFailure, | |
| 5 | } from "@t3tools/client-runtime/state/runtime"; | |
| 6 | ||
| 7 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; | |
| 8 | import 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. | |
| 12 | const liveUndos: Array<() => Promise<void> | null> = []; | |
| 13 | ||
| 14 | /** Runs the newest Undo whose claim still holds; false when nothing is left to undo. */ | |
| 15 | export 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. */ | |
| 25 | export 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 | 1 | // Shared across hook instances so sidebar, header and menu actions invalidate each other. |
| 2 | 2 | const currentActions = new Map<string, symbol>(); |
| 3 | const listeners = new Set<() => void>(); | |
| 4 | ||
| 5 | export function subscribe(listener: () => void) { | |
| 6 | listeners.add(listener); | |
| 7 | return () => { | |
| 8 | listeners.delete(listener); | |
| 9 | }; | |
| 10 | } | |
| 11 | ||
| 12 | function notify() { | |
| 13 | for (const listener of listeners) listener(); | |
| 14 | } | |
| 3 | 15 | |
| 4 | 16 | /** Claims one kind of thread action; a later claim of that kind expires its Undo. */ |
| 5 | 17 | export function begin(kind: string, threadKey: string) { |
| 6 | 18 | const key = JSON.stringify([kind, threadKey]); |
| 7 | 19 | const token = Symbol(); |
| 8 | 20 | currentActions.set(key, token); |
| 21 | notify(); | |
| 9 | 22 | const isCurrent = () => currentActions.get(key) === token; |
| 10 | 23 | return { |
| 11 | 24 | isCurrent, |
| 12 | 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 | 34 | /** Expires only this action kind, leaving unrelated thread actions intact. */ |
| 19 | 35 | export function invalidate(kind: string, threadKey: string) { |
| 20 | 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 | 15 | import { useCallback, useMemo, useRef } from "react"; |
| 16 | 16 | |
| 17 | 17 | import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; |
| 18 | import { snoozeWakeDescription } from "../components/Sidebar.snooze"; | |
| 19 | 18 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 20 | 19 | import { terminalEnvironment } from "../state/terminal"; |
| 21 | 20 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| ... | ... | @@ -44,7 +43,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " |
| 44 | 43 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; |
| 45 | 44 | import { useClientSettings } from "./useSettings"; |
| 46 | 45 | import * as ThreadUndo from "./threadUndo"; |
| 47 | import { showUndoToast } from "./showUndoToast"; | |
| 46 | import { showThreadUndoNotice } from "./showThreadUndoNotice"; | |
| 48 | 47 | import { useAtomCommand } from "../state/use-atom-command"; |
| 49 | 48 | |
| 50 | 49 | export class ThreadArchiveBlockedError extends Schema.TaggedError<ThreadArchiveBlockedError>()( |
| ... | ... | @@ -223,7 +222,6 @@ export function useThreadActions() { |
| 223 | 222 | const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); |
| 224 | 223 | const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); |
| 225 | 224 | const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin); |
| 226 | const timestampFormat = useClientSettings((settings) => settings.timestampFormat); | |
| 227 | 225 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); |
| 228 | 226 | const clearProjectDraftThreadById = useComposerDraftStore( |
| 229 | 227 | (store) => store.clearProjectDraftThreadById, |
| ... | ... | @@ -313,9 +311,8 @@ export function useThreadActions() { |
| 313 | 311 | } |
| 314 | 312 | refreshArchivedThreadsForEnvironment(threadRef.environmentId); |
| 315 | 313 | opts.onArchived?.(); |
| 316 | showUndoToast({ | |
| 317 | title: "Thread archived", | |
| 318 | description: thread.title, | |
| 314 | showThreadUndoNotice({ | |
| 315 | action: "Archived", | |
| 319 | 316 | claim: action, |
| 320 | 317 | // Undo also brings the reader back when archiving moved them to a draft. |
| 321 | 318 | undo: () => unarchiveThread(threadRef, { navigate: shouldNavigateToDraft }), |
| ... | ... | @@ -613,9 +610,8 @@ export function useThreadActions() { |
| 613 | 610 | input: { threadId: target.threadId }, |
| 614 | 611 | }); |
| 615 | 612 | if (result._tag === "Success" && action.isCurrent()) { |
| 616 | showUndoToast({ | |
| 617 | title: "Thread unpinned", | |
| 618 | description: thread?.title, | |
| 613 | showThreadUndoNotice({ | |
| 614 | action: "Unpinned", | |
| 619 | 615 | claim: action, |
| 620 | 616 | undo: () => pinThread(target, orderKey === undefined ? {} : { orderKey }), |
| 621 | 617 | failureTitle: "Failed to undo unpin", |
| ... | ... | @@ -629,11 +625,7 @@ export function useThreadActions() { |
| 629 | 625 | ); |
| 630 | 626 | |
| 631 | 627 | 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) => { | |
| 637 | 629 | // Version skew: never send the command to a server that predates it — |
| 638 | 630 | // the raw protocol rejection would read as a random failure. |
| 639 | 631 | if (!readEnvironmentSupportsSettlement(target.environmentId)) { |
| ... | ... | @@ -671,13 +663,8 @@ export function useThreadActions() { |
| 671 | 663 | if (wokeAt !== null) { |
| 672 | 664 | markThreadVisited(scopedThreadKey(target), wokeAt); |
| 673 | 665 | } |
| 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", | |
| 681 | 668 | claim: action, |
| 682 | 669 | undo: async () => { |
| 683 | 670 | const unsettled = await unsettleThread(target); |
| ... | ... | @@ -797,12 +784,7 @@ export function useThreadActions() { |
| 797 | 784 | ); |
| 798 | 785 | |
| 799 | 786 | 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) => { | |
| 806 | 788 | // Version skew: never send the command to a server that predates it. |
| 807 | 789 | if (!readEnvironmentSupportsSnooze(target.environmentId)) { |
| 808 | 790 | return AsyncResult.failure( |
| ... | ... | @@ -833,21 +815,20 @@ export function useThreadActions() { |
| 833 | 815 | environmentId: target.environmentId, |
| 834 | 816 | input: { threadId: target.threadId, snoozedUntil }, |
| 835 | 817 | }); |
| 836 | if (result._tag !== "Success" || opts.undoToast === false) { | |
| 818 | if (result._tag !== "Success") { | |
| 837 | 819 | action.finish(); |
| 838 | 820 | return result; |
| 839 | 821 | } |
| 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", | |
| 844 | 825 | claim: action, |
| 845 | 826 | undo: () => unsnoozeThread(target), |
| 846 | 827 | failureTitle: "Failed to wake thread", |
| 847 | 828 | }); |
| 848 | 829 | return result; |
| 849 | 830 | }, |
| 850 | [resolveThreadTarget, snoozeThreadMutation, timestampFormat, unsnoozeThread], | |
| 831 | [resolveThreadTarget, snoozeThreadMutation, unsnoozeThread], | |
| 851 | 832 | ); |
| 852 | 833 | |
| 853 | 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 | 4 | import { useThreadActions } from "./useThreadActions"; |
| 5 | 5 | import { threadEnvironment } from "../state/threads"; |
| 6 | 6 | import { toastManager } from "../components/ui/toast"; |
| 7 | import { useThreadUndoNotice } from "./showThreadUndoNotice"; | |
| 7 | 8 | |
| 8 | 9 | const commands = vi.hoisted(() => ({ |
| 9 | 10 | pin: vi.fn(), |
| ... | ... | @@ -78,18 +79,14 @@ const target = { |
| 78 | 79 | environmentId: EnvironmentId.make("undo-env"), |
| 79 | 80 | threadId: ThreadId.make("thread"), |
| 80 | 81 | }; |
| 81 | const event = {} as Parameters<NonNullable<React.ComponentProps<"button">["onClick"]>>[0]; | |
| 82 | ||
| 83 | function 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); | |
| 82 | function currentUndo() { | |
| 83 | const notice = useThreadUndoNotice.getState().notice; | |
| 84 | expect(notice).not.toBeNull(); | |
| 85 | return notice!.undo; | |
| 90 | 86 | } |
| 91 | 87 | |
| 92 | 88 | beforeEach(() => { |
| 89 | vi.useFakeTimers(); | |
| 93 | 90 | for (const command of Object.values(commands)) { |
| 94 | 91 | command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); |
| 95 | 92 | } |
| ... | ... | @@ -98,19 +95,21 @@ beforeEach(() => { |
| 98 | 95 | threadShell.pinnedAt = null; |
| 99 | 96 | threadShell.snoozedUntil = null; |
| 100 | 97 | }); |
| 101 | afterEach(() => vi.restoreAllMocks()); | |
| 98 | afterEach(() => { | |
| 99 | vi.runAllTimers(); | |
| 100 | vi.useRealTimers(); | |
| 101 | vi.restoreAllMocks(); | |
| 102 | }); | |
| 102 | 103 | |
| 103 | 104 | describe("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 () => { | |
| 107 | 106 | const sidebar = useThreadActions(); |
| 108 | 107 | const header = useThreadActions(); |
| 109 | 108 | await sidebar.unpinThread(target); |
| 110 | const staleUndo = undoOf(add, 0); | |
| 109 | const staleUndo = currentUndo(); | |
| 111 | 110 | await header.pinThread(target, { orderKey: "a1" }); |
| 112 | 111 | await header.unpinThread(target); |
| 113 | const latestUndo = undoOf(add, 1); | |
| 112 | const latestUndo = currentUndo(); | |
| 114 | 113 | await staleUndo(); |
| 115 | 114 | expect(commands.pin).toHaveBeenCalledTimes(1); |
| 116 | 115 | await latestUndo(); |
| ... | ... | @@ -127,15 +126,15 @@ describe("unpin Undo", () => { |
| 127 | 126 | describe("archive Undo", () => { |
| 128 | 127 | it("unarchives and returns to the thread when archiving left it", async () => { |
| 129 | 128 | const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); |
| 130 | vi.spyOn(toastManager, "close").mockImplementation(() => {}); | |
| 131 | 129 | router.state.matches[0]!.params = { |
| 132 | 130 | environmentId: target.environmentId, |
| 133 | 131 | threadId: target.threadId, |
| 134 | 132 | }; |
| 135 | 133 | const actions = useThreadActions(); |
| 136 | 134 | 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()(); | |
| 139 | 138 | expect(commands.unarchive).toHaveBeenCalledExactlyOnceWith({ |
| 140 | 139 | environmentId: target.environmentId, |
| 141 | 140 | input: { threadId: target.threadId }, |
| ... | ... | @@ -149,11 +148,9 @@ describe("archive Undo", () => { |
| 149 | 148 | }); |
| 150 | 149 | |
| 151 | 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 | 151 | const actions = useThreadActions(); |
| 155 | 152 | await actions.archiveThread(target); |
| 156 | await undoOf(add, 0)(); | |
| 153 | await currentUndo()(); | |
| 157 | 154 | expect(commands.unarchive).toHaveBeenCalledOnce(); |
| 158 | 155 | expect(router.navigate).not.toHaveBeenCalled(); |
| 159 | 156 | }); |
| ... | ... | @@ -167,27 +164,25 @@ describe("archive Undo", () => { |
| 167 | 164 | }); |
| 168 | 165 | |
| 169 | 166 | describe("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 | 168 | const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); |
| 172 | vi.spyOn(toastManager, "close").mockImplementation(() => {}); | |
| 173 | 169 | const actions = useThreadActions(); |
| 174 | 170 | 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(); | |
| 177 | 174 | await actions.unsettleThread(target); |
| 178 | 175 | await undo(); |
| 179 | 176 | expect(commands.unsettle).toHaveBeenCalledOnce(); |
| 180 | 177 | }); |
| 181 | 178 | |
| 182 | 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 | 180 | const snoozedUntil = "2030-01-01T09:00:00.000Z"; |
| 186 | 181 | threadShell.pinnedAt = "2026-01-01T00:00:00.000Z"; |
| 187 | 182 | threadShell.snoozedUntil = snoozedUntil; |
| 188 | 183 | const actions = useThreadActions(); |
| 189 | 184 | await actions.settleThread(target); |
| 190 | await undoOf(add, 0)(); | |
| 185 | await currentUndo()(); | |
| 191 | 186 | expect(commands.unsettle).toHaveBeenCalledOnce(); |
| 192 | 187 | expect(commands.pin).toHaveBeenCalledExactlyOnceWith({ |
| 193 | 188 | environmentId: target.environmentId, |
| ... | ... | @@ -200,31 +195,21 @@ describe("settle and snooze Undo", () => { |
| 200 | 195 | }); |
| 201 | 196 | |
| 202 | 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 | 198 | const actions = useThreadActions(); |
| 206 | 199 | await actions.unpinThread(target); |
| 207 | const staleUnpinUndo = undoOf(add, 0); | |
| 200 | const staleUnpinUndo = currentUndo(); | |
| 208 | 201 | await actions.settleThread(target); |
| 209 | 202 | await staleUnpinUndo(); |
| 210 | 203 | expect(commands.pin).not.toHaveBeenCalled(); |
| 211 | 204 | }); |
| 212 | 205 | |
| 213 | it("stays silent for batch settles", async () => { | |
| 206 | it("wakes the thread from the snooze notice", async () => { | |
| 214 | 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 | 208 | const actions = useThreadActions(); |
| 223 | 209 | 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()(); | |
| 228 | 213 | expect(commands.unsnooze).toHaveBeenCalledExactlyOnceWith({ |
| 229 | 214 | environmentId: target.environmentId, |
| 230 | 215 | input: { threadId: target.threadId, reason: "user" }, |
apps/web/src/routes/_chat.tsx+14| ... | ... | @@ -16,6 +16,9 @@ import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 16 | 16 | import { startNewThreadFromContext } from "../lib/chatThreadActions"; |
| 17 | 17 | import { isPreviewFocused } from "../lib/previewFocus"; |
| 18 | 18 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 19 | import { isEditableFocused } from "../lib/editableFocus"; | |
| 20 | import { isModelPickerOpen } from "../modelPickerVisibility"; | |
| 21 | import { undoLatestThreadAction } from "../hooks/showThreadUndoNotice"; | |
| 19 | 22 | import { resolveShortcutCommand } from "../keybindings"; |
| 20 | 23 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 21 | 24 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; |
| ... | ... | @@ -66,6 +69,8 @@ function ChatRouteGlobalShortcuts() { |
| 66 | 69 | terminalOpen, |
| 67 | 70 | previewFocus: isPreviewFocused(), |
| 68 | 71 | previewOpen, |
| 72 | editableFocus: isEditableFocused(event.target), | |
| 73 | modelPickerOpen: isModelPickerOpen(), | |
| 69 | 74 | }, |
| 70 | 75 | }); |
| 71 | 76 | |
| ... | ... | @@ -73,6 +78,15 @@ function ChatRouteGlobalShortcuts() { |
| 73 | 78 | return; |
| 74 | 79 | } |
| 75 | 80 | |
| 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 | 90 | if (event.key === "Escape" && selectedThreadKeysSize > 0) { |
| 77 | 91 | event.preventDefault(); |
| 78 | 92 | clearSelection(); |
docs/user/keybindings.md+5-4| ... | ... | @@ -111,10 +111,11 @@ a shortcut. |
| 111 | 111 | `thread.stop` interrupts the running turn in the focused thread. It has no default |
| 112 | 112 | shortcut; assign one in **Settings → Keybindings**. |
| 113 | 113 | |
| 114 | `thread.undo` (`mod+z` by default) reverses the most recent thread action that is | |
| 115 | still offering **Undo** in a notification, such as an unpin, settle, snooze, or | |
| 116 | archive. Its default rule skips text fields and terminals so native undo keeps | |
| 117 | working there. | |
| 114 | `thread.undo` (`mod+z` by default) reverses the actions shown in the notice at the | |
| 115 | bottom of the sidebar, such as unpin, settle, snooze, or archive. Consecutive | |
| 116 | actions of the same kind undo together. The notice remains available for five | |
| 117 | seconds after the latest action. The default shortcut skips text fields and | |
| 118 | terminals so native undo keeps working there. | |
| 118 | 119 | |
| 119 | 120 | `chat.new` may ask you to choose a project when there is more than one. |
| 120 | 121 | `chat.newLocal` skips that chooser. Both use your |