1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import { requestCustomSnooze } from "../components/CustomSnoozeDialog";
3import { openGuestShareDialog } from "../components/chat/guestShareDialogState";
4import {
5 type AtomCommandResult,
6 isAtomCommandInterrupted,
7 settlePromise,
8 squashAtomCommandFailure,
9} from "@t3tools/client-runtime/state/runtime";
10import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
11import {
12 AuthOrchestrationOperateScope,
13 type ScopedThreadRef,
14 type ThreadId,
15} from "@t3tools/contracts";
16import { useRouter } from "@tanstack/react-router";
17
18import { pickSnoozePreset, resolveSnoozePresets } from "../components/Sidebar.snooze";
19import {
20 buildThreadActionMenuItems,
21 threadActionRequiresOperate,
22 type ThreadActionMenuId,
23} from "../components/threadActionMenu.logic";
24import { titleRegenerationToast } from "../components/threadTitleRegenerationToast";
25import { stackedThreadToast, toastManager } from "../components/ui/toast";
26import { copyThreadReference } from "../lib/copyThreadReference";
27import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference";
28import { threadEnvironment } from "../state/threads";
29import { useOrchestrationCommand } from "../state/use-orchestration-command";
30import { readEnvironmentScope } from "../state/session";
31import {
32 readEnvironmentServerConfigLoaded,
33 readEnvironmentSupportsAutoSettleOptOut,
34 readThreadShell,
35 useProjects,
36} from "../state/entities";
37import { usePrimaryEnvironmentId } from "../state/environments";
38import { readLocalApi } from "../localApi";
39import { configuredHostedAppUrl } from "../hostedPairing";
40import {
41 deriveLogicalProjectKeyFromSettings,
42 derivePhysicalProjectKey,
43 selectProjectGroupingSettings,
44} from "@t3tools/client-runtime/state/project-grouping";
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 { canTurnThreadIntoProject, 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
62export function useThreadActionMenu(input: {
63 readonly threadRef: ScopedThreadRef | null;
64 readonly projectCwd: string | null;
65 readonly onStartRename: () => void;
66 readonly onFind?: ((threadRef: ScopedThreadRef) => void) | undefined;
67 readonly canShareWithGuest: boolean;
68}) {
69 const { threadRef, projectCwd, onStartRename, onFind, canShareWithGuest } = input;
70 const router = useRouter();
71 const projects = useProjects();
72 const primaryEnvironmentId = usePrimaryEnvironmentId();
73 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
74 const logicalProjectKeyByPhysicalKey = buildPhysicalToLogicalProjectKeyMap({
75 projects,
76 settings: projectGroupingSettings,
77 primaryEnvironmentId,
78 });
79 const {
80 settleThread,
81 unsettleThread,
82 snoozeThread,
83 unsnoozeThread,
84 pinThread,
85 confirmAndUnpinThread,
86 setThreadAutoSettle,
87 archiveThread,
88 deleteThread,
89 markThreadUnread,
90 turnThreadIntoProject,
91 } = useThreadActions();
92 const regenerateThreadTitle = useOrchestrationCommand(threadEnvironment.regenerateTitle, {
93 reportFailure: false,
94 });
95 const handleNewThread = useNewThreadHandler();
96 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
97 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
98 const timestampFormat = useClientSettings((s) => s.timestampFormat);
99 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
100 onCopy: ({ path }) => {
101 toastManager.add({ type: "success", title: "Path copied", description: path });
102 },
103 onError: (error) => failureToast("Failed to copy path", error),
104 });
105 const { copyToClipboard: copyBranchToClipboard } = useCopyToClipboard<{ branch: string }>({
106 target: "branch name",
107 onCopy: ({ branch }) => {
108 toastManager.add({ type: "success", title: "Branch copied", description: branch });
109 },
110 onError: (error) => failureToast("Failed to copy branch", error),
111 });
112 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({
113 onCopy: ({ threadId }) => {
114 toastManager.add({ type: "success", title: "Thread ID copied", description: threadId });
115 },
116 onError: (error) => failureToast("Failed to copy thread ID", error),
117 });
118
119 const openMenu = (position: { x: number; y: number }) => {
120 if (threadRef === null) return;
121 void (async () => {
122 const api = readLocalApi();
123 if (!api) return;
124 const thread = readThreadShell(threadRef);
125 if (!thread) {
126 failureToast("Thread actions unavailable", new Error("This thread has not loaded yet."));
127 return;
128 }
129 const now = new Date();
130 const configLoaded = readEnvironmentServerConfigLoaded(threadRef.environmentId);
131 const supports = {
132 settlement: configLoaded,
133 autoSettleOptOut: readEnvironmentSupportsAutoSettleOptOut(threadRef.environmentId),
134 snooze: configLoaded,
135 pinning: configLoaded,
136 titleRegeneration: configLoaded,
137 };
138 const isRegeneratingTitle = thread.titleRegeneration != null;
139 const snoozePresets = resolveSnoozePresets(now, timestampFormat, threadRef.environmentId);
140 const items = buildThreadActionMenuItems({
141 canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope),
142 canFind: onFind !== undefined,
143 branch: thread.branch ?? null,
144 projectFilter: null,
145 isPinned: thread.pinnedAt != null,
146 isSettled: supports.settlement && thread.settledOverride === "settled",
147 autoSettleEnabled: thread.autoSettleDisabledAt == null,
148 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),
149 canSnoozeNow: canSnooze(thread, { now: now.toISOString() }),
150 isRegeneratingTitle,
151 isRunning: !threadRuntimeCanArchive(thread.runtime),
152 supports,
153 snoozePresets,
154 canShareWithGuest,
155 canTurnIntoProject: canTurnThreadIntoProject(threadRef),
156 });
157 const clicked = await settlePromise(() => api.contextMenu.show(items, position));
158 if (clicked._tag === "Failure" || clicked.value === null) return;
159 const action: ThreadActionMenuId = clicked.value;
160 if (
161 threadActionRequiresOperate(action) &&
162 !readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)
163 ) {
164 failureToast(
165 "Thread action unavailable",
166 new Error("This connection cannot change threads."),
167 );
168 return;
169 }
170 if (action.startsWith("snooze:")) {
171 const preset =
172 action === "snooze:custom"
173 ? await requestCustomSnooze()
174 : pickSnoozePreset(
175 snoozePresets,
176 action.slice("snooze:".length),
177 timestampFormat,
178 threadRef.environmentId,
179 );
180 if (!preset) return;
181 const result = await snoozeThread(threadRef, preset.snoozedUntil);
182 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
183 failureToast("Failed to snooze thread", squashAtomCommandFailure(result));
184 }
185 return;
186 }
187 const reportFailure = async (
188 title: string,
189 run: () => Promise<AtomCommandResult<unknown, unknown>>,
190 ) => {
191 const result = await run();
192 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
193 failureToast(title, squashAtomCommandFailure(result));
194 }
195 };
196 switch (action) {
197 case "find":
198 onFind?.(threadRef);
199 return;
200 case "project-settings": {
201 const project = projects.find(
202 (candidate) =>
203 candidate.environmentId === thread.environmentId && candidate.id === thread.projectId,
204 );
205 if (!project) return;
206 const projectKey =
207 logicalProjectKeyByPhysicalKey.get(derivePhysicalProjectKey(project)) ??
208 deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings);
209 void router.navigate({
210 to: "/projects/$projectKey",
211 params: { projectKey },
212 });
213 return;
214 }
215 case "new-thread-on-branch": {
216 const result = await settlePromise(() =>
217 handleNewThread(scopeProjectRef(threadRef.environmentId, thread.projectId), {
218 branch: thread.branch,
219 worktreePath: thread.worktreePath,
220 envMode: thread.worktreePath ? "worktree" : "local",
221 startFromOrigin: false,
222 }),
223 );
224 if (result._tag === "Failure") {
225 failureToast("Could not create thread", squashAtomCommandFailure(result));
226 }
227 return;
228 }
229 case "settle":
230 await reportFailure("Failed to settle thread", () => settleThread(threadRef));
231 return;
232 case "unsettle":
233 await reportFailure("Failed to un-settle thread", () => unsettleThread(threadRef));
234 return;
235 case "unsnooze":
236 await reportFailure("Failed to wake thread", () => unsnoozeThread(threadRef));
237 return;
238 case "pin":
239 await reportFailure("Failed to pin thread", () => pinThread(threadRef));
240 return;
241 case "unpin": {
242 await reportFailure("Failed to unpin thread", () => confirmAndUnpinThread(threadRef));
243 return;
244 }
245 case "auto-settle:enabled":
246 case "auto-settle:disabled":
247 await reportFailure("Failed to update auto-settle", () =>
248 setThreadAutoSettle(threadRef, action === "auto-settle:enabled"),
249 );
250 return;
251 case "rename":
252 onStartRename();
253 return;
254 case "regenerate-title": {
255 if (isRegeneratingTitle) return;
256 const toast = titleRegenerationToast([
257 await regenerateThreadTitle({
258 environmentId: threadRef.environmentId,
259 input: { threadId: threadRef.threadId },
260 }),
261 ]);
262 if (toast) toastManager.add(toast);
263 return;
264 }
265 case "mark-unread":
266 markThreadUnread(threadRef);
267 return;
268 case "share-guest":
269 openGuestShareDialog(threadRef);
270 return;
271 case "turn-into-project": {
272 const result = await turnThreadIntoProject(threadRef);
273 if (result._tag === "Success") {
274 toastManager.add({
275 type: "success",
276 title: "Project created",
277 description: thread.title,
278 });
279 } else if (!isAtomCommandInterrupted(result)) {
280 failureToast("Could not turn thread into a project", squashAtomCommandFailure(result));
281 }
282 return;
283 }
284 case "copy-path": {
285 const workspacePath = thread.worktreePath ?? projectCwd;
286 if (!workspacePath) {
287 toastManager.add(
288 stackedThreadToast({
289 type: "error",
290 title: "Path unavailable",
291 description: "This thread does not have a workspace path to copy.",
292 }),
293 );
294 return;
295 }
296 copyPathToClipboard(workspacePath, { path: workspacePath });
297 return;
298 }
299 case "copy-branch":
300 if (thread.branch) {
301 copyBranchToClipboard(thread.branch, { branch: thread.branch });
302 }
303 return;
304 case "copy-thread":
305 void copyThreadReference(
306 threadChipCopyTarget(threadRef, thread.title, configuredHostedAppUrl()),
307 );
308 return;
309 case "copy-thread-id":
310 copyThreadIdToClipboard(thread.id, { threadId: thread.id });
311 return;
312 case "archive": {
313 if (confirmThreadArchive) {
314 const confirmed = await settlePromise(() =>
315 api.dialogs.confirm(`Archive thread "${thread.title}"?`),
316 );
317 if (confirmed._tag === "Failure" || !confirmed.value) return;
318 }
319 let didArchive = false;
320 const result = await archiveThread(threadRef, {
321 onArchived: () => {
322 didArchive = true;
323 },
324 });
325 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
326 failureToast(
327 didArchive ? "Thread archived, but navigation failed" : "Failed to archive thread",
328 squashAtomCommandFailure(result),
329 );
330 }
331 return;
332 }
333 case "delete": {
334 if (confirmThreadDelete) {
335 const confirmed = await settlePromise(() =>
336 api.dialogs.confirm(
337 [
338 `Delete thread "${thread.title}"?`,
339 "This permanently clears conversation history for this thread.",
340 ].join("\n"),
341 { variant: "destructive" },
342 ),
343 );
344 if (confirmed._tag === "Failure" || !confirmed.value) return;
345 }
346 const deleted = await deleteThread(threadRef);
347 if (
348 deleted._tag === "Failure" &&
349 !isAtomCommandInterrupted(deleted) &&
350 // A missing thread means cleanup failed after deletion; deleteThread already toasted that failure.
351 readThreadShell(threadRef) !== null
352 ) {
353 failureToast("Failed to delete thread", squashAtomCommandFailure(deleted));
354 }
355 return;
356 }
357 default:
358 return;
359 }
360 })();
361 };
362
363 const closeMenu = () => {
364 void readLocalApi()?.contextMenu.close();
365 };
366
367 return { openMenu, closeMenu };
368}