"use client" import { useState, useRef, useImperativeHandle, forwardRef, useCallback, useEffect, useMemo, type ReactNode, type KeyboardEvent, type ClipboardEvent } from "react" import { renderToStaticMarkup } from "react-dom/server" import { cn } from "../../utils/cn" import { Send01Icon, StopCircleIcon } from "../icons-v2-generated" import { Tag } from "../ui/tag" import { ChatTypingIndicator } from "./chat-typing-indicator" import { SlashCommandSuggestions } from "./slash-command-suggestions" import type { ChatInputProps, ChatInputRef, MentionMeta, SlashCommandSummary } from "./types" /** SHARED with `lib/config/slash-commands-config.ts` AND the chat-route slash * dispatch parser. Keep all three in sync. */ const SLASH_INPUT_TRIGGER = /^\/([a-z][a-z0-9-]*)?$/ /** A committed `@`-mention serializes into the draft text as `@:` * (id charset allows `+ / = . -` for base64 Relay global ids). The editor * renders each such token as an inline chip; the serialized string keeps the * literal token so the host's `@type:id` reconciliation + send payload are * unchanged. */ const MENTION_GLOBAL = /@[A-Za-z0-9_.+/=-]+:[A-Za-z0-9_.+/=-]+/g /** The IN-PROGRESS trigger being typed — `@` + search string at the END of the * draft, preceded by start-of-text or whitespace (so emails never fire). */ const MENTION_TRIGGER_AT_END = /(^|\s)@([\w.-]*)$/ type Segment = { kind: 'text'; text: string } | { kind: 'mention'; token: string; label: string; icon?: ReactNode } function parseSegments(value: string, meta: Map): Segment[] { const segs: Segment[] = [] let last = 0 MENTION_GLOBAL.lastIndex = 0 let m: RegExpExecArray | null while ((m = MENTION_GLOBAL.exec(value)) !== null) { if (m.index > last) segs.push({ kind: 'text', text: value.slice(last, m.index) }) const token = m[0].slice(1) const info = meta.get(token) segs.push({ kind: 'mention', token, label: info?.label ?? token.slice(token.indexOf(':') + 1), icon: info?.icon }) last = m.index + m[0].length } if (last < value.length) segs.push({ kind: 'text', text: value.slice(last) }) return segs } const isChip = (n: Node): n is HTMLElement => n.nodeType === 1 && (n as HTMLElement).dataset?.token !== undefined /** Serialize the editor DOM back to the draft string: text nodes verbatim, * mention chips → `@`, browser-inserted `
` ignored (newlines live as * literal `\n` text via `white-space: pre-wrap`). Chips are atomic * (`contentEditable=false`) so we never descend into their inner markup. */ function serialize(el: HTMLElement): string { let out = '' for (const node of Array.from(el.childNodes)) { if (node.nodeType === Node.TEXT_NODE) out += node.textContent ?? '' else if (isChip(node)) out += `@${(node as HTMLElement).dataset.token}` else if ((node as HTMLElement).tagName === 'BR') continue else out += node.textContent ?? '' } return out } /** Build a mention chip as a plain (React-free) DOM node — the editor is an * UNCONTROLLED contenteditable, so chips must be real DOM the browser owns * (atomically deletable, never reconciled by React). We REUSE the lib `Tag` * (`variant="badge"` + its built-in `onClose` ⊗) by rendering it to static * markup; the outer wrapper carries `data-token`/`contenteditable=false`, and * the close button is wired via a delegated click on the editor (events don't * survive static markup). */ function buildChipEl(token: string, label: string, icon?: ReactNode): HTMLElement { const span = document.createElement('span') span.dataset.token = token span.setAttribute('contenteditable', 'false') // Center the chip in its line box DETERMINISTICALLY (independent of font // metrics): `vertical-align: middle` references the text baseline + x-height, // not the geometric line-box center, so it leaves the chip ~2px low. Instead // make the wrapper exactly one line box tall (`h-9` == editor `leading-9`, // 36px) and `align-top` (pure geometric: wrapper top == line-box top, no // baseline offset), then center the shorter chip inside via `items-center`. span.className = 'mx-0.5 inline-flex h-9 items-center select-none align-top' span.innerHTML = renderToStaticMarkup( {}} // Match the context-chip pill, not the badge defaults: muted (grey) lead // icon + ⊗ instead of white (`text-ods-text-primary` stays on the label // only), and NO accent-yellow border on hover. className="max-w-[16rem] hover:border-ods-border [&_svg]:text-ods-text-secondary" />, ) return span } /** Replace the editor's content with text nodes + chips parsed from `value`. */ function rebuildDom(el: HTMLElement, value: string, meta: Map): void { el.replaceChildren() for (const seg of parseSegments(value, meta)) { if (seg.kind === 'text') el.appendChild(document.createTextNode(seg.text)) else el.appendChild(buildChipEl(seg.token, seg.label, seg.icon)) } } function placeCaretAtEnd(el: HTMLElement): void { const sel = typeof window !== 'undefined' ? window.getSelection() : null if (!sel) return const range = document.createRange() range.selectNodeContents(el) range.collapse(false) sel.removeAllRanges() sel.addRange(range) } /** Place the caret at a character offset in a PLAIN-text editor (no chips) — * used only by `setValueAndCursor` (slash prefill). */ function placeCaretAtOffset(el: HTMLElement, offset: number): void { const sel = typeof window !== 'undefined' ? window.getSelection() : null if (!sel) return const node = el.firstChild const range = document.createRange() if (node && node.nodeType === Node.TEXT_NODE) { range.setStart(node, Math.max(0, Math.min(offset, node.textContent?.length ?? 0))) range.collapse(true) } else { range.selectNodeContents(el) range.collapse(false) } sel.removeAllRanges() sel.addRange(range) } /** Insert plain text at the caret as a real text node (paste / Shift+Enter), so * no `
`/`
` ever enters the DOM and serialization stays deterministic. */ function insertTextAtCaret(text: string): void { const sel = typeof window !== 'undefined' ? window.getSelection() : null if (!sel || sel.rangeCount === 0) return const range = sel.getRangeAt(0) range.deleteContents() const tn = document.createTextNode(text) range.insertNode(tn) range.setStartAfter(tn) range.collapse(true) sel.removeAllRanges() sel.addRange(range) } const ChatInput = forwardRef((allProps, ref) => { const { slashCommands, ...rest } = allProps const { className, onSend, onStop, sending = false, awaitingResponse = false, placeholder = "Enter your Request...", reserveAvatarOffset: _reserveAvatarOffset, disabled = false, autoFocus = false, fullWidth = false, allowEmptySend = false, onMentionQueryChange, onValueChange, startIcon, hideBorder, previewText, // Remaining textarea-only attrs are intentionally dropped — the editor is a // contenteditable div, not a