| author | |
| committer | |
| log | b2ee17d7c1b1bdff543ffff43929d01b92a902f4 |
| tree | 70de0bc92c9290b8b92282f0e050c5428ea45010 |
| parent | 7963cc70f3c9873768a5eaad7ca0a20a23d67aed |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>8 files changed, 692 insertions(+), 70 deletions(-)
apps/web/src/components/ChatView.tsx+2| ... | ... | @@ -5979,6 +5979,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 5979 | 5979 | activeThreadId={activeThread.id} |
| 5980 | 5980 | {...(routeKind === "draft" && draftId ? { draftId } : {})} |
| 5981 | 5981 | activeThreadTitle={activeThread.title} |
| 5982 | isServerThread={isServerThread} | |
| 5983 | changeRequestState={activeThreadPr?.state ?? null} | |
| 5982 | 5984 | activeProjectName={activeProject?.title} |
| 5983 | 5985 | activeProjectCwd={activeProject?.workspaceRoot ?? null} |
| 5984 | 5986 | openInCwd={gitCwd} |
apps/web/src/components/SidebarV2.tsx+16-56| ... | ... | @@ -108,6 +108,7 @@ import { |
| 108 | 108 | import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; |
| 109 | 109 | import type { SidebarThreadSummary } from "../types"; |
| 110 | 110 | import { cn } from "~/lib/utils"; |
| 111 | import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; | |
| 111 | 112 | import { |
| 112 | 113 | buildBulkTitleRegenerationContextMenuItem, |
| 113 | 114 | formatWorkingDurationLabel, |
| ... | ... | @@ -2539,62 +2540,21 @@ export default function SidebarV2() { |
| 2539 | 2540 | const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); |
| 2540 | 2541 | const clicked = await settlePromise(() => |
| 2541 | 2542 | api.contextMenu.show( |
| 2542 | [ | |
| 2543 | ...(thread.branch | |
| 2544 | ? [ | |
| 2545 | { | |
| 2546 | id: "new-thread-on-branch", | |
| 2547 | label: `New thread on ${thread.branch}`, | |
| 2548 | }, | |
| 2549 | ] | |
| 2550 | : []), | |
| 2551 | ...(supportsPinning | |
| 2552 | ? [ | |
| 2553 | isPinned | |
| 2554 | ? { id: "unpin", label: "Unpin thread" } | |
| 2555 | : { id: "pin", label: "Pin thread" }, | |
| 2556 | ] | |
| 2557 | : []), | |
| 2558 | // Both lifecycle actions stay available on pinned threads: | |
| 2559 | // settling clears the pin ("done" beats "keep on top"), and | |
| 2560 | // snoozing hides the card until wake with the pin intact. | |
| 2561 | ...(supportsSettlement | |
| 2562 | ? [ | |
| 2563 | isSettled | |
| 2564 | ? { id: "unsettle", label: "Un-settle thread" } | |
| 2565 | : { id: "settle", label: "Settle thread" }, | |
| 2566 | ] | |
| 2567 | : []), | |
| 2568 | ...(supportsSnooze | |
| 2569 | ? [ | |
| 2570 | isSnoozed | |
| 2571 | ? { id: "unsnooze", label: "Wake thread" } | |
| 2572 | : { | |
| 2573 | id: "snooze", | |
| 2574 | label: "Snooze", | |
| 2575 | disabled: !canSnooze(thread, { now: new Date().toISOString() }), | |
| 2576 | children: snoozePresets.map((preset) => ({ | |
| 2577 | id: `snooze:${preset.id}`, | |
| 2578 | label: `${preset.label} (${preset.whenLabel})`, | |
| 2579 | })), | |
| 2580 | }, | |
| 2581 | ] | |
| 2582 | : []), | |
| 2583 | { id: "rename", label: "Rename thread" }, | |
| 2584 | ...(supportsTitleRegeneration | |
| 2585 | ? [ | |
| 2586 | { | |
| 2587 | id: "regenerate-title", | |
| 2588 | label: isRegeneratingTitle ? "Regenerating…" : "Regenerate title", | |
| 2589 | disabled: isRegeneratingTitle, | |
| 2590 | }, | |
| 2591 | ] | |
| 2592 | : []), | |
| 2593 | { id: "mark-unread", label: "Mark unread" }, | |
| 2594 | { id: "copy-path", label: "Copy path", icon: "copy" }, | |
| 2595 | ...(thread.branch ? [{ id: "copy-branch", label: "Copy branch", icon: "copy" }] : []), | |
| 2596 | { id: "delete", label: "Delete", destructive: true, icon: "trash" }, | |
| 2597 | ], | |
| 2543 | buildThreadActionMenuItems({ | |
| 2544 | branch: thread.branch ?? null, | |
| 2545 | isPinned, | |
| 2546 | isSettled, | |
| 2547 | isSnoozed, | |
| 2548 | canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), | |
| 2549 | isRegeneratingTitle, | |
| 2550 | supports: { | |
| 2551 | settlement: supportsSettlement, | |
| 2552 | snooze: supportsSnooze, | |
| 2553 | pinning: supportsPinning, | |
| 2554 | titleRegeneration: supportsTitleRegeneration, | |
| 2555 | }, | |
| 2556 | snoozePresets, | |
| 2557 | }), | |
| 2598 | 2558 | position, |
| 2599 | 2559 | ), |
| 2600 | 2560 | ); |
apps/web/src/components/chat/ChatHeader.test.ts+22-1| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | import { shouldShowOpenInPicker } from "./ChatHeader"; | |
| 4 | import { resolveRenameCommit, shouldShowOpenInPicker } from "./ChatHeader"; | |
| 5 | 5 | |
| 6 | 6 | describe("shouldShowOpenInPicker", () => { |
| 7 | 7 | const primaryEnvironmentId = EnvironmentId.make("environment-primary"); |
| ... | ... | @@ -46,3 +46,24 @@ describe("shouldShowOpenInPicker", () => { |
| 46 | 46 | ).toBe(false); |
| 47 | 47 | }); |
| 48 | 48 | }); |
| 49 | ||
| 50 | describe("resolveRenameCommit", () => { | |
| 51 | it("commits a trimmed changed title", () => { | |
| 52 | expect(resolveRenameCommit({ title: " New title ", originalTitle: "Old" })).toEqual({ | |
| 53 | action: "commit", | |
| 54 | title: "New title", | |
| 55 | }); | |
| 56 | }); | |
| 57 | ||
| 58 | it("rejects empty and whitespace-only titles", () => { | |
| 59 | expect(resolveRenameCommit({ title: " ", originalTitle: "Old" })).toEqual({ | |
| 60 | action: "reject-empty", | |
| 61 | }); | |
| 62 | }); | |
| 63 | ||
| 64 | it("no-ops when the trimmed title is unchanged", () => { | |
| 65 | expect(resolveRenameCommit({ title: " Old ", originalTitle: "Old" })).toEqual({ | |
| 66 | action: "noop", | |
| 67 | }); | |
| 68 | }); | |
| 69 | }); |
apps/web/src/components/chat/ChatHeader.tsx+173-13| ... | ... | @@ -6,10 +6,25 @@ import { |
| 6 | 6 | type ThreadId, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | 8 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 9 | import { memo } from "react"; | |
| 9 | import { | |
| 10 | isAtomCommandInterrupted, | |
| 11 | squashAtomCommandFailure, | |
| 12 | } from "@t3tools/client-runtime/state/runtime"; | |
| 13 | import type { ChangeRequestStateLike } from "@t3tools/client-runtime/state/thread-settled"; | |
| 14 | import { ChevronDownIcon } from "lucide-react"; | |
| 15 | import { | |
| 16 | memo, | |
| 17 | useCallback, | |
| 18 | useMemo, | |
| 19 | useRef, | |
| 20 | useState, | |
| 21 | type KeyboardEvent as ReactKeyboardEvent, | |
| 22 | type MouseEvent as ReactMouseEvent, | |
| 23 | } from "react"; | |
| 10 | 24 | import GitActionsControl from "../GitActionsControl"; |
| 11 | 25 | import { type DraftId } from "~/composerDraftStore"; |
| 12 | 26 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 27 | import { toastManager } from "../ui/toast"; | |
| 13 | 28 | import ProjectScriptsControl, { |
| 14 | 29 | type NewProjectScriptInput, |
| 15 | 30 | type ProjectScriptActionResult, |
| ... | ... | @@ -17,6 +32,9 @@ import ProjectScriptsControl, { |
| 17 | 32 | import { OpenInPicker } from "./OpenInPicker"; |
| 18 | 33 | import { usePrimaryEnvironmentId } from "../../state/environments"; |
| 19 | 34 | import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts"; |
| 35 | import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; | |
| 36 | import { threadEnvironment } from "../../state/threads"; | |
| 37 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 20 | 38 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 21 | 39 | import { cn } from "~/lib/utils"; |
| 22 | 40 | |
| ... | ... | @@ -25,6 +43,10 @@ interface ChatHeaderProps { |
| 25 | 43 | activeThreadId: ThreadId; |
| 26 | 44 | draftId?: DraftId; |
| 27 | 45 | activeThreadTitle: string; |
| 46 | /** Drafts have no server thread yet, so the title carries no action menu. */ | |
| 47 | isServerThread: boolean; | |
| 48 | /** PR state feeding the settled classification, resolved by ChatView. */ | |
| 49 | changeRequestState: ChangeRequestStateLike | null; | |
| 28 | 50 | activeProjectName: string | undefined; |
| 29 | 51 | activeProjectCwd: string | null; |
| 30 | 52 | openInCwd: string | null; |
| ... | ... | @@ -44,6 +66,20 @@ interface ChatHeaderProps { |
| 44 | 66 | onDeleteProjectScript: (scriptId: string) => Promise<ProjectScriptActionResult>; |
| 45 | 67 | } |
| 46 | 68 | |
| 69 | /** | |
| 70 | * Rename commit rule shared with the sidebar's inline rename: trim, reject | |
| 71 | * empty (the caller toasts), and skip the mutation when nothing changed. | |
| 72 | */ | |
| 73 | export function resolveRenameCommit(input: { | |
| 74 | readonly title: string; | |
| 75 | readonly originalTitle: string; | |
| 76 | }): { action: "commit"; title: string } | { action: "reject-empty" } | { action: "noop" } { | |
| 77 | const trimmed = input.title.trim(); | |
| 78 | if (trimmed.length === 0) return { action: "reject-empty" }; | |
| 79 | if (trimmed === input.originalTitle) return { action: "noop" }; | |
| 80 | return { action: "commit", title: trimmed }; | |
| 81 | } | |
| 82 | ||
| 47 | 83 | export function shouldShowOpenInPicker(input: { |
| 48 | 84 | readonly activeProjectName: string | undefined; |
| 49 | 85 | readonly activeThreadEnvironmentId: EnvironmentId; |
| ... | ... | @@ -61,6 +97,8 @@ export const ChatHeader = memo(function ChatHeader({ |
| 61 | 97 | activeThreadId, |
| 62 | 98 | draftId, |
| 63 | 99 | activeThreadTitle, |
| 100 | isServerThread, | |
| 101 | changeRequestState, | |
| 64 | 102 | activeProjectName, |
| 65 | 103 | activeProjectCwd, |
| 66 | 104 | openInCwd, |
| ... | ... | @@ -86,8 +124,91 @@ export const ChatHeader = memo(function ChatHeader({ |
| 86 | 124 | activeThreadEnvironmentId, |
| 87 | 125 | primaryEnvironmentId, |
| 88 | 126 | }); |
| 127 | const activeThreadRef = useMemo( | |
| 128 | () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), | |
| 129 | [activeThreadEnvironmentId, activeThreadId], | |
| 130 | ); | |
| 131 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { | |
| 132 | reportFailure: false, | |
| 133 | }); | |
| 134 | // Inline rename, keyed by thread: navigating away drops an in-progress | |
| 135 | // rename instead of committing stale text. Cleared on thread change (not | |
| 136 | // just hidden) so returning to the thread doesn't revive the old draft. | |
| 137 | const [renaming, setRenaming] = useState<{ threadId: ThreadId; title: string } | null>(null); | |
| 138 | if (renaming !== null && renaming.threadId !== activeThreadId) { | |
| 139 | setRenaming(null); | |
| 140 | } | |
| 141 | const renamingTitle = renaming?.threadId === activeThreadId ? renaming.title : null; | |
| 142 | const renameCommittedRef = useRef(false); | |
| 143 | const startRename = useCallback(() => { | |
| 144 | renameCommittedRef.current = false; | |
| 145 | setRenaming({ threadId: activeThreadId, title: activeThreadTitle }); | |
| 146 | }, [activeThreadId, activeThreadTitle]); | |
| 147 | const commitRename = useCallback( | |
| 148 | (title: string) => { | |
| 149 | setRenaming(null); | |
| 150 | const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle }); | |
| 151 | if (resolution.action === "reject-empty") { | |
| 152 | toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); | |
| 153 | return; | |
| 154 | } | |
| 155 | if (resolution.action === "noop") return; | |
| 156 | void updateThreadMetadata({ | |
| 157 | environmentId: activeThreadEnvironmentId, | |
| 158 | input: { threadId: activeThreadId, title: resolution.title }, | |
| 159 | }).then((result) => { | |
| 160 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 161 | const error = squashAtomCommandFailure(result); | |
| 162 | toastManager.add({ | |
| 163 | type: "error", | |
| 164 | title: "Failed to rename thread", | |
| 165 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 166 | }); | |
| 167 | } | |
| 168 | }); | |
| 169 | }, | |
| 170 | [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata], | |
| 171 | ); | |
| 172 | const { openMenu } = useThreadActionMenu({ | |
| 173 | threadRef: isServerThread ? activeThreadRef : null, | |
| 174 | projectCwd: activeProjectCwd, | |
| 175 | changeRequestState, | |
| 176 | onStartRename: startRename, | |
| 177 | }); | |
| 178 | const titleButtonRef = useRef<HTMLButtonElement | null>(null); | |
| 179 | const openMenuFromTitle = useCallback(() => { | |
| 180 | const rect = titleButtonRef.current?.getBoundingClientRect(); | |
| 181 | if (!rect) return; | |
| 182 | openMenu({ x: rect.left, y: rect.bottom + 4 }); | |
| 183 | }, [openMenu]); | |
| 184 | const handleHeaderContextMenu = useCallback( | |
| 185 | (event: ReactMouseEvent) => { | |
| 186 | if (!isServerThread || renamingTitle !== null) return; | |
| 187 | // The right-side controls (git, scripts, open-in) keep their own | |
| 188 | // behavior; only the breadcrumb area opens the thread menu. | |
| 189 | if ((event.target as HTMLElement).closest("[data-chat-header-actions]")) return; | |
| 190 | event.preventDefault(); | |
| 191 | openMenu({ x: event.clientX, y: event.clientY }); | |
| 192 | }, | |
| 193 | [isServerThread, openMenu, renamingTitle], | |
| 194 | ); | |
| 195 | const handleRenameKeyDown = useCallback( | |
| 196 | (event: ReactKeyboardEvent<HTMLInputElement>) => { | |
| 197 | if (event.key === "Enter") { | |
| 198 | renameCommittedRef.current = true; | |
| 199 | commitRename(event.currentTarget.value); | |
| 200 | } else if (event.key === "Escape") { | |
| 201 | renameCommittedRef.current = true; | |
| 202 | setRenaming(null); | |
| 203 | } | |
| 204 | }, | |
| 205 | [commitRename], | |
| 206 | ); | |
| 89 | 207 | return ( |
| 90 | <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3"> | |
| 208 | <div | |
| 209 | className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3" | |
| 210 | onContextMenu={handleHeaderContextMenu} | |
| 211 | > | |
| 91 | 212 | <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3"> |
| 92 | 213 | {/* The project always leads the header: knowing which project a |
| 93 | 214 | thread lives in is priority zero, and the thread title alone |
| ... | ... | @@ -119,19 +240,58 @@ export const ChatHeader = memo(function ChatHeader({ |
| 119 | 240 | </span> |
| 120 | 241 | </span> |
| 121 | 242 | ) : null} |
| 122 | <Tooltip> | |
| 123 | <TooltipTrigger | |
| 124 | render={ | |
| 125 | <h2 | |
| 126 | aria-label={activeThreadTitle} | |
| 127 | className="min-w-0 flex-1 truncate text-sm font-medium text-foreground" | |
| 128 | > | |
| 243 | {renamingTitle !== null ? ( | |
| 244 | <input | |
| 245 | autoFocus | |
| 246 | aria-label="Thread title" | |
| 247 | className="min-w-0 flex-1 rounded-sm bg-transparent text-sm font-medium text-foreground outline-none ring-1 ring-ring/50 focus:ring-ring" | |
| 248 | defaultValue={renamingTitle} | |
| 249 | onBlur={(event) => { | |
| 250 | if (renameCommittedRef.current) return; | |
| 251 | commitRename(event.currentTarget.value); | |
| 252 | }} | |
| 253 | onFocus={(event) => event.currentTarget.select()} | |
| 254 | onKeyDown={handleRenameKeyDown} | |
| 255 | /> | |
| 256 | ) : isServerThread ? ( | |
| 257 | <Tooltip> | |
| 258 | <TooltipTrigger | |
| 259 | render={ | |
| 260 | <button | |
| 261 | ref={titleButtonRef} | |
| 262 | type="button" | |
| 263 | aria-label={`Thread actions for ${activeThreadTitle}`} | |
| 264 | aria-haspopup="menu" | |
| 265 | onClick={openMenuFromTitle} | |
| 266 | className="group/thread-title inline-flex min-w-0 flex-1 cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" | |
| 267 | /> | |
| 268 | } | |
| 269 | > | |
| 270 | <h2 className="min-w-0 truncate text-sm font-medium text-foreground"> | |
| 129 | 271 | {activeThreadTitle} |
| 130 | 272 | </h2> |
| 131 | } | |
| 132 | /> | |
| 133 | <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup> | |
| 134 | </Tooltip> | |
| 273 | <ChevronDownIcon | |
| 274 | aria-hidden | |
| 275 | className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/thread-title:opacity-100 group-focus-visible/thread-title:opacity-100" | |
| 276 | /> | |
| 277 | </TooltipTrigger> | |
| 278 | <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup> | |
| 279 | </Tooltip> | |
| 280 | ) : ( | |
| 281 | <Tooltip> | |
| 282 | <TooltipTrigger | |
| 283 | render={ | |
| 284 | <h2 | |
| 285 | aria-label={activeThreadTitle} | |
| 286 | className="min-w-0 flex-1 truncate text-sm font-medium text-foreground" | |
| 287 | > | |
| 288 | {activeThreadTitle} | |
| 289 | </h2> | |
| 290 | } | |
| 291 | /> | |
| 292 | <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup> | |
| 293 | </Tooltip> | |
| 294 | )} | |
| 135 | 295 | </div> |
| 136 | 296 | <div |
| 137 | 297 | data-chat-header-actions |
apps/web/src/components/threadActionMenu.logic.test.ts created+66| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./threadActionMenu.logic"; | |
| 4 | ||
| 5 | const baseState: ThreadActionMenuState = { | |
| 6 | branch: null, | |
| 7 | isPinned: false, | |
| 8 | isSettled: false, | |
| 9 | isSnoozed: false, | |
| 10 | canSnoozeNow: true, | |
| 11 | isRegeneratingTitle: false, | |
| 12 | supports: { settlement: true, snooze: true, pinning: true, titleRegeneration: true }, | |
| 13 | snoozePresets: [ | |
| 14 | { id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" }, | |
| 15 | ], | |
| 16 | }; | |
| 17 | ||
| 18 | function ids(state: ThreadActionMenuState): string[] { | |
| 19 | return buildThreadActionMenuItems(state).map((item) => item.id); | |
| 20 | } | |
| 21 | ||
| 22 | describe("buildThreadActionMenuItems", () => { | |
| 23 | it("hides lifecycle items when the environment lacks the capabilities", () => { | |
| 24 | expect( | |
| 25 | ids({ | |
| 26 | ...baseState, | |
| 27 | supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, | |
| 28 | }), | |
| 29 | ).toEqual(["rename", "mark-unread", "copy-path", "delete"]); | |
| 30 | }); | |
| 31 | ||
| 32 | it("includes branch items only for threads with a branch", () => { | |
| 33 | const withBranch = ids({ ...baseState, branch: "feat/menu" }); | |
| 34 | expect(withBranch).toContain("new-thread-on-branch"); | |
| 35 | expect(withBranch).toContain("copy-branch"); | |
| 36 | expect(ids(baseState)).not.toContain("new-thread-on-branch"); | |
| 37 | expect(ids(baseState)).not.toContain("copy-branch"); | |
| 38 | }); | |
| 39 | ||
| 40 | it("flips lifecycle labels with thread state", () => { | |
| 41 | expect(ids({ ...baseState, isPinned: true, isSettled: true, isSnoozed: true })).toEqual( | |
| 42 | expect.arrayContaining(["unpin", "unsettle", "unsnooze"]), | |
| 43 | ); | |
| 44 | expect(ids(baseState)).toEqual(expect.arrayContaining(["pin", "settle", "snooze"])); | |
| 45 | }); | |
| 46 | ||
| 47 | it("disables snooze when the thread cannot snooze, keeping presets visible", () => { | |
| 48 | const snooze = buildThreadActionMenuItems({ ...baseState, canSnoozeNow: false }).find( | |
| 49 | (item) => item.id === "snooze", | |
| 50 | ); | |
| 51 | expect(snooze?.disabled).toBe(true); | |
| 52 | expect(snooze?.children?.map((child) => child.id)).toEqual(["snooze:hour"]); | |
| 53 | }); | |
| 54 | ||
| 55 | it("disables title regeneration while one is in flight", () => { | |
| 56 | const item = buildThreadActionMenuItems({ ...baseState, isRegeneratingTitle: true }).find( | |
| 57 | (candidate) => candidate.id === "regenerate-title", | |
| 58 | ); | |
| 59 | expect(item).toMatchObject({ label: "Regenerating…", disabled: true }); | |
| 60 | }); | |
| 61 | ||
| 62 | it("marks delete as destructive and keeps it last", () => { | |
| 63 | const items = buildThreadActionMenuItems({ ...baseState, branch: "main" }); | |
| 64 | expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true }); | |
| 65 | }); | |
| 66 | }); |
apps/web/src/components/threadActionMenu.logic.ts created+105| ... | ... | @@ -0,0 +1,105 @@ |
| 1 | import type { ContextMenuItem } from "@t3tools/contracts"; | |
| 2 | import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; | |
| 3 | ||
| 4 | /** | |
| 5 | * Ids for the per-thread action menu. Snooze presets are dispatched as | |
| 6 | * `snooze:<presetId>` so the union stays closed while the preset list | |
| 7 | * remains data-driven. | |
| 8 | */ | |
| 9 | export type ThreadActionMenuId = | |
| 10 | | "new-thread-on-branch" | |
| 11 | | "pin" | |
| 12 | | "unpin" | |
| 13 | | "settle" | |
| 14 | | "unsettle" | |
| 15 | | "snooze" | |
| 16 | | `snooze:${string}` | |
| 17 | | "unsnooze" | |
| 18 | | "rename" | |
| 19 | | "regenerate-title" | |
| 20 | | "mark-unread" | |
| 21 | | "copy-path" | |
| 22 | | "copy-branch" | |
| 23 | | "delete"; | |
| 24 | ||
| 25 | export interface ThreadActionMenuState { | |
| 26 | readonly branch: string | null; | |
| 27 | readonly isPinned: boolean; | |
| 28 | readonly isSettled: boolean; | |
| 29 | readonly isSnoozed: boolean; | |
| 30 | readonly canSnoozeNow: boolean; | |
| 31 | readonly isRegeneratingTitle: boolean; | |
| 32 | readonly supports: { | |
| 33 | readonly settlement: boolean; | |
| 34 | readonly snooze: boolean; | |
| 35 | readonly pinning: boolean; | |
| 36 | readonly titleRegeneration: boolean; | |
| 37 | }; | |
| 38 | readonly snoozePresets: ReadonlyArray<SnoozePreset>; | |
| 39 | } | |
| 40 | ||
| 41 | /** | |
| 42 | * Single source for the per-thread action menu: the sidebar row's right-click | |
| 43 | * menu and the chat header menu both render exactly this list, so labels, | |
| 44 | * ordering, and capability gating cannot drift between the two surfaces. | |
| 45 | */ | |
| 46 | export function buildThreadActionMenuItems( | |
| 47 | state: ThreadActionMenuState, | |
| 48 | ): ReadonlyArray<ContextMenuItem<ThreadActionMenuId>> { | |
| 49 | return [ | |
| 50 | ...(state.branch | |
| 51 | ? [ | |
| 52 | { | |
| 53 | id: "new-thread-on-branch" as const, | |
| 54 | label: `New thread on ${state.branch}`, | |
| 55 | }, | |
| 56 | ] | |
| 57 | : []), | |
| 58 | ...(state.supports.pinning | |
| 59 | ? [ | |
| 60 | state.isPinned | |
| 61 | ? { id: "unpin" as const, label: "Unpin thread" } | |
| 62 | : { id: "pin" as const, label: "Pin thread" }, | |
| 63 | ] | |
| 64 | : []), | |
| 65 | // Both lifecycle actions stay available on pinned threads: settling | |
| 66 | // clears the pin ("done" beats "keep on top"), and snoozing hides the | |
| 67 | // card until wake with the pin intact. | |
| 68 | ...(state.supports.settlement | |
| 69 | ? [ | |
| 70 | state.isSettled | |
| 71 | ? { id: "unsettle" as const, label: "Un-settle thread" } | |
| 72 | : { id: "settle" as const, label: "Settle thread" }, | |
| 73 | ] | |
| 74 | : []), | |
| 75 | ...(state.supports.snooze | |
| 76 | ? [ | |
| 77 | state.isSnoozed | |
| 78 | ? { id: "unsnooze" as const, label: "Wake thread" } | |
| 79 | : { | |
| 80 | id: "snooze" as const, | |
| 81 | label: "Snooze", | |
| 82 | disabled: !state.canSnoozeNow, | |
| 83 | children: state.snoozePresets.map((preset) => ({ | |
| 84 | id: `snooze:${preset.id}` as const, | |
| 85 | label: `${preset.label} (${preset.whenLabel})`, | |
| 86 | })), | |
| 87 | }, | |
| 88 | ] | |
| 89 | : []), | |
| 90 | { id: "rename", label: "Rename thread" }, | |
| 91 | ...(state.supports.titleRegeneration | |
| 92 | ? [ | |
| 93 | { | |
| 94 | id: "regenerate-title" as const, | |
| 95 | label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title", | |
| 96 | disabled: state.isRegeneratingTitle, | |
| 97 | }, | |
| 98 | ] | |
| 99 | : []), | |
| 100 | { id: "mark-unread", label: "Mark unread" }, | |
| 101 | { id: "copy-path", label: "Copy path", icon: "copy" }, | |
| 102 | ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []), | |
| 103 | { id: "delete", label: "Delete", destructive: true, icon: "trash" }, | |
| 104 | ]; | |
| 105 | } |
apps/web/src/hooks/useThreadActionMenu.ts created+299| ... | ... | @@ -0,0 +1,299 @@ |
| 1 | import { scopeProjectRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; | |
| 2 | import { | |
| 3 | type AtomCommandResult, | |
| 4 | isAtomCommandInterrupted, | |
| 5 | settlePromise, | |
| 6 | squashAtomCommandFailure, | |
| 7 | } from "@t3tools/client-runtime/state/runtime"; | |
| 8 | import { | |
| 9 | canSnooze, | |
| 10 | effectiveSettled, | |
| 11 | effectiveSnoozed, | |
| 12 | type ChangeRequestStateLike, | |
| 13 | } from "@t3tools/client-runtime/state/thread-settled"; | |
| 14 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 15 | import { useCallback } from "react"; | |
| 16 | ||
| 17 | import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze"; | |
| 18 | import { | |
| 19 | buildThreadActionMenuItems, | |
| 20 | type ThreadActionMenuId, | |
| 21 | } from "../components/threadActionMenu.logic"; | |
| 22 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; | |
| 23 | import { threadEnvironment } from "../state/threads"; | |
| 24 | import { useAtomCommand } from "../state/use-atom-command"; | |
| 25 | import { | |
| 26 | readEnvironmentSupportsPinning, | |
| 27 | readEnvironmentSupportsSettlement, | |
| 28 | readEnvironmentSupportsSnooze, | |
| 29 | readEnvironmentSupportsTitleRegeneration, | |
| 30 | readThreadShell, | |
| 31 | } from "../state/entities"; | |
| 32 | import { readLocalApi } from "../localApi"; | |
| 33 | import { useUiStateStore } from "../uiStateStore"; | |
| 34 | import { useCopyToClipboard } from "./useCopyToClipboard"; | |
| 35 | import { useNewThreadHandler } from "./useHandleNewThread"; | |
| 36 | import { useClientSettings } from "./useSettings"; | |
| 37 | import { useThreadActions } from "./useThreadActions"; | |
| 38 | ||
| 39 | function failureToast(title: string, error: unknown) { | |
| 40 | toastManager.add( | |
| 41 | stackedThreadToast({ | |
| 42 | type: "error", | |
| 43 | title, | |
| 44 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 45 | }), | |
| 46 | ); | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * The per-thread action menu (pin, settle, snooze, rename, copy, delete…) as | |
| 51 | * a self-contained hook, for surfaces other than the sidebar row — today the | |
| 52 | * chat header. Renders through the native context-menu bridge and dispatches | |
| 53 | * through the same mutations the sidebar uses. | |
| 54 | * | |
| 55 | * Unlike the sidebar, settle and snooze here never navigate away: the caller | |
| 56 | * is acting on the thread they are reading, and ChatView's parked-thread | |
| 57 | * banner already offers the way back. | |
| 58 | */ | |
| 59 | export function useThreadActionMenu(input: { | |
| 60 | readonly threadRef: ScopedThreadRef | null; | |
| 61 | /** Fallback for "Copy path" when the thread has no worktree. */ | |
| 62 | readonly projectCwd: string | null; | |
| 63 | /** PR state feeding auto-settle classification, as resolved by the caller. */ | |
| 64 | readonly changeRequestState: ChangeRequestStateLike | null; | |
| 65 | readonly onStartRename: () => void; | |
| 66 | }) { | |
| 67 | const { threadRef, projectCwd, changeRequestState, onStartRename } = input; | |
| 68 | const { | |
| 69 | settleThread, | |
| 70 | unsettleThread, | |
| 71 | snoozeThread, | |
| 72 | unsnoozeThread, | |
| 73 | pinThread, | |
| 74 | unpinThread, | |
| 75 | deleteThread, | |
| 76 | } = useThreadActions(); | |
| 77 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { | |
| 78 | reportFailure: false, | |
| 79 | }); | |
| 80 | const handleNewThread = useNewThreadHandler(); | |
| 81 | const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); | |
| 82 | const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); | |
| 83 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); | |
| 84 | const timestampFormat = useClientSettings((s) => s.timestampFormat); | |
| 85 | const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ | |
| 86 | onCopy: ({ path }) => { | |
| 87 | toastManager.add({ type: "success", title: "Path copied", description: path }); | |
| 88 | }, | |
| 89 | onError: (error) => failureToast("Failed to copy path", error), | |
| 90 | }); | |
| 91 | const { copyToClipboard: copyBranchToClipboard } = useCopyToClipboard<{ branch: string }>({ | |
| 92 | target: "branch name", | |
| 93 | onCopy: ({ branch }) => { | |
| 94 | toastManager.add({ type: "success", title: "Branch copied", description: branch }); | |
| 95 | }, | |
| 96 | onError: (error) => failureToast("Failed to copy branch", error), | |
| 97 | }); | |
| 98 | ||
| 99 | const openMenu = useCallback( | |
| 100 | (position: { x: number; y: number }) => { | |
| 101 | if (threadRef === null) return; | |
| 102 | void (async () => { | |
| 103 | const api = readLocalApi(); | |
| 104 | if (!api) return; | |
| 105 | // Snapshot at open time — the menu is modal, so state read now is | |
| 106 | // what the user is looking at. | |
| 107 | const thread = readThreadShell(threadRef); | |
| 108 | if (!thread) return; | |
| 109 | const now = new Date(); | |
| 110 | const supports = { | |
| 111 | settlement: readEnvironmentSupportsSettlement(threadRef.environmentId), | |
| 112 | snooze: readEnvironmentSupportsSnooze(threadRef.environmentId), | |
| 113 | pinning: readEnvironmentSupportsPinning(threadRef.environmentId), | |
| 114 | titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId), | |
| 115 | }; | |
| 116 | const isRegeneratingTitle = thread.titleRegeneration != null; | |
| 117 | const snoozePresets = resolveSnoozePresets(now, timestampFormat); | |
| 118 | const items = buildThreadActionMenuItems({ | |
| 119 | branch: thread.branch ?? null, | |
| 120 | isPinned: thread.pinnedAt != null, | |
| 121 | isSettled: | |
| 122 | supports.settlement && | |
| 123 | effectiveSettled(thread, { | |
| 124 | // Minute-quantized like useNowMinute, so this classification | |
| 125 | // can never disagree with the sidebar partition or ChatView's | |
| 126 | // parked-thread banner within the same minute. | |
| 127 | now: `${now.toISOString().slice(0, 16)}:00.000Z`, | |
| 128 | autoSettleAfterDays, | |
| 129 | changeRequestState, | |
| 130 | }), | |
| 131 | isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), | |
| 132 | canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), | |
| 133 | isRegeneratingTitle, | |
| 134 | supports, | |
| 135 | snoozePresets, | |
| 136 | }); | |
| 137 | const clicked = await settlePromise(() => api.contextMenu.show(items, position)); | |
| 138 | if (clicked._tag === "Failure" || clicked.value === null) return; | |
| 139 | const action: ThreadActionMenuId = clicked.value; | |
| 140 | if (action.startsWith("snooze:")) { | |
| 141 | const preset = snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); | |
| 142 | if (!preset) return; | |
| 143 | const result = await snoozeThread(threadRef, preset.snoozedUntil); | |
| 144 | if (result._tag === "Failure") { | |
| 145 | if (!isAtomCommandInterrupted(result)) { | |
| 146 | failureToast("Failed to snooze thread", squashAtomCommandFailure(result)); | |
| 147 | } | |
| 148 | return; | |
| 149 | } | |
| 150 | toastManager.add( | |
| 151 | stackedThreadToast({ | |
| 152 | type: "success", | |
| 153 | title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, | |
| 154 | timeout: 5_000, | |
| 155 | actionProps: { | |
| 156 | children: "Undo", | |
| 157 | onClick: () => { | |
| 158 | void unsnoozeThread(threadRef).then((undone) => { | |
| 159 | if (undone._tag === "Failure" && !isAtomCommandInterrupted(undone)) { | |
| 160 | failureToast("Failed to wake thread", squashAtomCommandFailure(undone)); | |
| 161 | } | |
| 162 | }); | |
| 163 | }, | |
| 164 | }, | |
| 165 | }), | |
| 166 | ); | |
| 167 | return; | |
| 168 | } | |
| 169 | const reportFailure = async ( | |
| 170 | title: string, | |
| 171 | run: () => Promise<AtomCommandResult<unknown, unknown>>, | |
| 172 | ) => { | |
| 173 | const result = await run(); | |
| 174 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 175 | failureToast(title, squashAtomCommandFailure(result)); | |
| 176 | } | |
| 177 | }; | |
| 178 | switch (action) { | |
| 179 | case "new-thread-on-branch": { | |
| 180 | // Explicit branch carry-over: reuse the thread's worktree when it | |
| 181 | // has one, otherwise its branch on the local checkout. | |
| 182 | const result = await settlePromise(() => | |
| 183 | handleNewThread(scopeProjectRef(threadRef.environmentId, thread.projectId), { | |
| 184 | branch: thread.branch, | |
| 185 | worktreePath: thread.worktreePath, | |
| 186 | envMode: thread.worktreePath ? "worktree" : "local", | |
| 187 | startFromOrigin: false, | |
| 188 | }), | |
| 189 | ); | |
| 190 | if (result._tag === "Failure") { | |
| 191 | failureToast("Could not create thread", squashAtomCommandFailure(result)); | |
| 192 | } | |
| 193 | return; | |
| 194 | } | |
| 195 | case "settle": | |
| 196 | await reportFailure("Failed to settle thread", () => settleThread(threadRef)); | |
| 197 | return; | |
| 198 | case "unsettle": | |
| 199 | await reportFailure("Failed to un-settle thread", () => unsettleThread(threadRef)); | |
| 200 | return; | |
| 201 | case "unsnooze": | |
| 202 | await reportFailure("Failed to wake thread", () => unsnoozeThread(threadRef)); | |
| 203 | return; | |
| 204 | case "pin": | |
| 205 | await reportFailure("Failed to pin thread", () => pinThread(threadRef)); | |
| 206 | return; | |
| 207 | case "unpin": | |
| 208 | await reportFailure("Failed to unpin thread", () => unpinThread(threadRef)); | |
| 209 | return; | |
| 210 | case "rename": | |
| 211 | onStartRename(); | |
| 212 | return; | |
| 213 | case "regenerate-title": | |
| 214 | if (isRegeneratingTitle) return; | |
| 215 | await reportFailure("Failed to regenerate thread title", () => | |
| 216 | updateThreadMetadata({ | |
| 217 | environmentId: threadRef.environmentId, | |
| 218 | input: { threadId: threadRef.threadId, regenerateTitle: true }, | |
| 219 | }), | |
| 220 | ); | |
| 221 | return; | |
| 222 | case "mark-unread": | |
| 223 | markThreadUnread(scopedThreadKey(threadRef), thread.latestTurn?.completedAt); | |
| 224 | return; | |
| 225 | case "copy-path": { | |
| 226 | const workspacePath = thread.worktreePath ?? projectCwd; | |
| 227 | if (!workspacePath) { | |
| 228 | toastManager.add( | |
| 229 | stackedThreadToast({ | |
| 230 | type: "error", | |
| 231 | title: "Path unavailable", | |
| 232 | description: "This thread does not have a workspace path to copy.", | |
| 233 | }), | |
| 234 | ); | |
| 235 | return; | |
| 236 | } | |
| 237 | copyPathToClipboard(workspacePath, { path: workspacePath }); | |
| 238 | return; | |
| 239 | } | |
| 240 | case "copy-branch": | |
| 241 | if (thread.branch) { | |
| 242 | copyBranchToClipboard(thread.branch, { branch: thread.branch }); | |
| 243 | } | |
| 244 | return; | |
| 245 | case "delete": { | |
| 246 | if (confirmThreadDelete) { | |
| 247 | const confirmed = await settlePromise(() => | |
| 248 | api.dialogs.confirm( | |
| 249 | [ | |
| 250 | `Delete thread "${thread.title}"?`, | |
| 251 | "This permanently clears conversation history for this thread.", | |
| 252 | ].join("\n"), | |
| 253 | ), | |
| 254 | ); | |
| 255 | if (confirmed._tag === "Failure" || !confirmed.value) return; | |
| 256 | } | |
| 257 | const deleted = await deleteThread(threadRef); | |
| 258 | if ( | |
| 259 | deleted._tag === "Failure" && | |
| 260 | !isAtomCommandInterrupted(deleted) && | |
| 261 | // A failure with the thread already gone is worktree cleanup | |
| 262 | // failing after a successful delete — deleteThread has toasted | |
| 263 | // that itself, and "Failed to delete thread" would be a lie. | |
| 264 | readThreadShell(threadRef) !== null | |
| 265 | ) { | |
| 266 | failureToast("Failed to delete thread", squashAtomCommandFailure(deleted)); | |
| 267 | } | |
| 268 | return; | |
| 269 | } | |
| 270 | default: | |
| 271 | return; | |
| 272 | } | |
| 273 | })(); | |
| 274 | }, | |
| 275 | [ | |
| 276 | autoSettleAfterDays, | |
| 277 | changeRequestState, | |
| 278 | confirmThreadDelete, | |
| 279 | copyBranchToClipboard, | |
| 280 | copyPathToClipboard, | |
| 281 | deleteThread, | |
| 282 | handleNewThread, | |
| 283 | markThreadUnread, | |
| 284 | onStartRename, | |
| 285 | pinThread, | |
| 286 | projectCwd, | |
| 287 | settleThread, | |
| 288 | snoozeThread, | |
| 289 | threadRef, | |
| 290 | timestampFormat, | |
| 291 | unpinThread, | |
| 292 | unsettleThread, | |
| 293 | unsnoozeThread, | |
| 294 | updateThreadMetadata, | |
| 295 | ], | |
| 296 | ); | |
| 297 | ||
| 298 | return { openMenu }; | |
| 299 | } |
apps/web/src/state/entities.ts+9| ... | ... | @@ -250,6 +250,15 @@ export function readEnvironmentSupportsPinning(environmentId: EnvironmentId): bo |
| 250 | 250 | ); |
| 251 | 251 | } |
| 252 | 252 | |
| 253 | /** Whether the environment's server understands thread title regeneration. | |
| 254 | Same version-skew contract as settlement. */ | |
| 255 | export function readEnvironmentSupportsTitleRegeneration(environmentId: EnvironmentId): boolean { | |
| 256 | return ( | |
| 257 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | |
| 258 | .threadTitleRegeneration === true | |
| 259 | ); | |
| 260 | } | |
| 261 | ||
| 253 | 262 | export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { |
| 254 | 263 | return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); |
| 255 | 264 | } |