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 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 { 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 { 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 { 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 { 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 } 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 type { ComposerAttachment } from '@/store/composer' import { notifyError } from '@/store/notifications' import { $connection } from '@/store/session' 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() : '' } 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 { t } = useI18n() return (
) } 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 enterRef = useEnterAnimation(messageStatus === 'running', `assistant-message:${messageId}`) if (isPlaceholder) { return null } return (
{hoistedTodos.length > 0 && } {messageStatus === 'running' && } {previewTargets.length > 0 && (
{previewTargets.map(target => ( ))}
)}
{messageText.trim().length > 0 && ( )}
) } const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({ children, label, className, ...rest }) => (
{children}
) const ResponseLoadingIndicator: FC = () => { const { t } = useI18n() const elapsed = useElapsedSeconds() 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-render // activity signal; when it hasn't changed for STREAM_STALL_S, re-show the // dither + a timer counting from the last activity. const StreamStallIndicator: FC<{ activity: string }> = ({ activity }) => { const [stalled, setStalled] = useState(false) useEffect(() => { setStalled(false) const id = window.setTimeout(() => setStalled(true), STREAM_STALL_S * 1000) return () => window.clearTimeout(id) }, [activity]) const elapsed = useElapsedSeconds(stalled) if (!stalled) { return null } 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 }) => { 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, messageText, 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<{ messageId: string; text: string }> = ({ messageId, text }) => { 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 () => { if (!text || $voicePlayback.get().status !== 'idle') { return } try { await playSpeechText(text, { messageId, source: 'read-aloud' }) } catch (error) { notifyError(error, copy.readAloudFailed) } }, [copy.readAloudFailed, messageId, text]) 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({ 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); // 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-hidden 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 = const UserMessage: FC<{ onCancel?: () => Promise | void }> = ({ onCancel }) => { const { t } = useI18n() const copy = t.assistant.thread 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 ? ( ) : ( )}
)}
{copy.restoreCheckpoint} / {copy.goForward}
) } 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) { return ( {slashStatus.groups.command} · ) } 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 }) 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" suppressContentEditableWarning /> {staging && ( {copy.attachingFile} )}
) }