import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' import { ActionBarPrimitive, BranchPickerPrimitive, ComposerPrimitive, ErrorPrimitive, MessagePrimitive, type ToolCallMessagePartProps, useAui, useAuiState, useMessageRuntime } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { IconPlayerStopFilled } from '@tabler/icons-react' import { type ClipboardEvent, type ComponentProps, 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, droppedFileInlineRefs, type InlineRefInput, 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, isImagePath, partitionDroppedFiles } from '@/app/chat/hooks/use-composer-actions' import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions' import { ClarifyTool } from '@/components/assistant-ui/clarify-tool' import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text' import { ThreadMessageList } from '@/components/assistant-ui/thread-list' import { ThreadTimeline } from '@/components/assistant-ui/thread-timeline' import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback' import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button' import { UserMessageText } from '@/components/assistant-ui/user-message-text' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { DisclosureRow } from '@/components/chat/disclosure-row' import { GeneratedImage } from '@/components/chat/generated-image-result' import { Intro, type IntroProps } from '@/components/chat/intro' import { PreviewAttachment } from '@/components/chat/preview-attachment' import { Codicon } from '@/components/ui/codicon' import { ConfirmDialog } from '@/components/ui/confirm-dialog' 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 { useI18n } from '@/i18n' import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { LinkifiedText } from '@/lib/external-link' import { triggerHaptic } from '@/lib/haptics' import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon, XIcon } 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 { $compactionActive } from '@/store/compaction' import type { ComposerAttachment } from '@/store/composer' import { notifyError } from '@/store/notifications' import { $connection } from '@/store/session' import { notifyThreadEditClose, notifyThreadEditOpen } from '@/store/thread-scroll' import { $voicePlayback } from '@/store/voice-playback' type ThreadLoadingState = 'response' | 'session' interface MessageActionProps { messageId: string /** Lazy accessor — reads the live message text at action time. Passing the * text itself as a prop forces the whole footer to re-render on every * streaming delta flush (the text changes ~30×/s), which profiling showed * was a large slice of per-token script time on long transcripts. */ getMessageText: () => 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() : '' } // Cheap streaming-stable "does this message have visible text" check: returns // on the first non-whitespace text part without concatenating the whole // message. Used as a useAuiState selector so its boolean output stays stable // across token flushes (flips false→true once per turn). function contentHasVisibleText(content: unknown): boolean { if (typeof content === 'string') { return content.trim().length > 0 } if (!Array.isArray(content)) { return false } for (const part of content) { if (partText(part).trim().length > 0) { return true } } return false } export const Thread: FC<{ clampToComposer?: boolean cwd?: string | null gateway?: HermesGateway | null intro?: IntroProps loading?: ThreadLoadingState onBranchInNewChat?: (messageId: string) => void onCancel?: () => Promise | void onDismissError?: (messageId: string) => void onRestoreToMessage?: (messageId: string) => Promise | void sessionId?: string | null sessionKey?: string | null }> = ({ clampToComposer = false, cwd = null, gateway = null, intro, loading, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, sessionId = null, sessionKey }) => { const messageComponents = useMemo( () => ({ AssistantMessage: () => , SystemMessage, UserEditComposer: () => , UserMessage: () => }), [cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, 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 { t } = useI18n() return (
) } const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void onDismissError?: (messageId: string) => void }> = ({ onBranchInNewChat, onDismissError }) => { const messageId = useAuiState(s => s.message.id) const messageRuntime = useMessageRuntime() const { t } = useI18n() // PERF: this component must NOT subscribe to the streaming text. Every // selector here returns a value that stays referentially stable across // token flushes (booleans, status strings, '' while running), so the // 30 Hz delta stream only re-renders the markdown part and the tiny // StreamStallIndicator leaf — not the footer/preview/root subtree. const messageStatus = useAuiState(s => s.message.status?.type) const isRunning = messageStatus === 'running' const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0) const hasVisibleText = useAuiState(s => contentHasVisibleText(s.message.content)) // Preview targets only materialize once the turn completes — while running // the selector returns '' (stable), so per-token flushes skip the regex // scan and the re-render it would cause. const completedText = useAuiState(s => s.message.status?.type === 'running' ? '' : messageContentText(s.message.content) ) const previewTargets = useMemo(() => { if (!completedText || !/(https?:\/\/|file:\/\/)/i.test(completedText)) { return [] } return pickPrimaryPreviewTarget(extractPreviewTargets(completedText)) }, [completedText]) const getMessageText = useCallback(() => messageContentText(messageRuntime.getState().content), [messageRuntime]) const enterRef = useEnterAnimation(isRunning, `assistant-message:${messageId}`) if (isPlaceholder) { return null } return (
{/* Todos render in the composer status stack now, not inline. */} {isRunning && } {previewTargets.length > 0 && (
{previewTargets.map(target => ( ))}
)} {onDismissError && ( onDismissError(messageId)} side="top" tooltip={t.assistant.thread.dismissError} > )}
{hasVisibleText && ( )}
) } const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({ children, label, className, ...rest }) => (
{children}
) // Fixed label while auto-compaction runs — decoupled from backend status text. const COMPACTION_LABEL = 'Summarizing thread' const CompactionHint: FC = () => ( {COMPACTION_LABEL} ) const ResponseLoadingIndicator: FC = () => { const { t } = useI18n() const elapsed = useElapsedSeconds() const compacting = useStore($compactionActive) return ( ) } // Seconds of no visible output (text or part count) before a still-running turn // is treated as stalled and the thinking indicator returns at the tail. const STREAM_STALL_S = 2 // Tail "still thinking" indicator: the pre-first-token spinner goes away once // text flows, but if the stream then goes quiet mid-turn (tool think-time, // provider stall) nothing signals that work continues. Watch a per-flush // activity signal; when it hasn't changed for STREAM_STALL_S, re-show the // dither + a timer counting from the last activity. // // Subscribes to the activity signal ITSELF (rather than taking it as a prop) // so that per-token updates re-render only this leaf, not the whole // AssistantMessage subtree. const StreamStallIndicator: FC = () => { const activity = useAuiState(s => { let textLength = 0 for (const part of s.message.content) { const text = (part as { text?: unknown }).text if (typeof text === 'string') { textLength += text.length } } return `${s.message.content.length}:${textLength}` }) const [stalled, setStalled] = useState(false) const compacting = useStore($compactionActive) useEffect(() => { setStalled(false) const id = window.setTimeout(() => setStalled(true), STREAM_STALL_S * 1000) return () => window.clearTimeout(id) }, [activity]) const active = stalled || compacting const elapsed = useElapsedSeconds(active) if (!active) { return null } return ( ) } const ImageGenerateTool: FC = ({ args, result }) => { const aspectRatio = typeof args?.aspect_ratio === 'string' ? args.aspect_ratio : undefined 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 }) => { const { t } = useI18n() // `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() // Re-run when the disclosure toggles so the observer attaches to the new // DOM after expand/collapse (refs are conditionally rendered on `open`). }, [isPreview, open]) return (
setUserOpen(!open)} open={open}> {t.assistant.thread.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), endIndex + 1) .some(p => p?.type === 'reasoning' && p.status?.type !== 'complete') ) // A reasoning group with no actual text is pure noise — drop the whole // "Thinking" disclosure rather than leave an empty header eating a row. This // applies live too: encrypted/spinner-coerced reasoning (Opus reasoning max) // never carries visible text, and the bottom-of-thread loader already signals // "thinking", so an empty header is never wanted. Real reasoning surfaces the // instant its first token lands. const hasContent = useAuiState(s => s.message.parts .slice(Math.max(0, startIndex), endIndex + 1) .some(p => p?.type === 'reasoning' && typeof p.text === 'string' && p.text.trim().length > 0) ) if (!hasContent) { return null } return ( {children} ) } const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => { const displayText = text.trimStart() const messageRunning = useAuiState(s => s.message.status?.type === 'running') const isRunning = status?.type === 'running' || messageRunning return ( } isRunning={isRunning} text={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, labels: { today: (time: string) => string; yesterday: (time: string) => string } ): 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 labels.today(TIME_FMT.format(date)) } if (dayDelta === 1) { return labels.yesterday(TIME_FMT.format(date)) } return SHORT_FMT.format(date) } const AssistantActionBar: FC = ({ messageId, getMessageText, onBranchInNewChat }) => { const { t } = useI18n() const copy = t.assistant.thread const [menuOpen, setMenuOpen] = useState(false) return (
triggerHaptic('submit')} tooltip={copy.refresh}> e.preventDefault()} sideOffset={6}> onBranchInNewChat?.(messageId)}> {copy.branchNewChat}
) } const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getText, messageId }) => { const { t } = useI18n() const copy = t.assistant.thread 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 () => { const text = getText() if (!text || $voicePlayback.get().status !== 'idle') { return } try { await playSpeechText(text, { messageId, source: 'read-aloud' }) } catch (error) { notifyError(error, copy.readAloudFailed) } }, [copy.readAloudFailed, getText, messageId]) return ( { e.preventDefault() void (isSpeaking ? stopVoicePlayback() : read()) }} > {isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud} ) } const MessageTimestamp: FC = () => { const { t } = useI18n() const createdAt = useAuiState(s => s.message.createdAt) const label = formatMessageTimestamp(createdAt, t.assistant.thread) 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({ attachments, children, messageId }: { attachments?: ReactNode children: ReactNode messageId?: string }) { return ( // Fragment, not a wrapper: a wrapping element becomes the sticky's // containing block (it'd stick within its own height = never). The bubble // and attachments are flow siblings so the bubble pins against the scroller // while attachments below it scroll away. <>
{children}
{attachments} ) } // Shared "user bubble" base. Both the read-only message and the inline // edit composer render the same bubble surface (rounded glass card); // they only differ in border weight, cursor, and padding-right (the // read-only view reserves room for the restore icon). // // no-drag: sticky bubbles park at --sticky-human-top (~4px), sliding under the // titlebar's [-webkit-app-region:drag] strips (app-shell.tsx). Electron resolves // drag regions at the compositor level — z-index and pointer-events don't help — // so without the carve-out, clicking a stuck bubble drags the window instead of // opening the edit composer. 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-y-auto rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]' const USER_ACTION_ICON_BUTTON_CLASS = 'grid 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 = // Background-process notifications are injected into the conversation as user // messages (the agent must react to them, and message-role alternation forbids // a synthetic system row mid-loop). They are NOT something the human typed, so // render them as a compact system-style notice instead of a user bubble. // Shape: see tools/process_registry.py format_process_notification(). const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/ const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => { const body = text.replace(/^\[IMPORTANT:\s*/, '').replace(/\]$/, '') const newline = body.indexOf('\n') const headline = (newline === -1 ? body : body.slice(0, newline)).trim() const detail = newline === -1 ? '' : body.slice(newline + 1).trim() return (
{headline} {detail && (
output
            {detail}
          
)}
) } const UserMessage: FC<{ onCancel?: () => Promise | void onRestoreToMessage?: (messageId: string) => Promise | void }> = ({ onCancel, onRestoreToMessage }) => { const { t } = useI18n() const copy = t.assistant.thread const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false) 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 lastClampHeightRef = useRef(-1) const lineHeightRef = useRef(0) const measureClamp = useCallback((entries: readonly ResizeObserverEntry[]) => { const inner = clampInnerRef.current const outer = inner?.parentElement if (!inner || !outer) { return } // Prefer the size the ResizeObserver already computed — reading // `scrollHeight` outside RO timing forces a synchronous layout, and with // many user bubbles observed at once those reads interleave with the // style write below into a read-write-read reflow cascade. const entryHeight = entries.find(entry => entry.target === inner)?.borderBoxSize?.[0]?.blockSize const fullHeight = Math.ceil(entryHeight ?? inner.scrollHeight) if (fullHeight === lastClampHeightRef.current) { return } lastClampHeightRef.current = fullHeight // Line-height is stable for the life of the bubble (font settings don't // change under it) — resolve the computed style once. if (!lineHeightRef.current) { const styles = getComputedStyle(inner) lineHeightRef.current = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20 } outer.style.setProperty('--human-msg-full', `${fullHeight}px`) setBodyClamped(fullHeight > lineHeightRef.current * 2 + 1) }, []) useResizeObserver(measureClamp, clampInnerRef) // Injected background-process notification, not a human prompt — render the // compact system-style notice (after all hooks above have run). if (PROCESS_NOTIFICATION_RE.test(messageText.trim())) { return ( ) } const hasBody = messageText.trim().length > 0 const isLatestUser = messageId === latestUserId const showStop = isLatestUser && threadRunning && Boolean(onCancel) // Restore (re-run this exact prompt) is available everywhere the Stop button // isn't — including mid-stream on older prompts, since the action interrupts // the live turn before rewinding. const showRestore = !showStop && Boolean(onRestoreToMessage) && hasBody const bubbleClassName = cn( USER_BUBBLE_BASE_CLASS, 'cursor-pointer pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors', 'border-(--ui-stroke-tertiary) hover:border-(--ui-stroke-secondary)' ) const bubbleContent = 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.
{/* Match the edit composer's collapsed line box (min-h-[1.25rem]) so clicking to edit can't grow the bubble by a sub-pixel and reflow the turn 1px. */}
) return ( 0 ? (
) : null } >
{/* Always editable — clicking opens the edit composer even while a turn streams; sending the edit reverts (interrupt + rewind). */} {(showStop || showRestore) && (
{showStop ? ( ) : ( )}
)}
{copy.restoreCheckpoint} / {copy.goForward}
{showRestore && ( setRestoreConfirmOpen(false)} onConfirm={() => onRestoreToMessage?.(messageId)} open={restoreConfirmOpen} title={copy.restoreTitle} /> )}
) } const SLASH_STATUS_RE = /^slash:(?\/[^\n]+)\n(?[\s\S]*)$/ const STEER_NOTE_RE = /^steer:(?[\s\S]+)$/ const SystemMessage: FC = () => { const text = useAuiState(s => messageContentText(s.message.content)) if (!text) { return null } const steerNote = text.match(STEER_NOTE_RE) if (steerNote?.groups) { return ( steered · {steerNote.groups.text.trim()} ) } const slashStatus = text.match(SLASH_STATUS_RE) if (slashStatus?.groups) { const output = slashStatus.groups.output.trim() // Single-line status (e.g. "model → x") reads best centered inline; padded // multiline output (catalogs, usage tables) needs left-aligned, wider room // or the column alignment breaks. const multiline = output.includes('\n') return ( {slashStatus.groups.command} {multiline ? ( ) : ( <> · )} ) } const multiline = text.includes('\n') return ( ) } interface UserEditComposerProps { cwd: string | null gateway: HermesGateway | null sessionId: string | null } const UserEditComposer: FC = ({ cwd, gateway, sessionId }) => { const { t } = useI18n() const copy = t.assistant.thread 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([]) // See index.tsx: set in keydown when the open popover consumes a nav/control // key so the matching keyup skips refreshTrigger (timing-immune vs reading // `trigger`, which keyup sees as already-null after Escape). const triggerKeyConsumedRef = useRef(false) const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top') const [focusRequestId, setFocusRequestId] = useState(0) const [submitting, setSubmitting] = useState(false) // True while OS-drop files are being staged/uploaded into the session. Blocks // submit and shows a spinner so confirming the edit can't race the async // upload and drop the gateway-side ref before it lands in the draft. const [staging, setStaging] = useState(false) const expanded = draft.includes('\n') const canSubmit = draft.trim().length > 0 const at = useAtCompletions({ cwd, gateway, sessionId }) const slash = useSlashCompletions({ gateway }) useEffect(() => () => notifyThreadEditClose(), []) 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) // Only reset the highlight when the trigger actually changed (opened, or // the query/kind differs). Re-detecting the *same* trigger — e.g. on a // caret move (mouseup) or a stray refresh — must preserve the user's // current selection instead of snapping back to the first item. if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) { setTriggerActive(0) } }, [trigger]) 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 insertRefStrings = useCallback( (refs: InlineRefInput[]) => { const editor = editorRef.current if (!editor || refs.length === 0) { return false } const nextDraft = insertInlineRefsIntoEditor(editor, refs) if (nextDraft === null) { return false } draftRef.current = nextDraft aui.composer().setText(nextDraft) requestEditFocus() return true }, [aui, requestEditFocus] ) const insertDroppedRefs = useCallback( (candidates: ReturnType) => insertRefStrings(droppedFileInlineRefs(candidates, cwd)), [cwd, insertRefStrings] ) // OS/Finder drops carry an absolute path on THIS machine — the gateway can't // read it in remote mode, and an image needs its bytes uploaded for vision. // Stage each through the same file.attach/image.attach_bytes pipeline the main // composer uses, then insert the *gateway-side* ref the agent can resolve — // never the raw local path (the MahmoudR remote-attach bug, which the main // composer fixes but this edit composer used to reproduce). const uploadOsDropRefs = useCallback( async (osDrops: ReturnType): Promise => { if (!gateway || !sessionId) { // No session to stage into — best-effort inline refs (matches old path). return droppedFileInlineRefs(osDrops, cwd) } const remote = $connection.get()?.mode === 'remote' const requestGateway = (method: string, params?: Record) => gateway.request(method, params) const refs: InlineRefInput[] = [] for (const candidate of osDrops) { const path = candidate.path || '' if (!path) { continue } const kind: ComposerAttachment['kind'] = candidate.file?.type.startsWith('image/') || isImagePath(candidate.file?.name || path) ? 'image' : 'file' try { const uploaded = await uploadComposerAttachment( { detail: path, id: attachmentId(kind, path), kind, label: pathLabel(path), path }, { remote, requestGateway, sessionId } ) const ref = attachmentDisplayText(uploaded) if (ref) { refs.push(ref) } } catch (err) { notifyError(err, t.desktop.dropFiles) } } return refs }, [cwd, gateway, sessionId, t.desktop.dropFiles] ) 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() // In-app drags (project tree / gutter) are workspace-relative paths that // resolve on the gateway as-is, so they stay inline refs. OS drops need to // be staged + uploaded first, then their gateway-side ref is inserted. const { inAppRefs, osDrops } = partitionDroppedFiles(candidates) if (insertDroppedRefs(inAppRefs)) { triggerHaptic('selection') } if (osDrops.length) { setStaging(true) void uploadOsDropRefs(osDrops) .then(refs => { if (insertRefStrings(refs)) { triggerHaptic('selection') } }) .finally(() => setStaging(false)) } } 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 || staging || !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() triggerKeyConsumedRef.current = true setTriggerActive(idx => (idx + 1) % triggerItems.length) return } if (event.key === 'ArrowUp') { event.preventDefault() triggerKeyConsumedRef.current = true setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length) return } if (event.key === 'Enter' || event.key === 'Tab') { event.preventDefault() triggerKeyConsumedRef.current = true const item = triggerItems[triggerActive] if (item) { replaceTriggerWithChip(item) } return } if (event.key === 'Escape') { event.preventDefault() triggerKeyConsumedRef.current = true closeTrigger() return } } if (event.key === 'Escape') { event.preventDefault() aui.composer().cancel() return } if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() submitEdit(event.currentTarget) } } const handleKeyUp = () => { // If this keyup belongs to a key the open trigger popover already consumed // in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never // edit text, and for Escape the keydown already closed the menu — a refresh // here would re-detect the still-present `/` and instantly reopen it. We // read a ref set during keydown rather than `trigger`, because by keyup // time React has re-rendered and `trigger` may already be null. if (triggerKeyConsumedRef.current) { triggerKeyConsumedRef.current = false return } window.setTimeout(refreshTrigger, 0) } return (
{trigger && ( )}
window.setTimeout(closeTrigger, 80)} onDragOver={handleDragOver} onDrop={handleDrop} onFocus={() => markActiveComposer('edit')} onInput={handleInput} onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} onMouseUp={refreshTrigger} onPaste={handlePaste} ref={editorRef} role="textbox" spellCheck={false} suppressContentEditableWarning />