| author | |
| committer | |
| log | 5781b5240bd5d2e21c651f6b228975ac40cbd67b |
| tree | ded6bf03a7c32ef9d7daa1a4754941362700d0ff |
| parent | 6b0a04ade021588dd9f1afbfa0e75cccdb7b2da4 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>14 files changed, 520 insertions(+), 159 deletions(-)
apps/web/src/components/ChatView.tsx+16-2| ... | @@ -229,6 +229,8 @@ import { | ... | @@ -229,6 +229,8 @@ import { |
| 229 | } from "@t3tools/client-runtime/state/subagentRuntime"; | 229 | } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 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"; | ||
| 233 | import { undoLatestThreadAction } from "../hooks/showUndoToast"; | ||
| 232 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 234 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 233 | import { | 235 | import { |
| 234 | AlarmClockIcon, | 236 | AlarmClockIcon, |
| ... | @@ -6647,11 +6649,12 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -6647,11 +6649,12 @@ export default function ChatView(props: ChatViewProps) { |
| 6647 | }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); | 6649 | }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); |
| 6648 | 6650 | ||
| 6649 | const getShortcutContext = useCallback( | 6651 | const getShortcutContext = useCallback( |
| 6650 | () => ({ | 6652 | (eventTarget: EventTarget | null = document.activeElement) => ({ |
| 6651 | terminalFocus: getTerminalFocusOwner() !== null, | 6653 | terminalFocus: getTerminalFocusOwner() !== null, |
| 6652 | terminalOpen: Boolean(terminalUiState.terminalOpen), | 6654 | terminalOpen: Boolean(terminalUiState.terminalOpen), |
| 6653 | previewFocus: isPreviewFocused(), | 6655 | previewFocus: isPreviewFocused(), |
| 6654 | previewOpen: previewPanelOpen, | 6656 | previewOpen: previewPanelOpen, |
| 6657 | editableFocus: isEditableFocused(eventTarget), | ||
| 6655 | modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, | 6658 | modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, |
| 6656 | isWeb: !isElectron, | 6659 | isWeb: !isElectron, |
| 6657 | isDesktop: isElectron, | 6660 | isDesktop: isElectron, |
| ... | @@ -6679,7 +6682,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -6679,7 +6682,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6679 | if (event.defaultPrevented && terminalFocusOwner === null) { | 6682 | if (event.defaultPrevented && terminalFocusOwner === null) { |
| 6680 | return; | 6683 | return; |
| 6681 | } | 6684 | } |
| 6682 | const shortcutContext = getShortcutContext(); | 6685 | const shortcutContext = getShortcutContext(event.target); |
| 6683 | 6686 | ||
| 6684 | if ( | 6687 | if ( |
| 6685 | !shortcutContext.terminalFocus && | 6688 | !shortcutContext.terminalFocus && |
| ... | @@ -6728,6 +6731,17 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -6728,6 +6731,17 @@ export default function ChatView(props: ChatViewProps) { |
| 6728 | return; | 6731 | return; |
| 6729 | } | 6732 | } |
| 6730 | 6733 | ||
| 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 | |||
| 6731 | if (command === "thread.pin") { | 6745 | if (command === "thread.pin") { |
| 6732 | event.preventDefault(); | 6746 | event.preventDefault(); |
| 6733 | event.stopPropagation(); | 6747 | event.stopPropagation(); |
apps/web/src/components/Sidebar.tsx+8-23| ... | @@ -208,12 +208,7 @@ import { | ... | @@ -208,12 +208,7 @@ import { |
| 208 | type TerminalStatusIndicator, | 208 | type TerminalStatusIndicator, |
| 209 | useLinkedThreadPullRequest, | 209 | useLinkedThreadPullRequest, |
| 210 | } from "./ThreadStatusIndicators"; | 210 | } from "./ThreadStatusIndicators"; |
| 211 | import { | 211 | import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze"; |
| 212 | resolveSnoozePresets, | ||
| 213 | snoozeWakeDescription, | ||
| 214 | snoozeWakeLabel, | ||
| 215 | type SnoozePreset, | ||
| 216 | } from "./Sidebar.snooze"; | ||
| 217 | import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; | 212 | import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; |
| 218 | import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; | 213 | import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; |
| 219 | import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; | 214 | import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; |
| ... | @@ -3082,7 +3077,9 @@ export default function Sidebar() { | ... | @@ -3082,7 +3077,9 @@ export default function Sidebar() { |
| 3082 | settlingThreadKeysRef.current.add(threadKey); | 3077 | settlingThreadKeysRef.current.add(threadKey); |
| 3083 | try { | 3078 | try { |
| 3084 | const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); | 3079 | const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); |
| 3085 | const result = await settleThread(threadRef); | 3080 | const result = await settleThread(threadRef, { |
| 3081 | undoToast: opts.coSettlingKeys === undefined, | ||
| 3082 | }); | ||
| 3086 | if (result._tag === "Failure") { | 3083 | if (result._tag === "Failure") { |
| 3087 | // Never navigate away from a thread that did not settle. | 3084 | // Never navigate away from a thread that did not settle. |
| 3088 | if (!isAtomCommandInterrupted(result)) { | 3085 | if (!isAtomCommandInterrupted(result)) { |
| ... | @@ -3734,7 +3731,9 @@ export default function Sidebar() { | ... | @@ -3734,7 +3731,9 @@ export default function Sidebar() { |
| 3734 | // Snoozing the open thread moves you forward, same as settle — | 3731 | // Snoozing the open thread moves you forward, same as settle — |
| 3735 | // both park the thread you're done with for now. | 3732 | // both park the thread you're done with for now. |
| 3736 | const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); | 3733 | const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); |
| 3737 | const result = await snoozeThread(threadRef, preset.snoozedUntil); | 3734 | const result = await snoozeThread(threadRef, preset.snoozedUntil, { |
| 3735 | undoToast: opts.coSnoozingKeys === undefined, | ||
| 3736 | }); | ||
| 3738 | if (result._tag === "Failure") { | 3737 | if (result._tag === "Failure") { |
| 3739 | // Never navigate away from a thread that did not snooze. | 3738 | // Never navigate away from a thread that did not snooze. |
| 3740 | return isAtomCommandInterrupted(result) | 3739 | return isAtomCommandInterrupted(result) |
| ... | @@ -3780,23 +3779,9 @@ export default function Sidebar() { | ... | @@ -3780,23 +3779,9 @@ export default function Sidebar() { |
| 3780 | ); | 3779 | ); |
| 3781 | return; | 3780 | return; |
| 3782 | } | 3781 | } |
| 3783 | if (outcome.status !== "success") return; | ||
| 3784 | // Snooze hides the row, so the toast is the only confirmation — | ||
| 3785 | // and the Undo is the escape hatch for a mis-click. | ||
| 3786 | toastManager.add( | ||
| 3787 | stackedThreadToast({ | ||
| 3788 | type: "success", | ||
| 3789 | title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, | ||
| 3790 | timeout: 5_000, | ||
| 3791 | actionProps: { | ||
| 3792 | children: "Undo", | ||
| 3793 | onClick: () => attemptUnsnooze(threadRef), | ||
| 3794 | }, | ||
| 3795 | }), | ||
| 3796 | ); | ||
| 3797 | })(); | 3782 | })(); |
| 3798 | }, | 3783 | }, |
| 3799 | [attemptUnsnooze, performSnooze, timestampFormat], | 3784 | [performSnooze], |
| 3800 | ); | 3785 | ); |
| 3801 | 3786 | ||
| 3802 | const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); | 3787 | const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); |
apps/web/src/hooks/showUndoToast.test.ts+33-1| ... | @@ -3,7 +3,7 @@ import * as Cause from "effect/Cause"; | ... | @@ -3,7 +3,7 @@ import * as Cause from "effect/Cause"; |
| 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 4 | ||
| 5 | import { toastManager } from "../components/ui/toast"; | 5 | import { toastManager } from "../components/ui/toast"; |
| 6 | import { showUndoToast } from "./showUndoToast"; | 6 | import { showUndoToast, undoLatestThreadAction } from "./showUndoToast"; |
| 7 | import * as ThreadUndo from "./threadUndo"; | 7 | import * as ThreadUndo from "./threadUndo"; |
| 8 | 8 | ||
| 9 | afterEach(() => vi.restoreAllMocks()); | 9 | afterEach(() => vi.restoreAllMocks()); |
| ... | @@ -93,3 +93,35 @@ describe("showUndoToast", () => { | ... | @@ -93,3 +93,35 @@ describe("showUndoToast", () => { |
| 93 | expect(add).toHaveBeenCalledOnce(); | 93 | expect(add).toHaveBeenCalledOnce(); |
| 94 | }); | 94 | }); |
| 95 | }); | 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+40-14| ... | @@ -7,6 +7,20 @@ import { | ... | @@ -7,6 +7,20 @@ import { |
| 7 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; | 7 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; |
| 8 | import type * as ThreadUndo from "./threadUndo"; | 8 | import type * as ThreadUndo from "./threadUndo"; |
| 9 | 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 | |||
| 10 | /** Shows a single-use Undo while its thread action still owns the claim. */ | 24 | /** Shows a single-use Undo while its thread action still owns the claim. */ |
| 11 | export function showUndoToast({ | 25 | export function showUndoToast({ |
| 12 | title, | 26 | title, |
| ... | @@ -23,6 +37,7 @@ export function showUndoToast({ | ... | @@ -23,6 +37,7 @@ export function showUndoToast({ |
| 23 | }) { | 37 | }) { |
| 24 | if (!claim.isCurrent()) return; | 38 | if (!claim.isCurrent()) return; |
| 25 | let undoStarted = false; | 39 | let undoStarted = false; |
| 40 | let toastId: string | undefined; | ||
| 26 | const reportFailure = (error: unknown) => { | 41 | const reportFailure = (error: unknown) => { |
| 27 | toastManager.add( | 42 | toastManager.add( |
| 28 | stackedThreadToast({ | 43 | stackedThreadToast({ |
| ... | @@ -32,7 +47,26 @@ export function showUndoToast({ | ... | @@ -32,7 +47,26 @@ export function showUndoToast({ |
| 32 | }), | 47 | }), |
| 33 | ); | 48 | ); |
| 34 | }; | 49 | }; |
| 35 | const toastId = toastManager.add({ | 50 | const forget = () => { |
| 51 | const index = liveUndos.indexOf(run); | ||
| 52 | if (index !== -1) liveUndos.splice(index, 1); | ||
| 53 | }; | ||
| 54 | const run = () => { | ||
| 55 | forget(); | ||
| 56 | if (undoStarted || !claim.isCurrent()) return null; | ||
| 57 | undoStarted = true; | ||
| 58 | claim.finish(); | ||
| 59 | if (toastId !== undefined) toastManager.close(toastId); | ||
| 60 | return undo() | ||
| 61 | .then((result) => { | ||
| 62 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 63 | reportFailure(squashAtomCommandFailure(result)); | ||
| 64 | } | ||
| 65 | }) | ||
| 66 | .catch(reportFailure); | ||
| 67 | }; | ||
| 68 | liveUndos.push(run); | ||
| 69 | toastId = toastManager.add({ | ||
| 36 | ...stackedThreadToast({ | 70 | ...stackedThreadToast({ |
| 37 | type: "success", | 71 | type: "success", |
| 38 | title, | 72 | title, |
| ... | @@ -41,21 +75,13 @@ export function showUndoToast({ | ... | @@ -41,21 +75,13 @@ export function showUndoToast({ |
| 41 | actionProps: { | 75 | actionProps: { |
| 42 | children: "Undo", | 76 | children: "Undo", |
| 43 | onClick: async () => { | 77 | onClick: async () => { |
| 44 | if (undoStarted || !claim.isCurrent()) return; | 78 | await run(); |
| 45 | undoStarted = true; | ||
| 46 | claim.finish(); | ||
| 47 | toastManager.close(toastId); | ||
| 48 | try { | ||
| 49 | const result = await undo(); | ||
| 50 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 51 | reportFailure(squashAtomCommandFailure(result)); | ||
| 52 | } | ||
| 53 | } catch (error) { | ||
| 54 | reportFailure(error); | ||
| 55 | } | ||
| 56 | }, | 79 | }, |
| 57 | }, | 80 | }, |
| 58 | }), | 81 | }), |
| 59 | onClose: claim.finish, | 82 | onClose: () => { |
| 83 | claim.finish(); | ||
| 84 | forget(); | ||
| 85 | }, | ||
| 60 | }); | 86 | }); |
| 61 | } | 87 | } |
apps/web/src/hooks/useThreadActionMenu.ts+3-23| ... | @@ -11,7 +11,7 @@ import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | ... | @@ -11,7 +11,7 @@ import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; |
| 11 | import { useRouter } from "@tanstack/react-router"; | 11 | import { useRouter } from "@tanstack/react-router"; |
| 12 | import { useCallback, useMemo } from "react"; | 12 | import { useCallback, useMemo } from "react"; |
| 13 | 13 | ||
| 14 | import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze"; | 14 | import { resolveSnoozePresets } from "../components/Sidebar.snooze"; |
| 15 | import { | 15 | import { |
| 16 | buildThreadActionMenuItems, | 16 | buildThreadActionMenuItems, |
| 17 | type ThreadActionMenuId, | 17 | type ThreadActionMenuId, |
| ... | @@ -162,29 +162,9 @@ export function useThreadActionMenu(input: { | ... | @@ -162,29 +162,9 @@ export function useThreadActionMenu(input: { |
| 162 | : snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); | 162 | : snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); |
| 163 | if (!preset) return; | 163 | if (!preset) return; |
| 164 | const result = await snoozeThread(threadRef, preset.snoozedUntil); | 164 | const result = await snoozeThread(threadRef, preset.snoozedUntil); |
| 165 | if (result._tag === "Failure") { | 165 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 166 | if (!isAtomCommandInterrupted(result)) { | 166 | failureToast("Failed to snooze thread", squashAtomCommandFailure(result)); |
| 167 | failureToast("Failed to snooze thread", squashAtomCommandFailure(result)); | ||
| 168 | } | ||
| 169 | return; | ||
| 170 | } | 167 | } |
| 171 | toastManager.add( | ||
| 172 | stackedThreadToast({ | ||
| 173 | type: "success", | ||
| 174 | title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, | ||
| 175 | timeout: 5_000, | ||
| 176 | actionProps: { | ||
| 177 | children: "Undo", | ||
| 178 | onClick: () => { | ||
| 179 | void unsnoozeThread(threadRef).then((undone) => { | ||
| 180 | if (undone._tag === "Failure" && !isAtomCommandInterrupted(undone)) { | ||
| 181 | failureToast("Failed to wake thread", squashAtomCommandFailure(undone)); | ||
| 182 | } | ||
| 183 | }); | ||
| 184 | }, | ||
| 185 | }, | ||
| 186 | }), | ||
| 187 | ); | ||
| 188 | return; | 168 | return; |
| 189 | } | 169 | } |
| 190 | const reportFailure = async ( | 170 | const reportFailure = async ( |
apps/web/src/hooks/useThreadActions.ts+168-68| ... | @@ -15,6 +15,7 @@ import { useRouter } from "@tanstack/react-router"; | ... | @@ -15,6 +15,7 @@ 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"; | ||
| 18 | import { useComposerDraftStore } from "../composerDraftStore"; | 19 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 19 | import { terminalEnvironment } from "../state/terminal"; | 20 | import { terminalEnvironment } from "../state/terminal"; |
| 20 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 21 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| ... | @@ -222,6 +223,7 @@ export function useThreadActions() { | ... | @@ -222,6 +223,7 @@ export function useThreadActions() { |
| 222 | const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); | 223 | const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); |
| 223 | const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); | 224 | const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); |
| 224 | const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin); | 225 | const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin); |
| 226 | const timestampFormat = useClientSettings((settings) => settings.timestampFormat); | ||
| 225 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); | 227 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); |
| 226 | const clearProjectDraftThreadById = useComposerDraftStore( | 228 | const clearProjectDraftThreadById = useComposerDraftStore( |
| 227 | (store) => store.clearProjectDraftThreadById, | 229 | (store) => store.clearProjectDraftThreadById, |
| ... | @@ -252,6 +254,30 @@ export function useThreadActions() { | ... | @@ -252,6 +254,30 @@ export function useThreadActions() { |
| 252 | return resolveThreadRouteRef(currentRouteParams); | 254 | return resolveThreadRouteRef(currentRouteParams); |
| 253 | }, [router]); | 255 | }, [router]); |
| 254 | 256 | ||
| 257 | const unarchiveThread = useCallback( | ||
| 258 | async (target: ScopedThreadRef, opts: { navigate?: boolean } = {}) => { | ||
| 259 | ThreadUndo.invalidate("archive", scopedThreadKey(target)); | ||
| 260 | const result = await unarchiveThreadMutation({ | ||
| 261 | environmentId: target.environmentId, | ||
| 262 | input: { threadId: target.threadId }, | ||
| 263 | }); | ||
| 264 | if (result._tag === "Failure") { | ||
| 265 | return result; | ||
| 266 | } | ||
| 267 | refreshArchivedThreadsForEnvironment(target.environmentId); | ||
| 268 | if (opts.navigate) { | ||
| 269 | return settlePromise(() => | ||
| 270 | router.navigate({ | ||
| 271 | to: "/$environmentId/$threadId", | ||
| 272 | params: buildThreadRouteParams(target), | ||
| 273 | }), | ||
| 274 | ); | ||
| 275 | } | ||
| 276 | return result; | ||
| 277 | }, | ||
| 278 | [router, unarchiveThreadMutation], | ||
| 279 | ); | ||
| 280 | |||
| 255 | const archiveThread = useCallback( | 281 | const archiveThread = useCallback( |
| 256 | async (target: ScopedThreadRef, opts: { onArchived?: () => void } = {}) => { | 282 | async (target: ScopedThreadRef, opts: { onArchived?: () => void } = {}) => { |
| 257 | const resolved = resolveThreadTarget(target); | 283 | const resolved = resolveThreadTarget(target); |
| ... | @@ -272,11 +298,13 @@ export function useThreadActions() { | ... | @@ -272,11 +298,13 @@ export function useThreadActions() { |
| 272 | const shouldNavigateToDraft = | 298 | const shouldNavigateToDraft = |
| 273 | currentRouteThreadRef?.threadId === threadRef.threadId && | 299 | currentRouteThreadRef?.threadId === threadRef.threadId && |
| 274 | currentRouteThreadRef.environmentId === threadRef.environmentId; | 300 | currentRouteThreadRef.environmentId === threadRef.environmentId; |
| 301 | const action = ThreadUndo.begin("archive", scopedThreadKey(threadRef)); | ||
| 275 | const archiveResult = await archiveThreadMutation({ | 302 | const archiveResult = await archiveThreadMutation({ |
| 276 | environmentId: threadRef.environmentId, | 303 | environmentId: threadRef.environmentId, |
| 277 | input: { threadId: threadRef.threadId }, | 304 | input: { threadId: threadRef.threadId }, |
| 278 | }); | 305 | }); |
| 279 | if (archiveResult._tag === "Failure") { | 306 | if (archiveResult._tag === "Failure") { |
| 307 | action.finish(); | ||
| 280 | return archiveResult; | 308 | return archiveResult; |
| 281 | } | 309 | } |
| 282 | const wokeAt = threadWokeAt(thread, { now: new Date().toISOString() }); | 310 | const wokeAt = threadWokeAt(thread, { now: new Date().toISOString() }); |
| ... | @@ -285,6 +313,14 @@ export function useThreadActions() { | ... | @@ -285,6 +313,14 @@ export function useThreadActions() { |
| 285 | } | 313 | } |
| 286 | refreshArchivedThreadsForEnvironment(threadRef.environmentId); | 314 | refreshArchivedThreadsForEnvironment(threadRef.environmentId); |
| 287 | opts.onArchived?.(); | 315 | opts.onArchived?.(); |
| 316 | showUndoToast({ | ||
| 317 | title: "Thread archived", | ||
| 318 | description: thread.title, | ||
| 319 | claim: action, | ||
| 320 | // Undo also brings the reader back when archiving moved them to a draft. | ||
| 321 | undo: () => unarchiveThread(threadRef, { navigate: shouldNavigateToDraft }), | ||
| 322 | failureTitle: "Failed to undo archive", | ||
| 323 | }); | ||
| 288 | 324 | ||
| 289 | if (shouldNavigateToDraft) { | 325 | if (shouldNavigateToDraft) { |
| 290 | const navigationResult = await settlePromise(() => | 326 | const navigationResult = await settlePromise(() => |
| ... | @@ -298,21 +334,13 @@ export function useThreadActions() { | ... | @@ -298,21 +334,13 @@ export function useThreadActions() { |
| 298 | 334 | ||
| 299 | return archiveResult; | 335 | return archiveResult; |
| 300 | }, | 336 | }, |
| 301 | [archiveThreadMutation, getCurrentRouteThreadRef, markThreadVisited, resolveThreadTarget], | 337 | [ |
| 302 | ); | 338 | archiveThreadMutation, |
| 303 | 339 | getCurrentRouteThreadRef, | |
| 304 | const unarchiveThread = useCallback( | 340 | markThreadVisited, |
| 305 | async (target: ScopedThreadRef) => { | 341 | resolveThreadTarget, |
| 306 | const result = await unarchiveThreadMutation({ | 342 | unarchiveThread, |
| 307 | environmentId: target.environmentId, | 343 | ], |
| 308 | input: { threadId: target.threadId }, | ||
| 309 | }); | ||
| 310 | if (result._tag === "Success") { | ||
| 311 | refreshArchivedThreadsForEnvironment(target.environmentId); | ||
| 312 | } | ||
| 313 | return result; | ||
| 314 | }, | ||
| 315 | [unarchiveThreadMutation], | ||
| 316 | ); | 344 | ); |
| 317 | 345 | ||
| 318 | const deleteThread = useCallback( | 346 | const deleteThread = useCallback( |
| ... | @@ -508,38 +536,6 @@ export function useThreadActions() { | ... | @@ -508,38 +536,6 @@ export function useThreadActions() { |
| 508 | ], | 536 | ], |
| 509 | ); | 537 | ); |
| 510 | 538 | ||
| 511 | const settleThread = useCallback( | ||
| 512 | async (target: ScopedThreadRef) => { | ||
| 513 | // Version skew: never send the command to a server that predates it — | ||
| 514 | // the raw protocol rejection would read as a random failure. | ||
| 515 | if (!readEnvironmentSupportsSettlement(target.environmentId)) { | ||
| 516 | return AsyncResult.failure( | ||
| 517 | Cause.fail( | ||
| 518 | new ThreadSettlementUnsupportedError({ | ||
| 519 | environmentId: target.environmentId, | ||
| 520 | threadId: target.threadId, | ||
| 521 | }), | ||
| 522 | ), | ||
| 523 | ); | ||
| 524 | } | ||
| 525 | const resolved = resolveThreadTarget(target); | ||
| 526 | const wokeAt = resolved | ||
| 527 | ? threadWokeAt(resolved.thread, { now: new Date().toISOString() }) | ||
| 528 | : null; | ||
| 529 | // Settle is a high-frequency lifecycle action and stays silent — no | ||
| 530 | // toast. | ||
| 531 | const result = await settleThreadMutation({ | ||
| 532 | environmentId: target.environmentId, | ||
| 533 | input: { threadId: target.threadId }, | ||
| 534 | }); | ||
| 535 | if (result._tag === "Success" && wokeAt !== null) { | ||
| 536 | markThreadVisited(scopedThreadKey(target), wokeAt); | ||
| 537 | } | ||
| 538 | return result; | ||
| 539 | }, | ||
| 540 | [markThreadVisited, resolveThreadTarget, settleThreadMutation], | ||
| 541 | ); | ||
| 542 | |||
| 543 | const unsettleThread = useCallback( | 539 | const unsettleThread = useCallback( |
| 544 | async (target: ScopedThreadRef) => { | 540 | async (target: ScopedThreadRef) => { |
| 545 | if (!readEnvironmentSupportsSettlement(target.environmentId)) { | 541 | if (!readEnvironmentSupportsSettlement(target.environmentId)) { |
| ... | @@ -552,6 +548,7 @@ export function useThreadActions() { | ... | @@ -552,6 +548,7 @@ export function useThreadActions() { |
| 552 | ), | 548 | ), |
| 553 | ); | 549 | ); |
| 554 | } | 550 | } |
| 551 | ThreadUndo.invalidate("settle", scopedThreadKey(target)); | ||
| 555 | // reason "user" pins the thread active: auto-settle (PR merged / | 552 | // reason "user" pins the thread active: auto-settle (PR merged / |
| 556 | // inactivity) stays suppressed until real activity clears the pin. | 553 | // inactivity) stays suppressed until real activity clears the pin. |
| 557 | return unsettleThreadMutation({ | 554 | return unsettleThreadMutation({ |
| ... | @@ -631,6 +628,89 @@ export function useThreadActions() { | ... | @@ -631,6 +628,89 @@ export function useThreadActions() { |
| 631 | [pinThread, unpinThreadMutation], | 628 | [pinThread, unpinThreadMutation], |
| 632 | ); | 629 | ); |
| 633 | 630 | ||
| 631 | const settleThread = useCallback( | ||
| 632 | async ( | ||
| 633 | target: ScopedThreadRef, | ||
| 634 | // Batch callers settle a selection at once and stay silent as before. | ||
| 635 | opts: { undoToast?: boolean } = {}, | ||
| 636 | ) => { | ||
| 637 | // Version skew: never send the command to a server that predates it — | ||
| 638 | // the raw protocol rejection would read as a random failure. | ||
| 639 | if (!readEnvironmentSupportsSettlement(target.environmentId)) { | ||
| 640 | return AsyncResult.failure( | ||
| 641 | Cause.fail( | ||
| 642 | new ThreadSettlementUnsupportedError({ | ||
| 643 | environmentId: target.environmentId, | ||
| 644 | threadId: target.threadId, | ||
| 645 | }), | ||
| 646 | ), | ||
| 647 | ); | ||
| 648 | } | ||
| 649 | const resolved = resolveThreadTarget(target); | ||
| 650 | const wokeAt = resolved | ||
| 651 | ? threadWokeAt(resolved.thread, { now: new Date().toISOString() }) | ||
| 652 | : null; | ||
| 653 | // Settling also drops the pin and the snooze server-side, so Undo | ||
| 654 | // has to put those back as well. | ||
| 655 | const pinOrderKey = resolved?.thread.pinnedAt != null ? resolved.thread.pinOrderKey : null; | ||
| 656 | const wasPinned = resolved?.thread.pinnedAt != null; | ||
| 657 | const snoozedUntil = resolved?.thread.snoozedUntil ?? null; | ||
| 658 | // An older unpin/snooze Undo would re-pin or re-snooze, and the server | ||
| 659 | // treats either as a promotion that un-settles; settling supersedes them. | ||
| 660 | ThreadUndo.invalidate("pin", scopedThreadKey(target)); | ||
| 661 | ThreadUndo.invalidate("snooze", scopedThreadKey(target)); | ||
| 662 | const action = ThreadUndo.begin("settle", scopedThreadKey(target)); | ||
| 663 | const result = await settleThreadMutation({ | ||
| 664 | environmentId: target.environmentId, | ||
| 665 | input: { threadId: target.threadId }, | ||
| 666 | }); | ||
| 667 | if (result._tag !== "Success") { | ||
| 668 | action.finish(); | ||
| 669 | return result; | ||
| 670 | } | ||
| 671 | if (wokeAt !== null) { | ||
| 672 | markThreadVisited(scopedThreadKey(target), wokeAt); | ||
| 673 | } | ||
| 674 | if (opts.undoToast === false) { | ||
| 675 | action.finish(); | ||
| 676 | return result; | ||
| 677 | } | ||
| 678 | showUndoToast({ | ||
| 679 | title: "Thread settled", | ||
| 680 | description: resolved?.thread.title, | ||
| 681 | claim: action, | ||
| 682 | undo: async () => { | ||
| 683 | const unsettled = await unsettleThread(target); | ||
| 684 | if (unsettled._tag !== "Success") return unsettled; | ||
| 685 | if (wasPinned) { | ||
| 686 | const pinned = await pinThread( | ||
| 687 | target, | ||
| 688 | pinOrderKey == null ? {} : { orderKey: pinOrderKey }, | ||
| 689 | ); | ||
| 690 | if (pinned._tag !== "Success") return pinned; | ||
| 691 | } | ||
| 692 | if (snoozedUntil !== null) { | ||
| 693 | return snoozeThreadMutation({ | ||
| 694 | environmentId: target.environmentId, | ||
| 695 | input: { threadId: target.threadId, snoozedUntil }, | ||
| 696 | }); | ||
| 697 | } | ||
| 698 | return unsettled; | ||
| 699 | }, | ||
| 700 | failureTitle: "Failed to undo settle", | ||
| 701 | }); | ||
| 702 | return result; | ||
| 703 | }, | ||
| 704 | [ | ||
| 705 | markThreadVisited, | ||
| 706 | pinThread, | ||
| 707 | resolveThreadTarget, | ||
| 708 | settleThreadMutation, | ||
| 709 | snoozeThreadMutation, | ||
| 710 | unsettleThread, | ||
| 711 | ], | ||
| 712 | ); | ||
| 713 | |||
| 634 | const confirmAndUnpinThread = useCallback( | 714 | const confirmAndUnpinThread = useCallback( |
| 635 | async (target: ScopedThreadRef) => { | 715 | async (target: ScopedThreadRef) => { |
| 636 | const localApi = readLocalApi(); | 716 | const localApi = readLocalApi(); |
| ... | @@ -695,8 +775,34 @@ export function useThreadActions() { | ... | @@ -695,8 +775,34 @@ export function useThreadActions() { |
| 695 | [reorderActiveThreadMutation], | 775 | [reorderActiveThreadMutation], |
| 696 | ); | 776 | ); |
| 697 | 777 | ||
| 778 | const unsnoozeThread = useCallback( | ||
| 779 | async (target: ScopedThreadRef) => { | ||
| 780 | if (!readEnvironmentSupportsSnooze(target.environmentId)) { | ||
| 781 | return AsyncResult.failure( | ||
| 782 | Cause.fail( | ||
| 783 | new ThreadSnoozeUnsupportedError({ | ||
| 784 | environmentId: target.environmentId, | ||
| 785 | threadId: target.threadId, | ||
| 786 | }), | ||
| 787 | ), | ||
| 788 | ); | ||
| 789 | } | ||
| 790 | ThreadUndo.invalidate("snooze", scopedThreadKey(target)); | ||
| 791 | return unsnoozeThreadMutation({ | ||
| 792 | environmentId: target.environmentId, | ||
| 793 | input: { threadId: target.threadId, reason: "user" }, | ||
| 794 | }); | ||
| 795 | }, | ||
| 796 | [unsnoozeThreadMutation], | ||
| 797 | ); | ||
| 798 | |||
| 698 | const snoozeThread = useCallback( | 799 | const snoozeThread = useCallback( |
| 699 | async (target: ScopedThreadRef, snoozedUntil: string) => { | 800 | async ( |
| 801 | target: ScopedThreadRef, | ||
| 802 | snoozedUntil: string, | ||
| 803 | // Batch callers report one toast for the whole selection instead. | ||
| 804 | opts: { undoToast?: boolean } = {}, | ||
| 805 | ) => { | ||
| 700 | // Version skew: never send the command to a server that predates it. | 806 | // Version skew: never send the command to a server that predates it. |
| 701 | if (!readEnvironmentSupportsSnooze(target.environmentId)) { | 807 | if (!readEnvironmentSupportsSnooze(target.environmentId)) { |
| 702 | return AsyncResult.failure( | 808 | return AsyncResult.failure( |
| ... | @@ -722,32 +828,26 @@ export function useThreadActions() { | ... | @@ -722,32 +828,26 @@ export function useThreadActions() { |
| 722 | ), | 828 | ), |
| 723 | ); | 829 | ); |
| 724 | } | 830 | } |
| 725 | return snoozeThreadMutation({ | 831 | const action = ThreadUndo.begin("snooze", scopedThreadKey(target)); |
| 832 | const result = await snoozeThreadMutation({ | ||
| 726 | environmentId: target.environmentId, | 833 | environmentId: target.environmentId, |
| 727 | input: { threadId: target.threadId, snoozedUntil }, | 834 | input: { threadId: target.threadId, snoozedUntil }, |
| 728 | }); | 835 | }); |
| 729 | }, | 836 | if (result._tag !== "Success" || opts.undoToast === false) { |
| 730 | [resolveThreadTarget, snoozeThreadMutation], | 837 | action.finish(); |
| 731 | ); | 838 | return result; |
| 732 | |||
| 733 | const unsnoozeThread = useCallback( | ||
| 734 | async (target: ScopedThreadRef) => { | ||
| 735 | if (!readEnvironmentSupportsSnooze(target.environmentId)) { | ||
| 736 | return AsyncResult.failure( | ||
| 737 | Cause.fail( | ||
| 738 | new ThreadSnoozeUnsupportedError({ | ||
| 739 | environmentId: target.environmentId, | ||
| 740 | threadId: target.threadId, | ||
| 741 | }), | ||
| 742 | ), | ||
| 743 | ); | ||
| 744 | } | 839 | } |
| 745 | return unsnoozeThreadMutation({ | 840 | // Snooze hides the row, so the toast is the only confirmation. |
| 746 | environmentId: target.environmentId, | 841 | showUndoToast({ |
| 747 | input: { threadId: target.threadId, reason: "user" }, | 842 | title: `Snoozed until ${snoozeWakeDescription(snoozedUntil, new Date(), timestampFormat)}`, |
| 843 | description: resolved?.thread.title, | ||
| 844 | claim: action, | ||
| 845 | undo: () => unsnoozeThread(target), | ||
| 846 | failureTitle: "Failed to wake thread", | ||
| 748 | }); | 847 | }); |
| 848 | return result; | ||
| 749 | }, | 849 | }, |
| 750 | [unsnoozeThreadMutation], | 850 | [resolveThreadTarget, snoozeThreadMutation, timestampFormat, unsnoozeThread], |
| 751 | ); | 851 | ); |
| 752 | 852 | ||
| 753 | const confirmAndDeleteThread = useCallback( | 853 | const confirmAndDeleteThread = useCallback( |
apps/web/src/hooks/useThreadActions.undo.test.ts+188-24| ... | @@ -1,69 +1,233 @@ | ... | @@ -1,69 +1,233 @@ |
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | 3 | ||
| 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 | 7 | ||
| 8 | const commands = vi.hoisted(() => ({ pin: vi.fn(), unpin: vi.fn() })); | 8 | const commands = vi.hoisted(() => ({ |
| 9 | pin: vi.fn(), | ||
| 10 | unpin: vi.fn(), | ||
| 11 | archive: vi.fn(), | ||
| 12 | unarchive: vi.fn(), | ||
| 13 | settle: vi.fn(), | ||
| 14 | unsettle: vi.fn(), | ||
| 15 | snooze: vi.fn(), | ||
| 16 | unsnooze: vi.fn(), | ||
| 17 | })); | ||
| 18 | const router = vi.hoisted(() => ({ | ||
| 19 | navigate: vi.fn(async () => {}), | ||
| 20 | state: { matches: [{ params: {} as Record<string, string> }] }, | ||
| 21 | })); | ||
| 9 | vi.mock("react", async (original) => ({ | 22 | vi.mock("react", async (original) => ({ |
| 10 | ...(await original<typeof import("react")>()), | 23 | ...(await original<typeof import("react")>()), |
| 11 | useCallback: (callback: unknown) => callback, | 24 | useCallback: (callback: unknown) => callback, |
| 12 | useMemo: (create: () => unknown) => create(), | 25 | useMemo: (create: () => unknown) => create(), |
| 13 | useRef: (value: unknown) => ({ current: value }), | 26 | useRef: (value: unknown) => ({ current: value }), |
| 14 | })); | 27 | })); |
| 15 | vi.mock("@tanstack/react-router", () => ({ useRouter: () => ({}) })); | 28 | vi.mock("@tanstack/react-router", () => ({ useRouter: () => router })); |
| 16 | vi.mock("./useSettings", () => ({ useClientSettings: () => false })); | 29 | vi.mock("./useSettings", () => ({ useClientSettings: () => false })); |
| 17 | vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() })); | 30 | vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() })); |
| 18 | vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() })); | 31 | vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() })); |
| 19 | vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() })); | 32 | vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() })); |
| 20 | vi.mock("../uiStateStore", () => ({ useUiStateStore: () => vi.fn() })); | 33 | vi.mock("../uiStateStore", () => ({ useUiStateStore: () => vi.fn() })); |
| 34 | vi.mock("../lib/archivedThreadsState", () => ({ refreshArchivedThreadsForEnvironment: vi.fn() })); | ||
| 35 | const threadShell = vi.hoisted(() => ({ | ||
| 36 | title: "Thread", | ||
| 37 | pinOrderKey: "a0", | ||
| 38 | pinnedAt: null as string | null, | ||
| 39 | snoozedUntil: null as string | null, | ||
| 40 | projectId: "project", | ||
| 41 | environmentId: "undo-env", | ||
| 42 | session: null, | ||
| 43 | })); | ||
| 21 | vi.mock("../state/entities", async (original) => ({ | 44 | vi.mock("../state/entities", async (original) => ({ |
| 22 | ...(await original<typeof import("../state/entities")>()), | 45 | ...(await original<typeof import("../state/entities")>()), |
| 23 | readEnvironmentSupportsPinning: () => true, | 46 | readEnvironmentSupportsPinning: () => true, |
| 24 | readEnvironmentSupportsPinReorder: () => true, | 47 | readEnvironmentSupportsPinReorder: () => true, |
| 25 | readThreadShell: () => ({ title: "Thread", pinOrderKey: "a0" }), | 48 | readEnvironmentSupportsSettlement: () => true, |
| 49 | readEnvironmentSupportsSnooze: () => true, | ||
| 50 | readThreadShell: () => threadShell, | ||
| 26 | })); | 51 | })); |
| 27 | vi.mock("../state/use-atom-command", () => ({ | 52 | vi.mock("../state/use-atom-command", () => ({ |
| 28 | useAtomCommand: (command: unknown) => | 53 | useAtomCommand: (command: unknown) => { |
| 29 | command === threadEnvironment.pin | 54 | switch (command) { |
| 30 | ? commands.pin | 55 | case threadEnvironment.pin: |
| 31 | : command === threadEnvironment.unpin | 56 | return commands.pin; |
| 32 | ? commands.unpin | 57 | case threadEnvironment.unpin: |
| 33 | : vi.fn(), | 58 | return commands.unpin; |
| 59 | case threadEnvironment.archive: | ||
| 60 | return commands.archive; | ||
| 61 | case threadEnvironment.unarchive: | ||
| 62 | return commands.unarchive; | ||
| 63 | case threadEnvironment.settle: | ||
| 64 | return commands.settle; | ||
| 65 | case threadEnvironment.unsettle: | ||
| 66 | return commands.unsettle; | ||
| 67 | case threadEnvironment.snooze: | ||
| 68 | return commands.snooze; | ||
| 69 | case threadEnvironment.unsnooze: | ||
| 70 | return commands.unsnooze; | ||
| 71 | default: | ||
| 72 | return vi.fn(); | ||
| 73 | } | ||
| 74 | }, | ||
| 34 | })); | 75 | })); |
| 35 | 76 | ||
| 77 | const target = { | ||
| 78 | environmentId: EnvironmentId.make("undo-env"), | ||
| 79 | threadId: ThreadId.make("thread"), | ||
| 80 | }; | ||
| 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); | ||
| 90 | } | ||
| 91 | |||
| 92 | beforeEach(() => { | ||
| 93 | for (const command of Object.values(commands)) { | ||
| 94 | command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); | ||
| 95 | } | ||
| 96 | router.navigate.mockClear(); | ||
| 97 | router.state.matches[0]!.params = {}; | ||
| 98 | threadShell.pinnedAt = null; | ||
| 99 | threadShell.snoozedUntil = null; | ||
| 100 | }); | ||
| 36 | afterEach(() => vi.restoreAllMocks()); | 101 | afterEach(() => vi.restoreAllMocks()); |
| 37 | 102 | ||
| 38 | describe("unpin Undo", () => { | 103 | describe("unpin Undo", () => { |
| 39 | it("ignores an old toast across hook instances and still restores the latest unpin", async () => { | 104 | it("ignores an old toast across hook instances and still restores the latest unpin", async () => { |
| 40 | commands.pin.mockResolvedValue({ _tag: "Success", value: undefined }); | ||
| 41 | commands.unpin.mockResolvedValue({ _tag: "Success", value: undefined }); | ||
| 42 | const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); | 105 | const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); |
| 43 | vi.spyOn(toastManager, "close").mockImplementation(() => {}); | 106 | vi.spyOn(toastManager, "close").mockImplementation(() => {}); |
| 44 | const sidebar = useThreadActions(); | 107 | const sidebar = useThreadActions(); |
| 45 | const header = useThreadActions(); | 108 | const header = useThreadActions(); |
| 46 | const target = { | ||
| 47 | environmentId: EnvironmentId.make("undo-env"), | ||
| 48 | threadId: ThreadId.make("thread"), | ||
| 49 | }; | ||
| 50 | await sidebar.unpinThread(target); | 109 | await sidebar.unpinThread(target); |
| 51 | const staleUndo = add.mock.calls[0]?.[0].actionProps?.onClick; | 110 | const staleUndo = undoOf(add, 0); |
| 52 | await header.pinThread(target, { orderKey: "a1" }); | 111 | await header.pinThread(target, { orderKey: "a1" }); |
| 53 | await header.unpinThread(target); | 112 | await header.unpinThread(target); |
| 54 | const latestUndo = add.mock.calls[1]?.[0].actionProps?.onClick; | 113 | const latestUndo = undoOf(add, 1); |
| 55 | expect(staleUndo).toBeTypeOf("function"); | 114 | await staleUndo(); |
| 56 | expect(latestUndo).toBeTypeOf("function"); | ||
| 57 | const event = {} as Parameters<NonNullable<typeof staleUndo>>[0]; | ||
| 58 | staleUndo?.(event); | ||
| 59 | expect(commands.pin).toHaveBeenCalledTimes(1); | 115 | expect(commands.pin).toHaveBeenCalledTimes(1); |
| 60 | latestUndo?.(event); | 116 | await latestUndo(); |
| 61 | expect(commands.pin).toHaveBeenCalledTimes(2); | 117 | expect(commands.pin).toHaveBeenCalledTimes(2); |
| 62 | expect(commands.pin).toHaveBeenLastCalledWith({ | 118 | expect(commands.pin).toHaveBeenLastCalledWith({ |
| 63 | environmentId: target.environmentId, | 119 | environmentId: target.environmentId, |
| 64 | input: { threadId: target.threadId, orderKey: "a0" }, | 120 | input: { threadId: target.threadId, orderKey: "a0" }, |
| 65 | }); | 121 | }); |
| 66 | latestUndo?.(event); | 122 | await latestUndo(); |
| 67 | expect(commands.pin).toHaveBeenCalledTimes(2); | 123 | expect(commands.pin).toHaveBeenCalledTimes(2); |
| 68 | }); | 124 | }); |
| 69 | }); | 125 | }); |
| 126 | |||
| 127 | describe("archive Undo", () => { | ||
| 128 | it("unarchives and returns to the thread when archiving left it", async () => { | ||
| 129 | const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); | ||
| 130 | vi.spyOn(toastManager, "close").mockImplementation(() => {}); | ||
| 131 | router.state.matches[0]!.params = { | ||
| 132 | environmentId: target.environmentId, | ||
| 133 | threadId: target.threadId, | ||
| 134 | }; | ||
| 135 | const actions = useThreadActions(); | ||
| 136 | await actions.archiveThread(target); | ||
| 137 | expect(add).toHaveBeenCalledWith(expect.objectContaining({ title: "Thread archived" })); | ||
| 138 | await undoOf(add, 0)(); | ||
| 139 | expect(commands.unarchive).toHaveBeenCalledExactlyOnceWith({ | ||
| 140 | environmentId: target.environmentId, | ||
| 141 | input: { threadId: target.threadId }, | ||
| 142 | }); | ||
| 143 | expect(router.navigate).toHaveBeenCalledWith( | ||
| 144 | expect.objectContaining({ | ||
| 145 | to: "/$environmentId/$threadId", | ||
| 146 | params: { environmentId: target.environmentId, threadId: target.threadId }, | ||
| 147 | }), | ||
| 148 | ); | ||
| 149 | }); | ||
| 150 | |||
| 151 | it("stays put when the archived thread was not open", async () => { | ||
| 152 | const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); | ||
| 153 | vi.spyOn(toastManager, "close").mockImplementation(() => {}); | ||
| 154 | const actions = useThreadActions(); | ||
| 155 | await actions.archiveThread(target); | ||
| 156 | await undoOf(add, 0)(); | ||
| 157 | expect(commands.unarchive).toHaveBeenCalledOnce(); | ||
| 158 | expect(router.navigate).not.toHaveBeenCalled(); | ||
| 159 | }); | ||
| 160 | |||
| 161 | it("shows no Undo when the archive failed", async () => { | ||
| 162 | commands.archive.mockResolvedValue({ _tag: "Failure", cause: new Error("nope") }); | ||
| 163 | const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); | ||
| 164 | await useThreadActions().archiveThread(target); | ||
| 165 | expect(add).not.toHaveBeenCalled(); | ||
| 166 | }); | ||
| 167 | }); | ||
| 168 | |||
| 169 | describe("settle and snooze Undo", () => { | ||
| 170 | it("un-settles from the toast and expires the Undo after a manual un-settle", async () => { | ||
| 171 | const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); | ||
| 172 | vi.spyOn(toastManager, "close").mockImplementation(() => {}); | ||
| 173 | const actions = useThreadActions(); | ||
| 174 | await actions.settleThread(target); | ||
| 175 | expect(add).toHaveBeenCalledWith(expect.objectContaining({ title: "Thread settled" })); | ||
| 176 | const undo = undoOf(add, 0); | ||
| 177 | await actions.unsettleThread(target); | ||
| 178 | await undo(); | ||
| 179 | expect(commands.unsettle).toHaveBeenCalledOnce(); | ||
| 180 | }); | ||
| 181 | |||
| 182 | it("re-pins and re-snoozes a thread that settling had cleared", async () => { | ||
| 183 | const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); | ||
| 184 | vi.spyOn(toastManager, "close").mockImplementation(() => {}); | ||
| 185 | const snoozedUntil = "2030-01-01T09:00:00.000Z"; | ||
| 186 | threadShell.pinnedAt = "2026-01-01T00:00:00.000Z"; | ||
| 187 | threadShell.snoozedUntil = snoozedUntil; | ||
| 188 | const actions = useThreadActions(); | ||
| 189 | await actions.settleThread(target); | ||
| 190 | await undoOf(add, 0)(); | ||
| 191 | expect(commands.unsettle).toHaveBeenCalledOnce(); | ||
| 192 | expect(commands.pin).toHaveBeenCalledExactlyOnceWith({ | ||
| 193 | environmentId: target.environmentId, | ||
| 194 | input: { threadId: target.threadId, orderKey: "a0" }, | ||
| 195 | }); | ||
| 196 | expect(commands.snooze).toHaveBeenCalledExactlyOnceWith({ | ||
| 197 | environmentId: target.environmentId, | ||
| 198 | input: { threadId: target.threadId, snoozedUntil }, | ||
| 199 | }); | ||
| 200 | }); | ||
| 201 | |||
| 202 | it("expires an older unpin Undo when the thread is settled", async () => { | ||
| 203 | const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); | ||
| 204 | vi.spyOn(toastManager, "close").mockImplementation(() => {}); | ||
| 205 | const actions = useThreadActions(); | ||
| 206 | await actions.unpinThread(target); | ||
| 207 | const staleUnpinUndo = undoOf(add, 0); | ||
| 208 | await actions.settleThread(target); | ||
| 209 | await staleUnpinUndo(); | ||
| 210 | expect(commands.pin).not.toHaveBeenCalled(); | ||
| 211 | }); | ||
| 212 | |||
| 213 | it("stays silent for batch settles", async () => { | ||
| 214 | const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); | ||
| 215 | await useThreadActions().settleThread(target, { undoToast: false }); | ||
| 216 | expect(add).not.toHaveBeenCalled(); | ||
| 217 | }); | ||
| 218 | |||
| 219 | it("wakes the thread from the snooze toast", async () => { | ||
| 220 | const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); | ||
| 221 | vi.spyOn(toastManager, "close").mockImplementation(() => {}); | ||
| 222 | const actions = useThreadActions(); | ||
| 223 | await actions.snoozeThread(target, new Date(Date.now() + 60_000).toISOString()); | ||
| 224 | expect(add).toHaveBeenCalledWith( | ||
| 225 | expect.objectContaining({ title: expect.stringMatching(/^Snoozed until /) }), | ||
| 226 | ); | ||
| 227 | await undoOf(add, 0)(); | ||
| 228 | expect(commands.unsnooze).toHaveBeenCalledExactlyOnceWith({ | ||
| 229 | environmentId: target.environmentId, | ||
| 230 | input: { threadId: target.threadId, reason: "user" }, | ||
| 231 | }); | ||
| 232 | }); | ||
| 233 | }); |
apps/web/src/keybindings.test.ts+27| ... | @@ -226,6 +226,33 @@ describe("settle thread shortcut", () => { | ... | @@ -226,6 +226,33 @@ describe("settle thread shortcut", () => { |
| 226 | }); | 226 | }); |
| 227 | }); | 227 | }); |
| 228 | 228 | ||
| 229 | describe("thread undo shortcut", () => { | ||
| 230 | it("resolves mod+z with nothing editable focused", () => { | ||
| 231 | assert.equal( | ||
| 232 | resolveShortcutCommand(event({ key: "z", metaKey: true }), DEFAULT_RESOLVED_KEYBINDINGS, { | ||
| 233 | platform: "MacIntel", | ||
| 234 | context: { terminalFocus: false, editableFocus: false }, | ||
| 235 | }), | ||
| 236 | "thread.undo", | ||
| 237 | ); | ||
| 238 | }); | ||
| 239 | |||
| 240 | it("leaves native undo alone inside text fields and terminals", () => { | ||
| 241 | assert.isNull( | ||
| 242 | resolveShortcutCommand(event({ key: "z", ctrlKey: true }), DEFAULT_RESOLVED_KEYBINDINGS, { | ||
| 243 | platform: "Win32", | ||
| 244 | context: { editableFocus: true }, | ||
| 245 | }), | ||
| 246 | ); | ||
| 247 | assert.isNull( | ||
| 248 | resolveShortcutCommand(event({ key: "z", ctrlKey: true }), DEFAULT_RESOLVED_KEYBINDINGS, { | ||
| 249 | platform: "Win32", | ||
| 250 | context: { terminalFocus: true }, | ||
| 251 | }), | ||
| 252 | ); | ||
| 253 | }); | ||
| 254 | }); | ||
| 255 | |||
| 229 | describe("copy thread reference shortcut", () => { | 256 | describe("copy thread reference shortcut", () => { |
| 230 | it("resolves Cmd+Shift+C on macOS and Ctrl+Shift+C elsewhere", () => { | 257 | it("resolves Cmd+Shift+C on macOS and Ctrl+Shift+C elsewhere", () => { |
| 231 | assert.equal( | 258 | assert.equal( |
apps/web/src/keybindings.ts+4| ... | @@ -36,6 +36,9 @@ export interface ShortcutMatchContext { | ... | @@ -36,6 +36,9 @@ export interface ShortcutMatchContext { |
| 36 | previewOpen: boolean; | 36 | previewOpen: boolean; |
| 37 | isWeb: boolean; | 37 | isWeb: boolean; |
| 38 | isDesktop: boolean; | 38 | isDesktop: boolean; |
| 39 | /** A text field, textarea, select or rich-text editor owns the keyboard. | ||
| 40 | Optional: only chords that collide with native editing consult it. */ | ||
| 41 | editableFocus?: boolean; | ||
| 39 | [key: string]: boolean; | 42 | [key: string]: boolean; |
| 40 | } | 43 | } |
| 41 | 44 | ||
| ... | @@ -149,6 +152,7 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc | ... | @@ -149,6 +152,7 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc |
| 149 | previewOpen: false, | 152 | previewOpen: false, |
| 150 | isWeb: !isElectron, | 153 | isWeb: !isElectron, |
| 151 | isDesktop: isElectron, | 154 | isDesktop: isElectron, |
| 155 | editableFocus: false, | ||
| 152 | ...options?.context, | 156 | ...options?.context, |
| 153 | }; | 157 | }; |
| 154 | } | 158 | } |
apps/web/src/lib/editableFocus.ts created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | const EDITABLE_SELECTOR = [ | ||
| 2 | "input", | ||
| 3 | "textarea", | ||
| 4 | "select", | ||
| 5 | '[contenteditable=""]', | ||
| 6 | '[contenteditable="true"]', | ||
| 7 | '[contenteditable="plaintext-only"]', | ||
| 8 | '[role="textbox"]', | ||
| 9 | ].join(","); | ||
| 10 | |||
| 11 | /** | ||
| 12 | * Whether a text-editing element owns the keyboard. Shortcuts that share | ||
| 13 | * their chord with native editing (mod+z) must yield when this is true. | ||
| 14 | */ | ||
| 15 | export function isEditableFocused(target: EventTarget | null = document.activeElement): boolean { | ||
| 16 | return target instanceof Element && target.closest(EDITABLE_SELECTOR) !== null; | ||
| 17 | } | ||
docs/user/keybindings.md+9-3| ... | @@ -84,9 +84,10 @@ Join modifiers and a key with `+`, such as `mod+shift+d` or `ctrl+l`. | ... | @@ -84,9 +84,10 @@ Join modifiers and a key with `+`, such as `mod+shift+d` or `ctrl+l`. |
| 84 | ## When conditions | 84 | ## When conditions |
| 85 | 85 | ||
| 86 | Available context keys are `terminalFocus`, `terminalOpen`, `previewFocus`, | 86 | Available context keys are `terminalFocus`, `terminalOpen`, `previewFocus`, |
| 87 | `previewOpen`, `modelPickerOpen`, `isWeb`, and `isDesktop`. `isWeb` is true in a | 87 | `previewOpen`, `modelPickerOpen`, `editableFocus`, `isWeb`, and `isDesktop`. |
| 88 | browser tab. `isDesktop` is true in the desktop app. Unknown keys evaluate to | 88 | `editableFocus` is true while a text field, the composer, or another editor has |
| 89 | `false`. | 89 | the keyboard. `isWeb` is true in a browser tab. `isDesktop` is true in the |
| 90 | desktop app. Unknown keys evaluate to `false`. | ||
| 90 | 91 | ||
| 91 | `mod+1` through `mod+9` jump to the first nine threads, and to models while the | 92 | `mod+1` through `mod+9` jump to the first nine threads, and to models while the |
| 92 | model picker is open. Those defaults use `isDesktop` so they do not steal the | 93 | model picker is open. Those defaults use `isDesktop` so they do not steal the |
| ... | @@ -110,6 +111,11 @@ a shortcut. | ... | @@ -110,6 +111,11 @@ a shortcut. |
| 110 | `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 |
| 111 | shortcut; assign one in **Settings → Keybindings**. | 112 | shortcut; assign one in **Settings → Keybindings**. |
| 112 | 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. | ||
| 118 | |||
| 113 | `chat.new` may ask you to choose a project when there is more than one. | 119 | `chat.new` may ask you to choose a project when there is more than one. |
| 114 | `chat.newLocal` skips that chooser. Both use your | 120 | `chat.newLocal` skips that chooser. Both use your |
| 115 | [new-thread defaults](./thread-sidebar.md#start-a-thread). | 121 | [new-thread defaults](./thread-sidebar.md#start-a-thread). |
docs/user/thread-sidebar.md+5-1| ... | @@ -29,7 +29,11 @@ and worktree while you stay in the new thread composer. This requires a Git proj | ... | @@ -29,7 +29,11 @@ and worktree while you stay in the new thread composer. This requires a Git proj |
| 29 | 29 | ||
| 30 | Pin a thread from its menu to keep it above your active work. | 30 | Pin a thread from its menu to keep it above your active work. |
| 31 | 31 | ||
| 32 | On web and desktop, unpinning offers Undo for five seconds to restore the thread's pinned position. | 32 | On web and desktop, unpinning, settling, snoozing, and archiving a thread each show |
| 33 | a notification with **Undo** for five seconds. Undo restores the thread's previous | ||
| 34 | state, including its pinned position, and reopens an archived thread you were | ||
| 35 | viewing. `mod+z` triggers the most recent Undo when no text field is focused; see | ||
| 36 | [Keybindings](./keybindings.md#commands-with-special-behavior). | ||
| 33 | 37 | ||
| 34 | On web and desktop, you can also drag files from your computer onto any thread row: | 38 | On web and desktop, you can also drag files from your computer onto any thread row: |
| 35 | the thread opens and the files are attached in its composer, ready for | 39 | the thread opens and the files are attached in its composer, ready for |
packages/contracts/src/keybindings.ts+1| ... | @@ -42,6 +42,7 @@ const THREAD_KEYBINDING_COMMANDS = [ | ... | @@ -42,6 +42,7 @@ const THREAD_KEYBINDING_COMMANDS = [ |
| 42 | "thread.copyReference", | 42 | "thread.copyReference", |
| 43 | "thread.settle", | 43 | "thread.settle", |
| 44 | "thread.pin", | 44 | "thread.pin", |
| 45 | "thread.undo", | ||
| 45 | ...THREAD_JUMP_KEYBINDING_COMMANDS, | 46 | ...THREAD_JUMP_KEYBINDING_COMMANDS, |
| 46 | ] as const; | 47 | ] as const; |
| 47 | export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; | 48 | export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; |
packages/shared/src/keybindings.ts+1| ... | @@ -62,6 +62,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ | ... | @@ -62,6 +62,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 62 | { key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" }, | 62 | { key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" }, |
| 63 | { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" }, | 63 | { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" }, |
| 64 | { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" }, | 64 | { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" }, |
| 65 | { key: "mod+z", command: "thread.undo", when: "!terminalFocus && !editableFocus" }, | ||
| 65 | ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ | 66 | ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ |
| 66 | key: `mod+${index + 1}`, | 67 | key: `mod+${index + 1}`, |
| 67 | command, | 68 | command, |