import type { ContextMenuItem } from "@t3tools/contracts"; import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; /** * Ids for the per-thread action menu. Snooze presets are dispatched as * `snooze:` so the union stays closed while the preset list * remains data-driven. */ export type ThreadActionMenuId = | "new-thread-on-branch" | "project-settings" | "pin" | "unpin" | "settle" | "unsettle" | "snooze" | `snooze:${string}` | "unsnooze" | "rename" | "regenerate-title" | "mark-unread" | "copy" | "copy-path" | "copy-branch" | "copy-thread-id" | "archive" | "delete"; export interface ThreadActionMenuState { readonly branch: string | null; readonly isPinned: boolean; readonly isSettled: boolean; readonly isSnoozed: boolean; readonly canSnoozeNow: boolean; readonly isRegeneratingTitle: boolean; /** Archive rejects a thread with an active turn, so disable it here rather than let the action fail. */ readonly isRunning: boolean; readonly supports: { readonly settlement: boolean; readonly snooze: boolean; readonly pinning: boolean; readonly titleRegeneration: boolean; }; readonly snoozePresets: ReadonlyArray; } /** * Single source for the per-thread action menu: the sidebar row's right-click * menu and the chat header menu both render exactly this list, so labels, * ordering, and capability gating cannot drift between the two surfaces. */ export function buildThreadActionMenuItems( state: ThreadActionMenuState, ): ReadonlyArray> { return [ ...(state.branch ? [ { id: "new-thread-on-branch" as const, label: `New thread on ${state.branch}`, icon: "message-square-plus", }, ] : []), ...(state.supports.pinning ? [ state.isPinned ? { id: "unpin" as const, label: "Unpin thread", icon: "pin-off" } : { id: "pin" as const, label: "Pin thread", icon: "pin" }, ] : []), // Both lifecycle actions stay available on pinned threads: settling // clears the pin ("done" beats "keep on top"), and snoozing hides the // card until wake with the pin intact. ...(state.supports.settlement ? [ state.isSettled ? { id: "unsettle" as const, label: "Un-settle thread", icon: "circle-check" } : { id: "settle" as const, label: "Settle thread", icon: "circle-check" }, ] : []), ...(state.supports.snooze ? [ state.isSnoozed ? { id: "unsnooze" as const, label: "Wake thread", icon: "clock" } : { id: "snooze" as const, label: "Snooze", icon: "clock", disabled: !state.canSnoozeNow, children: state.snoozePresets.map((preset) => ({ id: `snooze:${preset.id}` as const, label: `${preset.label} (${preset.whenLabel})`, })), }, ] : []), { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true }, ...(state.supports.titleRegeneration ? [ { id: "regenerate-title" as const, label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title", icon: "refresh-cw", disabled: state.isRegeneratingTitle, }, ] : []), { id: "mark-unread", label: "Mark unread", icon: "mail-open" }, { id: "copy", label: "Copy", icon: "copy", separatorBefore: true, children: [ { id: "copy-path", label: "Path", icon: "folder" }, ...(state.branch ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }] : []), { id: "copy-thread-id", label: "Thread ID", icon: "hash" }, ], }, { id: "project-settings", label: "Project settings", icon: "settings" }, // Archive removes the thread from the sidebar while keeping its // conversation under Settings > Archived threads — distinct from Settle // (stays visible in the Settled shelf) and Delete (clears history for // good), so it sits beside Delete without borrowing its destructive // styling. { id: "archive", label: "Archive thread", icon: "archive", disabled: state.isRunning, separatorBefore: true, }, { id: "delete", label: "Delete", destructive: true, icon: "trash", }, ]; }