Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
@@ -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={``} />)
|
||||
|
||||
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
|
||||
// (``). 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'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 — ``.
|
||||
// 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'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()
|
||||
})
|
||||
})
|
||||
+215
@@ -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
Reference in New Issue
Block a user