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 | "filter-by-project"
12 | "project-settings"
13 | "pin"
14 | "unpin"
15 | "settle"
16 | "unsettle"
17 | "auto-settle"
18 | "auto-settle:enabled"
19 | "auto-settle:disabled"
20 | "snooze"
21 | `snooze:${string}`
22 | "unsnooze"
23 | "rename"
24 | "regenerate-title"
25 | "mark-unread"
26 | "copy"
27 | "copy-path"
28 | "copy-branch"
29 | "copy-thread-id"
30 | "archive"
31 | "delete";
32
33export type DraftActionMenuId =
34 | "copy"
35 | "copy-path"
36 | "copy-branch"
37 | "project-settings"
38 | "discard";
39
40/** Right-click menu for an unsent draft row in the sidebar. */
41export function buildDraftActionMenuItems(options: {
42 readonly hasPath: boolean;
43 readonly hasBranch: boolean;
44 readonly hasProject: boolean;
45}): ReadonlyArray<ContextMenuItem<DraftActionMenuId>> {
46 return [
47 {
48 id: "copy",
49 label: "Copy",
50 icon: "copy",
51 disabled: !options.hasPath && !options.hasBranch,
52 children: [
53 ...(options.hasPath ? [{ id: "copy-path" as const, label: "Path", icon: "folder" }] : []),
54 ...(options.hasBranch
55 ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }]
56 : []),
57 ],
58 },
59 ...(options.hasProject
60 ? [{ id: "project-settings" as const, label: "Project settings", icon: "settings" }]
61 : []),
62 {
63 id: "discard",
64 label: "Discard draft",
65 icon: "trash",
66 destructive: true,
67 separatorBefore: true,
68 },
69 ];
70}
71
72export interface ThreadActionMenuState {
73 readonly canOperate: boolean;
74 readonly canOrganize: boolean;
75 readonly branch: string | null;
76 /**
77 * Project scoping for the thread list. Null on surfaces with no scoped
78 * list behind the menu (the chat header), where the item must not show.
79 */
80 readonly projectFilter: {
81 readonly label: string;
82 /** True when the list is already scoped to this thread's project. */
83 readonly isActive: boolean;
84 } | null;
85 readonly isPinned: boolean;
86 readonly isSettled: boolean;
87 /** False while the user has turned automatic settlement off for this thread. */
88 readonly autoSettleEnabled: boolean;
89 readonly isSnoozed: boolean;
90 readonly canSnoozeNow: boolean;
91 readonly isRegeneratingTitle: boolean;
92 /** Archive rejects a thread with an attached provider, so disable it here rather than let the action fail. */
93 readonly isRunning: boolean;
94 readonly supports: {
95 readonly settlement: boolean;
96 /** Server understands thread.auto-settle.set. */
97 readonly autoSettleOptOut: boolean;
98 readonly snooze: boolean;
99 readonly pinning: boolean;
100 readonly titleRegeneration: boolean;
101 };
102 readonly snoozePresets: ReadonlyArray<SnoozePreset>;
103}
104
105/** Local navigation, read markers, and copying remain available to read-only clients. */
106export function threadActionRequiresOperate(action: ThreadActionMenuId): boolean {
107 return ![
108 "new-thread-on-branch",
109 "project-settings",
110 "mark-unread",
111 "copy",
112 "copy-path",
113 "copy-branch",
114 "copy-thread-id",
115 ].includes(action);
116}
117
118export function threadActionIsOrganization(action: ThreadActionMenuId): boolean {
119 return (
120 ["pin", "unpin", "settle", "unsettle", "unsnooze", "snooze", "auto-settle"].includes(action) ||
121 action.startsWith("snooze:") ||
122 action.startsWith("auto-settle:")
123 );
124}
125
126/**
127 * Single source for the per-thread action menu: the sidebar row's right-click
128 * menu and the chat header menu share labels, ordering, and capability gating.
129 * Each surface supplies state for the actions it supports.
130 */
131export function buildThreadActionMenuItems(
132 state: ThreadActionMenuState,
133): ReadonlyArray<ContextMenuItem<ThreadActionMenuId>> {
134 const items: ReadonlyArray<ContextMenuItem<ThreadActionMenuId>> = [
135 ...(state.branch
136 ? [
137 {
138 id: "new-thread-on-branch" as const,
139 label: `New thread on ${state.branch}`,
140 icon: "message-square-plus",
141 },
142 ]
143 : []),
144 ...(state.supports.pinning
145 ? [
146 state.isPinned
147 ? { id: "unpin" as const, label: "Unpin thread", icon: "pin-off" }
148 : { id: "pin" as const, label: "Pin thread", icon: "pin" },
149 ]
150 : []),
151 // Both lifecycle actions stay available on pinned threads: settling
152 // clears the pin ("done" beats "keep on top"), and snoozing hides the
153 // card until wake with the pin intact.
154 ...(state.supports.settlement
155 ? [
156 state.isSettled
157 ? { id: "unsettle" as const, label: "Un-settle thread", icon: "circle-check" }
158 : { id: "settle" as const, label: "Settle thread", icon: "circle-check" },
159 ]
160 : []),
161 ...(state.supports.snooze
162 ? [
163 state.isSnoozed
164 ? { id: "unsnooze" as const, label: "Wake thread", icon: "clock" }
165 : {
166 id: "snooze" as const,
167 label: "Snooze",
168 icon: "clock",
169 disabled: !state.canSnoozeNow,
170 children: [
171 ...state.snoozePresets.map((preset) => ({
172 id: `snooze:${preset.id}` as const,
173 label: `${preset.label} (${preset.whenLabel})`,
174 })),
175 { id: "snooze:custom" as const, label: "Custom…", separatorBefore: true },
176 ],
177 },
178 ]
179 : []),
180 { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true },
181 ...(state.supports.titleRegeneration
182 ? [
183 {
184 id: "regenerate-title" as const,
185 label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title",
186 icon: "refresh-cw",
187 disabled: state.isRegeneratingTitle,
188 },
189 ]
190 : []),
191 { id: "mark-unread", label: "Mark unread", icon: "mail-open" },
192 ...(state.projectFilter
193 ? [
194 {
195 id: "filter-by-project" as const,
196 label: state.projectFilter.isActive
197 ? "Show all projects"
198 : `Filter by ${state.projectFilter.label}`,
199 icon: "folder-tree",
200 },
201 ]
202 : []),
203 // A submenu with the current option checked, not a one-shot action:
204 // this is a setting, and it sits with the other per-thread settings
205 // rather than the lifecycle verbs above. Disabled keeps long-running
206 // threads out of the settled shelf no matter how quiet they get.
207 ...(state.supports.autoSettleOptOut
208 ? [
209 {
210 id: "auto-settle" as const,
211 label: "Auto-settle behavior",
212 icon: "timer",
213 children: [
214 {
215 id: "auto-settle:enabled" as const,
216 label: "Enabled",
217 checked: state.autoSettleEnabled,
218 },
219 {
220 id: "auto-settle:disabled" as const,
221 label: "Disabled",
222 checked: !state.autoSettleEnabled,
223 },
224 ],
225 },
226 ]
227 : []),
228 {
229 id: "copy",
230 label: "Copy",
231 icon: "copy",
232 separatorBefore: true,
233 children: [
234 { id: "copy-path", label: "Path", icon: "folder" },
235 ...(state.branch
236 ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }]
237 : []),
238 { id: "copy-thread-id", label: "Thread ID", icon: "hash" },
239 ],
240 },
241 { id: "project-settings", label: "Project settings", icon: "settings" },
242 // Archive removes the thread from the sidebar while keeping its
243 // conversation under Settings > Archived threads — distinct from Settle
244 // (stays visible in the Settled shelf) and Delete (clears history for
245 // good), so it sits beside Delete without borrowing its destructive
246 // styling.
247 {
248 id: "archive",
249 label: "Archive thread",
250 icon: "archive",
251 disabled: state.isRunning,
252 separatorBefore: true,
253 },
254 {
255 id: "delete",
256 label: "Delete",
257 destructive: true,
258 icon: "trash",
259 },
260 ];
261 return items.map((item) =>
262 threadActionRequiresOperate(item.id) &&
263 !(threadActionIsOrganization(item.id) ? state.canOrganize : state.canOperate)
264 ? {
265 ...item,
266 disabled: true,
267 ...(item.children
268 ? { children: item.children.map((child) => ({ ...child, disabled: true })) }
269 : {}),
270 }
271 : item,
272 );
273}