| 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 | | "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 | |
| 33 | export 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. */ |
| 41 | export 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 | |
| 72 | export 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. */ |
| 106 | export 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 | |
| 118 | export 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 | */ |
| 131 | export 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 | } |