import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { type ClipboardEvent, type FormEvent, type KeyboardEvent, type DragEvent as ReactDragEvent, useCallback, useEffect, useRef, useState } from 'react' import { formatRefValue, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' import { Button } from '@/components/ui/button' import { useMediaQuery } from '@/hooks/use-media-query' import { useResizeObserver } from '@/hooks/use-resize-observer' import { chatMessageText } from '@/lib/chat-messages' import { contextPath } from '@/lib/chat-runtime' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' import { $composerAttachments, $composerDraft, clearComposerAttachments, type ComposerAttachment } from '@/store/composer' import { $queuedPromptsBySession, enqueueQueuedPrompt, removeQueuedPrompt, type QueuedPromptEntry, updateQueuedPrompt } from '@/store/composer-queue' import { $messages } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions' import { AttachmentList } from './attachments' import { ContextMenu } from './context-menu' import { ComposerControls } from './controls' import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' import { useSlashCompletions } from './hooks/use-slash-completions' import { useVoiceConversation } from './hooks/use-voice-conversation' import { useVoiceRecorder } from './hooks/use-voice-recorder' import { composerPlainText, placeCaretEnd, refChipElement, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor' import { QueuePanel } from './queue-panel' import { SkinSlashPopover } from './skin-slash-popover' import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils' import { ComposerTriggerPopover } from './trigger-popover' import type { ChatBarProps } from './types' import { UrlDialog } from './url-dialog' import { VoiceActivity, VoicePlaybackActivity } from './voice-activity' const COMPOSER_STACK_BREAKPOINT_PX = 320 const COMPOSER_FADE_BACKGROUND = 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))' interface QueueEditState { attachments: ComposerAttachment[] draft: string entryId: string sessionKey: string } const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a })) export function ChatBar({ busy, cwd, disabled, focusKey, gateway, maxRecordingSeconds = 120, queueSessionKey, sessionId, state, onCancel, onAddUrl, onAttachDroppedItems, onAttachImageBlob, onPasteClipboardImage, onPickFiles, onPickFolders, onPickImages, onRemoveAttachment, onSubmit, onTranscribeAudio }: ChatBarProps) { const aui = useAui() const draft = useAuiState(s => s.composer.text) const attachments = useStore($composerAttachments) const queuedPromptsBySession = useStore($queuedPromptsBySession) const scrolledUp = useStore($threadScrolledUp) const activeQueueSessionKey = queueSessionKey || sessionId || null const queuedPrompts = activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : [] const composerRef = useRef(null) const composerSurfaceRef = useRef(null) const editorRef = useRef(null) const draftRef = useRef(draft) const previousBusyRef = useRef(busy) const drainingQueueRef = useRef(false) const urlInputRef = useRef(null) const [urlOpen, setUrlOpen] = useState(false) const [urlValue, setUrlValue] = useState('') const [expanded, setExpanded] = useState(false) const [voiceConversationActive, setVoiceConversationActive] = useState(false) const [tight, setTight] = useState(false) const [dragActive, setDragActive] = useState(false) const [queueEdit, setQueueEdit] = useState(null) const dragDepthRef = useRef(0) const lastSpokenIdRef = useRef(null) const narrow = useMediaQuery('(max-width: 480px)') const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null }) const slash = useSlashCompletions({ gateway: gateway ?? null }) const stacked = expanded || narrow || tight const hasComposerPayload = draft.trim().length > 0 || attachments.length > 0 const canSubmit = busy || hasComposerPayload const editingQueuedPrompt = queueEdit ? queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null : null const busyAction = busy && hasComposerPayload ? 'queue' : 'stop' const showHelpHint = draft === '?' const placeholder = disabled ? 'Starting Hermes…' : 'Ask anything' const focusInput = () => window.requestAnimationFrame(() => editorRef.current?.focus({ preventScroll: true })) useEffect(() => { if (!disabled) { focusInput() } }, [disabled, focusKey]) useEffect(() => { draftRef.current = draft $composerDraft.set(draft) const editor = editorRef.current if (editor && document.activeElement !== editor && composerPlainText(editor) !== draft) { renderComposerContents(editor, draft) } }, [draft]) useEffect( () => $composerDraft.subscribe(value => { if (value !== draftRef.current) { aui.composer().setText(value) } }), [aui] ) useEffect(() => { if (urlOpen) { window.requestAnimationFrame(() => urlInputRef.current?.focus({ preventScroll: true })) } }, [urlOpen]) useEffect(() => { if (!draft) { setExpanded(false) return } if (expanded) { return } const wraps = (editorRef.current?.scrollHeight ?? 0) > 56 if (draft.includes('\n') || wraps) { setExpanded(true) } }, [draft, expanded]) const syncComposerMetrics = useCallback(() => { const composer = composerRef.current if (!composer) { return } const { height, width } = composer.getBoundingClientRect() const surfaceHeight = composerSurfaceRef.current?.getBoundingClientRect().height const root = document.documentElement if (width > 0) { setTight(width < COMPOSER_STACK_BREAKPOINT_PX) } if (height > 0) { root.style.setProperty('--composer-measured-height', `${Math.round(height)}px`) } if (surfaceHeight && surfaceHeight > 0) { root.style.setProperty('--composer-surface-measured-height', `${Math.round(surfaceHeight)}px`) } }, []) useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef) useEffect(() => { return () => { const root = document.documentElement root.style.removeProperty('--composer-measured-height') root.style.removeProperty('--composer-surface-measured-height') } }, []) const insertText = (text: string) => { const currentDraft = draftRef.current const sep = currentDraft && !currentDraft.endsWith('\n') ? '\n' : '' const nextDraft = `${currentDraft}${sep}${text}` draftRef.current = nextDraft aui.composer().setText(nextDraft) focusInput() } const insertInlineRefs = (refs: string[]) => { const editor = editorRef.current if (!refs.length || !editor) { return false } const inline = refs.join(' ') const selection = window.getSelection() const range = selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer) ? selection.getRangeAt(0) : null editor.focus({ preventScroll: true }) if (range) { const beforeRange = range.cloneRange() beforeRange.selectNodeContents(editor) beforeRange.setEnd(range.startContainer, range.startOffset) const beforeContainer = document.createElement('div') beforeContainer.appendChild(beforeRange.cloneContents()) const afterRange = range.cloneRange() afterRange.selectNodeContents(editor) afterRange.setStart(range.endContainer, range.endOffset) const afterContainer = document.createElement('div') afterContainer.appendChild(afterRange.cloneContents()) const beforeText = composerPlainText(beforeContainer) const afterText = composerPlainText(afterContainer) const needsBeforeSpace = beforeText.length > 0 && !/\s$/.test(beforeText) const needsAfterSpace = afterText.length === 0 || !/^\s/.test(afterText) range.deleteContents() const fragment = document.createDocumentFragment() if (needsBeforeSpace) { fragment.appendChild(document.createTextNode(' ')) } refs.forEach((ref, index) => { const match = ref.match(/^@([^:]+):(.+)$/) fragment.appendChild(match ? refChipElement(match[1], match[2]) : document.createTextNode(ref)) if (index < refs.length - 1) { fragment.appendChild(document.createTextNode(' ')) } }) const trailingSpace = needsAfterSpace ? document.createTextNode(' ') : null if (trailingSpace) { fragment.appendChild(trailingSpace) } range.insertNode(fragment) const nextRange = document.createRange() if (trailingSpace) { nextRange.setStart(trailingSpace, trailingSpace.length) } else { nextRange.setStartAfter(fragment.lastChild || range.startContainer) } nextRange.collapse(true) selection?.removeAllRanges() selection?.addRange(nextRange) } else { const current = composerPlainText(editor) renderComposerContents(editor, `${current}${current && !/\s$/.test(current) ? ' ' : ''}${inline} `) placeCaretEnd(editor) } const nextDraft = composerPlainText(editor) draftRef.current = nextDraft aui.composer().setText(nextDraft) return true } const droppedFileInlineRef = (candidate: DroppedFile) => { if (!candidate.path) { return null } const rel = contextPath(candidate.path, cwd || '') if (candidate.line) { const { line, lineEnd } = candidate const range = lineEnd && lineEnd > line ? `${line}-${lineEnd}` : `${line}` return `@line:${formatRefValue(`${rel}:${range}`)}` } const kind = candidate.isDirectory ? 'folder' : 'file' return `@${kind}:${formatRefValue(rel)}` } const selectSkinSlashCommand = (command: string) => { draftRef.current = command aui.composer().setText(command) focusInput() } const handlePaste = (event: ClipboardEvent) => { const imageBlobs = extractClipboardImageBlobs(event.clipboardData) if (imageBlobs.length > 0) { event.preventDefault() if (onAttachImageBlob) { triggerHaptic('selection') for (const blob of imageBlobs) { void onAttachImageBlob(blob) } } return } const pastedText = event.clipboardData.getData('text') if (!pastedText) { return } if (DATA_IMAGE_URL_RE.test(pastedText.trim())) { event.preventDefault() return } event.preventDefault() document.execCommand('insertText', false, pastedText) const nextDraft = composerPlainText(event.currentTarget) draftRef.current = nextDraft aui.composer().setText(nextDraft) } const [trigger, setTrigger] = useState(null) const [triggerActive, setTriggerActive] = useState(0) const [triggerItems, setTriggerItems] = useState([]) const refreshTrigger = useCallback(() => { const editor = editorRef.current if (!editor) { return } const before = textBeforeCaret(editor) const detected = detectTrigger(before ?? composerPlainText(editor)) setTrigger(detected) setTriggerActive(0) }, []) const handleEditorInput = (event: FormEvent) => { const editor = event.currentTarget if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') { editor.replaceChildren() } const nextDraft = composerPlainText(editor) if (nextDraft !== draftRef.current) { draftRef.current = nextDraft aui.composer().setText(nextDraft) } window.setTimeout(refreshTrigger, 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]) const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false const closeTrigger = () => { setTrigger(null) setTriggerItems([]) setTriggerActive(0) } useEffect(() => { setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1))) }, [triggerItems.length]) const replaceTriggerWithChip = (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) 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() } const handleEditorKeyDown = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k') { event.preventDefault() if (!busy) void drainNextQueued() return } 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 === 'Enter' && !event.shiftKey) { event.preventDefault() if (!busy && !hasComposerPayload && queuedPrompts.length > 0) { void drainNextQueued() return } submitDraft() } } const handleEditorKeyUp = () => { window.setTimeout(refreshTrigger, 0) } const dragHasAttachments = (transfer: DataTransfer | null) => { if (!transfer) { return false } if (Array.from(transfer.types || []).includes(HERMES_PATHS_MIME)) { return true } if (Array.from(transfer.types || []).includes('Files')) { return true } return Array.from(transfer.items || []).some(item => item.kind === 'file') } const resetDragState = () => { dragDepthRef.current = 0 setDragActive(false) } const handleDragEnter = (event: ReactDragEvent) => { if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer)) { return } event.preventDefault() dragDepthRef.current += 1 if (!dragActive) { setDragActive(true) } } const handleDragOver = (event: ReactDragEvent) => { if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer)) { return } event.preventDefault() event.dataTransfer.dropEffect = 'copy' } const handleDragLeave = (event: ReactDragEvent) => { if (!onAttachDroppedItems) { return } event.preventDefault() dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) if (dragDepthRef.current === 0) { setDragActive(false) } } const handleDrop = (event: ReactDragEvent) => { if (!onAttachDroppedItems) { return } event.preventDefault() resetDragState() const candidates = extractDroppedFiles(event.dataTransfer) if (candidates.length === 0) { return } if (Array.from(event.dataTransfer.types || []).includes(HERMES_PATHS_MIME)) { const refs = candidates.map(droppedFileInlineRef).filter((ref): ref is string => Boolean(ref)) if (insertInlineRefs(refs)) { triggerHaptic('selection') } return } void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => { if (attached) { triggerHaptic('selection') focusInput() } }) } const handleInputDragOver = (event: ReactDragEvent) => { if (!dragHasAttachments(event.dataTransfer)) { return } event.preventDefault() event.stopPropagation() event.dataTransfer.dropEffect = 'copy' } const handleInputDrop = (event: ReactDragEvent) => { if (!dragHasAttachments(event.dataTransfer)) { return } const candidates = extractDroppedFiles(event.dataTransfer) const refs = candidates.map(droppedFileInlineRef).filter((ref): ref is string => Boolean(ref)) if (!refs.length) { return } event.preventDefault() event.stopPropagation() resetDragState() if (insertInlineRefs(refs)) { triggerHaptic('selection') } } const clearDraft = () => { aui.composer().setText('') draftRef.current = '' if (editorRef.current) { editorRef.current.replaceChildren() } } const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => { draftRef.current = text aui.composer().setText(text) $composerAttachments.set(cloneAttachments(attachments)) const editor = editorRef.current if (editor) { renderComposerContents(editor, text) placeCaretEnd(editor) } } const beginQueuedEdit = (entry: QueuedPromptEntry) => { if (!activeQueueSessionKey || queueEdit) return setQueueEdit({ attachments: cloneAttachments($composerAttachments.get()), draft: draftRef.current, entryId: entry.id, sessionKey: activeQueueSessionKey }) loadIntoComposer(entry.text, entry.attachments) triggerHaptic('selection') focusInput() } const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => { if (!queueEdit) return false if (action === 'save') { const text = draftRef.current const next = cloneAttachments($composerAttachments.get()) if (!text.trim() && next.length === 0) return false const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { attachments: next, text }) triggerHaptic(saved ? 'success' : 'selection') } else { triggerHaptic('cancel') } loadIntoComposer(queueEdit.draft, queueEdit.attachments) setQueueEdit(null) focusInput() return true } const queueCurrentDraft = useCallback(() => { if (!activeQueueSessionKey || (!draft.trim() && attachments.length === 0)) return false if (!enqueueQueuedPrompt(activeQueueSessionKey, { text: draft, attachments })) return false clearDraft() clearComposerAttachments() triggerHaptic('selection') return true }, [activeQueueSessionKey, attachments, draft]) // All queue drain paths share one lock + send-then-remove sequence. // `pickEntry` lets each caller choose head, by-id, or skip-edited. const runDrain = useCallback( async (pickEntry: (entries: QueuedPromptEntry[]) => QueuedPromptEntry | undefined): Promise => { if (drainingQueueRef.current || !activeQueueSessionKey) return false const entry = pickEntry(queuedPrompts) if (!entry) return false drainingQueueRef.current = true try { const accepted = await Promise.resolve(onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true })) if (accepted === false) return false removeQueuedPrompt(activeQueueSessionKey, entry.id) return true } finally { drainingQueueRef.current = false } }, [activeQueueSessionKey, onSubmit, queuedPrompts] ) const drainNextQueued = useCallback( () => runDrain(entries => { const skip = queueEdit?.entryId return skip ? entries.find(e => e.id !== skip) : entries[0] }), [queueEdit, runDrain] ) const sendQueuedNow = useCallback( (id: string) => runDrain(entries => entries.find(e => e.id === id && id !== queueEdit?.entryId)), [queueEdit, runDrain] ) const interruptAndSendNextQueued = useCallback(async () => { if (queuedPrompts.length === 0) return false await Promise.resolve(onCancel()) return drainNextQueued() }, [drainNextQueued, onCancel, queuedPrompts.length]) // Auto-drain on busy → false (turn settled). useEffect(() => { const wasBusy = previousBusyRef.current previousBusyRef.current = busy if (busy || !wasBusy || queuedPrompts.length === 0) return void drainNextQueued() }, [busy, drainNextQueued, queuedPrompts.length]) // Clean up queue edit when its target disappears (session swap or external delete). useEffect(() => { if (!queueEdit) return if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) return loadIntoComposer(queueEdit.draft, queueEdit.attachments) setQueueEdit(null) }, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps const submitDraft = () => { if (queueEdit) { exitQueuedEdit('save') } else if (busy) { if (hasComposerPayload) queueCurrentDraft() else if (queuedPrompts.length > 0) void interruptAndSendNextQueued() else { triggerHaptic('cancel') void Promise.resolve(onCancel()) } } else if (!hasComposerPayload && queuedPrompts.length > 0) { void drainNextQueued() } else if (draft.trim() || attachments.length > 0) { const submitted = draft triggerHaptic('submit') clearDraft() void onSubmit(submitted) } focusInput() } const submitUrl = () => { const url = urlValue.trim() if (!url) { return } if (onAddUrl) { onAddUrl(url) } else { insertText(`@url:${url}`) } triggerHaptic('success') setUrlValue('') setUrlOpen(false) } const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({ focusInput, maxRecordingSeconds, onTranscript: insertText, onTranscribeAudio }) const pendingResponse = () => { const messages = $messages.get() const last = messages.findLast(m => m.role === 'assistant' && !m.hidden) if (!last || last.id === lastSpokenIdRef.current) { return null } const text = chatMessageText(last).trim() if (!text) { return null } return { id: last.id, pending: Boolean(last.pending), text } } const consumePendingResponse = () => { const messages = $messages.get() const last = messages.findLast(m => m.role === 'assistant' && !m.hidden) if (last) { lastSpokenIdRef.current = last.id } } const submitVoiceTurn = async (text: string) => { if (busy) { return } triggerHaptic('submit') clearDraft() await onSubmit(text) } const conversation = useVoiceConversation({ busy, consumePendingResponse, enabled: voiceConversationActive, onFatalError: () => setVoiceConversationActive(false), onSubmit: submitVoiceTurn, onTranscribeAudio, pendingResponse }) const contextMenu = ( { triggerHaptic('open') setUrlOpen(true) }} onPasteClipboardImage={onPasteClipboardImage} onPickFiles={onPickFiles} onPickFolders={onPickFolders} onPickImages={onPickImages} state={state} /> ) const controls = ( { setVoiceConversationActive(false) void conversation.end() }, onStart: () => setVoiceConversationActive(true), onStopTurn: conversation.stopTurn, onToggleMute: conversation.toggleMute, status: conversation.status }} disabled={disabled} hasComposerPayload={hasComposerPayload} onDictate={dictate} state={state} voiceStatus={voiceStatus} /> ) const input = (
window.setTimeout(closeTrigger, 80)} onDragOver={handleInputDragOver} onDrop={handleInputDrop} onInput={handleEditorInput} onKeyDown={handleEditorKeyDown} onKeyUp={handleEditorKeyUp} onMouseUp={refreshTrigger} onPaste={handlePaste} ref={editorRef} role="textbox" suppressContentEditableWarning />
) return ( <> { e.preventDefault() submitDraft() }} ref={composerRef} > {showHelpHint && } {trigger && ( )} {activeQueueSessionKey && queuedPrompts.length > 0 && (
{ if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) { exitQueuedEdit('cancel') } }} onEdit={beginQueuedEdit} onSendNow={id => void sendQueuedNow(id)} />
)}
{dragActive && (
Drop files to attach
)}
{queueEdit && editingQueuedPrompt && (
Editing queued turn in composer
)} {attachments.length > 0 && }
{contextMenu}
{input}
{controls}
) } export function ChatBarFallback() { return (
) }