authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-07 06:18:12-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-07 10:18:12+00:00
logb2ee17d7c1b1bdff543ffff43929d01b92a902f4
tree70de0bc92c9290b8b92282f0e050c5428ea45010
parent7963cc70f3c9873768a5eaad7ca0a20a23d67aed
signature Signed by PGP key B5690EEEBB952194

feat(web): thread actions from the chat header title (#5592)

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) {
59795979 activeThreadId={activeThread.id}
59805980 {...(routeKind === "draft" && draftId ? { draftId } : {})}
59815981 activeThreadTitle={activeThread.title}
5982 isServerThread={isServerThread}
5983 changeRequestState={activeThreadPr?.state ?? null}
59825984 activeProjectName={activeProject?.title}
59835985 activeProjectCwd={activeProject?.workspaceRoot ?? null}
59845986 openInCwd={gitCwd}
apps/web/src/components/SidebarV2.tsx+16-56
......@@ -108,6 +108,7 @@ import {
108108import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat";
109109import type { SidebarThreadSummary } from "../types";
110110import { cn } from "~/lib/utils";
111import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
111112import {
112113 buildBulkTitleRegenerationContextMenuItem,
113114 formatWorkingDurationLabel,
......@@ -2539,62 +2540,21 @@ export default function SidebarV2() {
25392540 const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
25402541 const clicked = await settlePromise(() =>
25412542 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 }),
25982558 position,
25992559 ),
26002560 );
apps/web/src/components/chat/ChatHeader.test.ts+22-1
......@@ -1,7 +1,7 @@
11import { EnvironmentId } from "@t3tools/contracts";
22import { describe, expect, it } from "vite-plus/test";
33
4import { shouldShowOpenInPicker } from "./ChatHeader";
4import { resolveRenameCommit, shouldShowOpenInPicker } from "./ChatHeader";
55
66describe("shouldShowOpenInPicker", () => {
77 const primaryEnvironmentId = EnvironmentId.make("environment-primary");
......@@ -46,3 +46,24 @@ describe("shouldShowOpenInPicker", () => {
4646 ).toBe(false);
4747 });
4848});
49
50describe("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 {
66 type ThreadId,
77} from "@t3tools/contracts";
88import { scopeThreadRef } from "@t3tools/client-runtime/environment";
9import { memo } from "react";
9import {
10 isAtomCommandInterrupted,
11 squashAtomCommandFailure,
12} from "@t3tools/client-runtime/state/runtime";
13import type { ChangeRequestStateLike } from "@t3tools/client-runtime/state/thread-settled";
14import { ChevronDownIcon } from "lucide-react";
15import {
16 memo,
17 useCallback,
18 useMemo,
19 useRef,
20 useState,
21 type KeyboardEvent as ReactKeyboardEvent,
22 type MouseEvent as ReactMouseEvent,
23} from "react";
1024import GitActionsControl from "../GitActionsControl";
1125import { type DraftId } from "~/composerDraftStore";
1226import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
27import { toastManager } from "../ui/toast";
1328import ProjectScriptsControl, {
1429 type NewProjectScriptInput,
1530 type ProjectScriptActionResult,
......@@ -17,6 +32,9 @@ import ProjectScriptsControl, {
1732import { OpenInPicker } from "./OpenInPicker";
1833import { usePrimaryEnvironmentId } from "../../state/environments";
1934import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts";
35import { useThreadActionMenu } from "~/hooks/useThreadActionMenu";
36import { threadEnvironment } from "../../state/threads";
37import { useAtomCommand } from "../../state/use-atom-command";
2038import { ProjectFavicon } from "../ProjectFavicon";
2139import { cn } from "~/lib/utils";
2240
......@@ -25,6 +43,10 @@ interface ChatHeaderProps {
2543 activeThreadId: ThreadId;
2644 draftId?: DraftId;
2745 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;
2850 activeProjectName: string | undefined;
2951 activeProjectCwd: string | null;
3052 openInCwd: string | null;
......@@ -44,6 +66,20 @@ interface ChatHeaderProps {
4466 onDeleteProjectScript: (scriptId: string) => Promise<ProjectScriptActionResult>;
4567}
4668
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 */
73export 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
4783export function shouldShowOpenInPicker(input: {
4884 readonly activeProjectName: string | undefined;
4985 readonly activeThreadEnvironmentId: EnvironmentId;
......@@ -61,6 +97,8 @@ export const ChatHeader = memo(function ChatHeader({
6197 activeThreadId,
6298 draftId,
6399 activeThreadTitle,
100 isServerThread,
101 changeRequestState,
64102 activeProjectName,
65103 activeProjectCwd,
66104 openInCwd,
......@@ -86,8 +124,91 @@ export const ChatHeader = memo(function ChatHeader({
86124 activeThreadEnvironmentId,
87125 primaryEnvironmentId,
88126 });
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 );
89207 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 >
91212 <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">
92213 {/* The project always leads the header: knowing which project a
93214 thread lives in is priority zero, and the thread title alone
......@@ -119,19 +240,58 @@ export const ChatHeader = memo(function ChatHeader({
119240 </span>
120241 </span>
121242 ) : 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">
129271 {activeThreadTitle}
130272 </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 )}
135295 </div>
136296 <div
137297 data-chat-header-actions
apps/web/src/components/threadActionMenu.logic.test.ts created+66
......@@ -0,0 +1,66 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./threadActionMenu.logic";
4
5const 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
18function ids(state: ThreadActionMenuState): string[] {
19 return buildThreadActionMenuItems(state).map((item) => item.id);
20}
21
22describe("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 @@
1import type { ContextMenuItem } from "@t3tools/contracts";
2import 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 */
9export 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
25export 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 */
46export 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 @@
1import { scopeProjectRef, scopedThreadKey } from "@t3tools/client-runtime/environment";
2import {
3 type AtomCommandResult,
4 isAtomCommandInterrupted,
5 settlePromise,
6 squashAtomCommandFailure,
7} from "@t3tools/client-runtime/state/runtime";
8import {
9 canSnooze,
10 effectiveSettled,
11 effectiveSnoozed,
12 type ChangeRequestStateLike,
13} from "@t3tools/client-runtime/state/thread-settled";
14import type { ScopedThreadRef } from "@t3tools/contracts";
15import { useCallback } from "react";
16
17import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze";
18import {
19 buildThreadActionMenuItems,
20 type ThreadActionMenuId,
21} from "../components/threadActionMenu.logic";
22import { stackedThreadToast, toastManager } from "../components/ui/toast";
23import { threadEnvironment } from "../state/threads";
24import { useAtomCommand } from "../state/use-atom-command";
25import {
26 readEnvironmentSupportsPinning,
27 readEnvironmentSupportsSettlement,
28 readEnvironmentSupportsSnooze,
29 readEnvironmentSupportsTitleRegeneration,
30 readThreadShell,
31} from "../state/entities";
32import { readLocalApi } from "../localApi";
33import { useUiStateStore } from "../uiStateStore";
34import { useCopyToClipboard } from "./useCopyToClipboard";
35import { useNewThreadHandler } from "./useHandleNewThread";
36import { useClientSettings } from "./useSettings";
37import { useThreadActions } from "./useThreadActions";
38
39function 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 */
59export 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
250250 );
251251}
252252
253/** Whether the environment's server understands thread title regeneration.
254 Same version-skew contract as settlement. */
255export function readEnvironmentSupportsTitleRegeneration(environmentId: EnvironmentId): boolean {
256 return (
257 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
258 .threadTitleRegeneration === true
259 );
260}
261
253262export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null {
254263 return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref));
255264}