- {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
- />
-
-
-
- {staging && (
-
-
- {copy.attachingFile}
-
- )}
-
-
-
-
-
- )
-}
diff --git a/apps/desktop/src/components/assistant-ui/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/user-edit-composer.tsx
new file mode 100644
index 00000000000..ffc45e166d8
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/user-edit-composer.tsx
@@ -0,0 +1,701 @@
+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 { 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 { 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/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 { 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 { $connection } from '@/store/session'
+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)
+ 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
+ />
+
+
+
+ {staging && (
+
+
+ {copy.attachingFile}
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/apps/desktop/src/components/assistant-ui/user-message.tsx b/apps/desktop/src/components/assistant-ui/user-message.tsx
new file mode 100644
index 00000000000..89a387ee8ae
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/user-message.tsx
@@ -0,0 +1,367 @@
+import { ActionBarPrimitive, BranchPickerPrimitive, MessagePrimitive, useAuiState } from '@assistant-ui/react'
+import { type FC, type ReactNode, useCallback, useRef, useState } from 'react'
+
+import { DirectiveContent } from '@/components/assistant-ui/directive-text'
+import { messageAttachmentRefs, messageContentText } from '@/components/assistant-ui/thread-content'
+import { type RestoreMessageTarget } from '@/components/assistant-ui/thread-types'
+import { UserMessageText } from '@/components/assistant-ui/user-message-text'
+import { Codicon } from '@/components/ui/codicon'
+import { useResizeObserver } from '@/hooks/use-resize-observer'
+import { useI18n } from '@/i18n'
+import { triggerHaptic } from '@/lib/haptics'
+import { StopFilled } from '@/lib/icons'
+import { cn } from '@/lib/utils'
+import { notifyThreadEditOpen } from '@/store/thread-scroll'
+import { isWatchWindow } from '@/store/windows'
+
+export 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.
+export 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]'
+
+export 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'
+
+export const USER_ACTION_ICON_SIZE = '0.6875rem'
+export 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}
+
+
+ )}
+
+ )
+}
+
+export const UserMessage: FC<{
+ onCancel?: () => Promise
| void
+ onRequestRestoreConfirm?: (messageId: string, target: RestoreMessageTarget) => void
+}> = ({ onCancel, onRequestRestoreConfirm }) => {
+ 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 runtimeUserOrdinal = useAuiState(s => {
+ let ordinal = 0
+
+ for (const message of s.thread.messages) {
+ if (message.role !== 'user') {
+ continue
+ }
+
+ if (message.id === s.message.id) {
+ return ordinal
+ }
+
+ ordinal += 1
+ }
+
+ 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)
+
+ // Watch windows spectate a subagent run driven elsewhere — prompts can't be
+ // edited, restored, or stopped from here. The bubble stays a button that
+ // toggles the 2-line clamp so long prompts are still fully readable.
+ const readOnly = isWatchWindow()
+ const [expanded, setExpanded] = useState(false)
+ const clampActive = !(readOnly && expanded)
+
+ 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 = !readOnly && 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 = !readOnly && !showStop && Boolean(onRequestRestoreConfirm) && 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
+ }
+ messageId={messageId}
+ >
+
+
+
+ {readOnly ? (
+ // Spectator transcript: clicking only toggles the clamp so the
+ // full prompt is readable — never opens an edit composer.
+
+ ) : (
+ // 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}
+
+
+
+
+
+
+ )
+}