| 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,7 +230,6 @@ import { |
| 230 | import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; | 230 | import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; |
| 231 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; | 231 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 232 | import { isEditableFocused } from "../lib/editableFocus"; | 232 | import { isEditableFocused } from "../lib/editableFocus"; |
| 233 | import { undoLatestThreadAction } from "../hooks/showUndoToast"; | ||
| 234 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 233 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 235 | import { | 234 | import { |
| 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 | } |
| 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 | 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 | ); |
| 3889 | 3885 | ||
| 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"; |
| 27 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 27 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 28 | import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences"; | 28 | import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences"; |
| 29 | import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; | ||
| 29 | import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; | 30 | import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; |
| 30 | import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; | 31 | import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; |
| 31 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; | 32 | import { 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() { |
| 221 | export const SidebarChromeFooter = memo(function SidebarChromeFooter() { | 222 | export 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 @@ | ||
| 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,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 @@ | ||
| 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,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. |
| 2 | const currentActions = new Map<string, symbol>(); | 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 | /** 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. */ |
| 5 | export function begin(kind: string, threadKey: string) { | 17 | export 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. */ |
| 19 | export function invalidate(kind: string, threadKey: string) { | 35 | export 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"; |
| 15 | import { useCallback, useMemo, useRef } from "react"; | 15 | import { useCallback, useMemo, useRef } from "react"; |
| 16 | 16 | ||
| 17 | import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; | 17 | import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; |
| 18 | import { snoozeWakeDescription } from "../components/Sidebar.snooze"; | ||
| 19 | import { useComposerDraftStore } from "../composerDraftStore"; | 18 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 20 | import { terminalEnvironment } from "../state/terminal"; | 19 | import { terminalEnvironment } from "../state/terminal"; |
| 21 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 20 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| ... | @@ -44,7 +43,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " | ... | @@ -44,7 +43,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " |
| 44 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; | 43 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; |
| 45 | import { useClientSettings } from "./useSettings"; | 44 | import { useClientSettings } from "./useSettings"; |
| 46 | import * as ThreadUndo from "./threadUndo"; | 45 | import * as ThreadUndo from "./threadUndo"; |
| 47 | import { showUndoToast } from "./showUndoToast"; | 46 | import { showThreadUndoNotice } from "./showThreadUndoNotice"; |
| 48 | import { useAtomCommand } from "../state/use-atom-command"; | 47 | import { useAtomCommand } from "../state/use-atom-command"; |
| 49 | 48 | ||
| 50 | export class ThreadArchiveBlockedError extends Schema.TaggedError<ThreadArchiveBlockedError>()( | 49 | export 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 | ); |
| 630 | 626 | ||
| 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 | ); |
| 798 | 785 | ||
| 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 | ); |
| 852 | 833 | ||
| 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" |
| 4 | import { useThreadActions } from "./useThreadActions"; | 4 | import { useThreadActions } from "./useThreadActions"; |
| 5 | import { threadEnvironment } from "../state/threads"; | 5 | import { threadEnvironment } from "../state/threads"; |
| 6 | import { toastManager } from "../components/ui/toast"; | 6 | import { toastManager } from "../components/ui/toast"; |
| 7 | import { useThreadUndoNotice } from "./showThreadUndoNotice"; | ||
| 7 | 8 | ||
| 8 | const commands = vi.hoisted(() => ({ | 9 | const 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 | }; |
| 81 | const event = {} as Parameters<NonNullable<React.ComponentProps<"button">["onClick"]>>[0]; | 82 | function currentUndo() { |
| 82 | 83 | const notice = useThreadUndoNotice.getState().notice; | |
| 83 | function 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 | } |
| 91 | 87 | ||
| 92 | beforeEach(() => { | 88 | beforeEach(() => { |
| 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 | }); |
| 101 | afterEach(() => vi.restoreAllMocks()); | 98 | afterEach(() => { |
| 99 | vi.runAllTimers(); | ||
| 100 | vi.useRealTimers(); | ||
| 101 | vi.restoreAllMocks(); | ||
| 102 | }); | ||
| 102 | 103 | ||
| 103 | describe("unpin Undo", () => { | 104 | describe("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", () => { |
| 127 | describe("archive Undo", () => { | 126 | describe("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 | }); |
| 150 | 149 | ||
| 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 | }); |
| 168 | 165 | ||
| 169 | describe("settle and snooze Undo", () => { | 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 | 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 | }); |
| 181 | 178 | ||
| 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 | }); |
| 201 | 196 | ||
| 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 | }); |
| 212 | 205 | ||
| 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"; |
| 16 | import { startNewThreadFromContext } from "../lib/chatThreadActions"; | 16 | import { startNewThreadFromContext } from "../lib/chatThreadActions"; |
| 17 | import { isPreviewFocused } from "../lib/previewFocus"; | 17 | import { isPreviewFocused } from "../lib/previewFocus"; |
| 18 | import { isTerminalFocused } from "../lib/terminalFocus"; | 18 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 19 | import { isEditableFocused } from "../lib/editableFocus"; | ||
| 20 | import { isModelPickerOpen } from "../modelPickerVisibility"; | ||
| 21 | import { undoLatestThreadAction } from "../hooks/showThreadUndoNotice"; | ||
| 19 | import { resolveShortcutCommand } from "../keybindings"; | 22 | import { resolveShortcutCommand } from "../keybindings"; |
| 20 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; | 23 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 21 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; | 24 | import { 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 | }); |
| 71 | 76 | ||
| ... | @@ -73,6 +78,15 @@ function ChatRouteGlobalShortcuts() { | ... | @@ -73,6 +78,15 @@ function ChatRouteGlobalShortcuts() { |
| 73 | return; | 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 | 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 default | 111 | `thread.stop` interrupts the running turn in the focused thread. It has no default |
| 112 | shortcut; assign one in **Settings → Keybindings**. | 112 | shortcut; assign one in **Settings → Keybindings**. |
| 113 | 113 | ||
| 114 | `thread.undo` (`mod+z` by default) reverses the most recent thread action that is | 114 | `thread.undo` (`mod+z` by default) reverses the actions shown in the notice at the |
| 115 | still offering **Undo** in a notification, such as an unpin, settle, snooze, or | 115 | bottom of the sidebar, such as unpin, settle, snooze, or archive. Consecutive |
| 116 | archive. Its default rule skips text fields and terminals so native undo keeps | 116 | actions of the same kind undo together. The notice remains available for five |
| 117 | working there. | 117 | seconds after the latest action. The default shortcut skips text fields and |
| 118 | terminals so native undo keeps working there. | ||
| 118 | 119 | ||
| 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 your | 121 | `chat.newLocal` skips that chooser. Both use your |