import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react' import { type ClipboardEvent, type FC, type FocusEvent, type FormEvent, type KeyboardEvent, type DragEvent as ReactDragEvent, useCallback, useEffect, useRef, useState } from 'react' import { ComposerDirectiveActions } from '@/app/chat/composer/directive-actions' import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from '@/app/chat/composer/drop-affordance' import { type ComposerInsertMode, focusComposerInput, markActiveComposer, onComposerFocusRequest, onComposerInsertRequest, releaseActiveComposer } from '@/app/chat/composer/focus' import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions' import { rebuildAroundCaret, triggerKeyUpHandler } from '@/app/chat/composer/hooks/use-composer-trigger' import { useComposerUndo } from '@/app/chat/composer/hooks/use-composer-undo' import { useEmojiCompletions } from '@/app/chat/composer/hooks/use-emoji-completions' import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions' import { dragHasAttachments, droppedFileInlineRefs, type InlineRefInput, insertInlineRefsIntoEditor } from '@/app/chat/composer/inline-refs' import { chipTypedPathOnSpace, pathifyRefs } from '@/app/chat/composer/path-refs' import { composerPlainText, insertComposerContentsAtCaret, placeCaretEnd, refChipElement, renderComposerContents, replaceBeforeCaret, RICH_INPUT_SLOT } from '@/app/chat/composer/rich-editor' import { detectTrigger, openDirectiveScope, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils' import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover' import { isRedoShortcut, isUndoShortcut } from '@/app/chat/composer/undo-history' import { chipTypedUrlOnSpace, linkifyUrls } from '@/app/chat/composer/url-refs' import { extractDroppedFiles, HERMES_PATHS_MIME, isImagePath, partitionDroppedFiles } from '@/app/chat/hooks/use-composer-actions' import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions' import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' import { StickyHumanMessageContainer, StopGlyph, USER_ACTION_ICON_BUTTON_CLASS, USER_ACTION_ICON_SIZE, USER_BUBBLE_BASE_CLASS } from '@/components/assistant-ui/thread/user-message' import { Codicon } from '@/components/ui/codicon' import type { HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime' import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { Loader2Icon } from '@/lib/icons' import { cn } from '@/lib/utils' import type { ComposerAttachment } from '@/store/composer' import { notifyError } from '@/store/notifications' import { $terminalBackend } from '@/store/session' import { isSessionRemote } from '@/store/session-states' import { notifyThreadEditClose } from '@/store/thread-scroll' interface UserEditComposerProps { cwd: string | null gateway: HermesGateway | null sessionId: string | null } export 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) // Capture the original draft immediately before the first edit. The runtime // may hydrate composer.text after this component's first render, so taking a // mount-time snapshot can incorrectly classify every later blur as dirty. const initialDraftRef = useRef(null) const draftRef = useRef(draft) const composingRef = useRef(false) 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 emoji = useEmojiCompletions() // Timers this composer schedules must not outlive it. Every callback below // touches component state, a ref or the composer core, and this is the one // composer that routinely unmounts mid-flight: confirming an edit tears it // down while the 200ms submit latch is still pending, so the latch resumes // against an unmounted tree. Two of the callbacks already carry defensive // try/catch for the racing-teardown case; clearing the timers removes the // race instead of surviving it. const pendingTimeoutsRef = useRef>(new Set()) const scheduleTimeout = useCallback((run: () => void, delayMs: number): void => { const id = window.setTimeout(() => { pendingTimeoutsRef.current.delete(id) run() }, delayMs) pendingTimeoutsRef.current.add(id) }, []) // This is the one composer that routinely unmounts, so it is where the focus // bus leaks: confirming or cancelling an edit tears the composer down while // `'edit'` is still the active target. Release it alongside the thread-scroll // cleanup so keyboard routing falls back to the visible chat composer. // // It also drains whatever `scheduleTimeout` still has pending, which is a // second concern under the same heading rather than a separate one: both // are "this composer is going away", they unmount together by definition, // and a sibling unmount-only effect would only be a second place to forget. useEffect( () => () => { notifyThreadEditClose() releaseActiveComposer('edit') for (const id of pendingTimeoutsRef.current) { window.clearTimeout(id) } pendingTimeoutsRef.current.clear() }, [] ) const focusEditor = useCallback(() => { const editor = editorRef.current focusComposerInput(editor) if (editor) { placeCaretEnd(editor) } markActiveComposer('edit') }, []) const requestEditFocus = useCallback(() => { setFocusRequestId(id => id + 1) }, []) const rememberInitialDraft = useCallback(() => { if (initialDraftRef.current === null) { initialDraftRef.current = draftRef.current } }, []) const appendExternalText = useCallback( (text: string, mode: ComposerInsertMode) => { const value = text.trim() if (!value) { return } rememberInitialDraft() 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, { trailingCommitted: true }) placeCaretEnd(editor) } setFocusRequestId(id => id + 1) }, [aui, rememberInitialDraft] ) // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { draftRef.current = draft const editor = editorRef.current if ( editor && (editor.childNodes.length === 0 || (document.activeElement !== editor && composerPlainText(editor) !== draft)) ) { // Inert by construction — this repaints on mount or when the editor // isn't the one being typed into. A message opened for edit is finished // text, so a `/command` ending it is committed and chips, matching how // the transcript rendered that same message a moment ago. renderComposerContents(editor, draft, { trailingCommitted: true }) 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 = sanitizeComposerInput(composerPlainText(editor)) if (nextDraft !== draftRef.current) { draftRef.current = nextDraft aui.composer().setText(nextDraft) } return nextDraft }, [aui] ) // Same stack the main composer owns, for the same reason: the editor mutates // through `Range` to dodge Chromium's O(n²) editing pipeline, which also // dodges its undo stack, so a paste was invisible to Cmd+Z. `rememberInitialDraft` // already marks every mutation site (it's the dirty-edit guard), so the undo // points ride along with it. const syncFromEditorRef = useCallback(() => { const editor = editorRef.current return editor ? syncDraftFromEditor(editor) : draftRef.current }, [syncDraftFromEditor]) const { recordUndoPoint, redo, undo, withUndoPoint } = useComposerUndo({ editorRef, syncDraftFromEditor: syncFromEditorRef }) 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 : trigger?.kind === ':' ? emoji.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 : trigger?.kind === ':' ? emoji.loading : false const replaceTriggerWithChip = useCallback( (item: Unstable_TriggerItem) => { const editor = editorRef.current if (!editor || !trigger) { return } rememberInitialDraft() recordUndoPoint() 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 ? scheduleTimeout(refreshTrigger, 0) : closeTrigger() } // In place first, spanning Chromium's split text nodes (see // rangeBeforeCaret). The re-render fallback only runs when the caret // genuinely can't anchor the token — it rebuilds from serialized text, // which re-chips `@` refs but resets the caret to the end. const fragment = document.createDocumentFragment() directive ? fragment.append(refChipElement(directive[1], directive[2]), document.createTextNode(' ')) : fragment.append(document.createTextNode(text)) if (!replaceBeforeCaret(editor, trigger.tokenLength, fragment)) { rebuildAroundCaret(editor, trigger.tokenLength, text) } finish() }, [ aui, closeTrigger, recordUndoPoint, refreshTrigger, rememberInitialDraft, requestEditFocus, scheduleTimeout, trigger ] ) const insertRefStrings = useCallback( (refs: InlineRefInput[]) => { const editor = editorRef.current if (!editor || refs.length === 0) { return false } // Bank BEFORE the insert — insertInlineRefsIntoEditor mutates in place, so // recording after it would snapshot the state we're trying to undo to. const undone = withUndoPoint(() => insertInlineRefsIntoEditor(editor, refs) !== null) if (!undone) { return false } rememberInitialDraft() const nextDraft = composerPlainText(editor) draftRef.current = nextDraft aui.composer().setText(nextDraft) requestEditFocus() return true }, [aui, rememberInitialDraft, requestEditFocus, withUndoPoint] ) 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 = isSessionRemote(sessionId) 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 }, { backendCwd: cwd, remote, requestGateway, sessionId, terminalBackend: $terminalBackend.get() } ) 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 flushEditorToDraft = useCallback( (editor: HTMLDivElement) => { if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') { editor.replaceChildren() } rememberInitialDraft() const nextDraft = syncDraftFromEditor(editor) scheduleTimeout(refreshTrigger, 0) return nextDraft }, [refreshTrigger, rememberInitialDraft, scheduleTimeout, syncDraftFromEditor] ) const handleInput = (event: FormEvent) => { if (composingRef.current) { return } flushEditorToDraft(event.currentTarget) } // Native typing/deleting still goes through Chromium's editing pipeline, whose // undo stack we've taken over — bank the pre-edit state here, while // `beforeinput` can still see the old text. const handleBeforeInput = (event: FormEvent) => { const inputType = (event.nativeEvent as InputEvent).inputType if (inputType === 'historyUndo' || inputType === 'historyRedo') { return } recordUndoPoint({ coalesce: inputType === 'insertText' || inputType === 'deleteContentBackward' }) } const handlePaste = (event: ClipboardEvent) => { const pastedText = sanitizeComposerInput(event.clipboardData.getData('text')) if (!pastedText || DATA_IMAGE_URL_RE.test(pastedText.trim())) { event.preventDefault() return } event.preventDefault() rememberInitialDraft() recordUndoPoint() // Links land as `@url:` chips, same as the main composer — including // consuming an open `@url:` scope rather than stacking a second directive // in front of the chip. insertComposerContentsAtCaret( event.currentTarget, pathifyRefs(linkifyUrls(pastedText)), openDirectiveScope(event.currentTarget) ) syncDraftFromEditor(event.currentTarget) } const submitEdit = (editor: HTMLDivElement) => { if (composingRef.current) { return } const nextDraft = syncDraftFromEditor(editor) if (submitting || staging || !nextDraft.trim()) { return } setSubmitting(true) // `aui.composer().send()` throws "Composer is not available" when the edit // composer core has been torn down (e.g. a blur-driven cancel raced the // click). Reset `submitting` on failure so the arrow can't wedge on `true` // and leave revert as the only way out (#49903 is the same unguarded-core // hazard on the main composer). try { aui.composer().send() // Clear latch after cooldown to allow re-submission. This prevents rapid // double-Enter but doesn't require tracking when onEdit settles (which may // be synchronous or async, and whose promise we don't have access to). scheduleTimeout(() => { setSubmitting(false) }, 200) } catch { setSubmitting(false) } } const handleEditBlur = useCallback( (event: FocusEvent) => { const nextTarget = event.relatedTarget if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { return } scheduleTimeout(() => { const root = rootRef.current const active = document.activeElement if (submitting || (root && active && root.contains(active))) { return } const editor = editorRef.current // Dirty edit guard: when the user actually typed something, blur must // not cancel the composer — that would discard their in-flight // edits. Compare against the draft captured immediately before the // first edit; when no edit event occurred, the current hydrated draft // is the clean baseline. const initialDraft = initialDraftRef.current ?? draftRef.current if (editor && syncDraftFromEditor(editor) !== initialDraft) { closeTrigger() return } closeTrigger() // Swallow the unbound-core throw: if the composer core was already torn // down (a send/cancel raced this timer), cancel() throws "Composer is // not available" as an uncaught renderer error. Nothing to cancel then. try { aui.composer().cancel() } catch { // Composer core already gone — the edit is closing anyway. } }, 80) }, [aui, closeTrigger, scheduleTimeout, submitting, syncDraftFromEditor] ) const handleKeyDown = (event: KeyboardEvent) => { // Self-heal a stale composition flag (same recovery as the main composer's // handleEditorKeyDown, #44135): compositionend can be missed, and a wedged // composingRef would swallow every Enter until the edit composer remounts. if (composingRef.current && !event.nativeEvent.isComposing) { composingRef.current = false } // IME composition: Enter confirms composed text, not a message submission. if (composingRef.current || event.nativeEvent.isComposing) { return } // IME commit Enter still carrying keyCode 229 (VK_PROCESSKEY) after // compositionend — same guard as the main composer. if (event.key === 'Enter' && event.keyCode === 229) { return } 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 } } // Undo/redo before Escape — we own the stack, and a stray Cmd+Z must never // fall through to something that cancels the edit outright. if (isUndoShortcut(event.nativeEvent)) { event.preventDefault() undo() return } if (isRedoShortcut(event.nativeEvent)) { event.preventDefault() redo() return } if (event.key === 'Escape') { event.preventDefault() aui.composer().cancel() return } // A typed link finished with a space chips like a pasted one. if (withUndoPoint(() => chipTypedUrlOnSpace(event))) { event.preventDefault() rememberInitialDraft() syncDraftFromEditor(event.currentTarget) return } // Same for a bare `@path`. if (withUndoPoint(() => chipTypedPathOnSpace(event))) { event.preventDefault() rememberInitialDraft() syncDraftFromEditor(event.currentTarget) return } if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() submitEdit(event.currentTarget) } } const handleKeyUp = triggerKeyUpHandler(triggerKeyConsumedRef, refreshTrigger) return (
{trigger && ( )}
scheduleTimeout(closeTrigger, 80)} onCompositionEnd={event => { composingRef.current = false flushEditorToDraft(event.currentTarget) }} onCompositionStart={() => { composingRef.current = true }} onDragOver={handleDragOver} onDrop={handleDrop} onFocus={() => markActiveComposer('edit')} onInput={handleInput} onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} onMouseUp={refreshTrigger} onPaste={handlePaste} ref={editorRef} role="textbox" spellCheck={false} suppressContentEditableWarning />