'use client'; import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'; import { Textarea } from '@djangocfg/ui-core/components'; import { cn } from '@djangocfg/ui-core/lib'; import { attachComposer } from '@djangocfg/ui-tools/composer-registry'; import { useChatContextOptional } from '../context'; import type { UseChatComposerReturn } from '../hooks/useChatComposer'; import { Attachments } from '../messages/Attachments'; import { AttachProvider } from './AttachContext'; import { ComposerActionBar } from './ComposerActionBar'; import { ComposerButton } from './ComposerButton'; import { ComposerFooter } from './ComposerFooter'; import { ComposerSizeProvider } from './size-context'; import { SlashHighlightTextarea } from './slash/SlashHighlightTextarea'; import { SlashMenu } from './slash/SlashMenu'; import { useSlashCommands } from './slash/useSlashCommands'; import { useComposerActions } from './useComposerActions'; import { useComposerAttach } from './useComposerAttach'; import type { ComposerAppearance, ComposerAttachConfig, ComposerFooterProps, ComposerLayout, ComposerSize, ComposerSlotComponents, ComposerSlotProps, ComposerSlots, } from './types'; export type { ComposerSize, ComposerAppearance } from './types'; export interface ComposerProps { composer: UseChatComposerReturn; placeholder?: string; disabled?: boolean; /** Render the paperclip attach button. The composer ships a built-in * file picker — paperclip, `+` menu, drag-drop and Ctrl+V paste all * funnel through one validated pipeline. */ showAttachmentButton?: boolean; /** Tune the built-in attach pipeline — accepted types, size / count * caps, paste, and the optional upload transport. */ attach?: ComposerAttachConfig; /** Override the built-in file picker. When set, the paperclip and the * `+`-menu attach item call this instead of opening the native picker * — for hosts that drive their own (e.g. a Wails native dialog). */ onPickFiles?: () => void; className?: string; textareaClassName?: string; /** Visual size — controls textarea height + button slot size. * * - ``sm`` — dense compact composer (admin sidebars, etc); defaults * to the `inline` single-row layout. * - ``md`` — default. Stacked layout (textarea + action bar). * - ``lg`` — generous textarea. Use when the chat is the page's * primary surface (onboarding, support). */ size?: ComposerSize; /** Spaciousness of the surface — orthogonal to `size`. * * - ``compact`` — default; embedded composer (FAB panel, dock). * - ``full`` — full-page chat; roomier surface, taller textarea, * larger padding + radius. The host owns the centered max-width * container; the composer just makes itself more spacious. */ appearance?: ComposerAppearance; /** Show "Stop" button instead of "Send" while streaming. */ isStreaming?: boolean; onCancel?: () => void; // ── Slot system ────────────────────────────────────────────────────────── /** Layout geometry. Default `stacked`; `size="sm"` falls back to `inline`. */ layout?: ComposerLayout; /** Tier A — declarative action descriptors + raw-node escape hatches. */ composerSlots?: ComposerSlots; /** Tier B — replace a primitive entirely. */ slots?: ComposerSlotComponents; /** Per-slot prop overrides for the built-in primitives. */ slotProps?: ComposerSlotProps; /** Telegram-style mic↔send swap. Default `true`. */ micSendSwap?: boolean; /** Footer toolbar config below the input surface. `false` hides it. */ footer?: ComposerFooterProps | false; } const SIZE_CLASSES: Record = { sm: { // Symmetric corner inset — px == py so the trailing send button sits // equidistant from the surface's right and bottom edges. surface: 'rounded-xl px-2 py-2', textarea: 'min-h-7 max-h-44 px-1.5 py-1', text: 'text-sm', containerPadding: 'px-2 pt-1.5 pb-[max(0.375rem,env(safe-area-inset-bottom))]', inlineSlot: '[&>:not(textarea)]:h-7', }, md: { // Symmetric corner inset — px == py so the trailing send button sits // equidistant from the surface's right and bottom edges (was py-1.5, // which left the button closer to the bottom than the right). surface: 'rounded-2xl px-2 py-2', // ~one-line tall (min-h-8) with tight, balanced vertical padding. // `py-1.5` (6px) stacked under the surface's own `py-1.5` read like a // phantom empty line under a single-line input — `py-1` keeps the // text vertically centred without the extra bottom gap. textarea: 'min-h-8 max-h-60 px-2 py-1', // Comfortable 15px chat body. `text-base sm:text-sm` collapsed to // 14px and read too small; 15px matches the rich-editor baseline. text: 'text-[15px]', containerPadding: 'px-2.5 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]', inlineSlot: '[&>:not(textarea)]:h-9', }, lg: { // Symmetric corner inset — px == py so the trailing send button sits // equidistant from the surface's right and bottom edges. surface: 'rounded-2xl px-3 py-3', textarea: 'min-h-12 max-h-72 px-2.5 py-2', text: 'text-base', containerPadding: 'px-3.5 pt-3 pb-[max(0.875rem,env(safe-area-inset-bottom))]', inlineSlot: '[&>:not(textarea)]:h-12', }, }; /** Per-size cluster gap for the stacked grid layout. */ const GAP: Record = { sm: 'gap-0.5', md: 'gap-1', lg: 'gap-1.5', }; /** Extra spaciousness layered on top of `size` when `appearance="full"`. * Empty strings for `compact` keep the current geometry untouched. */ const APPEARANCE_CLASSES: Record = { compact: { surface: '', textarea: '', text: '', containerPadding: '' }, full: { // Symmetric corner inset — px == py so the trailing send button sits // equidistant from the surface's right and bottom edges. surface: 'rounded-3xl px-3 py-3', textarea: 'min-h-10 max-h-80 px-2.5 py-2', text: 'text-base', containerPadding: 'px-4 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]', }, }; export const Composer = forwardRef(function Composer( { composer, placeholder = 'Type a message...', disabled, showAttachmentButton = false, attach, onPickFiles, className, textareaClassName, size = 'md', appearance = 'compact', isStreaming: isStreamingProp, onCancel: onCancelProp, layout: layoutProp, composerSlots, slots, slotProps, micSendSwap = true, footer, }, ref, ) { const ctx = useChatContextOptional(); const isStreaming = isStreamingProp ?? ctx?.isStreaming ?? false; const onCancel = onCancelProp ?? ctx?.cancelStream; const isDisabled = disabled ?? isStreaming; const sz = SIZE_CLASSES[size]; // `appearance` is orthogonal to `size` — `full` layers extra // spaciousness (radius, padding, textarea height) over the chosen size. const ap = APPEARANCE_CLASSES[appearance]; // `size="sm"` defaults to the single-row `inline` layout unless the // host overrides it explicitly — see §3.1 of the redesign doc. const layout: ComposerLayout = layoutProp ?? (size === 'sm' ? 'inline' : 'stacked'); // Publish the composer's imperative handle to the cross-tool // composer registry (`@djangocfg/ui-tools/composer-registry`). // Consumers (useAutoFocusOnStreamEnd, VoiceComposerSlot) read it // from there — works the same inside or outside a ChatProvider. const composerFocus = composer.focus; const composerSetValue = composer.setValue; const textareaRef = composer.textareaRef; const getValueRef = useRef<() => string>(() => composer.value); getValueRef.current = () => composer.value; // A custom `Textarea` slot (e.g. the TipTap ``) // has no `