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 (
)}
{/* Markdown toolbar — optional, shown at top of the input box */}
{showMarkdownToolbar && (
)}
{/* Action bar — attachment buttons on the left, Send on the right.
Hidden entirely when there's nothing to show (no send, no attach). */}
{(showSend || showFileButton || showImageButton) && (