import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' import { ActionBarPrimitive, BranchPickerPrimitive, ComposerPrimitive, ErrorPrimitive, MessagePrimitive, type ToolCallMessagePartProps, useAui, useAuiState } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { IconPlayerStopFilled } from '@tabler/icons-react' import { type ClipboardEvent, type FC, type FocusEvent, type FormEvent, type KeyboardEvent, type DragEvent as ReactDragEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from '@/app/chat/composer/drop-affordance' import { type ComposerInsertMode, focusComposerInput, markActiveComposer, onComposerFocusRequest, onComposerInsertRequest } from '@/app/chat/composer/focus' import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions' import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions' import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from '@/app/chat/composer/inline-refs' import { composerPlainText, placeCaretEnd, refChipElement, renderComposerContents, RICH_INPUT_SLOT } from '@/app/chat/composer/rich-editor' import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils' import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover' import { extractDroppedFiles, HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions' import { ClarifyTool } from '@/components/assistant-ui/clarify-tool' import { DirectiveContent } from '@/components/assistant-ui/directive-text' import { UserMessageText } from '@/components/assistant-ui/user-message-text' import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' import { MarkdownText } from '@/components/assistant-ui/markdown-text' import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer' import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool' import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback' import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { DisclosureRow } from '@/components/chat/disclosure-row' import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/chat/generated-image-context' import { ImageGenerationPlaceholder } from '@/components/chat/image-generation-placeholder' import { Intro, type IntroProps } from '@/components/chat/intro' import { PreviewAttachment } from '@/components/chat/preview-attachment' import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Loader } from '@/components/ui/loader' import type { HermesGateway } from '@/hermes' import { useResizeObserver } from '@/hooks/use-resize-observer' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons' import { extractPreviewTargets } from '@/lib/preview-targets' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback' import { notifyError } from '@/store/notifications' import { $voicePlayback } from '@/store/voice-playback' type ThreadLoadingState = 'response' | 'session' interface MessageActionProps { messageId: string messageText: string onBranchInNewChat?: (messageId: string) => void } let readAloudAudio: HTMLAudioElement | null = null function partText(part: unknown): string { if (typeof part === 'string') { return part } if (!part || typeof part !== 'object') { return '' } const row = part as { text?: unknown; type?: unknown } return (!row.type || row.type === 'text') && typeof row.text === 'string' ? row.text : '' } function messageContentText(content: unknown): string { if (typeof content === 'string') { return content.trim() } return Array.isArray(content) ? content.map(partText).join('').trim() : '' } const INTERRUPTED_ONLY_RE = /^_?\[interrupted\]_?$/i const isInterruptedOnlyMessage = (text: string) => INTERRUPTED_ONLY_RE.test(text.trim()) export const Thread: FC<{ clampToComposer?: boolean cwd?: string | null gateway?: HermesGateway | null intro?: IntroProps loading?: ThreadLoadingState onBranchInNewChat?: (messageId: string) => void onCancel?: () => Promise | void sessionId?: string | null sessionKey?: string | null }> = ({ clampToComposer = false, cwd = null, gateway = null, intro, loading, onBranchInNewChat, onCancel, sessionId = null, sessionKey }) => { const messageComponents = useMemo( () => ({ AssistantMessage: () => , SystemMessage, UserEditComposer: () => , UserMessage: () => }), [cwd, gateway, onBranchInNewChat, onCancel, sessionId] ) const emptyPlaceholder = intro ? (
) : undefined return (
: null} sessionKey={sessionKey} /> {loading === 'session' && }
) } function pickPrimaryPreviewTarget(targets: string[]): string[] { if (targets.length <= 1) { return targets } const localUrl = targets.find(value => /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(value)) return [localUrl || targets[targets.length - 1]] } const CenteredThreadSpinner: FC = () => (
) const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => { const messageId = useAuiState(s => s.message.id) const content = useAuiState(s => s.message.content) const messageText = messageContentText(content) const hoistedTodos = useMemo(() => todosFromMessageContent(content), [content]) const previewTargets = useMemo(() => { if (!messageText || !/(https?:\/\/|file:\/\/)/i.test(messageText)) { return [] } return pickPrimaryPreviewTarget(extractPreviewTargets(messageText)) }, [messageText]) const messageStatus = useAuiState(s => s.message.status?.type) const isPlaceholder = messageStatus === 'running' && content.length === 0 const interruptedOnly = useMemo(() => isInterruptedOnlyMessage(messageText), [messageText]) if (isPlaceholder) { return null } return (
{hoistedTodos.length > 0 && } {previewTargets.length > 0 && (
{previewTargets.map(target => ( ))}
)}
{messageText.trim().length > 0 && !interruptedOnly && ( )}
) } const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({ children, label, className, ...rest }) => (
{children}
) const ResponseLoadingIndicator: FC = () => { const elapsed = useElapsedSeconds() return ( ) } const ImageGenerateTool: FC = ({ result }) => { const generatedImage = useGeneratedImageContext() const running = result === undefined useEffect(() => { generatedImage?.setPending(running) }, [generatedImage, running]) if (!running) { return null } return (
) } const ChainToolFallback: FC = props => { // todo parts are hoisted to a dedicated panel above the message content. if (props.toolName === 'todo') { return null } if (props.toolName === 'image_generate') { return } if (props.toolName === 'clarify') { return } return } const ThinkingDisclosure: FC<{ children: ReactNode messageRunning?: boolean pending?: boolean timerKey?: string }> = ({ children, messageRunning = false, pending = false, timerKey }) => { // `null` = no explicit user toggle yet, defer to the streaming default. // The default is "auto-open while streaming, auto-collapse when done" so // reasoning surfaces a live preview without manual interaction. The first // explicit toggle wins from then on. const [userOpen, setUserOpen] = useState(null) const elapsed = useElapsedSeconds(pending, timerKey) const scrollRef = useRef(null) const contentRef = useRef(null) const enterRef = useEnterAnimation(messageRunning, timerKey) const open = userOpen ?? pending const isPreview = pending && userOpen === null // While the preview is live, pin the scroll container to the bottom on // every content growth so the latest tokens are always visible. Combined // with the top mask in styles.css, this reads as text settling in from // below while older lines fade out at the top. useEffect(() => { if (!isPreview) { return } const el = scrollRef.current const content = contentRef.current if (!el || !content) { return } const pin = () => { el.scrollTop = el.scrollHeight } pin() const observer = new ResizeObserver(pin) observer.observe(content) return () => observer.disconnect() }, [isPreview]) return (
setUserOpen(!open)} open={open}> Thinking {pending && ( )} {open && (
{children}
)}
) } // Self-gate "Thinking…" on this message's own reasoning parts. Reading // `thread.isRunning` directly would flicker shimmer/timer on every old // assistant whenever the external-store runtime clears+reimports its // repository (one ref-identity bump per streaming delta). const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; startIndex: number }> = ({ children, endIndex, startIndex }) => { const messageId = useAuiState(s => s.message.id) const messageRunning = useAuiState(s => s.message.status?.type === 'running') const pending = useAuiState( s => s.thread.isRunning && s.message.status?.type === 'running' && s.message.parts .slice(Math.max(0, startIndex), Math.min(s.message.parts.length, endIndex)) .some(p => p?.type === 'reasoning' && p.status?.type !== 'complete') ) return ( {children} ) } const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => { const displayText = text.trimStart() return (
{displayText}
) } // Module-level constant so the `components` prop on `MessagePrimitive.Parts` // has a stable identity across renders. Without this every AssistantMessage // render would create a fresh `components` object, invalidating the memo on // `MessagePrimitivePartByIndex` and forcing every tool/reasoning child to // re-render on every streaming delta. Memo invalidation alone doesn't // remount, but combined with the previous ToolFallback group-swap it was a // big chunk of the per-delta work. const MESSAGE_PARTS_COMPONENTS = { Reasoning: ReasoningTextPart, ReasoningGroup: ReasoningAccordionGroup, Text: MarkdownText, ToolGroup: ToolGroupSlot, tools: { Fallback: ChainToolFallback } } as const const TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }) const SHORT_FMT = new Intl.DateTimeFormat(undefined, { day: 'numeric', hour: 'numeric', minute: '2-digit', month: 'short' }) function startOfDay(d: Date): number { return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() } function formatMessageTimestamp(value: Date | string | number | undefined): string { if (!value) { return '' } const date = value instanceof Date ? value : new Date(value) if (Number.isNaN(date.getTime())) { return '' } const dayDelta = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000) if (dayDelta === 0) { return `Today, ${TIME_FMT.format(date)}` } if (dayDelta === 1) { return `Yesterday, ${TIME_FMT.format(date)}` } return SHORT_FMT.format(date) } const AssistantActionBar: FC = ({ messageId, messageText, onBranchInNewChat }) => { const [menuOpen, setMenuOpen] = useState(false) return (
triggerHaptic('submit')} tooltip="Refresh"> e.preventDefault()} sideOffset={6}> onBranchInNewChat?.(messageId)}> Branch in new chat
) } const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, text }) => { const voicePlayback = useStore($voicePlayback) const readAloudStatus = voicePlayback.source === 'read-aloud' && voicePlayback.messageId === messageId ? voicePlayback.status : 'idle' const isPreparing = readAloudStatus === 'preparing' const isSpeaking = readAloudStatus === 'speaking' const anyPlaybackActive = voicePlayback.status !== 'idle' const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : Volume2Icon const read = useCallback(async () => { if (!text || $voicePlayback.get().status !== 'idle') { return } try { await playSpeechText(text, { messageId, source: 'read-aloud' }) } catch (error) { notifyError(error, 'Read aloud failed') } }, [messageId, text]) return ( { e.preventDefault() void (isSpeaking ? stopVoicePlayback() : read()) }} > {isPreparing ? 'Preparing audio...' : isSpeaking ? 'Stop reading' : 'Read aloud'} ) } const MessageTimestamp: FC = () => { const createdAt = useAuiState(s => s.message.createdAt) const label = formatMessageTimestamp(createdAt) if (!label) { return null } return {label} } const AssistantFooter: FC = props => (
/
) const EMPTY_ATTACHMENT_REFS: string[] = [] function messageAttachmentRefs(value: unknown): string[] { if (!Array.isArray(value)) { return EMPTY_ATTACHMENT_REFS } return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS } function StickyHumanMessageContainer({ children }: { children: ReactNode }) { return (
{children}
) } // Shared "user bubble" base. Both the read-only message and the inline // edit composer render the same bubble surface (rounded glass card, // shadow-composer); they only differ in border weight, cursor, and // padding-right (the read-only view reserves room for the restore icon). const USER_BUBBLE_BASE_CLASS = 'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left shadow-composer' const USER_ACTION_ICON_BUTTON_CLASS = 'grid cursor-pointer place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70' const USER_ACTION_ICON_SIZE = '0.6875rem' const StopGlyph = const UserMessage: FC<{ onCancel?: () => Promise | void }> = ({ onCancel }) => { const messageId = useAuiState(s => s.message.id) const content = useAuiState(s => s.message.content) const messageText = messageContentText(content) const threadRunning = useAuiState(s => s.thread.isRunning) const latestUserId = useAuiState(s => { for (let i = s.thread.messages.length - 1; i >= 0; i--) { const message = s.thread.messages[i] as { id?: string; role?: string } if (message.role === 'user') { return message.id ?? null } } return null }) const attachmentRefs = useAuiState(s => { const custom = (s.message.metadata?.custom ?? {}) as { attachmentRefs?: unknown } return messageAttachmentRefs(custom.attachmentRefs) }) // Sticky human bubbles clamp to ~2 lines with a soft fade so a long prompt // doesn't dominate the viewport while the response streams underneath; the // clamp lifts on hover / focus (see styles.css). We measure the *unclamped* // inner wrapper so the ResizeObserver only fires on real content / width // changes, not on every frame while the outer max-height animates open. const clampInnerRef = useRef(null) const [bodyClamped, setBodyClamped] = useState(false) const measureClamp = useCallback(() => { const inner = clampInnerRef.current const outer = inner?.parentElement if (!inner || !outer) { return } const styles = getComputedStyle(inner) const lineHeight = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20 const fullHeight = inner.scrollHeight outer.style.setProperty('--human-msg-full', `${fullHeight}px`) setBodyClamped(fullHeight > lineHeight * 2 + 1) }, []) useResizeObserver(measureClamp, clampInnerRef) const hasBody = messageText.trim().length > 0 const isLatestUser = messageId === latestUserId const showStop = isLatestUser && threadRunning && Boolean(onCancel) const showRestore = !isLatestUser && !threadRunning const bubbleClassName = cn( USER_BUBBLE_BASE_CLASS, 'border-(--ui-stroke-tertiary) pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors', !threadRunning && 'cursor-pointer hover:border-(--ui-stroke-secondary)' ) const bubbleContent = ( <> {attachmentRefs.length > 0 && ( )} {hasBody && ( // Render the user's text through a minimal markdown pipeline: // backtick `code` and ``` fenced ``` blocks, with directive chips // (`@file:` etc.) still resolved inside the plain-text spans.
)} ) return (
{threadRunning ? (
{bubbleContent}
) : ( )} {(showStop || showRestore) && (
{showStop ? ( ) : ( )}
)}
Restore checkpoint / Go forward
) } const SLASH_STATUS_RE = /^slash:(?\/[^\n]+)\n(?[\s\S]*)$/ const SystemMessage: FC = () => { const text = useAuiState(s => messageContentText(s.message.content)) if (!text) { return null } const slashStatus = text.match(SLASH_STATUS_RE) if (slashStatus?.groups) { return ( {slashStatus.groups.command} · {slashStatus.groups.output.trim()} ) } return ( {text} ) } interface UserEditComposerProps { cwd: string | null gateway: HermesGateway | null sessionId: string | null } const UserEditComposer: FC = ({ cwd, gateway, sessionId }) => { const aui = useAui() const draft = useAuiState(s => s.composer.text) const rootRef = useRef(null) const editorRef = useRef(null) const draftRef = useRef(draft) const dragDepthRef = useRef(0) const [dragActive, setDragActive] = useState(false) const [trigger, setTrigger] = useState(null) const [triggerActive, setTriggerActive] = useState(0) const [triggerItems, setTriggerItems] = useState([]) const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top') const [focusRequestId, setFocusRequestId] = useState(0) const [submitting, setSubmitting] = useState(false) const expanded = draft.includes('\n') const canSubmit = draft.trim().length > 0 const at = useAtCompletions({ cwd, gateway, sessionId }) const slash = useSlashCompletions({ gateway }) const focusEditor = useCallback(() => { const editor = editorRef.current focusComposerInput(editor) if (editor) { placeCaretEnd(editor) } markActiveComposer('edit') }, []) const requestEditFocus = useCallback(() => { setFocusRequestId(id => id + 1) }, []) const appendExternalText = useCallback( (text: string, mode: ComposerInsertMode) => { const value = text.trim() if (!value) { return } const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : '' const next = `${base}${sep}${value}` draftRef.current = next aui.composer().setText(next) const editor = editorRef.current if (editor) { renderComposerContents(editor, next) placeCaretEnd(editor) } setFocusRequestId(id => id + 1) }, [aui] ) useEffect(() => { draftRef.current = draft const editor = editorRef.current if ( editor && (editor.childNodes.length === 0 || (document.activeElement !== editor && composerPlainText(editor) !== draft)) ) { renderComposerContents(editor, draft) if (document.activeElement === editor) { placeCaretEnd(editor) } } }, [draft]) useEffect(() => { focusEditor() }, [focusEditor, focusRequestId]) useEffect(() => { const offFocus = onComposerFocusRequest(target => { if (target === 'edit') { setFocusRequestId(id => id + 1) } }) const offInsert = onComposerInsertRequest(({ mode, target, text }) => { if (target === 'edit') { appendExternalText(text, mode) } }) return () => { offFocus() offInsert() } }, [appendExternalText]) const syncDraftFromEditor = useCallback( (editor: HTMLDivElement) => { const nextDraft = composerPlainText(editor) if (nextDraft !== draftRef.current) { draftRef.current = nextDraft aui.composer().setText(nextDraft) } return nextDraft }, [aui] ) const refreshTrigger = useCallback(() => { const editor = editorRef.current if (!editor) { return } const before = textBeforeCaret(editor) const detected = detectTrigger(before ?? composerPlainText(editor)) if (detected) { const rect = editor.getBoundingClientRect() const spaceAbove = rect.top const spaceBelow = window.innerHeight - rect.bottom setTriggerPlacement(spaceAbove < 220 && spaceBelow > spaceAbove ? 'bottom' : 'top') } setTrigger(detected) setTriggerActive(0) }, []) const closeTrigger = useCallback(() => { setTrigger(null) setTriggerItems([]) setTriggerActive(0) }, []) const triggerAdapter: Unstable_TriggerAdapter | null = trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null useEffect(() => { if (!trigger || !triggerAdapter?.search) { setTriggerItems([]) return } setTriggerItems(triggerAdapter.search(trigger.query)) }, [trigger, triggerAdapter]) useEffect(() => { setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1))) }, [triggerItems.length]) const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false const replaceTriggerWithChip = useCallback( (item: Unstable_TriggerItem) => { const editor = editorRef.current if (!editor || !trigger) { return } const serialized = hermesDirectiveFormatter.serialize(item) const starter = serialized.endsWith(':') const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} ` const directive = !starter && serialized.match(/^@([^:]+):(.+)$/) const finish = () => { draftRef.current = composerPlainText(editor) aui.composer().setText(draftRef.current) requestEditFocus() starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger() } const sel = window.getSelection() const range = sel?.rangeCount ? sel.getRangeAt(0) : null const node = range?.startContainer const offset = range?.startOffset ?? 0 if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) { const current = composerPlainText(editor) renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`) placeCaretEnd(editor) return finish() } const replaceRange = document.createRange() replaceRange.setStart(node, offset - trigger.tokenLength) replaceRange.setEnd(node, offset) replaceRange.deleteContents() if (directive) { const chip = refChipElement(directive[1], directive[2]) const space = document.createTextNode(' ') const fragment = document.createDocumentFragment() fragment.append(chip, space) replaceRange.insertNode(fragment) const caret = document.createRange() caret.setStart(space, 1) caret.collapse(true) sel.removeAllRanges() sel.addRange(caret) return finish() } document.execCommand('insertText', false, text) finish() }, [aui, closeTrigger, refreshTrigger, requestEditFocus, trigger] ) const insertDroppedRefs = useCallback( (candidates: ReturnType) => { const editor = editorRef.current if (!editor) { return false } const refs = candidates .map(candidate => droppedFileInlineRef(candidate, cwd)) .filter((ref): ref is string => Boolean(ref)) const nextDraft = insertInlineRefsIntoEditor(editor, refs) if (nextDraft === null) { return false } draftRef.current = nextDraft aui.composer().setText(nextDraft) requestEditFocus() return true }, [aui, cwd, requestEditFocus] ) const resetDragState = useCallback(() => { dragDepthRef.current = 0 setDragActive(false) }, []) const handleDragEnter = (event: ReactDragEvent) => { if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { return } event.preventDefault() dragDepthRef.current += 1 if (!dragActive) { setDragActive(true) } } const handleDragOver = (event: ReactDragEvent) => { if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { return } event.preventDefault() event.dataTransfer.dropEffect = 'copy' } const handleDragLeave = (event: ReactDragEvent) => { event.preventDefault() dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) if (dragDepthRef.current === 0) { setDragActive(false) } } const handleDrop = (event: ReactDragEvent) => { if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { return } const candidates = extractDroppedFiles(event.dataTransfer) if (!candidates.length) { return } event.preventDefault() event.stopPropagation() resetDragState() if (insertDroppedRefs(candidates)) { triggerHaptic('selection') } } const handleInput = (event: FormEvent) => { const editor = event.currentTarget if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') { editor.replaceChildren() } syncDraftFromEditor(editor) window.setTimeout(refreshTrigger, 0) } const handlePaste = (event: ClipboardEvent) => { const pastedText = event.clipboardData.getData('text') if (!pastedText || DATA_IMAGE_URL_RE.test(pastedText.trim())) { event.preventDefault() return } event.preventDefault() document.execCommand('insertText', false, pastedText) syncDraftFromEditor(event.currentTarget) } const submitEdit = (editor: HTMLDivElement) => { const nextDraft = syncDraftFromEditor(editor) if (submitting || !nextDraft.trim()) { return } setSubmitting(true) aui.composer().send() } const handleEditBlur = useCallback( (event: FocusEvent) => { const nextTarget = event.relatedTarget if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { return } window.setTimeout(() => { const root = rootRef.current const active = document.activeElement if (submitting || (root && active && root.contains(active))) { return } closeTrigger() aui.composer().cancel() }, 80) }, [aui, closeTrigger, submitting] ) const handleKeyDown = (event: KeyboardEvent) => { if (trigger && triggerItems.length > 0) { if (event.key === 'ArrowDown') { event.preventDefault() setTriggerActive(idx => (idx + 1) % triggerItems.length) return } if (event.key === 'ArrowUp') { event.preventDefault() setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length) return } if (event.key === 'Enter' || event.key === 'Tab') { event.preventDefault() const item = triggerItems[triggerActive] if (item) { replaceTriggerWithChip(item) } return } if (event.key === 'Escape') { event.preventDefault() closeTrigger() return } } if (event.key === 'Escape') { event.preventDefault() aui.composer().cancel() return } if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() submitEdit(event.currentTarget) } } return (
{trigger && ( )}
window.setTimeout(closeTrigger, 80)} onDragOver={handleDragOver} onDrop={handleDrop} onFocus={() => markActiveComposer('edit')} onInput={handleInput} onKeyDown={handleKeyDown} onKeyUp={() => window.setTimeout(refreshTrigger, 0)} onMouseUp={refreshTrigger} onPaste={handlePaste} ref={editorRef} role="textbox" suppressContentEditableWarning />
) }