'use client'; import { Fragment, memo, useMemo, type ReactNode } from 'react'; import { Copy, Pencil, RefreshCw, Trash } from 'lucide-react'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from '@djangocfg/ui-core/components'; import type { ChatMessage, ChatRole } from '../types'; /** * A single entry in a message bubble's right-click / long-press context * menu. Typed + serializable-ish (icon is a ReactNode, the rest is data). * * Hosts inject extra items — "Reveal in Finder", "Copy as markdown", * "Inspect" — by passing `bubbleMenuItems` / `getBubbleMenuItems` on * `` (and the matching ``). * The built-in Copy / Regenerate / Delete actions are produced as default * items by `buildDefaultBubbleMenuItems` so a host can keep, reorder, or * fully replace them. */ export interface BubbleMenuItem { /** Stable id — used as the React key. */ id: string; label: string; icon?: ReactNode; /** Fires on click / Enter. Receives the message the menu opened on. */ onSelect: (message: ChatMessage) => void; /** Red destructive styling (e.g. Delete). */ danger?: boolean; /** Render a divider BEFORE this item. */ separator?: boolean; disabled?: boolean; } /** * Handlers the default Copy / Regenerate / Delete items wire to. Mirrors * the existing `` props so a host can keep passing the same * callbacks it always did and get a context menu instead of a button row. * * Role-gated to match the old button row: Regenerate shows only on * assistant turns, Edit only on user turns, and items whose handler is * absent are dropped. */ export interface DefaultBubbleMenuHandlers { role: ChatRole; onCopy?: () => void; onRegenerate?: () => void; /** * Fires the host's composer-edit trigger for a USER message — gated to * `role === 'user'` (mirrors the assistant-only Regenerate gate). This * does NOT edit in the bubble: the item just hands the message to the * host, which loads its text into the real composer and re-runs the turn * via `chat.editMessage` on submit. Absent → no Edit item. */ onEdit?: () => void; onDelete?: () => void; } /** * Build the built-in Copy / Regenerate / Delete items as `BubbleMenuItem`s. * Hosts compose these with their own extras (see `composeBubbleMenuItems`) * — keep them, drop one, reorder, or ignore them entirely and pass a fully * custom list. An item is omitted when its handler is undefined; Regenerate * is additionally gated to assistant turns. */ export function buildDefaultBubbleMenuItems({ role, onCopy, onRegenerate, onEdit, onDelete, }: DefaultBubbleMenuHandlers): BubbleMenuItem[] { const items: BubbleMenuItem[] = []; if (onCopy) { items.push({ id: 'copy', label: 'Copy', icon: , onSelect: () => onCopy(), }); } if (onRegenerate && role === 'assistant') { items.push({ id: 'regenerate', label: 'Regenerate', icon: , onSelect: () => onRegenerate(), }); } // Edit — user turns only (mirrors the assistant-only Regenerate gate). // Fires the host trigger; the actual edit happens in the composer. if (onEdit && role === 'user') { items.push({ id: 'edit', label: 'Edit', icon: , onSelect: () => onEdit(), }); } if (onDelete) { items.push({ id: 'delete', label: 'Delete', icon: , onSelect: () => onDelete(), danger: true, // Divide built-in destructive action from the rest only when it // isn't the very first item (e.g. a copy-only user bubble). separator: items.length > 0, }); } return items; } /** * Merge the built-in default items with host-provided extras. Defaults * come first, then a divider, then host items — unless the host's first * extra already declares its own `separator`. Either side may be empty. */ export function composeBubbleMenuItems( defaults: BubbleMenuItem[], hostItems?: BubbleMenuItem[], ): BubbleMenuItem[] { if (!hostItems?.length) return defaults; if (!defaults.length) return hostItems; const [first, ...restHost] = hostItems; const bridged: BubbleMenuItem = first.separator ? first : { ...first, separator: true }; return [...defaults, bridged, ...restHost]; } export interface BubbleContextMenuProps { message: ChatMessage; items: BubbleMenuItem[]; children: ReactNode; /** Wrapper className (defaults to `contents` — adds no layout box). */ className?: string; } /** * BubbleContextMenu — wraps a bubble in a Radix context menu (right-click * + touch long-press, Esc / arrow-key accessible, all handled by the * primitive). Renders `items` as menu rows. * * No items → renders `children` bare (no trigger overhead). The wrapper is * `display:contents` by default so it never perturbs the bubble's layout. */ function BubbleContextMenuInner({ message, items, children, className = 'contents', }: BubbleContextMenuProps) { if (items.length === 0) return <>{children}; return ( {children} {items.map((item) => ( {item.separator ? : null} item.onSelect(message)} > {item.icon} {item.label} ))} ); } export const BubbleContextMenu = memo(BubbleContextMenuInner); BubbleContextMenu.displayName = 'BubbleContextMenu'; /** * Resolve the effective menu items for a bubble — combines a static list * and/or a per-message builder with the built-in defaults. Used by * `` so callers can pass any of: * - nothing → defaults only * - `bubbleMenuItems` (static host extras) → defaults + extras * - `getBubbleMenuItems(message, defaults)` → host owns the full list */ export function useResolvedBubbleMenuItems( message: ChatMessage, defaults: BubbleMenuItem[], bubbleMenuItems?: BubbleMenuItem[], getBubbleMenuItems?: (message: ChatMessage, defaults: BubbleMenuItem[]) => BubbleMenuItem[], ): BubbleMenuItem[] { return useMemo(() => { if (getBubbleMenuItems) return getBubbleMenuItems(message, defaults); return composeBubbleMenuItems(defaults, bubbleMenuItems); }, [message, defaults, bubbleMenuItems, getBubbleMenuItems]); }