'use client'; import { useMemo } from 'react'; import { ArrowUp, Paperclip, Square } from 'lucide-react'; import type { UseChatComposerReturn } from '../hooks/useChatComposer'; import type { ComposerAction } from './types'; /** `order` values reserved for built-in actions so host extras land between. */ const ORDER = { attach: 100, hostStart: 200, hostEnd: 200, mic: 800, send: 900, } as const; export interface UseComposerActionsParams { composer: UseChatComposerReturn; isStreaming: boolean; isDisabled: boolean; /** Built-in attach action wiring. */ showAttachmentButton?: boolean; onPickFiles?: () => void; attachLabel?: string; /** Host-supplied declarative clusters. */ actionsStart?: ComposerAction[]; actionsEnd?: ComposerAction[]; /** Send / stop wiring. */ onSend: () => void; onCancel?: () => void; sendLabel?: string; stopLabel?: string; /** Telegram-style mic↔send swap. When true (default), mic and send never * show together — mic when the draft is empty, send once there is text. */ micSendSwap?: boolean; } export interface ComposerActionClusters { actionsStart: ComposerAction[]; actionsEnd: ComposerAction[]; } /** * Merges built-in send/stop/attach descriptors with host-provided arrays, * applies `hideWhen` against live composer state, resolves the mic↔send * swap, and sorts each cluster by `order`. */ export function useComposerActions(params: UseComposerActionsParams): ComposerActionClusters { const { composer, isStreaming, isDisabled, showAttachmentButton, onPickFiles, attachLabel = 'Attach files', actionsStart, actionsEnd, onSend, onCancel, sendLabel = 'Send', stopLabel = 'Stop', micSendSwap = true, } = params; const hasText = composer.canSubmit; const canSubmit = composer.canSubmit; return useMemo(() => { const start: ComposerAction[] = []; const end: ComposerAction[] = []; if (showAttachmentButton) { start.push({ id: 'attach', icon: , label: attachLabel, onClick: () => onPickFiles?.(), disabled: isDisabled, variant: 'ghost', order: ORDER.attach, }); } for (const a of actionsStart ?? []) { start.push({ order: ORDER.hostStart, ...a }); } for (const a of actionsEnd ?? []) { end.push({ order: ORDER.hostEnd, ...a }); } // Send / stop. While streaming → stop; otherwise → send. Both are // round, theme-inverting buttons (ChatGPT/Gemini style). if (isStreaming) { end.push({ id: 'stop', icon: , label: stopLabel, onClick: () => onCancel?.(), variant: 'send', order: ORDER.send, }); } else { end.push({ id: 'send', icon: , label: sendLabel, onClick: onSend, disabled: !canSubmit, variant: 'send', order: ORDER.send, }); } // Apply `hideWhen` against the live composer state. const visible = (a: ComposerAction): boolean => { switch (a.hideWhen) { case 'streaming': return !isStreaming; case 'empty': return hasText; case 'hasText': return !hasText; case 'disabled': return !isDisabled; default: return true; } }; let filteredEnd = end.filter(visible); const filteredStart = start.filter(visible); // Mic↔send swap: when there is text and a mic action exists, drop the // mic so only send shows. The host marks its mic with `id: 'mic'` // (or `hideWhen: 'hasText'`); this is a safety net for the common id. if (micSendSwap && hasText) { filteredEnd = filteredEnd.filter((a) => a.id !== 'mic'); } const byOrder = (a: ComposerAction, b: ComposerAction) => (a.order ?? 0) - (b.order ?? 0); return { actionsStart: filteredStart.sort(byOrder), actionsEnd: filteredEnd.sort(byOrder), }; }, [ showAttachmentButton, attachLabel, onPickFiles, isDisabled, actionsStart, actionsEnd, isStreaming, stopLabel, onCancel, sendLabel, onSend, canSubmit, hasText, micSendSwap, ]); }