1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import { requestCustomSnooze } from "../components/CustomSnoozeDialog";
3import {
4 type AtomCommandResult,
5 isAtomCommandInterrupted,
6 settlePromise,
7 squashAtomCommandFailure,
8} from "@t3tools/client-runtime/state/runtime";
9import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
10import {
11 AuthOrchestrationOperateScope,
12 type ScopedThreadRef,
13 type ThreadId,
14} from "@t3tools/contracts";
15import { useRouter } from "@tanstack/react-router";
16import { useCallback, useMemo } from "react";
17
18import { resolveSnoozePresets } from "../components/Sidebar.snooze";
19import {
20 buildThreadActionMenuItems,
21 threadActionRequiresOperate,
22 threadActionIsOrganization,
23 type ThreadActionMenuId,
24} from "../components/threadActionMenu.logic";
25import { stackedThreadToast, toastManager } from "../components/ui/toast";
26import { threadEnvironment } from "../state/threads";
27import { useOrchestrationCommand } from "../state/use-orchestration-command";
28import { readEnvironmentScope, readThreadOrganizationPermission } from "../state/session";
29import {
30 readEnvironmentSupportsAutoSettleOptOut,
31 readEnvironmentSupportsPinning,
32 readEnvironmentSupportsSettlement,
33 readEnvironmentSupportsSnooze,
34 readEnvironmentSupportsTitleRegeneration,
35 readThreadShell,
36 useProjects,
37} from "../state/entities";
38import { usePrimaryEnvironmentId } from "../state/environments";
39import { readLocalApi } from "../localApi";
40import {
41 deriveLogicalProjectKeyFromSettings,
42 derivePhysicalProjectKey,
43 selectProjectGroupingSettings,
44} from "../logicalProject";
45import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping";
46import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models";
47import { useCopyToClipboard } from "./useCopyToClipboard";
48import { useNewThreadHandler } from "./useHandleNewThread";
49import { useClientSettings } from "./useSettings";
50import { useThreadActions } from "./useThreadActions";
51
52function failureToast(title: string, error: unknown) {
53 toastManager.add(
54 stackedThreadToast({
55 type: "error",
56 title,
57 description: error instanceof Error ? error.message : "An error occurred.",
58 }),
59 );
60}
61
62/**
63 * The per-thread action menu (pin, settle, snooze, rename, copy, delete…) as
64 * a self-contained hook, for surfaces other than the sidebar row — today the
65 * chat header. Renders through the native context-menu bridge and dispatches
66 * through the same mutations the sidebar uses.
67 *
68 * Unlike the sidebar, settle and snooze here never navigate away: the caller
69 * is acting on the thread they are reading, and ChatView's parked-thread
70 * banner already offers the way back.
71 */
72export function useThreadActionMenu(input: {
73 readonly threadRef: ScopedThreadRef | null;
74 /** Fallback for "Copy path" when the thread has no worktree. */
75 readonly projectCwd: string | null;
76 readonly onStartRename: () => void;
77}) {
78 const { threadRef, projectCwd, onStartRename } = input;
79 const router = useRouter();
80 const projects = useProjects();
81 const primaryEnvironmentId = usePrimaryEnvironmentId();
82 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
83 const logicalProjectKeyByPhysicalKey = useMemo(
84 () =>
85 buildPhysicalToLogicalProjectKeyMap({
86 projects,
87 settings: projectGroupingSettings,
88 primaryEnvironmentId,
89 }),
90 [primaryEnvironmentId, projectGroupingSettings, projects],
91 );
92 const {
93 settleThread,
94 unsettleThread,
95 snoozeThread,
96 unsnoozeThread,
97 pinThread,
98 confirmAndUnpinThread,
99 setThreadAutoSettle,
100 archiveThread,
101 deleteThread,
102 markThreadUnread,
103 } = useThreadActions();
104 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
105 reportFailure: false,
106 });
107 const handleNewThread = useNewThreadHandler();
108 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
109 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
110 const timestampFormat = useClientSettings((s) => s.timestampFormat);
111 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
112 onCopy: ({ path }) => {
113 toastManager.add({ type: "success", title: "Path copied", description: path });
114 },
115 onError: (error) => failureToast("Failed to copy path", error),
116 });
117 const { copyToClipboard: copyBranchToClipboard } = useCopyToClipboard<{ branch: string }>({
118 target: "branch name",
119 onCopy: ({ branch }) => {
120 toastManager.add({ type: "success", title: "Branch copied", description: branch });
121 },
122 onError: (error) => failureToast("Failed to copy branch", error),
123 });
124 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({
125 onCopy: ({ threadId }) => {
126 toastManager.add({ type: "success", title: "Thread ID copied", description: threadId });
127 },
128 onError: (error) => failureToast("Failed to copy thread ID", error),
129 });
130
131 const openMenu = useCallback(
132 (position: { x: number; y: number }) => {
133 if (threadRef === null) return;
134 void (async () => {
135 const api = readLocalApi();
136 if (!api) return;
137 // Snapshot at open time — the menu is modal, so state read now is
138 // what the user is looking at.
139 const thread = readThreadShell(threadRef);
140 if (!thread) return;
141 const now = new Date();
142 const supports = {
143 settlement: readEnvironmentSupportsSettlement(threadRef.environmentId),
144 autoSettleOptOut: readEnvironmentSupportsAutoSettleOptOut(threadRef.environmentId),
145 snooze: readEnvironmentSupportsSnooze(threadRef.environmentId),
146 pinning: readEnvironmentSupportsPinning(threadRef.environmentId),
147 titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId),
148 };
149 const isRegeneratingTitle = thread.titleRegeneration != null;
150 const snoozePresets = resolveSnoozePresets(now, timestampFormat);
151 const items = buildThreadActionMenuItems({
152 canOrganize: readThreadOrganizationPermission(threadRef.environmentId),
153 canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope),
154 branch: thread.branch ?? null,
155 projectFilter: null,
156 isPinned: thread.pinnedAt != null,
157 isSettled: supports.settlement && thread.settledOverride === "settled",
158 autoSettleEnabled: thread.autoSettleDisabledAt == null,
159 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),
160 canSnoozeNow: canSnooze(thread, { now: now.toISOString() }),
161 isRegeneratingTitle,
162 isRunning: !threadRuntimeCanArchive(thread.runtime),
163 supports,
164 snoozePresets,
165 });
166 const clicked = await settlePromise(() => api.contextMenu.show(items, position));
167 if (clicked._tag === "Failure" || clicked.value === null) return;
168 const action: ThreadActionMenuId = clicked.value;
169 if (
170 threadActionRequiresOperate(action) &&
171 !(threadActionIsOrganization(action)
172 ? readThreadOrganizationPermission(threadRef.environmentId)
173 : readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope))
174 ) {
175 failureToast(
176 "Thread action unavailable",
177 new Error("This connection cannot change threads."),
178 );
179 return;
180 }
181 if (action.startsWith("snooze:")) {
182 const preset =
183 action === "snooze:custom"
184 ? await requestCustomSnooze()
185 : snoozePresets.find((candidate) => `snooze:${candidate.id}` === action);
186 if (!preset) return;
187 const result = await snoozeThread(threadRef, preset.snoozedUntil);
188 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
189 failureToast("Failed to snooze thread", squashAtomCommandFailure(result));
190 }
191 return;
192 }
193 const reportFailure = async (
194 title: string,
195 run: () => Promise<AtomCommandResult<unknown, unknown>>,
196 ) => {
197 const result = await run();
198 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
199 failureToast(title, squashAtomCommandFailure(result));
200 }
201 };
202 switch (action) {
203 case "project-settings": {
204 const project = projects.find(
205 (candidate) =>
206 candidate.environmentId === thread.environmentId &&
207 candidate.id === thread.projectId,
208 );
209 if (!project) return;
210 const projectKey =
211 logicalProjectKeyByPhysicalKey.get(derivePhysicalProjectKey(project)) ??
212 deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings);
213 void router.navigate({
214 to: "/projects/$projectKey",
215 params: { projectKey },
216 });
217 return;
218 }
219 case "new-thread-on-branch": {
220 // Explicit branch carry-over: reuse the thread's worktree when it
221 // has one, otherwise its branch on the local checkout.
222 const result = await settlePromise(() =>
223 handleNewThread(scopeProjectRef(threadRef.environmentId, thread.projectId), {
224 branch: thread.branch,
225 worktreePath: thread.worktreePath,
226 envMode: thread.worktreePath ? "worktree" : "local",
227 startFromOrigin: false,
228 }),
229 );
230 if (result._tag === "Failure") {
231 failureToast("Could not create thread", squashAtomCommandFailure(result));
232 }
233 return;
234 }
235 case "settle":
236 await reportFailure("Failed to settle thread", () => settleThread(threadRef));
237 return;
238 case "unsettle":
239 await reportFailure("Failed to un-settle thread", () => unsettleThread(threadRef));
240 return;
241 case "unsnooze":
242 await reportFailure("Failed to wake thread", () => unsnoozeThread(threadRef));
243 return;
244 case "pin":
245 await reportFailure("Failed to pin thread", () => pinThread(threadRef));
246 return;
247 case "unpin": {
248 await reportFailure("Failed to unpin thread", () => confirmAndUnpinThread(threadRef));
249 return;
250 }
251 case "auto-settle:enabled":
252 case "auto-settle:disabled":
253 await reportFailure("Failed to update auto-settle", () =>
254 setThreadAutoSettle(threadRef, action === "auto-settle:enabled"),
255 );
256 return;
257 case "rename":
258 onStartRename();
259 return;
260 case "regenerate-title":
261 if (isRegeneratingTitle) return;
262 await reportFailure("Failed to regenerate thread title", () =>
263 updateThreadMetadata({
264 environmentId: threadRef.environmentId,
265 input: { threadId: threadRef.threadId, regenerateTitle: true },
266 }),
267 );
268 return;
269 case "mark-unread":
270 markThreadUnread(threadRef);
271 return;
272 case "copy-path": {
273 const workspacePath = thread.worktreePath ?? projectCwd;
274 if (!workspacePath) {
275 toastManager.add(
276 stackedThreadToast({
277 type: "error",
278 title: "Path unavailable",
279 description: "This thread does not have a workspace path to copy.",
280 }),
281 );
282 return;
283 }
284 copyPathToClipboard(workspacePath, { path: workspacePath });
285 return;
286 }
287 case "copy-branch":
288 if (thread.branch) {
289 copyBranchToClipboard(thread.branch, { branch: thread.branch });
290 }
291 return;
292 case "copy-thread-id":
293 copyThreadIdToClipboard(thread.id, { threadId: thread.id });
294 return;
295 case "archive": {
296 if (confirmThreadArchive) {
297 const confirmed = await settlePromise(() =>
298 api.dialogs.confirm(`Archive thread "${thread.title}"?`),
299 );
300 if (confirmed._tag === "Failure" || !confirmed.value) return;
301 }
302 let didArchive = false;
303 const result = await archiveThread(threadRef, {
304 onArchived: () => {
305 didArchive = true;
306 },
307 });
308 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
309 failureToast(
310 didArchive ? "Thread archived, but navigation failed" : "Failed to archive thread",
311 squashAtomCommandFailure(result),
312 );
313 }
314 return;
315 }
316 case "delete": {
317 if (confirmThreadDelete) {
318 const confirmed = await settlePromise(() =>
319 api.dialogs.confirm(
320 [
321 `Delete thread "${thread.title}"?`,
322 "This permanently clears conversation history for this thread.",
323 ].join("\n"),
324 { variant: "destructive" },
325 ),
326 );
327 if (confirmed._tag === "Failure" || !confirmed.value) return;
328 }
329 const deleted = await deleteThread(threadRef);
330 if (
331 deleted._tag === "Failure" &&
332 !isAtomCommandInterrupted(deleted) &&
333 // A failure with the thread already gone is worktree cleanup
334 // failing after a successful delete — deleteThread has toasted
335 // that itself, and "Failed to delete thread" would be a lie.
336 readThreadShell(threadRef) !== null
337 ) {
338 failureToast("Failed to delete thread", squashAtomCommandFailure(deleted));
339 }
340 return;
341 }
342 default:
343 return;
344 }
345 })();
346 },
347 [
348 archiveThread,
349 confirmThreadArchive,
350 confirmThreadDelete,
351 confirmAndUnpinThread,
352 copyBranchToClipboard,
353 copyPathToClipboard,
354 copyThreadIdToClipboard,
355 deleteThread,
356 handleNewThread,
357 logicalProjectKeyByPhysicalKey,
358 markThreadUnread,
359 onStartRename,
360 pinThread,
361 projectCwd,
362 projectGroupingSettings,
363 projects,
364 router,
365 setThreadAutoSettle,
366 settleThread,
367 snoozeThread,
368 threadRef,
369 timestampFormat,
370 unsettleThread,
371 unsnoozeThread,
372 updateThreadMetadata,
373 ],
374 );
375
376 const closeMenu = useCallback(() => {
377 void readLocalApi()?.contextMenu.close();
378 }, []);
379
380 return { openMenu, closeMenu };
381}