refactor(desktop): split thread.tsx god file into focused modules

Behavior-preserving extraction of the 1,942-line thread.tsx transcript
renderer into co-located sibling modules, matching the existing flat
assistant-ui/ convention:

- thread-content.ts / thread-timestamp.ts: pure helpers (+ unit tests)
- thread-types.ts: shared RestoreMessageTarget
- thread-status.tsx: loading / stall / background-resume indicators
- thread-message-parts.tsx: reasoning + tool part components
- assistant-message.tsx, system-message.tsx, user-message.tsx,
  user-edit-composer.tsx: the message renderers

thread.tsx now holds only the Thread route component (1,942 -> 119 lines).
Also drops a dead readAloudAudio module variable (no references).
This commit is contained in:
Brooklyn Nicholson 2026-06-30 01:21:55 -05:00
parent a81c5922a2
commit 7ff6908a59
12 changed files with 2020 additions and 1836 deletions

View file

@ -0,0 +1,258 @@
import {
ActionBarPrimitive,
BranchPickerPrimitive,
ErrorPrimitive,
MessagePrimitive,
useAuiState,
useMessageRuntime
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type FC, useCallback, useMemo, useState } from 'react'
import {
contentHasVisibleText,
messageContentText,
pickPrimaryPreviewTarget
} from '@/components/assistant-ui/thread-content'
import { MESSAGE_PARTS_COMPONENTS } from '@/components/assistant-ui/thread-message-parts'
import { StreamStallIndicator } from '@/components/assistant-ui/thread-status'
import { formatMessageTimestamp } from '@/components/assistant-ui/thread-timestamp'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
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 { useI18n } from '@/i18n'
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 { notifyError } from '@/store/notifications'
import { $voicePlayback } from '@/store/voice-playback'
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
}
export 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 (
<MessagePrimitive.Root
className="group flex w-full min-w-0 max-w-full flex-col gap-0 self-start overflow-hidden"
data-role="assistant"
data-slot="aui_assistant-message-root"
data-streaming={isRunning ? 'true' : undefined}
ref={enterRef}
>
<div
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground"
data-slot="aui_assistant-message-content"
>
{/* Todos render in the composer status stack now, not inline. */}
<MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
{isRunning && <StreamStallIndicator />}
{previewTargets.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{previewTargets.map(target => (
<PreviewAttachment key={target} source="explicit-link" target={target} />
))}
</div>
)}
<MessagePrimitive.Error>
<ErrorPrimitive.Root
className="mt-1.5 flex items-start gap-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
role="alert"
>
<ErrorPrimitive.Message className="min-w-0 flex-1" />
{onDismissError && (
<TooltipIconButton
className="-my-0.5 shrink-0 text-current opacity-70 hover:opacity-100"
onClick={() => onDismissError(messageId)}
side="top"
tooltip={t.assistant.thread.dismissError}
>
<XIcon className="size-3.5" />
</TooltipIconButton>
)}
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
</div>
{hasVisibleText && (
<AssistantFooter getMessageText={getMessageText} messageId={messageId} onBranchInNewChat={onBranchInNewChat} />
)}
</MessagePrimitive.Root>
)
}
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText, onBranchInNewChat }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const [menuOpen, setMenuOpen] = useState(false)
return (
<div className="relative flex w-full shrink-0 justify-end">
<ActionBarPrimitive.Root
className={cn(
// NOTE: intentionally NOT `hideWhenRunning`. That prop unmounts the
// bar while the thread streams, which collapses every completed
// assistant message's footer by this bar's height and shifts the
// whole conversation when the turn resolves. The bar is already
// invisible by default (opacity-0 + pointer-events-none, reveals on
// hover), so keeping it mounted reserves stable layout height with
// no visual change during streaming.
'relative flex flex-row items-center justify-end gap-2 py-1.5 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100',
menuOpen && 'pointer-events-auto opacity-100 [&_button]:opacity-100'
)}
data-slot="aui_msg-actions"
>
<CopyButton appearance="icon" buttonSize="icon" label={copy.copy} text={getMessageText} />
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip={copy.refresh}>
<Codicon name="refresh" />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
<DropdownMenuTrigger asChild>
<TooltipIconButton tooltip={copy.moreActions}>
<Codicon name="ellipsis" />
</TooltipIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" onCloseAutoFocus={e => e.preventDefault()} sideOffset={6}>
<MessageTimestamp />
<DropdownMenuItem onSelect={() => onBranchInNewChat?.(messageId)}>
<GitBranchIcon />
{copy.branchNewChat}
</DropdownMenuItem>
<ReadAloudItem getText={getMessageText} messageId={messageId} />
</DropdownMenuContent>
</DropdownMenu>
</ActionBarPrimitive.Root>
</div>
)
}
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 (
<DropdownMenuItem
disabled={isPreparing || (!isSpeaking && anyPlaybackActive)}
onSelect={e => {
e.preventDefault()
void (isSpeaking ? stopVoicePlayback() : read())
}}
>
<Icon className={isPreparing ? 'animate-spin' : undefined} />
{isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud}
</DropdownMenuItem>
)
}
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 <DropdownMenuLabel className="text-xs font-normal text-muted-foreground">{label}</DropdownMenuLabel>
}
const AssistantFooter: FC<MessageActionProps> = props => (
<div className="flex min-h-6 flex-col items-end gap-1 pr-(--message-text-indent) pl-(--message-text-indent)">
<BranchPickerPrimitive.Root
className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
hideWhenSingleBranch
>
<BranchPickerPrimitive.Previous className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
<Codicon name="chevron-left" size="0.875rem" />
</BranchPickerPrimitive.Previous>
<span className="tabular-nums">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
<Codicon name="chevron-right" size="0.875rem" />
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
<AssistantActionBar {...props} />
</div>
)

View file

@ -0,0 +1,81 @@
import { MessagePrimitive, useAuiState } from '@assistant-ui/react'
import { type FC } from 'react'
import { messageContentText } from '@/components/assistant-ui/thread-content'
import { Codicon } from '@/components/ui/codicon'
import { LinkifiedText } from '@/lib/external-link'
import { cn } from '@/lib/utils'
const SLASH_STATUS_RE = /^slash:(?<command>\/[^\n]+)\n(?<output>[\s\S]*)$/
const STEER_NOTE_RE = /^steer:(?<text>[\s\S]+)$/
export 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 (
<MessagePrimitive.Root
className="flex max-w-[min(86%,44rem)] items-center gap-1.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60"
data-role="system"
data-slot="aui_system-message-root"
>
<Codicon className="text-muted-foreground/55" name="compass" size="0.75rem" />
<span className="text-muted-foreground/55">steered</span>
<span className="text-muted-foreground/35">·</span>
<span className="whitespace-pre-wrap">{steerNote.groups.text.trim()}</span>
</MessagePrimitive.Root>
)
}
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 (
<MessagePrimitive.Root
className={cn(
'w-[60%] max-w-[44rem] self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60',
multiline ? 'text-left' : 'text-center'
)}
data-role="system"
data-slot="aui_system-message-root"
>
<span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span>
{multiline ? (
<LinkifiedText className="mt-0.5 block whitespace-pre-wrap" explicitOnly pretty={false} text={output} />
) : (
<>
<span className="mx-1.5 text-muted-foreground/35">·</span>
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={output} />
</>
)}
</MessagePrimitive.Root>
)
}
const multiline = text.includes('\n')
return (
<MessagePrimitive.Root
className={cn(
'w-[60%] max-w-[44rem] self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/55',
multiline ? 'text-left' : 'text-center'
)}
data-role="system"
data-slot="aui_system-message-root"
>
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={text} />
</MessagePrimitive.Root>
)
}

View file

@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import {
contentHasVisibleText,
messageAttachmentRefs,
messageContentText,
partText,
pickPrimaryPreviewTarget
} from './thread-content'
describe('partText', () => {
it('returns plain strings as-is', () => {
expect(partText('hello')).toBe('hello')
})
it('reads text from untyped and text parts', () => {
expect(partText({ text: 'a' })).toBe('a')
expect(partText({ type: 'text', text: 'b' })).toBe('b')
})
it('ignores non-text parts and malformed input', () => {
expect(partText({ type: 'tool', text: 'x' })).toBe('')
expect(partText({ text: 42 })).toBe('')
expect(partText(null)).toBe('')
expect(partText(undefined)).toBe('')
})
})
describe('messageContentText', () => {
it('trims string content', () => {
expect(messageContentText(' hi ')).toBe('hi')
})
it('concatenates array text parts and trims', () => {
expect(messageContentText([{ text: ' a' }, { type: 'text', text: 'b ' }])).toBe('ab')
})
it('returns empty string for non-string, non-array content', () => {
expect(messageContentText(null)).toBe('')
expect(messageContentText({ text: 'x' })).toBe('')
})
})
describe('contentHasVisibleText', () => {
it('detects visible text in strings and arrays', () => {
expect(contentHasVisibleText('hi')).toBe(true)
expect(contentHasVisibleText([{ text: ' ' }, { text: 'x' }])).toBe(true)
})
it('is false when there is no visible text', () => {
expect(contentHasVisibleText(' ')).toBe(false)
expect(contentHasVisibleText([{ text: ' ' }, { type: 'tool', text: 'y' }])).toBe(false)
expect(contentHasVisibleText(null)).toBe(false)
})
})
describe('messageAttachmentRefs', () => {
it('returns string arrays untouched', () => {
const value = ['@file:a', '@file:b']
expect(messageAttachmentRefs(value)).toBe(value)
})
it('returns a stable empty array for invalid input', () => {
const a = messageAttachmentRefs(null)
const b = messageAttachmentRefs([1, 2])
expect(a).toEqual([])
expect(a).toBe(b)
})
})
describe('pickPrimaryPreviewTarget', () => {
it('returns the input when one or zero targets', () => {
expect(pickPrimaryPreviewTarget([])).toEqual([])
expect(pickPrimaryPreviewTarget(['https://x.dev'])).toEqual(['https://x.dev'])
})
it('prefers a localhost URL when present', () => {
expect(pickPrimaryPreviewTarget(['https://example.com', 'http://localhost:3000'])).toEqual([
'http://localhost:3000'
])
})
it('falls back to the last target when no localhost URL', () => {
expect(pickPrimaryPreviewTarget(['https://a.dev', 'https://b.dev'])).toEqual(['https://b.dev'])
})
})

View file

@ -0,0 +1,63 @@
const EMPTY_ATTACHMENT_REFS: string[] = []
export 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 : ''
}
export 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).
export 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 function messageAttachmentRefs(value: unknown): string[] {
if (!Array.isArray(value)) {
return EMPTY_ATTACHMENT_REFS
}
return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS
}
export 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]]
}

View file

@ -0,0 +1,205 @@
import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
import { type ComponentProps, type FC, type ReactNode, useEffect, useRef, useState } from 'react'
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
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 { useI18n } from '@/i18n'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ args, result }) => {
const aspectRatio = typeof args?.aspect_ratio === 'string' ? args.aspect_ratio : undefined
return (
<div className="mt-1.5">
<GeneratedImage aspectRatio={aspectRatio} result={result} />
</div>
)
}
const ChainToolFallback: FC<ToolCallMessagePartProps> = 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 <ImageGenerateTool {...props} />
}
if (props.toolName === 'clarify') {
return <ClarifyTool {...props} />
}
return <ToolFallback {...props} />
}
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<boolean | null>(null)
const elapsed = useElapsedSeconds(pending, timerKey)
const scrollRef = useRef<HTMLDivElement | null>(null)
const contentRef = useRef<HTMLDivElement | null>(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 (
<div
className="text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)"
data-slot="aui_thinking-disclosure"
ref={enterRef}
>
<DisclosureRow onToggle={() => setUserOpen(!open)} open={open}>
<span className="flex min-w-0 items-baseline gap-1.5">
<span
className={cn(
'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)',
pending && 'shimmer text-foreground/55'
)}
>
{t.assistant.thread.thinking}
</span>
{pending && (
<ActivityTimerText
className="text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)"
seconds={elapsed}
/>
)}
</span>
</DisclosureRow>
{open && (
<div
className={cn(
// Body sits flush with the "Thinking" header — no left indent —
// and inherits the disclosure-level opacity fade defined in
// styles.css (~0.67 at rest, 1 on hover/focus).
'mt-0.5 w-full min-w-0 max-w-full overflow-hidden wrap-anywhere pb-1',
isPreview && 'thinking-preview max-h-40'
)}
ref={scrollRef}
>
<div ref={contentRef}>{children}</div>
</div>
)}
</div>
)
}
// 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 (
<ThinkingDisclosure messageRunning={messageRunning} pending={pending} timerKey={`reasoning:${messageId}`}>
{children}
</ThinkingDisclosure>
)
}
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 (
<MarkdownTextContent
containerClassName="text-xs leading-snug text-muted-foreground/85"
containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
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.
export const MESSAGE_PARTS_COMPONENTS = {
Reasoning: ReasoningTextPart,
ReasoningGroup: ReasoningAccordionGroup,
Text: MarkdownText,
ToolGroup: ToolGroupSlot,
tools: { Fallback: ChainToolFallback }
} as const

View file

@ -0,0 +1,167 @@
import { useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type FC, type ReactNode, useEffect, useState } from 'react'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $backgroundResume } from '@/store/background-delegation'
import { $compactionActive } from '@/store/compaction'
import { $activeSessionAwaitingInput } from '@/store/prompts'
const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({
children,
label,
className,
...rest
}) => (
<div
aria-label={label}
aria-live="polite"
className={cn('flex max-w-full items-center gap-2 self-start text-sm text-muted-foreground/70', className)}
role="status"
{...rest}
>
{children}
</div>
)
// Fixed label while auto-compaction runs — decoupled from backend status text.
const COMPACTION_LABEL = 'Summarizing thread'
const CompactionHint: FC = () => (
<span className="shimmer min-w-0 truncate text-muted-foreground/55">{COMPACTION_LABEL}</span>
)
export const CenteredThreadSpinner: FC = () => {
const { t } = useI18n()
return (
<div
aria-label={t.assistant.thread.loadingSession}
className="pointer-events-none absolute inset-0 z-1 grid place-items-center"
role="status"
>
<Loader
aria-hidden="true"
className="size-12 text-midground/70"
pathSteps={220}
role="presentation"
strokeScale={0.72}
type="rose-curve"
/>
</div>
)
}
export const ResponseLoadingIndicator: FC = () => {
const { t } = useI18n()
const elapsed = useElapsedSeconds()
const compacting = useStore($compactionActive)
return (
<StatusRow
data-slot="aui_response-loading"
label={compacting ? COMPACTION_LABEL : t.assistant.thread.loadingResponse}
>
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
{compacting && <CompactionHint />}
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)
}
// Parked-background affordance: a top-level delegate_task runs in the
// background, so the parent turn ends and the app goes idle while the subagent
// keeps working and its result re-enters as a fresh turn later. Instead of a
// spinner (reads as "stuck"), reuse the same compact, centered system-note
// chrome as the steer / slash-status lines (SystemMessage above) so it sits in
// the thread like every other meta line. Idle-only (gated upstream). Null when
// nothing is parked.
export const BackgroundResumeNotice: FC = () => {
const { t } = useI18n()
const resume = useStore($backgroundResume)
if (!resume) {
return null
}
const label = resume.activity ?? t.assistant.thread.resumeWhenBackgroundDone(resume.count)
return (
<div
aria-live="polite"
className="flex max-w-[min(86%,44rem)] items-center gap-1.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/55"
data-slot="aui_background-resume"
role="status"
>
<Codicon className="text-muted-foreground/55" name="sync" size="0.75rem" />
<span className="shimmer min-w-0 truncate">{label}</span>
</div>
)
}
// 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.
export 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)
// A pending clarify / approval / sudo / secret means the turn is paused on the
// user, not working — so don't resurrect the "thinking" timer while they
// decide (matches the pet's awaitingInput pose taking priority over busy).
const awaitingInput = useStore($activeSessionAwaitingInput)
useEffect(() => {
setStalled(false)
const id = window.setTimeout(() => setStalled(true), STREAM_STALL_S * 1000)
return () => window.clearTimeout(id)
}, [activity])
const active = (stalled || compacting) && !awaitingInput
const elapsed = useElapsedSeconds(active)
if (!active) {
return null
}
return (
<StatusRow
className="mt-1.5"
data-slot="aui_stream-stall"
label={compacting ? COMPACTION_LABEL : 'Hermes is thinking'}
>
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
{compacting && <CompactionHint />}
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)
}

View file

@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { formatMessageTimestamp } from './thread-timestamp'
const labels = {
today: (time: string) => `Today at ${time}`,
yesterday: (time: string) => `Yesterday at ${time}`
}
describe('formatMessageTimestamp', () => {
it('returns an empty string for missing values', () => {
expect(formatMessageTimestamp(undefined, labels)).toBe('')
expect(formatMessageTimestamp('not-a-date', labels)).toBe('')
})
it('uses the today label for timestamps earlier today', () => {
const now = new Date()
const earlierToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 30)
expect(formatMessageTimestamp(earlierToday, labels)).toMatch(/^Today at /)
})
it('uses the yesterday label for timestamps the prior day', () => {
const now = new Date()
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 0)
yesterday.setDate(yesterday.getDate() - 1)
expect(formatMessageTimestamp(yesterday, labels)).toMatch(/^Yesterday at /)
})
it('falls back to an absolute format for older timestamps', () => {
const old = new Date(2020, 0, 15, 9, 30)
const out = formatMessageTimestamp(old, labels)
expect(out).not.toMatch(/^Today at /)
expect(out).not.toMatch(/^Yesterday at /)
expect(out.length).toBeGreaterThan(0)
})
})

View file

@ -0,0 +1,39 @@
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()
}
export 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)
}

View file

@ -0,0 +1,4 @@
export interface RestoreMessageTarget {
text: string
userOrdinal: number | null
}

File diff suppressed because it is too large Load diff

View file

@ -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<UserEditComposerProps> = ({ cwd, gateway, sessionId }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const aui = useAui()
const draft = useAuiState(s => s.composer.text)
const rootRef = useRef<HTMLDivElement | null>(null)
const editorRef = useRef<HTMLDivElement | null>(null)
const draftRef = useRef(draft)
const dragDepthRef = useRef(0)
const [dragActive, setDragActive] = useState(false)
const [trigger, setTrigger] = useState<TriggerState | null>(null)
const [triggerActive, setTriggerActive] = useState(0)
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
// 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<typeof extractDroppedFiles>) => 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<typeof extractDroppedFiles>): Promise<InlineRefInput[]> => {
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 = <T,>(method: string, params?: Record<string, unknown>) =>
gateway.request<T>(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<HTMLElement>) => {
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
event.preventDefault()
dragDepthRef.current += 1
if (!dragActive) {
setDragActive(true)
}
}
const handleDragOver = (event: ReactDragEvent<HTMLElement>) => {
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}
const handleDragLeave = (event: ReactDragEvent<HTMLElement>) => {
event.preventDefault()
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) {
setDragActive(false)
}
}
const handleDrop = (event: ReactDragEvent<HTMLElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<ComposerPrimitive.Root className="contents" data-slot="aui_edit-composer-root">
<StickyHumanMessageContainer>
<div
className="composer-human-message-container human-execution-message-top relative flex w-full items-start rounded-md bg-(--ui-chat-surface-background)"
onBlur={handleEditBlur}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
ref={rootRef}
>
{trigger && (
<ComposerTriggerPopover
activeIndex={triggerActive}
items={triggerItems}
kind={trigger.kind}
loading={triggerLoading}
onHover={setTriggerActive}
onPick={replaceTriggerWithChip}
placement={triggerPlacement}
/>
)}
<div
className={cn(
USER_BUBBLE_BASE_CLASS,
'ui-prompt-input__container relative border-(--ui-stroke-secondary) data-[expanded=true]:min-h-20',
COMPOSER_DROP_FADE_CLASS,
dragActive && COMPOSER_DROP_ACTIVE_CLASS
)}
data-expanded={expanded ? 'true' : undefined}
>
<div
aria-label={copy.editMessage}
autoCapitalize="off"
autoCorrect="off"
className={cn(
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] text-foreground/95 outline-none',
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
'**:data-ref-text:cursor-default',
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
)}
contentEditable
data-placeholder={copy.editMessage}
data-slot={RICH_INPUT_SLOT}
onBlur={() => 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
/>
<ComposerPrimitive.Input
asChild
className="sr-only"
submitMode="ctrlEnter"
tabIndex={-1}
unstable_focusOnScrollToBottom={false}
>
<textarea
aria-hidden
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="sr-only"
spellCheck={false}
tabIndex={-1}
/>
</ComposerPrimitive.Input>
{staging && (
<span
className="pointer-events-none absolute bottom-2 left-2 inline-flex items-center gap-1 rounded-full bg-background/80 px-1.5 py-0.5 text-[0.62rem] text-muted-foreground backdrop-blur-[1px]"
data-slot="aui_edit-staging"
>
<Loader2Icon className="size-3 animate-spin" />
{copy.attachingFile}
</span>
)}
<button
aria-label={copy.sendEdited}
className={cn('absolute right-2 bottom-2 size-5', USER_ACTION_ICON_BUTTON_CLASS)}
disabled={!canSubmit || submitting || staging}
onClick={() => {
const editor = editorRef.current
if (editor) {
submitEdit(editor)
}
}}
title={copy.sendEdited}
type="button"
>
{submitting ? StopGlyph : <Codicon name="arrow-up" size={USER_ACTION_ICON_SIZE} />}
</button>
</div>
</div>
</StickyHumanMessageContainer>
</ComposerPrimitive.Root>
)
}

View file

@ -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.
<>
<div
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-1"
data-message-id={messageId}
data-role="user"
data-slot="aui_user-message-root"
>
{children}
</div>
{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 = <StopFilled aria-hidden className="size-3.5 -translate-y-px" />
// 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 (
<div className="flex max-w-[min(86%,44rem)] flex-col gap-0.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60">
<span className="flex items-center gap-1.5">
<Codicon className="shrink-0 text-muted-foreground/55" name="terminal" size="0.75rem" />
<span className="wrap-anywhere">{headline}</span>
</span>
{detail && (
<details className="pl-[1.3125rem]">
<summary className="cursor-pointer select-none text-muted-foreground/45 hover:text-muted-foreground/70">
output
</summary>
<pre
className="mt-0.5 max-h-48 overflow-auto whitespace-pre-wrap font-mono text-[0.625rem] leading-4 text-muted-foreground/55"
data-selectable-text="true"
>
{detail}
</pre>
</details>
)}
</div>
)
}
export const UserMessage: FC<{
onCancel?: () => Promise<void> | 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<HTMLDivElement | null>(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 (
<MessagePrimitive.Root
className="flex w-full min-w-0 flex-col items-stretch"
data-role="user"
data-slot="aui_user-message-root"
>
<ProcessNotificationNote text={messageText.trim()} />
</MessagePrimitive.Root>
)
}
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.
<div
className={cn(clampActive && 'sticky-human-clamp')}
data-clamped={clampActive && bodyClamped ? 'true' : undefined}
>
{/* 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. */}
<div className="min-h-[1.25rem]" ref={clampInnerRef}>
<UserMessageText className="wrap-anywhere" text={messageText} />
</div>
</div>
)
return (
<MessagePrimitive.Root asChild>
<StickyHumanMessageContainer
attachments={
// Attachments live BELOW the sticky bubble in normal flow, so they
// scroll away behind the pinned bubble instead of riding along with
// it. Image refs render as thumbnails, file refs as chips; no border.
attachmentRefs.length > 0 ? (
<div className="flex flex-wrap gap-1 -mt-3 mb-2">
<DirectiveContent text={attachmentRefs.join(' ')} />
</div>
) : null
}
messageId={messageId}
>
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
<div className="relative w-full">
{readOnly ? (
// Spectator transcript: clicking only toggles the clamp so the
// full prompt is readable — never opens an edit composer.
<button
aria-expanded={bodyClamped ? expanded : undefined}
className={cn(bubbleClassName, !bodyClamped && 'cursor-default')}
onClick={() => {
if (!bodyClamped) {
return
}
triggerHaptic('selection')
setExpanded(value => !value)
}}
title={bodyClamped ? (expanded ? t.common.collapse : copy.expandMessage) : undefined}
type="button"
>
{bubbleContent}
</button>
) : (
// Always editable — clicking opens the edit composer even while a
// turn streams; sending the edit reverts (interrupt + rewind).
<ActionBarPrimitive.Edit asChild>
<button
aria-label={copy.editMessage}
className={bubbleClassName}
onClick={() => triggerHaptic('selection')}
onPointerDown={() => notifyThreadEditOpen()}
title={copy.editMessage}
type="button"
>
{bubbleContent}
</button>
</ActionBarPrimitive.Edit>
)}
{(showStop || showRestore) && (
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
{showStop ? (
<button
aria-label={copy.stop}
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
void onCancel?.()
}}
title={copy.stop}
type="button"
>
{StopGlyph}
</button>
) : (
<button
aria-label={copy.restoreCheckpoint}
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onRequestRestoreConfirm?.(messageId, {
text: messageText,
userOrdinal: runtimeUserOrdinal
})
}}
onPointerDown={event => {
event.preventDefault()
event.stopPropagation()
}}
title={copy.restoreFromHere}
type="button"
>
<Codicon name="discard" size="0.875rem" />
</button>
)}
</div>
)}
</div>
<BranchPickerPrimitive.Root
className={cn(
'checkpoint-container flex items-center gap-1 pb-0 pt-1 pl-1.5 text-[0.75rem] leading-none text-(--ui-text-tertiary)',
readOnly && 'hidden'
)}
hideWhenSingleBranch
>
<span aria-hidden className="checkpoint-icon size-1.5 rounded-full border border-current" />
<BranchPickerPrimitive.Previous
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
title={copy.restorePrevious}
>
{copy.restoreCheckpoint}
</BranchPickerPrimitive.Previous>
<span className="checkpoint-divider opacity-55">
<BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
title={copy.restoreNext}
>
{copy.goForward}
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
</div>
</ActionBarPrimitive.Root>
</StickyHumanMessageContainer>
</MessagePrimitive.Root>
)
}