import * as React from "react"; import { flushSync } from "react-dom"; import { Bold, Code, Code2, ImagePlus, Italic, type LucideIcon, Paperclip, Send, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; /** * ChatInputArea — WealthX Design System * * General-purpose chat input used across any feature that involves * a conversation or messaging interface (Policy AI, Support Agent, * AI Conversations, Website Chat Widget, etc.). * * Features: * - Textarea with auto-resize up to `maxHeight` * - Enter to send / Shift+Enter for new line * - Optional markdown formatting toolbar (`showMarkdownToolbar`) * - Optional file attachment (Paperclip) — shown only when `onAttachFile` is provided * - Optional image upload (ImagePlus) — shown only when `onAttachImage` is provided * - Focus ring on outer container via `focus-within` * - Fully disabled during streaming / loading states * * @example * */ /** A taggable entry for the `@mention` autocomplete. */ export interface ChatMention { /** Stable id of the mentioned entity. This, not the label, is what is stored. */ id: string; /** Shown in the picker and as the chip text. */ label: string; /** Secondary text shown in the picker (e.g. role / email). */ sublabel?: string; } export interface ChatInputAreaProps { /** Controlled text value. */ value: string; /** Called on every keystroke. */ onChange: (value: string) => void; /** * Called when the user submits (Enter key or Send button click). * Receives the trimmed text. Not called when value is empty or when disabled. */ onSend: (value: string) => void; /** * When provided, a Paperclip button appears and this callback is fired * with the selected FileList. Hidden when omitted. */ onAttachFile?: (files: FileList) => void; /** * When provided, an ImagePlus button appears and this callback is fired * with the selected image FileList. Hidden when omitted. */ onAttachImage?: (files: FileList) => void; /** Disables all controls — use while streaming / waiting for a response. */ disabled?: boolean; /** Textarea placeholder text. */ placeholder?: string; /** * Hint text rendered below the input box. * Pass `false` to hide it entirely. * Defaults to "Enter to send · Shift+Enter for new line". */ hint?: string | false; /** * Maximum textarea height in pixels before scrolling kicks in. * @default 160 */ maxHeight?: number; /** Focus the textarea on mount. */ autoFocus?: boolean; /** * Show a markdown formatting toolbar (Bold, Italic, Code, Code block) * above the textarea. Wraps selected text or inserts a placeholder. * @default false */ showMarkdownToolbar?: boolean; /** * Show the Send button (and make Enter submit). Set `false` for auto-saving * editors (e.g. notes) where there is no explicit "send" — the Send button is * hidden and Enter inserts a new line instead of submitting. * @default true */ showSend?: boolean; /** * Fill the available height instead of auto-resizing to content. Use inside a * flex container for a full-height editor (e.g. a notes panel). * @default false */ fill?: boolean; /** * Taggable entries for `@mention` autocomplete. When provided, typing `@` * followed by a query opens a picker; navigate with ↑/↓, pick with Enter or * click, and the selected entry is inserted as the markdown link * `[@label](#staff-id)`. * * The id is what gets stored, so a mention keeps pointing at the same person * after a rename and stays unambiguous when two people share a display name. * `MarkdownContent` renders these back as mention chips. */ mentions?: ChatMention[]; className?: string; } const DEFAULT_HINT = "Enter to send · Shift+Enter for new line"; /** Trailing `@` token at the caret that drives mention autocomplete. */ const MENTION_RE = /@([\w'\- ]{0,40})$/; /** * Serialise a picked mention as `[@label](#staff-id)`. * * A bare `#fragment` href is deliberate: `MarkdownContent` sanitises with the default * schema, which strips any href whose protocol is not on its allowlist — so a custom * `staff:` scheme would be silently dropped, while a fragment passes untouched. Brackets * in the label are escaped so a name cannot break out of the link syntax. */ const mentionToken = (id: string, label: string): string => `[@${label.replace(/[[\]]/g, "\\$&")}](#staff-${id})`; // --------------------------------------------------------------------------- // Markdown toolbar // --------------------------------------------------------------------------- type ToolbarItem = | { type: "button"; icon: LucideIcon; label: string; title: string; before: string; after: string; placeholder: string; } | { type: "divider" }; /** Static config — defined once, no per-render allocation. */ const TOOLBAR_ITEMS: ToolbarItem[] = [ { type: "button", icon: Bold, label: "Bold", title: "Bold (Ctrl+B)", before: "**", after: "**", placeholder: "bold text", }, { type: "button", icon: Italic, label: "Italic", title: "Italic (Ctrl+I)", before: "*", after: "*", placeholder: "italic text", }, { type: "button", icon: Code, label: "Inline code", title: "Inline code", before: "`", after: "`", placeholder: "code", }, { type: "divider" }, { type: "button", icon: Code2, label: "Code block", title: "Code block", before: "```\n", after: "\n```", placeholder: "code block", }, ]; /** * Wraps the current selection (or inserts a placeholder) with markdown syntax. * Uses flushSync so the selection can be restored synchronously after the * controlled value update — no setTimeout timing hack needed. */ function applyMarkdown( textarea: HTMLTextAreaElement, before: string, after: string, placeholder: string, onChange: (value: string) => void, ) { const start = textarea.selectionStart; const end = textarea.selectionEnd; const selected = textarea.value.slice(start, end); const insertion = selected || placeholder; const next = textarea.value.slice(0, start) + before + insertion + after + textarea.value.slice(end); const newStart = start + before.length; const newEnd = newStart + insertion.length; // flushSync forces React to flush the state update synchronously so we can // restore selection immediately — React-blessed alternative to setTimeout. flushSync(() => onChange(next)); textarea.focus(); textarea.setSelectionRange(newStart, newEnd); } interface MarkdownToolbarProps { textareaRef: React.RefObject; onChange: (value: string) => void; disabled?: boolean; } /** Memoised — does not re-render on every parent keystroke. */ const MarkdownToolbar = React.memo(function MarkdownToolbar({ textareaRef, onChange, disabled, }: MarkdownToolbarProps) { // Single stable handler — reads format tokens from data attributes. const handleFormat = React.useCallback( (e: React.MouseEvent) => { if (!textareaRef.current) return; const { before, after, placeholder } = e.currentTarget.dataset as { before: string; after: string; placeholder: string; }; applyMarkdown(textareaRef.current, before, after, placeholder, onChange); }, [textareaRef, onChange], ); return (
{TOOLBAR_ITEMS.map((item, i) => item.type === "divider" ? (
); }); // --------------------------------------------------------------------------- // ChatInputArea // --------------------------------------------------------------------------- export function ChatInputArea({ value, onChange, onSend, onAttachFile, onAttachImage, disabled = false, placeholder = "Type your message…", hint = DEFAULT_HINT, maxHeight = 160, autoFocus = false, showMarkdownToolbar = false, showSend = true, fill = false, mentions = [], className, }: ChatInputAreaProps) { const textareaRef = React.useRef(null); const fileInputRef = React.useRef(null); const imageInputRef = React.useRef(null); // @mention autocomplete — `query` is the trailing token after `@`, null = closed. const [mentionQuery, setMentionQuery] = React.useState(null); const [mentionIndex, setMentionIndex] = React.useState(0); const mentionMatches = React.useMemo(() => { if (mentionQuery === null || mentions.length === 0) return []; const q = mentionQuery.toLowerCase(); return mentions .filter((m) => m.label.toLowerCase().includes(q)) .slice(0, 8); }, [mentionQuery, mentions]); const detectMention = React.useCallback( (text: string) => { if (mentions.length === 0) return; const m = text.match(MENTION_RE); setMentionQuery(m ? m[1] : null); setMentionIndex(0); }, [mentions.length], ); const selectMention = React.useCallback( (option: ChatMention) => { onChange( value.replace(MENTION_RE, `${mentionToken(option.id, option.label)} `), ); setMentionQuery(null); textareaRef.current?.focus(); }, [value, onChange], ); // Focus on mount when autoFocus is requested React.useEffect(() => { if (autoFocus) { setTimeout(() => textareaRef.current?.focus(), 50); } }, [autoFocus]); const handleSend = React.useCallback(() => { const text = value.trim(); if (!text || disabled) return; onSend(text); // Reset textarea height after clearing (caller is responsible for clearing value) if (textareaRef.current) { textareaRef.current.style.height = "auto"; } }, [value, disabled, onSend]); const handleKeyDown = React.useCallback( (e: React.KeyboardEvent) => { // While the mention picker is open it takes over ↑/↓/Enter/Escape. if (mentionMatches.length > 0) { const last = mentionMatches.length - 1; if (e.key === "ArrowDown") { e.preventDefault(); setMentionIndex((i) => (i >= last ? 0 : i + 1)); return; } if (e.key === "ArrowUp") { e.preventDefault(); setMentionIndex((i) => (i <= 0 ? last : i - 1)); return; } if (e.key === "Enter") { e.preventDefault(); selectMention(mentionMatches[mentionIndex] ?? mentionMatches[0]!); return; } if (e.key === "Escape") { e.preventDefault(); setMentionQuery(null); return; } } // Enter submits only when there is a Send action; otherwise it's a newline. if (showSend && e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }, [handleSend, showSend, mentionMatches, mentionIndex, selectMention], ); const handleTextareaChange = React.useCallback( (e: React.ChangeEvent) => { onChange(e.target.value); detectMention(e.target.value); // Auto-resize to content (skipped in `fill` mode where the textarea // flex-fills its container instead). if (fill) return; const el = e.target; el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`; }, [onChange, maxHeight, fill, detectMention], ); const handleFileChange = React.useCallback( (e: React.ChangeEvent) => { if (e.target.files?.length) { onAttachFile?.(e.target.files); e.target.value = ""; } }, [onAttachFile], ); const handleImageChange = React.useCallback( (e: React.ChangeEvent) => { if (e.target.files?.length) { onAttachImage?.(e.target.files); e.target.value = ""; } }, [onAttachImage], ); const showFileButton = typeof onAttachFile === "function"; const showImageButton = typeof onAttachImage === "function"; return (
{/* Unified input box — toolbar + textarea + action bar share one border */}
{/* @mention picker — floats above the input box */} {mentionMatches.length > 0 && (
    {mentionMatches.map((m, i) => (
  • ))}
)} {/* Markdown toolbar — optional, shown at top of the input box */} {showMarkdownToolbar && ( )}