Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
import { useStore } from '@nanostores/react'
import { $backdrop } from '@/store/backdrop'
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
export function Backdrop() {
const on = useStore($backdrop)
if (!on) {
return null
}
return (
<div aria-hidden className="pointer-events-none absolute inset-0 z-2 opacity-[0.025] mix-blend-difference">
<img
alt=""
className="h-[160dvh] w-auto min-w-dvw object-cover object-left-top [filter:invert(var(--backdrop-invert-mul,1))]"
fetchPriority="low"
src={assetPath('ds-assets/filler-bg0.jpg')}
/>
</div>
)
}
@@ -0,0 +1,33 @@
import { memo, useMemo } from 'react'
import { ansiColorClass, hasAnsiCodes, parseAnsi } from '@/lib/ansi'
import { cn } from '@/lib/utils'
interface AnsiTextProps {
text: string
className?: string
}
/** Renders text with embedded ANSI SGR codes as colored / bold spans. Falls
* back to a plain string node when no codes are present so the parser cost
* is paid only when there's something to colorize. */
export const AnsiText = memo(({ className, text }: AnsiTextProps) => {
const segments = useMemo(() => (hasAnsiCodes(text) ? parseAnsi(text) : null), [text])
if (!segments) {
return <span className={className}>{text}</span>
}
return (
<span className={className}>
{segments.map((segment, index) => (
<span
className={cn(segment.bold && 'font-semibold', segment.fg && ansiColorClass(segment.fg))}
key={`ansi-${index}`}
>
{segment.text}
</span>
))}
</span>
)
})
@@ -0,0 +1,136 @@
'use client'
import { useStore } from '@nanostores/react'
import { useEffect, useMemo } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { CodeCardIcon } from '@/components/chat/code-card'
import { WIDGET_SHELL_CLASS } from '@/components/chat/widget-shell'
import { useI18n } from '@/i18n'
import type { ArtifactDetection } from '@/lib/artifact-detect'
import { codiconForLanguage } from '@/lib/markdown-code'
import { cn } from '@/lib/utils'
import { $artifactRegistry, artifactsForSession, openArtifact, upsertArtifact } from '@/store/artifacts'
interface ArtifactCardProps {
code: string
detection: ArtifactDetection
streaming?: boolean
}
const KIND_ICON: Record<ArtifactDetection['kind'], string> = {
code: 'code',
html: 'browser',
svg: 'symbol-color'
}
function detectionIcon(detection: ArtifactDetection): string {
return detection.kind === 'code' ? codiconForLanguage(detection.language) : KIND_ICON[detection.kind]
}
/**
* Transcript stand-in for a fenced block that was promoted to an artifact.
* Replaces the wall of code with a compact, openable card: icon, title, kind,
* version badge. While the fence is still streaming
* it shows a shimmer + line count instead of the growing source.
*
* Registration is automatic on completion (so version history accumulates
* even if the user never opens the card) but opening the rail is strictly
* click-driven — a background stream never steals the pane.
*/
export function ArtifactCard({ code, detection, streaming = false }: ArtifactCardProps) {
const { t } = useI18n()
const copy = t.artifactCard
const view = useSessionView()
const runtimeId = useStore(view.$runtimeId)
const storedId = useStore(view.$storedId)
const registry = useStore($artifactRegistry)
const sessionId = storedId || runtimeId || ''
const trimmed = code.trim()
// Register/version the artifact once its fence has finished streaming.
// upsertArtifact dedupes on content hash, so re-renders and transcript
// replays are no-ops.
useEffect(() => {
if (!streaming && sessionId && trimmed) {
upsertArtifact(sessionId, detection, trimmed)
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- detection derives from code
}, [detection.kind, detection.language, detection.title, sessionId, streaming, trimmed])
const record = useMemo(() => {
void registry
const slugMatch = artifactsForSession(sessionId).find(
candidate => candidate.kind === detection.kind && candidate.versions.some(v => v.content === trimmed)
)
return slugMatch ?? null
}, [detection.kind, registry, sessionId, trimmed])
const lineCount = useMemo(() => trimmed.split('\n').length, [trimmed])
const kindLabel = copy.kind[detection.kind]
const versionCount = record?.versions.length ?? 0
const title = (record?.title || detection.title || kindLabel).trim() || kindLabel
const open = () => {
if (streaming || !sessionId || !trimmed) {
return
}
// Ensure the registry row exists even if the completion effect hasn't
// fired yet (e.g. clicked in the same frame the stream sealed).
const result = upsertArtifact(sessionId, detection, trimmed)
if (!result) {
return
}
// An older card opens at ITS version, not silently the newest — the user
// clicked this specific iteration.
const versionIndex = result.record.versions.findIndex(version => version.content === trimmed)
openArtifact(result.artifactId, versionIndex === -1 ? undefined : versionIndex)
}
return (
<button
className={cn(
WIDGET_SHELL_CLASS,
'group/artifact my-1.5 flex w-full max-w-md items-center gap-2.5 overflow-hidden text-left',
streaming ? 'cursor-default' : 'cursor-pointer'
)}
data-slot="aui_artifact-card"
disabled={streaming}
onClick={open}
type="button"
>
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground">
<CodeCardIcon className="text-[1rem]" name={detectionIcon(detection)} />
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
'block truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground',
streaming && 'shimmer text-foreground/55'
)}
>
{title}
</span>
<span className="block truncate text-[length:var(--conversation-tool-font-size)] text-muted-foreground">
{streaming
? copy.generating(lineCount)
: versionCount > 1
? `${kindLabel} · ${copy.versionBadge(versionCount)}`
: kindLabel}
</span>
</span>
{!streaming && (
<span className="shrink-0 text-[length:var(--conversation-tool-font-size)] font-medium text-muted-foreground opacity-0 transition-opacity group-hover/artifact:opacity-100">
{copy.open}
</span>
)}
</button>
)
}
@@ -0,0 +1,76 @@
/**
* The `chat.empty` slot mounts EVERY contributor, not just the first.
*
* Ownership of an empty transcript is per session and is not known until each
* plugin has loaded its own data, so a first-wins slot let whichever plugin
* happened to register first suppress the one that actually owns the chat —
* silently, permanently, and only for some users, since registration order
* depends on which plugins are installed.
*/
import { render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { registry } from '@/contrib'
import { CHAT_EMPTY_AREA, type ChatEmptyContribution } from '@/lib/chat-empty'
import { ChatEmptySlot } from './chat-empty-slot'
const disposers: (() => void)[] = []
function contribute(id: string, render: ChatEmptyContribution['render']) {
disposers.push(registry.register({ area: CHAT_EMPTY_AREA, data: { render }, id }))
}
afterEach(() => {
for (const dispose of disposers.splice(0)) {
dispose()
}
})
describe('an empty transcript asks every contributor', () => {
it('renders the owner even when an earlier contributor declined', () => {
contribute('declines', () => null)
contribute('owns', ({ sessionId }) => <span data-testid="owner">owner of {sessionId}</span>)
render(<ChatEmptySlot sessionId="s-1" />)
expect(screen.getByTestId('owner').textContent).toBe('owner of s-1')
})
it('renders nothing when everyone declines', () => {
contribute('a', () => null)
contribute('b', () => null)
const { container } = render(<ChatEmptySlot sessionId="s-1" />)
expect(container.textContent).toBe('')
})
it('renders nothing when nobody contributes at all', () => {
const { container } = render(<ChatEmptySlot sessionId="s-1" />)
expect(container.textContent).toBe('')
})
it('shows a conflict rather than hiding one of the claimants', () => {
contribute('first', () => <span>first</span>)
contribute('second', () => <span>second</span>)
render(<ChatEmptySlot sessionId="s-1" />)
expect(screen.getByText('first')).toBeTruthy()
expect(screen.getByText('second')).toBeTruthy()
})
it('isolates a throwing contributor from the ones beside it', () => {
contribute('boom', () => {
throw new Error('contributor exploded')
})
contribute('owns', () => <span data-testid="owner">still here</span>)
render(<ChatEmptySlot sessionId="s-1" />)
expect(screen.getByTestId('owner').textContent).toBe('still here')
})
})
@@ -0,0 +1,55 @@
import type { FC } from 'react'
import { useMemo } from 'react'
import { useContributions } from '@/contrib'
import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
import { CHAT_EMPTY_AREA, type ChatEmptyContribution } from '@/lib/chat-empty'
/**
* The empty transcript's contributed slot. Mounts every registration and lets
* each decide — it renders the session's empty state, or nothing at all if the
* session isn't one it owns.
*
* Deliberately a mount rather than a claim the transcript resolves up front:
* whether a session has an empty state depends on data the plugin loads on its
* own clock (a bot chat's roster lands after the transcript), and only a
* mounted component can subscribe and appear when it arrives.
*
* That same asynchrony is why the slot cannot mount only the first
* registration. Ownership is per session and is not known until each has
* loaded, so first-wins would let a plugin that DECLINES a session suppress the
* one that owns it, permanently and silently, purely on registration order.
* Mounting all of them means disjoint owners each work; two claiming the same
* session render both, which is a visible conflict rather than a silent drop.
*/
const ChatEmptyEntry: FC<{ id: string; render: ChatEmptyContribution['render']; sessionId: string }> = ({
id,
render,
sessionId
}) => {
// Stable component identity: ContribRender mounts this AS a component, so a
// fresh closure per render would remount the empty state on every tick.
const renderEmpty = useMemo(() => () => render({ sessionId }), [render, sessionId])
return (
<ContribBoundary id={id}>
<ContribRender render={renderEmpty} />
</ContribBoundary>
)
}
export const ChatEmptySlot: FC<{ sessionId: string }> = ({ sessionId }) => {
const contributions = useContributions(CHAT_EMPTY_AREA)
return (
<>
{contributions.map(contribution => {
const render = (contribution.data as ChatEmptyContribution | undefined)?.render
return render ? (
<ChatEmptyEntry id={contribution.id} key={contribution.id} render={render} sessionId={sessionId} />
) : null
})}
</>
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import { formatRefValue, hermesDirectiveFormatter } from './directive-text'
describe('formatRefValue', () => {
it('leaves simple paths untouched', () => {
expect(formatRefValue('src/index.ts')).toBe('src/index.ts')
expect(formatRefValue('https://example.com/post')).toBe('https://example.com/post')
})
it('wraps paths with whitespace in backticks', () => {
expect(formatRefValue('apple-touch-icon (1).png')).toBe('`apple-touch-icon (1).png`')
})
it('falls back to double quotes when value contains backticks', () => {
expect(formatRefValue('weird `name` (1).md')).toBe('"weird `name` (1).md"')
})
})
describe('hermesDirectiveFormatter.parse', () => {
it('keeps quoted file paths whole when parsing', () => {
const segments = hermesDirectiveFormatter.parse('see @image:`apple-touch-icon (1).png` for the icon')
expect(segments).toEqual([
{ kind: 'text', text: 'see ' },
{ kind: 'mention', type: 'image', label: 'apple-touch-icon (1).png', id: 'apple-touch-icon (1).png' },
{ kind: 'text', text: ' for the icon' }
])
})
it('still parses unquoted paths', () => {
const segments = hermesDirectiveFormatter.parse('@file:src/main.tsx the entry point')
// The label keeps its directory: it's the same string the `@` popover row
// showed, and a bare `main.tsx` can't tell two files apart.
expect(segments).toEqual([
{ kind: 'mention', type: 'file', label: 'src/main.tsx', id: 'src/main.tsx' },
{ kind: 'text', text: ' the entry point' }
])
})
it('parses session links with profile/id values', () => {
const segments = hermesDirectiveFormatter.parse('see @session:work/20260101_abc123 next')
expect(segments).toEqual([
{ kind: 'text', text: 'see ' },
{ kind: 'mention', type: 'session', label: '20260101…', id: 'work/20260101_abc123' },
{ kind: 'text', text: ' next' }
])
})
})
describe('inline skill references', () => {
const skills = (text: string) =>
[...hermesDirectiveFormatter.parse(text)]
.filter(segment => segment.kind === 'mention' && segment.type === 'skill')
.map(segment => (segment.kind === 'mention' ? segment.id : ''))
it('keeps a picked skill a chip in the sent message instead of flattening it', () => {
expect(skills('please run /clean on this')).toEqual(['/clean'])
})
it('keeps the surrounding prose as text around the chip', () => {
const segments = hermesDirectiveFormatter.parse('tidy this with /clean thanks')
expect(segments).toEqual([
{ kind: 'text', text: 'tidy this with ' },
{ kind: 'mention', type: 'skill', label: 'clean', id: '/clean' },
{ kind: 'text', text: ' thanks' }
])
})
it('leaves file paths and fractions alone', () => {
expect(skills('check src/foo/bar')).toEqual([])
expect(skills('look at /usr/local/bin')).toEqual([])
expect(skills('roughly 3 /4 of it')).toEqual([])
})
it('chips a leading slash, which now reaches the transcript as a skill invocation', () => {
// #71664 asserted the opposite, and was right at the time: a leading slash
// only ever EXECUTED, so it never reached a rendered message as text —
// the turn that reached the bubble was the expanded skill body. Projecting
// a skill turn back onto `/work fix it` changes that precondition, so the
// invocation now has to chip like any other skill reference.
expect(skills('/clean')).toEqual(['/clean'])
expect(skills('/work fix the leak')).toEqual(['/work'])
})
it('parses a skill chip alongside an @ reference', () => {
const mentions = [...hermesDirectiveFormatter.parse('run /clean on @file:`src/a.ts`')].filter(
segment => segment.kind === 'mention'
)
expect(mentions.map(segment => (segment.kind === 'mention' ? segment.type : ''))).toEqual(['skill', 'file'])
})
})
@@ -0,0 +1,575 @@
'use client'
import type { Unstable_DirectiveFormatter, Unstable_DirectiveSegment, Unstable_TriggerItem } from '@assistant-ui/core'
import type { TextMessagePartComponent, TextMessagePartProps } from '@assistant-ui/react'
import type { FC } from 'react'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import type { I18nContextValue } from '@/i18n'
import { extractEmbeddedImages } from '@/lib/embedded-images'
import { openLink } from '@/lib/external-link'
import { triggerHaptic } from '@/lib/haptics'
import { gatewayMediaDataUrl, isRemoteGateway } from '@/lib/media'
import { useSessionLinkTitle } from '@/lib/session-link-title'
import { parseSessionRefValue, sessionRefFallbackLabel } from '@/lib/session-refs'
import { cn } from '@/lib/utils'
import { referenceKind, referenceRe, referenceStyle, WIRE_REFERENCE_KINDS } from './reference-kinds'
const HERMES_REF_TYPES = WIRE_REFERENCE_KINDS
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
/** Icon glyphs come from the shared reference vocabulary, so the popover row
* and the chip can never drift apart. */
const iconPathsFor = (type: string) => referenceStyle(type).paths
const SVG_ATTRS =
'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'
/**
* The class + attributes that make any element an inline reference. Pair with
* the `.ref` rules in styles.css, which own the per-kind accent — pass the kind
* and the theme decides the colour.
*
* One helper for every surface: the composer's contenteditable chips, a sent
* message's mentions, a markdown link, a completion row's glyph. If it points
* at something from inside text, it goes through here.
*/
export function refAttrs(kind?: string, extra?: string): { className: string; 'data-ref'?: string } {
const className = extra ? `ref ${extra}` : 'ref'
return kind ? { className, 'data-ref': referenceKind(kind) } : { className }
}
/** The same thing as a raw attribute string, for HTML built by hand. */
export function refAttrsHtml(kind?: string): string {
return kind ? `class="ref" data-ref="${referenceKind(kind)}"` : 'class="ref"'
}
/** SVG markup string for embedding directly in HTML (composer contenteditable). */
export function directiveIconSvg(type: string) {
const inner = iconPathsFor(type)
.map(d => `<path d="${d}"/>`)
.join('')
return `<svg ${SVG_ATTRS}>${inner}</svg>`
}
function iconElementFromPaths(paths: string[]) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('fill', 'none')
svg.setAttribute('stroke', 'currentColor')
svg.setAttribute('stroke-linecap', 'round')
svg.setAttribute('stroke-linejoin', 'round')
svg.setAttribute('stroke-width', '2')
svg.setAttribute('viewBox', '0 0 24 24')
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
for (const d of paths) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', d)
svg.append(path)
}
return svg
}
export function directiveIconElement(type: string) {
return iconElementFromPaths(iconPathsFor(type))
}
/** Commands, skills, and themes are three more reference kinds — no separate
* pill styling, just the shared `.ref` treatment with their own accent. */
export type SlashChipKind = 'command' | 'skill' | 'theme'
export function slashIconElement(kind: SlashChipKind) {
return iconElementFromPaths(iconPathsFor(kind))
}
/** The glyph for a reference kind. Size, spacing, and opacity come from the
* `.ref > svg` rules — the icon only has to say which shape it is. */
const DirectiveIcon: FC<{ type: string; className?: string }> = ({ type, className }) => (
<svg
className={className}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
{iconPathsFor(type).map(d => (
<path d={d} key={d} />
))}
</svg>
)
/**
* Parses our composer's `@type:value` references into directive segments
* so they render as inline chips in user messages instead of raw text.
*
* Supported types: file, folder, url, image. Anything else stays plain text.
*
* Mirrors the Python `agent/context_references.REFERENCE_PATTERN` syntax:
* the value may be wrapped in backticks, single quotes, or double quotes so
* paths with spaces/parens/etc. survive parsing intact.
*/
const CANONICAL_DIRECTIVE_RE = /:([\w-]{1,64})\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/g
const HERMES_DIRECTIVE_RE = referenceRe()
// A skill referenced in a sent message — either the invocation that opens it
// (`/work fix the leak`, which is all a skill turn ever renders as) or one
// named mid-prose (`clean this up with /clean`). The composer inserts both as
// pills, so the sent message renders them as pills too rather than flattening
// back to raw text.
//
// #71664 deliberately excluded a LEADING slash, and was right then: a command
// only ever executed, so it never reached a rendered message as text. Skill
// turns now project back onto their invocation, so that precondition is gone
// and `^` joins the lookbehind.
//
// Unlike the composer's caret-anchored trigger, this scans finished text, so
// it must reject a token that continues into a path: `/usr/local/bin` would
// otherwise chip as `/usr`. `(?![\w-]*\/)` requires the token to end at
// something other than another slash.
const SLASH_SKILL_RE = /(?<=^|\s)\/([a-zA-Z][\w-]*)(?![\w-]*\/)/g
const TRAILING_PUNCTUATION_RE = /[,.;!?]+$/
function unwrapRefValue(raw: string): string {
if (raw.length < 2) {
return raw
}
const head = raw[0]
const tail = raw[raw.length - 1]
if ((head === '`' && tail === '`') || (head === '"' && tail === '"') || (head === "'" && tail === "'")) {
return raw.slice(1, -1)
}
return raw.replace(TRAILING_PUNCTUATION_RE, '')
}
function needsQuoting(value: string): boolean {
return /[\s()[\]{}<>"'`]/.test(value)
}
export function formatRefValue(value: string): string {
if (!needsQuoting(value)) {
return value
}
if (!value.includes('`')) {
return `\`${value}\``
}
if (!value.includes('"')) {
return `"${value}"`
}
if (!value.includes("'")) {
return `'${value}'`
}
return value
}
export const hermesDirectiveFormatter: Unstable_DirectiveFormatter = {
serialize(item: Unstable_TriggerItem): string {
const metadata = item.metadata as { rawText?: unknown; insertId?: unknown } | undefined
const rawText = typeof metadata?.rawText === 'string' ? metadata.rawText : null
const insertId = typeof metadata?.insertId === 'string' ? metadata.insertId : null
// Live-completion items carry the gateway's original `text` field via metadata.
if (rawText) {
// Palette starters (`@file:` with empty value) — insert verbatim so the
// user can keep typing the path inline.
if (rawText.endsWith(':') && !insertId) {
return rawText
}
// Simple references like `@diff` / `@staged`.
if (!insertId) {
return rawText
}
// Colon-less completions (`@diff`, `@staged`, agent mentions like
// `@researcher`) are plain inline text, not typed references. classify()
// gives them `insertId = text`, and the typed-reference branch below
// would mint a bogus `@simple:` kind around them — the composer showed
// "@simple:`@mr-tester`" for a picked agent mention.
if (!rawText.includes(':')) {
return rawText
}
// Typed references with a value — quote when needed.
const kindMatch = rawText.match(/^@([^:]+):/)
const kind = kindMatch?.[1] ?? item.type
return `@${kind}:${formatRefValue(insertId)}`
}
// Fallback for legacy callers that pass raw `id` strings.
if (item.id === `${item.type}:`) {
return `@${item.id}`
}
return `@${item.type}:${formatRefValue(item.id)}`
},
parse(text: string): readonly Unstable_DirectiveSegment[] {
return parseDirectiveText(text)
}
}
function parseDirectiveText(text: string): Unstable_DirectiveSegment[] {
const matches = [
...Array.from(text.matchAll(CANONICAL_DIRECTIVE_RE)).map(match => ({
start: match.index ?? 0,
end: (match.index ?? 0) + match[0].length,
type: match[1] || 'tool',
label: match[2] || match[3] || '',
id: match[3] || match[2] || ''
})),
...Array.from(text.matchAll(HERMES_DIRECTIVE_RE)).map(match => {
const id = unwrapRefValue(match[2] || '')
return {
start: match.index ?? 0,
end: (match.index ?? 0) + match[0].length,
type: match[1] || 'file',
label: refChipLabel(match[1] || 'file', id),
id
}
}),
...Array.from(text.matchAll(SLASH_SKILL_RE)).map(match => ({
start: match.index ?? 0,
end: (match.index ?? 0) + match[0].length,
type: 'skill',
label: match[1],
id: `/${match[1]}`
}))
]
.filter(match => match.id)
.sort((a, b) => a.start - b.start)
const segments: Unstable_DirectiveSegment[] = []
let cursor = 0
for (const match of matches) {
if (match.start < cursor) {
continue
}
if (match.start > cursor) {
segments.push({ kind: 'text', text: text.slice(cursor, match.start) })
}
segments.push({
kind: 'mention',
type: match.type,
label: match.label,
id: match.id
})
cursor = match.end
}
if (cursor < text.length) {
segments.push({ kind: 'text', text: text.slice(cursor) })
}
return segments
}
/** The single display label for a `@kind:value` reference — used by the `@`
* popover row, the composer chip, and the sent-message chip alike, so a
* reference reads the same everywhere. Upstream keeps one label on the
* directive node and hands it to every consumer verbatim; our wire format
* (`@kind:value`) can't carry a label, so this is the shared deriver that
* holds the same invariant.
*
* Paths keep their directory for the reason links keep theirs: a bare
* basename can't tell two references apart (`src`, `index.ts`, `main.tsx`
* repeat all over a repo), and browsing into `apps/desktop/` only to be
* handed a chip reading `desktop` throws away the context you navigated for.
* The chip's `truncate` cuts the overflow. */
export function refChipLabel(type: string, id: string): string {
if (type === 'terminal') {
return id || 'terminal'
}
if (type === 'session') {
return sessionRefFallbackLabel(id)
}
if (type === 'url') {
try {
const { hostname, pathname, search } = new URL(id)
const path = `${pathname}${search}`.replace(/\/$/, '')
return `${hostname.replace(/^www\./i, '')}${path}` || id
} catch {
return id
}
}
// `./` is noise the completer emits, not part of the reference. A trailing
// slash is kept — it's what distinguishes a folder from a file.
return id.replace(/^\.\//, '') || id
}
function safeEmbeddedImages(text: string) {
try {
return extractEmbeddedImages(text)
} catch {
return { cleanedText: text, images: [] as string[] }
}
}
function safeDirectiveSegments(text: string): Unstable_DirectiveSegment[] {
try {
return [...hermesDirectiveFormatter.parse(text)]
} catch {
return [{ kind: 'text', text }]
}
}
/**
* Renders text containing Hermes directives (`@file:...`, `@image:...`) as
* inline chips. Embedded MEDIA images render below as a thumbnail row.
*/
export function DirectiveContent({ text }: { text: string }) {
const { cleanedText, images } = useMemo(() => safeEmbeddedImages(text ?? ''), [text])
const segments = useMemo(() => safeDirectiveSegments(cleanedText), [cleanedText])
// `@image:<path>` directives render as a block-level thumbnail row (like
// embedded base64 images below), not inline mid-text — otherwise a large
// thumbnail gets wedged between words and breaks the text's line flow.
const imageSegments = useMemo(
() =>
segments.filter(
(segment): segment is Extract<Unstable_DirectiveSegment, { kind: 'mention' }> =>
segment.kind === 'mention' && segment.type === 'image'
),
[segments]
)
return (
<span className="whitespace-pre-line" data-slot="aui_directive-text">
{segments.map((segment, index) =>
segment.kind === 'text' ? (
<Fragment key={`t-${index}`}>{segment.text}</Fragment>
) : segment.type === 'image' ? null : segment.type === 'session' ? (
<SessionRefChip key={`m-${index}-${segment.id}`} label={segment.label} value={segment.id} />
) : segment.type === 'skill' ? (
<SlashChip key={`m-${index}-${segment.id}`} kind="skill" label={segment.label} value={segment.id} />
) : (
<DirectiveChip id={segment.id} key={`m-${index}-${segment.id}`} label={segment.label} type={segment.type} />
)
)}
{(imageSegments.length > 0 || images.length > 0) && (
<span className="mt-2 flex flex-wrap gap-2" data-slot="aui_embedded-images">
{imageSegments.map((segment, index) => (
<DirectiveImage id={segment.id} key={`img-ref-${index}-${segment.id}`} label={segment.label} />
))}
{images.map((src, index) => (
<ZoomableImage
alt=""
className="max-h-48 max-w-full rounded-lg border border-(--ui-stroke-tertiary) object-contain"
draggable={false}
key={`img-${index}`}
slot="aui_embedded-image"
src={src}
/>
))}
</span>
)}
</span>
)
}
/** assistant-ui adapter: same renderer, exposed as a TextMessagePartComponent. */
export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePartProps) => (
<DirectiveContent text={text ?? ''} />
)
/** Image refs render as a thumbnail rather than a chip — matches how persisted
* messages render after the backend embeds the data URL, so the UX is stable
* across initial send and refresh. */
const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => {
const isUrl = /^(?:https?|data):/i.test(id)
const [src, setSrc] = useState<string | null>(isUrl ? id : null)
const [failed, setFailed] = useState(false)
useEffect(() => {
if (isUrl || !id) {
return
}
let alive = true
// Remote gateway: the image lives on the gateway's disk, not ours — fetch
// it over the authenticated API. Local: read it straight off this disk.
const load =
window.hermesDesktop && isRemoteGateway() ? gatewayMediaDataUrl(id) : window.hermesDesktop?.readFileDataUrl(id)
void Promise.resolve(load)
.then(url => alive && url && setSrc(url))
.catch(() => alive && setFailed(true))
return () => {
alive = false
}
}, [id, isUrl])
if (failed) {
return <DirectiveChip id={id} label={label} type="image" />
}
if (!src) {
return (
<span
aria-hidden
className="inline-block size-12 shrink-0 animate-pulse rounded-md bg-[color-mix(in_srgb,currentColor_8%,transparent)]"
/>
)
}
return (
<ZoomableImage
alt={label}
className="max-h-48 max-w-full rounded-lg border border-(--ui-stroke-tertiary) object-contain"
draggable={false}
slot="aui_directive-image"
src={src}
/>
)
}
/** Opens the referenced session the way a sidebar ⌘-click would: jump to it if
* it's already a tile/main, otherwise open a stacked tab (never steals main
* from under the chat you're reading). Lazy-imports so the composer's rich
* editor can pull this module in without booting the profile/REST stack. */
export function openSessionRef(value: string) {
const { sessionId } = parseSessionRefValue(value)
if (!sessionId) {
return
}
triggerHaptic('selection')
// navigate is unused for the `tab` intent (focus-or-tile only).
void import('@/app/open-session').then(({ openSession }) => openSession(sessionId, () => undefined, 'tab'))
}
/** What activating a directive of a given kind does. The single source of truth
* for "you can act on this reference," shared by every surface that renders a
* chip: the composer's hover pill (`ComposerDirectiveActions`) and the sent
* message's clickable chip below. A kind with no entry is inert everywhere.
*
* Add a kind here and both surfaces light up — that's the whole point of one
* table. `icon`/`label` are for the pill; the transcript chip carries its own
* glyph and only reads `run`. */
export interface DirectiveAction {
icon: string
label: (t: I18nContextValue['t']) => string
run: (value: string) => void
}
export const DIRECTIVE_ACTIONS: Record<string, DirectiveAction> = {
session: {
icon: 'link-external',
label: t => t.composer.openDirective,
run: openSessionRef
},
url: {
icon: 'link-external',
label: t => t.composer.openDirective,
run: openLink
}
}
/** A `@session:<profile>/<id>` reference in the user transcript (directive
* segments), rendered as a chip like the other composer refs. Clicking it
* opens the session as a tab. */
export const SessionRefChip: FC<{
label?: string
value: string
}> = ({ label, value }) => {
const resolved = useSessionLinkTitle(value, label)
return <DirectiveChip id={value} label={resolved} onClick={() => openSessionRef(value)} type="session" />
}
/** A `@session:` reference in assistant markdown (`#session/` links rewritten
* in `preprocessMarkdown`). Reads as an ordinary inline link — the agent wrote
* it mid-sentence — with the funnel icon leading the resolved title. */
export const SessionRefLink: FC<{
label?: string
value: string
}> = ({ label, value }) => {
const resolved = useSessionLinkTitle(value, label)
return (
<a
{...refAttrs('session', 'wrap-anywhere')}
href="#"
onClick={event => {
event.preventDefault()
event.stopPropagation()
openSessionRef(value)
}}
title={value}
>
<DirectiveIcon type="session" />
{resolved}
</a>
)
}
/** A skill referenced inside a sent message — the rendered twin of the
* composer's slash pill, so a picked skill stays a chip after send. */
const SlashChip: FC<{ kind: SlashChipKind; label: string; value: string }> = ({ kind, label, value }) => (
<span {...refAttrs(kind)} data-slot="aui_slash-chip" title={value}>
<DirectiveIcon type={kind} />
{label}
</span>
)
/** A directive reference in a sent message. A kind with a `DIRECTIVE_ACTIONS`
* entry (a url, …) renders as a real button that runs it on click; everything
* else is inert text. `onClick` overrides for chips that resolve their target
* themselves (session, which needs the async navigator). */
const DirectiveChip: FC<{
type: string
label: string
id: string
onClick?: () => void
}> = ({ type, label, id, onClick }) => {
const activate = onClick ?? (DIRECTIVE_ACTIONS[type] ? () => DIRECTIVE_ACTIONS[type]!.run(id) : undefined)
const body = (
<>
<DirectiveIcon type={type} />
{label}
</>
)
const props = {
...refAttrs(type, cn('wrap-anywhere', activate && 'cursor-pointer')),
'data-directive-id': id,
'data-slot': 'aui_directive-chip',
title: id
}
return activate ? (
<button {...props} onClick={activate} type="button">
{body}
</button>
) : (
<span {...props}>{body}</span>
)
}
@@ -0,0 +1,39 @@
import { createElement } from 'react'
import { describe, expect, it } from 'vitest'
import { extractAlert } from './alert'
describe('extractAlert', () => {
it('detects each GFM alert kind from the leading marker', () => {
for (const [marker, type] of [
['[!NOTE]', 'note'],
['[!TIP]', 'tip'],
['[!IMPORTANT]', 'important'],
['[!WARNING]', 'warning'],
['[!CAUTION]', 'caution']
] as const) {
const node = createElement('p', null, `${marker}\nBody text`)
const result = extractAlert(node)
expect(result?.type).toBe(type)
}
})
it('is case-insensitive on the marker', () => {
expect(extractAlert(createElement('p', null, '[!note] hi'))?.type).toBe('note')
})
it('returns null for a plain blockquote', () => {
expect(extractAlert(createElement('p', null, 'just a quote'))).toBeNull()
expect(extractAlert('no marker here')).toBeNull()
})
it('strips the marker token from the body', () => {
const result = extractAlert(createElement('p', null, '[!WARNING]\nDanger ahead'))
expect(result).not.toBeNull()
// The marker must not survive into the rendered body.
expect(JSON.stringify(result?.body)).not.toContain('[!WARNING]')
expect(JSON.stringify(result?.body)).toContain('Danger ahead')
})
})
@@ -0,0 +1,125 @@
import { cloneElement, isValidElement, type ReactNode } from 'react'
import { AlertCircle, AlertTriangle, type IconComponent, Info, Zap } from '@/lib/icons'
import { cn } from '@/lib/utils'
export type AlertType = 'caution' | 'important' | 'note' | 'tip' | 'warning'
interface AlertStyle {
accent: string
icon: IconComponent
label: string
}
// GitHub's five alert kinds, mapped to our icon set + a tinted accent.
const ALERT_STYLES: Record<AlertType, AlertStyle> = {
caution: { accent: 'text-rose-600 dark:text-rose-400', icon: AlertTriangle, label: 'Caution' },
important: { accent: 'text-violet-600 dark:text-violet-400', icon: AlertCircle, label: 'Important' },
note: { accent: 'text-blue-600 dark:text-blue-400', icon: Info, label: 'Note' },
tip: { accent: 'text-emerald-600 dark:text-emerald-400', icon: Zap, label: 'Tip' },
warning: { accent: 'text-amber-600 dark:text-amber-400', icon: AlertTriangle, label: 'Warning' }
}
const MARKER_RE = /^\s*\[!(note|tip|important|warning|caution)\]\s*\n?/i
function firstText(node: ReactNode): string {
if (typeof node === 'string') {
return node
}
if (typeof node === 'number') {
return String(node)
}
if (Array.isArray(node)) {
for (const child of node) {
const text = firstText(child)
if (text.trim()) {
return text
}
}
return ''
}
if (isValidElement(node)) {
return firstText((node.props as { children?: ReactNode }).children)
}
return ''
}
// Remove the leading `[!TYPE]` token from the first text node that carries it,
// leaving the rest of the blockquote body intact. One-shot via the `state` flag.
function stripMarker(node: ReactNode, state: { done: boolean }): ReactNode {
if (state.done) {
return node
}
if (typeof node === 'string') {
const replaced = node.replace(MARKER_RE, '')
if (replaced !== node) {
state.done = true
return replaced
}
return node
}
if (Array.isArray(node)) {
return node.map((child, index) => <Fragmentless key={index} node={stripMarker(child, state)} />)
}
if (isValidElement(node)) {
const children = (node.props as { children?: ReactNode }).children
if (children == null) {
return node
}
return cloneElement(node, undefined, stripMarker(children, state))
}
return node
}
// Tiny helper so the array branch can return keyed nodes without wrapping
// strings in extra elements (React renders the raw node).
function Fragmentless({ node }: { node: ReactNode }) {
return <>{node}</>
}
/**
* Detect a GitHub-style alert blockquote (`> [!NOTE]`). Returns the alert kind
* and the body with the marker stripped, or null for a plain blockquote.
*/
export function extractAlert(children: ReactNode): { body: ReactNode; type: AlertType } | null {
const match = firstText(children).match(MARKER_RE)
if (!match) {
return null
}
return { body: stripMarker(children, { done: false }), type: match[1].toLowerCase() as AlertType }
}
export function MarkdownAlert({ children, type }: { children: ReactNode; type: AlertType }) {
const style = ALERT_STYLES[type]
const Icon = style.icon
return (
<div
className="my-2 rounded-lg border border-(--ui-stroke-tertiary) bg-muted/25 px-3 py-2 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
data-slot="aui_markdown-alert"
>
<div className={cn('mb-1 flex items-center gap-1.5 text-[0.8125rem] font-semibold', style.accent)}>
<Icon className="size-4 shrink-0" />
{style.label}
</div>
{children}
</div>
)
}
@@ -0,0 +1,55 @@
'use client'
import { type CSSProperties, useState } from 'react'
import { SplitButton } from '@/components/ui/split-button'
import { Play } from '@/lib/icons'
import { allowProvider } from '@/store/embed-consent'
import type { EmbedDescriptor } from './providers/types'
// Privacy placeholder shown before an embed reaches out to a third party. Sized
// to the embed's footprint (no layout shift). The split control mirrors the
// commit button: primary "Load" (this embed) with a caret for "Always allow
// <service>" (persisted). Global off lives in Appearance settings.
export function EmbedFacade({ descriptor, onLoad }: { descriptor: EmbedDescriptor; onLoad: () => void }) {
const [choice, setChoice] = useState('once')
const style: CSSProperties = descriptor.aspectRatio
? { aspectRatio: descriptor.aspectRatio }
: { height: descriptor.height ?? 320 }
const actions = [
{ id: 'once', label: `Load ${descriptor.label}` },
{ id: 'always', label: `Always allow ${descriptor.label}` }
]
return (
<span
className="flex size-full flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary)/30"
style={style}
>
<SplitButton
actions={actions}
onTrigger={id => (id === 'always' ? allowProvider(descriptor.provider) : onLoad())}
onValueChange={setChoice}
primaryIcon={<Play className="size-3 translate-x-px fill-current" />}
value={choice}
/>
<span className="text-[0.6875rem] text-(--ui-text-tertiary)">{hostOf(descriptor)}</span>
</span>
)
}
function hostOf(descriptor: EmbedDescriptor): string {
// x.com posts often arrive as twitter.com links — show the current brand.
if (descriptor.provider === 'twitter') {
return 'x.com'
}
try {
return new URL(descriptor.sourceUrl).hostname.replace(/^www\./, '')
} catch {
return descriptor.label
}
}
@@ -0,0 +1,4 @@
// Shared height cap for inline embeds. Ratio embeds cap their width off this in
// UrlEmbed so height follows the aspect ratio; fenced renderers (mermaid, svg)
// reuse it directly. Pure CSS — no measuring.
export const EMBED_MAX_H = '33dvh'
@@ -0,0 +1,3 @@
export function escapeHtml(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
@@ -0,0 +1,7 @@
export function EmbedFail({ label }: { label: string }) {
return (
<span className="grid min-h-32 w-full place-items-center p-4">
<span className="text-xs font-medium text-(--ui-red)">Failed to load {label} embed</span>
</span>
)
}
@@ -0,0 +1,55 @@
'use client'
import { type CSSProperties } from 'react'
import type { FrameEmbed } from './providers/types'
import { ScrollGate } from './scroll-gate'
import { useIsDark } from './use-is-dark'
const ALLOW = 'autoplay; encrypted-media; picture-in-picture; clipboard-write; fullscreen'
// Plain iframes (not webviews): a non-scrollable cross-origin iframe lets the
// wheel chain to the transcript instead of capturing it. Maps are the one
// exception — they're interactive, so a ScrollGate blocks them until ⌘ is held.
export default function FrameEmbedRenderer({ descriptor }: { descriptor: FrameEmbed }) {
const isDark = useIsDark()
const isMap = descriptor.provider === 'googlemaps' || descriptor.provider === 'openstreetmap'
// color-scheme makes the iframe's default (unpainted) backdrop follow the
// theme instead of flashing white at the corners / during load.
const colorScheme = isDark ? 'dark' : 'light'
const style: CSSProperties = descriptor.aspectRatio
? { aspectRatio: descriptor.aspectRatio, colorScheme }
: { colorScheme, height: descriptor.height }
if (isMap) {
return (
<div className="relative w-full overflow-hidden" style={style}>
<iframe
allow={ALLOW}
className="absolute inset-0 size-full border-0 bg-transparent"
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
src={descriptor.embedUrl}
style={{ colorScheme }}
title={`${descriptor.label} embed`}
/>
<ScrollGate />
</div>
)
}
return (
<iframe
allow={ALLOW}
allowFullScreen
className="block w-full border-0 bg-transparent"
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
scrolling="no"
src={descriptor.embedUrl}
style={style}
title={`${descriptor.label} embed`}
/>
)
}
@@ -0,0 +1,5 @@
export { extractAlert, MarkdownAlert } from './alert'
export type { EmbedDescriptor } from './providers'
export { detectEmbed, isEmbeddableUrl } from './providers'
export { RICH_FENCE_LANGUAGES, RichCodeBlock } from './registry'
export { UrlEmbed } from './url-embed'
@@ -0,0 +1,113 @@
'use client'
import mermaid from 'mermaid'
import { useEffect, useState } from 'react'
import { Zoomable } from '@/components/ui/zoomable'
import { copySvgAsPng, normalizeSvgSize } from '@/lib/svg-image'
import { cn } from '@/lib/utils'
import type { RichFenceProps } from './types'
import { useIsDark } from './use-is-dark'
let lastTheme: 'dark' | 'default' | null = null
// Re-initialise only on first use / theme flip. `securityLevel: 'strict'` makes
// mermaid sanitise label HTML and drop click handlers, so the rendered SVG is
// safe to inject.
function ensureInit(dark: boolean) {
const theme = dark ? 'dark' : 'default'
if (theme === lastTheme) {
return
}
mermaid.initialize({ fontFamily: 'inherit', securityLevel: 'strict', startOnLoad: false, theme })
lastTheme = theme
}
function SourcePreview({ code, muted }: { code: string; muted?: boolean }) {
return (
<pre
className={cn(
'overflow-auto p-3 font-mono text-[0.7rem] leading-relaxed whitespace-pre-wrap wrap-anywhere',
muted ? 'text-muted-foreground/70' : 'text-foreground/90'
)}
>
{code}
</pre>
)
}
// Lazy chunk (pulls in mermaid). Renders ```mermaid fences as diagrams; shows
// the source while the message streams (partial syntax throws) and falls back
// to source on parse failure.
export default function MermaidRenderer({ code, streaming }: RichFenceProps) {
const isDark = useIsDark()
const [svg, setSvg] = useState('')
const [failed, setFailed] = useState(false)
useEffect(() => {
if (streaming) {
return
}
let cancelled = false
setFailed(false)
void (async () => {
try {
ensureInit(isDark)
const id = `mmd-${Math.random().toString(36).slice(2)}`
const result = await mermaid.render(id, code)
if (!cancelled) {
setSvg(normalizeSvgSize(result.svg))
}
} catch {
if (!cancelled) {
setFailed(true)
setSvg('')
}
}
})()
return () => {
cancelled = true
}
}, [code, isDark, streaming])
if (streaming) {
return <SourcePreview code={code} muted />
}
if (failed) {
return <SourcePreview code={code} />
}
if (!svg) {
return <SourcePreview code={code} muted />
}
// Click to open the diagram full-screen with pan/zoom + copy-as-PNG. The
// overlay keeps the diagram's natural width (capped to the viewport) so it
// renders before any zoom; the inline version stays capped at 33dvh.
return (
<Zoomable
label="Open diagram"
onCopy={() => copySvgAsPng(svg)}
overlay={
<div
className="[&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-h-[80vh] [&_svg]:max-w-[85vw]"
dangerouslySetInnerHTML={{ __html: svg }}
/>
}
>
<div
className="overflow-hidden p-3 [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-h-[33dvh] [&_svg]:max-w-full"
dangerouslySetInnerHTML={{ __html: svg }}
/>
</Zoomable>
)
}
@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest'
import type { FrameEmbed, TweetEmbed } from './types'
import { detectEmbed, isEmbeddableUrl } from './index'
function frame(url: string): FrameEmbed {
const descriptor = detectEmbed(url)
if (!descriptor || descriptor.renderer !== 'frame') {
throw new Error(`expected a frame embed for ${url}`)
}
return descriptor
}
describe('detectEmbed — YouTube', () => {
it.each([
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'https://youtu.be/dQw4w9WgXcQ',
'https://www.youtube.com/shorts/dQw4w9WgXcQ',
'https://m.youtube.com/watch?v=dQw4w9WgXcQ',
'https://www.youtube.com/embed/dQw4w9WgXcQ',
'https://www.youtube.com/live/dQw4w9WgXcQ'
])('resolves %s to the privacy-enhanced embed of the same id', url => {
const embed = frame(url)
expect(embed.provider).toBe('youtube')
expect(embed.id).toBe('youtube:dQw4w9WgXcQ')
expect(embed.embedUrl).toContain('youtube-nocookie.com/embed/dQw4w9WgXcQ')
})
it('carries a start time from t/start through to the embed', () => {
expect(frame('https://youtu.be/dQw4w9WgXcQ?t=90').embedUrl).toContain('start=90')
expect(frame('https://youtu.be/dQw4w9WgXcQ?t=1m30s').embedUrl).toContain('start=90')
})
it('rejects ids that are not 11 chars', () => {
expect(detectEmbed('https://www.youtube.com/watch?v=short')).toBeNull()
})
})
describe('detectEmbed — other frame providers', () => {
it('resolves Vimeo numeric ids across path shapes', () => {
expect(frame('https://vimeo.com/76979871').embedUrl).toBe('https://player.vimeo.com/video/76979871')
expect(frame('https://vimeo.com/channels/staffpicks/76979871').id).toBe('vimeo:76979871')
})
it('resolves Instagram posts and reels', () => {
expect(frame('https://www.instagram.com/p/CabcDEF123/').embedUrl).toBe(
'https://www.instagram.com/p/CabcDEF123/embed'
)
expect(frame('https://www.instagram.com/reel/CabcDEF123/').embedUrl).toContain('/reel/CabcDEF123/embed')
expect(frame('https://www.instagram.com/reels/CabcDEF123/').embedUrl).toContain('/reel/CabcDEF123/embed')
})
it('resolves Pinterest pins across locale hosts', () => {
expect(frame('https://www.pinterest.com/pin/1234567890/').embedUrl).toBe(
'https://assets.pinterest.com/ext/embed.html?id=1234567890'
)
expect(frame('https://fr.pinterest.com/pin/1234567890/').provider).toBe('pinterest')
})
it('resolves TikTok videos to the official player', () => {
expect(frame('https://www.tiktok.com/@user/video/7212345678901234567').embedUrl).toBe(
'https://www.tiktok.com/player/v1/7212345678901234567'
)
})
it('resolves Spotify tracks, collections, and locale-prefixed urls', () => {
expect(frame('https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT').embedUrl).toBe(
'https://open.spotify.com/embed/track/4cOdK2wGLETKBW3PvgPWqT'
)
expect(frame('https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M').provider).toBe('spotify')
expect(frame('https://open.spotify.com/intl-de/album/1DFixLWuPkv3KT3TnV35m3').id).toBe(
'spotify:album:1DFixLWuPkv3KT3TnV35m3'
)
expect(detectEmbed('https://open.spotify.com/track/')).toBeNull()
})
})
describe('detectEmbed — maps', () => {
it('resolves Google Maps coordinates with zoom', () => {
const embed = frame('https://www.google.com/maps/@40.7128,-74.0060,12z')
expect(embed.provider).toBe('googlemaps')
expect(embed.embedUrl).toContain('output=embed')
expect(embed.embedUrl).toContain('q=40.7128%2C-74.006')
expect(embed.embedUrl).toContain('z=12')
})
it('resolves a Google Maps place name', () => {
expect(frame('https://www.google.com/maps/place/Eiffel+Tower/').embedUrl).toContain('q=Eiffel+Tower')
})
it('resolves OpenStreetMap fragment state to a bbox embed', () => {
const embed = frame('https://www.openstreetmap.org/#map=12/40.7128/-74.0060')
expect(embed.provider).toBe('openstreetmap')
expect(embed.embedUrl).toContain('export/embed.html')
expect(embed.embedUrl).toContain('marker=40.7128%2C-74.006')
expect(embed.embedUrl).toContain('bbox=')
})
})
describe('detectEmbed — Twitter/X', () => {
it('resolves twitter.com and x.com status urls to a tweet descriptor', () => {
for (const url of ['https://twitter.com/jack/status/20', 'https://x.com/jack/status/20']) {
const descriptor = detectEmbed(url)
expect(descriptor?.renderer).toBe('tweet')
expect((descriptor as TweetEmbed).tweetId).toBe('20')
}
})
})
describe('detectEmbed — non-matches', () => {
it.each([
'https://example.com/watch?v=dQw4w9WgXcQ',
'https://github.com/NousResearch/hermes',
'not-a-url',
'ftp://youtube.com/watch?v=dQw4w9WgXcQ',
'mailto:someone@youtube.com'
])('returns null for %s', url => {
expect(detectEmbed(url)).toBeNull()
expect(isEmbeddableUrl(url)).toBe(false)
})
it('handles empty input without throwing', () => {
expect(detectEmbed(undefined)).toBeNull()
expect(detectEmbed('')).toBeNull()
})
})
@@ -0,0 +1,54 @@
import { instagram } from './instagram'
import { maps } from './maps'
import { pinterest } from './pinterest'
import { spotify } from './spotify'
import { tiktok } from './tiktok'
import { twitter } from './twitter'
import type { EmbedDescriptor, EmbedMatcher } from './types'
import { vimeo } from './vimeo'
import { youtube } from './youtube'
export type { EmbedDescriptor, EmbedProvider, EmbedRenderer, FrameEmbed, TweetEmbed } from './types'
// All provider hosts are disjoint, so order is irrelevant — first match wins.
const MATCHERS: EmbedMatcher[] = [youtube, vimeo, instagram, pinterest, tiktok, twitter, spotify, maps]
function parseUrl(raw: string): URL | null {
try {
const url = new URL(raw)
return url.protocol === 'http:' || url.protocol === 'https:' ? url : null
} catch {
return null
}
}
/**
* Resolve a URL to a rich-embed descriptor, or null when no provider matches.
* Pure and synchronous — safe to call during render.
*/
export function detectEmbed(rawUrl: string | null | undefined): EmbedDescriptor | null {
if (!rawUrl) {
return null
}
const url = parseUrl(rawUrl)
if (!url) {
return null
}
for (const match of MATCHERS) {
const descriptor = match(url)
if (descriptor) {
return descriptor
}
}
return null
}
export function isEmbeddableUrl(rawUrl: string | null | undefined): boolean {
return detectEmbed(rawUrl) !== null
}
@@ -0,0 +1,26 @@
import { bareHost, type EmbedMatcher } from './types'
export const instagram: EmbedMatcher = url => {
if (bareHost(url.hostname) !== 'instagram.com') {
return null
}
const [typeRaw, code] = url.pathname.split('/').filter(Boolean)
const type = typeRaw === 'reels' ? 'reel' : typeRaw
if (!code || !['p', 'reel', 'tv'].includes(type || '') || !/^[A-Za-z0-9_-]+$/.test(code)) {
return null
}
return {
embedUrl: `https://www.instagram.com/${type}/${code}/embed`,
// Placeholder height for content-visibility; embed.js self-sizes in-document.
height: 450,
id: `instagram:${code}`,
label: 'Instagram',
maxWidth: 400,
provider: 'instagram',
renderer: 'frame',
sourceUrl: url.toString()
}
}
@@ -0,0 +1,95 @@
import { bareHost, type EmbedMatcher, type FrameEmbed } from './types'
// `@lat,lng` (optionally `,<zoom>z`) as it appears in Google Maps URLs.
const LATLNG_RE = /@(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)(?:,(\d+(?:\.\d+)?)z)?/
function googleMapsEmbed(url: URL): FrameEmbed | null {
const host = bareHost(url.hostname)
if (host !== 'google.com' && host !== 'maps.google.com' && !host.startsWith('google.')) {
return null
}
const isMapsPath = host.startsWith('maps.') || url.pathname.startsWith('/maps')
if (!isMapsPath) {
return null
}
// Prefer explicit coordinates; then a `q=` query; then a `/place/<name>`.
const coords = url.pathname.match(LATLNG_RE)
const placeName = url.pathname.match(/\/place\/([^/@]+)/)
const query = url.searchParams.get('q') || url.searchParams.get('query')
let q = ''
let zoom = ''
if (coords) {
q = `${coords[1]},${coords[2]}`
zoom = coords[3] ? String(Math.round(Number(coords[3]))) : ''
} else if (query) {
q = query
} else if (placeName) {
q = decodeURIComponent(placeName[1].replace(/\+/g, ' '))
}
if (!q) {
return null
}
// `output=embed` is the long-standing keyless Maps embed surface.
const params = new URLSearchParams({ output: 'embed', q })
if (zoom) {
params.set('z', zoom)
}
return {
aspectRatio: 16 / 10,
embedUrl: `https://maps.google.com/maps?${params.toString()}`,
id: `googlemaps:${q}${zoom ? `@${zoom}` : ''}`,
label: 'Google Maps',
maxWidth: 640,
provider: 'googlemaps',
renderer: 'frame',
sourceUrl: url.toString()
}
}
function openStreetMapEmbed(url: URL): FrameEmbed | null {
if (bareHost(url.hostname) !== 'openstreetmap.org') {
return null
}
// State lives in the fragment: `#map=<zoom>/<lat>/<lng>`.
const match = url.hash.match(/map=(\d+(?:\.\d+)?)\/(-?\d+(?:\.\d+)?)\/(-?\d+(?:\.\d+)?)/)
if (!match) {
return null
}
const zoom = Number(match[1])
const lat = Number(match[2])
const lng = Number(match[3])
// Degrees spanned at this zoom; halved for the bbox half-extent.
const lonDelta = 360 / 2 ** zoom
const latDelta = lonDelta / 2
const bbox = [lng - lonDelta / 2, lat - latDelta / 2, lng + lonDelta / 2, lat + latDelta / 2]
.map(value => value.toFixed(5))
.join(',')
const params = new URLSearchParams({ bbox, layer: 'mapnik', marker: `${lat},${lng}` })
return {
aspectRatio: 16 / 10,
embedUrl: `https://www.openstreetmap.org/export/embed.html?${params.toString()}`,
id: `openstreetmap:${lat},${lng}@${zoom}`,
label: 'OpenStreetMap',
maxWidth: 640,
provider: 'openstreetmap',
renderer: 'frame',
sourceUrl: url.toString()
}
}
export const maps: EmbedMatcher = url => googleMapsEmbed(url) || openStreetMapEmbed(url)
@@ -0,0 +1,28 @@
import { bareHost, type EmbedMatcher } from './types'
export const pinterest: EmbedMatcher = url => {
// Pinterest runs many locale TLDs (pinterest.co.uk, fr.pinterest.com, ...).
if (!bareHost(url.hostname).includes('pinterest.')) {
return null
}
const segments = url.pathname.split('/').filter(Boolean)
if (segments[0] !== 'pin' || !/^\d+$/.test(segments[1] || '')) {
return null
}
const id = segments[1]
return {
embedUrl: `https://assets.pinterest.com/ext/embed.html?id=${id}`,
// Pinterest's "small" pin size — the default card is too dominant inline.
height: 380,
id: `pinterest:${id}`,
label: 'Pinterest',
maxWidth: 236,
provider: 'pinterest',
renderer: 'frame',
sourceUrl: url.toString()
}
}
@@ -0,0 +1,34 @@
import { bareHost, type EmbedMatcher } from './types'
// Spotify's embed has only two layouts: compact (≤152) and full (352). Any
// in-between height renders the compact player and pads the rest with grey, so
// we snap to the compact size for every type — tight, no dead space.
const COMPACT_HEIGHT = 152
const EMBED_TYPES = new Set(['album', 'artist', 'episode', 'playlist', 'show', 'track'])
export const spotify: EmbedMatcher = url => {
if (bareHost(url.hostname) !== 'open.spotify.com') {
return null
}
// Drop an optional locale prefix (`/intl-de/track/...`).
const segments = url.pathname.split('/').filter(Boolean)
const start = segments[0]?.startsWith('intl-') ? 1 : 0
const type = segments[start] || ''
const id = segments[start + 1] || ''
if (!EMBED_TYPES.has(type) || !/^[A-Za-z0-9]+$/.test(id)) {
return null
}
return {
embedUrl: `https://open.spotify.com/embed/${type}/${id}`,
height: COMPACT_HEIGHT,
id: `spotify:${type}:${id}`,
label: 'Spotify',
maxWidth: 480,
provider: 'spotify',
renderer: 'frame',
sourceUrl: url.toString()
}
}
@@ -0,0 +1,28 @@
import { bareHost, type EmbedMatcher } from './types'
export const tiktok: EmbedMatcher = url => {
if (bareHost(url.hostname) !== 'tiktok.com') {
return null
}
const segments = url.pathname.split('/').filter(Boolean)
const videoIndex = segments.indexOf('video')
const id = videoIndex >= 0 ? segments[videoIndex + 1] : ''
if (!/^\d+$/.test(id || '')) {
return null
}
return {
// The official player is a clean dark video iframe (no white blockquote
// chrome), so it goes through the plain-iframe frame path, sized 9:16.
aspectRatio: 9 / 16,
embedUrl: `https://www.tiktok.com/player/v1/${id}`,
id: `tiktok:${id}`,
label: 'TikTok',
maxWidth: 365,
provider: 'tiktok',
renderer: 'frame',
sourceUrl: url.toString()
}
}
@@ -0,0 +1,27 @@
import { bareHost, type EmbedMatcher } from './types'
export const twitter: EmbedMatcher = url => {
const host = bareHost(url.hostname)
if (host !== 'twitter.com' && host !== 'x.com') {
return null
}
const segments = url.pathname.split('/').filter(Boolean)
const statusIndex = segments.indexOf('status')
const id = statusIndex >= 0 ? segments[statusIndex + 1] : ''
if (!/^\d+$/.test(id || '')) {
return null
}
return {
id: `twitter:${id}`,
label: 'X',
maxWidth: 480,
provider: 'twitter',
renderer: 'tweet',
sourceUrl: url.toString(),
tweetId: id
}
}
@@ -0,0 +1,52 @@
// Embed provider model. Detection is pure, synchronous, and dependency-free so
// it is safe to run during render and trivial to unit-test. Rendering lives in
// the lazy renderers (see ../registry.tsx) keyed off `renderer`.
export type EmbedProvider =
'googlemaps' | 'instagram' | 'openstreetmap' | 'pinterest' | 'spotify' | 'tiktok' | 'twitter' | 'vimeo' | 'youtube'
/** Which lazy renderer materialises the descriptor. */
export type EmbedRenderer = 'frame' | 'tweet'
interface EmbedLayout {
/** Frame aspect ratio (width / height). For video/maps. */
aspectRatio?: number
/** Fixed pixel height for non-ratio embeds (Instagram, Pinterest, Spotify). */
height?: number
/** Max rendered width in px; falls back to the conversation column. */
maxWidth?: number
}
interface BaseEmbed extends EmbedLayout {
/** Stable id for React keys / dedupe. */
id: string
/** Human-facing provider name (e.g. "YouTube"). */
label: string
provider: EmbedProvider
renderer: EmbedRenderer
/** Canonical URL opened in the system browser from the card. */
sourceUrl: string
}
/** A provider whose embed is a single iframe URL (video, post, map, ...). */
export interface FrameEmbed extends BaseEmbed {
/** URL loaded inside the iframe. */
embedUrl: string
renderer: 'frame'
}
/** Twitter/X ships no iframe URL — only a widget script (see social-embed.tsx). */
export interface TweetEmbed extends BaseEmbed {
renderer: 'tweet'
tweetId: string
}
export type EmbedDescriptor = FrameEmbed | TweetEmbed
/** A provider matcher. Receives a parsed http(s) URL; returns null if unmatched. */
export type EmbedMatcher = (url: URL) => EmbedDescriptor | null
/** Strip a leading `www.`/`m.`/`mobile.` so host checks read cleanly. */
export function bareHost(host: string): string {
return host.replace(/^(?:www|m|mobile)\./i, '').toLowerCase()
}
@@ -0,0 +1,32 @@
import { bareHost, type EmbedMatcher } from './types'
export const vimeo: EmbedMatcher = url => {
const host = bareHost(url.hostname)
if (host !== 'vimeo.com' && host !== 'player.vimeo.com') {
return null
}
// The clip id is the last all-digits segment, covering vimeo.com/123,
// /channels/x/123, /groups/x/videos/123, and player/video/123.
const id = url.pathname
.split('/')
.filter(Boolean)
.reverse()
.find(segment => /^\d+$/.test(segment))
if (!id) {
return null
}
return {
aspectRatio: 16 / 9,
embedUrl: `https://player.vimeo.com/video/${id}`,
id: `vimeo:${id}`,
label: 'Vimeo',
maxWidth: 640,
provider: 'vimeo',
renderer: 'frame',
sourceUrl: url.toString()
}
}
@@ -0,0 +1,65 @@
import { bareHost, type EmbedMatcher } from './types'
const YOUTUBE_ID_RE = /^[A-Za-z0-9_-]{11}$/
// `t`/`start` accept either raw seconds ("90") or the "1m30s" form.
function startSeconds(value: string | null): number | undefined {
if (!value) {
return undefined
}
if (/^\d+$/.test(value)) {
return Number(value)
}
const match = value.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/)
if (!match || !match[0]) {
return undefined
}
const seconds = Number(match[1] || 0) * 3600 + Number(match[2] || 0) * 60 + Number(match[3] || 0)
return seconds > 0 ? seconds : undefined
}
export const youtube: EmbedMatcher = url => {
const host = bareHost(url.hostname)
const segments = url.pathname.split('/').filter(Boolean)
let id = ''
if (host === 'youtu.be') {
id = segments[0] || ''
} else if (host === 'youtube.com' || host === 'youtube-nocookie.com') {
if (segments[0] === 'watch') {
id = url.searchParams.get('v') || ''
} else if (['embed', 'shorts', 'live', 'v'].includes(segments[0] || '')) {
id = segments[1] || ''
}
} else {
return null
}
if (!YOUTUBE_ID_RE.test(id)) {
return null
}
const params = new URLSearchParams({ modestbranding: '1', rel: '0' })
const start = startSeconds(url.searchParams.get('t') || url.searchParams.get('start'))
if (start) {
params.set('start', String(start))
}
return {
aspectRatio: 16 / 9,
embedUrl: `https://www.youtube-nocookie.com/embed/${id}?${params.toString()}`,
id: `youtube:${id}`,
label: 'YouTube',
maxWidth: 640,
provider: 'youtube',
renderer: 'frame',
sourceUrl: url.toString()
}
}
@@ -0,0 +1,39 @@
'use client'
import { type ComponentType, lazy, type LazyExoticComponent, type ReactNode, Suspense } from 'react'
import { RichBoundary } from './rich-boundary'
import type { RichFenceProps } from './types'
// Root renderer for fenced code blocks: a language → lazy-renderer table. Each
// renderer is its own split chunk (mermaid pulls in the mermaid lib, svg pulls
// in DOMPurify), loaded only when a block of that language actually appears.
const LAZY_FENCE: Record<string, LazyExoticComponent<ComponentType<RichFenceProps>>> = {
mermaid: lazy(() => import('./mermaid-embed')),
svg: lazy(() => import('./svg-embed'))
}
export const RICH_FENCE_LANGUAGES: ReadonlySet<string> = new Set(Object.keys(LAZY_FENCE))
interface RichCodeBlockProps extends RichFenceProps {
/** Rendered for unhandled languages, while the chunk loads, and on failure
* (typically the normal syntax-highlighted code block). */
fallback: ReactNode
language?: string
}
export function RichCodeBlock({ code, fallback, language, streaming }: RichCodeBlockProps) {
const Renderer = language ? LAZY_FENCE[language.toLowerCase()] : undefined
if (!Renderer) {
return <>{fallback}</>
}
return (
<RichBoundary fallback={fallback} resetKey={code}>
<Suspense fallback={fallback}>
<Renderer code={code} streaming={streaming} />
</Suspense>
</RichBoundary>
)
}
@@ -0,0 +1,34 @@
import { Component, type ReactNode } from 'react'
interface Props {
children: ReactNode
/** Rendered in place of the subtree when a render throws. */
fallback: ReactNode
/** Changing this clears a caught error (e.g. new source for a re-parse). */
resetKey?: string
}
/**
* Local boundary for rich renderers (Mermaid parse throws, malformed SVG, a
* provider widget blowing up). A failed embed must never blank the transcript —
* we show the `fallback` (typically the raw source) and recover when `resetKey`
* changes. Unlike MessageRenderBoundary this swallows ALL render errors, because
* the blast radius is one self-contained block, not the message tree.
*/
export class RichBoundary extends Component<Props, { failed: boolean }> {
state = { failed: false }
static getDerivedStateFromError() {
return { failed: true }
}
componentDidUpdate(prev: Props) {
if (this.state.failed && prev.resetKey !== this.props.resetKey) {
this.setState({ failed: false })
}
}
render() {
return this.state.failed ? this.props.fallback : this.props.children
}
}
@@ -0,0 +1,33 @@
'use client'
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
/** Block wheel until ⌘/Ctrl so map embeds don't hijack transcript scroll. */
export function ScrollGate() {
const [active, setActive] = useState(false)
useEffect(() => {
const sync = (event: KeyboardEvent) => setActive(event.metaKey || event.ctrlKey)
const clear = () => setActive(false)
window.addEventListener('keydown', sync)
window.addEventListener('keyup', sync)
window.addEventListener('blur', clear)
return () => {
window.removeEventListener('keydown', sync)
window.removeEventListener('keyup', sync)
window.removeEventListener('blur', clear)
}
}, [])
return (
<div className={cn('group/gate absolute inset-0', active ? 'pointer-events-none' : 'pointer-events-auto')}>
<span className="pointer-events-none absolute bottom-2 left-2 rounded-md bg-black/55 px-1.5 py-0.5 text-[0.625rem] font-medium text-white opacity-0 transition-opacity group-hover/embed:opacity-100">
Hold to zoom
</span>
</div>
)
}
@@ -0,0 +1,131 @@
'use client'
import { useEffect, useRef } from 'react'
import { escapeHtml } from './escape-html'
import type { EmbedDescriptor } from './providers/types'
import { useIsDark } from './use-is-dark'
// The provider embed scripts need a REAL origin to run (they touch
// cookies/storage/postMessage), so — exactly like react-social-media-embed — we
// render the official blockquote in this document and let the script swap it for
// a correctly-sized iframe. A sandboxed srcDoc iframe gives a null origin and
// the scripts silently bail (white / 2px). The container is height:auto, so it
// grows to whatever the provider renders. No measuring, no forced height.
type EmbedWindow = Window &
typeof globalThis & {
instgrm?: { Embeds?: { process?: () => void } }
twttr?: { widgets?: { load?: (el?: HTMLElement) => void } }
}
const SCRIPT: Record<string, { id: string; src: string }> = {
instagram: { id: 'hermes-ig-embed', src: 'https://www.instagram.com/embed.js' },
tiktok: { id: 'hermes-tt-embed', src: 'https://www.tiktok.com/embed.js' },
twitter: { id: 'hermes-tw-embed', src: 'https://platform.twitter.com/widgets.js' }
}
const PROCESS_DELAYS_MS = [0, 300, 800, 1600, 3000]
function markup(descriptor: EmbedDescriptor, theme: 'dark' | 'light'): string {
const url = escapeHtml(descriptor.sourceUrl)
switch (descriptor.provider) {
case 'instagram':
return `<blockquote class="instagram-media" data-instgrm-permalink="${url}" data-instgrm-version="14" style="margin:0;width:100%;min-width:0;max-width:100%"></blockquote>`
case 'tiktok': {
const id = escapeHtml(descriptor.id.replace(/^tiktok:/, ''))
return `<blockquote class="tiktok-embed" cite="${url}" data-video-id="${id}" style="margin:0;max-width:100%"><section></section></blockquote>`
}
case 'twitter':
// data-chrome="transparent" drops the card background so the themed page
// shows through instead of a white box.
return `<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}" data-chrome="transparent"><a href="${url}"></a></blockquote>`
default:
return ''
}
}
function loadScript(provider: string): Promise<void> {
const { id, src } = SCRIPT[provider]
// TikTok exposes no re-process API; its script rescans the document each time
// it runs, so we re-inject it. The others are loaded once and reused.
if (provider === 'tiktok') {
document.getElementById(id)?.remove()
} else if (document.getElementById(id)) {
return Promise.resolve()
}
return new Promise(resolve => {
const script = document.createElement('script')
script.async = true
script.id = id
script.onload = () => resolve()
script.onerror = () => resolve()
script.src = src
document.body.appendChild(script)
})
}
function processEmbed(provider: string, container: HTMLElement): void {
const win = window as EmbedWindow
if (provider === 'instagram') {
win.instgrm?.Embeds?.process?.()
} else if (provider === 'twitter') {
win.twttr?.widgets?.load?.(container)
}
// TikTok auto-scans on (re)injection — no manual process call.
}
export default function SocialEmbedRenderer({ descriptor }: { descriptor: EmbedDescriptor }) {
const isDark = useIsDark()
const ref = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const container = ref.current
if (!container) {
return
}
let cancelled = false
const timers: number[] = []
container.innerHTML = markup(descriptor, isDark ? 'dark' : 'light')
void loadScript(descriptor.provider).then(() => {
// The script renders asynchronously; nudge a few times so the embed
// settles whether the script was cached or freshly fetched.
for (const delay of PROCESS_DELAYS_MS) {
timers.push(window.setTimeout(() => !cancelled && processEmbed(descriptor.provider, container), delay))
}
})
return () => {
cancelled = true
for (const timer of timers) {
clearTimeout(timer)
}
container.innerHTML = ''
}
}, [descriptor, isDark])
// The white corner/box on tweets is a color-scheme MISMATCH: when the iframe's
// resolved scheme differs from ours, the browser paints an opaque (white)
// Canvas behind it. Twitter's embed resolves to `light`, so we force the iframe
// to `light` to match — no mismatch, no Canvas — and data-chrome=transparent
// then lets the dark page show through. (Confirmed: mkdocs-material #6889.)
return (
<div
className="w-full [&_.instagram-media]:!min-w-0 [&_iframe]:!m-0 [&_iframe]:!max-w-full [&_iframe]:[color-scheme:light]"
ref={ref}
/>
)
}
@@ -0,0 +1,45 @@
'use client'
import { type CSSProperties, useMemo } from 'react'
import type { FrameEmbed } from './providers/types'
import { useIsDark } from './use-is-dark'
const ALLOW = 'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture'
// Spotify paints a white backdrop behind its card; theme=0 gives the dark
// player and the card wrapper's overflow-hidden clips the corners.
function spotifySrc(embedUrl: string, isDark: boolean): string {
const url = new URL(embedUrl)
url.searchParams.set('utm_source', 'generator')
if (isDark) {
url.searchParams.set('theme', '0')
}
return url.toString()
}
export default function SpotifyEmbedRenderer({ descriptor }: { descriptor: FrameEmbed }) {
const isDark = useIsDark()
const src = useMemo(() => spotifySrc(descriptor.embedUrl, isDark), [descriptor.embedUrl, isDark])
// Match the iframe's own (light) scheme — a `dark` mismatch makes the browser
// paint an opaque white Canvas behind it. theme=0 still gives the dark player.
const style: CSSProperties = {
colorScheme: 'light',
height: descriptor.height
}
return (
<iframe
allow={ALLOW}
className="block w-full border-0 bg-transparent"
loading="lazy"
src={src}
style={style}
title="Spotify embed"
/>
)
}
@@ -0,0 +1,32 @@
'use client'
import DOMPurify from 'dompurify'
import { useMemo } from 'react'
import type { RichFenceProps } from './types'
// Lazy chunk (pulls in DOMPurify). Renders a ```svg fence as an image after
// hard-sanitising it: the svg profile strips scripts, event handlers, and
// foreignObject, so untrusted model output can't execute.
export default function SvgRenderer({ code }: RichFenceProps) {
const clean = useMemo(
() =>
DOMPurify.sanitize(code, {
USE_PROFILES: { svg: true, svgFilters: true }
}),
[code]
)
if (!clean.trim()) {
return null
}
// Left-aligned, capped on both axes so a large intrinsic SVG scales down
// (preserving ratio) instead of filling the column or centering.
return (
<div
className="my-2 [&_svg]:block [&_svg]:h-auto [&_svg]:w-auto [&_svg]:max-h-[33dvh] [&_svg]:max-w-full"
dangerouslySetInnerHTML={{ __html: clean }}
/>
)
}
@@ -0,0 +1,8 @@
// Shared prop contract for fenced-block renderers (mermaid, svg). Kept in its
// own module so renderers and the registry can both import it without a cycle.
export interface RichFenceProps {
code: string
/** True while the surrounding message is still streaming. Renderers that can
* throw on partial input (e.g. mermaid) defer until this is false. */
streaming?: boolean
}
@@ -0,0 +1,84 @@
'use client'
import { useStore } from '@nanostores/react'
import { type CSSProperties, lazy, Suspense, useState } from 'react'
import { PrettyLink } from '@/lib/external-link'
import { $embedAllowed, $embedMode } from '@/store/embed-consent'
import { EmbedFacade } from './embed-consent'
import { EMBED_MAX_H } from './embed-size'
import { EmbedFail } from './fail'
import type { EmbedDescriptor } from './providers/types'
import { RichBoundary } from './rich-boundary'
const FrameEmbedRenderer = lazy(() => import('./frame-embed'))
const SocialEmbedRenderer = lazy(() => import('./social-embed'))
const SpotifyEmbedRenderer = lazy(() => import('./spotify-embed'))
const YouTubeEmbedRenderer = lazy(() => import('./youtube-embed'))
function intrinsicHeight(descriptor: EmbedDescriptor): number {
if (descriptor.aspectRatio) {
return Math.round((descriptor.maxWidth ?? 640) / descriptor.aspectRatio)
}
return descriptor.height ?? 320
}
function LazyRenderer({ descriptor }: { descriptor: EmbedDescriptor }) {
// X and Instagram load their official blockquote script in-document. The tweet
// check also narrows the union to FrameEmbed for the iframe renderers below.
if (descriptor.renderer === 'tweet' || descriptor.provider === 'instagram') {
return <SocialEmbedRenderer descriptor={descriptor} />
}
if (descriptor.provider === 'youtube') {
return <YouTubeEmbedRenderer descriptor={descriptor} />
}
if (descriptor.provider === 'spotify') {
return <SpotifyEmbedRenderer descriptor={descriptor} />
}
return <FrameEmbedRenderer descriptor={descriptor} />
}
export function UrlEmbed({ descriptor }: { descriptor: EmbedDescriptor }) {
const mode = useStore($embedMode)
const allowed = useStore($embedAllowed)
const [loaded, setLoaded] = useState(false)
// Privacy gate: don't reach out to the provider until consented. `off` keeps
// it a plain link; otherwise the placeholder shows until "Load" (this embed)
// or "Always allow" / global `always` permits the fetch.
if (mode === 'off') {
return <PrettyLink className="wrap-anywhere" href={descriptor.sourceUrl} />
}
const consented = mode === 'always' || loaded || allowed.includes(descriptor.provider)
const aspect = descriptor.aspectRatio
// Ratio embeds cap WIDTH off the ratio so height tops out at the cap while
// scaling. Non-ratio embeds own their own height (measured / fixed).
const style: CSSProperties = {
containIntrinsicSize: `auto ${intrinsicHeight(descriptor)}px`,
contentVisibility: 'auto',
...(aspect
? { width: `min(${descriptor.maxWidth ?? 640}px, 100%, calc(${EMBED_MAX_H} * ${aspect}))` }
: { width: descriptor.maxWidth ? `min(${descriptor.maxWidth}px, 100%)` : '100%' })
}
return (
<span className="group/embed my-2 block overflow-hidden rounded-lg" data-slot="aui_embed-card" style={style}>
<RichBoundary fallback={<EmbedFail label={descriptor.label} />} resetKey={descriptor.id}>
{consented ? (
<Suspense fallback={null}>
<LazyRenderer descriptor={descriptor} />
</Suspense>
) : (
<EmbedFacade descriptor={descriptor} onLoad={() => setLoaded(true)} />
)}
</RichBoundary>
</span>
)
}
@@ -0,0 +1,18 @@
import { useEffect, useState } from 'react'
import { useThemeEpoch } from '@/hooks/use-theme-epoch'
const isDarkNow = () => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
// Tracks the app's dark/light mode off the `dark` class on <html> (set by
// themes/context.tsx). Embeds that theme their own content (tweets) read this.
// Rides the shared theme-repaint observer; setState bails on an unchanged
// boolean, so style-only repaints don't re-render.
export function useIsDark(): boolean {
const epoch = useThemeEpoch()
const [dark, setDark] = useState(isDarkNow)
useEffect(() => setDark(isDarkNow()), [epoch])
return dark
}
@@ -0,0 +1,47 @@
'use client'
import { useMemo } from 'react'
import type { FrameEmbed } from './providers/types'
import { useIsDark } from './use-is-dark'
const YOUTUBE_ALLOW =
'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen'
function youtubeSrc(embedUrl: string): string {
const url = new URL(embedUrl)
// Only pass origin when it is an HTTP(S) origin; custom schemes (app://,
// file://) can make the player reject otherwise embeddable videos.
if (
typeof window !== 'undefined' &&
(window.location.protocol === 'http:' || window.location.protocol === 'https:') &&
window.location.origin &&
window.location.origin !== 'null'
) {
url.searchParams.set('origin', window.location.origin)
}
return url.toString()
}
// Keep this as a plain iframe and let YouTube render its native player/error UI.
export default function YouTubeEmbedRenderer({ descriptor }: { descriptor: FrameEmbed }) {
const isDark = useIsDark()
const src = useMemo(() => youtubeSrc(descriptor.embedUrl), [descriptor.embedUrl])
// Width is capped to the ratio by UrlEmbed, so aspect-video sizes height ≤ cap.
return (
<iframe
allow={YOUTUBE_ALLOW}
allowFullScreen
className="block aspect-video w-full border-0 bg-transparent"
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
scrolling="no"
src={src}
style={{ colorScheme: isDark ? 'dark' : 'light' }}
title="YouTube embed"
/>
)
}
@@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest'
import {
directiveFrameHeight,
frameSizeFromMessage,
intentFromMessage,
themePrelude,
withInlineChrome
} from './inline-preview-directive'
describe('directiveFrameHeight', () => {
it('returns null (auto-size) when absent or garbage', () => {
expect(directiveFrameHeight(undefined)).toBeNull()
expect(directiveFrameHeight('')).toBeNull()
expect(directiveFrameHeight('tall')).toBeNull()
expect(directiveFrameHeight('12.5')).toBeNull()
})
it('clamps an explicit height to the sane band', () => {
expect(directiveFrameHeight('50')).toBe(120)
expect(directiveFrameHeight('480')).toBe(480)
expect(directiveFrameHeight('99999')).toBe(1200)
})
})
describe('withInlineChrome', () => {
const prelude = themePrelude({ '--foreground': '#eee' }, 'Inter')
it('puts the theme prelude FIRST so page styles override it', () => {
const doc = '<html><head><style>body{color:red}</style></head><body><h1>hi</h1></body></html>'
const framed = withInlineChrome(doc, 'tok', prelude)
expect(framed.startsWith(prelude)).toBe(true)
expect(framed.indexOf(prelude)).toBeLessThan(framed.indexOf('color:red'))
})
it('injects the measuring script before </body>', () => {
const doc = '<html><body><h1>hi</h1></body></html>'
const framed = withInlineChrome(doc, 'tok', prelude)
expect(framed.indexOf('postMessage')).toBeGreaterThan(framed.indexOf('<h1>'))
expect(framed.indexOf('postMessage')).toBeLessThan(framed.indexOf('</body>'))
expect(framed).toContain('"tok"')
})
it('appends the script when there is no body close tag', () => {
const framed = withInlineChrome('<h1>fragment</h1>', 'tok', prelude)
expect(framed).toContain('<h1>fragment</h1>')
expect(framed).toContain('postMessage')
})
})
describe('themePrelude', () => {
it('carries resolved tokens, transparent background, and the app font', () => {
const prelude = themePrelude({ '--foreground': 'oklch(0.9 0 0)', '--accent': '#7aa2f7' }, 'Inter, sans-serif')
expect(prelude).toContain('--foreground:oklch(0.9 0 0)')
expect(prelude).toContain('--accent:#7aa2f7')
expect(prelude).toContain('background:transparent')
expect(prelude).toContain('font-family:Inter, sans-serif')
})
it('omits the font rule when no font resolved', () => {
expect(themePrelude({}, '')).not.toContain('font-family')
})
})
describe('frameSizeFromMessage', () => {
const msg = (over: Record<string, unknown> = {}) => ({
type: 'hermes-inline-preview-size',
token: 'tok',
height: 500,
width: 300,
...over
})
it('accepts our message with our token, height clamped', () => {
expect(frameSizeFromMessage(msg(), 'tok')).toEqual({ height: 500, width: 300 })
expect(frameSizeFromMessage(msg({ height: 12 }), 'tok')?.height).toBe(120)
expect(frameSizeFromMessage(msg({ height: 5000 }), 'tok')?.height).toBe(1200)
expect(frameSizeFromMessage(msg({ height: 500.7 }), 'tok')?.height).toBe(501)
})
it('sanitizes width to 0 when missing or hostile', () => {
expect(frameSizeFromMessage(msg({ width: undefined }), 'tok')?.width).toBe(0)
expect(frameSizeFromMessage(msg({ width: 'wide' }), 'tok')?.width).toBe(0)
expect(frameSizeFromMessage(msg({ width: Infinity }), 'tok')?.width).toBe(0)
expect(frameSizeFromMessage(msg({ width: -10 }), 'tok')?.width).toBe(0)
})
it('rejects wrong type, wrong token, and hostile shapes', () => {
expect(frameSizeFromMessage(msg({ type: 'other' }), 'tok')).toBeNull()
expect(frameSizeFromMessage(msg({ token: 'stolen' }), 'tok')).toBeNull()
expect(frameSizeFromMessage(msg({ height: 'tall' }), 'tok')).toBeNull()
expect(frameSizeFromMessage(msg({ height: Infinity }), 'tok')).toBeNull()
expect(frameSizeFromMessage(msg({ height: -5 }), 'tok')).toBeNull()
expect(frameSizeFromMessage(null, 'tok')).toBeNull()
expect(frameSizeFromMessage('str', 'tok')).toBeNull()
})
})
describe('intentFromMessage', () => {
const msg = (over: Record<string, unknown> = {}) => ({
type: 'hermes-inline-preview-intent',
token: 'tok',
prompt: 'get-price eth',
...over
})
it('accepts our intent with our token, trimmed', () => {
expect(intentFromMessage(msg(), 'tok')).toBe('get-price eth')
expect(intentFromMessage(msg({ prompt: ' hi ' }), 'tok')).toBe('hi')
})
it('caps runaway prompts to a sentence-sized budget', () => {
expect(intentFromMessage(msg({ prompt: 'x'.repeat(9000) }), 'tok')).toHaveLength(500)
})
it('rejects wrong token, wrong type, empty, and hostile shapes', () => {
expect(intentFromMessage(msg({ token: 'stolen' }), 'tok')).toBeNull()
expect(intentFromMessage(msg({ type: 'hermes-inline-preview-size' }), 'tok')).toBeNull()
expect(intentFromMessage(msg({ prompt: ' ' }), 'tok')).toBeNull()
expect(intentFromMessage(msg({ prompt: 42 }), 'tok')).toBeNull()
expect(intentFromMessage(null, 'tok')).toBeNull()
})
})
describe('withInlineChrome intent wiring', () => {
it('injects hermes.send and the data-hermes-send click bridge', () => {
const framed = withInlineChrome('<html><body><h1>w</h1></body></html>', 'tok', '')
expect(framed).toContain('window.hermes={send:send}')
expect(framed).toContain('data-hermes-send')
expect(framed).toContain('hermes-inline-preview-intent')
})
})
@@ -0,0 +1,418 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import { requestComposerSubmit } from '@/app/chat/composer/focus'
import { useSessionView } from '@/app/chat/session-view'
import { useIsDark } from '@/components/assistant-ui/embeds/use-is-dark'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { readDesktopFileText } from '@/lib/desktop-fs'
import { localPreviewTarget } from '@/lib/local-preview'
/**
* `::preview{file="…"}` — a workspace HTML file rendered LIVE inside the
* assistant message. A sandboxed iframe with an opaque origin
* (`sandbox="allow-scripts"`, deliberately no `allow-same-origin`): scripts
* run and the widget is fully interactive, but the document cannot reach the
* app, its cookies, storage, or the bridge. The doc arrives via `srcdoc`
* from a bridge file read, so single-file HTML (what agents generate) is
* fully live; relative sibling assets don't resolve in an opaque origin.
*
* SIZE IS CONTENT-DRIVEN. The opaque origin means the parent can't measure
* the document, but we own the srcdoc string — an injected script posts the
* content's size up via postMessage (tagged with a per-mount token). Height
* tracks live within the clamp band; width adopts ONCE from the first
* report, so a fixed-size widget shrink-wraps and sits left in the message
* flow like an image, while a fluid page measures the full viewport and
* stays column-wide. A `height="480"` attribute only sets the starting
* height — measurement always wins.
*
* NATIVE BY DEFAULT. A theme prelude injects first: the app's resolved
* theme tokens under friendly names (--foreground, --muted-foreground,
* --accent, --border, --card), the app font, zero body margin/padding, and
* a transparent background — so widget-shaped content reads as part of the
* app. The page's own styles override all of it, so a full page keeps its
* own design.
*
* WIDGETS TALK BACK OFF-SCREEN. `window.hermes.send(prompt)` (or declarative
* `data-hermes-send` on any clickable element) routes the prompt through the
* composer's send path as a user turn typed `display_kind=hidden`: the agent
* wakes and the durable row exists (context, resume, audit via the DB), but
* no bubble renders — the widget updating is the visible response. Token-
* gated, length-capped, throttled to human speed.
*
* Non-HTML targets and remote gateways (no local file access) fall back to
* the standard preview-attachment card rather than a broken frame.
*/
const MIN_HEIGHT = 120
const MAX_HEIGHT = 1200
const DEFAULT_HEIGHT = 280
/** The transcript column cap the frame renders inside (`max-w-160` = 40rem). */
const MAX_COLUMN_WIDTH = 640
/** Ignore sub-pixel/rounding churn so a vh-sized page can't oscillate. */
const RESIZE_TOLERANCE = 4
export function directiveFrameHeight(raw: string | undefined): number | null {
if (!raw) {
return null
}
const parsed = Number(raw)
if (!Number.isInteger(parsed)) {
return null
}
return Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, parsed))
}
const SIZE_MESSAGE_TYPE = 'hermes-inline-preview-size'
const INTENT_MESSAGE_TYPE = 'hermes-inline-preview-intent'
/** Prompt length cap for a widget intent — a sentence, not a payload dump. */
const MAX_INTENT_LENGTH = 500
/** One intent per frame per second; clicks are human-speed. */
const INTENT_THROTTLE_MS = 1000
/** The script that gives the widget its ONE voice: `hermes.send(prompt)`.
* Posts the prompt up tagged with the mount token; the parent validates,
* throttles, and routes it through the composer as a normal user message —
* the widget speaks WITH the user's voice, visibly, never silently. Also
* wires `data-hermes-send` so declarative HTML works with zero script:
* `<button data-hermes-send="get-price eth">ETH</button>`. */
export function intentScript(token: string): string {
return (
'<script>(function(){var t=' +
JSON.stringify(token) +
';function send(p){if(typeof p!=="string"||!p.trim())return false;' +
'parent.postMessage({type:' +
JSON.stringify(INTENT_MESSAGE_TYPE) +
',token:t,prompt:p.slice(0,' +
String(MAX_INTENT_LENGTH) +
')},"*");return true}' +
'window.hermes={send:send};' +
'addEventListener("click",function(e){var el=e.target&&e.target.closest?' +
'e.target.closest("[data-hermes-send]"):null;' +
'if(el)send(el.getAttribute("data-hermes-send")||"")},true)})()</script>'
)
}
/** Parse a widget intent. Null unless it is OUR type with OUR token and a
* non-empty string prompt — same trust boundary as size reports, because
* this one turns into a user message. Trimmed and length-capped. */
export function intentFromMessage(data: unknown, token: string): string | null {
if (typeof data !== 'object' || data === null) {
return null
}
const message = data as { type?: unknown; token?: unknown; prompt?: unknown }
if (message.type !== INTENT_MESSAGE_TYPE || message.token !== token || typeof message.prompt !== 'string') {
return null
}
const prompt = message.prompt.trim().slice(0, MAX_INTENT_LENGTH)
return prompt || null
}
/** Semantic tokens handed into the frame, resolved to concrete values from
* the LIVE theme. Friendly names, not internal ones — this is the contract
* reference HTML / skills write against (`var(--foreground)` etc.). */
const THEME_BRIDGE_TOKENS: Record<string, string> = {
'--foreground': '--ui-text-primary',
'--muted-foreground': '--ui-text-tertiary',
'--accent': '--ui-accent',
'--border': '--ui-stroke-tertiary',
'--card': '--ui-bg-editor'
}
/** Resolve the bridge tokens + app font against the current document. */
export function collectThemeBridge(): { vars: Record<string, string>; font: string } {
const vars: Record<string, string> = {}
if (typeof document !== 'undefined') {
const root = getComputedStyle(document.documentElement)
for (const [alias, source] of Object.entries(THEME_BRIDGE_TOKENS)) {
const value = root.getPropertyValue(source).trim()
if (value) {
vars[alias] = value
}
}
}
const font = typeof document === 'undefined' ? '' : getComputedStyle(document.body).fontFamily
return { vars, font }
}
/**
* The style prelude that makes an inline widget read as NATIVE: the app's
* resolved theme tokens as CSS vars, the app font, no margin, and a
* transparent background so the widget sits directly on the chat surface.
* Injected FIRST, so the page's own styles override every default here — a
* full page that wants its own look keeps it.
*/
export function themePrelude(vars: Record<string, string>, font: string): string {
const tokens = Object.entries(vars)
.map(([name, value]) => `${name}:${value}`)
.join(';')
const fontRule = font ? `font-family:${font};` : ''
return (
`<style>:root{${tokens}}` +
`html,body{margin:0;padding:0;background:transparent;color:var(--foreground,inherit);${fontRule}}</style>`
)
}
/** The script injected into the srcdoc that reports content size to the
* parent. Runs inside the opaque origin, so postMessage is its only door —
* it can say "I am N pixels" and nothing else. Height is the document
* scrollHeight; width is the union of the body children's boxes (intrinsic
* content width — the document itself always fills the viewport, so
* scrollWidth would just echo the frame back). */
export function measurementScript(token: string): string {
return (
'<script>(function(){var t=' +
JSON.stringify(token) +
';var lastH=0,lastW=0;function post(){var d=document.documentElement;var b=document.body;' +
'var h=Math.max(d?d.scrollHeight:0,b?b.scrollHeight:0);' +
'var w=0;if(b){var kids=b.children;var L=Infinity,R=0;for(var i=0;i<kids.length;i++){' +
'var r=kids[i].getBoundingClientRect();if(r.width===0&&r.height===0)continue;' +
'if(r.left<L)L=r.left;if(r.right>R)R=r.right}' +
'if(R>L)w=R-L}' +
'w=Math.ceil(w);' +
'if(Math.abs(h-lastH)>1||Math.abs(w-lastW)>1){lastH=h;lastW=w;parent.postMessage({type:' +
JSON.stringify(SIZE_MESSAGE_TYPE) +
',token:t,height:h,width:w},"*")}}' +
'if(typeof ResizeObserver==="function"){var ro=new ResizeObserver(post);' +
'ro.observe(document.documentElement);if(document.body)ro.observe(document.body)}' +
'addEventListener("load",post);post()})()</script>'
)
}
/** Assemble the srcdoc: theme prelude first (so the page's own styles win),
* then the measuring + intent scripts before `</body>` when present so they
* run after the page's own markup, appended otherwise. */
export function withInlineChrome(doc: string, token: string, prelude: string): string {
const script = measurementScript(token) + intentScript(token)
const bodyClose = /<\/body\s*>/i.exec(doc)
const framed = bodyClose ? doc.slice(0, bodyClose.index) + script + doc.slice(bodyClose.index) : doc + script
return prelude + framed
}
export interface FrameSizeReport {
height: number
/** Intrinsic content width, 0 when unmeasurable. */
width: number
}
/** Parse a size report from the frame. Null unless it is OUR message type,
* carries OUR token, and holds a sane finite height — anything inside the
* sandbox can postMessage, so everything is validated before it moves the
* layout. Height clamped to the band; width sanitized but uncapped (the
* frame caps it against the column at render). */
export function frameSizeFromMessage(data: unknown, token: string): FrameSizeReport | null {
if (typeof data !== 'object' || data === null) {
return null
}
const message = data as { type?: unknown; token?: unknown; height?: unknown; width?: unknown }
if (message.type !== SIZE_MESSAGE_TYPE || message.token !== token || typeof message.height !== 'number') {
return null
}
if (!Number.isFinite(message.height) || message.height <= 0) {
return null
}
const width =
typeof message.width === 'number' && Number.isFinite(message.width) && message.width > 0
? Math.round(message.width)
: 0
return {
height: Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, Math.round(message.height))),
width
}
}
const HTML_FILE_RE = /\.(?:html?|xhtml)$/i
export function InlinePreviewDirective({
attrs,
streaming
}: {
attrs: Readonly<Record<string, string>>
streaming: boolean
}) {
const file = attrs.file ?? ''
// Not renderable inline: hand the leaf to the classic card. Non-HTML has
// nothing to frame. (Remote gateways used to bail here too — that predates
// the mode-aware fs bridge; the frame now reads through readDesktopFileText,
// which fetches over the authenticated /api/fs bridge in remote mode, so a
// URL connection — including a same-machine `hermes serve` — renders live.)
if (!file || !HTML_FILE_RE.test(file)) {
return file ? <PreviewAttachment source="explicit-link" target={file} /> : null
}
return <InlineHtmlFrame file={file} initialHeight={directiveFrameHeight(attrs.height)} streaming={streaming} />
}
function InlineHtmlFrame({
file,
initialHeight,
streaming
}: {
file: string
/** `height` attribute — the starting height only; measurement overrides. */
initialHeight: number | null
streaming: boolean
}) {
const cwd = useStore(useSessionView().$cwd)
const isDark = useIsDark()
const [doc, setDoc] = useState<string | null>(null)
const [failed, setFailed] = useState(false)
const [measured, setMeasured] = useState<number | null>(null)
const [contentWidth, setContentWidth] = useState<number | null>(null)
// One token per mount: the message listener only trusts reports from the
// document THIS mount injected, so two previews in one transcript (or a
// hostile page inventing messages) can't move each other's frames.
const token = useMemo(() => Math.random().toString(36).slice(2), [])
// Resolve against THIS session's cwd (the file was written by its agent).
const resolved = localPreviewTarget(file, cwd || undefined)
const path = resolved?.path ?? null
useEffect(() => {
// Wait for turn settle: mid-stream the file is often mid-write, and a
// half-written srcdoc renders as garbage that never self-corrects.
if (!path || streaming) {
return
}
let alive = true
void Promise.resolve(readDesktopFileText(path))
.then(result => {
if (!alive) {
return
}
if (!result || result.binary || !result.text) {
setFailed(true)
} else {
setDoc(result.text)
}
})
.catch(() => alive && setFailed(true))
return () => {
alive = false
}
}, [path, streaming])
useEffect(() => {
// Human-speed gate on widget intents. A closure local, not state: it's
// a rate limiter read inside the handler, never rendered.
let lastIntentAt = 0
const onMessage = (event: MessageEvent) => {
const intent = intentFromMessage(event.data, token)
if (intent !== null) {
const now = Date.now()
if (now - lastIntentAt >= INTENT_THROTTLE_MS) {
lastIntentAt = now
// Off-screen: the prompt reaches the agent as a normal user turn
// through the composer's own send path (steer/queue rules apply),
// but the row is typed hidden — no bubble, no UI space. The widget
// updating IS the visible response.
requestComposerSubmit(intent, { target: 'active', displayKind: 'hidden' })
}
return
}
const next = frameSizeFromMessage(event.data, token)
if (next === null) {
return
}
// Functional updates so the comparisons read current state without a
// shadow ref: same-value sets bail out in React, and the tolerance
// keeps a vh-sized page (which measures what it's given) from
// oscillating.
setMeasured(prev =>
Math.abs(next.height - (prev ?? initialHeight ?? DEFAULT_HEIGHT)) > RESIZE_TOLERANCE ? next.height : prev
)
// Width adopts ONCE, from the first report — measured at full column
// width, so it is the content's intrinsic span. Tracking width live
// would feedback-loop: %-width children reflow narrower every time
// the frame shrinks, spiraling toward zero.
if (next.width > 0) {
setContentWidth(prev => prev ?? next.width)
}
}
window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
}, [initialHeight, token])
// Resolved once per mount; theme switches remount the transcript anyway.
const framedDoc = useMemo(() => {
if (doc === null) {
return null
}
const { vars, font } = collectThemeBridge()
return withInlineChrome(doc, token, themePrelude(vars, font))
}, [doc, token])
if (!path || failed) {
return <PreviewAttachment source="explicit-link" target={file} />
}
const height = measured ?? initialHeight ?? DEFAULT_HEIGHT
// Left-aligned in the message flow, like an image: the frame is only as
// wide as its content (capped at the column). Fluid pages measure the
// full viewport and stay full-bleed.
const width = contentWidth !== null ? Math.min(contentWidth, MAX_COLUMN_WIDTH) : undefined
return (
<span className="my-2 block w-full max-w-160">
{framedDoc === null ? (
<span
className="block w-full animate-pulse rounded-md bg-[color-mix(in_srgb,currentColor_4%,transparent)]"
style={{ height }}
/>
) : (
<span
className="relative block max-w-full transition-[height] duration-200"
style={{ height, width: width ?? '100%' }}
>
<iframe
className="absolute inset-0 size-full border-0 bg-transparent"
loading="lazy"
sandbox="allow-scripts"
srcDoc={framedDoc}
style={{ colorScheme: isDark ? 'dark' : 'light' }}
title={file}
/>
</span>
)}
</span>
)
}
@@ -0,0 +1,210 @@
import {
type ComponentProps,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
useCallback,
useLayoutEffect,
useRef,
useState
} from 'react'
import { clearTableWidths, markdownTableKey, readTableWidths, writeTableWidths } from '@/lib/markdown-table-widths'
import { cn } from '@/lib/utils'
/**
* Drag-resizable columns for transcript markdown tables.
*
* Two choices keep this small:
*
* 1. A `<colgroup>` of percentages is the only state. Widths never touch the
* cells. One `<col>` per column, `table-layout: fixed`, and the browser does
* the rest — no per-cell measurement, no sticky header clone, no shadow copy
* of the table's contents.
* 2. A drag moves exactly one seam. The pair either side of the handle trade
* width and their sum is preserved, so the table box never changes size
* mid-drag: no reflow of the message around it, no scrollbar appearing under
* the pointer. jquery-resizable-columns settled on the same invariant, minus
* the absolutely-positioned handle overlay it has to re-sync on every window
* resize.
*
* Handles are plain markup inside each `<th>`; the table listens once and
* resolves which seam was grabbed from the DOM at pointer-down. No context, no
* per-column component, no index threading — a column knows its position
* because it *is* in that position.
*
* Until a table is resized it stays in auto layout, which is the better
* default: the browser fits columns to their content. The colgroup only appears
* once there is a width to state.
*
* A drag sets state on this component alone, and `children` is an already-built
* element tree whose reference does not change, so React reconciles the
* colgroup and bails out of the whole table body. Measured on a 43-row table: a
* 40-step drag mutates 78 `col[style]` attributes and touches no cell.
*/
/** A column can't be dragged narrower than this — below it the header label
* has no room and the seam becomes hard to grab back. */
const MIN_COLUMN_PX = 48
const equalWidths = (left: null | number[], right: null | number[]) =>
left === right || (!!left && !!right && left.length === right.length && left.every((v, i) => v === right[i]))
export function ResizableMarkdownTable({ children, className, ...props }: ComponentProps<'table'>) {
const tableRef = useRef<HTMLTableElement>(null)
const keyRef = useRef<null | string>(null)
// A drag owns the widths while it runs; the identity effect below must not
// overwrite them from storage between two pointermove frames.
const draggingRef = useRef(false)
const [widths, setWidths] = useState<null | number[]>(null)
// A markdown table has no identity of its own — it is re-parsed from text on
// every render. Its header row is the identity: the same table in the same
// message resolves to the same key after a re-render, a session switch, or a
// reload, and two tables only collide when they are, column for column, the
// same table.
useLayoutEffect(() => {
const cells = tableRef.current?.tHead?.rows[0]?.cells
if (!cells || cells.length < 2) {
keyRef.current = null
return
}
const key = markdownTableKey(Array.from(cells, cell => cell.textContent?.trim() ?? ''))
keyRef.current = key
if (draggingRef.current) {
return
}
const stored = readTableWidths(key, cells.length)
setWidths(current => (equalWidths(current, stored) ? current : stored))
}, [children])
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLTableElement>) => {
const handle = (event.target as HTMLElement | null)?.closest<HTMLElement>('[data-md-col-handle]')
const table = tableRef.current
if (!handle || !table || event.button !== 0) {
return
}
const cells = Array.from(table.tHead?.rows[0]?.cells ?? [])
const index = cells.indexOf(handle.closest('th') as HTMLTableCellElement)
const tableWidth = table.getBoundingClientRect().width
// The last column has no seam of its own, and a zero-width table (one in a
// collapsed pane) gives no denominator to work in.
if (index < 0 || index >= cells.length - 1 || tableWidth <= 0) {
return
}
event.preventDefault()
handle.setPointerCapture(event.pointerId)
handle.dataset.mdColActive = 'true'
draggingRef.current = true
// Seed from what is on screen, so the first drag continues the auto layout
// the user was looking at instead of snapping to even columns.
const start = cells.map(cell => (cell.getBoundingClientRect().width / tableWidth) * 100)
const pair = start[index] + start[index + 1]
const min = Math.min((MIN_COLUMN_PX / tableWidth) * 100, pair / 2)
const rtl = getComputedStyle(table).direction === 'rtl'
const startX = event.clientX
let next = start
const onMove = (move: PointerEvent) => {
const delta = ((rtl ? startX - move.clientX : move.clientX - startX) / tableWidth) * 100
const leading = Math.min(Math.max(start[index] + delta, min), pair - min)
next = start.map((value, at) => (at === index ? leading : at === index + 1 ? pair - leading : value))
setWidths(next)
}
const onUp = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
delete handle.dataset.mdColActive
draggingRef.current = false
if (keyRef.current && next !== start) {
writeTableWidths(keyRef.current, next)
}
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}, [])
// Double-click a seam to hand the columns back to auto layout — the same
// reset gesture the pane sashes use.
const onDoubleClick = useCallback((event: ReactMouseEvent<HTMLTableElement>) => {
if (!(event.target as HTMLElement | null)?.closest('[data-md-col-handle]')) {
return
}
if (keyRef.current) {
clearTableWidths(keyRef.current)
}
setWidths(null)
}, [])
return (
<div className="aui-md-table my-2 max-w-full overflow-x-auto rounded-[0.375rem] border border-(--ui-stroke-tertiary)">
<table
className={cn(
'm-0 w-full min-w-[18rem] border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-(--ui-stroke-tertiary) last:[&_tr]:border-0',
widths && 'table-fixed [&_td]:wrap-anywhere',
className
)}
onDoubleClick={onDoubleClick}
onPointerDown={onPointerDown}
ref={tableRef}
{...props}
>
{widths && (
<colgroup>
{widths.map((width, index) => (
<col key={index} style={{ width: `${width}%` }} />
))}
</colgroup>
)}
{children}
</table>
</div>
)
}
export function ResizableMarkdownTh({ children, className, ...props }: ComponentProps<'th'>) {
return (
<th
className={cn(
'relative px-2.5 py-1.5 text-left align-middle text-[0.75rem] font-medium text-muted-foreground',
// The trailing column has no seam: its right edge is the table's edge,
// and there is nothing on the far side to trade width with.
'[&:last-child_[data-md-col-handle]]:hidden',
className
)}
{...props}
>
{/* Truncation lives on an inner box, not the cell: the grab band straddles
the cell's edge, so a clipping `<th>` would cut half of it off. */}
<span className="block overflow-hidden text-ellipsis whitespace-nowrap">{children}</span>
{/* Invisible grab band straddling the seam, with the hairline revealed on
hover — the pane sash treatment (`tree-split.tsx`) scaled to a header
row. The table carries no vertical rules otherwise, so the line only
exists while you are reaching for it. */}
<span
aria-hidden
className="group/mdcol absolute inset-y-0 -end-1 z-10 w-2 cursor-col-resize select-none"
data-md-col-handle
>
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-(--ui-stroke-secondary) opacity-0 transition-opacity duration-100 group-hover/mdcol:opacity-100 [[data-md-col-active]_&]:opacity-100" />
</span>
</th>
)
}
@@ -0,0 +1,81 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { artifactsForSession, clearArtifactRegistry } from '@/store/artifacts'
import { $previewTabs } from '@/store/preview'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
import { MarkdownTextContent } from './markdown-text'
const HTML_DOC = `<!doctype html>
<html>
<head><title>Pomodoro Timer</title></head>
<body>
<h1>Pomodoro</h1>
<p>A tiny focus timer that counts down twenty-five minutes.</p>
<script>let seconds = 25 * 60; setInterval(() => { seconds -= 1 }, 1000)</script>
</body>
</html>`
const SMALL_SNIPPET = 'const x = 1'
function fenced(language: string, body: string): string {
return `Here you go:\n\n\`\`\`${language}\n${body}\n\`\`\`\n`
}
// End-to-end for the artifact path: a substantial ```html fence in assistant
// markdown must come out of preprocessMarkdown -> Streamdown -> SyntaxHighlighter
// as an artifact card (registered in the store), while small fences keep the
// plain code-card path.
describe('MarkdownTextContent artifacts', () => {
beforeEach(() => {
$activeSessionId.set('session-artifacts')
$selectedStoredSessionId.set(null)
window.localStorage.clear()
clearArtifactRegistry()
})
afterEach(() => {
cleanup()
$activeSessionId.set(null)
$selectedStoredSessionId.set(null)
clearArtifactRegistry()
window.localStorage.clear()
})
it('renders a substantial html fence as an artifact card and registers it', async () => {
render(<MarkdownTextContent isRunning={false} text={fenced('html', HTML_DOC)} />)
const card = await screen.findByText('Pomodoro Timer')
expect(card.closest('button')?.dataset.slot).toBe('aui_artifact-card')
expect(artifactsForSession('session-artifacts')).toHaveLength(1)
expect(artifactsForSession('session-artifacts')[0]?.kind).toBe('html')
// Registration alone must not open the rail (offer, don't hijack).
expect($previewTabs.get()).toHaveLength(0)
})
it('keeps a small fence as a plain code block', async () => {
const { container } = render(<MarkdownTextContent isRunning={false} text={fenced('js', SMALL_SNIPPET)} />)
// The code card mounts synchronously; Shiki may split tokens into spans,
// so assert on the card slots rather than text content.
expect(container.querySelector('[data-slot="code-card"]')).not.toBeNull()
expect(container.querySelector('[data-slot="aui_artifact-card"]')).toBeNull()
expect(artifactsForSession('session-artifacts')).toHaveLength(0)
})
it('renders a copy control on a fenced code block', async () => {
render(<MarkdownTextContent isRunning={false} text={fenced('js', SMALL_SNIPPET)} />)
expect(await screen.findByRole('button', { name: 'Copy code' })).toBeTruthy()
})
it('does not register while the message is still streaming', async () => {
render(<MarkdownTextContent isRunning text={fenced('html', HTML_DOC)} />)
await screen.findByText('Pomodoro Timer')
expect(artifactsForSession('session-artifacts')).toHaveLength(0)
})
})
@@ -0,0 +1,59 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { MarkdownTextContent } from './markdown-text'
// Regression for #82140: a plain filesystem href in assistant markdown
// (`[report](/home/user/report.md)`) rendered as a bare dead anchor —
// file:// is blocked in the renderer, and on a remote gateway the path
// isn't on this disk at all. Such links must route through the preview
// pipeline (PreviewAttachment → normalizeOrLocalPreviewTarget), which
// resolves the path at VIEW time against the session's backend: local
// connections read the file directly, remote connections fetch it over the
// authenticated /api/fs bridge. Media-extension paths keep their inline
// player instead.
describe('MarkdownLink filesystem hrefs', () => {
afterEach(cleanup)
it('routes an absolute file path link through the preview attachment', async () => {
render(<MarkdownTextContent isRunning={false} text="Wrote it: [report](/home/user/report.md)" />)
// PreviewAttachment paints the filename + an Open preview button —
// that's the view-time door, not a dead <a>.
await screen.findByText('report.md')
expect(screen.getByRole('button', { name: 'Open preview' })).toBeTruthy()
expect(document.querySelector('a[href="/home/user/report.md"]')).toBeNull()
})
it('routes file:// and ~/ links the same way', async () => {
render(
<MarkdownTextContent isRunning={false} text={'See [notes](file:///srv/data/notes.txt) and [todo](~/todo.md)'} />
)
await screen.findByText('notes.txt')
await screen.findByText('todo.md')
expect(screen.getAllByRole('button', { name: 'Open preview' })).toHaveLength(2)
})
it('renders a media player for a media-extension path link', async () => {
const { container } = render(<MarkdownTextContent isRunning={false} text="[clip](/tmp/demo.mp4)" />)
await waitFor(() => expect(container.querySelector('video')).not.toBeNull())
expect(container.querySelector('a[href="/tmp/demo.mp4"]')).toBeNull()
})
it('leaves anchors and relative links out of the preview pipeline', () => {
render(
<MarkdownTextContent
isRunning={false}
text={'[frag](#section-2) and [rel](docs/guide.md) and [site](https://example.com)'}
/>
)
// Fragment anchors survive untouched; relative links are NOT rewritten
// (they keep Streamdown's pre-existing handling) — neither gains a
// preview affordance.
expect(screen.queryByRole('button', { name: 'Open preview' })).toBeNull()
expect(document.querySelector('a[href="#section-2"]')).not.toBeNull()
})
})
@@ -0,0 +1,66 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { isMarkdownDocumentPath, mediaMarkdownHref } from '@/lib/media'
import { MarkdownTextContent } from './markdown-text'
// Regression for #84951: a `.md` delivered via MEDIA has no entry in
// MEDIA_BY_EXT, so it classified as a generic 'file' and rendered as a
// download-style link. Markdown is renderable content — it must route to the
// preview rail (which renders .md with a rendered/source toggle) instead.
describe('markdown documents delivered via MEDIA', () => {
afterEach(cleanup)
it('classifies markdown extensions as markdown documents', () => {
expect(isMarkdownDocumentPath('/tmp/report.md')).toBe(true)
expect(isMarkdownDocumentPath('/tmp/notes.markdown')).toBe(true)
expect(isMarkdownDocumentPath('C:\\Users\\a\\report.MD')).toBe(true)
expect(isMarkdownDocumentPath('/tmp/report.md?x=1')).toBe(true)
expect(isMarkdownDocumentPath('/tmp/archive.zip')).toBe(false)
expect(isMarkdownDocumentPath('/tmp/clip.mp4')).toBe(false)
expect(isMarkdownDocumentPath('/tmp/README')).toBe(false)
})
it('renders a MEDIA .md as a preview attachment, not a download link', async () => {
const href = mediaMarkdownHref('/home/user/out/report.md')
render(<MarkdownTextContent isRunning={false} text={`[report.md](${href})`} />)
// PreviewAttachment renders an "open preview" toggle button; the old
// MediaAttachment 'file' fallback rendered a bare "Open ..." anchor.
// Two buttons now: Download + Open preview (maintainer-requested).
const buttons = await screen.findAllByRole('button')
expect(buttons.length).toBe(2)
expect(screen.getByText('Download')).toBeTruthy()
expect(screen.queryByText(/^Loading /)).toBeNull()
expect(screen.getByText('report.md')).toBeTruthy()
})
it('renders a non-markdown MEDIA file as a preview attachment too', async () => {
// Extends #84951 to every non-media extension: PDFs, archives, data
// files. MediaAttachment's kind==='file' branch was a degraded dead-end
// (bare "Open ..." anchor, verified live with .pdf and .qzx7 — the
// markdown-LINK path already gave these a proper file card). MEDIA:
// must never render worse than a plain markdown link to the same file.
const href = mediaMarkdownHref('/home/user/out/archive.zip')
render(<MarkdownTextContent isRunning={false} text={`[archive.zip](${href})`} />)
const buttons = await screen.findAllByRole('button')
expect(buttons.length).toBe(2)
expect(screen.getByText('Download')).toBeTruthy()
expect(screen.getByText('archive.zip')).toBeTruthy()
expect(screen.queryByText(/^Open archive/)).toBeNull()
})
it('renders a MEDIA pdf as a preview attachment', async () => {
const href = mediaMarkdownHref('C:/Users/a/report.pdf')
render(<MarkdownTextContent isRunning={false} text={`[report.pdf](${href})`} />)
const buttons = await screen.findAllByRole('button')
expect(buttons.length).toBe(2)
expect(screen.getByText('report.pdf')).toBeTruthy()
})
})
@@ -0,0 +1,81 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import { MarkdownImage, MarkdownTextContent } from './markdown-text'
const REMOTE_IMAGE_PATH = '/home/user/project/images/remote-preview.png'
const REMOTE_IMAGE_DATA_URL = 'data:image/png;base64,cmVtb3RlLWltYWdl'
describe('MarkdownTextContent remote images', () => {
const api = vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('/api/fs/read-data-url?')) {
return { dataUrl: REMOTE_IMAGE_DATA_URL }
}
throw new Error(`unexpected path ${path}`)
})
let originalDesktop: typeof window.hermesDesktop
beforeEach(() => {
api.mockClear()
originalDesktop = window.hermesDesktop
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { api }
})
$connection.set({ mode: 'remote', profile: 'remote-work' } as never)
})
afterEach(() => {
cleanup()
$connection.set(null)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: originalDesktop
})
})
it('passes the gateway bridge data URL through Streamdown to the zoomable image', async () => {
render(<MarkdownTextContent isRunning={false} text={`![Remote preview](${REMOTE_IMAGE_PATH})`} />)
const image = await screen.findByRole('img', { name: 'Remote preview' })
expect(image.getAttribute('src')).toBe(REMOTE_IMAGE_DATA_URL)
expect(api).toHaveBeenCalledWith({
path: '/api/fs/read-data-url?path=%2Fhome%2Fuser%2Fproject%2Fimages%2Fremote-preview.png',
profile: 'remote-work'
})
})
})
// Regression for #40896: generated media often arrives as image markdown
// (`![clip](clip.mp4)`). A raw <img> with a video/audio source paints a
// broken-image icon even though the file is valid, so MarkdownImage must route
// video/audio sources to the proper <video>/<audio> element.
describe('MarkdownImage media routing', () => {
afterEach(cleanup)
it('renders a <video> (not a broken <img>) for a video source', async () => {
const { container } = render(<MarkdownImage alt="clip" src="file:///tmp/clip.mp4" />)
await waitFor(() => expect(container.querySelector('video')).not.toBeNull())
expect(container.querySelector('img')).toBeNull()
})
it('renders an <audio> element for an audio source', async () => {
const { container } = render(<MarkdownImage alt="note" src="file:///tmp/note.mp3" />)
await waitFor(() => expect(container.querySelector('audio')).not.toBeNull())
expect(container.querySelector('img')).toBeNull()
})
it('still renders an <img> for an image source', () => {
const { container } = render(<MarkdownImage alt="pic" src="file:///tmp/pic.png" />)
expect(container.querySelector('video')).toBeNull()
expect(container.querySelector('audio')).toBeNull()
})
})
@@ -0,0 +1,80 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MarkdownTextContent } from './markdown-text'
// Regression coverage for the "workspace failed to render / Maximum call stack
// size exceeded" crash loop (#78000, #85295).
//
// Two independent recursions in the markdown pipeline can overflow the JS call
// stack while React is rendering:
//
// 1. Raw inline HTML — `rehype-raw` hands `<unk><unk>…` to parse5, and
// `hast-util-from-parse5` recurses once per level of unclosed nesting. A
// degenerating model emitting thousands of `<unk>` tokens as reasoning is
// the reported trigger, and the payload persists to the session, so the
// crash repeats on every reload.
// 2. Deeply nested block structure — `> > > …` recurses in mdast→hast, which
// no HTML guard can prevent.
//
// The throw escaping to the workspace boundary is what blanks the whole app, so
// what matters is that every markdown surface stays up and keeps the text
// readable. These mount the real production component; a wrapper that silently
// stopped being applied has to fail here.
afterEach(cleanup)
// A stack overflow inside React's render logs through console.error; the test
// asserts on rendered output, not on the noise.
function renderQuietly(node: Parameters<typeof render>[0]) {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
return render(node)
} finally {
spy.mockRestore()
}
}
const DEGENERATE_UNK = `Let${'<unk>'.repeat(16_383)}`
const DEEP_BLOCKQUOTE = `${'> '.repeat(10_000)}still readable`
describe('markdown surface survives stack-overflow content', () => {
it.each([
['degenerate <unk> reasoning run', DEGENERATE_UNK],
['deeply nested blockquotes', DEEP_BLOCKQUOTE]
])('renders %s without throwing, and keeps it readable', (_label, text) => {
const { container } = renderQuietly(<MarkdownTextContent isRunning={false} text={text} />)
expect(container.textContent).toBeTruthy()
})
// The crash is a property of the CONTENT, not of which part carries it: the
// same text arrives as an answer, as reasoning, or in a tool result, and all
// three render through this component. Guarding only one of them is what let
// the bug survive an earlier fix attempt.
it.each([
['reasoning (disableArtifacts)', { disableArtifacts: true }],
['assistant answer', {}]
])('survives on the %s path', (_label, surfaceProps) => {
const { container } = renderQuietly(
<MarkdownTextContent isRunning={false} text={DEGENERATE_UNK} {...surfaceProps} />
)
expect(container.textContent).toBeTruthy()
})
it('still renders while the message is streaming', () => {
const { container } = renderQuietly(<MarkdownTextContent isRunning text={DEGENERATE_UNK} />)
expect(container.textContent).toBeTruthy()
})
it('leaves ordinary markdown on the rich path', async () => {
render(<MarkdownTextContent isRunning={false} text={'# Disk report\n\nC: is **full**'} />)
// A real heading element — the rich renderer ran, rather than degrading
// the whole message to plain text.
expect(await screen.findByRole('heading', { name: 'Disk report' })).toBeTruthy()
expect(screen.getByText('full')).toBeTruthy()
})
})
@@ -0,0 +1,66 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { __resetSessionLinkTitleCache } from '@/lib/session-link-title'
import { $sessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { MarkdownTextContent } from './markdown-text'
function makeSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
ended_at: null,
id: '20260101_abc123',
input_tokens: 0,
is_active: false,
last_active: 1_000,
message_count: 1,
model: null,
output_tokens: 0,
preview: null,
profile: 'work',
source: 'cli',
started_at: 1_000,
title: 'Branch plan',
tool_call_count: 0,
...overrides
}
}
afterEach(() => {
cleanup()
$sessions.set([])
__resetSessionLinkTitleCache()
})
// End-to-end for the agent-authored path: a bare ref in assistant markdown has
// to survive preprocessMarkdown -> Streamdown -> MarkdownLink and come out as
// an inline link titled after the session, not as literal text.
describe('MarkdownTextContent session refs', () => {
it('renders an agent-written @session ref as a link showing the session title', async () => {
$sessions.set([makeSession()])
render(<MarkdownTextContent isRunning={false} text="Context lives in @session:work/20260101_abc123 today." />)
const link = await screen.findByTitle('work/20260101_abc123')
expect(link.tagName).toBe('A')
expect(link.textContent).toBe('Branch plan')
expect(screen.queryByText(/@session:/)).toBeNull()
})
it('falls back to a short id when the session is unknown', async () => {
render(<MarkdownTextContent isRunning={false} text="See @session:work/20260101_abc123 for context." />)
const link = await screen.findByTitle('work/20260101_abc123')
expect(link.textContent).toBe('20260101…')
})
it('leaves a ref inside inline code as literal text', () => {
render(<MarkdownTextContent isRunning={false} text="Write `@session:work/20260101_abc123` to link a chat." />)
expect(screen.getByText('@session:work/20260101_abc123')).toBeTruthy()
expect(screen.queryByTitle('work/20260101_abc123')).toBeNull()
})
})
@@ -0,0 +1,393 @@
import { describe, expect, it } from 'vitest'
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
describe('preprocessMarkdown', () => {
it('strips inline accidental triple-backtick starts', () => {
const input = [
'Working as intended.',
"Here's your scene: ``` http://localhost:8812/",
'',
'- **Multicolored cube**',
'- **Rotates**'
].join('\n')
const output = preprocessMarkdown(input)
expect(output).not.toContain('```')
expect(output).toContain("Here's your scene:")
expect(output).not.toContain('http://localhost:8812/')
expect(output).toContain('- **Multicolored cube**')
})
it('demotes invalid fenced prose blocks with closers', () => {
const fence = '```'
const input = [
`${fence} http://localhost:8812/`,
'- **Scroll wheel** - zoom',
'- **Right-drag/pan** - disabled',
fence
].join('\n')
const output = preprocessMarkdown(input)
expect(output).not.toContain('```')
expect(output).not.toContain('http://localhost:8812/')
expect(output).toContain('- **Scroll wheel** - zoom')
})
it('drops fences around a preview-only URL block', () => {
const fence = '```'
const input = ['Server is back.', '', fence, 'http://localhost:8812/', fence].join('\n')
const output = preprocessMarkdown(input)
expect(output).toContain('Server is back.')
expect(output).not.toContain('```')
expect(output).not.toContain('http://localhost:8812/')
})
it('demotes prose sentence masquerading as fence info', () => {
const input = ['```Heads up - a bunny got added', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join(
'\n'
)
const output = preprocessMarkdown(input)
expect(output).not.toContain('```heads')
expect(output).toContain('Heads up - a bunny got added')
expect(output).toContain('- Pure white (`#ffffff`)')
})
it('keeps valid code fences intact', () => {
const fence = '```'
const input = [`${fence}ts`, 'const value = 1;', fence].join('\n')
const output = preprocessMarkdown(input)
expect(output).toContain('```ts')
expect(output).toContain('const value = 1;')
})
it('keeps dangling real code fences during streaming', () => {
const input = ['```ts', 'const value = 1;'].join('\n')
const output = preprocessMarkdown(input)
expect(output.startsWith('```ts')).toBe(true)
expect(output).toContain('const value = 1;')
})
it('demotes dangling prose fences', () => {
const input = ['```', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join('\n')
const output = preprocessMarkdown(input)
expect(output).not.toContain('```')
expect(output).toContain('- Pure white (`#ffffff`)')
})
it('autolinks raw urls in prose', () => {
const output = preprocessMarkdown(
'Book here:\nhttps://www.getyourguide.com/culebra-island-l145468/from-fajardo-tour-t19894/'
)
expect(output).toContain('<https://www.getyourguide.com/culebra-island-l145468/from-fajardo-tour-t19894/>')
})
it('strips orphan numeric citation markers outside code spans', () => {
const output = preprocessMarkdown('This is the source[0], but keep `items[0]` untouched.')
expect(output).toContain('source,')
expect(output).not.toContain('source[0]')
expect(output).toContain('`items[0]`')
})
it('demotes title/url blocks wrapped in malformed inline fences', () => {
const input = [
'**🚢 TOMORROW (Fajardo, crystal clear cays, pickup avail):**',
'',
'Icacos Full-Day Catamaran — 6hr, $140, small group, pickup```',
'https://www.getyourguide.com/fajardo-l882/from-fajardo-icacos-island-full-day-catamaran-trip-t19891/',
'```Sail Getaway Luxury Cat (Cordillera Cays, water slide, unlimited rum) — 6hr, $195```',
'https://www.getyourguide.com/fajardo-l882/icacos-all-inclusive-sailing-catamaran-beach-and-snorkel-t466138/'
].join('\n')
const output = preprocessMarkdown(input)
expect(output).not.toContain('```')
expect(output).toContain('Sail Getaway Luxury Cat')
expect(output).toContain(
'<https://www.getyourguide.com/fajardo-l882/from-fajardo-icacos-island-full-day-catamaran-trip-t19891/>'
)
expect(output).toContain(
'<https://www.getyourguide.com/fajardo-l882/icacos-all-inclusive-sailing-catamaran-beach-and-snorkel-t466138/>'
)
})
it('autolinks urls glued to prices and removes orphan fence tails', () => {
const input = [
'**🐢 TODAY (from San Juan, no driving):**',
'',
'Sea Turtles & Manatees Snorkel + Free Rum — 1.5hr,',
'~$56```https://www.getyourguide.com/san-juan-puerto-rico-l355/san-juan-snorkel-sea-turtles-manatees-free-video-rum-t879147/ Old San Juan Sunset Cruise w/ Drinks + Hotel Pickup — 1.5hr, ~$99 (drinks, no snorkel)```',
'https://www.getyourguide.com/en-gb/san-juan-puerto-rico-l355/san-juan-old-san-juan-sunset-cruise-with-drinks-transfer-t405191/'
].join('\n')
const output = preprocessMarkdown(input)
expect(output).not.toContain('```')
// Currency dollar amounts get escaped to `\$` in the preprocessor
// so they don't get parsed as math delimiters by remark-math (we
// enable singleDollarTextMath, which would otherwise greedy-match
// `$56...$99` as one big inline math span). The escape is invisible
// to the user — `\$` renders as a literal `$` in the final output.
expect(output).toContain(
'~\\$56<https://www.getyourguide.com/san-juan-puerto-rico-l355/san-juan-snorkel-sea-turtles-manatees-free-video-rum-t879147/> Old San Juan Sunset Cruise'
)
expect(output).toContain(
'<https://www.getyourguide.com/en-gb/san-juan-puerto-rico-l355/san-juan-old-san-juan-sunset-cruise-with-drinks-transfer-t405191/>'
)
})
it('demotes url-only fenced blocks to clickable markdown links', () => {
const input = [
'Sea Turtles & Manatees Snorkel + Free Rum — 1.5hr, ~$56',
'```',
'https://www.getyourguide.com/san-juan-puerto-rico-l355/san-juan-snorkel-sea-turtles-manatees-free-video-rum-t879147/',
'```',
'',
'Old San Juan Sunset Cruise w/ Drinks + Hotel Pickup — 1.5hr, ~$99',
'```',
'https://www.getyourguide.com/en-gb/san-juan-puerto-rico-l355/san-juan-old-san-juan-sunset-cruise-with-drinks-transfer-t405191/',
'```'
].join('\n')
const output = preprocessMarkdown(input)
expect(output).not.toContain('```')
expect(output).toContain(
'<https://www.getyourguide.com/san-juan-puerto-rico-l355/san-juan-snorkel-sea-turtles-manatees-free-video-rum-t879147/>'
)
expect(output).toContain(
'<https://www.getyourguide.com/en-gb/san-juan-puerto-rico-l355/san-juan-old-san-juan-sunset-cruise-with-drinks-transfer-t405191/>'
)
})
it('does not swallow trailing emphasis asterisks into an autolinked url', () => {
const input = '**PR opened: https://github.com/NousResearch/hermes-agent/pull/12345**'
const output = preprocessMarkdown(input)
// The URL is autolinked WITHOUT the trailing `**` glued into the href,
// and the bold emphasis run stays intact so it renders as bold + a link.
expect(output).toContain('<https://github.com/NousResearch/hermes-agent/pull/12345>')
expect(output).not.toContain('pull/12345**>')
expect(output).not.toContain('12345*')
})
it('stops an autolinked url at mid-string bold markers', () => {
const input = 'See https://github.com/foo/bar**bold** for details.'
const output = preprocessMarkdown(input)
expect(output).toContain('<https://github.com/foo/bar>')
expect(output).toContain('**bold**')
})
it('keeps underscores and tildes inside autolinked url paths', () => {
const input = 'Docs at https://example.com/a_b/c~d/page'
const output = preprocessMarkdown(input)
expect(output).toContain('<https://example.com/a_b/c~d/page>')
})
it('handles a fenced block larger than V8 spread-argument limit', () => {
// A single huge code block (e.g. a logged minified bundle) used to throw
// `RangeError: Maximum call stack size exceeded` via `out.push(...lines)`.
const body = Array.from({ length: 200_000 }, (_, i) => `line ${i}`).join('\n')
const input = `\`\`\`js\n${body}\n\`\`\``
expect(() => preprocessMarkdown(input)).not.toThrow()
})
it('keeps $$<digit>$$ display math intact instead of escaping it as currency', () => {
const output = preprocessMarkdown('$$5x = 10$$')
expect(output).toContain('$$5x = 10$$')
expect(output).not.toContain('\\$')
})
it('keeps numeric inline math intact instead of escaping it as currency', () => {
const input = ['- The observed outcome might be $4$', '- Because $4\\in A$, event $A$ occurred'].join('\n')
expect(preprocessMarkdown(input)).toBe(input)
})
it.each(['$4$', '$2/3$', '$5x=10$', '$4xy$', '$10kg$'])('preserves balanced numeric inline math: %s', input => {
expect(preprocessMarkdown(input)).toBe(input)
})
it('does not mistake a numeric formula closer for a later price opener', () => {
expect(preprocessMarkdown('Probability is $2/3$ and fee is $7.')).toBe('Probability is $2/3$ and fee is \\$7.')
expect(preprocessMarkdown('$4$ and $10')).toBe('$4$ and \\$10')
})
it('keeps escaping currency ranges instead of treating them as inline math', () => {
expect(preprocessMarkdown('$5-$10')).toBe('\\$5-\\$10')
expect(preprocessMarkdown('$5 and $x$')).toBe('\\$5 and $x$')
expect(preprocessMarkdown('Costs $5 + tax; formula is $x$.')).toBe('Costs \\$5 + tax; formula is $x$.')
expect(preprocessMarkdown('Costs $5 = base rate; formula is $x$.')).toBe('Costs \\$5 = base rate; formula is $x$.')
})
it.each([
['Costs $5; delta is $-x$.', 'Costs \\$5; delta is $-x$.'],
['Costs $5; result is $(x+1)$.', 'Costs \\$5; result is $(x+1)$.'],
['Costs $5; set is $[1,2]$.', 'Costs \\$5; set is $[1,2]$.']
])('escapes a price before a later complete math span: %s', (input, expected) => {
expect(preprocessMarkdown(input)).toBe(expected)
})
it('keeps the existing currency escaping semantics', () => {
expect(preprocessMarkdown('$1,299 total')).toBe('\\$1,299 total')
expect(preprocessMarkdown('already \\$5')).toBe('already \\$5')
expect(preprocessMarkdown('\\\\$5')).toBe('\\\\\\$5')
})
it('escapes a price while preserving numeric math later in the same sentence', () => {
const input = 'Costs $5; outcome is $4\\in A$.'
expect(preprocessMarkdown(input)).toBe('Costs \\$5; outcome is $4\\in A$.')
})
it('normalizes multiline bracket display math with delimiter-only lines', () => {
const input = [
'Correct.',
'',
'Both paths reach the same intersection:',
'',
'\\[',
'P(B)\\cdot P(A\\mid B)',
'=',
'P(A)\\cdot P(B\\mid A)',
'\\]',
'',
'Now isolate $P(A\\mid B)$.'
].join('\n')
const output = preprocessMarkdown(input)
expect(output).toContain('$$\nP(B)\\cdot P(A\\mid B)\n=\nP(A)\\cdot P(B\\mid A)\n$$')
expect(output).not.toContain('$$P(B)')
})
it('keeps display math inside its markdown container', () => {
const listInput = ['- \\[', ' P(A)', ' =', ' P(B)', ' \\]'].join('\n')
const listOutput = ['- $$', ' P(A)', ' =', ' P(B)', ' $$'].join('\n')
expect(preprocessMarkdown(listInput)).toBe(listOutput)
expect(preprocessMarkdown(['> \\[', '> P(A)', '> \\]'].join('\n'))).toBe(['> $$', '> P(A)', '> $$'].join('\n'))
})
it('rewrites double-backslash bracket math to dollar delimiters', () => {
const output = preprocessMarkdown('\\\\(x^2\\\\)')
expect(output).toContain('$x^2$')
})
it('rewrites [/math] and [/inline] tag pairs to dollar delimiters', () => {
expect(preprocessMarkdown('[/math]a+b[/math]')).toContain('$$a+b$$')
expect(preprocessMarkdown('[/inline]x[/inline]')).toContain('$x$')
})
it('escapes currency dollars in prose so they are not parsed as math', () => {
const output = preprocessMarkdown('$5 and $10')
expect(output).toContain('\\$5')
expect(output).toContain('\\$10')
})
it('moves hugging $$ delimiters of multiline display math onto their own lines', () => {
const input = [
'$$\\begin{aligned}',
'\\nabla \\cdot \\mathbf{E} &= \\frac{\\rho}{\\varepsilon_0} \\\\',
'\\nabla \\cdot \\mathbf{B} &= 0',
'\\end{aligned}$$'
].join('\n')
const output = preprocessMarkdown(input)
expect(output).toBe(
[
'$$',
'\\begin{aligned}',
'\\nabla \\cdot \\mathbf{E} &= \\frac{\\rho}{\\varepsilon_0} \\\\',
'\\nabla \\cdot \\mathbf{B} &= 0',
'\\end{aligned}',
'$$'
].join('\n')
)
})
it('keeps hugging display math inside its markdown container', () => {
const input = ['> $$\\begin{aligned}', '> a &= b', '> \\end{aligned}$$'].join('\n')
const output = preprocessMarkdown(input)
expect(output).toBe(['> $$', '> \\begin{aligned}', '> a &= b', '> \\end{aligned}', '> $$'].join('\n'))
})
it('splits the hugging $$ form that the bracket rewrite itself produces', () => {
const input = ['\\[\\begin{aligned}', 'a &= b', '\\end{aligned}\\]'].join('\n')
const output = preprocessMarkdown(input)
expect(output).toBe(['$$', '\\begin{aligned}', 'a &= b', '\\end{aligned}', '$$'].join('\n'))
})
it('keeps CRLF line endings consistent when splitting hugging delimiters', () => {
const input = '$$\\begin{aligned}\r\na &= b\r\n\\end{aligned}$$'
expect(preprocessMarkdown(input)).toBe('$$\r\n\\begin{aligned}\r\na &= b\r\n\\end{aligned}\r\n$$')
})
it('leaves single-line display math alone', () => {
expect(preprocessMarkdown('$$x^2 + y^2 = r^2$$')).toBe('$$x^2 + y^2 = r^2$$')
})
it('leaves a multiline $$ block that sits wholly inside one inline code span alone', () => {
const input = '`$$a\nb$$`'
expect(preprocessMarkdown(input)).toBe(input)
})
it('keeps a radical index inside inline math', () => {
expect(preprocessMarkdown('$\\sqrt[3]{8}$')).toBe('$\\sqrt[3]{8}$')
})
it('keeps a radical index inside display math', () => {
expect(preprocessMarkdown('$$\\sqrt[3]{8}$$')).toBe('$$\\sqrt[3]{8}$$')
})
it('keeps a radical index inside a multiline display block', () => {
const input = ['$$', '\\sqrt[3]{8} + \\sqrt[4]{16}', '$$'].join('\n')
expect(preprocessMarkdown(input)).toBe(input)
})
it('keeps a radical index in math that arrived as bracket delimiters', () => {
expect(preprocessMarkdown('\\(\\sqrt[3]{8}\\)')).toContain('$\\sqrt[3]{8}$')
})
it('still strips a citation marker in prose that also contains math', () => {
const output = preprocessMarkdown('Per the paper[2], $\\sqrt[3]{8}$ is 2.')
expect(output).toBe('Per the paper, $\\sqrt[3]{8}$ is 2.')
})
it('shields inline math whose body contains an escaped dollar', () => {
const output = preprocessMarkdown('$\\sqrt[3]{8} + \\$5$')
expect(output).toContain('\\sqrt[3]{8}')
})
})
@@ -0,0 +1,723 @@
'use client'
import { TextMessagePartProvider, useMessagePartText } from '@assistant-ui/react'
import {
type StreamdownTextComponents,
StreamdownTextPrimitive,
type SyntaxHighlighterProps,
tailBoundedRemend
} from '@assistant-ui/react-streamdown'
import type { code as streamdownCode } from '@streamdown/code'
import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react'
import { ExpandableBlock } from '@/components/chat/expandable-block'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { chunkByLines, SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { ErrorBoundary } from '@/components/error-boundary'
import { detectArtifact } from '@/lib/artifact-detect'
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
import { createMemoizedMathPlugin } from '@/lib/katex-memo'
import { parseMarkdownIntoBlocksCached } from '@/lib/markdown-blocks'
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
import {
downloadGatewayMediaFile,
isFileMediaPath,
isInlineMediaSrc,
isMarkdownDocumentPath,
isRemoteGateway,
mediaExternalUrl,
mediaKind,
mediaName,
mediaPathFromMarkdownHref,
resolveMediaDisplaySrc,
resolveMediaPlaybackSrc
} from '@/lib/media'
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
import { sessionRefFromMarkdownHref } from '@/lib/session-refs'
import { cn } from '@/lib/utils'
import { ArtifactCard } from './artifact-card'
import { SessionRefLink } from './directive-text'
import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds'
import { ResizableMarkdownTable, ResizableMarkdownTh } from './markdown-table'
import { paragraphPlainText, TranscriptDirectiveLeaf, useIsClaimedDirective } from './transcript-directive'
// Math rendering plugin (KaTeX). Configured once at module scope — the
// plugin is stateless beyond its internal cache so re-creating per-render
// would needlessly thrash. We use a memoizing wrapper around rehype-katex
// (see lib/katex-memo.ts) so that during streaming we re-katex only the
// equations whose source actually changed since the last token. With the
// stock @streamdown/math plugin every equation re-renders on every token,
// which throttles UI updates badly for math-heavy responses; the memoized
// plugin keeps the steady-state work proportional to "new equations
// arriving" rather than "equations × tokens-per-second".
//
// `singleDollarTextMath: true` enables `$x^2$` for inline math (de-facto
// LLM convention). The default false-setting only accepts `$$...$$`.
const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
// `@streamdown/code` statically imports ALL of shiki (every grammar + theme —
// the single largest chunk in the renderer), so it must never sit on the
// entry graph. Load it on first markdown mount and swap it into the plugin
// table when it lands; until then fenced code renders through the
// `SyntaxHighlighter` override's plain path (same output Shiki's own
// `delay` fallback shows), so nothing flashes or reflows unexpectedly.
type CodePlugin = typeof streamdownCode
let codePluginCache: CodePlugin | null = null
function useCodePlugin(): CodePlugin | null {
const [plugin, setPlugin] = useState(codePluginCache)
useEffect(() => {
if (plugin) {
return
}
let cancelled = false
void import('@streamdown/code').then(({ code }) => {
codePluginCache = code
if (!cancelled) {
setPlugin(code)
}
})
return () => {
cancelled = true
}
}, [plugin])
return plugin
}
// Replaces Streamdown's `parseIncompleteMarkdown` (full-text remend per
// flush) with a tail-bounded repair. Must stay module-scope so the prop
// identity is stable across renders.
function preprocessWithTailRepair(text: string): string {
try {
return tailBoundedRemend(preprocessMarkdown(text))
} catch {
return text
}
}
function useOpenMediaFile(path: string) {
const [openFailed, setOpenFailed] = useState(false)
const open = () => {
if (window.hermesDesktop && isRemoteGateway()) {
setOpenFailed(false)
void downloadGatewayMediaFile(path).catch(() => setOpenFailed(true))
} else {
openExternalLink(mediaExternalUrl(path))
}
}
return { open, openFailed }
}
function OpenMediaFailedNote({ name }: { name: string }) {
return (
<span className="mt-1 block text-xs text-muted-foreground">
Couldn&apos;t fetch {name} from the gateway (missing, unreadable, or too large).
</span>
)
}
function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) {
const { open, openFailed } = useOpenMediaFile(path)
return (
<span className="block">
<button
className="mt-2 ref text-xs font-medium text-muted-foreground hover:text-foreground"
onClick={open}
type="button"
>
Open {kind} file
</button>
{openFailed && <OpenMediaFailedNote name={mediaName(path)} />}
</span>
)
}
function MediaAttachment({ path }: { path: string }) {
const [src, setSrc] = useState('')
const [failed, setFailed] = useState(false)
const { open, openFailed } = useOpenMediaFile(path)
const kind = mediaKind(path)
const name = mediaName(path)
useEffect(() => {
let cancelled = false
let objectUrl = ''
setFailed(false)
setSrc('')
if (kind === 'file') {
setFailed(true)
return () => {
cancelled = true
}
}
void resolveMediaPlaybackSrc(path)
.then(value => {
if (value.startsWith('blob:')) {
objectUrl = value
}
if (!cancelled) {
setSrc(value)
} else if (objectUrl) {
URL.revokeObjectURL(objectUrl)
}
})
.catch(() => {
if (!cancelled) {
setFailed(true)
}
})
return () => {
cancelled = true
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
}
}
}, [kind, path])
if (kind === 'image' && src) {
return (
<span className="block">
<MarkdownImage alt={name} src={src} />
</span>
)
}
if (kind === 'audio' && src) {
return (
<span className="my-3 block max-w-md rounded-xl border border-(--ui-stroke-tertiary) bg-muted/35 p-3">
<span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span>
<audio className="block w-full" controls onError={() => setFailed(true)} preload="metadata" src={src} />
{failed && <OpenMediaButton kind="audio" path={path} />}
</span>
)
}
if (kind === 'video' && src) {
return (
<span className="my-3 block max-w-2xl rounded-xl border border-(--ui-stroke-tertiary) bg-muted/35 p-3">
<span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span>
<video
className="block max-h-112 w-full rounded-lg bg-black"
controls
onError={() => setFailed(true)}
src={src}
/>
{failed && <OpenMediaButton kind="video" path={path} />}
</span>
)
}
return (
<span className="wrap-anywhere">
<a
className="ref wrap-anywhere"
href="#"
onClick={event => {
event.preventDefault()
open()
}}
>
{failed ? `Open ${name}` : `Loading ${name}...`}
</a>
{openFailed && <OpenMediaFailedNote name={name} />}
</span>
)
}
function childrenToText(children: unknown): string {
if (typeof children === 'string' || typeof children === 'number') {
return String(children).trim()
}
if (Array.isArray(children) && children.every(c => typeof c === 'string' || typeof c === 'number')) {
return children.join('').trim()
}
return ''
}
function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a'>) {
const mediaPath = mediaPathFromMarkdownHref(href)
if (mediaPath) {
// A delivered markdown document is renderable content, not an opaque
// download: route it to the preview rail (which renders .md with a
// rendered/source toggle) instead of the download-link fallback that
// `mediaKind() === 'file'` would produce. (#84951)
if (isMarkdownDocumentPath(mediaPath)) {
return <PreviewAttachment source="tool-result" target={mediaPath} />
}
// Non-media files (PDFs, data files, anything outside MEDIA_BY_EXT):
// MediaAttachment's kind==='file' branch is a degraded dead-end (bare
// "Open <name>" anchor). Route through the preview pipeline instead —
// the same file card + "Open preview" the bare-path markdown-link
// branch below produces — so MEDIA: uniformly delivers the richest
// rendering for every file type.
if (mediaKind(mediaPath) === 'file') {
return <PreviewAttachment source="tool-result" target={mediaPath} />
}
return <MediaAttachment path={mediaPath} />
}
const previewTarget = previewTargetFromMarkdownHref(href)
if (previewTarget) {
return <PreviewAttachment source="explicit-link" target={previewTarget} />
}
const sessionRef = sessionRefFromMarkdownHref(href)
if (sessionRef) {
return <SessionRefLink value={sessionRef} />
}
const target = href ? normalizeExternalUrl(href) : href
if (!target || !/^https?:\/\//i.test(target)) {
// A plain filesystem href (`[report](/home/user/report.md)`, `file://…`,
// `~/notes.md`, `C:\…`) names a file on the AGENT's machine. A bare
// anchor is a dead link there — file:// is blocked in the renderer, and
// on a remote gateway the path isn't even on this disk. Route it through
// the preview pipeline instead: normalizeOrLocalPreviewTarget resolves at
// VIEW time against the session's backend (local reads the file directly;
// remote fetches it over the authenticated /api/fs bridge), so the same
// transcript works from every machine that opens it. Media extensions
// keep their richer inline player.
const fileHref = href && !href.startsWith('#') && isFileMediaPath(href) ? href : null
if (fileHref) {
return mediaKind(fileHref) === 'file' ? (
<PreviewAttachment source="explicit-link" target={fileHref} />
) : (
<MediaAttachment path={fileHref} />
)
}
return (
<a
className={cn('ref wrap-anywhere', className)}
href={href}
rel="noopener noreferrer"
target="_blank"
{...props}
>
{children}
</a>
)
}
const text = childrenToText(children)
// Bare autolink → inline rich embed when a provider matches. Labeled links
// (`[watch](url)`) stay plain. Desktop only (webview / iframe renderers).
if (window.hermesDesktop && text && normalizeExternalUrl(text) === target) {
const embed = detectEmbed(target)
if (embed) {
return <UrlEmbed descriptor={embed} />
}
}
const fallbackLabel = text && normalizeExternalUrl(text) !== target ? text : undefined
return (
<PrettyLink className={cn('wrap-anywhere', className)} fallbackLabel={fallbackLabel} href={target} {...props} />
)
}
// Generated/inline media often arrives as image markdown — `![clip](clip.mp4)`.
// A raw <img> with a video/audio source renders a broken-image icon (the file is
// valid, the browser just can't paint it as an image), so route those sources to
// MediaAttachment, which picks the right <video>/<audio> element (streaming
// protocol + open-externally fallback) by media kind. Detection is
// extension-based via mediaKind(); an extension-less/data/blob video URL still
// resolves to 'file' and falls through to the image path as before.
//
// This is split from the image path because that path is built on hooks: a
// conditional return inside it would have to sit after every hook call, which
// would still fire an image resolve for media we never render as an image.
export function MarkdownImage(props: ComponentProps<'img'>) {
const rawSrc = typeof props.src === 'string' ? props.src : ''
const kind = rawSrc ? mediaKind(rawSrc) : 'file'
if (kind === 'video' || kind === 'audio') {
return <MediaAttachment path={rawSrc} />
}
return <MarkdownImageContent {...props} />
}
function MarkdownImageContent({ className, src, alt, ...props }: ComponentProps<'img'>) {
const rawSrc = typeof src === 'string' ? src : ''
const [resolvedSrc, setResolvedSrc] = useState(() => (rawSrc && isInlineMediaSrc(rawSrc) ? rawSrc : ''))
const [failed, setFailed] = useState(false)
const { open, openFailed } = useOpenMediaFile(rawSrc)
const name = mediaName(rawSrc || String(alt || 'image'))
useEffect(() => {
let cancelled = false
setFailed(false)
setResolvedSrc(rawSrc && isInlineMediaSrc(rawSrc) ? rawSrc : '')
if (!rawSrc || isInlineMediaSrc(rawSrc)) {
return () => {
cancelled = true
}
}
void resolveMediaDisplaySrc(rawSrc)
.then(value => {
if (!cancelled) {
setResolvedSrc(value)
}
})
.catch(() => {
if (!cancelled) {
setFailed(true)
}
})
return () => {
cancelled = true
}
}, [rawSrc])
if (!rawSrc) {
return null
}
if (failed) {
return (
<span className="my-2 block text-sm text-muted-foreground">
Couldn&apos;t load {name}.{' '}
<button className="ref font-medium text-foreground" onClick={open} type="button">
Open image
</button>
{openFailed && <OpenMediaFailedNote name={name} />}
</span>
)
}
if (!resolvedSrc) {
return <span className="my-2 block text-sm text-muted-foreground">Loading {name}...</span>
}
// The width cap belongs on the container, not the <img>: a percentage
// max-width resolves to none while the container measures its fit-content
// width, so the box overshoots the rendered image and strands the download
// button — which anchors to the container — out in the margin.
return (
<ZoomableImage
alt={alt}
className={cn(
'm-0 block h-auto w-auto max-h-(--image-preview-height) max-w-full rounded-lg object-contain shadow-[0_0.0625rem_0.125rem_color-mix(in_srgb,#000_4%,transparent),0_0.625rem_1.5rem_color-mix(in_srgb,#000_5%,transparent)]',
className
)}
containerClassName="my-2 block w-fit max-w-[min(100%,var(--image-preview-max-width))]"
slot="aui_markdown-image"
src={resolvedSrc}
{...props}
/>
)
}
interface MarkdownTextSurfaceProps {
containerClassName?: string
containerProps?: ComponentProps<'div'>
defer?: boolean
/** Disable artifact-card promotion for fenced blocks (reasoning text — a
* model's scratchpad draft must not register artifact versions). */
disableArtifacts?: boolean
}
// Headings shrink to chat scale rather than the prose default (h1≈xl). Kept
// table-driven so adding/tweaking levels is one row.
const HEADING_SIZES: Record<'h1' | 'h2' | 'h3' | 'h4', string> = {
h1: 'text-[1rem] tracking-tight',
h2: 'text-[0.9375rem] tracking-tight',
h3: 'text-[0.875rem]',
h4: 'text-[0.8125rem]'
}
const MARKDOWN_CONTAINER_CLASS_NAME = cn(
'aui-md prose w-full max-w-none overflow-hidden text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground',
'prose-p:leading-(--dt-line-height) prose-li:leading-(--dt-line-height)',
'prose-headings:text-foreground prose-strong:text-foreground',
// Typography styles `pre` as a dark slab: light text (`--tw-prose-pre-code`,
// gray-200) on a dark bg. We strip its bg for our own light code card but its
// near-white foreground survives — invisible under Shiki's opaque token
// spans, but it's what un-highlighted text inherits (streaming delay,
// Suspense fallback, budget-exceeded blocks): unreadable in light mode.
'prose-pre:text-foreground',
'prose-a:break-words prose-p:[overflow-wrap:anywhere]',
'prose-li:marker:text-muted-foreground/70',
'prose-code:rounded-[0.25rem] prose-code:px-[0.1875rem] prose-code:py-px prose-code:font-mono prose-code:text-[0.9em] prose-code:font-normal prose-code:before:content-none prose-code:after:content-none',
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-(--paragraph-gap)'
)
const MAX_MARKDOWN_CHARS = 200_000
function HugeTextFallback({ containerClassName, text }: { containerClassName?: string; text: string }) {
const chunks = useMemo(() => chunkByLines(text, 200), [text])
return (
<div
className={cn(
'aui-md w-full max-w-none overflow-hidden rounded-[0.625rem] border border-(--ui-stroke-tertiary) font-mono text-[0.7rem] leading-relaxed text-foreground/90',
containerClassName
)}
>
<ExpandableBlock className="p-2">
{chunks.map((chunk, index) => (
<div
className="[content-visibility:auto]"
key={index}
style={{ containIntrinsicSize: `auto ${chunk.lines * 16}px` }}
>
{chunk.text}
</div>
))}
</ExpandableBlock>
</div>
)
}
/**
* Paragraph override. Almost always a plain `<p>` — but a paragraph that is
* exactly one `::name{...}` directive claimed by a plugin renders as that
* plugin's transcript component instead (`transcript.directives` area). The
* claim check subscribes to the registry, so hot-loading a plugin upgrades
* already-rendered directives in place; unclaimed directives stay prose.
*/
function MarkdownParagraph({
children,
className,
streaming,
...props
}: ComponentProps<'p'> & { streaming?: boolean }) {
const plain = paragraphPlainText(children)
const claimed = useIsClaimedDirective(plain)
if (claimed && plain !== null) {
return <TranscriptDirectiveLeaf streaming={streaming} text={plain} />
}
return (
// Vertical rhythm is owned by styles.css (`--paragraph-gap`), which
// must out-specify Tailwind Typography's `prose` margins — so no
// `my-*` here on purpose.
<p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props}>
{children}
</p>
)
}
function MarkdownTextSurface({
containerClassName,
containerProps,
defer,
disableArtifacts
}: MarkdownTextSurfaceProps) {
const { status, text } = useMessagePartText()
const isStreaming = status.type === 'running'
// Keep code parsing enabled while streaming so incomplete fenced blocks still
// render as code cards. The expensive Shiki pass is deferred by
// `SyntaxHighlighter` below when `isStreaming` is true, and the code plugin
// itself arrives async (useCodePlugin) so shiki never blocks cold start.
const code = useCodePlugin()
const plugins = useMemo(() => (code ? { math: mathPlugin, code } : { math: mathPlugin }), [code])
const components = useMemo(
() =>
({
h1: ({ className, ...props }: ComponentProps<'h1'>) => (
<h1 className={cn('my-1 font-semibold', HEADING_SIZES.h1, className)} {...props} />
),
h2: ({ className, ...props }: ComponentProps<'h2'>) => (
<h2 className={cn('my-1 font-semibold', HEADING_SIZES.h2, className)} {...props} />
),
h3: ({ className, ...props }: ComponentProps<'h3'>) => (
<h3 className={cn('my-1 font-semibold', HEADING_SIZES.h3, className)} {...props} />
),
h4: ({ className, ...props }: ComponentProps<'h4'>) => (
<h4 className={cn('my-1 font-semibold', HEADING_SIZES.h4, className)} {...props} />
),
p: (props: ComponentProps<'p'>) => <MarkdownParagraph {...props} streaming={isStreaming} />,
a: MarkdownLink,
// Inline code must not vote when an ancestor resolves `dir="auto"`
// (HTML's algorithm skips descendants that carry their own dir),
// mirroring the CSS isolate that already keeps it out of the
// plaintext scan. Fenced code never reaches this override; it goes
// through the code plugin's CodeCard path.
inlineCode: ({ className, ...props }: ComponentProps<'code'>) => (
<code className={className} dir="ltr" {...props} />
),
// `---` as quiet spacing, not a heavy full-width rule.
hr: (_props: ComponentProps<'hr'>) => <div aria-hidden className="my-3" />,
// Lists and blockquotes have chrome that sits *beside* the text
// (markers, the quote border), and that side is driven by the CSS
// `direction` of the box, which `unicode-bidi: plaintext` never
// touches — an RTL list otherwise renders its numbers stranded at
// the far left. `dir="auto"` lets the browser resolve the box
// direction from content; the plaintext rules in styles.css keep
// owning per-line text direction. Inline code carries `dir="ltr"`
// (see the `code` override) so it doesn't vote here either, same
// contract as the CSS isolate.
// A `> [!NOTE]`/`[!WARNING]`/... blockquote renders as a GFM alert
// callout; everything else stays a plain quote.
blockquote: ({ children, className, ...props }: ComponentProps<'blockquote'>) => {
const alert = extractAlert(children)
if (alert) {
return <MarkdownAlert type={alert.type}>{alert.body}</MarkdownAlert>
}
return (
<blockquote
className={cn('border-s-2 border-(--ui-stroke-tertiary) ps-3 text-muted-foreground italic', className)}
dir="auto"
{...props}
>
{children}
</blockquote>
)
},
ul: ({ className, ...props }: ComponentProps<'ul'>) => (
<ul className={cn('my-1 gap-0', className)} dir="auto" {...props} />
),
ol: ({ className, ...props }: ComponentProps<'ol'>) => (
<ol className={cn('my-1 gap-0', className)} dir="auto" {...props} />
),
li: ({ className, ...props }: ComponentProps<'li'>) => (
<li className={cn('leading-(--dt-line-height)', className)} {...props} />
),
// Columns are drag-resizable; the widths live outside the transcript
// (see markdown-table-widths.ts) so a new turn or a session switch
// doesn't undo a resize.
table: ResizableMarkdownTable,
thead: ({ className, ...props }: ComponentProps<'thead'>) => (
<thead className={cn('m-0 bg-muted/35 text-muted-foreground', className)} {...props} />
),
th: ResizableMarkdownTh,
td: ({ className, ...props }: ComponentProps<'td'>) => (
<td className={cn('px-2.5 py-1.5 align-top text-[0.8125rem] leading-snug', className)} {...props} />
),
img: MarkdownImage,
// ```mermaid / ```svg fences route to their lazy renderers; substantial
// html/svg/code fences promote to an artifact card that opens in the
// right rail; every other language falls back to the Shiki-highlighted
// code block.
SyntaxHighlighter: (props: SyntaxHighlighterProps) => {
const artifact = disableArtifacts ? null : detectArtifact(props.language, props.code)
if (artifact) {
return <ArtifactCard code={props.code} detection={artifact} streaming={isStreaming} />
}
return (
<RichCodeBlock
code={props.code}
fallback={<SyntaxHighlighter {...props} defer={isStreaming} />}
language={props.language}
streaming={isStreaming}
/>
)
}
}) as StreamdownTextComponents,
[disableArtifacts, isStreaming]
)
if (text.length > MAX_MARKDOWN_CHARS) {
return <HugeTextFallback containerClassName={containerClassName} text={text} />
}
return (
// Last line of defence for the whole markdown surface — assistant answers,
// reasoning, tool output and user bubbles all render through here.
//
// The pipeline is recursive in several places we don't own (parse5 →
// `hast-util-from-parse5` on raw HTML, `mdast-util-to-hast` on nested
// block structure), so pathological content can still throw
// `RangeError: Maximum call stack size exceeded` from inside Streamdown's
// render. `clampHtmlNestingDepth` removes the reachable cause we found;
// this catches whatever we haven't. Without it the throw unwinds past
// MessageRenderBoundary — which deliberately re-throws anything that isn't
// the transient assistant-ui lookup race — and blanks the entire workspace
// behind "workspace failed to render", on every reload, because the
// offending message is replayed from the session each time.
//
// Degrading to HugeTextFallback keeps the text readable and the rest of
// the transcript alive. The error stays latched for this surface: content
// that overflowed the stack will overflow again, and remounting per token
// during streaming would cost far more than the plain rendering saves.
<ErrorBoundary
fallback={() => <HugeTextFallback containerClassName={containerClassName} text={text} />}
label="markdown-render"
>
<StreamdownTextPrimitive
components={components}
containerClassName={cn(MARKDOWN_CONTAINER_CLASS_NAME, containerClassName)}
containerProps={containerProps}
defer={defer}
lineNumbers={false}
mode="streaming"
// Incomplete-markdown repair runs in preprocessWithTailRepair on the
// full accumulated text; the built-in tail-bounded remend is disabled
// because a custom parseMarkdownIntoBlocksFn is supplied, and
// parseIncompleteMarkdown stays false to avoid a second full-text
// remend pass.
parseIncompleteMarkdown={false}
parseMarkdownIntoBlocksFn={parseMarkdownIntoBlocksCached}
plugins={plugins}
preprocess={preprocessWithTailRepair}
/>
</ErrorBoundary>
)
}
interface MarkdownTextContentProps extends MarkdownTextSurfaceProps {
isRunning: boolean
text: string
}
export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: MarkdownTextContentProps) {
// No `smooth` on purpose — same as the assistant answer. `TextMessagePartProvider`
// mints a fresh part object on every `text` change, and useSmooth resets its
// reveal to empty whenever the part identity changes, so a smoothed reasoning
// stream re-types from the first character on every delta (the flash). Token-
// streaming reasoners (R1/Qwen/GLM/Claude thinking) hit it hardest; GPT-5's
// coarse summary updates too rarely to notice. Plain append matches the answer.
return (
<TextMessagePartProvider isRunning={isRunning} text={text}>
<MarkdownTextSurface defer {...surfaceProps} />
</TextMessagePartProvider>
)
}
const MarkdownTextImpl = () => {
return <MarkdownTextSurface defer />
}
export const MarkdownText = memo(MarkdownTextImpl)
@@ -0,0 +1,522 @@
'use client'
import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { ToolFallback } from '@/components/assistant-ui/tool/fallback'
import { WIDGET_SHELL_CLASS } from '@/components/chat/widget-shell'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
import {
addMcpServer,
authMcpServer,
cancelMcpOAuthFlow,
getActionStatus,
getMcpCatalog,
getMcpOAuthFlow,
installMcpCatalogEntry,
type McpCatalogEntry,
removeMcpServer,
setMcpServerEnabled
} from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AlertCircle, CheckCircle2, Loader2 } from '@/lib/icons'
import { brandFor, brandGlyphStyle } from '@/lib/mcp-brands'
import { completeMcpDesktopOAuth, McpOAuthCancelled } from '@/lib/mcp-dashboard-oauth'
import { directoryEntry } from '@/lib/mcp-directory'
import { prettyName } from '@/lib/text'
import { cn } from '@/lib/utils'
import { $gateway } from '@/store/gateway'
import { clearMcpSetupRequest, type McpSetupOutcome, sessionMcpSetupRequest } from '@/store/mcp-setup'
import { notifyError } from '@/store/notifications'
import { invalidateMcpSuggestionIndex } from '@/store/suggestion-providers/mcp'
import { selectMessageRunning } from './tool/fallback-model'
import { parseMaybeObject } from './tool/fallback-model/format'
type SetupAction = 'authorize' | 'enable' | 'install'
interface SetupArgs {
server: string
action: SetupAction
reason: string
}
const CATALOG_INSTALL_POLL_MS = 1500
// Thrown by the in-flight flow when the user cancels — the declined respond
// has already been sent, so the catch path must swallow this, not report it.
const CANCELLED = Symbol('mcp-setup-cancelled')
function readSetupArgs(args: unknown): SetupArgs {
const row = parseMaybeObject(args)
const rawAction = typeof row.action === 'string' ? row.action : 'install'
return {
action: rawAction === 'enable' || rawAction === 'authorize' ? rawAction : 'install',
reason: typeof row.reason === 'string' ? row.reason : '',
server: typeof row.server === 'string' ? row.server : ''
}
}
/** The tool's settled JSON — the card's outcome plus the tool-only
* `unanswered` status (timeout, no user action). */
type SettledResult = Omit<Partial<McpSetupOutcome>, 'status'> & {
status?: McpSetupOutcome['status'] | 'unanswered'
note?: string
}
function readSetupResult(result: unknown): SettledResult {
return parseMaybeObject(result) as SettledResult
}
const SHELL_CLASS = `${WIDGET_SHELL_CLASS} text-[length:var(--conversation-text-font-size)] text-(--ui-text-primary)`
// Same platform sniff the approval bar uses for its accelerator hint.
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
const ICON_CLASS = 'mt-px size-4 shrink-0 text-(--ui-text-tertiary)'
function SetupLine({ children, trailing }: { children: ReactNode; trailing?: ReactNode }) {
return (
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">{children}</div>
{trailing}
</div>
)
}
export const McpSetupTool = (props: ToolCallMessagePartProps) => {
// Settled → static outcome line (the flow already ran or was declined).
if (props.result !== undefined) {
return <McpSetupSettled {...props} />
}
return <McpSetupLive {...props} />
}
const McpSetupLive = (props: ToolCallMessagePartProps) => {
const messageRunning = useAuiState(selectMessageRunning)
// Stopped mid-prompt with no result — don't leave a dead interactive panel.
if (!messageRunning) {
return <ToolFallback {...props} />
}
return <McpSetupPending {...props} />
}
function McpSetupSettled({ args, result }: ToolCallMessagePartProps) {
const { t } = useI18n()
const copy = t.assistant.mcpSetup
const fromArgs = useMemo(() => readSetupArgs(args), [args])
const fromResult = useMemo(() => readSetupResult(result), [result])
const server = fromResult.server || fromArgs.server
const status = fromResult.status ?? 'error'
const displayName = prettyName(server)
const line =
status === 'installed'
? copy.installed(displayName)
: status === 'enabled'
? copy.enabled(displayName)
: status === 'authorized'
? copy.authorized(displayName)
: status === 'declined'
? copy.declined
: status === 'unanswered'
? copy.unanswered
: copy.failed(displayName)
const ok = status === 'installed' || status === 'enabled' || status === 'authorized'
const neutral = status === 'declined' || status === 'unanswered'
const toolCount = Array.isArray(fromResult.tools) ? fromResult.tools.length : 0
const brand = brandFor(server)
return (
<div className={cn(SHELL_CLASS, 'my-1.5 grid gap-1.5')} data-slot="mcp-setup-inline">
<SetupLine
trailing={
ok ? (
<CheckCircle2 aria-hidden className={cn(ICON_CLASS, 'text-emerald-400')} />
) : neutral && brand ? (
<brand.Icon aria-hidden className="mt-px size-4 shrink-0 opacity-60" style={brandGlyphStyle(brand)} />
) : neutral ? (
<Codicon className={ICON_CLASS} name="plug" size="1rem" />
) : (
<AlertCircle aria-hidden className={cn(ICON_CLASS, 'text-destructive')} />
)
}
>
<span className={cn('font-medium', neutral && 'italic text-(--ui-text-tertiary)')}>{line}</span>
{ok && toolCount > 0 && <span className="ml-2 text-(--ui-text-tertiary)">{copy.toolCount(toolCount)}</span>}
{!ok && !neutral && fromResult.detail ? (
<p className="mt-0.5 text-(--ui-text-secondary)">{fromResult.detail}</p>
) : null}
</SetupLine>
</div>
)
}
function McpSetupPending({ args }: ToolCallMessagePartProps) {
const { t } = useI18n()
const copy = t.assistant.mcpSetup
// The tool row is in whichever session's transcript rendered it — read THAT
// session's request (primary or tile), not the globally-active one.
const sessionId = useStore(useSessionView().$runtimeId)
const $request = useMemo(() => sessionMcpSetupRequest(sessionId), [sessionId])
const request = useStore($request)
const gateway = useStore($gateway)
const fromArgs = useMemo(() => readSetupArgs(args), [args])
const server = fromArgs.server || request?.server || ''
const action: SetupAction = fromArgs.action ?? request?.action ?? 'install'
const reason = fromArgs.reason || request?.reason || ''
const [working, setWorking] = useState(false)
const [envDraft, setEnvDraft] = useState<Record<string, string>>({})
const [entry, setEntry] = useState<McpCatalogEntry | null | undefined>(undefined)
const [envOpen, setEnvOpen] = useState(false)
// Set when the user cancels mid-flight (a stuck OAuth tab, a hung install).
// The in-flight flow checks it at every poll boundary and aborts via the
// CANCELLED sentinel; the declined respond has already been sent by then.
const cancelRef = useRef(false)
// Race: tool.start fires a tick before mcp.setup.request — hold the buttons
// until the gateway request is wired (same spinner rule as clarify).
const ready = Boolean(request?.requestId)
const respond = useCallback(
async (outcome: McpSetupOutcome) => {
// Another path (cancel racing completion) may have already resolved this
// request; the store is the single source of truth, so bail if this
// session's entry is gone — same guard as the approval bar.
if (!request || sessionMcpSetupRequest(request.sessionId).get()?.requestId !== request.requestId) {
return
}
if (!gateway) {
notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed)
return
}
// Clear first: the answer is decided, and an in-flight RPC must not
// leave a live card that can be answered a second time.
clearMcpSetupRequest(request.requestId, request.sessionId)
// A successful outcome changed mcp_servers — reload the live session
// BEFORE unblocking the tool, or the agent resumes being told the
// server is ready while its tool snapshot still lacks it (the same
// write-through mcp-tab's silentReload does; consent was the card
// click, so no confirm prompt). Reload failure isn't outcome failure:
// the config landed, tools arrive next session — report it and move on.
if (outcome.status === 'installed' || outcome.status === 'enabled' || outcome.status === 'authorized') {
try {
await gateway.request('reload.mcp', { confirm: true, session_id: request.sessionId ?? undefined })
} catch (error) {
notifyError(error, copy.reloadFailed)
}
// The just-set-up server must stop being suggested immediately.
invalidateMcpSuggestionIndex()
}
try {
await gateway.request<{ status?: string }>('mcp.setup.respond', {
request_id: request.requestId,
result: JSON.stringify(outcome)
})
// tool.complete lands next → McpSetupSettled.
} catch (error) {
notifyError(error, copy.sendFailed)
}
},
[copy.gatewayDisconnected, copy.reloadFailed, copy.sendFailed, gateway, request]
)
const decline = useCallback(() => {
// While a flow is in flight this is a CANCEL: answer declined right away
// and let the abandoned work notice via cancelRef at its next poll.
cancelRef.current = true
triggerHaptic('cancel')
void respond({ server, status: 'declined' })
}, [respond, server])
const approve = useCallback(async () => {
cancelRef.current = false
setWorking(true)
// Poll-boundary abort for the background-install loop; the OAuth flows
// carry their own cancel via completeMcpDesktopOAuth's `cancelled`.
const throwIfCancelled = <T,>(value: T): T => {
if (cancelRef.current) {
throw CANCELLED
}
return value
}
try {
if (action === 'enable') {
await setMcpServerEnabled(server, true)
triggerHaptic('submit')
await respond({ server, status: 'enabled' })
return
}
if (action === 'authorize') {
const flow = await completeMcpDesktopOAuth({
serverName: server,
start: authMcpServer,
status: getMcpOAuthFlow,
cancelled: () => cancelRef.current,
cancel: cancelMcpOAuthFlow,
openExternal: url => window.hermesDesktop.openExternal(url)
})
triggerHaptic('submit')
await respond({ server, status: 'authorized', tools: (flow.tools ?? []).map(tool => tool.name) })
return
}
// Install: prefer the reviewed catalog entry when one exists; otherwise
// fall back to the desktop suggestion directory (official URL-only
// remotes), written through the same validated POST the dashboard's add
// form uses. Required catalog credentials get an inline prompt first
// (never pre-filled, never echoed back).
let resolved = entry
if (resolved === undefined) {
const catalog = await getMcpCatalog()
resolved = catalog.entries.find(candidate => candidate.name === server) ?? null
setEntry(resolved)
}
if (!resolved) {
const known = directoryEntry(server)
if (!known) {
await respond({ detail: copy.notInCatalog(server), server, status: 'error' })
return
}
// URL-only remote: add to config, then run the OAuth/probe flow so
// "Install" lands the user on a working server, not a 401. If the
// flow dies after the config write (cancel, closed OAuth tab), roll
// the write back — decline means "no server", not an unauthorized
// entry squatting in mcp_servers (authoritative-write rule).
await addMcpServer({ name: known.name, url: known.url })
let flow
try {
flow = await completeMcpDesktopOAuth({
serverName: known.name,
start: authMcpServer,
status: getMcpOAuthFlow,
cancelled: () => cancelRef.current,
cancel: cancelMcpOAuthFlow,
openExternal: url => window.hermesDesktop.openExternal(url)
})
} catch (error) {
await removeMcpServer(known.name).catch(() => {
// Rollback is best-effort; the primary error/cancel wins.
})
throw error
}
triggerHaptic('submit')
await respond({ server, status: 'installed', tools: (flow.tools ?? []).map(tool => tool.name) })
return
}
const required = resolved.required_env.filter(env => env.required)
if (required.some(env => !envDraft[env.name]?.trim())) {
// Reveal the credential fields; the user approves again once filled.
setEnvOpen(true)
return
}
const res = await installMcpCatalogEntry(server, envDraft)
// Git-backed entries clone in the background — poll to completion so a
// non-zero exit surfaces as a real failure instead of a false success.
if (res.background && res.action) {
for (;;) {
const status = throwIfCancelled(await getActionStatus(res.action, 1))
if (!status.running) {
if (status.exit_code !== 0) {
throw new Error(copy.failed(server))
}
break
}
await new Promise(resolve => setTimeout(resolve, CATALOG_INSTALL_POLL_MS))
}
}
triggerHaptic('submit')
await respond({ server, status: 'installed' })
} catch (error) {
// User cancel: the declined respond is already on the wire — the
// abandoned flow just stops, nothing to report.
if (error === CANCELLED || error instanceof McpOAuthCancelled) {
return
}
notifyError(error, copy.failed(server))
await respond({
detail: error instanceof Error ? error.message : String(error),
server,
status: 'error'
})
} finally {
setWorking(false)
}
}, [action, copy, entry, envDraft, respond, server])
const title =
action === 'enable'
? copy.enableTitle(prettyName(server))
: action === 'authorize'
? copy.authorizeTitle(prettyName(server))
: copy.installTitle(prettyName(server))
const actionLabel =
action === 'enable' ? copy.enableAction : action === 'authorize' ? copy.authorizeAction : copy.installAction
// What connecting actually means — the endpoint that will be contacted.
// VS Code's trust dialog links the config it's about to trust; same idea.
// Catalog entries carry their transport URL in the API response; the
// static directory remains a fallback rung for older backends.
const known = directoryEntry(server)
const sourceLine = action === 'install' ? (entry?.url ?? known?.url ?? copy.catalogSource) : null
const brand = brandFor(server)
const trailingIcon = brand ? (
<brand.Icon aria-hidden className="mt-px size-4 shrink-0" style={brandGlyphStyle(brand)} />
) : (
<Codicon className={ICON_CLASS} name="plug" size="1rem" />
)
// ⌘/Ctrl+Enter → approve, Esc → decline/cancel. Same accelerators, same
// guard shape as the approval bar (tool/approval.tsx). Unlike approve, Esc
// stays live while a flow is in flight — that's the cancel path. Stands
// down whenever a focusable control has focus (clarify's rule): a keystroke
// meant for the composer, a popover, or the card's own credential fields
// must never silently approve an install or throw away typed input.
useEffect(() => {
if (!ready) {
return
}
const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.defaultPrevented) {
return
}
const active = document.activeElement as HTMLElement | null
if (
active &&
(active.isContentEditable || active.matches('a[href], button, input, select, textarea, [role="button"]'))
) {
return
}
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
if (!working) {
event.preventDefault()
void approve()
}
} else if (event.key === 'Escape') {
event.preventDefault()
decline()
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [approve, decline, ready, working])
if (!ready) {
return (
<div className={cn(SHELL_CLASS, 'my-1.5 flex items-center gap-2')} data-slot="mcp-setup-inline">
<Loader2 aria-hidden className="size-4 animate-spin text-(--ui-text-tertiary)" />
<span className="text-(--ui-text-tertiary)">{title}</span>
</div>
)
}
return (
<div className={cn(SHELL_CLASS, 'my-1.5 grid gap-1.5')} data-slot="mcp-setup-inline">
<SetupLine trailing={trailingIcon}>
<span className="font-medium leading-(--conversation-line-height)">{title}</span>
{reason ? <p className="mt-0.5 text-(--ui-text-secondary)">{reason}</p> : null}
{sourceLine && <p className="mt-0.5 truncate text-[0.6875rem] text-(--ui-text-tertiary)">{sourceLine}</p>}
</SetupLine>
{envOpen && entry && entry.required_env.length > 0 && (
<div className="grid gap-2" data-slot="mcp-setup-env">
<p className="text-[0.6875rem] text-(--ui-text-tertiary)">{copy.envRequired}</p>
{entry.required_env.map(env => (
<label className="grid gap-1" key={env.name}>
<span className="text-[0.6875rem] text-(--ui-text-secondary)">
{env.prompt || env.name}
{env.required ? ' *' : ''}
</span>
<Input
className="h-7 text-xs"
onChange={event => setEnvDraft(prev => ({ ...prev, [env.name]: event.currentTarget.value }))}
type="password"
value={envDraft[env.name] ?? ''}
/>
</label>
))}
</div>
)}
{/* Same strip as the tool approval bar (tool/approval.tsx): a bordered
primary-tinted action plus a quiet ghost decline, with the matching
keyboard hints. One consent vocabulary across the transcript. */}
<div className="flex items-center gap-2.5">
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
<Button
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
disabled={working}
onClick={() => void approve()}
size="xs"
variant="ghost"
>
{working ? <Loader2 className="size-3 animate-spin" /> : actionLabel}
{!working && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
</Button>
</div>
{/* Never disabled: while a flow is in flight this is the cancel —
a stuck OAuth tab or hung install must always have a way out. */}
<Button
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
onClick={decline}
size="xs"
variant="ghost"
>
{working ? t.common.cancel : copy.decline}
<span className="text-[0.625rem] opacity-55">Esc</span>
</Button>
</div>
</div>
)
}
@@ -0,0 +1,270 @@
import { act, cleanup, render, screen } from '@testing-library/react'
import { Component, type ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MessageRenderBoundary } from './message-render-boundary'
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
function Boom({ error }: { error: Error | null }): null {
if (error) {
throw error
}
return null
}
const lookupError = new Error('useClientLookup: Index 2 out of bounds (length: 2)')
const outerCaught: Error[] = []
// Records what propagates past MessageRenderBoundary, so the tests can tell
// a re-thrown error apart from a swallowed one.
class RecordingBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
state: { error: Error | null } = { error: null }
static getDerivedStateFromError(error: Error) {
return { error }
}
componentDidCatch(error: Error) {
outerCaught.push(error)
}
render() {
return this.state.error ? null : this.props.children
}
}
const lookupErrors = [
['useClientLookup', lookupError],
['tapClientLookup', new Error('tapClientLookup: Index 2 out of bounds (length: 2)')],
['tapClientResource', new Error('tapClientResource: Index 2 out of bounds (length: 2)')]
] as const
describe('MessageRenderBoundary', () => {
it('renders children when nothing throws', () => {
render(
<MessageRenderBoundary resetKey="a">
<div>content</div>
</MessageRenderBoundary>
)
expect(screen.getByText('content')).toBeTruthy()
})
it.each(lookupErrors)('swallows the transient %s out-of-bounds store race', (_label, error) => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const { container } = render(
<MessageRenderBoundary resetKey="a">
<Boom error={error} />
</MessageRenderBoundary>
)
expect(container.innerHTML).toBe('')
spy.mockRestore()
})
it('recovers on the next consistent snapshot when resetKey changes', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const { rerender } = render(
<MessageRenderBoundary resetKey="a">
<Boom error={lookupError} />
</MessageRenderBoundary>
)
rerender(
<MessageRenderBoundary resetKey="b">
<Boom error={null} />
</MessageRenderBoundary>
)
rerender(
<MessageRenderBoundary resetKey="b">
<div>recovered</div>
</MessageRenderBoundary>
)
expect(screen.getByText('recovered')).toBeTruthy()
spy.mockRestore()
})
it('re-throws unrelated errors so real bugs still surface', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
expect(() =>
render(
<MessageRenderBoundary resetKey="a">
<Boom error={new Error('genuine render bug')} />
</MessageRenderBoundary>
)
).toThrow('genuine render bug')
spy.mockRestore()
})
it('recovers on the retry timer without a resetKey change', () => {
// The mid-turn race: the message list shrinks and regrows while
// ids/roles/count stay stable, so resetKey never changes. The boundary
// must self-retry on a timer instead of rendering null for the rest of
// the turn.
vi.useFakeTimers()
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
let failing = true
function MaybeBoom() {
if (failing) {
throw new Error('useClientLookup: index 3 out of bounds')
}
return <div>turn content</div>
}
render(
<MessageRenderBoundary resetKey="0:m1:user">
<MaybeBoom />
</MessageRenderBoundary>
)
expect(screen.queryByText('turn content')).toBeNull()
failing = false
act(() => {
vi.advanceTimersByTime(0)
})
// Recovered through the retry timer alone; resetKey never changed.
expect(screen.getByText('turn content')).toBeTruthy()
spy.mockRestore()
})
it('stops retrying after the transient retry cap', () => {
// If the lookup stays out of bounds the boundary must give up instead of
// looping a setState/render cycle forever: initial render plus 5 retries,
// then it stays null and arms no further timer.
vi.useFakeTimers()
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
let attempts = 0
function AlwaysBoom(): null {
attempts += 1
throw lookupError
}
const { container } = render(
<MessageRenderBoundary resetKey="a">
<AlwaysBoom />
</MessageRenderBoundary>
)
// React dev mode replays a failed render once per attempt, and an error
// during the initial mount gets an extra sync retry from the root, so
// measure the per-attempt cost from the first retry instead of guessing.
const mountAttempts = attempts
act(() => {
vi.advanceTimersByTime(0)
})
const perRetry = attempts - mountAttempts
for (let retry = 0; retry < 4; retry += 1) {
act(() => {
vi.advanceTimersByTime(0)
})
}
// Initial render plus 5 retries, then the boundary gives up: it stays
// null and arms no further timer.
expect(attempts).toBe(mountAttempts + perRetry * 5)
expect(vi.getTimerCount()).toBe(0)
expect(container.innerHTML).toBe('')
act(() => {
vi.advanceTimersByTime(1000)
})
expect(attempts).toBe(mountAttempts + perRetry * 5)
spy.mockRestore()
})
it('resets the retry budget after a successful recovery', () => {
// The cap bounds a single streak of consecutive transient catches. A
// recovered boundary must get a fresh budget, otherwise enough separate
// races over a long session would permanently blank the turn.
vi.useFakeTimers()
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
let failing = true
function MaybeBoom() {
if (failing) {
throw lookupError
}
return <div>turn content</div>
}
const { rerender } = render(
<MessageRenderBoundary resetKey="a">
<MaybeBoom />
</MessageRenderBoundary>
)
// The mount is the first streak; five more follow. With a lifetime
// budget the sixth streak would find the cap exhausted and stay blank.
// Each rerender needs a fresh element: React bails out on an identical
// element reference and the child would never re-render (or re-throw).
for (let streak = 0; streak < 6; streak += 1) {
expect(screen.queryByText('turn content')).toBeNull()
failing = false
act(() => {
vi.advanceTimersByTime(0)
})
expect(screen.getByText('turn content')).toBeTruthy()
failing = true
rerender(
<MessageRenderBoundary resetKey="a">
<MaybeBoom />
</MessageRenderBoundary>
)
}
spy.mockRestore()
})
it('does not schedule a retry for non-transient errors', () => {
vi.useFakeTimers()
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
outerCaught.length = 0
render(
<RecordingBoundary>
<MessageRenderBoundary resetKey="a">
<Boom error={new Error('boom')} />
</MessageRenderBoundary>
</RecordingBoundary>
)
// MessageRenderBoundary re-threw, the outer boundary caught it, and no
// retry timer was armed for a failure that cannot heal itself.
expect(outerCaught.map(error => error.message)).toContain('boom')
expect(vi.getTimerCount()).toBe(0)
spy.mockRestore()
})
})
@@ -0,0 +1,103 @@
import { Component, type ReactNode } from 'react'
// `@assistant-ui/store`'s index-keyed child-scope lookup (`useClientLookup`)
// throws — rather than returning undefined — when a subscriber reads an index
// that the message/parts list no longer has. This races during high-frequency
// store replacement (session switch mid-stream, gateway reconnect replay): a
// subscriber from the previous, longer list is still in React's notification
// queue and reads one slot past the new, shorter array before it can unmount.
// The throw is transient and self-heals on the next consistent snapshot, but
// without a local boundary it unwinds to the root and blanks the whole app.
// Upstream-tracked: assistant-ui/assistant-ui#4051, #3652.
const isTransientLookupError = (error: unknown): boolean =>
error instanceof Error && /(useClientLookup|tapClient(Lookup|Resource)).*out of bounds/.test(error.message)
// Consecutive transient retries before giving up and waiting for a structural
// resetKey change (the pre-retry behavior). The race heals on the next
// consistent store snapshot, so one retry almost always recovers; the cap
// only bounds a pathological loop where the lookup stays out of bounds.
const MAX_TRANSIENT_RETRIES = 5
interface Props {
// Changes whenever the message list mutates STRUCTURALLY (ids/roles/count);
// remounting clears the caught error so the next consistent render recovers
// silently. Deliberately NOT the per-token signature: this prop reaches
// every turn's boundary, so a value that ticks with content length would
// re-render every boundary — and reconcile every turn's subtree — on every
// streamed token (measured: 540 wasted Block renders per explain() sample
// with two threads streaming).
resetKey: string
children: ReactNode
}
export class MessageRenderBoundary extends Component<Props, { error: Error | null }> {
state: { error: Error | null } = { error: null }
private retryTimer: number | null = null
private transientRetries = 0
static getDerivedStateFromError(error: Error) {
return { error }
}
componentDidCatch(error: Error) {
// The resetKey path below only recovers on a STRUCTURAL change, but this
// race also fires mid-turn while ids/roles/count are stable: without a
// self-retry the boundary renders null for the rest of the turn (or
// until an unrelated message add/remove). Retry on a timer, not rAF —
// a parked renderer never fires frames, and a timer always runs.
if (!isTransientLookupError(error) || this.transientRetries >= MAX_TRANSIENT_RETRIES) {
return
}
if (typeof window === 'undefined') {
return
}
this.transientRetries += 1
this.retryTimer = window.setTimeout(() => {
this.retryTimer = null
this.setState({ error: null })
}, 0)
}
componentDidUpdate(prev: Props, prevState: { error: Error | null }) {
if (this.state.error && prev.resetKey !== this.props.resetKey) {
this.setState({ error: null })
return
}
if (prevState.error && !this.state.error) {
// Recovered (retry or structural reset): reset the retry budget and
// drop any retry timer the structural reset just made redundant.
this.transientRetries = 0
if (this.retryTimer !== null) {
window.clearTimeout(this.retryTimer)
this.retryTimer = null
}
}
}
componentWillUnmount() {
if (this.retryTimer !== null) {
window.clearTimeout(this.retryTimer)
}
}
render() {
if (this.state.error) {
// Only swallow the transient store race; re-throw anything else so real
// bugs still reach the root error boundary.
if (!isTransientLookupError(this.state.error)) {
throw this.state.error
}
return null
}
return this.props.children
}
}
@@ -0,0 +1,173 @@
/**
* The composer's reference vocabulary: one place that decides what a `@file:`,
* a `@folder:`, a picked `/skill`, or any other reference LOOKS like — its
* icon, its accent, and the word for its kind.
*
* Both surfaces that show a reference read from this table:
*
* - the trigger popover row (browsing for one)
* - the chip (having picked one)
*
* so a thing is the same color with the same glyph wherever you meet it. They
* used to be two hand-maintained icon maps and two unrelated row layouts, which
* is why `@` and `/` looked like features from different apps.
*/
/** Every kind of thing the composer can reference. */
export type ReferenceKind =
| 'file'
| 'folder'
| 'url'
| 'image'
| 'tool'
| 'line'
| 'terminal'
| 'session'
| 'git'
| 'diff'
| 'staged'
| 'command'
| 'skill'
| 'theme'
| 'emoji'
| 'other'
interface ReferenceStyle {
/** Codicon name — the popover row's leading glyph. */
codicon: string
/** Tabler outline path data — the inline SVG a rendered reference uses. */
paths: string[]
/** Section label when a surface groups by this kind. */
label: string
}
// Colour is NOT here. A reference's accent lives in styles.css keyed on
// `data-ref="<kind>"`, so a theme restyles every reference at once and no hex
// or color-mix() ships from TypeScript. This table owns the two things CSS
// can't express: which glyph, and what to call the kind.
const FILE_PATHS = [
'M14 3v4a1 1 0 0 0 1 1h4',
'M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2',
'M9 9l1 0',
'M9 13l6 0',
'M9 17l6 0'
]
const TERMINAL_PATHS = ['M5 7l5 5l-5 5', 'M12 19l7 0']
export const REFERENCE_STYLES: Record<ReferenceKind, ReferenceStyle> = {
file: { codicon: 'file', paths: FILE_PATHS, label: 'Files' },
folder: {
codicon: 'folder',
paths: [
'M5 19l2.757 -7.351a1 1 0 0 1 .936 -.649h12.307a1 1 0 0 1 .986 1.164l-.996 5.211a2 2 0 0 1 -1.964 1.625h-14.026a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v2'
],
label: 'Folders'
},
url: {
codicon: 'globe',
paths: [
'M9 15l6 -6',
'M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464',
'M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463'
],
label: 'Links'
},
image: {
codicon: 'file-media',
paths: [
'M15 8h.01',
'M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12',
'M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5',
'M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3'
],
label: 'Images'
},
tool: {
codicon: 'tools',
paths: ['M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5'],
label: 'Tools'
},
line: {
codicon: 'list-selection',
paths: ['M5 9l14 0', 'M5 15l14 0', 'M11 4l-4 16', 'M17 4l-4 16'],
label: 'Lines'
},
terminal: { codicon: 'terminal', paths: TERMINAL_PATHS, label: 'Terminal' },
session: {
codicon: 'comment-discussion',
paths: ['M4 4h16v2.172a2 2 0 0 1 -.586 1.414l-4.414 4.414v7l-6 2v-8.5l-4.48 -4.928a2 2 0 0 1 -.52 -1.345v-2.227'],
label: 'Sessions'
},
git: { codicon: 'git-branch', paths: ['M7 18l0 -12', 'M7 8a2 2 0 1 0 0 -4a2 2 0 0 0 0 4'], label: 'Git' },
diff: { codicon: 'diff', paths: ['M12 5l0 14', 'M5 12l14 0'], label: 'Changes' },
staged: { codicon: 'diff-added', paths: ['M12 5l0 14', 'M5 12l14 0'], label: 'Staged' },
command: { codicon: 'terminal', paths: TERMINAL_PATHS, label: 'Commands' },
skill: { codicon: 'zap', paths: ['M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11'], label: 'Skills' },
theme: {
codicon: 'symbol-color',
paths: [
'M3 21v-4a4 4 0 1 1 4 4h-4',
'M21 3a16 16 0 0 0 -12.8 10.2',
'M21 3a16 16 0 0 1 -10.2 12.8',
'M10.6 9a9 9 0 0 1 4.4 4.4'
],
label: 'Themes'
},
emoji: { codicon: 'smiley', paths: [], label: 'Emoji' },
other: { codicon: 'symbol-misc', paths: FILE_PATHS, label: 'Other' }
}
const KNOWN = new Set(Object.keys(REFERENCE_STYLES))
/** Coerce any incoming type string to a kind we have a style for. */
export function referenceKind(type: string | undefined): ReferenceKind {
return type && KNOWN.has(type) ? (type as ReferenceKind) : 'other'
}
export function referenceStyle(type: string | undefined): ReferenceStyle {
return REFERENCE_STYLES[referenceKind(type)]
}
/**
* The kinds that travel in message text as `@kind:value`. A subset of the table
* above: `command`/`skill`/`theme` arrive via `/`, and `diff`/`staged`/`emoji`
* have no value to carry.
*/
export const WIRE_REFERENCE_KINDS = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const
/**
* The one pattern that recognises a reference in text.
*
* A value is quoted whenever it needs to be — `@url:` always, and any path with
* a space — so the quoted forms are tried BEFORE bare `\S+`, or a quoted value
* would end at the first space and strand the rest as prose.
*/
const REFERENCE_PATTERN = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/
/**
* A fresh matcher for every surface that has to find references in text: the
* composer hydrating a draft, the sent bubble, the edit composer.
*
* New instance per call on purpose — a shared `/g` regex carries `lastIndex`
* between callers, which is how a scanner silently skips the first reference in
* the next string it's handed.
*/
export function referenceRe(): RegExp {
return new RegExp(REFERENCE_PATTERN.source, 'g')
}
/** Remove reference-only lines when comparing visible message text. */
// Anchored + non-global: no shared `lastIndex` state (the hazard referenceRe()
// exists to avoid), and hoisting skips a RegExp construction per call — this
// runs on both sides of every message comparison in the reconcile loops.
const REFERENCE_LINE_RE = new RegExp(`^(?:${REFERENCE_PATTERN.source})$`)
export function textWithoutReferenceLines(text: string): string {
return text
.split('\n')
.filter(line => !REFERENCE_LINE_RE.test(line.trimEnd()))
.join('\n')
.trim()
}
@@ -0,0 +1,69 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { __resetSessionLinkTitleCache } from '@/lib/session-link-title'
import { $previewTabs, closeRightRail } from '@/store/preview'
import { DirectiveContent } from './directive-text'
import { MarkdownTextContent } from './markdown-text'
const openSession = vi.fn()
vi.mock('@/app/open-session', () => ({
openSession: (...args: unknown[]) => openSession(...args)
}))
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
afterEach(() => {
cleanup()
closeRightRail()
openSession.mockClear()
delete desktopWindow.hermesDesktop
__resetSessionLinkTitleCache()
})
// Both surfaces render a session ref differently — an inline link in agent
// prose, a chip in the user's own message — but either one opens the session
// it names via the shared door (focus if on screen, else a stacked tab).
describe('session refs open the session', () => {
it('opens the session from an agent-written link', async () => {
render(<MarkdownTextContent isRunning={false} text="Picked up in @session:work/20260101_abc123 last night." />)
fireEvent.click(await screen.findByTitle('work/20260101_abc123'))
await vi.waitFor(() => expect(openSession).toHaveBeenCalledWith('20260101_abc123', expect.any(Function), 'tab'))
})
it('opens the session from a chip in the user transcript', async () => {
render(<DirectiveContent text="pick up @session:work/20260101_abc123 please" />)
const chip = screen.getByTitle('work/20260101_abc123')
expect(chip.tagName).toBe('BUTTON')
fireEvent.click(chip)
await vi.waitFor(() => expect(openSession).toHaveBeenCalledWith('20260101_abc123', expect.any(Function), 'tab'))
})
})
// A url the user sent renders as a chip too, and it opens in the IN-APP
// browser — the same door the composer's hover pill uses, so a link behaves
// the same before and after send.
describe('url refs open in the browser pane', () => {
it('opens a url chip in the user transcript', async () => {
const openExternal = vi.fn().mockResolvedValue(undefined)
desktopWindow.hermesDesktop = { openExternal } as unknown as Window['hermesDesktop']
render(<DirectiveContent text="see @url:`https://example.com/docs` when you can" />)
const chip = screen.getByTitle('https://example.com/docs')
expect(chip.tagName).toBe('BUTTON')
fireEvent.click(chip)
expect(openExternal).not.toHaveBeenCalled()
await vi.waitFor(() => expect($previewTabs.get().at(-1)?.target.url).toBe('https://example.com/docs'))
})
})
@@ -0,0 +1,86 @@
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import type { ReactNode } from 'react'
import { vi } from 'vitest'
import { stubResizeObserver } from '@/test/jsdom'
/** Fixed clock for message fixtures, so nothing sorts by "now". */
export const createdAt = new Date('2026-05-01T00:00:00.000Z')
/** The browser APIs the transcript renders against — a resize observer,
* animation frames, `CSS.escape` for its selector lookups, and the scroll and
* WAAPI calls the message list makes on mount. jsdom has none of them, and a
* thread that cannot mount fails every assertion in the file for the wrong
* reason. */
export function stubThreadEnvironment() {
stubResizeObserver()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
vi.stubGlobal('CSS', { escape: (value: string) => value })
Element.prototype.scrollTo = function scrollTo() {}
Element.prototype.animate = function animate() {
return { cancel() {}, finished: Promise.resolve() } as unknown as Animation
}
}
/** Give jsdom a viewport that the thread's virtualizer treats as scrollable.
*
* jsdom reports every `offsetWidth`/`offsetHeight` as 0, which makes the
* message list measure itself as having no room and skip the rendering paths
* these tests are about. The stub falls through to a real value when one
* exists, so a test that sets its own dimensions still wins. */
export function stubThreadViewportSize() {
const stub = (prop: 'offsetHeight' | 'offsetWidth', clientProp: 'clientHeight' | 'clientWidth', fallback: number) => {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stub('offsetWidth', 'clientWidth', 800)
stub('offsetHeight', 'clientHeight', 600)
}
/** Wrap a transcript subtree in a runtime driven by a fixed message list, the
* way the app's external store drives it: the thread is running while the tail
* message is. */
export function ThreadRuntime({ children, messages }: { children: ReactNode; messages: ThreadMessage[] }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages,
isRunning: messages.at(-1)?.status?.type === 'running',
onNew: async () => {}
})
return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>
}
export function userMessage(id = 'user-1', text = 'edit me please'): ThreadMessage {
return {
id,
role: 'user',
content: [{ type: 'text', text }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
export function assistantMessage(): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} }
} as ThreadMessage
}
@@ -0,0 +1,83 @@
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { act, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { createdAt, stubThreadEnvironment, stubThreadViewportSize } from './test-utils'
import { Thread } from './thread'
stubThreadEnvironment()
stubThreadViewportSize()
const MESSAGES: ThreadMessage[] = [
{
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'hello from the user' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage,
{
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text: 'stable assistant reply' }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
]
function Harness({
onBranchInNewChat,
onCancel
}: {
onBranchInNewChat: (messageId: string) => void
onCancel: () => void
}) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: MESSAGES,
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread onBranchInNewChat={onBranchInNewChat} onCancel={onCancel} />
</AssistantRuntimeProvider>
)
}
describe('thread message mount stability', () => {
// Regression: the desktop controller re-renders every 15s (status
// snapshot poll) and used to pass freshly-created callbacks down to
// <Thread/>. Those callbacks were deps of the `messageComponents`
// useMemo, so new component *types* were created each poll and React
// unmounted/remounted every visible message — shiki re-highlighted
// code blocks and the whole thread visibly jumped.
it('keeps message DOM nodes mounted when callback props get new identities', async () => {
const { rerender } = render(<Harness onBranchInNewChat={() => {}} onCancel={() => {}} />)
await waitFor(() => {
expect(screen.getByText('stable assistant reply')).toBeTruthy()
expect(screen.getByText('hello from the user')).toBeTruthy()
})
const assistantBefore = screen.getByText('stable assistant reply')
const userBefore = screen.getByText('hello from the user')
// Same data, new callback identities — exactly what a parent
// re-render driven by an unrelated state update produces.
await act(async () => {
rerender(<Harness onBranchInNewChat={() => {}} onCancel={() => {}} />)
})
expect(screen.getByText('stable assistant reply')).toBe(assistantBefore)
expect(screen.getByText('hello from the user')).toBe(userBefore)
})
})
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { deliveryTargetFromCommand, replyTextFromResult } from './agent-delivery'
// Sender-side inter-agent deliveries render as "Messaged X" / "Message from
// X" notices instead of terminal transcript rows. This pins the detection
// (the canonical Bot Mode command shape) and the reply extraction.
describe('delivery command detection', () => {
it('matches the canonical delivery command', () => {
const cmd = 'hermes -p turqoise chat --in ~ -c "Bot Chat" -Q -q "Message from 🤖 Hermes (@hermes): hi there"'
expect(deliveryTargetFromCommand(cmd)).toBe('turqoise')
})
it('matches with a cd prefix and timeout wrapper', () => {
const cmd = 'cd ~ && timeout 240 hermes -p mr-tester chat --in "~" -Q -q "Message from 🤖 Hermes: hello"'
expect(deliveryTargetFromCommand(cmd)).toBe('mr-tester')
})
it('ignores ordinary terminal commands', () => {
expect(deliveryTargetFromCommand('ls -la')).toBeNull()
expect(deliveryTargetFromCommand('hermes -p turqoise chat -q "plain question"')).toBeNull()
expect(deliveryTargetFromCommand('hermes sessions list')).toBeNull()
})
})
describe('reply extraction', () => {
it('strips session_id bookkeeping and keeps the reply', () => {
const output = 'session_id: 20260813_220347_f69ac6\nHi Hermes! Good to hear from you.'
expect(replyTextFromResult({ output })).toBe('Hi Hermes! Good to hear from you.')
})
it('unwraps JSON-shaped terminal results', () => {
const result = JSON.stringify({ exit_code: 0, output: 'session_id: abc\nack' })
expect(replyTextFromResult(result)).toBe('ack')
})
it('returns empty for empty results', () => {
expect(replyTextFromResult(undefined)).toBe('')
expect(replyTextFromResult({ output: '' })).toBe('')
})
})
@@ -0,0 +1,132 @@
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
import { type FC, useEffect, useState } from 'react'
import { AGENT_MESSAGE_RE, agentAvatarCache, resolveAgentAvatar } from '@/components/assistant-ui/thread/user-message'
// Sender-side inter-agent delivery: `hermes -p <agent> chat … -q "Message
// from 🤖 <sender>…"` run through the terminal tool IS the messaging
// pipeline (the Bot Mode / multi-profile convention shipped with #85855).
// Rendering it as a terminal transcript makes the sending bot's chat read
// like ops tooling; the user-facing truth is "Messaged X" and, when the
// quiet run returns the recipient's reply, "Message from X" — the same
// compact event notices the receiving chat shows.
const DELIVERY_COMMAND_RE =
/(?:^|[;&|]\s*|\bhermes\s+)-p\s+("?)([a-z0-9][a-z0-9_-]{0,63})\1\s+chat\b[\s\S]*?-q\s+["']Message from/iu
export function deliveryTargetFromCommand(command: string): null | string {
const match = DELIVERY_COMMAND_RE.exec(command)
return match ? match[2].toLowerCase() : null
}
/** Extract the recipient's reply text from the terminal result payload. */
export function replyTextFromResult(result: unknown): string {
const container = (result ?? {}) as { content?: unknown; output?: unknown }
let raw = ''
if (typeof result === 'string') {
raw = result
} else if (typeof container.output === 'string') {
raw = container.output
} else if (Array.isArray(container.content)) {
raw = container.content
.map(entry => (typeof (entry as { text?: unknown })?.text === 'string' ? (entry as { text: string }).text : ''))
.join('\n')
}
// Terminal results may be JSON-wrapped: {"output": "...", "exit_code": 0}
if (raw.trimStart().startsWith('{')) {
try {
const parsed = JSON.parse(raw) as { output?: unknown }
if (typeof parsed.output === 'string') {
raw = parsed.output
}
} catch {
/* not JSON — use as-is */
}
}
// Drop session_id bookkeeping lines; what remains is the reply.
return raw
.split('\n')
.filter(line => !/^session_id:\s/.test(line.trim()))
.join('\n')
.trim()
}
const NOTICE_CLASS =
'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'
const AgentGlyph: FC<{ handle: string }> = ({ handle }) => {
const [avatar, setAvatar] = useState<null | string>(() => agentAvatarCache.get(handle.toLowerCase()) ?? null)
useEffect(() => {
let live = true
void resolveAgentAvatar(handle).then(url => {
if (live && url) {
setAvatar(url)
}
})
return () => {
live = false
}
}, [handle])
return avatar ? (
<img alt="" aria-hidden className="size-4 shrink-0 rounded-full object-cover" src={avatar} />
) : (
<span aria-hidden className="text-[0.8125rem] leading-none">
🤖
</span>
)
}
/** "Messaged X" (+ "Message from X" once the reply lands) for a delivery
* command run via the terminal tool. Returns null when the command is not
* a delivery — caller falls through to the normal terminal row. */
export const AgentDeliveryNotice: FC<ToolCallMessagePartProps> = props => {
const command = typeof props.args?.command === 'string' ? props.args.command : ''
const target = deliveryTargetFromCommand(command)
if (!target || props.isError) {
return null
}
const pending = props.result === undefined
const reply = pending ? '' : replyTextFromResult(props.result)
// Strip a leading agent-message prefix if the recipient echoed one back.
const replyBody = AGENT_MESSAGE_RE.exec(reply)?.[4] ?? reply
return (
<div className="flex w-full min-w-0 flex-col items-stretch gap-0.5">
<div className={NOTICE_CLASS} data-slot="aui_agent-delivery-notice">
<span className="flex items-center justify-center gap-1.5">
<AgentGlyph handle={target} />
<span className="wrap-anywhere">
{pending ? 'Messaging' : 'Messaged'} {target}
{pending ? '…' : ''}
</span>
</span>
</div>
{!pending && replyBody && (
<div className={NOTICE_CLASS} data-slot="aui_agent-reply-notice">
<span className="flex items-center justify-center gap-1.5">
<AgentGlyph handle={target} />
<span className="wrap-anywhere">Message from {target}</span>
</span>
<details className="self-center">
<summary className="cursor-pointer select-none text-center text-muted-foreground/45 hover:text-muted-foreground/70">
show message
</summary>
<div className="mt-1 max-w-[36rem] whitespace-pre-wrap rounded-lg border border-(--ui-stroke-tertiary) px-3 py-2 text-left text-[0.75rem] leading-5 text-foreground/85">
{replyBody}
</div>
</details>
</div>
)}
</div>
)
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { AGENT_MESSAGE_RE } from './user-message'
// Agent-to-agent deliveries render as a compact attributed timeline notice,
// not a user bubble. This pins the detection contract: the Bot Mode prefix
// ("Message from 🤖 <sender>: …"), the handle-carrying form
// ("Message from 🤖 <sender> (@<handle>): …"), and the legacy bracket form
// all match; human prose that merely mentions the phrase does not.
describe('agent message detection', () => {
it('matches the Bot Mode delivery prefix with sender and body', () => {
const m = AGENT_MESSAGE_RE.exec('Message from 🤖 Hermes: hello there')
expect(m?.[1]?.trim()).toBe('Hermes')
expect(m?.[4]).toBe('hello there')
})
it('captures the @handle when present', () => {
const m = AGENT_MESSAGE_RE.exec('Message from 🤖 Eats Tests (@mr-tester): run them all')
expect(m?.[1]?.trim()).toBe('Eats Tests')
expect(m?.[2]).toBe('mr-tester')
expect(m?.[4]).toBe('run them all')
})
it('matches without the robot emoji', () => {
const m = AGENT_MESSAGE_RE.exec('Message from Turquoise: ready to work')
expect(m?.[1]?.trim()).toBe('Turquoise')
expect(m?.[4]).toBe('ready to work')
})
it('matches the legacy bracket form', () => {
const m = AGENT_MESSAGE_RE.exec("[Message from agent 'turqoise'] ping")
expect(m?.[3]).toBe('turqoise')
expect(m?.[4]).toBe('ping')
})
it('spans multi-line bodies', () => {
const m = AGENT_MESSAGE_RE.exec('Message from 🤖 Dev: line one\nline two')
expect(m?.[4]).toBe('line one\nline two')
})
it('does not match prose that merely contains the phrase', () => {
expect(AGENT_MESSAGE_RE.test('I got a Message from 🤖 Hermes: earlier')).toBe(false)
expect(AGENT_MESSAGE_RE.test('can you explain what Message from means?')).toBe(false)
})
})
@@ -0,0 +1,146 @@
// Bug #2: the Branch-in-new-chat button used to render unconditionally even
// when its handler was a no-op (session-tile.tsx passed `() => undefined`
// for branched/tiled chats, where nested branching isn't supported). That
// left a visibly clickable button that silently did nothing. The fix makes
// AssistantMessage's action bar hide the button entirely when no handler is
// supplied, matching how onDismissError/onRestoreToMessage already behave.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { $displayTimestamps } from '@/store/display-timestamps'
import { stubThreadEnvironment } from '../test-utils'
import { formatTimelineRange, formatTimelineTimestamp } from './timestamp'
import { Thread } from '.'
// Timeline timestamps render only when `display.timestamps` is enabled.
$displayTimestamps.set(true)
const createdAt = new Date('2026-05-01T00:00:00.000Z')
const completedAt = createdAt.getTime() / 1000 + 1.25
stubThreadEnvironment()
afterEach(() => {
cleanup()
})
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'question one' }],
attachments: [],
createdAt,
metadata: { custom: { timelineTimestamp: createdAt.getTime() / 1000 } }
} as unknown as ThreadMessage
}
function assistantMessage(): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [
{
type: 'reasoning',
text: 'checked carefully',
timestamp: createdAt.getTime() / 1000 + 0.05,
completedAt: createdAt.getTime() / 1000 + 0.1
},
{
type: 'text',
text: 'done',
timestamp: createdAt.getTime() / 1000 + 0.125,
completedAt: createdAt.getTime() / 1000 + 0.5
}
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: { timelineCompletedAt: completedAt, timelineTimestamp: createdAt.getTime() / 1000 }
}
} as unknown as ThreadMessage
}
function Harness({
assistant = assistantMessage(),
onBranchInNewChat
}: {
assistant?: ThreadMessage
onBranchInNewChat?: (messageId: string) => void
}) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistant],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread onBranchInNewChat={onBranchInNewChat} />
</AssistantRuntimeProvider>
)
}
describe('AssistantMessage branch button visibility (bug #2 fix)', () => {
it('shows the Branch in new chat button when a handler is provided (open chat)', async () => {
render(<Harness onBranchInNewChat={() => undefined} />)
expect(await screen.findByRole('button', { name: 'Branch in new chat' })).toBeTruthy()
})
it('hides the Branch in new chat button when no handler is provided (session-tile / branched chat)', async () => {
render(<Harness />)
// Wait for the assistant message to actually mount before asserting
// absence, so a missing button isn't just a false negative from an
// unrendered message.
await screen.findByText('done')
expect(screen.queryByRole('button', { name: 'Branch in new chat' })).toBeNull()
})
})
describe('message timeline timestamps', () => {
it('always renders precise user and assistant lifecycle times', async () => {
const { container } = render(<Harness />)
await screen.findByText('done')
const stamps = Array.from(container.querySelectorAll('[data-slot="timeline-timestamp"]')).map(node =>
node.textContent?.trim()
)
const startedAt = createdAt.getTime() / 1000
expect(stamps).toContain(formatTimelineTimestamp(startedAt))
expect(stamps).toContain(formatTimelineRange(startedAt, completedAt))
expect(stamps).toContain(formatTimelineRange(startedAt + 0.05, startedAt + 0.1))
expect(stamps).toContain(formatTimelineRange(startedAt + 0.125, startedAt + 0.5))
})
it('suppresses an aggregate assistant stamp that exactly duplicates its sole part', async () => {
const startedAt = createdAt.getTime() / 1000
const assistant = {
...assistantMessage(),
content: [{ completedAt, text: 'done', timestamp: startedAt, type: 'text' }]
} as unknown as ThreadMessage
const { container } = render(<Harness assistant={assistant} />)
await screen.findByText('done')
const stamps = Array.from(container.querySelectorAll('[data-slot="timeline-timestamp"]')).map(node =>
node.textContent?.trim()
)
expect(stamps.filter(stamp => stamp === formatTimelineRange(startedAt, completedAt))).toHaveLength(1)
})
})
@@ -0,0 +1,749 @@
import {
ActionBarPrimitive,
BranchPickerPrimitive,
ErrorPrimitive,
MessagePrimitive,
useAuiState,
useMessageRuntime
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type FC, type ReactNode, useCallback, useMemo, useState } from 'react'
import { useInRouterContext, useNavigate } from 'react-router'
import { useSessionView } from '@/app/chat/session-view'
import { SETTINGS_ROUTE } from '@/app/routes'
import { ChangedFilesCard } from '@/components/assistant-ui/thread/changed-files-card'
import {
contentHasVisibleText,
messageContentText,
pickPrimaryPreviewTarget
} from '@/components/assistant-ui/thread/content'
import { MESSAGE_PARTS_COMPONENTS } from '@/components/assistant-ui/thread/message-parts'
import { ReactionPicker } from '@/components/assistant-ui/thread/message-reactions'
import { ResponseLoadingIndicator, TurnActivityIndicator } from '@/components/assistant-ui/thread/status'
import { MessageTimelineTimestamp } from '@/components/assistant-ui/thread/timeline-timestamp'
import { useMessageReactions, useTapbackDoubleClick } from '@/components/assistant-ui/thread/use-message-reactions'
import { AGENT_MESSAGE_RE } from '@/components/assistant-ui/thread/user-message'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import { formatElapsed } from '@/components/chat/activity-timer'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { useI18n } from '@/i18n'
import { type ErrorSurface, formatErrorDiagnostics } from '@/lib/error-surface'
import { triggerHaptic } from '@/lib/haptics'
import {
AudioLines,
GitForkIcon,
Loader2Icon,
RefreshCwIcon,
SmilePlusIcon,
Upload,
VolumeXIcon,
XIcon
} from '@/lib/icons'
import { extractPreviewTargets } from '@/lib/preview-targets'
import { markAssistantIdSpoken } from '@/lib/spoken-reply'
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 { requestSendDiagnostics } from '@/store/send-diagnostics'
import { $connection, $currentModel } from '@/store/session'
import { $voicePlayback } from '@/store/voice-playback'
// Stable empty identity for the settled-parts selector — a fresh [] per render
// would re-derive the changed-files card on every message re-render.
const EMPTY_PARTS: readonly unknown[] = []
// PERF: hoisted to module scope so the element OBJECT is identical on every
// render of every assistant message. React bails out of re-rendering a child
// whose element identity is unchanged, so a status flip on the message root
// (pending -> complete and back, N rows per stream flush) can no longer
// descend into the parts subtree at all. Its props were already the module
// constant MESSAGE_PARTS_COMPONENTS, so nothing per-message is captured here.
const MESSAGE_PARTS = <MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
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
}
interface AssistantMessageProps {
onBranchInNewChat?: (messageId: string) => void
onDismissError?: (messageId: string) => void
}
export const AssistantMessage: FC<AssistantMessageProps> = props => {
// A reply to an inter-agent delivery is part of that exchange, not part of
// the human conversation — collapse it under a compact notice ("Reply to
// <sender>", expandable), mirroring the sender-side notice the previous
// user message already renders as. Grok-bots parity: the transcript shows
// events; the texts are one click away. Detection: the immediately
// preceding user message matches AGENT_MESSAGE_RE.
const interAgentSender = useAuiState(s => {
const messages = s.thread.messages
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].id !== s.message.id) {
continue
}
for (let j = i - 1; j >= 0; j--) {
const prev = messages[j] as { content?: unknown; role?: string }
if (prev.role === 'assistant') {
return null
}
if (prev.role === 'user') {
const match = AGENT_MESSAGE_RE.exec(messageContentText(prev.content as never).trim())
return match ? (match[1] || match[3] || 'agent').trim() : null
}
}
return null
}
return null
})
// The collapse gate below needs the LIVE running status, but only an
// inter-agent reply can ever be collapsed. Dispatching on that first keeps
// the status subscription out of the standard path entirely — the standard
// message root now re-renders for content, never for a pending flip.
return interAgentSender ? (
<InterAgentAssistantMessage {...props} sender={interAgentSender} />
) : (
<AssistantMessageBody {...props} />
)
}
/** The compact stand-in a settled inter-agent reply collapses to (Grok-bots
* parity — the transcript shows the event; the text is one click away). */
const InterAgentCollapsedNotice: FC<{ sender: string }> = ({ sender }) => (
<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 justify-center gap-1.5">
<Codicon className="shrink-0 text-muted-foreground/55" name="arrow-small-right" size="0.8125rem" />
<span className="wrap-anywhere">Replied to {sender}</span>
</span>
<details className="self-center">
<summary className="cursor-pointer select-none text-center text-muted-foreground/45 hover:text-muted-foreground/70">
show reply
</summary>
<div className="mt-1 max-w-[36rem] rounded-lg border border-(--ui-stroke-tertiary) px-3 py-2 text-left text-[0.75rem] leading-5 text-foreground/85">
{MESSAGE_PARTS}
</div>
</details>
</div>
)
/**
* An assistant reply that answers an inter-agent delivery. Owns the only
* root-level `isRunning` subscription left in this file, and it is confined to
* the rare inter-agent case: the reply renders collapsed once it settles, so
* the gate genuinely needs live status. Never collapse while streaming — the
* user should see progress.
*
* The collapse is expressed as a CHILD of the normal body, not as a competing
* root. Returning a bare MessagePrimitive.Root here for the settled case put a
* different element type in this position than the running case
* (AssistantMessageBody), so settling unmounted the whole row and mounted a
* fresh one — throwing away the DOM the scroll anchor was holding, which can
* jump the transcript under the reader. One component, one root, children
* vary: settling is now a prop change React applies in place.
*/
const InterAgentAssistantMessage: FC<AssistantMessageProps & { sender: string }> = ({ sender, ...props }) => {
const isRunning = useAuiState(s => s.message.status?.type === 'running')
return (
<AssistantMessageBody
{...props}
collapsedNotice={isRunning ? null : <InterAgentCollapsedNotice sender={sender} />}
/>
)
}
const AssistantMessageBody: FC<AssistantMessageProps & { collapsedNotice?: null | ReactNode }> = ({
collapsedNotice = null,
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, and no
// longer subscribes to the streaming STATUS either. Every selector here
// returns a value that stays referentially stable across token flushes
// (booleans, '' while running), so the 30 Hz delta stream only re-renders
// the markdown part and the tiny status leaves — not the footer, the
// preview block, or this root.
const hasVisibleText = useAuiState(s => contentHasVisibleText(s.message.content))
// Sealed mid-turn commentary keeps its text but not the footer, so a
// tool-heavy turn doesn't grow a copy/refresh bar per paragraph (see
// ChatMessage.interim).
const isInterim = useAuiState(s => s.message.metadata?.custom?.interim === true)
// Whole-turn wall-clock seconds (set once at completion — referentially
// stable across the 30 Hz delta stream, so this adds no per-token renders).
const turnDurationS = useAuiState(s => s.message.metadata?.custom?.durationS as number | undefined)
const getMessageText = useCallback(() => messageContentText(messageRuntime.getState().content), [messageRuntime])
// useEnterAnimation consults `enabled` ONLY when its callback ref fires,
// i.e. at mount: the hook parks the value in a ref and returns a
// useCallback([]) identity, and its own contract is "`enabled` is captured
// at mount-time only — flipping it later doesn't suddenly play the animation
// on existing nodes" (see lib/use-enter-animation.ts). So a live
// subscription here would re-render this root on every pending flip to feed
// a value the hook already ignores. Capture it once, off the runtime, with
// no subscription at all.
const [initiallyRunning] = useState(() => messageRuntime.getState().status?.type === 'running')
const enterRef = useEnterAnimation(initiallyRunning, `assistant-message:${messageId}`)
// Double-click the reply to heart it (iMessage). Undefined while reactions
// are off, so the root carries no listener at all.
const onDoubleClick = useTapbackDoubleClick(messageId, 'assistant')
return (
<MessagePrimitive.Root
className={cn(
'group flex w-full min-w-0 max-w-full flex-col gap-0 self-start overflow-hidden',
collapsedNotice && 'pb-(--conversation-turn-gap)'
)}
data-role="assistant"
data-slot="aui_assistant-message-root"
// Collapsed inter-agent rows never carried the tapback listener; keeping
// that exact truth table means gating it on the notice rather than on
// whether the hook returned a handler.
onDoubleClick={collapsedNotice ? undefined : onDoubleClick}
ref={enterRef}
>
{collapsedNotice ?? (
<>
<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. */}
{MESSAGE_PARTS}
<AssistantStatusSlot />
<AssistantPreviewEmbeds />
<MessagePrimitive.Error>
<ErrorPrimitive.Root
className="mt-1.5 flex flex-col gap-1.5 rounded-lg border border-[color-mix(in_srgb,var(--dt-destructive)_35%,transparent)] bg-[color-mix(in_srgb,var(--dt-destructive)_7%,transparent)] px-3 py-2 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
role="alert"
>
<div className="flex items-start gap-1.5">
<div className="min-w-0 flex-1">
<ErrorLayerLabel />
<ErrorPrimitive.Message className="min-w-0" />
</div>
{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>
)}
</div>
<ErrorRecoveryActions />
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
</div>
<MessageTimelineTimestamp className="px-(--message-text-indent) pt-0.5" suppressIfDuplicatePart />
{hasVisibleText && !isInterim && (
<AssistantFooter
durationS={turnDurationS}
getMessageText={getMessageText}
messageId={messageId}
onBranchInNewChat={onBranchInNewChat}
/>
)}
{/* Last thing in the turn — under the action bar, the way Cursor ends a
turn on its summary rather than burying it above the controls. */}
<SettledChangedFiles />
<StreamingMarker />
</>
)}
</MessagePrimitive.Root>
)
}
/**
* PERF leaf: the only subscriber to this message's streaming status inside the
* message content. Previously `messageStatus` / `isPlaceholder` /
* `isLastMessage` were read by AssistantMessage itself, so every pending flip
* re-rendered the whole message subtree — at stream breadth N, N subtrees in a
* single commit, which is what widened the recalc scope. Reading them here
* confines the flip to this leaf; the sibling parts subtree is a hoisted
* constant element and bails out.
*
* Behaviour is byte-identical to the old inline expression, including the
* TAIL-ONLY rule: the activity row belongs to the tail of the thread, period.
* A stale pending bubble mid-transcript (a turn that ended without its settle
* event, a steer race) must never show one — a spinner above a later user
* message reads as the agent answering out of order.
*
* The activity row is mounted by the TAIL of the thread and decides for itself
* whether the turn owes the user a line, so there is deliberately no
* `isRunning` gate on the mount here. Gating it on this bubble's own `running`
* status was the hole: a turn that seals a bubble mid-flight (message.interim)
* or finishes one while the agent keeps going leaves a settled message at the
* tail, so the row unmounted and the seconds went uncounted while the
* composer's arc border and Stop button said work was still happening.
* TurnActivityIndicator subscribes to the status it needs internally, so it is
* itself a leaf and this stays off the message root either way.
*/
const AssistantStatusSlot: FC = () => {
// ONE subscription, not one per input. Each useAuiState is a separate store
// subscription with its own equality check and its own chance to schedule a
// render, and these inputs always move together on a status flip — so
// reading them separately just multiplies the wake-ups for a single logical
// change. The selector collapses them to one stable string, which bails out
// on every flush that does not actually change what this slot renders.
const slot = useAuiState(s => {
if (s.thread.messages[s.thread.messages.length - 1]?.id !== s.message.id) {
return 'none'
}
return s.message.status?.type === 'running' && s.message.content.length === 0 ? 'placeholder' : 'activity'
})
if (slot === 'none') {
return null
}
return slot === 'placeholder' ? <ResponseLoadingIndicator /> : <TurnActivityIndicator />
}
/**
* PERF leaf: owns the settled-text selector that feeds the link previews.
*
* This was the last status-dependent read at the message root, and the most
* expensive one: the selector flips between '' while running and the full
* `messageContentText(content)` join once settled, so every running <-> settled
* transition re-ran the join for the whole message AND re-rendered the root.
* At stream breadth N that is N joins plus N root re-renders per flip. Reading
* it here confines both to this leaf, which renders nothing at all in the
* common case.
*
* The streaming-side optimization is unchanged and still the point of the ''
* branch: preview targets only materialize once the turn completes, so while
* running the selector returns a stable '' and per-token flushes skip the
* regex scan and the re-render it would cause.
*
* Renders exactly what the root used to render at this position — the same
* wrapper div with the same classes, or nothing when there are no targets —
* so the DOM is byte-identical either way. A component boundary adds no node
* of its own, so unlike StreamingMarker this needs no placement care.
*/
const AssistantPreviewEmbeds: FC = () => {
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])
if (previewTargets.length === 0) {
return null
}
return (
<div className="mt-3 flex flex-wrap gap-2">
{previewTargets.map(target => (
<PreviewAttachment key={target} source="explicit-link" target={target} />
))}
</div>
)
}
/**
* PERF leaf: owns the `settledParts` selector so the tail's settle stops
* re-rendering the message root. This is the one status-derived selector that
* returns an OBJECT (`s.message.parts`) rather than a primitive, so it cannot
* bail out on identity churn — keeping it at the root meant every settle
* re-rendered the root and everything under it.
*
* Cursor's changed-files card only appears once the turn settles: while the
* agent is still editing, the tool rows narrate each patch and a card that
* grew a row per write would thrash the transcript. `EMPTY_PARTS` while
* running keeps this selector referentially stable across the 30 Hz delta
* stream.
*
* It also only rides the LAST turn. The card is a "here's what just landed"
* summary, not a per-turn artifact: leaving one behind on every reply would
* stack a wall of stale cards down the transcript. Sending the next message
* retires it — the working tree it describes is already history by then.
*/
const SettledChangedFiles: FC = () => {
const settledParts = useAuiState(s => {
const isLastMessage = s.thread.messages[s.thread.messages.length - 1]?.id === s.message.id
return s.message.status?.type === 'running' || !isLastMessage ? EMPTY_PARTS : s.message.parts
})
return <ChangedFilesCard parts={settledParts} />
}
/**
* Carries the streaming flag that used to sit on the message root as
* `data-streaming`.
*
* The flag has no CSS behind it (every `[data-streaming='true']` rule targets
* `[data-slot='code-card']`), but it is not dead: it is the settled-row signal
* for the short-session hang repro, which derives the settled count by
* subtracting the number of `[data-message-streaming='true']` markers from the
* number of message roots, and gates the assistant-response wait on that count
* growing. At most one marker per row carries the attribute, which is what
* makes the subtraction exact.
*
* Deliberately NOT named `data-streaming`: shiki-highlighter.tsx puts that
* exact attribute on a deferred `[data-slot='code-card']`, which is a
* descendant of this root. Once the repro matches on a descendant rather than
* the root's own attribute, a shared name would make any message holding a
* still-deferred code card read as "still streaming". A distinct name keeps
* the signal about the MESSAGE and immune to how deep it sits.
*
* On the root it was a per-flip attribute write on the element that owns the
* whole message subtree, which is the invalidation this prong exists to remove.
* Three properties make this placement cheap and behaviour-neutral:
*
* - A ROOT-LEVEL sibling, not a child of the message content. The
* `:first-child` / `:last-child` margin rules in styles.css match blocks
* *inside* `[data-slot='aui_assistant-message-content']`; a node added
* there would steal `:last-child` from the status indicator and silently
* change the gap between bubbles mid-stream. No rule selects message-root
* children by position, so this slot is inert.
* - PERMANENTLY MOUNTED, toggling only the attribute. Mounting/unmounting per
* flip would be a DOM structure change and dirty its siblings; an attribute
* write on a childless node invalidates exactly one element.
* - `display: none`, so it costs no layout or paint. `querySelectorAll` and
* `:has()` still match it — they read the DOM, not the box tree.
*
* Tracks plain `isRunning` (not tail-only), exactly like the old root
* attribute, so the repro's row accounting is unchanged.
*/
const StreamingMarker: FC = () => {
const isRunning = useAuiState(s => s.message.status?.type === 'running')
return (
<span
aria-hidden="true"
className="hidden"
data-message-streaming={isRunning ? 'true' : undefined}
data-slot="aui_message-streaming-marker"
/>
)
}
// ── Layered error card pieces ────────────────────────────────────────────
//
// The gateway stamps failed turns with a structured {layer, code, retryable}
// descriptor (metadata.custom.errorSurface — see agent/error_surface.py).
// These leaves render the layer label + recovery actions. Older backends
// never send the descriptor: the label falls back to a generic title and the
// action row still offers Retry / Open Logs / Copy error details, so nothing
// regresses on version skew.
const ErrorLayerLabel: FC = () => {
const { t } = useI18n()
const surface = useAuiState(s => s.message.metadata?.custom?.errorSurface as ErrorSurface | undefined)
const labels = t.assistant.thread.errorLayers
const label = (surface && labels[surface.layer]) || labels.generic
return <div className="font-medium">{label}</div>
}
// Isolated because useNavigate() THROWS outside a <Router> (bare test
// harnesses, embedded panes render threads router-free). The parent gates
// this child's mount on useInRouterContext(), which is safe anywhere.
const SwitchProviderAction: FC<{ label: string }> = ({ label }) => {
const navigate = useNavigate()
return (
<button className="aui-error-action" onClick={() => navigate(`${SETTINGS_ROUTE}?tab=config:model`)} type="button">
{label}
</button>
)
}
const ErrorRecoveryActions: FC = () => {
const { t } = useI18n()
const copy = t.assistant.thread
const surface = useAuiState(s => s.message.metadata?.custom?.errorSurface as ErrorSurface | undefined)
const errorText = useAuiState(s => {
const status = s.message.status as { error?: unknown; type?: string } | undefined
return status?.type === 'incomplete' && typeof status.error === 'string' ? status.error : ''
})
// useNavigate() would throw here when no Router is above us; the deep-link
// child mounts only when one is (see SwitchProviderAction).
const inRouter = useInRouterContext()
const model = useStore($currentModel)
const connection = useStore($connection)
// Open Logs reveals the LOCAL Electron profile's HERMES_HOME/logs. On a
// remote/cloud connection the failed turn's gateway+agent logs live on the
// remote box — the local folder only holds Desktop-side transport logs, so
// the label says "Open Desktop logs" there instead of implying it opens the
// runtime's logs.
const remoteConnection = connection?.mode === 'remote'
// Retry = assistant-ui reload (same wiring as the footer's refresh action):
// re-runs the failed turn's prompt in place. Suppressed when the classifier
// says the failure is deterministic (retrying reproduces it).
const retryable = !surface || surface.retryable
// Switch Provider deep-links Settings → Models for the layers where the fix
// is provider/endpoint/auth config, not a retry.
const showSwitchProvider = surface != null && ['auth', 'billing', 'endpoint', 'provider'].includes(surface.layer)
const openLogs = useCallback(async () => {
try {
const root = await window.hermesDesktop?.logsRoot?.()
if (!root) {
notifyError(new Error('logs root unavailable'), copy.errorOpenLogsFailed)
return
}
const result = await window.hermesDesktop?.openDir?.(root)
if (result && !result.ok) {
notifyError(new Error(result.error || 'open failed'), copy.errorOpenLogsFailed)
}
} catch (error) {
notifyError(error, copy.errorOpenLogsFailed)
}
}, [copy.errorOpenLogsFailed])
const diagnosticsText = useCallback(
() =>
formatErrorDiagnostics({
errorText,
model: model || undefined,
surface
}),
[errorText, model, surface]
)
return (
<div className="flex flex-wrap items-center gap-1.5">
{retryable && (
<ActionBarPrimitive.Reload asChild>
<button className="aui-error-action" onClick={() => triggerHaptic('submit')} type="button">
<RefreshCwIcon className="size-3" />
{copy.errorRetry}
</button>
</ActionBarPrimitive.Reload>
)}
{showSwitchProvider && inRouter && <SwitchProviderAction label={copy.errorSwitchProvider} />}
{window.hermesDesktop?.logsRoot && (
<button className="aui-error-action" onClick={() => void openLogs()} type="button">
{remoteConnection ? copy.errorOpenDesktopLogs : copy.errorOpenLogs}
</button>
)}
<button className="aui-error-action" onClick={() => requestSendDiagnostics(diagnosticsText())} type="button">
<Upload className="size-3" />
{copy.errorSendDiagnostics}
</button>
<CopyButton
appearance="inline"
className="aui-error-action"
label={copy.errorCopyDiagnostics}
text={diagnosticsText}
/>
</div>
)
}
const AssistantActionBar: FC<MessageActionProps & { durationS?: number }> = ({
durationS,
messageId,
getMessageText,
onBranchInNewChat
}) => {
const { t } = useI18n()
const copy = t.assistant.thread
const [pickerOpen, setPickerOpen] = useState(false)
const { enabled: reactionsEnabled, react, reactions: shownReactions } = useMessageReactions(messageId, 'assistant')
const pickEmoji = useCallback(
(emoji: null | string) => {
setPickerOpen(false)
react(emoji)
},
[react]
)
return (
<div className="relative flex w-full shrink-0 items-center justify-end gap-1.5">
{durationS !== undefined && (
<span
className="mr-auto select-none px-0.5 text-[0.6875rem] leading-5 tabular-nums text-muted-foreground"
data-slot="aui_turn-duration"
title={t.assistant.thread.turnDuration(formatElapsed(durationS))}
>
{formatElapsed(durationS)}
</span>
)}
<ActionBarPrimitive.Root
className={
// 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-1.5 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'
}
data-slot="aui_msg-actions"
>
{onBranchInNewChat && (
<TooltipIconButton
onClick={() => {
triggerHaptic('selection')
onBranchInNewChat(messageId)
}}
tooltip={copy.branchNewChat}
>
<GitForkIcon className="size-3.5" />
</TooltipIconButton>
)}
<CopyButton appearance="icon" buttonSize="icon" label={copy.copy} text={getMessageText} />
<ReadAloudButton getText={getMessageText} messageId={messageId} />
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip={copy.refresh}>
<RefreshCwIcon className="size-3.5" />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
</ActionBarPrimitive.Root>
{/* ONE slot, Slack-style: the picker trigger and the landed reaction are
the same element, so reacting never shifts layout. Empty → ☺, hidden
until hover like its action-bar neighbors (state lives in styles.css
— the aui_msg-reactions rules outweigh Tailwind utilities here).
Reacted → the emoji itself, always visible at full strength, and
clicking it reopens the picker to switch or retract. Outside
ActionBarPrimitive.Root so a landed reaction doesn't ride the bar's
hover opacity. */}
{(reactionsEnabled || shownReactions.length > 0) && (
<ReactionPicker
onOpenChange={setPickerOpen}
onSelect={pickEmoji}
open={pickerOpen}
selected={shownReactions.find(reaction => reaction.author === 'user')?.emoji}
>
<TooltipIconButton
data-reacted={shownReactions.length > 0 || undefined}
data-slot="aui_msg-reactions"
data-state={pickerOpen ? 'open' : undefined}
onClick={reactionsEnabled ? () => setPickerOpen(open => !open) : undefined}
tooltip={copy.react}
>
{shownReactions.length > 0 ? (
<span className="flex items-center gap-0.5 text-[0.8125rem] leading-none">
{shownReactions.map(reaction => (
<span className="reaction-pop" key={`${reaction.author}-${reaction.emoji}`}>
{reaction.emoji}
</span>
))}
</span>
) : (
<SmilePlusIcon className="size-3.5" />
)}
</TooltipIconButton>
</ReactionPicker>
)}
</div>
)
}
const ReadAloudButton: FC<{ getText: () => string; messageId: string }> = ({ getText, messageId }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const voicePlayback = useStore($voicePlayback)
const view = useSessionView()
const sessionId = useStore(view.$runtimeId)
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 : AudioLines
const tooltip = isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud
const read = useCallback(async () => {
const text = getText()
if (!text || $voicePlayback.get().status !== 'idle') {
return
}
try {
await playSpeechText(text, { messageId, source: 'read-aloud' })
markAssistantIdSpoken(sessionId, view.$messages.get(), messageId)
} catch (error) {
notifyError(error, copy.readAloudFailed)
}
}, [copy.readAloudFailed, getText, messageId, sessionId, view.$messages])
return (
<TooltipIconButton
disabled={isPreparing || (!isSpeaking && anyPlaybackActive)}
onClick={() => {
triggerHaptic('selection')
void (isSpeaking ? stopVoicePlayback() : read())
}}
tooltip={tooltip}
>
<Icon className={cn('size-3.5', isPreparing && 'animate-spin')} />
</TooltipIconButton>
)
}
const AssistantFooter: FC<MessageActionProps & { durationS?: number }> = ({ durationS, ...props }) => {
return (
<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 durationS={durationS} {...props} />
</div>
)
}
@@ -0,0 +1,102 @@
// Lists and blockquotes have chrome beside the text (markers, the quote
// border) whose side is driven by the box's CSS direction, which the
// unicode-bidi:plaintext rules never touch. These tests pin the split of
// responsibilities: ul/ol/blockquote carry dir="auto" so the browser
// resolves their box direction from content, inline code carries dir="ltr"
// so it neither votes in that resolution nor reorders, and plain prose
// blocks stay attribute-free (the plaintext CSS owns them). jsdom does not
// resolve dir="auto", so the contract is asserted at the attribute level.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { stubThreadEnvironment, stubThreadViewportSize } from '../test-utils'
import { Thread } from '.'
const createdAt = new Date('2026-06-01T00:00:00.000Z')
stubThreadEnvironment()
stubThreadViewportSize()
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'hi' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(text: string): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function Harness({ text }: { text: string }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage(text)],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('block-level direction chrome', () => {
it('lists carry dir="auto" so markers follow the resolved direction', async () => {
render(<Harness text={'מקומות:\n\n1. חוף גורדון\n2. שוק הכרמל\n\n- פריט\n- item'} />)
const item = await screen.findByText(/חוף גורדון/)
expect(item.closest('ol')?.getAttribute('dir')).toBe('auto')
const bullet = await screen.findByText(/פריט/)
expect(bullet.closest('ul')?.getAttribute('dir')).toBe('auto')
})
it('blockquotes carry dir="auto" so the border follows the resolved direction', async () => {
render(<Harness text={'> ציטוט קצר בעברית'} />)
const quote = await screen.findByText(/ציטוט קצר/)
expect(quote.closest('blockquote')?.getAttribute('dir')).toBe('auto')
})
it('inline code carries dir="ltr" so it does not vote in dir="auto" resolution', async () => {
render(<Harness text={'1. `npm install` מתקין תלויות'} />)
const code = await screen.findByText('npm install')
expect(code.tagName).toBe('CODE')
expect(code.getAttribute('dir')).toBe('ltr')
expect(code.closest('ol')?.getAttribute('dir')).toBe('auto')
})
it('plain prose blocks stay attribute-free (plaintext CSS owns them)', async () => {
render(<Harness text={'שלום לכולם'} />)
const paragraph = await screen.findByText(/שלום לכולם/)
expect(paragraph.closest('p')?.hasAttribute('dir')).toBe(false)
})
})
@@ -0,0 +1,75 @@
import { useStore } from '@nanostores/react'
import { type FC, useMemo } from 'react'
import { useComposerScope } from '@/app/chat/composer/scope'
import { useSessionView } from '@/app/chat/session-view'
import { deriveChangedFiles } from '@/components/assistant-ui/thread/changed-files'
import { WIDGET_SHELL_CLASS } from '@/components/chat/widget-shell'
import { DiffCount } from '@/components/ui/diff-count'
import { FadeScroll } from '@/components/ui/fade-scroll'
import { FileTypeIcon } from '@/components/ui/file-type-icon'
import { useI18n } from '@/i18n'
import { displayPath } from '@/lib/display-path'
import { cn } from '@/lib/utils'
import { openReviewForPath, revealReview } from '@/store/review'
// ~5 rows. A turn that rewrites twenty files should still read as one card in
// the transcript, not a wall the user has to scroll past to reach the composer.
const MAX_ROWS_HEIGHT = '9.375rem'
/**
* Cursor-style "N files changed" summary closing out the newest assistant turn:
* one row per file it edited with that file's +/-, and a Review action opening
* the diff pane (⌘G). A row click opens that file's diff directly.
*
* Wears the shared `WIDGET_SHELL_CLASS` so it reads as the same panel as the
* transcript's other inline widgets rather than inventing its own chrome.
*/
export const ChangedFilesCard: FC<{ parts: readonly unknown[] }> = ({ parts }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const files = useMemo(() => deriveChangedFiles(parts), [parts])
// Review THIS surface's repo: a tile transcript pins the pane to the tile's
// worktree; the primary passes null (follow the active session, as before).
const view = useSessionView()
const viewCwd = useStore(view.$cwd)
const scopeCwd = view.kind === 'primary' ? null : viewCwd || null
const composerScope = useComposerScope()
if (files.length === 0) {
return null
}
return (
<div
className={cn(WIDGET_SHELL_CLASS, 'mt-1.5 text-[length:var(--conversation-tool-font-size)]')}
data-slot="aui_changed-files"
>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-(--ui-text-primary)">{copy.filesChanged(files.length)}</span>
<button
className="shrink-0 cursor-pointer text-(--ui-text-tertiary) transition-colors hover:text-(--ui-text-primary)"
onClick={() => revealReview(scopeCwd, composerScope.target)}
type="button"
>
{copy.reviewChanges}
</button>
</div>
<FadeScroll className="-mx-1.5 mt-1.5 flex flex-col px-1.5" maxHeight={MAX_ROWS_HEIGHT}>
{files.map(file => (
<button
className="row-hover flex shrink-0 items-center gap-2 rounded-md px-1.5 py-1 text-left"
key={file.path}
onClick={() => void openReviewForPath(file.path, scopeCwd, composerScope.target)}
title={displayPath(file.path)}
type="button"
>
<FileTypeIcon className="shrink-0 text-(--ui-text-tertiary)" path={file.path} size="0.875rem" />
<span className="min-w-0 flex-1 truncate text-(--ui-text-secondary)">{file.name}</span>
<DiffCount added={file.added} removed={file.removed} />
</button>
))}
</FadeScroll>
</div>
)
}
@@ -0,0 +1,69 @@
// Pure derivation for the assistant message's "N files changed" card: fold a
// turn's file-edit tool parts into one row per file. No React/DOM.
import {
countDiffLineStats,
fileEditBasename,
fileEditPath,
inlineDiffFromResult,
isFileEditTool,
parseMaybeObject
} from '@/components/assistant-ui/tool/fallback-model'
export interface ChangedFile {
added: number
/** Basename, for the row label. */
name: string
/** Path exactly as the tool reported it (absolute or repo-relative). */
path: string
removed: number
}
interface ChangedFilePart {
args?: unknown
result?: unknown
toolName?: unknown
type?: unknown
}
/**
* One row per file the turn edited, in first-touched order, with the +/- of
* every edit to that file summed. Only landed edits with a diff count: a call
* still running has no result, and a failed one changed nothing.
*/
export function deriveChangedFiles(parts: readonly unknown[]): ChangedFile[] {
const byPath = new Map<string, ChangedFile>()
for (const raw of parts) {
const part = (raw ?? {}) as ChangedFilePart
if (part.type !== 'tool-call' || typeof part.toolName !== 'string' || !isFileEditTool(part.toolName)) {
continue
}
const result = parseMaybeObject(part.result)
const diff = inlineDiffFromResult(result)
if (!diff) {
continue
}
const path = fileEditPath(parseMaybeObject(part.args), result)
if (!path) {
continue
}
const stats = countDiffLineStats(diff)
const existing = byPath.get(path)
if (existing) {
existing.added += stats.added
existing.removed += stats.removed
} else {
byPath.set(path, { added: stats.added, name: fileEditBasename(path), path, removed: stats.removed })
}
}
return [...byPath.values()]
}
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import {
contentHasVisibleText,
messageAttachmentRefs,
messageContentText,
partText,
pickPrimaryPreviewTarget
} from './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'])
})
})
@@ -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]]
}
@@ -0,0 +1,94 @@
// Double-click an assistant reply to heart it (the iMessage gesture), gated on
// the same opt-in toggle as the rest of message reactions.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ReactionsStore from '@/store/reactions'
import { $reactionsEnabled } from '@/store/reactions-enabled'
import { $localReactions } from '@/store/reactions-local'
import { assistantMessage, stubThreadEnvironment } from '../test-utils'
import { isTapbackDoubleClick } from './use-message-reactions'
import { Thread } from '.'
stubThreadEnvironment()
// The gesture persists through the gateway; this suite is about the local
// paint, which is what the user actually sees on the click.
vi.mock('@/store/reactions', async importOriginal => ({
...(await importOriginal<typeof ReactionsStore>()),
toggleMessageReaction: vi.fn(async () => {})
}))
function Harness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantMessage()],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
beforeEach(() => {
$localReactions.set({})
$reactionsEnabled.set(false)
})
afterEach(() => {
cleanup()
})
describe('isTapbackDoubleClick', () => {
it('claims a plain double-click on message body', () => {
expect(isTapbackDoubleClick({ detail: 2, target: document.createElement('span') })).toBe(true)
})
it('ignores a triple-click, so selecting a paragraph does not re-toggle', () => {
expect(isTapbackDoubleClick({ detail: 3, target: document.createElement('span') })).toBe(false)
})
it('leaves double-click alone where it already means something', () => {
const code = document.createElement('pre')
const inner = document.createElement('code')
code.append(inner)
expect(isTapbackDoubleClick({ detail: 2, target: inner })).toBe(false)
expect(isTapbackDoubleClick({ detail: 2, target: document.createElement('a') })).toBe(false)
expect(isTapbackDoubleClick({ detail: 2, target: document.createElement('button') })).toBe(false)
})
})
describe('double-click to heart an assistant message', () => {
it('hearts the message, and a second double-click retracts it', async () => {
$reactionsEnabled.set(true)
render(<Harness />)
const message = (await screen.findByText('done')).closest('[data-slot="aui_assistant-message-root"]')
expect(message).toBeTruthy()
fireEvent.doubleClick(message!, { detail: 2 })
await waitFor(() => expect($localReactions.get()['assistant-1']?.[0]?.emoji).toBe('❤️'))
fireEvent.doubleClick(message!, { detail: 2 })
await waitFor(() => expect($localReactions.get()['assistant-1']).toEqual([]))
})
it('does nothing while reactions are off', async () => {
render(<Harness />)
const message = (await screen.findByText('done')).closest('[data-slot="aui_assistant-message-root"]')
fireEvent.doubleClick(message!, { detail: 2 })
expect($localReactions.get()['assistant-1']).toBeUndefined()
})
})
@@ -0,0 +1,215 @@
// Loading and activity indicators mount only on the thread's last message.
// The tail-only gate from ba756333 keeps non-tail running bubbles silent,
// including assistants followed only by a user or system row. The optimistic
// placeholder flow renders exactly one status row. These contracts pin the
// duplicate-indicator regression tracked in #68634.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { __resetElapsedTimerRegistryForTests } from '@/components/chat/activity-timer'
import { setSessionCompacting } from '@/store/compaction'
import { $activeSessionId, $turnStartedAt } from '@/store/session'
import { stubThreadEnvironment, stubThreadViewportSize, userMessage } from '../test-utils'
import { Thread } from '.'
// Layout/observer stubs mirrored from streaming.test.tsx. jsdom has no
// ResizeObserver, rAF, or real layout, and the Thread scroll container needs
// non-zero dimensions to mount without throwing.
stubThreadEnvironment()
stubThreadViewportSize()
const createdAt = new Date('2026-05-01T00:00:00.000Z')
const sessionId = 'session-68634'
// This shape mirrors the `/steer` note appended by appendSessionTextMessage
// in apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts.
function systemMessage(id: string, text: string): ThreadMessage {
return {
id,
role: 'system',
content: [{ type: 'text', text }],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function runningAssistantMessage(id: string, text: string): ThreadMessage {
return {
id,
role: 'assistant',
content: [{ type: 'text', text }],
status: { type: 'running' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function Harness({ messages, isRunning = false }: { messages: ThreadMessage[]; isRunning?: boolean }) {
// isRunning: false at the runtime level. Per-message `status: {type:
// 'running'}` is what drives TurnActivityIndicator mounting.
// Passing isRunning: true makes useExternalStoreRuntime auto-append a
// synthetic empty trailing assistant placeholder whenever the last message
// is not already a running assistant, such as the trailing user prompt
// cases below. That is the real production flow. The isRunning:true tests
// prove that the placeholder is treated as the tail and renders its own
// loading row while the real bubble's activity row stays silent.
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages,
isRunning,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('TurnActivityIndicator tail gating (#68634)', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
__resetElapsedTimerRegistryForTests()
$activeSessionId.set(sessionId)
$turnStartedAt.set(Date.now())
setSessionCompacting(sessionId, true)
})
afterEach(() => {
cleanup()
setSessionCompacting(sessionId, false)
$activeSessionId.set(null)
$turnStartedAt.set(null)
__resetElapsedTimerRegistryForTests()
vi.useRealTimers()
})
it('renders exactly one indicator, on the later bubble, when two assistant bubbles are running with content', () => {
const { container } = render(
<Harness
messages={[
userMessage('user-1', 'Summarize this thread for me'),
runningAssistantMessage('assistant-1', 'Working on it'),
userMessage('user-2', 'hola?'),
runningAssistantMessage('assistant-2', 'On it too')
]}
/>
)
act(() => {
vi.advanceTimersByTime(5_000)
})
const indicators = screen.getAllByRole('status', { name: 'Summarizing thread' })
expect(indicators.length).toBe(1)
const roots = container.querySelectorAll('[data-slot="aui_assistant-message-root"]')
expect(roots.length).toBe(2)
// The second assistant root is also the thread's any-role tail.
expect(roots[0]?.querySelector('[data-slot="aui_turn-activity"]')).toBeNull()
expect(roots[1]?.querySelector('[data-slot="aui_turn-activity"]')).not.toBeNull()
})
it('keeps a running assistant silent when a queued user prompt trails it and the runtime is idle', () => {
const { container } = render(
<Harness
messages={[
userMessage('user-1', 'Summarize this thread for me'),
runningAssistantMessage('assistant-1', 'Working on it'),
userMessage('user-2', 'hola?')
]}
/>
)
act(() => {
vi.advanceTimersByTime(5_000)
})
expect(container.querySelector('[data-slot="aui_response-loading"]')).toBeNull()
expect(container.querySelector('[data-slot="aui_turn-activity"]')).toBeNull()
})
it('keeps a running assistant silent when a steer system note trails it and the runtime is idle', () => {
const { container } = render(
<Harness
messages={[
userMessage('user-1', 'Summarize this thread for me'),
runningAssistantMessage('assistant-1', 'Working on it'),
systemMessage('system-steer-1', 'steer:focus on the errors')
]}
/>
)
act(() => {
vi.advanceTimersByTime(5_000)
})
expect(container.querySelector('[data-slot="aui_response-loading"]')).toBeNull()
expect(container.querySelector('[data-slot="aui_turn-activity"]')).toBeNull()
})
// In the production flow, isRunning: true with a trailing queued user prompt
// makes the runtime append an empty optimistic assistant placeholder after
// the real running bubble. The placeholder renders ResponseLoadingIndicator.
// During compaction, that row carries the same accessible label as the stall
// indicator. The real non-tail bubble must remain silent so there is exactly
// one status row.
it('still renders the indicator when the runtime appends an optimistic placeholder (isRunning:true)', () => {
render(
<Harness
isRunning
messages={[
userMessage('user-1', 'Summarize this thread for me'),
runningAssistantMessage('assistant-1', 'Working on it'),
userMessage('user-2', 'hola?')
]}
/>
)
act(() => {
vi.advanceTimersByTime(5_000)
})
const indicators = screen.getAllByRole('status', { name: 'Summarizing thread' })
expect(indicators.length).toBe(1)
// The surviving row belongs to the placeholder, while the real running
// bubble's stall row stays silent.
expect(document.querySelectorAll('[data-slot="aui_response-loading"]').length).toBe(1)
expect(document.querySelectorAll('[data-slot="aui_turn-activity"]').length).toBe(0)
})
// Outside compaction, the placeholder uses the plain loading label and the
// real bubble's stall row must remain silent after the stall threshold.
it('keeps a single status row for the placeholder outside compaction (isRunning:true)', () => {
setSessionCompacting(sessionId, false)
render(
<Harness
isRunning
messages={[
userMessage('user-1', 'Summarize this thread for me'),
runningAssistantMessage('assistant-1', 'Working on it'),
userMessage('user-2', 'hola?')
]}
/>
)
act(() => {
vi.advanceTimersByTime(5_000)
})
expect(document.querySelectorAll('[data-slot="aui_response-loading"]').length).toBe(1)
expect(document.querySelectorAll('[data-slot="aui_turn-activity"]').length).toBe(0)
})
})
@@ -0,0 +1,130 @@
// Thread deliberately keeps cwd/gateway/sessionId OUT of the messageComponents
// memo deps: those values change on every session switch, and reminting the
// component types mid-switch remounts the whole outgoing transcript. The
// mounted edit composer still has to see a same-session change (e.g. a cwd
// remap). It used to read the values from a render-time ref, but a mounted
// composer never re-reads the ref when the change leaves every
// ThreadMessageList prop referentially equal (Thread and ThreadMessageList
// are both memo'd, so the wrapper never re-renders). The values now travel
// through ThreadEditContext, whose propagation reaches the mounted consumer
// through the memo bail-out. These tests pin both directions: the composer
// sees the change, and the transcript still does not remount.
import { ExportedMessageRepository } from '@assistant-ui/react'
import { AssistantRuntimeProvider, type ThreadMessage } from '@assistant-ui/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { assistantMessage, stubThreadEnvironment, stubThreadViewportSize, userMessage } from '../test-utils'
import { Thread } from '.'
interface MockComposerProps {
cwd: string | null
gateway: unknown
sessionId: string | null
}
const composerRenders = vi.hoisted(() => [] as MockComposerProps[])
vi.mock('./user-edit-composer', () => ({
UserEditComposer: (props: MockComposerProps) => {
composerRenders.push(props)
return <div data-testid="edit-composer">{props.cwd}</div>
}
}))
stubThreadEnvironment()
afterEach(() => {
cleanup()
})
beforeEach(() => {
composerRenders.length = 0
})
stubThreadViewportSize()
const noopAsync = async () => {}
// The repository must stay referentially stable across rerenders: a new
// object would make the incremental runtime resync the transcript and
// unmount the open composer, defeating the test.
function Harness({ cwd, sessionKey }: { cwd: string; sessionKey: string }) {
const [repository] = useState(() => ExportedMessageRepository.fromArray([userMessage(), assistantMessage()]))
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: repository,
isRunning: false,
setMessages: () => {},
onNew: noopAsync,
onEdit: noopAsync,
onCancel: noopAsync,
onReload: noopAsync
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread cwd={cwd} sessionKey={sessionKey} />
</AssistantRuntimeProvider>
)
}
describe('thread edit context', () => {
it('passes a same-session cwd change to the mounted edit composer', async () => {
const { rerender } = render(<Harness cwd="/old" sessionKey="k1" />)
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
await screen.findByTestId('edit-composer')
expect(composerRenders.at(-1)?.cwd).toBe('/old')
// Same session, same messages: every ThreadMessageList prop stays
// referentially equal, so only context propagation can reach the
// mounted composer.
await act(async () => {
rerender(<Harness cwd="/new" sessionKey="k1" />)
})
expect(composerRenders.at(-1)?.cwd).toBe('/new')
expect(screen.getByTestId('edit-composer').textContent).toBe('/new')
})
it('still passes the new cwd after a session switch', async () => {
const { rerender } = render(<Harness cwd="/old" sessionKey="k1" />)
await act(async () => {
rerender(<Harness cwd="/new" sessionKey="k2" />)
})
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
await screen.findByTestId('edit-composer')
expect(composerRenders.at(-1)?.cwd).toBe('/new')
})
it('does not remount the transcript when cwd changes', async () => {
// The perf invariant behind keeping cwd out of the memo deps: a cwd
// change with identical messages must not remint the component types,
// so the mounted message DOM nodes survive the rerender.
const { rerender } = render(<Harness cwd="/old" sessionKey="k1" />)
await waitFor(() => {
expect(screen.getByText('done')).toBeTruthy()
expect(screen.getByText('edit me please')).toBeTruthy()
})
const assistantBefore = screen.getByText('done')
const userBefore = screen.getByText('edit me please')
await act(async () => {
rerender(<Harness cwd="/new" sessionKey="k1" />)
})
expect(screen.getByText('done')).toBe(assistantBefore)
expect(screen.getByText('edit me please')).toBe(userBefore)
})
})
@@ -0,0 +1,82 @@
import { render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
/**
* Issue #95595 proposed-fix #3: the `messageComponents` map handed to
* ThreadMessageList must keep its REFERENCE IDENTITY across a session switch.
* If it re-minted, React would unmount/remount every visible message — async
* re-rendered parts (shiki code blocks) collapse and re-expand, and the whole
* thread visibly jumps on every tab switch.
*
* The memo deps are deliberately only the boolean "definedness" gates (the
* callbacks themselves reach the composer through a ref), so a plain switch
* — sessionId changing, callbacks unchanged — must not change the map.
*/
let lastComponents: unknown
vi.mock('@/components/assistant-ui/thread/list', () => ({
ThreadMessageList: (props: { components: unknown }) => {
lastComponents = props.components
return null
}
}))
vi.mock('@/components/assistant-ui/thread/timeline', () => ({
ThreadTimeline: () => null
}))
vi.mock('@/components/assistant-ui/thread/status', () => ({
BackgroundResumeNotice: () => null,
CenteredThreadSpinner: () => null
}))
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
assistant: {
thread: {
restoreBody: 'restore body',
restoreConfirm: 'Restore',
restoreTitle: 'Restore this turn?'
}
},
common: {
cancel: 'Cancel',
confirm: 'Confirm',
done: 'Done',
loading: 'Loading'
}
}
})
}))
import { Thread } from './index'
describe('Thread messageComponents identity across session switches', () => {
it('does not re-mint messageComponents when only the session changes', () => {
const { rerender } = render(<Thread sessionId="session-a" />)
const first = lastComponents
expect(first).toBeDefined()
rerender(<Thread sessionId="session-b" />)
// THE guard: a switch must keep the component map reference, so the
// incoming transcript reconciles instead of remounting.
expect(lastComponents).toBe(first)
rerender(<Thread sessionId="session-c" />)
expect(lastComponents).toBe(first)
})
it('keeps the map stable across a plain parent re-render', () => {
const { rerender } = render(<Thread sessionId="session-a" />)
const first = lastComponents
rerender(<Thread sessionId="session-a" />)
expect(lastComponents).toBe(first)
})
})
@@ -0,0 +1,195 @@
import { createContext, memo, useCallback, useContext, useMemo, useRef, useState } from 'react'
import { ChatEmptySlot } from '@/components/assistant-ui/chat-empty-slot'
import { AssistantMessage } from '@/components/assistant-ui/thread/assistant-message'
import { ThreadMessageList } from '@/components/assistant-ui/thread/list'
import { BackgroundResumeNotice, CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
import { SystemMessage } from '@/components/assistant-ui/thread/system-message'
import { ThreadTimeline } from '@/components/assistant-ui/thread/timeline'
import { type RestoreMessageTarget } from '@/components/assistant-ui/thread/types'
import { UserEditComposer } from '@/components/assistant-ui/thread/user-edit-composer'
import { UserMessage } from '@/components/assistant-ui/thread/user-message'
import { Intro, type IntroProps } from '@/components/chat/intro'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
type ThreadLoadingState = 'response' | 'session'
interface ThreadEditContextValue {
cwd: string | null
gateway: HermesGateway | null
sessionId: string | null
}
// Edit-composer context. The composer only exists while a message is being
// edited, and it mounts deep inside the memo'd ThreadMessageList, so the
// edit context can neither ride the component-map memo deps (that remints
// the component types on every session switch and remounts the outgoing
// transcript) nor sit in a render-time ref (a mounted composer never
// re-reads it when a same-session change leaves every list prop
// referentially equal). Context solves both: the component type stays
// stable, and a changed value propagates straight to the mounted consumer.
const ThreadEditContext = createContext<ThreadEditContextValue>({ cwd: null, gateway: null, sessionId: null })
interface ThreadProps {
clampToComposer?: boolean
cwd?: string | null
gateway?: HermesGateway | null
intro?: IntroProps
loading?: ThreadLoadingState
onBranchInNewChat?: (messageId: string) => void
onCancel?: () => Promise<void> | void
onDismissError?: (messageId: string) => void
onRestoreToMessage?: (messageId: string, target?: RestoreMessageTarget) => Promise<void> | void
sessionId?: string | null
sessionKey?: string | null
}
// memo'd on purpose, and load-bearing for session-switch cost. ChatView
// re-renders on every route change (it reads `location`), and this subtree is
// the entire transcript — without a bail-out here the router's context update
// rebuilds every message of the OUTGOING thread before it is replaced. The
// props above are all stable across a plain re-render (see the component-map
// and loadingIndicator memos below), so the only thing that gets through is a
// genuine change.
export const Thread = memo(function Thread({
clampToComposer = false,
cwd = null,
gateway = null,
intro,
loading,
onBranchInNewChat,
onCancel,
onDismissError,
onRestoreToMessage,
sessionId = null,
sessionKey
}: ThreadProps) {
const { t } = useI18n()
const copy = t.assistant.thread
const [restoreConfirmTarget, setRestoreConfirmTarget] = useState<
(RestoreMessageTarget & { messageId: string }) | null
>(null)
const closeRestoreConfirm = useCallback(() => setRestoreConfirmTarget(null), [])
const confirmRestore = useCallback(() => {
if (!restoreConfirmTarget || !onRestoreToMessage) {
throw new Error('Restore is unavailable for this message.')
}
const { messageId, text, userOrdinal } = restoreConfirmTarget
closeRestoreConfirm()
void Promise.resolve(onRestoreToMessage(messageId, { text, userOrdinal })).catch((error: unknown) => {
notifyError(error, 'Restore failed')
})
}, [closeRestoreConfirm, onRestoreToMessage, restoreConfirmTarget])
const requestRestoreConfirm = useCallback((messageId: string, target: RestoreMessageTarget) => {
setRestoreConfirmTarget({ messageId, ...target })
}, [])
// The values in this map are component *types*: when their identity
// changes, React unmounts and remounts every visible message — async
// re-rendered parts (shiki code blocks) collapse and re-expand, so the
// whole thread visibly jumps. Parents re-render on unrelated state
// (e.g. the 15s status-snapshot poll in the desktop controller) and
// can't be trusted to keep callback identities stable (see #38333), so
// route the callbacks through a ref instead of listing them as memo
// deps. Only their definedness stays a dep — it gates UI (the user
// Stop button, the restore-confirm affordance). Assigned during render
// (the useStoreSelector pattern) so the ref never lags a render.
//
// cwd / gateway / sessionId stay OUT of the memo deps for the same
// reason: all three change on EVERY session switch, so listing them
// re-minted these types mid-switch and remounted the entire OUTGOING
// transcript — thousands of renders of a thread that was about to be
// replaced, all of it before the resume RPC had even been sent. They
// reach the edit composer through ThreadEditContext instead (see above).
const callbacksRef = useRef({ onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage })
callbacksRef.current = { onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage }
// Only changes identity when one of the three values does, so Thread
// re-renders for unrelated reasons never re-render the composer.
const editContext = useMemo(() => ({ cwd, gateway, sessionId }), [cwd, gateway, sessionId])
const hasBranchInNewChat = Boolean(onBranchInNewChat)
const hasCancel = Boolean(onCancel)
const hasDismissError = Boolean(onDismissError)
const hasRestoreToMessage = Boolean(onRestoreToMessage)
const messageComponents = useMemo(
() => ({
AssistantMessage: () => (
<AssistantMessage
onBranchInNewChat={
hasBranchInNewChat ? messageId => callbacksRef.current.onBranchInNewChat?.(messageId) : undefined
}
onDismissError={hasDismissError ? messageId => callbacksRef.current.onDismissError?.(messageId) : undefined}
/>
),
SystemMessage,
UserEditComposer: () => {
const { cwd: editCwd, gateway: editGateway, sessionId: editSessionId } = useContext(ThreadEditContext)
return <UserEditComposer cwd={editCwd} gateway={editGateway} sessionId={editSessionId} />
},
UserMessage: () => (
<UserMessage
onCancel={hasCancel ? () => callbacksRef.current.onCancel?.() : undefined}
onRequestRestoreConfirm={hasRestoreToMessage ? requestRestoreConfirm : undefined}
/>
)
}),
[hasBranchInNewChat, hasCancel, hasDismissError, hasRestoreToMessage, requestRestoreConfirm]
)
// Core's splash belongs to a fresh draft; a session that exists but has
// nothing in it yet gets whichever plugin owns it. The slot often renders
// nothing, which costs an empty container — harmless, since there is no
// content to lay out until the first message swaps this branch out.
const emptyBody = intro ? <Intro {...intro} /> : sessionId ? <ChatEmptySlot sessionId={sessionId} /> : null
const emptyPlaceholder = emptyBody ? (
<div className="flex min-h-0 w-full flex-col items-center justify-center pt-[var(--composer-measured-height)]">
{emptyBody}
</div>
) : undefined
// Stable element identity, for the same reason the component map above is
// memoized: this is a prop of the memo'd ThreadMessageList, so a fresh
// element every render defeats the bail-out and drags the whole transcript
// into the switch's render pass. It takes no props, so one element is
// always correct.
const loadingIndicator = useMemo(() => <BackgroundResumeNotice />, [])
return (
<ThreadEditContext.Provider value={editContext}>
<div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
<ThreadMessageList
clampToComposer={clampToComposer}
components={messageComponents}
emptyPlaceholder={emptyPlaceholder}
loadingIndicator={loadingIndicator}
sessionId={sessionId}
sessionKey={sessionKey}
/>
{loading === 'session' && <CenteredThreadSpinner />}
<ThreadTimeline />
<ConfirmDialog
confirmLabel={copy.restoreConfirm}
description={copy.restoreBody}
destructive
onClose={closeRestoreConfirm}
onConfirm={confirmRestore}
open={Boolean(restoreConfirmTarget)}
title={copy.restoreTitle}
/>
</div>
</ThreadEditContext.Provider>
)
})
@@ -0,0 +1,119 @@
// Two contracts with no coverage before the invalidation-scoping work split
// AssistantMessage into InterAgentAssistantMessage + AssistantMessageBody:
//
// 1. The collapse gate. A reply to an inter-agent delivery renders collapsed
// ("Replied to <sender>", expandable) ONLY once it settles — never while it
// streams, because the user should see progress. That gate is the sole
// remaining root-level `isRunning` subscription, so it is the thing most
// likely to break if the split is revisited.
// 2. The streaming marker. `data-message-streaming` moved off the message root
// onto a permanently-mounted hidden leaf, and
// scripts/run-short-session-hang-repro.mjs derives its settled-row count by
// subtracting `[data-message-streaming="true"]` markers from message roots.
// Nothing in the app itself reads it, so without this test a delete would
// look free and would silently regress that repro's response gate.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Thread } from '.'
const createdAt = new Date('2026-05-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
vi.stubGlobal('CSS', { escape: (str: string) => str })
Element.prototype.scrollTo = function scrollTo() {}
Element.prototype.animate = function animate() {
return { cancel() {}, finished: Promise.resolve() } as unknown as Animation
}
afterEach(() => {
cleanup()
})
const assistantMetadata = { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} }
function user(id: string, text: string): ThreadMessage {
return {
id,
role: 'user',
content: [{ type: 'text', text }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistant(id: string, text: string, running: boolean): ThreadMessage {
return {
id,
role: 'assistant',
content: text ? [{ type: 'text', text }] : [],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: assistantMetadata
} as ThreadMessage
}
function Harness({ messages }: { messages: ThreadMessage[] }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages,
isRunning: messages.at(-1)?.status?.type === 'running',
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
const DELIVERY = 'Message from 🤖 Hermes (@hermes): please check the build'
describe('inter-agent collapse gate', () => {
it('collapses a settled reply to an inter-agent delivery', async () => {
render(<Harness messages={[user('u1', DELIVERY), assistant('a1', 'build is green', false)]} />)
expect(await screen.findByText(/Replied to/)).toBeTruthy()
expect(screen.getByText('show reply')).toBeTruthy()
})
it('does NOT collapse while that reply is still streaming', async () => {
const { container } = render(<Harness messages={[user('u1', DELIVERY), assistant('a1', 'working on it', true)]} />)
await screen.findByText('working on it')
expect(screen.queryByText('show reply')).toBeNull()
// Expanded => the full body root, which carries the streaming marker.
expect(container.querySelector('[data-message-streaming="true"]')).toBeTruthy()
})
it('leaves an ordinary reply expanded', async () => {
render(<Harness messages={[user('u1', 'ordinary question'), assistant('a1', 'ordinary answer', false)]} />)
await screen.findByText('ordinary answer')
expect(screen.queryByText(/Replied to/)).toBeNull()
})
it('clears the streaming marker once the turn settles', async () => {
const { container } = render(<Harness messages={[user('u1', 'q'), assistant('a1', 'done', false)]} />)
await screen.findByText('done')
expect(container.querySelector('[data-message-streaming="true"]')).toBeNull()
// The marker element itself stays mounted (attribute toggles, no remount).
expect(
container.querySelector('[data-slot="aui_assistant-message-root"] [data-slot="aui_message-streaming-marker"]')
).toBeTruthy()
})
})
@@ -0,0 +1,367 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
buildGroups,
firstVisibleGroupIndex,
HIDDEN_TRANSCRIPT_RENDER_BUDGET,
LIVE_TAIL_MIN_GROUPS,
LIVE_TAIL_PARTS,
liveTailStart,
type MessageGroup,
resolveThreadScrollTarget,
RUN_START_SNAP_THRESHOLD_PX,
shouldClampTranscriptBudget,
shouldRePinOnTranscriptReload,
shouldSnapOnRunStart,
subscribeToThreadForeground,
transcriptBackfillFrameCount,
transcriptPaneBudget
} from './list'
afterEach(() => {
vi.restoreAllMocks()
})
describe('subscribeToThreadForeground', () => {
it('reanchors on focus when an active turn keeps document visibility pinned visible', () => {
const reanchor = vi.fn()
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
callback(0)
return 1
})
const unsubscribe = subscribeToThreadForeground(() => true, reanchor)
window.dispatchEvent(new Event('focus'))
expect(raf).toHaveBeenCalledOnce()
expect(reanchor).toHaveBeenCalledOnce()
unsubscribe()
})
it('leaves a scrolled-up reader in place when the window focuses', () => {
const reanchor = vi.fn()
const raf = vi.spyOn(window, 'requestAnimationFrame')
const unsubscribe = subscribeToThreadForeground(() => false, reanchor)
window.dispatchEvent(new Event('focus'))
expect(raf).not.toHaveBeenCalled()
expect(reanchor).not.toHaveBeenCalled()
unsubscribe()
})
it('drops a queued reanchor when the reader scrolls away before the frame', () => {
const frames: FrameRequestCallback[] = []
let following = true
const reanchor = vi.fn()
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
frames.push(callback)
return 7
})
const unsubscribe = subscribeToThreadForeground(() => following, reanchor)
window.dispatchEvent(new Event('focus'))
following = false
frames[0]?.(0)
expect(reanchor).not.toHaveBeenCalled()
unsubscribe()
})
it('cancels a queued reanchor when its thread unmounts', () => {
const cancel = vi.spyOn(window, 'cancelAnimationFrame')
const reanchor = vi.fn()
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(9)
const unsubscribe = subscribeToThreadForeground(() => true, reanchor)
window.dispatchEvent(new Event('focus'))
unsubscribe()
expect(cancel).toHaveBeenCalledWith(9)
expect(reanchor).not.toHaveBeenCalled()
})
})
// Signature rows are `${index}:${id}:${role}:${weight}` (see the useAuiState
// selector in list.tsx).
const signature = (rows: [string, string, number][]) =>
rows.map(([id, role, weight], index) => `${index}:${id}:${role}:${weight}`).join('\n')
describe('transcriptPaneBudget', () => {
it('uses a fixed live-tail budget while hidden instead of charging every mounted transcript', () => {
expect(transcriptPaneBudget(1, true)).toBe(HIDDEN_TRANSCRIPT_RENDER_BUDGET)
expect(transcriptPaneBudget(4, true)).toBe(HIDDEN_TRANSCRIPT_RENDER_BUDGET)
expect(transcriptPaneBudget(1, false)).toBeGreaterThan(HIDDEN_TRANSCRIPT_RENDER_BUDGET)
})
})
describe('shouldClampTranscriptBudget', () => {
it('never snaps a visible pane back after Show earlier', () => {
expect(shouldClampTranscriptBudget(false, 10, 5)).toBe(false)
expect(shouldClampTranscriptBudget(false, 5, 5)).toBe(false)
})
it('snaps only a hot-hidden pane that outgrew the retention budget', () => {
expect(shouldClampTranscriptBudget(true, 10, 5)).toBe(true)
expect(shouldClampTranscriptBudget(true, 5, 5)).toBe(false)
})
})
describe('buildGroups', () => {
it('returns no groups for an empty signature', () => {
expect(buildGroups('')).toEqual([])
})
it('groups a user message with the assistant turn(s) that follow it', () => {
const groups = buildGroups(
signature([
['u1', 'user', 1],
['a1', 'assistant', 4],
['a2', 'assistant', 2],
['u2', 'user', 1],
['a3', 'assistant', 3]
])
)
expect(groups).toEqual([
{ id: 'u1', indices: [0, 1, 2], kind: 'turn', weight: 7 },
{ id: 'u2', indices: [3, 4], kind: 'turn', weight: 4 }
])
})
it('keeps leading non-user messages as standalone groups', () => {
const groups = buildGroups(
signature([
['s1', 'system', 1],
['a0', 'assistant', 2],
['u1', 'user', 1],
['a1', 'assistant', 5]
])
)
expect(groups).toEqual([
{ id: 's1', index: 0, kind: 'standalone', weight: 1 },
{ id: 'a0', index: 1, kind: 'standalone', weight: 2 },
{ id: 'u1', indices: [2, 3], kind: 'turn', weight: 6 }
])
})
it('defaults a missing/zero weight to 1', () => {
const groups = buildGroups('0:a:assistant:0')
expect(groups).toEqual([{ id: 'a', index: 0, kind: 'standalone', weight: 1 }])
})
})
describe('resolveThreadScrollTarget', () => {
const context = (scrollElement: Pick<HTMLElement, 'scrollTop'>) => ({
contentElement: document.createElement('div'),
scrollElement: scrollElement as HTMLElement
})
it('settles when the browser clamps the requested bottom within half a CSS pixel', () => {
let actualScrollTop = 0
let writes = 0
const scrollElement = {
get scrollTop() {
return actualScrollTop
},
set scrollTop(value: number) {
writes += 1
actualScrollTop = value - 0.125
}
}
const target = 899
const requested = resolveThreadScrollTarget(target, context(scrollElement))
scrollElement.scrollTop = requested
const settled = resolveThreadScrollTarget(target, context(scrollElement))
expect(requested).toBe(target)
expect(actualScrollTop).toBe(898.875)
expect(settled).toBe(actualScrollTop)
expect(actualScrollTop < settled).toBe(false)
expect(writes).toBe(1)
})
it('keeps following while more than half a CSS pixel remains', () => {
const scrollElement = { scrollTop: 898.25 }
expect(resolveThreadScrollTarget(899, context(scrollElement))).toBe(899)
})
it('re-arms after streaming content increases the target', () => {
const scrollElement = { scrollTop: 898.875 }
expect(resolveThreadScrollTarget(899, context(scrollElement))).toBe(898.875)
expect(resolveThreadScrollTarget(999, context(scrollElement))).toBe(999)
})
})
describe('firstVisibleGroupIndex', () => {
const group = (id: string, weight: number): MessageGroup => ({ id, index: 0, kind: 'standalone', weight })
it('shows everything when total weight fits the budget', () => {
const groups = [group('a', 10), group('b', 10), group('c', 10)]
expect(firstVisibleGroupIndex(groups, 100)).toBe(0)
})
it('walks newest-first and hides everything before the turn that meets the budget', () => {
const groups = [group('old', 50), group('mid', 30), group('new', 30)]
// newest-first: 30 (new) < 60, +30 (mid) = 60 >= 60 → mid is the first
// visible group, old is hidden.
expect(firstVisibleGroupIndex(groups, 60)).toBe(1)
})
it('keeps whole turns intact — the turn that crosses the budget stays visible', () => {
const groups = [group('old', 5), group('huge', 500)]
expect(firstVisibleGroupIndex(groups, 60)).toBe(1)
})
it('returns groups.length for an empty list', () => {
expect(firstVisibleGroupIndex([], 60)).toBe(0)
})
it('keeps a floor of turns visible however heavy they are', () => {
// Without the floor a session of enormous turns puts "Show earlier" two
// turns from the bottom, which reads as broken rather than as paging.
const groups = Array.from({ length: 20 }, (_, i) => group(`g${i}`, 5_000))
expect(firstVisibleGroupIndex(groups, 600, 8)).toBe(groups.length - 8)
})
it('does not force the floor to hide turns the budget already showed', () => {
const groups = Array.from({ length: 20 }, (_, i) => group(`g${i}`, 1))
expect(firstVisibleGroupIndex(groups, 600, 8)).toBe(0)
})
})
describe('liveTailStart', () => {
const group = (id: string, weight: number): MessageGroup => ({ id, index: 0, kind: 'standalone', weight })
it('keeps the newest turns rendered until the parts budget is spent', () => {
// 10 turns x 10 parts. A 40-part tail covers the newest 4-5 turns.
const groups = Array.from({ length: 10 }, (_, i) => group(`g${i}`, 10))
const start = liveTailStart(groups)
expect(start).toBeGreaterThan(0)
expect(start).toBeLessThan(groups.length)
// Everything from `start` onward is the live tail...
const tailParts = groups.slice(start).reduce((sum, g) => sum + g.weight, 0)
expect(tailParts).toBeGreaterThan(LIVE_TAIL_PARTS)
// ...and dropping its oldest member puts it back under budget, i.e. the
// tail is minimal rather than sprawling.
const withoutOldest = groups.slice(start + 1).reduce((sum, g) => sum + g.weight, 0)
expect(withoutOldest).toBeLessThanOrEqual(LIVE_TAIL_PARTS)
})
it('virtualizes the old bulk of a long agent transcript', () => {
// The regression this guards: heavy tool turns. A turn-count tail (6) left
// NOTHING virtualized on transcripts like this, so every Radix overlay open
// paid a whole-document style recalc.
const groups = Array.from({ length: 40 }, (_, i) => group(`g${i}`, 120))
// Only the min-group floor stays rendered; the other 38 turns skip.
expect(liveTailStart(groups)).toBe(groups.length - LIVE_TAIL_MIN_GROUPS)
})
it('never virtualizes below the min-group floor, however heavy the turns', () => {
const groups = Array.from({ length: 5 }, (_, i) => group(`g${i}`, 10_000))
expect(liveTailStart(groups)).toBe(groups.length - LIVE_TAIL_MIN_GROUPS)
})
it('keeps every turn rendered when the whole transcript fits in the tail', () => {
const groups = [group('a', 5), group('b', 5), group('c', 5)]
expect(liveTailStart(groups)).toBe(0)
})
it('handles an empty transcript', () => {
expect(liveTailStart([])).toBe(0)
})
it('honors a custom budget', () => {
const groups = Array.from({ length: 10 }, (_, i) => group(`g${i}`, 1))
// A 3-part budget would keep 4 turns, but the max-groups ceiling is not hit
// here, so the parts budget wins.
expect(liveTailStart(groups, 3)).toBe(6)
})
it('never renders more than the old turn-count tail did, on any shape', () => {
// Guards the one way a parts budget can regress: a long transcript of tiny
// turns, where walking back 40 parts reaches further than 6 turns would.
const shapes = [
Array.from({ length: 40 }, () => 4), // long chat, tiny turns
Array.from({ length: 40 }, () => 1), // pathological: 1-part turns
Array.from({ length: 12 }, () => 6),
[80, 120, 60, 150, 90, 200, 70], // real agent tile
[30, 45]
]
for (const weights of shapes) {
const groups = weights.map((weight, i) => group(`g${i}`, weight))
const rendered = (start: number) => weights.slice(start).reduce((a, b) => a + b, 0)
const oldStart = Math.max(0, groups.length - 6)
expect(rendered(liveTailStart(groups))).toBeLessThanOrEqual(rendered(oldStart))
}
})
})
describe('transcriptBackfillFrameCount', () => {
it('settles a full pane in at most three prepend commits', () => {
expect(transcriptBackfillFrameCount()).toBeLessThanOrEqual(3)
})
})
describe('shouldSnapOnRunStart', () => {
it('snaps when the viewport is already at the bottom', () => {
expect(shouldSnapOnRunStart(0)).toBe(true)
})
it('snaps when the viewport is a line or two off the bottom', () => {
expect(shouldSnapOnRunStart(RUN_START_SNAP_THRESHOLD_PX - 1)).toBe(true)
})
it('leaves a reader who has scrolled into history alone', () => {
expect(shouldSnapOnRunStart(RUN_START_SNAP_THRESHOLD_PX)).toBe(false)
expect(shouldSnapOnRunStart(400)).toBe(false)
})
})
describe('shouldRePinOnTranscriptReload', () => {
it('pins on a session switch even before the transcript has settled', () => {
expect(shouldRePinOnTranscriptReload({ sessionSwitched: true, settledNonEmpty: false })).toBe(true)
})
it('pins on a session switch even when the prior session had settled', () => {
expect(shouldRePinOnTranscriptReload({ sessionSwitched: true, settledNonEmpty: true })).toBe(true)
})
it('preserves the reader position on a same-session refresh after settling', () => {
expect(shouldRePinOnTranscriptReload({ sessionSwitched: false, settledNonEmpty: true })).toBe(false)
})
it('pins on a cold-load arrival (same session, never settled non-empty)', () => {
expect(shouldRePinOnTranscriptReload({ sessionSwitched: false, settledNonEmpty: false })).toBe(true)
})
})
@@ -0,0 +1,860 @@
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import {
type ComponentProps,
type CSSProperties,
type FC,
memo,
type ReactNode,
startTransition,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState
} from 'react'
import { type GetTargetScrollTop, useStickToBottom } from 'use-stick-to-bottom'
import { usePaneLifecycle, usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { useI18n } from '@/i18n'
import { messagePaintWeight } from '@/lib/render-weight'
import { cn } from '@/lib/utils'
import {
onScrollToBottomRequest,
onThreadEditClose,
onThreadEditOpen,
publishThreadAtBottom,
resetPublishedThreadScroll
} from '@/store/thread-scroll'
import { isSecondaryWindow } from '@/store/windows'
import { MessageRenderBoundary } from '../message-render-boundary'
import { resolveShowEarlierAction, useTranscriptWindow } from './transcript-window'
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
export type MessageGroup = { id: string; weight: number } & (
{ index: number; kind: 'standalone' } | { indices: number[]; kind: 'turn' }
)
// DOM is bounded by a render-cost budget, not a message/turn count. The
// currency is `messagePaintWeight`: what a turn actually MOUNTS, which is what
// the grouping decides rather than what the payload weighs. A settled run of
// twelve reads is one grey summary line, a thought is one collapsed
// disclosure, a hoisted `todo` is nothing — while a diff, an image card or a
// wall of markdown really does build DOM and is charged for it.
//
// Pricing by payload instead had the budget counting work that never mounts:
// one tool-heavy turn measured 84-281 units of tool JSON that painted as a
// dozen one-line summaries, so a session spent the whole page in two or three
// turns and offered "Show earlier" over a screen and a half of transcript.
//
// "Show earlier" prepends another page; whole turns stay intact so the sticky
// human bubble never loses its turn. This is the long-session perf lever WITHOUT
// a virtualizer — pure rendering, never touches scrollTop, so it can't fight
// use-stick-to-bottom (the single scroll owner).
//
// 600 units ≈ 10-20 agentic turns on measured real sessions (a tool-heavy turn
// prices at 30-90, a plain exchange at 5-10), and a whole session of ordinary
// work now fits one page instead of paging three times to reach its start.
// What the DOM can hold is bounded above by the store window regardless
// (TRANSCRIPT_WINDOW_BUDGET), so this cannot admit more than one window's
// content.
const RENDER_BUDGET = 600
// Every mounted transcript list registers here (see the mount effect). The
// budget above is sized for ONE full-height pane; a grid split shows several
// panes at once, each a fraction of the screen — yet each was still mounting
// the full budget. Four visible panes meant 4x the mounted message fibers,
// and every streaming flush pays selector re-runs and React commit traversal
// over ALL of them — measured as the 4-zone collapse in the long-session
// matrix (worst-second 8fps while 1-2 zones held 50+). Sharing the budget
// keeps "screens of scrollback" constant instead of "turns per pane": a pane
// a quarter the height gets a quarter the page, floored at a quarter budget
// (MIN_VISIBLE_GROUPS still floors the turn count regardless of weight).
// Panes that already backfilled keep their mounted content when the count
// changes — the share only caps where NEW backfills stop.
const $mountedTranscriptPanes = atom(0)
// Never offer "Show earlier" over fewer turns than this, however heavy they
// are. A weight-only cut on a session of enormous turns put the button two
// turns from the bottom, where it reads as broken rather than as paging — the
// user has not been given enough transcript to have gone looking for more. The
// store window caps what the DOM can reach at all, so a floor here stays
// bounded.
const MIN_VISIBLE_GROUPS = 8
// On session switch, paint a small budget first (enough for the bottom turn(s)
// the user actually sees after scroll-to-bottom), then bump to the full budget
// in a requestAnimationFrame — defers the heavy markdown+syntax-highlight render
// past the initial commit, so the switch feels instant.
//
// 20, down from 60: the first-paint commit is synchronous and uninterruptible,
// and at 60 cost units it measured 627ms on a real session (LoAF: block=575ms, no
// attributed script — pure commit). A viewport after scroll-to-bottom shows
// 1-2 normal turns ≈ 10-20 units; the transition backfill below fills the rest
// interruptibly, so the only thing a smaller budget changes is how much work
// blocks the click-to-paint path.
const FIRST_PAINT_BUDGET = 20
// A hot-hidden transcript is retained for instant tab return, but keeping its
// full scrollback mounted defeats the bounded pane cache. Preserve only the
// live tail while hidden; revealing it resumes stepped backfill.
export const HIDDEN_TRANSCRIPT_RENDER_BUDGET = 40
export const transcriptPaneBudget = (mountedPanes: number, hidden: boolean): number =>
hidden
? HIDDEN_TRANSCRIPT_RENDER_BUDGET
: Math.max(Math.ceil(RENDER_BUDGET / Math.max(1, mountedPanes)), RENDER_BUDGET / 4)
// "Show earlier" raises renderBudget ABOVE paneBudget (one pane page per click).
// The render-phase cap must only snap a hot-hidden pane down to its retention
// budget — a visible pane's growth has to survive the next render or the click
// is a no-op. Parked panes are unmounted, so they never hit this path.
export const shouldClampTranscriptBudget = (hidden: boolean, renderBudget: number, paneBudget: number): boolean =>
hidden && renderBudget > paneBudget
// Units the backfill adds per committed step (see the backfill effect). A
// 60-unit step produced ~10 visible prepend frames after FIRST_PAINT_BUDGET
// retune (#83681). 290 fills a 600-unit page in two interruptible commits —
// still well under the measured 780ms single-jump freeze.
const BACKFILL_STEP = 290
export const transcriptBackfillFrameCount = (
firstPaint = FIRST_PAINT_BUDGET,
step = BACKFILL_STEP,
budget = RENDER_BUDGET
): number => Math.ceil(Math.max(0, budget - firstPaint) / step)
// Browsers may quantize a requested scrollTop to a nearby device-pixel
// boundary. use-stick-to-bottom otherwise compares the lower actual value to
// the integer target forever, re-requesting the same instant scroll every
// frame. Treat a subpixel remainder as achieved; larger gaps still follow new
// streamed content normally.
const SCROLL_TARGET_EPSILON_PX = 0.5
export const resolveThreadScrollTarget: GetTargetScrollTop = (targetScrollTop, { scrollElement }) => {
const currentScrollTop = scrollElement.scrollTop
const remaining = targetScrollTop - currentScrollTop
return remaining >= 0 && remaining <= SCROLL_TARGET_EPSILON_PX ? currentScrollTop : targetScrollTop
}
/** Near-bottom slack for a run-start snap. Wider than the subpixel epsilon
* use-stick-to-bottom uses for resize follow — a follow-up sent a line or two
* off the bottom should still track, but a reader in history must not yank. */
export const RUN_START_SNAP_THRESHOLD_PX = 64
export function shouldSnapOnRunStart(remainingPx: number, thresholdPx = RUN_START_SNAP_THRESHOLD_PX): boolean {
return remainingPx < thresholdPx
}
// True when the pin-to-bottom settle should re-arm. A same-session refresh
// (transcript briefly emptied and repopulated under the same key) must keep
// the reader's position; only a session switch or a cold-load arrival re-pins.
export function shouldRePinOnTranscriptReload(opts: { sessionSwitched: boolean; settledNonEmpty: boolean }): boolean {
return opts.sessionSwitched || !opts.settledNonEmpty
}
export function subscribeToThreadForeground(shouldReanchor: () => boolean, onReanchor: () => void): () => void {
let frameId: number | null = null
let framePending = false
const onForeground = () => {
if (framePending || document.visibilityState !== 'visible' || !shouldReanchor()) {
return
}
framePending = true
const scheduledId = requestAnimationFrame(() => {
frameId = null
framePending = false
if (document.visibilityState === 'visible' && shouldReanchor()) {
onReanchor()
}
})
// Browser callbacks are asynchronous; the guard also keeps synchronous
// requestAnimationFrame test doubles from leaving a completed frame pending.
if (framePending) {
frameId = scheduledId
}
}
document.addEventListener('visibilitychange', onForeground)
window.addEventListener('focus', onForeground)
return () => {
document.removeEventListener('visibilitychange', onForeground)
window.removeEventListener('focus', onForeground)
if (frameId !== null) {
cancelAnimationFrame(frameId)
}
frameId = null
framePending = false
}
}
interface ThreadMessageListProps {
clampToComposer: boolean
components: ThreadMessageComponents
emptyPlaceholder?: ReactNode
loadingIndicator?: ReactNode
sessionId?: string | null
sessionKey?: string | null
}
// Group each user message with the assistant turn(s) that follow it so the
// human bubble can `position: sticky` against the scroller across its whole
// turn (see StickyHumanMessageContainer in thread.tsx).
export function buildGroups(signature: string): MessageGroup[] {
if (!signature) {
return []
}
const messages = signature.split('\n').map(row => {
const [index, id, role, weight] = row.split(':')
return { id, index: Number(index), role, weight: Number(weight) || 1 }
})
const groups: MessageGroup[] = []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (message.role !== 'user') {
groups.push({ id: message.id, index: message.index, kind: 'standalone', weight: message.weight })
continue
}
const indices = [message.index]
let weight = message.weight
while (i + 1 < messages.length && messages[i + 1].role !== 'user') {
weight += messages[++i].weight
indices.push(messages[i].index)
}
groups.push({ id: message.id, indices, kind: 'turn', weight })
}
return groups
}
// Walk turns newest-first, summing their render weights until the budget is met;
// everything before the first kept turn is hidden. `minVisible` turns are kept
// regardless of weight. Returns the index of that first visible group.
export function firstVisibleGroupIndex(groups: readonly MessageGroup[], budget: number, minVisible = 0): number {
let firstVisible = groups.length
for (let i = groups.length - 1, weight = 0; i >= 0; i--) {
weight += groups[i].weight
firstVisible = i
if (weight >= budget) {
break
}
}
return Math.min(firstVisible, Math.max(0, groups.length - minVisible))
}
// content-visibility:auto skips off-screen turns for perf, but with
// contain-intrinsic-size:auto the browser only remembers a turn's size AFTER
// it has rendered. A turn that finishes streaming near the bottom may have had
// its (smaller) mid-stream size remembered; when it scrolls just off the top
// edge and gets skipped, it snaps back to that stale height, shifting content
// down. With overflow-anchor:none (the viewport can't self-correct) the
// stick-to-bottom lock drifts and the view creeps up over older turns — the
// "long session eventually shows old responses" glitch.
//
// Keep the newest turns always-rendered so a turn is only ever virtualized
// once its layout has settled at its final size (remembered == real → skipping
// it changes no height). Off-screen OLDER turns still skip, so the dialog/popover
// recalc win on long transcripts is preserved.
//
// The tail is budgeted in render-cost units, not turns, because that is what the
// cost actually scales with — the same currency as RENDER_BUDGET /
// FIRST_PAINT_BUDGET.
// A turn-count tail silently defeats itself on agent transcripts: one tool-heavy
// turn is 50-200 units, so a 6-TURN tail exempted the entire visible transcript
// and nothing virtualized at all. Measured on a 5-tile window (7/3/5/3/2 groups
// per tile): zero content-visibility containers were active, and every Radix
// overlay open paid the full ~610ms whole-document recalc that #66470 fixed.
//
// 40 units ≈ the 1-2 turns a viewport shows after scroll-to-bottom (the same
// reasoning as FIRST_PAINT_BUDGET=20, doubled so a turn that grows mid-stream
// doesn't fall out of the tail as it settles).
export const LIVE_TAIL_PARTS = 40
// Floor: always exempt at least this many turns regardless of weight, so a
// transcript of very heavy turns still keeps the streaming one unvirtualized.
export const LIVE_TAIL_MIN_GROUPS = 2
// Ceiling: never exempt more than this many turns, however light they are. On a
// long transcript of tiny turns a weight-only budget would walk back further
// than the old turn-count tail did and virtualize LESS — this keeps the new
// policy a strict improvement on every shape.
export const LIVE_TAIL_MAX_GROUPS = 6
/**
* Index of the newest group that still virtualizes — everything at or after it
* is the live tail and stays rendered. Walks newest-first accumulating weight,
* so the tail covers a viewport's worth of content rather than a fixed number
* of turns, clamped to [MIN, MAX] turns. Computed once per render, not per row.
*/
export function liveTailStart(
groups: readonly MessageGroup[],
tailWeight = LIVE_TAIL_PARTS,
minGroups = LIVE_TAIL_MIN_GROUPS,
maxGroups = LIVE_TAIL_MAX_GROUPS
): number {
let weight = 0
let start = groups.length
for (let i = groups.length - 1; i >= 0; i--) {
weight += groups[i]?.weight ?? 1
start = i
if (weight > tailWeight) {
break
}
}
// Clamp the tail to [minGroups, maxGroups] turns: the floor keeps the live
// turn rendered when turns are huge, the ceiling stops a tail of tiny turns
// from sprawling past what the old turn-count policy rendered.
const floor = Math.max(0, groups.length - minGroups)
const ceiling = Math.max(0, groups.length - maxGroups)
return Math.min(floor, Math.max(ceiling, start))
}
interface TurnRowProps {
components: ThreadMessageComponents
group: MessageGroup
resetKey: string
virtualized: boolean
}
// One turn (or standalone message) of the transcript. memo() is the point:
// the rows array below is REBUILT whenever the DOM budget's cut advances
// (hiddenCount changes its slice), and without per-row bail-out that rebuild
// re-rendered every mounted turn — markdown, code cards, tool blocks — in one
// synchronous frame, a 100-800ms stall once a second on a streaming long
// session. With memo, a rebuild re-renders only rows whose props changed:
// the dropped head row unmounts, the virtualization boundary rows flip their
// flag, and everything else bails on identical group/resetKey identity.
//
// content-visibility:auto (virtualized rows) — off-screen turns skip style
// recalc, layout, and paint. On a long transcript this is what keeps
// UNRELATED UI fast: any dialog/popover mount (Radix Presence reads
// getComputedStyle) forces a whole-document style recalc, measured
// ~650-730ms per open on a 1300-message session and ~100-200ms with this
// on. contain-intrinsic-size keeps a placeholder height for never-rendered
// turns (auto: remembered real size once rendered), so scrollbar/anchoring
// stay stable. Sticky human bubbles are unaffected — their turn is rendered
// whenever any part of it intersects the viewport.
//
// The live tail (newest turns) is exempt: virtualizing a turn whose final
// size hasn't been remembered yet snaps it to a stale height when it scrolls
// off, drifting stick-to-bottom up over old turns. See liveTailStart.
const TurnRow = memo(function TurnRow({ components, group, resetKey, virtualized }: TurnRowProps) {
return (
<div
className={cn(
'flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)',
virtualized && '[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
)}
>
<MessageRenderBoundary resetKey={resetKey}>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</MessageRenderBoundary>
</div>
)
})
const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
clampToComposer,
components,
emptyPlaceholder,
loadingIndicator,
sessionId = null,
sessionKey
}) => {
// TWO signatures, deliberately split. The STRUCTURAL one (ids/roles/count)
// changes only when messages are added/removed/swapped — it keys the error
// boundaries and the row identity. The WEIGHT one (parts + character cost)
// ticks while a streaming turn appends content — it feeds only the render
// budget. Folding weights into the structural key handed every boundary a
// new resetKey per appended part, which reconciled every turn's subtree on
// every tick (measured: 540 wasted Block renders per explain() sample with
// two threads streaming).
const structuralSignature = useAuiState(s =>
s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n')
)
const weightSignature = useAuiState(s =>
s.thread.messages.map(message => messagePaintWeight(message.content)).join(',')
)
const { t } = useI18n()
// Row structure is memoized on the STRUCTURAL signature only, so streaming
// part-appends can't churn group identity (that would defeat the rows memo
// below on every tick). Weights are folded in separately for the budget.
const groups = useMemo(() => buildGroups(structuralSignature), [structuralSignature])
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
// use-stick-to-bottom owns scrollTop (single writer): follow while locked,
// escape on user scroll-up, re-lock at bottom. Snap instantly, not spring — a
// spring can't tell live-token growth from a session-switch bulk relayout, and
// chasing the latter reads as the view scrolling to random spots before
// settling. Its refs hang off our own DOM so the sticky human bubbles survive.
const { scrollRef, contentRef, isAtBottom, scrollToBottom, stopScroll } = useStickToBottom({
initial: 'instant',
resize: 'instant',
targetScrollTop: resolveThreadScrollTarget
})
const { olderAvailable, expandWindow } = useTranscriptWindow()
useEffect(() => {
$mountedTranscriptPanes.set($mountedTranscriptPanes.get() + 1)
return () => $mountedTranscriptPanes.set($mountedTranscriptPanes.get() - 1)
}, [])
const mountedPanes = useStore($mountedTranscriptPanes)
const paneLifecycle = usePaneLifecycle()
const paneVisible = usePaneVisible()
// Hidden panes retain only a live-tail budget. Visible panes share the normal
// screen budget; a reveal backfills older rows in bounded transition steps.
const paneBudget = transcriptPaneBudget(mountedPanes, paneLifecycle === 'hot-hidden')
const [renderBudget, setRenderBudget] = useState(FIRST_PAINT_BUDGET)
// Cut the budget during RENDER, not in the post-commit layout effect. An
// effect-time cut is too late: React would first build the whole tree with
// the full budget (up to 300 cost units of markdown + syntax highlighting),
// commit it, and only then re-render at the small budget. The render-phase
// state adjustment restarts this component immediately — before any child
// renders — so the heavy commit never happens.
//
// Two triggers, because the transcript swap arrives differently per path:
// a WARM switch publishes sessionKey + messages in one commit (the key
// branch), while a COLD switch changes sessionKey with an empty transcript
// and the prefetched messages land hundreds of ms later under the SAME key
// (the empty→non-empty branch).
const hasGroups = groups.length > 0
const [budgetSessionKey, setBudgetSessionKey] = useState(sessionKey)
const [hadGroups, setHadGroups] = useState(hasGroups)
if (budgetSessionKey !== sessionKey) {
setBudgetSessionKey(sessionKey)
setHadGroups(hasGroups)
setRenderBudget(FIRST_PAINT_BUDGET)
} else if (shouldClampTranscriptBudget(paneLifecycle === 'hot-hidden', renderBudget, paneBudget)) {
// Apply the hidden budget during render so React never first commits the
// stale full transcript after this pane moves to the background.
setRenderBudget(paneBudget)
} else if (hadGroups !== hasGroups) {
setHadGroups(hasGroups)
if (hasGroups) {
setRenderBudget(FIRST_PAINT_BUDGET)
}
}
// Where to land after a prepend, in distance-from-bottom (survives the
// height change). Shared by "Show earlier" and the budget backfill below.
const restoreFromBottomRef = useRef<number | null>(null)
// False from a session switch until the settle loop below parks the
// transcript at its true bottom. While false, scrollTop is a way-point of a
// load in progress, not a reading position anyone chose — never anchor to it.
const loadSettledRef = useRef(false)
// Session the settle loop last armed for, so a re-arm within the same load
// is distinguishable from a switch to a different transcript.
const settleKeyRef = useRef(sessionKey)
// True once the CURRENT session has settled with a non-empty transcript.
// A same-session refresh must keep the reader's position; only a switch or
// a cold-load arrival re-arms. Reset on switch so a mid-settle key change
// cannot inherit the outgoing session's settled flag.
const settledNonEmptyRef = useRef(false)
// Record where the view should land once a prepend has grown the content,
// measured from the BOTTOM so the added height doesn't invalidate it. Only a
// settled load has an offset the user chose; mid-load the answer is simply
// the bottom.
const anchorBeforePrepend = useCallback(() => {
const el = scrollRef.current
restoreFromBottomRef.current = el && loadSettledRef.current ? el.scrollHeight - el.scrollTop : 0
}, [scrollRef])
// Backfill from FIRST_PAINT_BUDGET to the full budget after the small
// commit painted — as a TRANSITION, so the heavy markdown + syntax
// highlight render of the older turns is interruptible instead of one long
// synchronous commit that freezes input right after the switch. Route
// changes stay urgent (main.tsx disables router transitions); it's exactly
// this backfill that belongs at background priority. "Show earlier" pages
// (budget > paneBudget) never re-enter here.
//
// In BOUNDED STEPS, not one jump to the full budget. A transition render is
// interruptible but its COMMIT is not, and one 20→600 step commits every
// backfilled turn at once — measured as a 780ms uninterruptible frame when
// the session was revealed while other tiles streamed (the flushes kept
// interrupting the transition, which finally landed whole, seconds later,
// mid-stream). Each step commits at most BACKFILL_STEP units; the effect
// re-arms off the committed budget, so steps pace one per frame.
useEffect(() => {
if (renderBudget >= paneBudget) {
return
}
const rafId = requestAnimationFrame(() => {
// The backfill PREPENDS older turns, so everything on screen slides down
// by their height. Anchor first and let the restore effect below re-apply
// it in the same commit the taller tree lands in — otherwise the view is
// stranded near the TOP until use-stick-to-bottom's ResizeObserver
// catches up a frame or two later (measured: an 11.5k px jump showing
// ~160ms of unrelated old turns, on every session load).
anchorBeforePrepend()
// Functional max, not a plain set: an urgent "Show earlier" click can
// land between scheduling and committing this transition, and a plain
// set would rebase over it and shrink the budget back down.
startTransition(() => setRenderBudget(budget => Math.max(budget, Math.min(budget + BACKFILL_STEP, paneBudget))))
})
return () => cancelAnimationFrame(rafId)
}, [anchorBeforePrepend, paneBudget, renderBudget])
// Weights (part count + visible character cost) fold into the BUDGET only.
// Group identity stays structural, so a streaming append re-runs this cheap
// sum — not the row JSX. Settled content hits messagePaintWeight's WeakMap.
const weightedGroups = useMemo(() => {
const weights = weightSignature.split(',').map(w => Number(w) || 1)
return groups.map(group => ({
...group,
weight:
group.kind === 'turn'
? group.indices.reduce((sum, index) => sum + (weights[index] ?? 1), 0)
: (weights[group.index] ?? 1)
}))
}, [groups, weightSignature])
// The turn floor applies to a real page only. During the first-paint budget
// the point is a small synchronous commit; forcing 8 turns into it would put
// back exactly the freeze FIRST_PAINT_BUDGET exists to avoid, and the rAF
// backfill a frame later fills them in anyway.
const hiddenCount = firstVisibleGroupIndex(
weightedGroups,
renderBudget,
renderBudget >= paneBudget ? MIN_VISIBLE_GROUPS : 0
)
// Memoized for IDENTITY, not to save the slice: `rows` below keys off this
// array, and an inline slice handed it a fresh array every render — so the
// moment a transcript outgrew the render budget (hiddenCount > 0), every
// streamed token rebuilt every visible row's JSX and re-rendered the whole
// mounted transcript. Under the budget the raw `groups` identity made the
// memo hold; heavy sessions lost it exactly when they could least afford to.
const visibleGroups = useMemo(() => (hiddenCount > 0 ? groups.slice(hiddenCount) : groups), [groups, hiddenCount])
// Where the always-rendered live tail begins. Derived from the WEIGHTED
// groups (render cost, not turns) so the tail is a viewport's worth of content —
// see liveTailStart. Computed once here rather than per row.
const tailStart = useMemo(
() => liveTailStart(hiddenCount > 0 ? weightedGroups.slice(hiddenCount) : weightedGroups),
[weightedGroups, hiddenCount]
)
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
// hide the titlebar tool cluster + session header, but the OS traffic lights
// still sit in the top-left, so reserve the titlebar gap above the transcript.
const secondaryWindow = isSecondaryWindow()
// NB: CSS calc() requires whitespace around the +/- operator. This string is
// assigned verbatim to the --sticky-human-top inline style below (it does not
// go through Tailwind, which would auto-space it), so the spaces are load-
// bearing — without them the declaration is invalid, gets dropped, and the
// sticky user bubble falls back to its ~4px default and slides under the OS
// traffic lights.
const secondaryTitlebarGap = 'calc(var(--titlebar-height) + 0.75rem)'
const threadContentTopPad = secondaryWindow
? 'pt-[calc(var(--titlebar-height)+0.75rem)]'
: 'pt-[calc(var(--titlebar-height)-0.5rem)]'
useEffect(() => publishThreadAtBottom(isAtBottom, { paneVisible }), [isAtBottom, paneVisible])
useEffect(() => () => resetPublishedThreadScroll({ paneVisible }), [paneVisible])
// Floating jump button (outside this subtree) → return to the bottom.
useEffect(() => onScrollToBottomRequest(() => void scrollToBottom(), sessionId), [scrollToBottom, sessionId])
// Waking from display: hidden (HUD mode hides the main window; OS hide does
// the same to any window): rAF and ResizeObserver may have been frozen, so
// the virtualizer's measurements — and scrollTop itself — are stale. Active
// turns disable Chromium's background throttling, which can keep visibility
// pinned at `visible`; window focus is then the only foreground edge. If the
// user was following the bottom, re-anchor on either signal. Consult this
// thread's local state rather than the composer-facing global mirror, which
// can be overwritten by another mounted pane; leave a scrolled-up reader
// exactly where they were.
useEffect(
() =>
subscribeToThreadForeground(
() => isAtBottom,
() => void scrollToBottom()
),
[isAtBottom, scrollToBottom]
)
const endEditHold = useCallback(() => {
scrollRef.current?.removeAttribute('data-editing')
}, [scrollRef])
// Inline edit grows a sticky bubble. Escape before focus/layout so the
// resize-follow can't snap scrollTop; native anchoring holds the viewport.
const beginEditHold = useCallback(() => {
const el = scrollRef.current
if (!el) {
return
}
endEditHold()
stopScroll()
el.setAttribute('data-editing', 'true')
}, [endEditHold, scrollRef, stopScroll])
useEffect(() => onThreadEditOpen(beginEditHold), [beginEditHold])
useEffect(() => onThreadEditClose(endEditHold), [endEditHold])
useEffect(() => () => endEditHold(), [endEditHold])
// New run → snap to the latest turn only when already near the bottom.
useAuiEvent('thread.runStart', () => {
const el = scrollRef.current
if (el && shouldSnapOnRunStart(el.scrollHeight - el.scrollTop - el.clientHeight)) {
scrollToBottom()
}
})
// Reset the cap and pin to bottom on mount + every session switch (messages
// swap in place on a long-lived runtime, so sessionKey is the only signal).
// The swap is multi-step and lays out over many frames; letting the library
// follow re-pins every frame to a moving target — visible as ~10 scroll jumps.
// Instead: quiet it, glue to the true bottom until the height holds steady,
// then hand back locked. Live streaming afterward uses the normal resize follow.
//
// `hasGroups` joins sessionKey as a dep because a COLD load changes the key
// while the transcript is still empty and publishes messages hundreds of ms
// later. Keyed on the switch alone the loop measured an EMPTY viewport, saw
// a stable height in two frames, and handed back "settled" before the
// transcript existed — so the turns painted at scrollTop 0 and only snapped
// down once use-stick-to-bottom's ResizeObserver noticed, a full-viewport
// lurch on every cold load. The empty→non-empty flip re-arms for the
// transcript that actually arrived; being a boolean, it cannot re-fire on a
// streaming append.
useLayoutEffect(() => {
const el = scrollRef.current
if (!el) {
return
}
const sessionSwitched = settleKeyRef.current !== sessionKey
if (sessionSwitched) {
settledNonEmptyRef.current = false
}
// Same-session refresh (transcript briefly cleared and repopulated) must
// keep the reader's position. Run before stopScroll / scrollTop reset so
// a refresh neither yanks the view nor clears the settled flag.
if (!shouldRePinOnTranscriptReload({ sessionSwitched, settledNonEmpty: settledNonEmptyRef.current })) {
return
}
stopScroll()
el.scrollTop = el.scrollHeight
loadSettledRef.current = false
// An anchor captured for the OUTGOING transcript must not be applied to
// this one — a switch owns the position outright. The empty→non-empty
// re-arm is the SAME load, whose in-flight anchor is still correct.
if (sessionSwitched) {
settleKeyRef.current = sessionKey
restoreFromBottomRef.current = null
}
let frame = 0
let stableFrames = 0
let lastHeight = el.scrollHeight
const settle = () => {
const node = scrollRef.current
if (!node) {
return
}
const height = node.scrollHeight
stableFrames = height === lastHeight ? stableFrames + 1 : 0
lastHeight = height
node.scrollTop = height
// Most session switches are synchronous and stabilize within 2 frames;
// the old 90-frame ceiling was for slow async image loads. Cap at 15
// frames to minimize the settle-loop racing markdown paint on every switch.
if (stableFrames >= 2 || ++frame > 15) {
void scrollToBottom('instant')
settledNonEmptyRef.current = hasGroups
loadSettledRef.current = true
return
}
rafId = requestAnimationFrame(settle)
}
let rafId = requestAnimationFrame(settle)
return () => cancelAnimationFrame(rafId)
}, [hasGroups, scrollRef, scrollToBottom, sessionKey, stopScroll])
// Prepend an older page while preserving the on-screen position. The user is
// scrolled up (reading history) so the stick-to-bottom lock is escaped and
// won't fight this manual restore. Spend the already-materialized DOM page
// first; only when that is exhausted pull more messages out of the session
// store (#55191).
const showEarlier = useCallback(() => {
const action = resolveShowEarlierAction(hiddenCount, olderAvailable)
if (!action) {
return
}
anchorBeforePrepend()
// Both paths grow the DOM budget by one pane page. Windowed rows are older
// than the current page, so expand-without-grow paints nothing.
setRenderBudget(budget => budget + paneBudget)
if (action === 'window') {
expandWindow()
}
}, [anchorBeforePrepend, expandWindow, hiddenCount, olderAvailable, paneBudget])
useLayoutEffect(() => {
const el = scrollRef.current
if (el && restoreFromBottomRef.current != null) {
el.scrollTop = el.scrollHeight - restoreFromBottomRef.current
restoreFromBottomRef.current = null
}
// renderBudget covers DOM pages; groups.length covers store-window expands.
}, [scrollRef, renderBudget, groups.length])
// The row array is memoized on the inputs the rows actually read. This
// component re-renders on every isAtBottom flip — and use-stick-to-bottom
// flips it from a ResizeObserver, so a sidebar DRAG re-renders this list per
// frame. Without the memo, the inline .map() rebuilt every row's JSX each
// time, and rebuilt children re-render their whole subtree even when nothing
// changed (measured live: 865 wasted Block renders in one drag, walked to
// "MessageRenderBoundary (children only)" by explain()). With it, React
// bails out on element identity and a scroll flip re-renders nothing below.
const rows = useMemo(
() =>
visibleGroups.map((group, indexInVisible) => (
<TurnRow
components={components}
group={group}
key={group.id}
resetKey={structuralSignature}
virtualized={indexInVisible < tailStart}
/>
)),
[visibleGroups, components, structuralSignature, tailStart]
)
return (
<div
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
style={
{
height: clampToComposer ? 'var(--thread-viewport-height)' : '100%',
...(secondaryWindow ? { '--sticky-human-top': secondaryTitlebarGap } : {})
} as CSSProperties
}
>
{secondaryWindow && (
// Secondary windows hide the titlebar chrome, so the scroller runs to
// the window's top edge and streamed text slides up under the OS
// traffic lights. Content padding alone scrolls away with the text — a
// fixed opaque strip (the titlebar's drag region) masks anything behind
// it and keeps the window draggable, matching the main window's header.
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 z-10 h-(--titlebar-height) bg-background [-webkit-app-region:drag]"
/>
)}
<div
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
data-following={isAtBottom ? 'true' : 'false'}
data-slot="aui_thread-viewport"
ref={scrollRef as React.RefCallback<HTMLDivElement>}
>
{renderEmpty ? (
<div
className="mx-auto grid h-full w-full max-w-(--composer-width) grid-rows-[minmax(0,1fr)_auto] min-w-0 gap-(--conversation-turn-gap) px-6 py-8"
data-slot="aui_thread-content"
>
{emptyPlaceholder}
</div>
) : (
<div
className={cn('mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6', threadContentTopPad)}
data-slot="aui_thread-content"
ref={contentRef as React.RefCallback<HTMLDivElement>}
>
{(hiddenCount > 0 || olderAvailable) && (
<button
className="mx-auto mb-(--conversation-turn-gap) rounded-full border border-border/65 bg-(--composer-fill) px-3 py-1 text-xs text-muted-foreground hover:text-foreground"
onClick={showEarlier}
type="button"
>
{t.assistant.thread.showEarlier}
</button>
)}
{rows}
{loadingIndicator}
{clampToComposer && (
<div
aria-hidden="true"
className="shrink-0"
data-slot="aui_composer-clearance"
style={{ height: 'var(--thread-last-message-clearance)' }}
/>
)}
</div>
)}
</div>
</div>
)
}
export const ThreadMessageList = memo(ThreadMessageListInner)
@@ -0,0 +1,356 @@
import {
type ReasoningMessagePartComponent,
type TextMessagePartProps,
type ToolCallMessagePartProps,
useAuiState,
useMessagePartReasoning
} from '@assistant-ui/react'
import { useStore } from '@nanostores/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 { McpSetupTool } from '@/components/assistant-ui/mcp-setup-tool'
import { AgentDeliveryNotice, deliveryTargetFromCommand } from '@/components/assistant-ui/thread/agent-delivery'
import { TimelineTimestamp } from '@/components/assistant-ui/thread/timeline-timestamp'
import { DelegateTool } from '@/components/assistant-ui/tool/delegate'
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool/fallback'
import { formatElapsed, useElapsedSeconds, useMeasuredDuration } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { GeneratedImage } from '@/components/chat/generated-image-result'
import { SCAFFOLD_LABEL_CLASS, SCAFFOLD_META_CLASS, ScaffoldRow } from '@/components/chat/scaffold-row'
import { useI18n } from '@/i18n'
import { generatedImageFromResult } from '@/lib/generated-images'
import { separateGluedReasoningBlocks } from '@/lib/reasoning-blocks'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { $reasoningCollapsedByDefault } from '@/store/reasoning-disclosure'
type TimelineToolCallProps = ToolCallMessagePartProps & { completedAt?: number; timestamp?: number }
const ImageGenerateTool: FC<TimelineToolCallProps> = props => {
const { args, completedAt, result, timestamp } = props
const aspectRatio = typeof args?.aspect_ratio === 'string' ? args.aspect_ratio : undefined
// The image card owns successful generations. Failed or malformed results
// still need the normal tool row: it extracts the error text and gives the
// user an honest, expandable failure rather than silently dropping the call.
if (result !== undefined && !generatedImageFromResult(result)) {
return <ToolFallback {...props} />
}
return (
<div className="mt-1.5">
<TimelineTimestamp className="mb-0.5 block" completedAt={completedAt} timestamp={timestamp} />
<GeneratedImage aspectRatio={aspectRatio} result={result} />
</div>
)
}
const DelegateToolPart: FC<TimelineToolCallProps> = props => {
// A call that failed outright dispatched nothing — there are no children to
// list, only an error. The generic row extracts and expands it properly.
if (props.isError) {
return <ToolFallback {...props} />
}
return (
<>
<TimelineTimestamp className="mb-0.5 block" completedAt={props.completedAt} timestamp={props.timestamp} />
<DelegateTool args={props.args} result={props.result} toolCallId={props.toolCallId} />
</>
)
}
const ChainToolFallback: FC<TimelineToolCallProps> = props => {
// todo parts are hoisted to a dedicated panel above the message content.
if (props.toolName === 'todo') {
return null
}
// An inter-agent delivery run through the terminal tool renders as the
// compact "Messaged X" / "Message from X" notices, not a transcript row
// (Grok-bots parity; the receiving side already renders notices via
// AGENT_MESSAGE_RE). Non-delivery terminal calls fall through unchanged.
if (props.toolName === 'terminal' && !props.isError) {
const command = typeof props.args?.command === 'string' ? props.args.command : ''
if (deliveryTargetFromCommand(command)) {
return <AgentDeliveryNotice {...props} />
}
}
// A reaction's UI is the emoji landing on the bubble (message.reaction
// event) — a "React To Message" tool block next to it would be the agent
// narrating its own tapback. Failures still render so they're debuggable.
if (props.toolName === 'react_to_message' && !props.isError) {
return null
}
if (props.toolName === 'delegate_task') {
return <DelegateToolPart {...props} />
}
if (props.toolName === 'image_generate') {
return <ImageGenerateTool {...props} />
}
if (props.toolName === 'clarify') {
return (
<>
<TimelineTimestamp className="mb-0.5 block" completedAt={props.completedAt} timestamp={props.timestamp} />
<ClarifyTool {...props} />
</>
)
}
if (props.toolName === 'setup_mcp') {
return <McpSetupTool {...props} />
}
return <ToolFallback {...props} />
}
type TimelineTextPartProps = TextMessagePartProps & { completedAt?: number; timestamp?: number }
const TimelineMarkdownText: FC<TimelineTextPartProps> = ({ completedAt, timestamp }) => (
<>
<TimelineTimestamp className="mb-0.5 block" completedAt={completedAt} timestamp={timestamp} />
<MarkdownText />
</>
)
const ThinkingDisclosure: FC<{
children: ReactNode
completedAt?: number
messageRunning?: boolean
pending?: boolean
timestamp?: number
// Required: the block's duration is remembered against this key, so a
// component that mounts after the block finished can still report it.
timerKey: string
}> = ({ children, completedAt, messageRunning = false, pending = false, timestamp, timerKey }) => {
const { t } = useI18n()
const reasoningCollapsedByDefault = useStore($reasoningCollapsedByDefault)
// `null` = no explicit user toggle yet. Live reasoning remains visible by
// default, unless the user opts into the low-jitter collapsed presentation.
const [userOpen, setUserOpen] = useState<boolean | null>(null)
const elapsed = useElapsedSeconds(pending, timerKey)
const thoughtFor = useMeasuredDuration(pending, timerKey)
const scrollRef = useRef<HTMLDivElement | null>(null)
const contentRef = useRef<HTMLDivElement | null>(null)
const enterRef = useEnterAnimation(messageRunning, timerKey)
// A live preview that later settles must not unmount its body — that is the
// "turn settled and everything jumped" shift. Latch that we showed one so
// the clip stays. Groups that mount already complete (earlier thoughts in
// a still-running turn) never latch, so they stay collapsed.
const [sawLivePreview, setSawLivePreview] = useState(false)
if (pending && !sawLivePreview) {
setSawLivePreview(true)
}
// The collapsed-by-default preference outranks the latch: it opts out of
// live previews entirely, so there is nothing to hold open.
const showPreview = !reasoningCollapsedByDefault && (pending || sawLivePreview)
const open = userOpen ?? showPreview
const isPreview = userOpen === null && showPreview
// Three ways a finished block can report itself. With a measured duration it
// says so, unless the timer's whole seconds round it to "0s" — accurate and
// useless — in which case it just says it was quick. With no duration at all
// it still has to read as finished; a turn that ended must not go on saying
// "Thinking".
let thoughtLabel = t.assistant.thread.thinking
if (!pending) {
if (thoughtFor === null) {
thoughtLabel = t.assistant.thread.thought
} else if (thoughtFor < 1) {
thoughtLabel = t.assistant.thread.thoughtBriefly
} else {
thoughtLabel = t.assistant.thread.thoughtFor(formatElapsed(thoughtFor))
}
}
// While the preview is live, pin the scroll container to the bottom on
// every content growth so the latest tokens are always visible.
useEffect(() => {
if (!isPreview) {
return
}
const el = scrollRef.current
const content = contentRef.current
if (!el || !content) {
return
}
// Height-gated: the observer also fires when the container's WIDTH changes
// (sidebar sash drag resizes every message), and pinning there forces a
// scrollHeight read+write per preview per frame. Only actual content
// growth needs the pin; the height rides the RO entry, reflow-free.
let lastHeight = -1
const pin = (entries: readonly ResizeObserverEntry[]) => {
const height = entries[entries.length - 1]?.borderBoxSize?.[0]?.blockSize ?? -1
const grew = height < 0 || height > lastHeight
lastHeight = height
if (grew) {
el.scrollTop = el.scrollHeight
}
}
// No sync pin(): the observer's guaranteed initial delivery runs it with
// layout already clean (still before paint), avoiding a forced reflow.
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-conversation-scaffold=""
data-slot="aui_thinking-disclosure"
ref={enterRef}
>
<ScaffoldRow
onToggle={() => setUserOpen(!open)}
open={open}
trailing={
<span className="flex shrink-0 items-center gap-1.5">
<TimelineTimestamp className={SCAFFOLD_META_CLASS} completedAt={completedAt} timestamp={timestamp} />
{pending && <ActivityTimerText className={SCAFFOLD_META_CLASS} seconds={elapsed} />}
</span>
}
>
<span className={cn(SCAFFOLD_LABEL_CLASS, pending && 'shimmer')}>{thoughtLabel}</span>
</ScaffoldRow>
{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). overflow-auto so
// the max-h-40 preview is a real scroller, not a clip.
'mt-0.5 w-full min-w-0 max-w-full overflow-auto overscroll-contain wrap-anywhere pb-1',
isPreview && 'max-h-40'
)}
data-slot="aui_thinking-body"
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)
)
const timestamp = useAuiState(s =>
s.message.parts.slice(Math.max(0, startIndex), endIndex + 1).reduce<number | undefined>((earliest, part) => {
const value = part.type === 'reasoning' ? (part as { timestamp?: number }).timestamp : undefined
return value === undefined ? earliest : earliest === undefined ? value : Math.min(earliest, value)
}, undefined)
)
const completedAt = useAuiState(s =>
s.message.parts.slice(Math.max(0, startIndex), endIndex + 1).reduce<number | undefined>((latest, part) => {
const value = part.type === 'reasoning' ? (part as { completedAt?: number }).completedAt : undefined
return value === undefined ? latest : latest === undefined ? value : Math.max(latest, value)
}, undefined)
)
if (!hasContent) {
return null
}
return (
// Keyed per block, not per message: the timer registry hands every caller
// of a key the same origin, so a turn that thinks three separate times used
// to measure the second and third blocks from the first one's start and
// report the running total as each block's duration.
<ThinkingDisclosure
completedAt={completedAt}
messageRunning={messageRunning}
pending={pending}
timerKey={`reasoning:${messageId}:${startIndex}`}
timestamp={timestamp}
>
{children}
</ThinkingDisclosure>
)
}
// Read the part from context, same contract as MarkdownText's
// useMessagePartText — the reasoning-only smoothing wrapper (removed) stalled
// the char-reveal at empty, blanking the widget.
const ReasoningTextPart: ReasoningMessagePartComponent = () => {
const { status, text } = useMessagePartReasoning()
const messageRunning = useAuiState(s => s.message.status?.type === 'running')
return (
<MarkdownTextContent
containerClassName="text-xs leading-snug text-muted-foreground/85"
containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
disableArtifacts
isRunning={status.type === 'running' || messageRunning}
text={separateGluedReasoningBlocks(text.trimStart())}
/>
)
}
// 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: TimelineMarkdownText,
ToolGroup: ToolGroupSlot,
tools: { Fallback: ChainToolFallback }
} as const
@@ -0,0 +1,208 @@
import { EmojiPicker } from 'frimousse'
import { type FC, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { triggerHaptic } from '@/lib/haptics'
import { Plus } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { QUICK_REACTIONS } from '@/store/reactions'
import type { MessageReaction } from '@/types/hermes'
// Served from the app's own origin (vite.config.ts `hermes:emojibase-assets`
// plugin bundles emojibase-data): Electron must work offline, and the app
// should never phone a CDN to draw a picker.
const EMOJIBASE_URL = './emojibase'
// Slack tints its picker cells in a repeating palette (green, blue, yellow,
// pink, brown, purple…) so long scrolls stay scannable. Same trick, in the
// app's own accent idiom (bg-emerald-500/15 etc. are existing patterns).
// Keyed off the emoji's codepoint — deterministic, and stable under
// frimousse's virtualized rows (an index cycle would reshuffle on scroll).
const CELL_TINTS = [
'hover:bg-emerald-500/15 data-[active]:bg-emerald-500/20',
'hover:bg-sky-500/15 data-[active]:bg-sky-500/20',
'hover:bg-amber-500/15 data-[active]:bg-amber-500/20',
'hover:bg-pink-500/15 data-[active]:bg-pink-500/20',
'hover:bg-orange-500/15 data-[active]:bg-orange-500/20',
'hover:bg-violet-500/15 data-[active]:bg-violet-500/20'
] as const
const cellTint = (emoji: string) => CELL_TINTS[(emoji.codePointAt(0) ?? 0) % CELL_TINTS.length]
/** The full emoji picker, revealed behind the quick row's "+". Headless — styled here. */
const FullEmojiPicker: FC<{ onSelect: (emoji: string) => void }> = ({ onSelect }) => (
<EmojiPicker.Root
className="flex h-72 w-76 flex-col"
emojibaseUrl={EMOJIBASE_URL}
onEmojiSelect={emoji => onSelect(emoji.emoji)}
>
{/* Borderless, underline-on-focus — the app's SearchField idiom (DESIGN.md),
not a boxed search bar. Search matches labels AND emojibase tags
("lol" → 😂), which frimousse handles natively. */}
<EmojiPicker.Search
autoFocus
className="mx-1 border-b border-(--ui-stroke-tertiary) bg-transparent px-1 pb-1 text-sm outline-hidden focus:border-(--ui-stroke-secondary)"
placeholder="Search…"
/>
<EmojiPicker.Viewport className="relative flex-1 outline-hidden">
<EmojiPicker.Loading className="absolute inset-0 grid place-items-center text-xs text-(--ui-text-tertiary)">
Loading emoji
</EmojiPicker.Loading>
<EmojiPicker.Empty className="absolute inset-0 grid place-items-center text-xs text-(--ui-text-tertiary)">
No emoji found.
</EmojiPicker.Empty>
<EmojiPicker.List
className="select-none pb-1"
components={{
CategoryHeader: ({ category, ...props }) => (
<div
className="bg-(--ui-bg-elevated) px-1.5 pt-2 pb-1 text-[0.6875rem] text-(--ui-text-tertiary)"
{...props}
>
{category.label}
</div>
),
Emoji: ({ emoji, ...props }) => (
<button
className={cn('grid size-8 shrink-0 place-items-center rounded-md text-lg', cellTint(emoji.emoji))}
{...props}
>
{emoji.emoji}
</button>
),
Row: ({ children, ...props }) => (
<div className="flex px-1" {...props}>
{children}
</div>
)
}}
/>
</EmojiPicker.Viewport>
</EmojiPicker.Root>
)
/**
* The reaction picker — six quick emoji, then "+" for the full set.
*
* Rides the shared Popover, so it inherits the app's menu/popover surface
* treatment rather than inventing a floating pill (DESIGN.md: popovers get one
* shared shadow + hairline; call sites don't reinvent elevation).
*/
export const ReactionPicker: FC<{
align?: 'end' | 'start'
children: React.ReactNode
onOpenChange: (open: boolean) => void
onSelect: (emoji: string) => void
open: boolean
selected?: string
}> = ({ align = 'end', children, onOpenChange, onSelect, open, selected }) => {
const [expanded, setExpanded] = useState(false)
return (
<Popover
onOpenChange={next => {
onOpenChange(next)
if (!next) {
// Always reopen on the quick row.
setExpanded(false)
}
}}
open={open}
>
<PopoverAnchor asChild>{children}</PopoverAnchor>
<PopoverContent
align={align}
// Opt this one surface out of the shared popover glass: emoji hover
// tints at 15% alpha are unreadable over blurred transcript text.
// Overriding the local surface var keeps the arrow matched for free.
className={cn('w-auto p-1 [--popover-surface:var(--ui-bg-elevated)]', !expanded && 'flex gap-0.5')}
onCloseAutoFocus={event => event.preventDefault()}
side="top"
>
{expanded ? (
<FullEmojiPicker onSelect={onSelect} />
) : (
<>
{QUICK_REACTIONS.map(emoji => (
<Button
aria-label={emoji}
aria-pressed={selected === emoji}
className={cn('text-base', selected === emoji && 'bg-(--chrome-action-hover)')}
key={emoji}
onClick={() => {
triggerHaptic('selection')
onSelect(emoji)
}}
size="icon-sm"
variant="ghost"
>
{emoji}
</Button>
))}
<Button aria-label="More emoji" onClick={() => setExpanded(true)} size="icon-sm" variant="ghost">
<Plus />
</Button>
</>
)}
</PopoverContent>
</Popover>
)
}
/**
* The reactions a message carries.
*
* Flat by design (DESIGN.md: "Flat, not boxed") — no pill, no border, no fill.
* It reads as quiet metadata in the same register as the message age and the
* checkpoint row it sits beside. Your own reaction is clickable to retract;
* the agent's is display-only.
*/
export const ReactionBadge: FC<{
className?: string
onRetract?: () => void
reactions: MessageReaction[]
}> = ({ className, onRetract, reactions }) => {
if (!reactions.length) {
return null
}
return (
<span
className={cn('flex items-center gap-1 text-[0.8125rem] leading-none', className)}
data-slot="aui_msg-reactions"
>
{reactions.map(reaction =>
reaction.author === 'user' && onRetract ? (
<button
aria-label={`Remove ${reaction.emoji} reaction`}
className="reaction-pop cursor-pointer leading-none transition-transform hover:scale-110 active:scale-95"
key={`${reaction.author}-${reaction.emoji}`}
onClick={event => {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onRetract()
}}
onPointerDown={event => {
event.preventDefault()
event.stopPropagation()
}}
type="button"
>
{reaction.emoji}
</button>
) : (
<span
className="reaction-pop leading-none"
key={`${reaction.author}-${reaction.emoji}`}
title="Reacted by Hermes"
>
{reaction.emoji}
</span>
)
)}
</span>
)
}
@@ -0,0 +1,100 @@
// Link previews moved off the message root into the AssistantPreviewEmbeds
// leaf, because the selector behind them (`'' while running`, the full
// `messageContentText(content)` join once settled) flipped on every
// running <-> settled transition and re-rendered the root with it.
//
// Two things are pinned here. That the embed still renders at all — the move
// was verbatim JSX and had no coverage before. And that it renders ONLY once
// the turn settles: the '' branch is a deliberate streaming optimization (it
// keeps the selector referentially stable so per-token flushes skip the regex
// scan), so a rewrite that drops it would make previews flicker in mid-stream
// with nothing to catch it.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Thread } from '.'
const createdAt = new Date('2026-05-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
vi.stubGlobal('CSS', { escape: (str: string) => str })
Element.prototype.scrollTo = function scrollTo() {}
Element.prototype.animate = function animate() {
return { cancel() {}, finished: Promise.resolve() } as unknown as Animation
}
afterEach(() => {
cleanup()
})
const assistantMetadata = { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} }
function user(id: string, text: string): ThreadMessage {
return {
id,
role: 'user',
content: [{ type: 'text', text }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistant(id: string, text: string, running: boolean): ThreadMessage {
return {
id,
role: 'assistant',
content: text ? [{ type: 'text', text }] : [],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: assistantMetadata
} as ThreadMessage
}
function Harness({ messages }: { messages: ThreadMessage[] }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages,
isRunning: messages.at(-1)?.status?.type === 'running',
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
const TARGET = 'https://example.com/docs'
const WITH_PREVIEW = `Serving now: [Preview: example](#preview/${TARGET})`
describe('settled-turn link previews', () => {
it('renders the embed once the turn has settled', async () => {
const { container } = render(<Harness messages={[user('u1', 'start it'), assistant('a1', WITH_PREVIEW, false)]} />)
await screen.findByText('Serving now:', { exact: false })
expect(container.querySelector(`[title="${TARGET}"]`)).toBeTruthy()
})
it('does not render the embed while the turn is still running', async () => {
const { container } = render(<Harness messages={[user('u1', 'start it'), assistant('a1', WITH_PREVIEW, true)]} />)
await screen.findByText('Serving now:', { exact: false })
expect(container.querySelector(`[title="${TARGET}"]`)).toBeNull()
})
})
@@ -0,0 +1,166 @@
// The invalidation-scoping property, as a render-count contract.
//
// A streaming turn flips its message status many times a second, and at
// stream breadth N that is N status flips per flush. The whole point of this
// work is that a flip re-renders only the small leaves that actually display
// status — never the message ROOT, whose subtree is the entire rendered
// message and whose re-render is what widened style recalculation to document
// scope.
//
// That property is invisible to a DOM assertion: the transcript looks
// identical either way. So this counts renders instead. AssistantMessageBody
// is the root component, and `useTapbackDoubleClick` is called by it and by
// nothing else in the tree, which makes it an exact render counter for the
// root without needing to export or wrap an internal component.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as messageReactionsModule from '@/components/assistant-ui/thread/use-message-reactions'
import { Thread } from '.'
let rootRenders = 0
vi.mock('@/components/assistant-ui/thread/use-message-reactions', async importActual => {
const actual = await importActual<typeof messageReactionsModule>()
return {
...actual,
useTapbackDoubleClick: (messageId: string, role: 'assistant' | 'user') => {
if (role === 'assistant') {
rootRenders += 1
}
return actual.useTapbackDoubleClick(messageId, role)
}
}
})
const createdAt = new Date('2026-05-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
vi.stubGlobal('CSS', { escape: (str: string) => str })
Element.prototype.scrollTo = function scrollTo() {}
Element.prototype.animate = function animate() {
return { cancel() {}, finished: Promise.resolve() } as unknown as Animation
}
beforeEach(() => {
rootRenders = 0
})
afterEach(() => {
cleanup()
})
const assistantMetadata = { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} }
function user(id: string, text: string): ThreadMessage {
return {
id,
role: 'user',
content: [{ type: 'text', text }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistant(id: string, text: string, running: boolean): ThreadMessage {
return {
id,
role: 'assistant',
content: text ? [{ type: 'text', text }] : [],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: assistantMetadata
} as ThreadMessage
}
function Harness({ messages }: { messages: ThreadMessage[] }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages,
isRunning: messages.at(-1)?.status?.type === 'running',
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('streaming-status invalidation scope', () => {
it('does not re-render the message root when the turn settles', async () => {
const messages = [user('u1', 'question'), assistant('a1', 'partial answer', true)]
const { container, findByText, rerender } = render(<Harness messages={messages} />)
await findByText('partial answer')
// The leaf carries the streaming flag while the turn is in flight.
expect(container.querySelector('[data-message-streaming="true"]')).toBeTruthy()
const rendersWhileStreaming = rootRenders
rerender(<Harness messages={[messages[0], assistant('a1', 'partial answer', false)]} />)
// The leaf saw the flip...
await waitFor(() => {
expect(container.querySelector('[data-message-streaming="true"]')).toBeNull()
})
// ...on the same permanently-mounted node (attribute toggle, no remount)...
expect(container.querySelector('[data-slot="aui_message-streaming-marker"]')).toBeTruthy()
// ...and the root did not re-render for it.
expect(rootRenders).toBe(rendersWhileStreaming)
})
it('does not re-render the message root when streaming text arrives', async () => {
const messages = [user('u1', 'question'), assistant('a1', 'one', true)]
const { findByText, rerender } = render(<Harness messages={messages} />)
await findByText('one')
const rendersAfterFirstToken = rootRenders
// A delta flush: same status, more text. The root must not subscribe to
// the streaming text either — only the markdown part re-renders.
rerender(<Harness messages={[messages[0], assistant('a1', 'one two', true)]} />)
await findByText('one two')
expect(rootRenders).toBe(rendersAfterFirstToken)
})
it('keeps the same root DOM node across the settle transition', async () => {
// The inter-agent reply collapses once it settles. Rendering that as a
// competing root swapped the element type at this position, so React
// unmounted the row and mounted a fresh one — discarding the DOM the
// scroll anchor was holding.
const delivery = 'Message from 🤖 Hermes (@hermes): please check the build'
const messages = [user('u1', delivery), assistant('a1', 'working on it', true)]
const { container, findByText, rerender } = render(<Harness messages={messages} />)
await findByText('working on it')
const before = container.querySelector('[data-slot="aui_assistant-message-root"]')
expect(before).toBeTruthy()
rerender(<Harness messages={[messages[0], assistant('a1', 'working on it', false)]} />)
await findByText(/Replied to/)
const after = container.querySelector('[data-slot="aui_assistant-message-root"]')
// Same element, updated in place — not a remount.
expect(after).toBe(before)
})
})
@@ -0,0 +1,72 @@
// The thinking indicator (dither block) may only ever render at the TAIL of
// the thread. A message stuck status:running mid-transcript — however it got
// there (missed settle event, steer race, upstream state bug) — must render
// its content with no spinner: a live indicator above a later user message
// reads as the agent answering out of order.
import { type ThreadMessage } from '@assistant-ui/react'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { stubThreadEnvironment, ThreadRuntime, userMessage } from '../test-utils'
import { Thread } from '.'
const createdAt = new Date('2026-05-01T00:00:00.000Z')
stubThreadEnvironment()
afterEach(() => {
cleanup()
})
const assistantMetadata = {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
function assistant(id: string, text: string, running: boolean): ThreadMessage {
return {
id,
role: 'assistant',
content: text ? [{ type: 'text', text }] : [],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: assistantMetadata
} as ThreadMessage
}
const Harness = ({ messages }: { messages: ThreadMessage[] }) => (
<ThreadRuntime messages={messages}>
<Thread />
</ThreadRuntime>
)
describe('thinking indicator is tail-only', () => {
it('shows the loading indicator on a running placeholder at the tail', async () => {
const { container } = render(<Harness messages={[userMessage('u1', 'question'), assistant('a1', '', true)]} />)
expect(await screen.findByRole('status', { name: 'Hermes is loading a response' })).toBeTruthy()
expect(container.querySelector('[data-slot="aui_response-loading"]')).toBeTruthy()
})
it('never shows an indicator on a stale running message mid-transcript', async () => {
// A stranded pending bubble from an earlier turn, then a newer exchange.
const { container } = render(
<Harness
messages={[
userMessage('u1', 'first question'),
assistant('a1', '', true),
userMessage('u2', 'second question'),
assistant('a2', 'answered', false)
]}
/>
)
await screen.findByText('answered')
expect(container.querySelector('[data-slot="aui_response-loading"]')).toBeNull()
expect(container.querySelector('[data-slot="aui_turn-activity"]')).toBeNull()
})
})
@@ -0,0 +1,88 @@
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { __resetElapsedTimerRegistryForTests } from '@/components/chat/activity-timer'
import { I18nProvider } from '@/i18n'
import { $providerWaitSessions, setSessionProviderWait } from '@/store/provider-wait'
import { $activeSessionId, $turnStartedAt } from '@/store/session'
import { ResponseLoadingIndicator } from './status'
function renderIndicator() {
return render(
<I18nProvider configClient={null} initialLocale="en">
<ResponseLoadingIndicator />
</I18nProvider>
)
}
describe('ResponseLoadingIndicator timer', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
// useViewedInterval gates ticking on document focus + visibility; jsdom's
// hasFocus() is unreliable across runners, so pin it (same as the
// background-sync backstop tests).
vi.spyOn(globalThis.document, 'hasFocus').mockReturnValue(true)
__resetElapsedTimerRegistryForTests()
})
afterEach(() => {
cleanup()
$activeSessionId.set(null)
$turnStartedAt.set(null)
$providerWaitSessions.set({})
__resetElapsedTimerRegistryForTests()
vi.restoreAllMocks()
vi.useRealTimers()
})
it('preserves each running session timer while switching between sessions', () => {
$activeSessionId.set('session-a')
$turnStartedAt.set(Date.now())
const sessionA = renderIndicator()
act(() => vi.advanceTimersByTime(5_000))
expect(screen.getAllByText((_, node) => node?.textContent === '5s').length).toBeGreaterThan(0)
sessionA.unmount()
$activeSessionId.set('session-b')
$turnStartedAt.set(Date.now())
const sessionB = renderIndicator()
act(() => vi.advanceTimersByTime(3_000))
expect(screen.getAllByText((_, node) => node?.textContent === '3s').length).toBeGreaterThan(0)
sessionB.unmount()
$activeSessionId.set('session-a')
$turnStartedAt.set(new Date('2026-01-01T00:00:00.000Z').getTime())
renderIndicator()
expect(screen.getAllByText((_, node) => node?.textContent === '8s').length).toBeGreaterThan(0)
})
it('names a prolonged provider wait in the existing response status row', () => {
$activeSessionId.set('session-a')
$turnStartedAt.set(Date.now())
setSessionProviderWait('session-a', '⏳ waiting on local-model — 30s with no output yet')
renderIndicator()
expect(screen.getByText('⏳ waiting on local-model — 30s with no output yet')).toBeTruthy()
})
})
// The status line sits between tool rows and thinking headers, which the
// transcript rests at a fade. Without the mark it reads a shade brighter than
// both — the one line in the column claiming emphasis it hasn't earned.
describe('status line', () => {
afterEach(cleanup)
it('is marked as transcript scaffolding', () => {
$activeSessionId.set('session-a')
$turnStartedAt.set(Date.now())
const { container } = renderIndicator()
expect(container.querySelector('[role="status"]')?.hasAttribute('data-conversation-scaffold')).toBe(true)
})
})
@@ -0,0 +1,387 @@
import { useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type FC, type ReactNode, useEffect, useMemo, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { activitySignature, toolNarratesWait, TURN_QUIET_S } from '@/components/assistant-ui/thread/turn-activity'
import { toolPresentVerb } from '@/components/assistant-ui/tool/run-summary'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { SCAFFOLD_LABEL_CLASS } from '@/components/chat/scaffold-row'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { StatusPulse } from '@/components/ui/status-pulse'
import { getLocalModelsStatus } from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $backgroundResume } from '@/store/background-delegation'
import { sessionCompacting } from '@/store/compaction'
import { $localModelsEnabled } from '@/store/local-models-flag'
import { sessionAwaitingInput } from '@/store/prompts'
import { parseModelLoadWait, sessionProviderWait } from '@/store/provider-wait'
import { $currentModel } from '@/store/session'
import { type DraftingTool, sessionDraftingTool } from '@/store/tool-drafting'
import type { LocalModelLoadProgress } from '@/types/hermes'
// A status line is scaffolding like any other — "Editing" while the model
// drafts a call is the same kind of line as "Explored 3 files" once it has run,
// and reads as one continuous column only if it shares their type and colour.
const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({
children,
label,
className,
...rest
}) => (
<div
aria-label={label}
aria-live="polite"
className={cn(
'flex min-w-0 max-w-full items-center gap-1.5 self-start leading-(--conversation-line-height)',
'text-(--conversation-scaffold-text)',
className
)}
data-conversation-scaffold=""
role="status"
{...rest}
>
{children}
</div>
)
// Fixed label while auto-compaction runs — decoupled from backend status text.
const COMPACTION_LABEL = 'Summarizing thread'
const HintText: FC<{ children: ReactNode }> = ({ children }) => (
<span className={cn(SCAFFOLD_LABEL_CLASS, 'shimmer min-w-0 flex-1 truncate')}>{children}</span>
)
/** Renderer-side load synthesis: poll the local-models status while a turn
* is busy with NO progress frame from the backend. The backend's wait loop
* only narrates the MAIN chat request — a model load triggered while the
* gateway is still initializing, or one consumed by a parallel auxiliary
* call (title generation autoloads the same model), never gets a frame,
* and the load looked like nothing was happening. The status route reads
* the same SSE snapshot, so this bar carries the identical percent. */
function useLocalModelLoad(active: boolean): (LocalModelLoadProgress & { model: string }) | null {
const model = useStore($currentModel)
const [progress, setProgress] = useState<(LocalModelLoadProgress & { model: string }) | null>(null)
// Behind the --local launch flag: without it, no status polling and no
// load bar (the local server can't be the current provider anyway).
const enabled = $localModelsEnabled.get()
useEffect(() => {
if (!enabled || !active || !model) {
setProgress(null)
return
}
let cancelled = false
let timer: number | undefined
const tick = async () => {
try {
const status = await getLocalModelsStatus()
const entry = status.loading?.[model]
if (!cancelled) {
setProgress(entry ? { ...entry, model } : null)
}
} catch {
if (!cancelled) {
setProgress(null)
}
}
if (!cancelled) {
timer = window.setTimeout(() => void tick(), 1_500)
}
}
void tick()
return () => {
cancelled = true
if (timer !== undefined) {
window.clearTimeout(timer)
}
}
}, [enabled, active, model])
return progress
}
/** Wait hint with a real progress bar for managed-local model loads and
* prompt processing. The percents come from llama-server itself (per-tensor
* load callback / live prefill counter, via the gateway's wait frames), so a
* determinate bar is honest — a 40s cold load or a long prefill reads as
* visible progress instead of an alarming stall. */
const WaitHint: FC<{ hint: string }> = ({ hint }) => {
const { t } = useI18n()
const load = parseModelLoadWait(hint)
if (!load) {
return <HintText>{hint}</HintText>
}
const label =
load.kind === 'load' ? t.assistant.thread.loadingLocalModel(load.model) : t.assistant.thread.processingPrompt
return <ProgressHint label={label} percent={load.percent} />
}
const ProgressHint: FC<{ label: string; percent: null | number }> = ({ label, percent }) => (
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className={cn(SCAFFOLD_LABEL_CLASS, 'shimmer min-w-0 shrink truncate')}>{label}</span>
{percent !== null && (
<>
<span className="h-1 w-24 shrink-0 overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
<span
className="block h-full rounded-full bg-primary transition-[width] duration-500"
style={{ width: `${Math.max(2, percent)}%` }}
/>
</span>
<span className={cn(SCAFFOLD_LABEL_CLASS, 'shrink-0 tabular-nums')}>{percent}%</span>
</>
)}
</span>
)
/** These indicators render inside whichever transcript mounted them, so every
* session-scoped signal comes from that surface's view — a tile must never
* show the primary chat's compaction, prompt-wait, or turn timer. */
function useThreadSessionStatus() {
const view = useSessionView()
const sessionId = useStore(view.$runtimeId)
// The same turn-busy the composer's arc border and Stop button read. The
// message-level `running` flag is a weaker signal: it goes false in the gaps
// between bubbles (a sealed interim row, a settled turn the backend hasn't
// finished with), which is exactly when the transcript used to fall silent
// while the app still said it was working.
const busy = useStore(view.$busy)
const turnStartedAt = useStore(view.$turnStartedAt)
const compacting = useStore(useMemo(() => sessionCompacting(sessionId), [sessionId]))
const drafting = useStore(useMemo(() => sessionDraftingTool(sessionId), [sessionId]))
const providerWait = useStore(useMemo(() => sessionProviderWait(sessionId), [sessionId]))
// 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(useMemo(() => sessionAwaitingInput(sessionId), [sessionId]))
return {
awaitingInput,
busy,
compacting,
drafting,
providerWait,
// Epoch ms this surface's turn began, or undefined between turns. The
// origin for anything measuring the WHOLE turn rather than one phase of
// it — including the first seconds of a brand-new chat, where the value is
// seeded at submit and there is no runtime session to key off yet.
turnStartedAt: turnStartedAt ?? undefined
}
}
// Long enough that a tool whose arguments arrive in a few frames never gets to
// strobe a label, short enough that a real wait is named almost immediately.
const DRAFTING_REVEAL_MS = 200
/**
* What to call the wait, if it deserves a name. Compaction outranks a draft —
* it's rarer, slower, and explains a transcript that looks like it reset.
*/
function useStatusHint(compacting: boolean, drafting: DraftingTool | null, providerWait: string): string {
const [revealed, setRevealed] = useState(false)
const name = drafting?.name ?? ''
useEffect(() => {
setRevealed(false)
if (!name) {
return
}
const id = window.setTimeout(() => setRevealed(true), DRAFTING_REVEAL_MS)
return () => window.clearTimeout(id)
}, [name])
if (compacting) {
return COMPACTION_LABEL
}
if (providerWait) {
return providerWait
}
return revealed && name ? toolPresentVerb(name) : ''
}
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 { compacting, drafting, providerWait, turnStartedAt } = useThreadSessionStatus()
const elapsed = useElapsedSeconds(true, undefined, turnStartedAt)
const hint = useStatusHint(compacting, drafting, providerWait)
// Renderer-synthesized load bar: covers loads the backend's wait loop
// can't narrate (gateway still initializing, or an auxiliary call — not
// the main request — triggered the autoload). A real wait frame wins.
const localLoad = useLocalModelLoad(!hint)
return (
<StatusRow data-slot="aui_response-loading" label={hint || t.assistant.thread.loadingResponse}>
<StatusPulse
aria-hidden="true"
className="dither inline-block size-3 rounded-[2px] text-midground/80"
kind="opacity"
/>
{hint ? (
<WaitHint hint={hint} />
) : localLoad ? (
<ProgressHint label={t.assistant.thread.loadingLocalModel(localLoad.model)} percent={localLoad.percent} />
) : null}
<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>
)
}
// Tail activity row. The pre-first-token spinner goes away once content flows,
// but a turn keeps working through gaps it produces nothing during — between
// one tool result landing and the next call arriving, while the provider
// thinks, while a sealed bubble waits on the next one. The composer's arc
// border and Stop button are lit through all of it; the transcript used to be
// silent for most of it, and those seconds went uncounted.
//
// So this row follows the SAME busy signal the composer does, and times every
// gap from the moment the turn last showed something rather than from its own
// mount. What it doesn't do is double-narrate: a tool call in flight already
// carries its own row and timer.
//
// 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 TurnActivityIndicator: FC = () => {
const { t } = useI18n()
const activity = useAuiState(s => activitySignature(s.message.content))
// Timestamp of the last visible progress, held from the moment the quiet
// spell qualifies. Holding the timestamp (not a boolean) is what lets the
// timer read "quiet for 12s" rather than the age of this component, which is
// the whole turn so far.
const [quietSince, setQuietSince] = useState<number | undefined>(undefined)
const { awaitingInput, busy, compacting, drafting, providerWait, turnStartedAt } = useThreadSessionStatus()
const hint = useStatusHint(compacting, drafting, providerWait)
// A tool run at the tail already narrates the wait — its summary counts the
// calls, its ticker names the current one, and it carries its own timer. A
// second spinner under that adds a line and says nothing new. Silent tools
// (`todo`, reactions) render nothing, so they narrate nothing.
const toolNarrating = useAuiState(s => toolNarratesWait(s.message.content))
// Streaming counts as working too, and it leads busy by a flush on the first
// turn of a fresh chat — so the row can't wait for the store to catch up.
const messageRunning = useAuiState(s => s.message.status?.type === 'running')
// Renderer-synthesized load bar (see ResponseLoadingIndicator).
const working = busy || messageRunning
const localLoad = useLocalModelLoad(working && !hint && !toolNarrating)
useEffect(() => {
setQuietSince(undefined)
const seenAt = Date.now()
const id = window.setTimeout(() => setQuietSince(seenAt), TURN_QUIET_S * 1000)
return () => window.clearTimeout(id)
}, [activity])
// Every second the app claims to be working belongs to something. A named
// wait says what it is straight away; an unnamed gap has to go quiet for
// TURN_QUIET_S first, or a run of quick calls would strobe a row between
// each one. The two exemptions are waits already accounted for elsewhere: a
// question the user is answering, and a tool call carrying its own timer.
// A live local-model load is a named wait too — it must not wait out the
// quiet window (the load IS the story from second one).
const active =
working && !awaitingInput && !toolNarrating && (Boolean(hint) || localLoad !== null || quietSince !== undefined)
// Compaction owns the whole turn, so it keeps counting from the turn's start;
// anything else counts from the moment the turn last produced something — the
// gap's own mark, or the draft's, whichever named the wait first.
const elapsed = useElapsedSeconds(
active,
undefined,
compacting ? turnStartedAt : (quietSince ?? drafting?.since ?? turnStartedAt)
)
if (!active) {
return null
}
return (
<StatusRow data-slot="aui_turn-activity" label={hint || 'Hermes is working'}>
<StatusPulse
aria-hidden="true"
className="dither inline-block size-3 rounded-[2px] text-midground/80"
kind="opacity"
/>
{hint ? (
<WaitHint hint={hint} />
) : localLoad ? (
<ProgressHint label={t.assistant.thread.loadingLocalModel(localLoad.model)} percent={localLoad.percent} />
) : null}
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)
}
@@ -0,0 +1,793 @@
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { useEffect, useState } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { $reasoningCollapsedByDefault } from '@/store/reasoning-disclosure'
import { stubThreadEnvironment, stubThreadViewportSize, ThreadRuntime } from '../test-utils'
import { Thread } from '.'
const createdAt = new Date('2026-05-01T00:00:00.000Z')
const resizeObservers = new Set<TestResizeObserver>()
class TestResizeObserver {
private target: Element | null = null
constructor(private readonly callback: ResizeObserverCallback) {
resizeObservers.add(this)
}
observe(target: Element) {
this.target = target
}
unobserve() {}
disconnect() {
resizeObservers.delete(this)
}
trigger(height: number) {
if (!this.target) {
return
}
this.callback(
[
{
contentRect: { height } as DOMRectReadOnly,
target: this.target
} as ResizeObserverEntry
],
this as unknown as ResizeObserver
)
}
}
stubThreadEnvironment()
// This suite drives the virtualizer, so it needs an observer that reports.
vi.stubGlobal('ResizeObserver', TestResizeObserver)
stubThreadViewportSize()
async function wait(ms: number) {
await act(async () => {
await new Promise(resolve => window.setTimeout(resolve, ms))
})
}
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'Stream a response' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(text: string, running = true): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text }],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantErrorMessage(error: string): ThreadMessage {
return {
id: 'assistant-error-1',
role: 'assistant',
content: [],
status: { type: 'incomplete', reason: 'error', error },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantReasoningMessage(text: string, running = false): ThreadMessage {
return {
id: 'assistant-reasoning-1',
role: 'assistant',
content: [{ type: 'reasoning', text }],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantMultiReasoningMessage(texts: string[]): ThreadMessage {
return {
id: 'assistant-reasoning-multi-1',
role: 'assistant',
content: texts.map(text => ({ type: 'reasoning', text })),
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantSeparatedReasoningMessage(): ThreadMessage {
return {
id: 'assistant-reasoning-separated-1',
role: 'assistant',
content: [
{ type: 'reasoning', text: ' Complete first thought.', status: { type: 'complete' } },
{ type: 'text', text: 'Interim answer.' },
{ type: 'reasoning', text: ' Streaming second thought.', status: { type: 'running' } }
],
status: { type: 'running' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantTodoMessage(
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>,
running = true
): ThreadMessage {
const suffix = todos.map(todo => `${todo.id}:${todo.status}`).join('|') || 'empty'
return {
id: `assistant-todo-${running ? 'running' : 'done'}-${suffix}`,
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'todo-1',
toolName: 'todo',
args: { todos },
argsText: JSON.stringify({ todos }),
...(running ? {} : { result: { todos } })
}
],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantImageMessage(
running = false,
result: unknown = { image: 'https://cdn.example/cat.png', success: true }
): ThreadMessage {
return {
id: `assistant-image-${running ? 'running' : 'done'}`,
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'image-1',
toolName: 'image_generate',
args: { prompt: 'draw a cat' },
argsText: JSON.stringify({ prompt: 'draw a cat' }),
...(running ? {} : { result })
}
],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantTerminalMessage(): ThreadMessage {
return {
id: 'assistant-terminal-1',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'terminal-1',
toolName: 'terminal',
args: { command: 'npm run check --workspace=apps/desktop' },
argsText: JSON.stringify({ command: 'npm run check --workspace=apps/desktop' }),
result: { exit_code: 0, stdout: 'all checks passed' }
}
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
interface StreamingControls {
emitSecond: () => void
complete: () => void
}
function StreamingHarness({ onControls }: { onControls?: (controls: StreamingControls) => void } = {}) {
const [messages, setMessages] = useState<ThreadMessage[]>([userMessage()])
const [isRunning, setIsRunning] = useState(true)
useEffect(() => {
const first = window.setTimeout(() => {
setMessages([userMessage(), assistantMessage('first chunk')])
}, 50)
if (onControls) {
onControls({
emitSecond: () => {
setMessages([userMessage(), assistantMessage('first chunk second chunk')])
},
complete: () => {
setMessages([userMessage(), assistantMessage('first chunk second chunk', false)])
setIsRunning(false)
}
})
return () => window.clearTimeout(first)
}
const second = window.setTimeout(() => {
setMessages([userMessage(), assistantMessage('first chunk second chunk')])
}, 500)
const complete = window.setTimeout(() => {
setMessages([userMessage(), assistantMessage('first chunk second chunk', false)])
setIsRunning(false)
}, 700)
return () => {
window.clearTimeout(first)
window.clearTimeout(second)
window.clearTimeout(complete)
}
}, [onControls])
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages,
isRunning,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread loading={isRunning && messages.at(-1)?.role !== 'assistant' ? 'response' : undefined} />
</AssistantRuntimeProvider>
)
}
const TodoHarness = ({ message }: { message: ThreadMessage }) => (
<ThreadRuntime messages={[message]}>
<Thread />
</ThreadRuntime>
)
function MessageHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function TranscriptHarness({ messages }: { messages: ThreadMessage[] }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages,
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function assistantInterimMessage(text: string, id = 'assistant-interim-1'): ThreadMessage {
return {
id,
role: 'assistant',
content: [{ type: 'text', text }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: { interim: true }
}
} as ThreadMessage
}
function RunningMessageHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
isRunning: true,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function ReasoningHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantReasoningMessage(' The user is asking what this file is.')],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function RunningReasoningHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantReasoningMessage('```ts\nconst answer = 42\n', true)],
isRunning: true,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
// A turn that streams reasoning and then settles — the transition the
// preview latch exists for. `settle()` flips the thread to not-running.
function renderSettlingReasoning() {
let setRunning: ((running: boolean) => void) | undefined
function SettlingReasoningHarness() {
const [running, setRunningState] = useState(true)
setRunning = setRunningState
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantReasoningMessage('The user asked a question.', running)],
isRunning: running,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
const { container } = render(<SettlingReasoningHarness />)
return { container, settle: () => act(() => setRunning?.(false)) }
}
function GroupedReasoningHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantMultiReasoningMessage([' First thought.', ' Second thought.'])],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function IntroHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread intro={{ personality: 'default', seed: 1 }} />
</AssistantRuntimeProvider>
)
}
function DismissibleErrorHarness({ onDismissError }: { onDismissError: (messageId: string) => void }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantErrorMessage('OpenRouter rejected the request (403).')],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread onDismissError={onDismissError} />
</AssistantRuntimeProvider>
)
}
describe('assistant-ui streaming renderer', () => {
beforeEach(() => {
resizeObservers.clear()
$reasoningCollapsedByDefault.set(false)
})
it('renders assistant text incrementally before completion', async () => {
let controls: StreamingControls | undefined
const registerControls = (next: StreamingControls) => {
controls = next
}
const { container } = render(<StreamingHarness onControls={registerControls} />)
expect(screen.getByRole('status', { name: 'Hermes is loading a response' })).toBeTruthy()
await waitFor(() => {
expect(container.textContent).toContain('first chunk')
})
expect(container.textContent).not.toContain('second chunk')
expect(screen.queryByRole('status', { name: 'Hermes is loading a response' })).toBeNull()
// Producer-gated, not wall-clock-gated: the old test slept 80ms and
// assumed a 500ms timer could not fire before the assertion. On a loaded
// runner the test thread could be descheduled for >500ms, so both chunks
// arrived and this clean behavior test flaked.
act(() => controls?.emitSecond())
await waitFor(() => {
expect(container.textContent).toContain('first chunk second chunk')
})
act(() => controls?.complete())
await waitFor(() => {
expect(container.textContent).toContain('first chunk second chunk')
})
})
it('does not render composer clearance for intro-only threads', () => {
const { container } = render(<IntroHarness />)
expect(container.querySelector('[data-slot="aui_composer-clearance"]')).toBeNull()
})
it('suppresses the action footer on sealed interim messages, keeping it on the final reply', () => {
const { container } = render(
<TranscriptHarness
messages={[
userMessage(),
assistantInterimMessage('Let me check the files.'),
assistantInterimMessage('Now applying the patch.', 'assistant-interim-2'),
assistantMessage('All done — patch applied.', false)
]}
/>
)
// Interim commentary stays visible…
expect(container.textContent).toContain('Let me check the files.')
expect(container.textContent).toContain('Now applying the patch.')
expect(container.textContent).toContain('All done — patch applied.')
// …but only the turn's final reply carries the copy/refresh action bar.
const actionBars = container.querySelectorAll('[data-slot="aui_msg-actions"]')
expect(actionBars).toHaveLength(1)
const finalRoot = [...container.querySelectorAll('[data-slot="aui_assistant-message-root"]')].find(root =>
root.textContent?.includes('All done — patch applied.')
)
expect(finalRoot?.querySelector('[data-slot="aui_msg-actions"]')).toBeTruthy()
})
it('puts the turn duration on the action bar row instead of a line of its own', () => {
const settled = {
...assistantMessage('All done.', false),
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: { durationS: 12 }
}
} as ThreadMessage
const { container } = render(<TranscriptHarness messages={[userMessage(), settled]} />)
const duration = container.querySelector('[data-slot="aui_turn-duration"]')
const actions = container.querySelector('[data-slot="aui_msg-actions"]')
// Same row as the (always-mounted) action bar: the footer's height is
// already reserved while the turn streams, so landing the duration there
// adds no height when the turn settles.
expect(duration).toBeTruthy()
expect(duration?.parentElement).toBe(actions?.parentElement)
})
it('renders assistant provider errors inline', () => {
render(<MessageHarness message={assistantErrorMessage('OpenRouter rejected the request (403).')} />)
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
})
it('omits the dismiss control when no onDismissError handler is supplied', () => {
render(<MessageHarness message={assistantErrorMessage('OpenRouter rejected the request (403).')} />)
expect(screen.queryByRole('button', { name: 'Dismiss error' })).toBeNull()
})
it('invokes onDismissError with the errored message id when the dismiss control is clicked', () => {
const onDismissError = vi.fn()
render(<DismissibleErrorHarness onDismissError={onDismissError} />)
const dismiss = screen.getByRole('button', { name: 'Dismiss error' })
fireEvent.click(dismiss)
expect(onDismissError).toHaveBeenCalledTimes(1)
expect(onDismissError).toHaveBeenCalledWith('assistant-error-1')
})
// Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned
// by the use-stick-to-bottom library and covered by its own test suite. We
// don't re-assert its scrollTop mechanics here — doing so in jsdom (no real
// layout, spring animation via rAF) only produces brittle change-detector
// tests. The rendering/streaming-content tests below remain the contract.
it('renders an incomplete streaming fenced code block as a code card', async () => {
const { container } = render(<RunningMessageHarness message={assistantMessage('```ts\nconst answer = 42\n')} />)
await waitFor(() => {
expect(container.querySelector('[data-slot="code-card"]')).toBeTruthy()
})
expect(container.textContent).toContain('const answer = 42')
expect(container.textContent).not.toContain('```ts')
})
it('renders an incomplete streaming reasoning fenced code block as a code card', async () => {
const { container } = render(<RunningReasoningHarness />)
const ui = within(container)
const thinkingToggle = ui.getByRole('button', { name: /thinking/i })
if (thinkingToggle.getAttribute('aria-expanded') !== 'true') {
fireEvent.click(thinkingToggle)
}
await waitFor(() => {
expect(container.querySelector('[data-slot="code-card"]')).toBeTruthy()
})
await waitFor(() => {
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toContain('const answer = 42')
})
expect(container.textContent).not.toContain('```ts')
})
it('keeps the height-capped thinking preview scrollable after the turn settles', async () => {
const { container, settle } = renderSettlingReasoning()
const live = container.querySelector('[data-slot="aui_thinking-body"]')?.className ?? ''
expect(live).toContain('max-h-40')
expect(live).toMatch(/\boverflow-auto\b/)
expect(live).not.toMatch(/\boverflow-hidden\b/)
settle()
await waitFor(() => {
expect(within(container).getByRole('button', { name: /thought/i })).toBeTruthy()
})
const settled = container.querySelector('[data-slot="aui_thinking-body"]')?.className ?? ''
expect(settled).toContain('max-h-40')
expect(settled).toMatch(/\boverflow-auto\b/)
expect(settled).not.toMatch(/\boverflow-hidden\b/)
})
it('does not collapse a live thinking preview when the turn settles', async () => {
const { container, settle } = renderSettlingReasoning()
const toggle = within(container).getByRole('button', { name: /thinking/i })
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(container.querySelector('[data-slot="aui_reasoning-text"]')).toBeTruthy()
settle()
await waitFor(() => {
expect(
within(container)
.getByRole('button', { name: /thought/i })
.getAttribute('aria-expanded')
).toBe('true')
})
expect(container.querySelector('[data-slot="aui_reasoning-text"]')).toBeTruthy()
})
it('leaves a settling turn collapsed when the collapsed-by-default preference is enabled', async () => {
$reasoningCollapsedByDefault.set(true)
const { container, settle } = renderSettlingReasoning()
expect(
within(container)
.getByRole('button', { name: /thinking/i })
.getAttribute('aria-expanded')
).toBe('false')
settle()
await waitFor(() => {
expect(
within(container)
.getByRole('button', { name: /thought/i })
.getAttribute('aria-expanded')
).toBe('false')
})
expect(container.querySelector('[data-slot="aui_reasoning-text"]')).toBeNull()
})
it('keeps streaming reasoning collapsed by default when the preference is enabled', () => {
$reasoningCollapsedByDefault.set(true)
const { container } = render(<RunningReasoningHarness />)
const thinkingToggle = within(container).getByRole('button', { name: /thinking/i })
expect(thinkingToggle.getAttribute('aria-expanded')).toBe('false')
expect(container.querySelector('[data-slot="aui_reasoning-text"]')).toBeNull()
fireEvent.click(thinkingToggle)
expect(thinkingToggle.getAttribute('aria-expanded')).toBe('true')
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toContain('const answer = 42')
})
it('renders reasoning text without a leading token space', () => {
const { container } = render(<ReasoningHarness />)
const ui = within(container)
// Settled, so the header is past tense — a running block says "Thinking".
fireEvent.click(ui.getByRole('button', { name: /thought/i }))
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toBe(
'The user is asking what this file is.'
)
})
it('groups consecutive reasoning parts under one thinking disclosure', () => {
const { container } = render(<GroupedReasoningHarness />)
const disclosures = container.querySelectorAll('[data-slot="aui_thinking-disclosure"]')
expect(disclosures.length).toBe(1)
fireEvent.click(disclosures[0].querySelector('button')!)
const reasoningParts = container.querySelectorAll('[data-slot="aui_reasoning-text"]')
expect(reasoningParts.length).toBe(2)
expect(reasoningParts[0]?.textContent).toBe('First thought.')
expect(reasoningParts[1]?.textContent).toBe('Second thought.')
})
it('does not reopen an earlier completed thinking group when a later group is running', () => {
const { container } = render(<RunningMessageHarness message={assistantSeparatedReasoningMessage()} />)
const disclosures = container.querySelectorAll('[data-slot="aui_thinking-disclosure"]')
expect(disclosures.length).toBe(2)
expect(disclosures[0].querySelector('button')?.getAttribute('aria-expanded')).toBe('false')
expect(disclosures[1].querySelector('button')?.getAttribute('aria-expanded')).toBe('true')
expect(container.textContent).not.toContain('Complete first thought.')
expect(container.textContent).toContain('Interim answer.')
})
it('does not render an inline todo panel — todos live in the composer status stack', () => {
const { container } = render(
<TodoHarness
message={assistantTodoMessage([
{ content: 'Gather ingredients', id: 'prep', status: 'completed' },
{ content: 'Boil water', id: 'boil', status: 'in_progress' }
])}
/>
)
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeNull()
})
it('renders completed image generation results in the tool slot', async () => {
const { container } = render(<MessageHarness message={assistantImageMessage()} />)
await waitFor(() => {
expect(screen.getByRole('img', { name: 'Generated image' }).getAttribute('src')).toBe(
'https://cdn.example/cat.png'
)
})
expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeTruthy()
expect(screen.queryByRole('status', { name: /rendering image/i })).toBeNull()
})
it('uses the normal tool row for failed image generations instead of dropping their error payload', async () => {
const { container } = render(
<MessageHarness
message={assistantImageMessage(false, { error: 'FAL rejected the prompt', image: null, success: false })}
/>
)
fireEvent.click(container.querySelector('[data-tool-row] button')!)
await waitFor(() => {
expect(container.textContent).toContain('FAL rejected the prompt')
})
expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeNull()
expect(container.textContent).not.toContain('"success":false')
})
it('shows the command prompt and exit code for terminal calls', async () => {
const { container } = render(<MessageHarness message={assistantTerminalMessage()} />)
fireEvent.click(container.querySelector('[data-tool-row] button')!)
await waitFor(() => {
expect(container.textContent).toContain('$ npm run check --workspace=apps/desktop')
expect(container.textContent).toContain('exit 0')
expect(container.textContent).toContain('all checks passed')
})
})
})
@@ -0,0 +1,67 @@
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { $displayTimestamps } from '@/store/display-timestamps'
import { stubThreadEnvironment } from '../test-utils'
import { Thread } from '.'
// Timeline timestamps render only when `display.timestamps` is enabled.
$displayTimestamps.set(true)
const timestamp = new Date('2026-05-01T00:00:00.000Z')
stubThreadEnvironment()
function Harness({ text }: { text: string }) {
const message = {
id: 'system-1',
role: 'system',
content: [{ type: 'text', text }],
createdAt: timestamp,
metadata: { custom: { timelineTimestamp: timestamp.getTime() / 1000 } }
} as unknown as ThreadMessage
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function expectTimestampSeparated(container: HTMLElement, precedingText: string) {
const row = container.querySelector('[data-role="system"]')
const stamp = row?.querySelector('[data-slot="timeline-timestamp"]')?.textContent
expect(stamp).toBeTruthy()
expect(row?.textContent).toContain(`${precedingText} ${stamp}`)
}
afterEach(cleanup)
describe('system message timestamp text separation', () => {
it('separates an ordinary system row timestamp in accessible and copied text', () => {
const { container } = render(<Harness text="Review saved." />)
expectTimestampSeparated(container, 'Review saved.')
})
it('separates a slash-status timestamp in accessible and copied text', () => {
const { container } = render(<Harness text={'slash:/model\nmodel changed'} />)
expectTimestampSeparated(container, 'model changed')
})
it('separates a steer timestamp in accessible and copied text', () => {
const { container } = render(<Harness text="steer:rerun tests" />)
expectTimestampSeparated(container, 'rerun tests')
})
})
@@ -0,0 +1,115 @@
import { MessagePrimitive, useAuiState } from '@assistant-ui/react'
import { type FC } from 'react'
import { messageContentText } from '@/components/assistant-ui/thread/content'
import { MessageTimelineTimestamp } from '@/components/assistant-ui/thread/timeline-timestamp'
import { SCAFFOLD_LABEL_CLASS } from '@/components/chat/scaffold-row'
import { Codicon } from '@/components/ui/codicon'
import { ToolIcon } from '@/components/ui/tool-icon'
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]+)$/
const REVIEW_NOTE_RE = /^review:(?<label>[^:\n]+):?\s*(?<detail>[\s\S]*)$/
export const SystemMessage: FC = () => {
const text = useAuiState(s => messageContentText(s.message.content))
if (!text) {
return null
}
// The self-improvement review saved something to memory/skills — the same
// kind of event as a landed `memory` write, so it wears the same chrome:
// brain glyph with the gold→purple glow, gradient label, purple detail,
// left-aligned in the reading column like every other scaffold line.
const reviewNote = text.match(REVIEW_NOTE_RE)
if (reviewNote?.groups) {
const detail = reviewNote.groups.detail.trim()
return (
<MessagePrimitive.Root
className="flex w-full min-w-0 max-w-full items-start gap-1.5 self-start py-0.5"
data-role="system"
data-slot="aui_system-message-root"
>
<span className="tool-memory-legendary-glyph flex h-(--conversation-line-height) w-3.5 shrink-0 items-center justify-center">
<ToolIcon className="text-(--tool-memory-legendary-icon)" name="brain" size="0.875rem" />
</span>
<span className={cn(SCAFFOLD_LABEL_CLASS, 'tool-memory-legendary-title shrink-0 text-transparent')}>
{reviewNote.groups.label.trim()}
</span>
{detail && (
<span className={cn(SCAFFOLD_LABEL_CLASS, 'tool-memory-legendary-meta min-w-0 wrap-anywhere')}>{detail}</span>
)}
</MessagePrimitive.Root>
)
}
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> <MessageTimelineTimestamp />
</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} />
</>
)}{' '}
<MessageTimelineTimestamp className={cn(multiline ? 'mt-0.5 block' : 'ml-1.5')} />
</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} />{' '}
<MessageTimelineTimestamp className={cn(multiline ? 'mt-0.5 block' : 'ml-1.5')} />
</MessagePrimitive.Root>
)
}
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { activeTimelineIndex, deriveTimelineEntries, sameTimelineEntries, timelinePreview } from './timeline-data'
describe('timelinePreview', () => {
it('collapses whitespace to a single line', () => {
expect(timelinePreview('hello\n\n world\tagain')).toBe('hello world again')
})
it('truncates with an ellipsis past the limit', () => {
const out = timelinePreview('abcdefghij', 5)
expect(out).toBe('abcd…')
expect(out.length).toBe(5)
})
})
describe('deriveTimelineEntries', () => {
it('keeps non-empty user prompts in order', () => {
expect(
deriveTimelineEntries([
{ id: 'u1', role: 'user', text: 'first' },
{ id: 'a1', role: 'assistant', text: 'answer' },
{ id: 'u2', role: 'user', text: ' second ' }
])
).toEqual([
{ id: 'u1', preview: 'first' },
{ id: 'u2', preview: 'second' }
])
})
it('drops blanks and background-process notifications', () => {
expect(
deriveTimelineEntries([
{ id: 'u1', role: 'user', text: ' ' },
{ id: 'u2', role: 'user', text: '[IMPORTANT: Background process 123 finished]' },
{ id: 'u3', role: 'user', text: 'real prompt' }
]).map(e => e.id)
).toEqual(['u3'])
})
})
describe('sameTimelineEntries', () => {
const rail = [
{ id: 'u1', preview: 'first' },
{ id: 'u2', preview: 'second' }
]
it('treats an identical derivation as unchanged, so the memo can reuse it', () => {
expect(sameTimelineEntries(rail, [...rail.map(e => ({ ...e }))])).toBe(true)
})
it('detects a changed preview, id, or length', () => {
expect(sameTimelineEntries(rail, [rail[0], { id: 'u2', preview: 'edited' }])).toBe(false)
expect(sameTimelineEntries(rail, [rail[0], { id: 'u9', preview: 'second' }])).toBe(false)
expect(sameTimelineEntries(rail, [rail[0]])).toBe(false)
})
it('is stable when a filtered-out prompt joins the transcript', () => {
const withNoise = deriveTimelineEntries([
{ id: 'u1', role: 'user', text: 'first' },
{ id: 'u2', role: 'user', text: 'second' },
{ id: 'u3', role: 'user', text: '[IMPORTANT: Background process 7 finished]' }
])
expect(sameTimelineEntries(rail, withNoise)).toBe(true)
})
})
describe('activeTimelineIndex', () => {
it('returns the last prompt scrolled to or above the top edge', () => {
expect(activeTimelineIndex([-400, -10, 320])).toBe(1)
})
it('falls back to the first rendered entry', () => {
expect(activeTimelineIndex([null, 120, 480])).toBe(1)
expect(activeTimelineIndex([null, null])).toBe(0)
})
})
@@ -0,0 +1,89 @@
// Pure timeline helpers — no React/DOM; tested in thread-timeline-data.test.ts.
export interface TimelineSourceMessage {
id: string
role: string
text: string
}
export interface TimelineEntry {
id: string
preview: string
}
// Injected as user messages for alternation; not human prompts (thread.tsx).
const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/
const PREVIEW_MAX = 120
export function timelinePreview(text: string, max: number = PREVIEW_MAX): string {
const collapsed = text.replace(/\s+/g, ' ').trim()
if (collapsed.length <= max) {
return collapsed
}
return `${collapsed.slice(0, max - 1).trimEnd()}`
}
export function deriveTimelineEntries(messages: readonly TimelineSourceMessage[]): TimelineEntry[] {
const entries: TimelineEntry[] = []
for (const message of messages) {
if (message.role !== 'user') {
continue
}
const text = message.text.trim()
if (!text || PROCESS_NOTIFICATION_RE.test(text)) {
continue
}
entries.push({ id: message.id, preview: timelinePreview(text) })
}
return entries
}
/** Do two derivations describe the same rail? Lets a rebuild hand back the
* PREVIOUS array so an unchanged transcript costs zero re-renders. */
export function sameTimelineEntries(a: readonly TimelineEntry[], b: readonly TimelineEntry[]): boolean {
if (a === b) {
return true
}
if (a.length !== b.length) {
return false
}
return a.every((entry, index) => entry.id === b[index].id && entry.preview === b[index].preview)
}
/** Last user prompt at/above the viewport top (with slack); else first rendered. */
export function activeTimelineIndex(offsets: readonly (number | null)[], slack: number = 8): number {
let active = -1
let firstRendered = -1
for (let i = 0; i < offsets.length; i++) {
const offset = offsets[i]
if (offset == null) {
continue
}
if (firstRendered === -1) {
firstRendered = i
}
if (offset <= slack) {
active = i
}
}
if (active !== -1) {
return active
}
return firstRendered === -1 ? 0 : firstRendered
}
@@ -0,0 +1,166 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
/**
* The timeline must do NO work it can't currently show. Two gates are proven
* here by rendering the real component and counting the work it performs:
*
* - a background (kept-alive but hidden) tab derives nothing and subscribes
* to nothing — the transcript selector is never even called;
* - an unhovered rail builds its ticks but not the popover's rows.
*
* The prompt-id selector is also asserted to be content-blind, which is what
* keeps a streaming assistant reply from re-deriving previews per token.
*/
interface FakeMessage {
content: unknown
id: string
role: string
}
const selectorCalls = vi.fn()
const transcriptReads = vi.fn()
let messages: FakeMessage[] = []
vi.mock('@assistant-ui/react', () => ({
useAui: () => ({
thread: () => ({
getState: () => {
transcriptReads()
return { messages }
}
})
}),
useAuiState: (selector: (state: { thread: { messages: FakeMessage[] } }) => unknown) => {
selectorCalls()
return selector({ thread: { messages } })
}
}))
let paneActive = true
vi.mock('@/components/pane-shell/pane-visibility', () => ({
usePaneVisible: () => paneActive
}))
vi.mock('@/lib/haptics', () => ({ triggerHaptic: () => {} }))
const { ThreadTimeline } = await import('./timeline')
const userTurn = (id: string, text: string): FakeMessage => ({
content: [{ text, type: 'text' }],
id,
role: 'user'
})
const transcript = (count: number): FakeMessage[] =>
Array.from({ length: count }, (_, i) => userTurn(`u${i}`, `prompt ${i}`))
const renderTimeline = (ui: ReactNode = <ThreadTimeline />) => render(ui)
afterEach(() => {
cleanup()
selectorCalls.mockClear()
transcriptReads.mockClear()
paneActive = true
messages = []
})
describe('ThreadTimeline in a background tab', () => {
it('renders nothing and never reads the transcript', () => {
paneActive = false
messages = transcript(6)
const { container } = renderTimeline()
expect(container.querySelector('[data-slot="thread-timeline"]')).toBeNull()
expect(selectorCalls).not.toHaveBeenCalled()
expect(transcriptReads).not.toHaveBeenCalled()
})
it('renders the rail once its pane becomes the visible tab', () => {
messages = transcript(6)
const { container } = renderTimeline()
expect(container.querySelector('[data-slot="thread-timeline"]')).not.toBeNull()
expect(selectorCalls).toHaveBeenCalled()
})
})
describe('ThreadTimeline popover', () => {
it('builds no rows until the rail is hovered', () => {
messages = transcript(6)
const { container } = renderTimeline()
const popover = container.querySelector('[data-slot="thread-timeline-popover"]')
// The shell renders (it owns the fade transition); its rows do not.
expect(popover).not.toBeNull()
expect(popover?.querySelectorAll('button')).toHaveLength(0)
expect(screen.queryByText('prompt 0')).toBeNull()
})
it('builds the rows on hover and keeps them for the close fade', () => {
messages = transcript(6)
const { container } = renderTimeline()
const rail = container.querySelector<HTMLElement>('[data-slot="thread-timeline"]')!
fireEvent.mouseEnter(rail)
const popover = container.querySelector('[data-slot="thread-timeline-popover"]')
expect(popover?.querySelectorAll('button')).toHaveLength(6)
fireEvent.mouseLeave(rail)
// Still mounted — the popover fades out, it does not pop out of existence.
expect(popover?.querySelectorAll('button')).toHaveLength(6)
})
})
describe('ThreadTimeline below the threshold', () => {
it('renders nothing for a short thread', () => {
messages = transcript(2)
const { container } = renderTimeline()
expect(container.querySelector('[data-slot="thread-timeline"]')).toBeNull()
})
})
describe('ThreadTimeline while a reply streams', () => {
it('does not re-derive the rail as assistant content grows', () => {
messages = [...transcript(6), { content: [{ text: 'th', type: 'text' }], id: 'a1', role: 'assistant' }]
const { rerender } = renderTimeline()
const derivations = transcriptReads.mock.calls.length
// A token lands: the assistant message's content changes, the user prompt
// ids do not — so the memo's change signal is untouched and the previews
// are never rebuilt.
messages = [
...messages.slice(0, -1),
{ content: [{ text: 'thinking…', type: 'text' }], id: 'a1', role: 'assistant' }
]
rerender(<ThreadTimeline />)
expect(transcriptReads.mock.calls.length).toBe(derivations)
})
it('re-derives once a new prompt is sent', () => {
messages = transcript(6)
const { rerender } = renderTimeline()
const derivations = transcriptReads.mock.calls.length
messages = [...messages, userTurn('u6', 'prompt 6')]
rerender(<ThreadTimeline />)
expect(transcriptReads.mock.calls.length).toBeGreaterThan(derivations)
})
})
@@ -0,0 +1,46 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $displayTimestamps, setDisplayTimestampsFromConfig } from '@/store/display-timestamps'
import { TimelineTimestamp } from './timeline-timestamp'
afterEach(cleanup)
beforeEach(() => {
$displayTimestamps.set(false)
})
describe('setDisplayTimestampsFromConfig', () => {
it('accepts boolean and string forms, defaulting off', () => {
setDisplayTimestampsFromConfig(true)
expect($displayTimestamps.get()).toBe(true)
setDisplayTimestampsFromConfig(false)
expect($displayTimestamps.get()).toBe(false)
setDisplayTimestampsFromConfig('true')
expect($displayTimestamps.get()).toBe(true)
setDisplayTimestampsFromConfig(undefined)
expect($displayTimestamps.get()).toBe(false)
})
})
describe('TimelineTimestamp display.timestamps gate', () => {
const timestamp = new Date('2026-05-01T00:00:00.000Z').getTime() / 1000
it('renders nothing while display.timestamps is off (the default)', () => {
const { container } = render(<TimelineTimestamp timestamp={timestamp} />)
expect(container.querySelector('[data-slot="timeline-timestamp"]')).toBeNull()
})
it('renders the stamp once display.timestamps is on', () => {
$displayTimestamps.set(true)
const { container } = render(<TimelineTimestamp timestamp={timestamp} />)
expect(container.querySelector('[data-slot="timeline-timestamp"]')).toBeTruthy()
})
})
@@ -0,0 +1,114 @@
import { useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import type { FC } from 'react'
import { cn } from '@/lib/utils'
import { $displayTimestamps } from '@/store/display-timestamps'
import { formatTimelineRange } from './timestamp'
const preciseDateTime = new Intl.DateTimeFormat(undefined, {
day: 'numeric',
fractionalSecondDigits: 3,
hour: 'numeric',
minute: '2-digit',
month: 'short',
second: '2-digit',
year: 'numeric'
})
const validUnixSeconds = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value) && value > 0
const unixDate = (value: unknown): Date | null => {
if (!validUnixSeconds(value)) {
return null
}
const date = new Date(value * 1000)
return Number.isNaN(date.getTime()) ? null : date
}
export const TimelineTimestamp: FC<{
className?: string
completedAt?: number
timestamp?: number
}> = ({ className, completedAt, timestamp }) => {
// One config key everywhere (#41531): `display.timestamps` in config.yaml
// gates transcript timestamps here exactly as it gates the classic CLI's
// [HH:MM] labels. Display-only, so toggling never touches model context.
const enabled = useStore($displayTimestamps)
const started = unixDate(timestamp)
if (!enabled || !started || !validUnixSeconds(timestamp)) {
return null
}
const completed = validUnixSeconds(completedAt) && completedAt > timestamp ? unixDate(completedAt) : null
const validCompletedAt = completed && validUnixSeconds(completedAt) ? completedAt : undefined
const startLabel = formatTimelineRange(timestamp, undefined)
const completedLabel = validCompletedAt === undefined ? '' : formatTimelineRange(validCompletedAt, undefined)
const title = completed
? `${preciseDateTime.format(started)}${preciseDateTime.format(completed)}`
: preciseDateTime.format(started)
return (
<span
className={cn('text-[0.625rem] leading-4 tabular-nums text-muted-foreground/55', className)}
data-slot="timeline-timestamp"
title={title}
>
<time dateTime={started.toISOString()}>{startLabel}</time>
{completed && validCompletedAt !== undefined && (
<>
{' → '}
<time dateTime={completed.toISOString()}>{completedLabel}</time>
</>
)}
</span>
)
}
/** Timestamp for the current assistant-ui message lifecycle. */
export const MessageTimelineTimestamp: FC<{
className?: string
suppressIfDuplicatePart?: boolean
}> = ({ className, suppressIfDuplicatePart = false }) => {
const timestamp = useAuiState(s => {
const value = (s.message.metadata?.custom as { timelineTimestamp?: unknown } | undefined)?.timelineTimestamp
return validUnixSeconds(value) ? value : undefined
})
const completedAt = useAuiState(s => {
const value = (s.message.metadata?.custom as { timelineCompletedAt?: unknown } | undefined)?.timelineCompletedAt
return validUnixSeconds(value) ? value : undefined
})
const duplicatePart = useAuiState(s => {
const custom = (s.message.metadata?.custom ?? {}) as {
timelineCompletedAt?: unknown
timelineTimestamp?: unknown
}
const solePart =
s.message.parts.length === 1 ? (s.message.parts[0] as { completedAt?: unknown; timestamp?: unknown }) : null
return (
Boolean(solePart) &&
solePart?.timestamp === custom.timelineTimestamp &&
(solePart?.completedAt === custom.timelineCompletedAt ||
(!validUnixSeconds(solePart?.completedAt) && !validUnixSeconds(custom.timelineCompletedAt)))
)
})
if (suppressIfDuplicatePart && duplicatePart) {
return null
}
return <TimelineTimestamp className={className} completedAt={completedAt} timestamp={timestamp} />
}
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it } from 'vitest'
import { ownViewport } from './timeline'
/**
* Several chat surfaces are mounted at once — side by side in a split, and
* stacked as kept-alive inactive tabs. A timeline scrolls its OWN thread.
*/
afterEach(() => {
document.body.innerHTML = ''
})
const surface = (id: string, hidden = false) => `
<div ${hidden ? 'data-pane-hidden' : ''}>
<div data-session-anchor="${id}">
<div data-slot="aui_thread-viewport" id="viewport-${id}"></div>
<div data-slot="thread-timeline" id="timeline-${id}"></div>
</div>
</div>
`
describe('ownViewport', () => {
it('resolves the viewport of the surface the timeline lives in', () => {
document.body.innerHTML = surface('workspace') + surface('session-tile:b')
expect(ownViewport(document.getElementById('timeline-session-tile:b'))?.id).toBe('viewport-session-tile:b')
expect(ownViewport(document.getElementById('timeline-workspace'))?.id).toBe('viewport-workspace')
})
it('ignores a kept-alive tab that matches first', () => {
document.body.innerHTML = surface('workspace', true) + surface('session-tile:b')
expect(ownViewport(document.getElementById('timeline-session-tile:b'))?.id).toBe('viewport-session-tile:b')
})
it('falls back to the document when there is no surface around it', () => {
document.body.innerHTML = '<div data-slot="aui_thread-viewport" id="viewport-lone"></div>'
expect(ownViewport(null)?.id).toBe('viewport-lone')
})
})
@@ -0,0 +1,419 @@
import { useAui, useAuiState } from '@assistant-ui/react'
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import {
activeTimelineIndex,
deriveTimelineEntries,
sameTimelineEntries,
type TimelineEntry,
type TimelineSourceMessage
} from './timeline-data'
const MIN_ENTRIES = 4
const VIEWPORT = '[data-slot="aui_thread-viewport"]'
const HOVER_CLOSE_MS = 140
const ROW_CLASS =
'row-hover relative flex w-full min-w-0 max-w-full select-none overflow-hidden rounded-md px-2 py-1 text-left outline-hidden'
// Surface (border-color/bg/shadow/blur) comes from the shared
// `[data-slot='thread-timeline-popover']` rule in styles.css, so it's 1:1 with
// the dropdown/select/dialog menus. We only own layout + the border/radius here.
const POPOVER_SHELL =
'absolute right-full top-1/2 z-50 max-h-[min(22rem,calc(100vh-8rem))] w-80 max-w-[min(20rem,calc(100vw-2rem))] -translate-y-1/2 overflow-x-hidden overflow-y-auto overscroll-contain rounded-lg border p-1 text-popover-foreground transition-[opacity,transform] duration-100 ease-out group-hover/timeline:transition-none'
function userPromptText(content: unknown): string {
if (typeof content === 'string') {
return content
}
if (!Array.isArray(content)) {
return ''
}
let out = ''
for (const part of content) {
if (typeof part === 'string') {
out += part
continue
}
if (!part || typeof part !== 'object') {
continue
}
const row = part as { text?: unknown; type?: unknown }
if ((!row.type || row.type === 'text') && typeof row.text === 'string') {
out += row.text
}
}
return out
}
/** Index-keyed ref-array setter — `ref={listRef(refs, i)}`. */
const listRef =
<T,>(refs: React.RefObject<(T | null)[]>, index: number) =>
(node: T | null) => {
refs.current[index] = node
}
/** Mouse enter/leave pair forwarding `on` to the shared paint(). */
const hoverProps = (index: number, paint: (index: number, on: boolean) => void) => ({
onMouseEnter: () => paint(index, true),
onMouseLeave: () => paint(index, false)
})
// Constant-duration jump (eased), NOT native `behavior:'smooth'` — Chromium's
// smooth scroll animates proportional to distance, so jumping across a long
// thread crawls for seconds. A fixed ~260ms feels instant near or far. A
// shared rAF handle cancels a prior jump so rapid tick clicks don't fight.
let jumpRaf = 0
function jumpScroll(viewport: HTMLElement, top: number, duration = 170): void {
cancelAnimationFrame(jumpRaf)
const start = viewport.scrollTop
const delta = top - start
if (Math.abs(delta) < 2) {
viewport.scrollTop = top
return
}
const t0 = performance.now()
const ease = (t: number) => 1 - (1 - t) ** 3 // easeOutCubic
const step = (now: number) => {
const p = Math.min(1, (now - t0) / duration)
viewport.scrollTop = start + delta * ease(p)
if (p < 1) {
jumpRaf = requestAnimationFrame(step)
}
}
jumpRaf = requestAnimationFrame(step)
}
// A timeline belongs to ONE chat surface, and several are mounted at once — side
// by side in a split, and stacked (hidden but kept alive) as inactive tabs. Walk
// up to this timeline's own surface before looking for the viewport; a
// document-wide lookup scrolls somebody else's thread.
export const ownViewport = (root: HTMLElement | null): HTMLElement | null =>
(root?.closest('[data-session-anchor]') ?? document).querySelector<HTMLElement>(VIEWPORT)
function scrollToPrompt(root: HTMLElement | null, id: string) {
const viewport = ownViewport(root)
const node = viewport?.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`)
if (!viewport || !node) {
return
}
const top = viewport.scrollTop + (node.getBoundingClientRect().top - viewport.getBoundingClientRect().top) - 8
triggerHaptic('selection')
jumpScroll(viewport, Math.max(0, top))
}
/**
* Right-edge prompt rail — hover previews, click to jump. ≥4 user turns only.
*
* Everything here is DEFERRED until it can actually be seen. A chat surface
* stays mounted while its tab is in the background (keep-alive, see
* pane-visibility.ts), and a background thread keeps streaming, so a naive
* timeline would re-derive previews and re-measure prompt offsets all day for
* a rail nobody is looking at. Four gates, cheapest first:
*
* 1. INACTIVE PANE → render null and subscribe to nothing. The transcript
* selector, the scroll listener, and the popover markup all stand down.
* 2. ACTIVE BUT UNHOVERED → the ticks paint, but the popover's rows are not
* built at all; the previews only exist once the pointer opens it.
* 3. BELOW THE THRESHOLD → the rail renders null, so the measure effect never
* touches layout for it.
* 4. FOLLOWING THE BOTTOM → the active prompt is the last one by definition,
* answered from data instead of a rect walk (see compute() below).
*/
export const ThreadTimeline: FC = () => {
// Cheapest possible gate, and it must come first: an inactive tab returns
// before any of the work below is even declared.
return usePaneVisible() ? <ActiveThreadTimeline /> : null
}
/** Derived prompt rail for a VISIBLE surface. Split out so the hook body — and
* the transcript subscription it opens — never runs for a background tab. */
const ActiveThreadTimeline: FC = () => {
// Cheap in the selector, expensive only when it changes: the ids alone tell
// us whether the RAIL changed. Prompt text is immutable once sent, and an
// edit rewinds the transcript (dropping every id after it) and re-appends a
// fresh message id — so a preview can never go stale behind a stable id.
// Streaming an assistant reply churns that message's content on every token
// and leaves this string untouched, which is the whole point.
const promptIds = useAuiState(s => {
let ids = ''
for (const message of s.thread.messages) {
if (message.role === 'user') {
ids += `${message.id}\n`
}
}
return ids
})
// `promptIds` is the change signal; the transcript is read imperatively when
// it fires, so the selector above never pays for text extraction. The client
// goes through a ref so the memo keys on the SIGNAL alone — an accessor whose
// identity churned would otherwise re-derive every render, which is exactly
// the streaming cost this is here to avoid.
const aui = useAui()
const auiRef = useRef(aui)
auiRef.current = aui
const previousRef = useRef<TimelineEntry[]>([])
const entries = useMemo(() => {
const rows: TimelineSourceMessage[] = []
for (const message of auiRef.current.thread().getState().messages) {
if (message.role === 'user') {
rows.push({ id: message.id, role: 'user', text: userPromptText(message.content) })
}
}
const next = deriveTimelineEntries(rows)
// Hand back the PREVIOUS array when nothing user-visible moved. Blank and
// background-notification prompts are filtered out, so a new id can leave
// the rail identical — without this, that re-renders both subtrees and
// restarts the measure effect for no visible change.
if (sameTimelineEntries(previousRef.current, next)) {
return previousRef.current
}
previousRef.current = next
return next
// promptIds is the intentional re-eval TRIGGER, not a value the derivation
// reads (the transcript comes off the ref) — same shape as ChatRoutesSurface's
// gatewayState memo in app/contrib/controller.tsx.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [promptIds])
const [activeIndex, setActiveIndex] = useState(0)
const [open, setOpen] = useState(false)
const closeTimerRef = useRef<number | undefined>(undefined)
const rootRef = useRef<HTMLDivElement | null>(null)
const jump = useCallback((id: string) => scrollToPrompt(rootRef.current, id), [])
// Hover sync lives on the DOM, not in React state — the tick and its popover
// row are siblings in different subtrees, so a shared index-keyed paint() lights
// both without a re-render (and without coupling them through a parent atom).
const tickRefs = useRef<(HTMLSpanElement | null)[]>([])
const rowRefs = useRef<(HTMLButtonElement | null)[]>([])
// Hover sync: light the tick + its popover row, and scroll that row into view
// when the list overflows so the hovered prompt is always visible.
const paint = useCallback((index: number, on: boolean) => {
const tick = tickRefs.current[index]
if (tick) {
tick.style.opacity = on ? '1' : ''
}
const row = rowRefs.current[index]
row?.classList.toggle('bg-(--ui-row-hover-background)', on)
if (on) {
row?.scrollIntoView({ block: 'nearest' })
}
}, [])
const keepOpen = useCallback(() => {
window.clearTimeout(closeTimerRef.current)
setOpen(true)
}, [])
const closeSoon = useCallback(() => {
window.clearTimeout(closeTimerRef.current)
closeTimerRef.current = window.setTimeout(() => setOpen(false), HOVER_CLOSE_MS)
}, [])
useEffect(() => () => window.clearTimeout(closeTimerRef.current), [])
useEffect(() => {
// Below the threshold the rail renders null, so measuring prompt offsets
// buys nothing — bail before touching layout at all.
if (entries.length < MIN_ENTRIES) {
return
}
const viewport = ownViewport(rootRef.current)
if (!viewport) {
return
}
let raf = 0
const compute = () => {
raf = 0
// Pinned to the bottom (the entire streaming steady-state): the active
// prompt is simply the last one. Skipping the walk matters — it reads a
// rect per user message per scroll frame, and interleaved with React's
// streaming style writes each read forces a full reflow (the single
// hottest frame in the multitab profile).
if (viewport.dataset.following === 'true') {
setActiveIndex(prev => (prev === entries.length - 1 ? prev : entries.length - 1))
return
}
const top = viewport.getBoundingClientRect().top
const offsets = entries.map(entry => {
const node = viewport.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(entry.id)}"]`)
return node ? node.getBoundingClientRect().top - top : null
})
const next = activeTimelineIndex(offsets)
setActiveIndex(prev => (prev === next ? prev : next))
}
const onScroll = () => {
if (!raf) {
raf = requestAnimationFrame(compute)
}
}
// Initial compute rides the same rAF batching as scroll. A sync call here
// reads getBoundingClientRect for every user message while other commit
// effects are still writing styles — on a session switch that interleaving
// forces a full reflow per read on a large transcript. One rAF later the
// reads batch into a single layout pass, and back-to-back entries updates
// (prefetch paint, then resume reconcile) coalesce into one compute.
onScroll()
viewport.addEventListener('scroll', onScroll, { passive: true })
return () => {
viewport.removeEventListener('scroll', onScroll)
if (raf) {
cancelAnimationFrame(raf)
}
}
}, [entries])
if (entries.length < MIN_ENTRIES) {
return null
}
return (
<div
aria-label="Conversation timeline"
className="group/timeline pointer-events-auto absolute right-0 top-1/2 z-40 flex -translate-y-1/2 flex-col items-end"
data-slot="thread-timeline"
data-suppress-pane-reveal=""
onMouseEnter={keepOpen}
onMouseLeave={closeSoon}
ref={rootRef}
role="navigation"
>
<TimelineTicks activeIndex={activeIndex} entries={entries} onHover={paint} onJump={jump} tickRefs={tickRefs} />
<TimelinePopover
activeIndex={activeIndex}
entries={entries}
onHover={paint}
onJump={jump}
open={open}
rowRefs={rowRefs}
/>
</div>
)
}
const TimelinePopover: FC<{
activeIndex: number
entries: TimelineEntry[]
onHover: (index: number, on: boolean) => void
onJump: (id: string) => void
open: boolean
rowRefs: React.RefObject<(HTMLButtonElement | null)[]>
}> = ({ activeIndex, entries, onHover, onJump, open, rowRefs }) => {
// The rail is the always-visible part; this list is not built until the
// pointer first opens it. The SHELL always renders so the opacity/translate
// transition has a node to animate — only the N rows are deferred, and they
// stay mounted afterwards so the close fade still has content.
const [everOpened, setEverOpened] = useState(open)
if (open && !everOpened) {
setEverOpened(true)
}
return (
<div
className={cn(
POPOVER_SHELL,
open ? 'pointer-events-auto opacity-100 translate-x-0' : 'pointer-events-none translate-x-1 opacity-0'
)}
data-slot="thread-timeline-popover"
>
{everOpened &&
entries.map((entry, index) => (
<button
aria-label={entry.preview}
className={cn(ROW_CLASS, index === activeIndex && 'bg-(--ui-row-active-background) text-foreground')}
key={entry.id}
onClick={() => onJump(entry.id)}
ref={listRef(rowRefs, index)}
type="button"
{...hoverProps(index, onHover)}
>
<span className="block w-full min-w-0 truncate font-medium leading-snug text-foreground">
{entry.preview}
</span>
</button>
))}
</div>
)
}
const TimelineTicks: FC<{
activeIndex: number
entries: TimelineEntry[]
onHover: (index: number, on: boolean) => void
onJump: (id: string) => void
tickRefs: React.RefObject<(HTMLSpanElement | null)[]>
}> = ({ activeIndex, entries, onHover, onJump, tickRefs }) => (
<div className="flex flex-col items-end py-1" data-slot="thread-timeline-ticks">
{entries.map((entry, index) => (
<button
aria-label={entry.preview}
className="flex h-2 w-7 cursor-pointer items-center justify-end pr-1"
key={entry.id}
onClick={() => onJump(entry.id)}
type="button"
{...hoverProps(index, onHover)}
>
<span
className={cn(
'block h-px w-3 transition-opacity duration-100 ease-out',
index === activeIndex ? 'bg-(--theme-primary)' : 'dither text-(--ui-text-quaternary) opacity-70'
)}
ref={listRef(tickRefs, index)}
/>
</button>
))}
</div>
)
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { formatMessageTimestamp, formatTimelineRange, formatTimelineTimestamp } from './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)
})
})
describe('precise timeline timestamps', () => {
it('includes seconds and milliseconds for an event', () => {
const local = new Date(2026, 4, 1, 13, 2, 3, 456)
const formatted = formatTimelineTimestamp(local.getTime() / 1000)
expect(formatted).toMatch(/13|1/)
expect(formatted).toContain('02')
expect(formatted).toContain('03')
expect(formatted).toContain('456')
})
it('renders start and finish as a range', () => {
const start = new Date(2026, 4, 1, 13, 2, 3, 456).getTime() / 1000
const finish = start + 1.25
expect(formatTimelineRange(start, finish)).toBe(
`${formatTimelineTimestamp(start)}${formatTimelineTimestamp(finish)}`
)
})
it('returns an empty string for invalid timeline values', () => {
expect(formatTimelineTimestamp(undefined)).toBe('')
expect(formatTimelineTimestamp(Number.NaN)).toBe('')
expect(formatTimelineRange(undefined, 10)).toBe('')
})
})
@@ -0,0 +1,68 @@
import { fmtClock, fmtDayTime } from '@/lib/time'
const fmtTimelineClock = new Intl.DateTimeFormat(undefined, {
fractionalSecondDigits: 3,
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
})
const timelineDate = (seconds: number | undefined): Date | null => {
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0) {
return null
}
const date = new Date(seconds * 1000)
return Number.isNaN(date.getTime()) ? null : date
}
/** Millisecond-precise local clock for transcript activity boundaries. */
export function formatTimelineTimestamp(seconds: number | undefined): string {
const date = timelineDate(seconds)
return date ? fmtTimelineClock.format(date) : ''
}
export function formatTimelineRange(start: number | undefined, end: number | undefined): string {
const from = formatTimelineTimestamp(start)
if (!from) {
return ''
}
const to = formatTimelineTimestamp(end)
return to ? `${from}${to}` : from
}
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(fmtClock.format(date))
}
if (dayDelta === 1) {
return labels.yesterday(fmtClock.format(date))
}
return fmtDayTime.format(date)
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { resolveShowEarlierAction } from './transcript-window'
describe('resolveShowEarlierAction', () => {
it('spends the already-materialized DOM page first', () => {
expect(resolveShowEarlierAction(3, true)).toBe('dom')
expect(resolveShowEarlierAction(3, false)).toBe('dom')
})
it('expands the store window once the DOM page is exhausted', () => {
expect(resolveShowEarlierAction(0, true)).toBe('window')
})
it('is a no-op when neither DOM nor store has older content', () => {
expect(resolveShowEarlierAction(0, false)).toBe(null)
})
})
@@ -0,0 +1,34 @@
import { createContext, type ReactNode, useContext } from 'react'
export interface TranscriptWindowValue {
/** Store holds older messages the runtime window has not materialized. */
olderAvailable: boolean
/** Pull one more page of older messages out of the session store. */
expandWindow: () => void
}
const TranscriptWindowContext = createContext<TranscriptWindowValue>({
olderAvailable: false,
expandWindow: () => {}
})
export function TranscriptWindowProvider({ children, value }: { children: ReactNode; value: TranscriptWindowValue }) {
return <TranscriptWindowContext.Provider value={value}>{children}</TranscriptWindowContext.Provider>
}
export function useTranscriptWindow(): TranscriptWindowValue {
return useContext(TranscriptWindowContext)
}
/**
* "Show earlier" pages the DOM budget first and only then asks the store for
* more messages — the DOM page is already-materialized content, so spending it
* first keeps the click cheap and the store window as small as it can be.
*/
export function resolveShowEarlierAction(hiddenCount: number, olderAvailable: boolean): 'dom' | 'window' | null {
if (hiddenCount > 0) {
return 'dom'
}
return olderAvailable ? 'window' : null
}
@@ -0,0 +1,61 @@
// The transcript's activity row has to account for every second the app spends
// claiming to work. Before this, it ran off a text-length signature and only
// deferred to the tail part, so it went silent — and stopped counting — in the
// gaps: between a tool result landing and the next call arriving, and while a
// hoisted tool that renders nothing was in flight.
import { describe, expect, it } from 'vitest'
import { activitySignature, toolNarratesWait } from './turn-activity'
const text = (value: string) => ({ text: value, type: 'text' })
const call = (toolName: string, settled: boolean) => ({
toolName,
type: 'tool-call',
...(settled ? { result: 'ok' } : {})
})
describe('activitySignature', () => {
it('changes when a tool call lands its result', () => {
// The result MUTATES the existing part: same part count, same text. A
// signature blind to it reads the finished call as more of the same
// silence, so the gap after it gets dated from when the call started.
expect(activitySignature([text('working'), call('terminal', false)])).not.toBe(
activitySignature([text('working'), call('terminal', true)])
)
})
it('changes when prose streams and when a part is appended', () => {
const base = activitySignature([text('wor')])
expect(activitySignature([text('working')])).not.toBe(base)
expect(activitySignature([text('wor'), call('read_file', false)])).not.toBe(base)
})
it('is stable while nothing visible happens', () => {
const parts = [text('working'), call('terminal', true)]
expect(activitySignature(parts)).toBe(activitySignature([...parts]))
})
})
describe('toolNarratesWait', () => {
it('defers to a tool call in flight — it has its own row and timer', () => {
expect(toolNarratesWait([text('working'), call('terminal', false)])).toBe(true)
})
it('does not defer to a settled call: the gap after it belongs to nobody', () => {
expect(toolNarratesWait([text('working'), call('terminal', true)])).toBe(false)
})
it('does not defer to silent tools, which render nothing to narrate with', () => {
expect(toolNarratesWait([call('todo', false)])).toBe(false)
expect(toolNarratesWait([call('react_to_message', false)])).toBe(false)
})
it('defers to a call in flight even when a later part follows it', () => {
// Tail-part-only detection missed this: a turn that starts prose while a
// call is still running showed the row under the tool's own timer.
expect(toolNarratesWait([call('terminal', false), text('meanwhile')])).toBe(true)
})
})
@@ -0,0 +1,60 @@
import { isSilentTool } from '@/lib/tool-render-class'
/**
* Seconds of silence before an unnamed wait earns a row of its own.
*
* Long enough that the pause between one tool call finishing and the next
* arriving stays silent — a row that appeared for 300ms between every call
* would strobe down a long run — short enough that a real gap is timed almost
* as soon as it starts.
*/
export const TURN_QUIET_S = 2
export interface ActivityPart {
result?: unknown
text?: unknown
toolName?: string
type: string
}
/**
* What the tail message has produced so far, as a value that changes exactly
* when the turn makes visible progress.
*
* Part count and text length cover streamed prose and each new call. Settled
* calls are counted separately because a result lands by MUTATING the part
* that was already there: neither the count nor the text changes, so a
* signature without it reads a finished tool call as more of the same silence
* and dates the gap after it from whenever the call started.
*/
export function activitySignature(content: readonly ActivityPart[]): string {
let textLength = 0
let settledTools = 0
for (const part of content) {
if (typeof part.text === 'string') {
textLength += part.text.length
}
if (part.type === 'tool-call' && part.result !== undefined) {
settledTools += 1
}
}
return `${content.length}:${textLength}:${settledTools}`
}
/**
* Whether a tool call is already narrating this wait.
*
* A call in flight renders its own row, with its own timer, so a second
* spinner under it would count the same seconds twice. Silent tools don't:
* `todo` is hoisted to its own panel and a reaction's UI is the emoji landing
* on the bubble, so neither leaves anything on screen to time — a wait on one
* of those is as unnarrated as a wait on nothing at all.
*/
export function toolNarratesWait(content: readonly ActivityPart[]): boolean {
return content.some(
part => part.type === 'tool-call' && part.result === undefined && !isSilentTool(part.toolName ?? '')
)
}
@@ -0,0 +1,119 @@
// The gap case: the agent is working, the composer's arc border is on and Stop
// is armed, but the tail bubble has settled — a sealed interim row, or a turn
// whose last message completed while the agent kept going. The transcript used
// to show nothing there, and the seconds went uncounted.
import { type ThreadMessage } from '@assistant-ui/react'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { __resetElapsedTimerRegistryForTests } from '@/components/chat/activity-timer'
import { $activeSessionId, $busy, $messages, $turnStartedAt } from '@/store/session'
import { stubThreadEnvironment, ThreadRuntime, userMessage } from '../test-utils'
import { Thread } from '.'
stubThreadEnvironment()
const createdAt = new Date('2026-05-01T00:00:00.000Z')
const sessionId = 'session-turn-gap'
function assistant(id: string, content: unknown[], running: boolean): ThreadMessage {
return {
id,
role: 'assistant',
content,
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} }
} as unknown as ThreadMessage
}
const toolCall = (toolName: string, settled: boolean) => ({
type: 'tool-call',
toolCallId: `${toolName}-1`,
toolName,
args: {},
...(settled ? { result: 'ok' } : {})
})
const Harness = ({ messages }: { messages: ThreadMessage[] }) => (
<ThreadRuntime messages={messages}>
<Thread />
</ThreadRuntime>
)
const timerText = (value: string) => screen.getAllByText((_, node) => node?.textContent === value)
describe('the turn timer covers the gaps, not just the streaming', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
vi.spyOn(globalThis.document, 'hasFocus').mockReturnValue(true)
__resetElapsedTimerRegistryForTests()
$activeSessionId.set(sessionId)
$messages.set([])
$turnStartedAt.set(Date.now())
$busy.set(true)
})
afterEach(() => {
cleanup()
$activeSessionId.set(null)
$turnStartedAt.set(null)
$busy.set(false)
__resetElapsedTimerRegistryForTests()
vi.restoreAllMocks()
vi.useRealTimers()
})
it('times a settled tail bubble while the session is still working', () => {
// The sealed-bubble gap. Nothing is running at message level; the session
// is busy, so the transcript owes the user a line and a count — measured
// from the last thing the turn produced, not from when the row appeared.
const { container } = render(
<Harness
messages={[userMessage('u1', 'do the thing'), assistant('a1', [{ type: 'text', text: 'On it.' }], false)]}
/>
)
act(() => vi.advanceTimersByTime(7_000))
expect(container.querySelector('[data-slot="aui_turn-activity"]')).not.toBeNull()
expect(timerText('7s').length).toBeGreaterThan(0)
})
it('times the gap between a finished tool call and the next thing', () => {
const { container } = render(
<Harness messages={[userMessage('u1', 'read it'), assistant('a1', [toolCall('read_file', true)], true)]} />
)
act(() => vi.advanceTimersByTime(9_000))
expect(container.querySelector('[data-slot="aui_turn-activity"]')).not.toBeNull()
expect(timerText('9s').length).toBeGreaterThan(0)
})
it('stays silent under a tool call still in flight — that row has its own timer', () => {
const { container } = render(
<Harness messages={[userMessage('u1', 'run it'), assistant('a1', [toolCall('terminal', false)], true)]} />
)
act(() => vi.advanceTimersByTime(9_000))
expect(container.querySelector('[data-slot="aui_turn-activity"]')).toBeNull()
})
it('stops when the session stops working', () => {
$busy.set(false)
const { container } = render(
<Harness
messages={[userMessage('u1', 'do the thing'), assistant('a1', [{ type: 'text', text: 'Done.' }], false)]}
/>
)
act(() => vi.advanceTimersByTime(7_000))
expect(container.querySelector('[data-slot="aui_turn-activity"]')).toBeNull()
})
})
@@ -0,0 +1,4 @@
export interface RestoreMessageTarget {
text: string
userOrdinal: number | null
}

Some files were not shown because too many files have changed in this diff Show More