| 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 | 229 | } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 230 | 230 | import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; |
| 231 | 231 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 232 | import { isEditableFocused } from "../lib/editableFocus"; | |
| 233 | import { undoLatestThreadAction } from "../hooks/showUndoToast"; | |
| 232 | 234 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 233 | 235 | import { |
| 234 | 236 | AlarmClockIcon, |
| ... | ... | @@ -6647,11 +6649,12 @@ export default function ChatView(props: ChatViewProps) { |
| 6647 | 6649 | }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); |
| 6648 | 6650 | |
| 6649 | 6651 | const getShortcutContext = useCallback( |
| 6650 | () => ({ | |
| 6652 | (eventTarget: EventTarget | null = document.activeElement) => ({ | |
| 6651 | 6653 | terminalFocus: getTerminalFocusOwner() !== null, |
| 6652 | 6654 | terminalOpen: Boolean(terminalUiState.terminalOpen), |
| 6653 | 6655 | previewFocus: isPreviewFocused(), |
| 6654 | 6656 | previewOpen: previewPanelOpen, |
| 6657 | editableFocus: isEditableFocused(eventTarget), | |
| 6655 | 6658 | modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, |
| 6656 | 6659 | isWeb: !isElectron, |
| 6657 | 6660 | isDesktop: isElectron, |
| ... | ... | @@ -6679,7 +6682,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6679 | 6682 | if (event.defaultPrevented && terminalFocusOwner === null) { |
| 6680 | 6683 | return; |
| 6681 | 6684 | } |
| 6682 | const shortcutContext = getShortcutContext(); | |
| 6685 | const shortcutContext = getShortcutContext(event.target); | |
| 6683 | 6686 | |
| 6684 | 6687 | if ( |
| 6685 | 6688 | !shortcutContext.terminalFocus && |
| ... | ... | @@ -6728,6 +6731,17 @@ export default function ChatView(props: ChatViewProps) { |
| 6728 | 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 | 6745 | if (command === "thread.pin") { |
| 6732 | 6746 | event.preventDefault(); |
| 6733 | 6747 | event.stopPropagation(); |
apps/web/src/components/Sidebar.tsx+8-23| ... | ... | @@ -208,12 +208,7 @@ import { |
| 208 | 208 | type TerminalStatusIndicator, |
| 209 | 209 | useLinkedThreadPullRequest, |
| 210 | 210 | } from "./ThreadStatusIndicators"; |
| 211 | import { | |
| 212 | resolveSnoozePresets, | |
| 213 | snoozeWakeDescription, | |
| 214 | snoozeWakeLabel, | |
| 215 | type SnoozePreset, | |
| 216 | } from "./Sidebar.snooze"; | |
| 211 | import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze"; | |
| 217 | 212 | import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; |
| 218 | 213 | import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; |
| 219 | 214 | import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; |
| ... | ... | @@ -3082,7 +3077,9 @@ export default function Sidebar() { |
| 3082 | 3077 | settlingThreadKeysRef.current.add(threadKey); |
| 3083 | 3078 | try { |
| 3084 | 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 | 3083 | if (result._tag === "Failure") { |
| 3087 | 3084 | // Never navigate away from a thread that did not settle. |
| 3088 | 3085 | if (!isAtomCommandInterrupted(result)) { |
| ... | ... | @@ -3734,7 +3731,9 @@ export default function Sidebar() { |
| 3734 | 3731 | // Snoozing the open thread moves you forward, same as settle — |
| 3735 | 3732 | // both park the thread you're done with for now. |
| 3736 | 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 | 3737 | if (result._tag === "Failure") { |
| 3739 | 3738 | // Never navigate away from a thread that did not snooze. |
| 3740 | 3739 | return isAtomCommandInterrupted(result) |
| ... | ... | @@ -3780,23 +3779,9 @@ export default function Sidebar() { |
| 3780 | 3779 | ); |
| 3781 | 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 | 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 | 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 4 | |
| 5 | 5 | import { toastManager } from "../components/ui/toast"; |
| 6 | import { showUndoToast } from "./showUndoToast"; | |
| 6 | import { showUndoToast, undoLatestThreadAction } from "./showUndoToast"; | |
| 7 | 7 | import * as ThreadUndo from "./threadUndo"; |
| 8 | 8 | |
| 9 | 9 | afterEach(() => vi.restoreAllMocks()); |
| ... | ... | @@ -93,3 +93,35 @@ describe("showUndoToast", () => { |
| 93 | 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 | 7 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; |
| 8 | 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 | 24 | /** Shows a single-use Undo while its thread action still owns the claim. */ |
| 11 | 25 | export function showUndoToast({ |
| 12 | 26 | title, |
| ... | ... | @@ -23,6 +37,7 @@ export function showUndoToast({ |
| 23 | 37 | }) { |
| 24 | 38 | if (!claim.isCurrent()) return; |
| 25 | 39 | let undoStarted = false; |
| 40 | let toastId: string | undefined; | |
| 26 | 41 | const reportFailure = (error: unknown) => { |
| 27 | 42 | toastManager.add( |
| 28 | 43 | stackedThreadToast({ |
| ... | ... | @@ -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 | 70 | ...stackedThreadToast({ |
| 37 | 71 | type: "success", |
| 38 | 72 | title, |
| ... | ... | @@ -41,21 +75,13 @@ export function showUndoToast({ |
| 41 | 75 | actionProps: { |
| 42 | 76 | children: "Undo", |
| 43 | 77 | onClick: async () => { |
| 44 | if (undoStarted || !claim.isCurrent()) return; | |
| 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 | } | |
| 78 | await run(); | |
| 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 | 11 | import { useRouter } from "@tanstack/react-router"; |
| 12 | 12 | import { useCallback, useMemo } from "react"; |
| 13 | 13 | |
| 14 | import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze"; | |
| 14 | import { resolveSnoozePresets } from "../components/Sidebar.snooze"; | |
| 15 | 15 | import { |
| 16 | 16 | buildThreadActionMenuItems, |
| 17 | 17 | type ThreadActionMenuId, |
| ... | ... | @@ -162,29 +162,9 @@ export function useThreadActionMenu(input: { |
| 162 | 162 | : snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); |
| 163 | 163 | if (!preset) return; |
| 164 | 164 | const result = await snoozeThread(threadRef, preset.snoozedUntil); |
| 165 | if (result._tag === "Failure") { | |
| 166 | if (!isAtomCommandInterrupted(result)) { | |
| 167 | failureToast("Failed to snooze thread", squashAtomCommandFailure(result)); | |
| 168 | } | |
| 169 | return; | |
| 165 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 166 | failureToast("Failed to snooze thread", squashAtomCommandFailure(result)); | |
| 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 | 168 | return; |
| 189 | 169 | } |
| 190 | 170 | const reportFailure = async ( |
apps/web/src/hooks/useThreadActions.ts+168-68| ... | ... | @@ -15,6 +15,7 @@ import { useRouter } from "@tanstack/react-router"; |
| 15 | 15 | import { useCallback, useMemo, useRef } from "react"; |
| 16 | 16 | |
| 17 | 17 | import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; |
| 18 | import { snoozeWakeDescription } from "../components/Sidebar.snooze"; | |
| 18 | 19 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 19 | 20 | import { terminalEnvironment } from "../state/terminal"; |
| 20 | 21 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| ... | ... | @@ -222,6 +223,7 @@ export function useThreadActions() { |
| 222 | 223 | const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); |
| 223 | 224 | const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); |
| 224 | 225 | const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin); |
| 226 | const timestampFormat = useClientSettings((settings) => settings.timestampFormat); | |
| 225 | 227 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); |
| 226 | 228 | const clearProjectDraftThreadById = useComposerDraftStore( |
| 227 | 229 | (store) => store.clearProjectDraftThreadById, |
| ... | ... | @@ -252,6 +254,30 @@ export function useThreadActions() { |
| 252 | 254 | return resolveThreadRouteRef(currentRouteParams); |
| 253 | 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 | 281 | const archiveThread = useCallback( |
| 256 | 282 | async (target: ScopedThreadRef, opts: { onArchived?: () => void } = {}) => { |
| 257 | 283 | const resolved = resolveThreadTarget(target); |
| ... | ... | @@ -272,11 +298,13 @@ export function useThreadActions() { |
| 272 | 298 | const shouldNavigateToDraft = |
| 273 | 299 | currentRouteThreadRef?.threadId === threadRef.threadId && |
| 274 | 300 | currentRouteThreadRef.environmentId === threadRef.environmentId; |
| 301 | const action = ThreadUndo.begin("archive", scopedThreadKey(threadRef)); | |
| 275 | 302 | const archiveResult = await archiveThreadMutation({ |
| 276 | 303 | environmentId: threadRef.environmentId, |
| 277 | 304 | input: { threadId: threadRef.threadId }, |
| 278 | 305 | }); |
| 279 | 306 | if (archiveResult._tag === "Failure") { |
| 307 | action.finish(); | |
| 280 | 308 | return archiveResult; |
| 281 | 309 | } |
| 282 | 310 | const wokeAt = threadWokeAt(thread, { now: new Date().toISOString() }); |
| ... | ... | @@ -285,6 +313,14 @@ export function useThreadActions() { |
| 285 | 313 | } |
| 286 | 314 | refreshArchivedThreadsForEnvironment(threadRef.environmentId); |
| 287 | 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 | 325 | if (shouldNavigateToDraft) { |
| 290 | 326 | const navigationResult = await settlePromise(() => |
| ... | ... | @@ -298,21 +334,13 @@ export function useThreadActions() { |
| 298 | 334 | |
| 299 | 335 | return archiveResult; |
| 300 | 336 | }, |
| 301 | [archiveThreadMutation, getCurrentRouteThreadRef, markThreadVisited, resolveThreadTarget], | |
| 302 | ); | |
| 303 | ||
| 304 | const unarchiveThread = useCallback( | |
| 305 | async (target: ScopedThreadRef) => { | |
| 306 | const result = await unarchiveThreadMutation({ | |
| 307 | environmentId: target.environmentId, | |
| 308 | input: { threadId: target.threadId }, | |
| 309 | }); | |
| 310 | if (result._tag === "Success") { | |
| 311 | refreshArchivedThreadsForEnvironment(target.environmentId); | |
| 312 | } | |
| 313 | return result; | |
| 314 | }, | |
| 315 | [unarchiveThreadMutation], | |
| 337 | [ | |
| 338 | archiveThreadMutation, | |
| 339 | getCurrentRouteThreadRef, | |
| 340 | markThreadVisited, | |
| 341 | resolveThreadTarget, | |
| 342 | unarchiveThread, | |
| 343 | ], | |
| 316 | 344 | ); |
| 317 | 345 | |
| 318 | 346 | const deleteThread = useCallback( |
| ... | ... | @@ -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 | 539 | const unsettleThread = useCallback( |
| 544 | 540 | async (target: ScopedThreadRef) => { |
| 545 | 541 | if (!readEnvironmentSupportsSettlement(target.environmentId)) { |
| ... | ... | @@ -552,6 +548,7 @@ export function useThreadActions() { |
| 552 | 548 | ), |
| 553 | 549 | ); |
| 554 | 550 | } |
| 551 | ThreadUndo.invalidate("settle", scopedThreadKey(target)); | |
| 555 | 552 | // reason "user" pins the thread active: auto-settle (PR merged / |
| 556 | 553 | // inactivity) stays suppressed until real activity clears the pin. |
| 557 | 554 | return unsettleThreadMutation({ |
| ... | ... | @@ -631,6 +628,89 @@ export function useThreadActions() { |
| 631 | 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 | 714 | const confirmAndUnpinThread = useCallback( |
| 635 | 715 | async (target: ScopedThreadRef) => { |
| 636 | 716 | const localApi = readLocalApi(); |
| ... | ... | @@ -695,8 +775,34 @@ export function useThreadActions() { |
| 695 | 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 | 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 | 806 | // Version skew: never send the command to a server that predates it. |
| 701 | 807 | if (!readEnvironmentSupportsSnooze(target.environmentId)) { |
| 702 | 808 | return AsyncResult.failure( |
| ... | ... | @@ -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 | 833 | environmentId: target.environmentId, |
| 727 | 834 | input: { threadId: target.threadId, snoozedUntil }, |
| 728 | 835 | }); |
| 729 | }, | |
| 730 | [resolveThreadTarget, snoozeThreadMutation], | |
| 731 | ); | |
| 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 | ); | |
| 836 | if (result._tag !== "Success" || opts.undoToast === false) { | |
| 837 | action.finish(); | |
| 838 | return result; | |
| 744 | 839 | } |
| 745 | return unsnoozeThreadMutation({ | |
| 746 | environmentId: target.environmentId, | |
| 747 | input: { threadId: target.threadId, reason: "user" }, | |
| 840 | // Snooze hides the row, so the toast is the only confirmation. | |
| 841 | showUndoToast({ | |
| 842 | title: `Snoozed until ${snoozeWakeDescription(snoozedUntil, new Date(), timestampFormat)}`, | |
| 843 | description: resolved?.thread.title, | |
| 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 | 853 | const confirmAndDeleteThread = useCallback( |
apps/web/src/hooks/useThreadActions.undo.test.ts+188-24| ... | ... | @@ -1,69 +1,233 @@ |
| 1 | 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 | 4 | import { useThreadActions } from "./useThreadActions"; |
| 5 | 5 | import { threadEnvironment } from "../state/threads"; |
| 6 | 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 | 22 | vi.mock("react", async (original) => ({ |
| 10 | 23 | ...(await original<typeof import("react")>()), |
| 11 | 24 | useCallback: (callback: unknown) => callback, |
| 12 | 25 | useMemo: (create: () => unknown) => create(), |
| 13 | 26 | useRef: (value: unknown) => ({ current: value }), |
| 14 | 27 | })); |
| 15 | vi.mock("@tanstack/react-router", () => ({ useRouter: () => ({}) })); | |
| 28 | vi.mock("@tanstack/react-router", () => ({ useRouter: () => router })); | |
| 16 | 29 | vi.mock("./useSettings", () => ({ useClientSettings: () => false })); |
| 17 | 30 | vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() })); |
| 18 | 31 | vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() })); |
| 19 | 32 | vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() })); |
| 20 | 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 | 44 | vi.mock("../state/entities", async (original) => ({ |
| 22 | 45 | ...(await original<typeof import("../state/entities")>()), |
| 23 | 46 | readEnvironmentSupportsPinning: () => true, |
| 24 | 47 | readEnvironmentSupportsPinReorder: () => true, |
| 25 | readThreadShell: () => ({ title: "Thread", pinOrderKey: "a0" }), | |
| 48 | readEnvironmentSupportsSettlement: () => true, | |
| 49 | readEnvironmentSupportsSnooze: () => true, | |
| 50 | readThreadShell: () => threadShell, | |
| 26 | 51 | })); |
| 27 | 52 | vi.mock("../state/use-atom-command", () => ({ |
| 28 | useAtomCommand: (command: unknown) => | |
| 29 | command === threadEnvironment.pin | |
| 30 | ? commands.pin | |
| 31 | : command === threadEnvironment.unpin | |
| 32 | ? commands.unpin | |
| 33 | : vi.fn(), | |
| 53 | useAtomCommand: (command: unknown) => { | |
| 54 | switch (command) { | |
| 55 | case threadEnvironment.pin: | |
| 56 | return commands.pin; | |
| 57 | case threadEnvironment.unpin: | |
| 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 | 101 | afterEach(() => vi.restoreAllMocks()); |
| 37 | 102 | |
| 38 | 103 | describe("unpin Undo", () => { |
| 39 | 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 | 105 | const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); |
| 43 | 106 | vi.spyOn(toastManager, "close").mockImplementation(() => {}); |
| 44 | 107 | const sidebar = useThreadActions(); |
| 45 | 108 | const header = useThreadActions(); |
| 46 | const target = { | |
| 47 | environmentId: EnvironmentId.make("undo-env"), | |
| 48 | threadId: ThreadId.make("thread"), | |
| 49 | }; | |
| 50 | 109 | await sidebar.unpinThread(target); |
| 51 | const staleUndo = add.mock.calls[0]?.[0].actionProps?.onClick; | |
| 110 | const staleUndo = undoOf(add, 0); | |
| 52 | 111 | await header.pinThread(target, { orderKey: "a1" }); |
| 53 | 112 | await header.unpinThread(target); |
| 54 | const latestUndo = add.mock.calls[1]?.[0].actionProps?.onClick; | |
| 55 | expect(staleUndo).toBeTypeOf("function"); | |
| 56 | expect(latestUndo).toBeTypeOf("function"); | |
| 57 | const event = {} as Parameters<NonNullable<typeof staleUndo>>[0]; | |
| 58 | staleUndo?.(event); | |
| 113 | const latestUndo = undoOf(add, 1); | |
| 114 | await staleUndo(); | |
| 59 | 115 | expect(commands.pin).toHaveBeenCalledTimes(1); |
| 60 | latestUndo?.(event); | |
| 116 | await latestUndo(); | |
| 61 | 117 | expect(commands.pin).toHaveBeenCalledTimes(2); |
| 62 | 118 | expect(commands.pin).toHaveBeenLastCalledWith({ |
| 63 | 119 | environmentId: target.environmentId, |
| 64 | 120 | input: { threadId: target.threadId, orderKey: "a0" }, |
| 65 | 121 | }); |
| 66 | latestUndo?.(event); | |
| 122 | await latestUndo(); | |
| 67 | 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 | 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 | 256 | describe("copy thread reference shortcut", () => { |
| 230 | 257 | it("resolves Cmd+Shift+C on macOS and Ctrl+Shift+C elsewhere", () => { |
| 231 | 258 | assert.equal( |
apps/web/src/keybindings.ts+4| ... | ... | @@ -36,6 +36,9 @@ export interface ShortcutMatchContext { |
| 36 | 36 | previewOpen: boolean; |
| 37 | 37 | isWeb: boolean; |
| 38 | 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 | 42 | [key: string]: boolean; |
| 40 | 43 | } |
| 41 | 44 | |
| ... | ... | @@ -149,6 +152,7 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc |
| 149 | 152 | previewOpen: false, |
| 150 | 153 | isWeb: !isElectron, |
| 151 | 154 | isDesktop: isElectron, |
| 155 | editableFocus: false, | |
| 152 | 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 | 84 | ## When conditions |
| 85 | 85 | |
| 86 | 86 | Available context keys are `terminalFocus`, `terminalOpen`, `previewFocus`, |
| 87 | `previewOpen`, `modelPickerOpen`, `isWeb`, and `isDesktop`. `isWeb` is true in a | |
| 88 | browser tab. `isDesktop` is true in the desktop app. Unknown keys evaluate to | |
| 89 | `false`. | |
| 87 | `previewOpen`, `modelPickerOpen`, `editableFocus`, `isWeb`, and `isDesktop`. | |
| 88 | `editableFocus` is true while a text field, the composer, or another editor has | |
| 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 | 92 | `mod+1` through `mod+9` jump to the first nine threads, and to models while the |
| 92 | 93 | model picker is open. Those defaults use `isDesktop` so they do not steal the |
| ... | ... | @@ -110,6 +111,11 @@ a shortcut. |
| 110 | 111 | `thread.stop` interrupts the running turn in the focused thread. It has no default |
| 111 | 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 | 119 | `chat.new` may ask you to choose a project when there is more than one. |
| 114 | 120 | `chat.newLocal` skips that chooser. Both use your |
| 115 | 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 | 29 | |
| 30 | 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 | 38 | On web and desktop, you can also drag files from your computer onto any thread row: |
| 35 | 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 | 42 | "thread.copyReference", |
| 43 | 43 | "thread.settle", |
| 44 | 44 | "thread.pin", |
| 45 | "thread.undo", | |
| 45 | 46 | ...THREAD_JUMP_KEYBINDING_COMMANDS, |
| 46 | 47 | ] as const; |
| 47 | 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 | 62 | { key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" }, |
| 63 | 63 | { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" }, |
| 64 | 64 | { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" }, |
| 65 | { key: "mod+z", command: "thread.undo", when: "!terminalFocus && !editableFocus" }, | |
| 65 | 66 | ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ |
| 66 | 67 | key: `mod+${index + 1}`, |
| 67 | 68 | command, |