'use client'; import type { ComponentType, ReactNode } from 'react'; import type { UseChatComposerReturn } from '../hooks/useChatComposer'; import type { ChatAttachment } from '../types'; import type { SlashConfig } from './slash/types'; /** Composer visual size — shared across the `` API. */ export type ComposerSize = 'sm' | 'md' | 'lg'; /** * Spaciousness of the input surface — orthogonal to `size`: * - `compact` — default; embedded composer (FAB panel, dock). * - `full` — full-page chat; roomier surface, bigger textarea + padding. */ export type ComposerAppearance = 'compact' | 'full'; /** * Geometry of the input surface: * - `stacked` — textarea on top, action bar pinned below, one bordered surface. * - `inline` — single horizontal row (compact; `size="sm"` defaults to this). */ export type ComposerLayout = 'stacked' | 'inline'; /** Conditional visibility of an action, evaluated against composer state. */ export type ComposerActionVisibility = | 'streaming' | 'empty' | 'hasText' | 'disabled'; /** * Declarative descriptor for one action-bar button (Tier A). The Composer * renders these into consistent ``s — size, aria and tooltip * are handled centrally so hosts cannot misalign anything. */ export interface ComposerAction { /** Stable key. */ id: string; /** Lucide icon element. */ icon: ReactNode; /** Used as `aria-label` and tooltip — required for a11y. */ label: string; onClick: () => void; disabled?: boolean; /** Toggle state → emits `aria-pressed`. */ pressed?: boolean; /** `send` — theme-inverting filled circle (ChatGPT send button). */ variant?: 'ghost' | 'secondary' | 'primary' | 'send'; /** Hide the action when the composer is in this state. */ hideWhen?: ComposerActionVisibility; /** Sort weight within its cluster (ascending). Built-ins reserve high values. */ order?: number; } /** * Tier A — declarative slot arrays + raw-node escape hatches. Hosts supply * action descriptors; the Composer owns the rendering. */ export interface ComposerSlots { /** Bottom-left cluster. Built-in attach prepends here when enabled. */ actionsStart?: ComposerAction[]; /** Bottom-right cluster. Built-in mic/send append here. */ actionsEnd?: ComposerAction[]; /** Full-width row above the textarea (reply banner, custom tray). */ blockStart?: ReactNode; /** Raw nodes placed left of the textarea in the `inline` layout. */ inlineStart?: ReactNode; /** Raw nodes placed right of the textarea in the `inline` layout. */ inlineEnd?: ReactNode; /** * Slash-command surface. When provided, the composer mounts an * internal `` as a floating popover anchored above the * input surface and routes ↑/↓/Enter/Tab/Esc to the slash hook * while the menu is open. See `./slash/README.md`. */ slashCommands?: SlashConfig; } // ── Tier B — full slot replacement ───────────────────────────────────────── export interface SendButtonProps { /** True while a reply is streaming — render the stop affordance. */ streaming: boolean; disabled: boolean; size: ComposerSize; onSend: () => void; onCancel?: () => void; } export interface AttachButtonProps { disabled: boolean; size: ComposerSize; onClick: () => void; } export interface ComposerTextareaProps { composer: UseChatComposerReturn; placeholder: string; disabled: boolean; size: ComposerSize; className?: string; } export interface ActionBarProps { /** Already filtered + sorted by `useComposerActions`. */ actionsStart: ComposerAction[]; actionsEnd: ComposerAction[]; size: ComposerSize; layout: ComposerLayout; inlineStart?: ReactNode; inlineEnd?: ReactNode; } /** Tier B — swap a primitive entirely. Each is optional. */ export interface ComposerSlotComponents { SendButton?: ComponentType; AttachButton?: ComponentType; Textarea?: ComponentType; /** Replace the whole `blockEnd` action-bar row. */ ActionBar?: ComponentType; } /** Per-slot prop overrides forwarded to the built-in primitives. */ export interface ComposerSlotProps { send?: Partial; attach?: Partial; textarea?: { className?: string }; actionBar?: { className?: string }; } // ── Footer ───────────────────────────────────────────────────────────────── /** Props for the strip below the input surface. */ export interface ComposerFooterProps { start?: ReactNode; center?: ReactNode; end?: ReactNode; /** Auto char counter — reads `composer.value` + `maxLength`. */ showCounter?: boolean; /** Auto keyboard-shortcut hint — reads `submitOn`. @default false */ showHint?: boolean; /** Live value, for the counter. */ value?: string; /** Character cap, for the counter. */ maxLength?: number; /** Submit binding, for the hint. */ submitOn?: 'enter' | 'cmd+enter'; size?: ComposerSize; className?: string; } // ── Attach pipeline ───────────────────────────────────────────────────────── /** Asset categories the picker accepts. Maps to MIME prefixes / extensions. */ export type ComposerAcceptType = 'image' | 'audio' | 'video' | 'document'; /** * Result a host's `uploadFn` resolves to — the remote location the * attachment's `url` is rewritten to once the upload completes. */ export interface ComposerUploadResult { url: string; thumbnailUrl?: string; } /** * Optional upload transport. The single environment-specific seam: * - omitted → attachments stay local (`blob:` object-URL, `status:'ready'`). * - web → multipart POST to a CDN. * - Wails → a Wails-bound upload method. * * `onProgress` receives 0..1; call it to drive the staging-tray progress. */ export type ComposerUploadFn = ( file: File, onProgress: (fraction: number) => void, ) => Promise; /** * Declarative config for the built-in attach pipeline. When present (or * when `showAttachmentButton` is set), the composer mounts its own file * picker — paperclip, `+` menu, drag-drop and paste all funnel through it. */ export interface ComposerAttachConfig { /** Accepted asset categories. Default: all four. */ accept?: ComposerAcceptType[]; /** Max files per message. Falls back to the composer's `maxAttachments`. */ maxFiles?: number; /** Per-file size cap in bytes. Default: unlimited. */ maxSizeBytes?: number; /** Allow selecting more than one file at once. Default `true`. */ multiple?: boolean; /** Upload transport — see {@link ComposerUploadFn}. */ uploadFn?: ComposerUploadFn; /** Enable Ctrl+V / Cmd+V paste-to-attach. Default `true`. */ pasteEnabled?: boolean; /** Called when a file is rejected (size / type / count). */ onReject?: (file: File, reason: 'size' | 'type' | 'count') => void; /** * Convert a long plain-text paste (⌘V / Ctrl+V) into a "Pasted text" * attachment chip instead of dumping it into the textarea — the * ChatGPT/Claude behaviour. Default `true`. Short pastes (below * {@link pasteTextThreshold}) always go straight into the textarea. */ pasteTextAsChunk?: boolean; /** * Character threshold above which a plain-text paste becomes a chunk. * Default `2000` (matches Claude / LibreChat). Ignored when * `pasteTextAsChunk` is `false`. */ pasteTextThreshold?: number; } /** Imperative handle the attach pipeline exposes to the composer + menu. */ export interface ComposerAttachHandle { /** Open the native file picker. */ openPicker: () => void; /** Funnel already-resolved files (drop / paste) through validation. */ attachFiles: (files: File[]) => void; /** Remove a staged attachment, revoking its object-URL first. */ removeAttachment: (id: string) => void; /** True when attaching is unavailable (disabled / at file cap). */ disabled: boolean; /** Hidden `` props — the composer renders this node. */ inputProps: { ref: React.RefObject; type: 'file'; accept: string; multiple: boolean; hidden: true; onChange: (e: React.ChangeEvent) => void; }; }