feat(desktop): reconcile live tool events, polish thread chrome, harden boot

- chat-messages: match tool rows by overlapping query/context/preview values
  so preview-first `tool.progress` rows reliably adopt later stable-id
  `tool.start` payloads instead of spawning ghost rows or mis-merging
  parallel same-name calls; preserve prior args/result across phases.
- tui_gateway: emit full args + parsed result on `tool.start` / `tool.complete`,
  drop redundant `tool.started` re-emit from `tool.progress`.
- electron/main: prefer SOURCE_REPO_ROOT before PATH `hermes` in dev so
  local backend edits actually run; split hardening helpers into
  `electron/hardening.cjs` with tests.
- thread/tool UI: one-shot enter animation keyed by stable ids, braille
  spinner for running rows, Cursor-like disclosure rows, drill-down +
  duration/count formatting via new tool-fallback-model.
- composer: extract `text-utils`, drop liquid-glass overrides.
- right-rail: split preview-pane into preview-console / preview-file.
- runtime: incremental external-store runtime + runtime-readiness gate;
  onboarding store + tests; route-resume hook test.
- regression tests for live tool reconciliation (parallel tools, id-less
  progress, preview-first rows, structured args/results).
This commit is contained in:
Brooklyn Nicholson
2026-05-11 21:38:47 -04:00
parent fdf73f0adf
commit d208f2c2c0
64 changed files with 5614 additions and 2703 deletions
+313 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { ChatMessagePart } from './chat-messages'
import {
appendAssistantTextPart,
chatMessageText,
@@ -218,10 +219,321 @@ describe('upsertToolPart', () => {
'complete'
)
const completedResult = completed[0] && 'result' in completed[0] ? (completed[0].result as Record<string, unknown>) : {}
const completedResult =
completed[0] && 'result' in completed[0] ? (completed[0].result as Record<string, unknown>) : {}
const clearedResult = cleared[0] && 'result' in cleared[0] ? (cleared[0].result as Record<string, unknown>) : {}
expect(completedResult.todos).toEqual([{ content: 'Boil water', id: 'boil', status: 'in_progress' }])
expect(clearedResult.todos).toEqual([])
})
it('keeps parallel same-name tools distinct without explicit ids', () => {
const startedTokyo = upsertToolPart(
[],
{
context: 'tokyo weather',
name: 'web_search'
},
'running'
)
const startedReykjavik = upsertToolPart(
startedTokyo,
{
context: 'reykjavik weather',
name: 'web_search'
},
'running'
)
const completedTokyo = upsertToolPart(
startedReykjavik,
{
context: 'tokyo weather',
message: 'tokyo done',
name: 'web_search',
summary: 'Did 5 searches'
},
'complete'
)
const completedBoth = upsertToolPart(
completedTokyo,
{
context: 'reykjavik weather',
message: 'reykjavik done',
name: 'web_search',
summary: 'Did 5 searches'
},
'complete'
)
const webParts = completedBoth.filter(
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
part.type === 'tool-call' && part.toolName === 'web_search'
)
const contexts = webParts.map(part => String((part.args as Record<string, unknown>)?.context || ''))
const summaries = webParts.map(part => {
if (!('result' in part) || !part.result || typeof part.result !== 'object') {
return ''
}
return String((part.result as Record<string, unknown>).summary || '')
})
expect(webParts).toHaveLength(2)
expect(contexts).toEqual(['tokyo weather', 'reykjavik weather'])
expect(summaries).toEqual(['Did 5 searches', 'Did 5 searches'])
})
it('preserves query args when completion payload omits context', () => {
const started = upsertToolPart(
[],
{
context: 'auckland weather today and tomorrow forecast',
name: 'web_search',
tool_id: 'search-1'
},
'running'
)
const completed = upsertToolPart(
started,
{
duration_s: 1.1,
name: 'web_search',
summary: 'Did 5 searches in 1.1s',
tool_id: 'search-1'
},
'complete'
)
const [part] = completed
expect(part?.type).toBe('tool-call')
expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).args).toMatchObject({
context: 'auckland weather today and tomorrow forecast'
})
expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).result).toMatchObject({
summary: 'Did 5 searches in 1.1s'
})
})
it('does not append phantom same-name tool rows for id-less progress updates', () => {
const startedA = upsertToolPart(
[],
{
context: 'reykjavik weather today and tomorrow forecast',
name: 'web_search'
},
'running'
)
const startedB = upsertToolPart(
startedA,
{
context: 'kathmandu weather today and tomorrow forecast',
name: 'web_search'
},
'running'
)
const progressed = upsertToolPart(
startedB,
{
name: 'web_search'
},
'running'
)
const webParts = progressed.filter(
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
part.type === 'tool-call' && part.toolName === 'web_search'
)
expect(webParts).toHaveLength(2)
})
it('matches id-less live starts with later identified completions', () => {
const started = upsertToolPart(
[],
{
context: 'asuncion paraguay weather today and tomorrow forecast',
name: 'web_search'
},
'running'
)
const completed = upsertToolPart(
started,
{
context: 'asuncion paraguay weather today and tomorrow forecast',
duration_s: 1.1,
name: 'web_search',
summary: 'Did 5 searches in 1.1s',
tool_id: 'search-asuncion'
},
'complete'
)
const webParts = completed.filter(
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
part.type === 'tool-call' && part.toolName === 'web_search'
)
expect(webParts).toHaveLength(1)
expect(webParts[0].toolCallId).toBe('search-asuncion')
expect(webParts[0].result).toMatchObject({ summary: 'Did 5 searches in 1.1s' })
})
it('matches id-less live starts with later identified progress updates', () => {
const started = upsertToolPart(
[],
{
context: 'reykjavik tashkent uzbekistan weather today and tomorrow forecast',
name: 'web_search'
},
'running'
)
const progressed = upsertToolPart(
started,
{
context: 'reykjavik tashkent uzbekistan weather today and tomorrow forecast',
name: 'web_search',
tool_id: 'search-reykjavik'
},
'running'
)
const webParts = progressed.filter(
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
part.type === 'tool-call' && part.toolName === 'web_search'
)
expect(webParts).toHaveLength(1)
expect(webParts[0].toolCallId).toBe('search-reykjavik')
})
it('reconciles preview-first progress rows with later stable-id starts', () => {
const progressA = upsertToolPart(
[],
{
name: 'web_search',
preview: 'tokyo weather'
},
'running'
)
const progressB = upsertToolPart(
progressA,
{
name: 'web_search',
preview: 'reykjavik weather'
},
'running'
)
const startedA = upsertToolPart(
progressB,
{
args: { query: 'tokyo weather' },
name: 'web_search',
tool_id: 'search-tokyo'
},
'running'
)
const startedB = upsertToolPart(
startedA,
{
args: { query: 'reykjavik weather' },
name: 'web_search',
tool_id: 'search-reykjavik'
},
'running'
)
const completedA = upsertToolPart(
startedB,
{
name: 'web_search',
summary: 'Did 5 searches',
tool_id: 'search-tokyo'
},
'complete'
)
const completedB = upsertToolPart(
completedA,
{
name: 'web_search',
summary: 'Did 5 searches',
tool_id: 'search-reykjavik'
},
'complete'
)
const webParts = completedB
.filter(
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
part.type === 'tool-call' && part.toolName === 'web_search'
)
.map(part => ({
id: part.toolCallId,
query: String((part.args as Record<string, unknown>)?.query || ''),
summary:
part.result && typeof part.result === 'object'
? String((part.result as Record<string, unknown>).summary || '')
: ''
}))
expect(webParts).toEqual([
{ id: 'search-tokyo', query: 'tokyo weather', summary: 'Did 5 searches' },
{ id: 'search-reykjavik', query: 'reykjavik weather', summary: 'Did 5 searches' }
])
})
it('uses structured live tool args for titles before hydrate', () => {
const started = upsertToolPart(
[],
{
args: { search_term: 'reykjavik bishkek kyrgyzstan weather today and tomorrow forecast' },
name: 'web_search',
tool_id: 'search-bishkek'
},
'running'
)
const [part] = started
expect(part?.type).toBe('tool-call')
expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).args).toMatchObject({
search_term: 'reykjavik bishkek kyrgyzstan weather today and tomorrow forecast'
})
})
it('keeps structured live tool results before hydrate', () => {
const completed = upsertToolPart(
[],
{
args: { query: 'suva weather' },
name: 'web_search',
result: { data: { web: [{ title: 'Suva forecast', url: 'https://example.test', description: 'Sunny' }] } },
summary: 'Did 1 search in 0.5s',
tool_id: 'search-suva'
},
'complete'
)
const [part] = completed
expect(part?.type).toBe('tool-call')
expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).result).toMatchObject({
data: { web: [{ title: 'Suva forecast' }] },
summary: 'Did 1 search in 0.5s'
})
})
})
+186 -9
View File
@@ -23,10 +23,16 @@ export type GatewayEventPayload = {
rendered?: string
status?: string
message?: string
id?: string
name?: string
tool_id?: string
tool_call_id?: string
args?: unknown
arguments?: unknown
context?: string
input?: unknown
preview?: string
result?: unknown
summary?: string
error?: string | boolean
inline_diff?: string
@@ -209,7 +215,135 @@ export function hasToolPart(message: ChatMessage): boolean {
}
function toolId(payload: GatewayEventPayload | undefined): string {
return payload?.tool_id || payload?.name || `tool-${Date.now()}`
return payload?.tool_id || payload?.tool_call_id || payload?.id || ''
}
let liveToolCounter = 0
function nextLiveToolId(name: string): string {
liveToolCounter += 1
return `live-tool:${name}:${liveToolCounter}`
}
function firstStringField(record: Record<string, unknown>, keys: readonly string[]): string {
for (const key of keys) {
const value = record[key]
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
}
return ''
}
function normalizeToolMatchValue(value: string): string {
return value.trim().toLowerCase()
}
function collectToolMatchValues(query: string, context: string, preview: string): string[] {
return [...new Set([query, context, preview].map(normalizeToolMatchValue).filter(Boolean))]
}
function toolPayloadMatchValues(payload: GatewayEventPayload | undefined): string[] {
const payloadArgs = liveToolArgs(payload)
const query = firstStringField(payloadArgs, ['search_term', 'query'])
const context = typeof payload?.context === 'string' ? payload.context.trim() : ''
const preview = typeof payload?.preview === 'string' ? payload.preview.trim() : ''
return collectToolMatchValues(query, context, preview)
}
function toolPartMatchValues(part: ChatMessagePart): string[] {
if (part.type !== 'tool-call' || !part.args || typeof part.args !== 'object') {
return []
}
const args = part.args as Record<string, unknown>
const query = firstStringField(args, ['search_term', 'query'])
const context = typeof args.context === 'string' ? args.context.trim() : ''
const preview = typeof args.preview === 'string' ? args.preview.trim() : ''
return collectToolMatchValues(query, context, preview)
}
function hasToolMatchOverlap(left: string[], right: string[]): boolean {
if (!left.length || !right.length) {
return false
}
const rightSet = new Set(right)
return left.some(value => rightSet.has(value))
}
function findToolPartIndex(
parts: ChatMessagePart[],
name: string,
stableId: string,
payload: GatewayEventPayload | undefined,
phase: 'running' | 'complete'
): number {
const matchValues = toolPayloadMatchValues(payload)
const overlaps = (index: number) => hasToolMatchOverlap(matchValues, toolPartMatchValues(parts[index]))
if (stableId) {
const stableIndex = parts.findIndex(part => part.type === 'tool-call' && part.toolCallId === stableId)
if (stableIndex >= 0) {
return stableIndex
}
// Some live streams start without an id, then complete with one. Fall
// through to pending same-name/context matching so the completion updates
// the synthetic live row instead of appending a duplicate completed row.
if (phase === 'running' && !matchValues.length) {
return -1
}
}
const pendingIndices = parts
.map((part, index) => ({ part, index }))
.filter(({ part }) => part.type === 'tool-call' && part.toolName === name && part.result === undefined)
.map(({ index }) => index)
if (pendingIndices.length === 0) {
return -1
}
if (matchValues.length) {
const contextualIndex = pendingIndices.find(overlaps)
if (contextualIndex !== undefined) {
return contextualIndex
}
}
if (pendingIndices.length === 1) {
const [singlePendingIndex] = pendingIndices
if (phase === 'running' && matchValues.length && !overlaps(singlePendingIndex)) {
return stableId ? singlePendingIndex : -1
}
return singlePendingIndex
}
// Completion events without stable IDs frequently arrive after multiple
// same-name starts (parallel tool calls). Resolve them oldest-first so we
// don't collapse an entire burst into a single row.
if (phase === 'complete') {
return pendingIndices[0]
}
if (stableId) {
return pendingIndices[0]
}
// For progress/running events with no stable id, update the most-recent
// pending same-name tool instead of creating a phantom extra row.
return pendingIndices.at(-1) ?? -1
}
// Carry todo state across sparse progress payloads: if this todo event lacks
@@ -221,27 +355,43 @@ function carryTodos(payload: GatewayEventPayload | undefined, ...prev: unknown[]
return next === null ? undefined : { todos: next }
}
if (payload?.name !== 'todo') {return undefined}
if (payload?.name !== 'todo') {
return undefined
}
for (const p of prev) {
const carried = parseTodos(recordFromUnknown(p)?.todos)
if (carried !== null) {return { todos: carried }}
if (carried !== null) {
return { todos: carried }
}
}
return undefined
}
function toolArgs(payload: GatewayEventPayload | undefined, prevArgs?: unknown): Record<string, unknown> {
const prev = parseMaybeJsonObject(prevArgs)
const eventArgs = liveToolArgs(payload)
return {
...prev,
...eventArgs,
...(payload?.context ? { context: payload.context } : {}),
...(payload?.preview ? { preview: payload.preview } : {}),
...carryTodos(payload, prevArgs)
}
}
function toolResult(payload: GatewayEventPayload | undefined, prevResult?: unknown, prevArgs?: unknown): Record<string, unknown> {
function toolResult(
payload: GatewayEventPayload | undefined,
prevResult?: unknown,
prevArgs?: unknown
): Record<string, unknown> {
const parsedResult = parseMaybeJsonObject(payload?.result)
return {
...parsedResult,
...(payload?.inline_diff ? { inline_diff: payload.inline_diff } : {}),
...(payload?.summary ? { summary: payload.summary } : {}),
...(payload?.message ? { message: payload.message } : {}),
@@ -257,19 +407,22 @@ export function upsertToolPart(
payload: GatewayEventPayload | undefined,
phase: 'running' | 'complete'
): ChatMessagePart[] {
const id = toolId(payload)
const stableId = toolId(payload)
const name = payload?.name || 'tool'
const next = [...parts]
const index = next.findIndex(
part => part.type === 'tool-call' && ((part.toolCallId && part.toolCallId === id) || part.toolName === name)
)
const index = findToolPartIndex(next, name, stableId, payload, phase)
const prev = index >= 0 ? next[index] : null
const prevArgs = prev && 'args' in prev ? prev.args : undefined
const prevResult = prev && 'result' in prev ? prev.result : undefined
const args = toolArgs(payload, prevArgs)
const id =
stableId ||
(prev && 'toolCallId' in prev && typeof prev.toolCallId === 'string' ? prev.toolCallId : '') ||
nextLiveToolId(name)
const base = {
type: 'tool-call' as const,
toolCallId: id,
@@ -279,7 +432,9 @@ export function upsertToolPart(
...(phase === 'complete' && { result: toolResult(payload, prevResult, prevArgs), isError: Boolean(payload?.error) })
} satisfies ChatMessagePart
if (index === -1) {return [...next, base]}
if (index === -1) {
return [...next, base]
}
next[index] = { ...next[index], ...base }
return next
@@ -319,6 +474,28 @@ function firstNonEmptyObject(...values: unknown[]): Record<string, unknown> {
return {}
}
function liveToolArgs(payload: GatewayEventPayload | undefined): Record<string, unknown> {
const direct = firstNonEmptyObject(payload?.args, payload?.arguments)
const input = firstNonEmptyObject(payload?.input)
const fn = recordFromUnknown(input.function)
const nested = firstNonEmptyObject(
input.args,
input.arguments,
input.parameters,
input.input,
fn?.arguments,
fn?.args,
fn?.parameters
)
return {
...input,
...nested,
...direct
}
}
function parseStoredToolResult(content: unknown): unknown {
if (content && typeof content === 'object') {
return content
+4 -1
View File
@@ -101,7 +101,10 @@ export function parseCommitHeader(raw: string): ParsedCommit {
}
function tidySubject(subject: string): string {
const cleaned = subject.replace(/\s+/g, ' ').replace(/[.;,\s]+$/, '').trim()
const cleaned = subject
.replace(/\s+/g, ' ')
.replace(/[.;,\s]+$/, '')
.trim()
if (!cleaned) {
return cleaned
+10 -16
View File
@@ -63,7 +63,8 @@ describe('external link helpers', () => {
const bridge = vi.fn().mockResolvedValue('El Yunque Tour Water Slide, Rope Swing & Pickup')
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
const url = 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure-with-transport.a46272756.activity-details'
const url =
'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure-with-transport.a46272756.activity-details'
const [first, second] = await Promise.all([fetchLinkTitle(url), fetchLinkTitle(url)])
@@ -95,11 +96,7 @@ describe('external link helpers', () => {
const openExternal = vi.fn().mockResolvedValue(undefined)
installDesktopBridge({ openExternal: openExternal as unknown as Window['hermesDesktop']['openExternal'] })
render(
<ExternalLink href="https://example.com/path/to/resource">
Example link
</ExternalLink>
)
render(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>)
fireEvent.click(screen.getByRole('link', { name: 'Example link' }))
expect(openExternal).toHaveBeenCalledWith('https://example.com/path/to/resource')
@@ -108,11 +105,7 @@ describe('external link helpers', () => {
it('shows a trailing external-link icon', () => {
installDesktopBridge()
render(
<ExternalLink href="https://example.com/path/to/resource">
Example link
</ExternalLink>
)
render(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>)
const link = screen.getByRole('link', { name: 'Example link' })
expect(link.querySelector('svg')).toBeTruthy()
@@ -125,9 +118,7 @@ describe('external link helpers', () => {
const url =
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
render(
<LinkifiedText text={`Read ${url}`} />
)
render(<LinkifiedText text={`Read ${url}`} />)
const link = screen.getByTitle(url)
expect(link.textContent).toContain('From Fajardo Full Day Cordillera Islands Catamaran Tour')
@@ -152,7 +143,8 @@ describe('external link helpers', () => {
it('ignores error-like fetched titles and falls back to slug label', async () => {
const bridge = vi.fn().mockResolvedValue('GetYourGuide Error')
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
const url = 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
const url =
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
render(<PrettyLink href={url} />)
@@ -168,6 +160,8 @@ describe('external link helpers', () => {
render(<LinkifiedText text="Source expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure" />)
const link = screen.getByRole('link')
expect(link.getAttribute('href')).toBe('https://expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure')
expect(link.getAttribute('href')).toBe(
'https://expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure'
)
})
})
+8 -1
View File
@@ -204,7 +204,14 @@ export function ExternalLinkIcon({ className }: { className?: string }) {
return <ArrowUpRight aria-hidden className={cn('ml-1 inline size-[0.78em] align-[-0.08em] opacity-70', className)} />
}
export function ExternalLink({ children, className, href, onClick, showExternalIcon = true, ...rest }: ExternalLinkProps) {
export function ExternalLink({
children,
className,
href,
onClick,
showExternalIcon = true,
...rest
}: ExternalLinkProps) {
const target = normalizeExternalUrl(href)
return (
@@ -0,0 +1,188 @@
import {
AssistantRuntimeImpl,
BaseAssistantRuntimeCore,
ExternalStoreThreadListRuntimeCore,
ExternalStoreThreadRuntimeCore,
hasUpcomingMessage
} from '@assistant-ui/core/internal'
import {
type AssistantRuntime,
type ExternalStoreAdapter,
type ThreadMessage,
useRuntimeAdapters
} from '@assistant-ui/react'
import { useEffect, useMemo, useState } from 'react'
const EMPTY_ARRAY = Object.freeze([])
const shallowEqual = (a: object, b: object): boolean => {
const aKeys = Object.keys(a)
if (aKeys.length !== Object.keys(b).length) {
return false
}
for (const key of aKeys) {
if (a[key as keyof typeof a] !== b[key as keyof typeof b]) {
return false
}
}
return true
}
const getThreadListAdapter = (store: ExternalStoreAdapter) => store.adapters?.threadList ?? {}
function syncRepositoryIncrementally(
runtime: ExternalStoreThreadRuntimeCore,
messageRepository: NonNullable<ExternalStoreAdapter['messageRepository']>
): readonly ThreadMessage[] {
const repository = (runtime as unknown as { repository: ExternalStoreThreadRuntimeCore['repository'] }).repository
const incomingIds = new Set(messageRepository.messages.map(({ message }) => message.id))
for (const { message, parentId } of messageRepository.messages) {
repository.addOrUpdateMessage(parentId, message)
}
for (const { message } of repository.export().messages) {
if (!incomingIds.has(message.id)) {
repository.deleteMessage(message.id)
}
}
const headId = messageRepository.headId ?? messageRepository.messages.at(-1)?.message.id ?? null
repository.resetHead(headId)
return repository.getMessages()
}
class IncrementalExternalStoreThreadRuntimeCore extends ExternalStoreThreadRuntimeCore {
override __internal_setAdapter(store: ExternalStoreAdapter): void {
if (!store.messageRepository) {
super.__internal_setAdapter(store)
return
}
const self = this as unknown as {
_assistantOptimisticId: null | string
_capabilities: object
_messages: readonly ThreadMessage[]
_notifyEventSubscribers: (event: string, payload: object) => void
_notifySubscribers: () => void
_store?: ExternalStoreAdapter
}
if (self._store === store) {
return
}
const isRunning = store.isRunning ?? false
this.isDisabled = store.isDisabled ?? false
const oldStore = self._store
self._store = store
if (this.extras !== store.extras) {
this.extras = store.extras
}
const newSuggestions = store.suggestions ?? EMPTY_ARRAY
if (!shallowEqual(this.suggestions, newSuggestions)) {
this.suggestions = newSuggestions
}
const newCapabilities = {
switchToBranch: store.setMessages !== undefined,
switchBranchDuringRun: false,
edit: store.onEdit !== undefined,
reload: store.onReload !== undefined,
cancel: store.onCancel !== undefined,
speech: store.adapters?.speech !== undefined,
dictation: store.adapters?.dictation !== undefined,
voice: store.adapters?.voice !== undefined,
unstable_copy: store.unstable_capabilities?.copy !== false,
attachments: !!store.adapters?.attachments,
feedback: !!store.adapters?.feedback,
queue: false
}
if (!shallowEqual(self._capabilities, newCapabilities)) {
self._capabilities = newCapabilities
}
if (oldStore && oldStore.isRunning === store.isRunning && oldStore.messageRepository === store.messageRepository) {
self._notifySubscribers()
return
}
if (self._assistantOptimisticId) {
this.repository.deleteMessage(self._assistantOptimisticId)
self._assistantOptimisticId = null
}
const messages = syncRepositoryIncrementally(this, store.messageRepository)
if (messages.length > 0) {
this.ensureInitialized()
}
if ((oldStore?.isRunning ?? false) !== (store.isRunning ?? false)) {
self._notifyEventSubscribers(store.isRunning ? 'runStart' : 'runEnd', {})
}
if (hasUpcomingMessage(isRunning, messages)) {
self._assistantOptimisticId = this.repository.appendOptimisticMessage(messages.at(-1)?.id ?? null, {
role: 'assistant',
content: []
})
}
this.repository.resetHead(self._assistantOptimisticId ?? messages.at(-1)?.id ?? null)
self._messages = this.repository.getMessages()
self._notifySubscribers()
}
}
class IncrementalExternalStoreRuntimeCore extends BaseAssistantRuntimeCore {
threads: ExternalStoreThreadListRuntimeCore
constructor(adapter: ExternalStoreAdapter) {
super()
this.threads = new ExternalStoreThreadListRuntimeCore(
getThreadListAdapter(adapter),
() => new IncrementalExternalStoreThreadRuntimeCore(this._contextProvider, adapter)
)
}
setAdapter(adapter: ExternalStoreAdapter): void {
this.threads.__internal_setAdapter(getThreadListAdapter(adapter))
this.threads.getMainThreadRuntimeCore().__internal_setAdapter(adapter)
}
}
export function useIncrementalExternalStoreRuntime<T extends ThreadMessage>(
store: ExternalStoreAdapter<T>
): AssistantRuntime {
const [runtime] = useState(() => new IncrementalExternalStoreRuntimeCore(store as ExternalStoreAdapter))
useEffect(() => {
runtime.setAdapter(store as ExternalStoreAdapter)
})
const { modelContext } = useRuntimeAdapters() ?? {}
useEffect(() => {
if (!modelContext) {
return undefined
}
return runtime.registerModelContextProvider(modelContext)
}, [modelContext, runtime])
return useMemo(() => new AssistantRuntimeImpl(runtime), [runtime])
}
+13 -3
View File
@@ -28,6 +28,7 @@ const CITATION_MARKER_RE = /(?<=[\p{L}\p{N})\].,!?:;"'”’])\[(?:\d+(?:\s*,\s*
*/
function hasCloseFenceLine(body: string, marker: string): boolean {
const lines = body.split('\n')
// Original regex required `\n` immediately before the close fence, so the
// first line of `body` (which has no preceding newline within `body`)
// cannot itself be the close fence.
@@ -35,8 +36,15 @@ function hasCloseFenceLine(body: string, marker: string): boolean {
const line = lines[i]
let lo = 0
let hi = line.length
while (lo < hi && (line[lo] === ' ' || line[lo] === '\t')) lo += 1
while (hi > lo && (line[hi - 1] === ' ' || line[hi - 1] === '\t')) hi -= 1
while (lo < hi && (line[lo] === ' ' || line[lo] === '\t')) {
lo += 1
}
while (hi > lo && (line[hi - 1] === ' ' || line[hi - 1] === '\t')) {
hi -= 1
}
if (line.slice(lo, hi) === marker) {
return true
}
@@ -122,7 +130,9 @@ function normalizeVisibleProse(text: string): string {
.map(part =>
part.startsWith('`')
? part
: autoLinkRawUrls(part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, ''))
: autoLinkRawUrls(
part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, '')
)
)
.join('')
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { interpretRuntimeReadiness } from './runtime-readiness'
describe('interpretRuntimeReadiness', () => {
it('prefers runtime_check when both signals exist', () => {
const result = interpretRuntimeReadiness({
setup: { provider_configured: false },
setupError: null,
runtime: { ok: true },
runtimeError: null
})
expect(result).toEqual({
checksDisagree: true,
ready: true,
reason: null,
source: 'runtime_check'
})
})
it('surfaces runtime mismatch details when runtime_check fails', () => {
const result = interpretRuntimeReadiness({
setup: { provider_configured: true },
setupError: null,
runtime: { error: 'No provider can serve the selected model.', ok: false },
runtimeError: null
})
expect(result.ready).toBe(false)
expect(result.source).toBe('runtime_check')
expect(result.checksDisagree).toBe(true)
expect(result.reason).toContain('No provider can serve the selected model.')
expect(result.reason).toContain('setup.status reports configured credentials')
})
it('falls back to setup.status when runtime_check has no boolean result', () => {
const result = interpretRuntimeReadiness({
setup: { provider_configured: true },
setupError: null,
runtime: null,
runtimeError: 'runtime check RPC unavailable'
})
expect(result).toEqual({
checksDisagree: false,
ready: true,
reason: null,
source: 'setup_status'
})
})
it('uses explicit fallback when both checks are missing', () => {
const result = interpretRuntimeReadiness({
setup: null,
setupError: 'setup.status timeout',
runtime: null,
runtimeError: 'setup.runtime_check timeout'
})
expect(result.ready).toBe(false)
expect(result.source).toBe('fallback')
expect(result.reason).toBe('setup.runtime_check timeout')
})
})
+147
View File
@@ -0,0 +1,147 @@
export interface SetupStatusSnapshot {
provider_configured?: boolean
}
export interface RuntimeCheckSnapshot {
error?: string
ok?: boolean
}
export interface RuntimeReadinessSignals {
setup: null | SetupStatusSnapshot
setupError: null | string
runtime: null | RuntimeCheckSnapshot
runtimeError: null | string
}
export interface RuntimeReadinessOptions {
defaultReason?: string
unknownReady?: boolean
}
export interface RuntimeReadinessResult {
checksDisagree: boolean
ready: boolean
reason: null | string
source: 'fallback' | 'runtime_check' | 'setup_status'
}
export type RuntimeReadinessRequester = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
const DEFAULT_NOT_READY_REASON = 'Add a provider credential before sending your first message.'
function toErrorMessage(error: unknown): null | string {
if (error instanceof Error) {
return error.message
}
if (typeof error === 'string') {
return error
}
if (error === null || error === undefined) {
return null
}
return String(error)
}
function normalizeMessage(value: null | string | undefined): null | string {
const next = value?.trim()
return next ? next : null
}
async function requestWithFallback<T>(
requestGateway: RuntimeReadinessRequester,
method: string
): Promise<{ error: null | string; value: null | T }> {
try {
return { error: null, value: await requestGateway<T>(method) }
} catch (error) {
return { error: toErrorMessage(error), value: null }
}
}
export async function fetchRuntimeReadinessSignals(
requestGateway: RuntimeReadinessRequester
): Promise<RuntimeReadinessSignals> {
const [setup, runtime] = await Promise.all([
requestWithFallback<SetupStatusSnapshot>(requestGateway, 'setup.status'),
requestWithFallback<RuntimeCheckSnapshot>(requestGateway, 'setup.runtime_check')
])
return {
setup: setup.value,
setupError: setup.error,
runtime: runtime.value,
runtimeError: runtime.error
}
}
export function interpretRuntimeReadiness(
signals: RuntimeReadinessSignals,
options: RuntimeReadinessOptions = {}
): RuntimeReadinessResult {
const defaultReason = options.defaultReason ?? DEFAULT_NOT_READY_REASON
const unknownReady = options.unknownReady ?? false
const setupConfigured =
typeof signals.setup?.provider_configured === 'boolean' ? Boolean(signals.setup.provider_configured) : undefined
const runtimeOk = typeof signals.runtime?.ok === 'boolean' ? Boolean(signals.runtime.ok) : undefined
const runtimeFailure = normalizeMessage(signals.runtime?.error) ?? normalizeMessage(signals.runtimeError)
const setupFailure = normalizeMessage(signals.setupError)
const checksDisagree =
typeof setupConfigured === 'boolean' && typeof runtimeOk === 'boolean' && setupConfigured !== runtimeOk
if (typeof runtimeOk === 'boolean') {
if (runtimeOk) {
return {
checksDisagree,
ready: true,
reason: null,
source: 'runtime_check'
}
}
let reason = runtimeFailure ?? defaultReason
if (checksDisagree && setupConfigured) {
reason = `${reason} setup.status reports configured credentials, but runtime resolution still failed.`
}
return {
checksDisagree,
ready: false,
reason,
source: 'runtime_check'
}
}
if (typeof setupConfigured === 'boolean') {
return {
checksDisagree: false,
ready: setupConfigured,
reason: setupConfigured ? null : (runtimeFailure ?? setupFailure ?? defaultReason),
source: 'setup_status'
}
}
return {
checksDisagree: false,
ready: unknownReady,
reason: unknownReady ? null : (runtimeFailure ?? setupFailure ?? defaultReason),
source: 'fallback'
}
}
export async function evaluateRuntimeReadiness(
requestGateway: RuntimeReadinessRequester,
options: RuntimeReadinessOptions = {}
): Promise<RuntimeReadinessResult> {
const signals = await fetchRuntimeReadinessSignals(requestGateway)
return interpretRuntimeReadiness(signals, options)
}
+20 -8
View File
@@ -13,7 +13,9 @@ const isStatus = (v: unknown): v is TodoStatus => (STATUSES as readonly string[]
function parseArray(value: unknown[]): TodoItem[] {
return value.flatMap(item => {
if (!isRecord(item) || !isStatus(item.status)) {return []}
if (!isRecord(item) || !isStatus(item.status)) {
return []
}
const id = String(item.id ?? '').trim()
const content = String(item.content ?? '').trim()
@@ -22,15 +24,25 @@ function parseArray(value: unknown[]): TodoItem[] {
}
function parse(value: unknown, depth: number): null | TodoItem[] {
if (depth > 2) {return null}
if (Array.isArray(value)) {return parseArray(value)}
if (typeof value === 'string' && value.trim()) {
try { return parse(JSON.parse(value), depth + 1) } catch { return null }
if (depth > 2) {
return null
}
if (isRecord(value) && Object.hasOwn(value, 'todos')) {return parse(value.todos, depth + 1)}
if (Array.isArray(value)) {
return parseArray(value)
}
if (typeof value === 'string' && value.trim()) {
try {
return parse(JSON.parse(value), depth + 1)
} catch {
return null
}
}
if (isRecord(value) && Object.hasOwn(value, 'todos')) {
return parse(value.todos, depth + 1)
}
return null
}
@@ -44,9 +44,7 @@ describe('formatToolResultSummary', () => {
details: `prefix ${'x'.repeat(500)}`
})
const detailsLine = summary
.split('\n')
.find(line => line.startsWith('- Details:'))
const detailsLine = summary.split('\n').find(line => line.startsWith('- Details:'))
expect(detailsLine).toBeTruthy()
expect(detailsLine?.length).toBeLessThan(230)
+177 -53
View File
@@ -2,7 +2,21 @@
// mode still gets the raw JSON section.
const WRAPPER_KEYS = ['data', 'result', 'output', 'response', 'payload'] as const
const PRIORITY_KEYS = ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'status', 'id', 'message', 'summary', 'description'] as const
const PRIORITY_KEYS = [
'title',
'name',
'path',
'file',
'filepath',
'url',
'href',
'link',
'status',
'id',
'message',
'summary',
'description'
] as const
const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const
const ERROR_MSG_KEYS = ['message', 'reason', 'detail', 'stderr'] as const
const NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na'])
@@ -14,17 +28,29 @@ const isRecord = (v: unknown): v is Json => Boolean(v && typeof v === 'object' &
function tryJson(value: string): unknown {
const t = value.trim()
if (!t) {return ''}
if (!t) {
return ''
}
if (!/^[{[]|^"/.test(t)) {return value}
if (!/^[{[]|^"/.test(t)) {
return value
}
try { return JSON.parse(t) } catch { return value }
try {
return JSON.parse(t)
} catch {
return value
}
}
const norm = (v: unknown): unknown => (typeof v === 'string' ? tryJson(v) : v)
const titleCase = (k: string) =>
k.split(/[_\-.]+/).filter(Boolean).map(p => `${p[0]?.toUpperCase() ?? ''}${p.slice(1)}`).join(' ')
k
.split(/[_\-.]+/)
.filter(Boolean)
.map(p => `${p[0]?.toUpperCase() ?? ''}${p.slice(1)}`)
.join(' ')
const pluralize = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`
@@ -37,12 +63,16 @@ function clipInline(value: string, max = 180): string {
function clipBlock(value: string, maxChars = 1800, maxLines = 18): string {
const t = value.trim()
if (!t) {return ''}
if (!t) {
return ''
}
const lines = t.split('\n')
let text = lines.slice(0, maxLines).join('\n')
const clipped = lines.length > maxLines || text.length > maxChars
if (text.length > maxChars) {text = text.slice(0, maxChars - 1).trimEnd()}
if (text.length > maxChars) {
text = text.slice(0, maxChars - 1).trimEnd()
}
return clipped && !text.endsWith('…') ? `${text}` : text
}
@@ -51,7 +81,9 @@ function firstString(record: Json, keys: readonly string[]): string {
for (const k of keys) {
const v = record[k]
if (typeof v === 'string' && v.trim()) {return v.trim()}
if (typeof v === 'string' && v.trim()) {
return v.trim()
}
}
return ''
@@ -68,25 +100,37 @@ const isWrapperKey = (k: string) => (WRAPPER_KEYS as readonly string[]).includes
const skipField = (k: string, v: unknown) => isWrapperKey(k) || ((k === 'success' || k === 'ok') && v === true)
function summarizeScalar(v: unknown): string {
if (typeof v === 'string') {return clipInline(v)}
if (typeof v === 'string') {
return clipInline(v)
}
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
if (typeof v === 'number' || typeof v === 'boolean') {
return String(v)
}
return ''
}
function summarizeRecordInline(record: Json, depth: number): string {
if (depth > 3) {return pluralize(Object.keys(record).length, 'field')}
if (depth > 3) {
return pluralize(Object.keys(record).length, 'field')
}
const title = firstString(record, ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'id'])
const status = firstString(record, ['status', 'category', 'type'])
const message = firstString(record, ['snippet', 'summary', 'description', 'message'])
if (title && status) {return `${clipInline(title, 110)} (${clipInline(status, 54)})`}
if (title && status) {
return `${clipInline(title, 110)} (${clipInline(status, 54)})`
}
if (title && message && title !== message) {return `${clipInline(title, 90)} - ${clipInline(message, 84)}`}
if (title && message && title !== message) {
return `${clipInline(title, 90)} - ${clipInline(message, 84)}`
}
if (title) {return clipInline(title, 150)}
if (title) {
return clipInline(title, 150)
}
const pairs = orderedKeys(Object.keys(record))
.filter(k => !skipField(k, record[k]))
@@ -104,15 +148,25 @@ function summarizeRecordInline(record: Json, depth: number): string {
function summarizeListItem(item: unknown, depth: number): string {
const v = norm(item)
if (typeof v === 'string') {return clipInline(v)}
if (typeof v === 'string') {
return clipInline(v)
}
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
if (typeof v === 'number' || typeof v === 'boolean') {
return String(v)
}
if (v == null) {return ''}
if (v == null) {
return ''
}
if (Array.isArray(v)) {return pluralize(v.length, 'item')}
if (Array.isArray(v)) {
return pluralize(v.length, 'item')
}
if (isRecord(v)) {return summarizeRecordInline(v, depth + 1)}
if (isRecord(v)) {
return summarizeRecordInline(v, depth + 1)
}
return clipInline(String(v))
}
@@ -121,32 +175,50 @@ function formatFieldValue(value: unknown, depth: number): string {
const v = norm(value)
const scalar = summarizeScalar(v)
if (scalar) {return scalar}
if (scalar) {
return scalar
}
if (v == null) {return ''}
if (v == null) {
return ''
}
if (Array.isArray(v)) {
if (!v.length) {return '0 items'}
if (!v.length) {
return '0 items'
}
const scalars = v.map(summarizeScalar).filter(Boolean)
if (scalars.length === v.length && v.length <= 4) {return clipInline(scalars.join(', '))}
if (scalars.length === v.length && v.length <= 4) {
return clipInline(scalars.join(', '))
}
const first = summarizeListItem(v[0], depth + 1)
return first ? `${pluralize(v.length, 'item')} (${first})` : pluralize(v.length, 'item')
}
if (isRecord(v)) {return summarizeRecordInline(v, depth + 1)}
if (isRecord(v)) {
return summarizeRecordInline(v, depth + 1)
}
return clipInline(String(v))
}
function formatArraySummary(value: unknown[], depth: number): string {
if (!value.length) {return 'No items returned.'}
if (!value.length) {
return 'No items returned.'
}
const max = 6
const lines = value.slice(0, max).map(item => summarizeListItem(item, depth + 1)).filter(Boolean).map(l => `- ${l}`)
const lines = value
.slice(0, max)
.map(item => summarizeListItem(item, depth + 1))
.filter(Boolean)
.map(l => `- ${l}`)
if (!lines.length) {return `Returned ${pluralize(value.length, 'item')}.`}
if (!lines.length) {
return `Returned ${pluralize(value.length, 'item')}.`
}
if (value.length > max) {
const remaining = value.length - max
@@ -159,13 +231,17 @@ function formatArraySummary(value: unknown[], depth: number): string {
function formatRecordSummary(record: Json, depth: number): string {
const keys = Object.keys(record)
if (!keys.length) {return 'Returned an empty object.'}
if (!keys.length) {
return 'Returned an empty object.'
}
if (depth <= 2) {
const direct = firstString(record, ['message', 'summary', 'description', 'preview', 'text', 'content'])
const meaningful = keys.filter(k => !skipField(k, record[k]) && !isWrapperKey(k))
if (direct && meaningful.length <= 1) {return clipBlock(direct)}
if (direct && meaningful.length <= 1) {
return clipBlock(direct)
}
}
const candidates = orderedKeys(keys).filter(k => !skipField(k, record[k]))
@@ -175,13 +251,19 @@ function formatRecordSummary(record: Json, depth: number): string {
for (const k of candidates) {
const v = formatFieldValue(record[k], depth + 1)
if (!v) {continue}
if (!v) {
continue
}
lines.push(`- ${titleCase(k)}: ${v}`)
if (lines.length >= max) {break}
if (lines.length >= max) {
break
}
}
if (!lines.length) {return `Returned object with ${pluralize(keys.length, 'field')}.`}
if (!lines.length) {
return `Returned object with ${pluralize(keys.length, 'field')}.`
}
if (candidates.length > lines.length) {
const remaining = candidates.length - lines.length
@@ -192,18 +274,30 @@ function formatRecordSummary(record: Json, depth: number): string {
}
function formatSummaryValue(value: unknown, depth: number): string {
if (depth > 4) {return ''}
if (depth > 4) {
return ''
}
const v = norm(value)
if (typeof v === 'string') {return clipBlock(v)}
if (typeof v === 'string') {
return clipBlock(v)
}
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
if (typeof v === 'number' || typeof v === 'boolean') {
return String(v)
}
if (v == null) {return ''}
if (v == null) {
return ''
}
if (Array.isArray(v)) {return formatArraySummary(v, depth + 1)}
if (Array.isArray(v)) {
return formatArraySummary(v, depth + 1)
}
if (isRecord(v)) {return formatRecordSummary(v, depth + 1)}
if (isRecord(v)) {
return formatRecordSummary(v, depth + 1)
}
return clipInline(String(v))
}
@@ -232,17 +326,29 @@ function unwrapPayload(value: unknown): unknown {
function hasMeaningfulErrorValue(value: unknown): boolean {
const v = norm(value)
if (v == null) {return false}
if (v == null) {
return false
}
if (typeof v === 'string') {return !NON_ERROR_TEXT.has(v.trim().toLowerCase())}
if (typeof v === 'string') {
return !NON_ERROR_TEXT.has(v.trim().toLowerCase())
}
if (typeof v === 'boolean') {return v}
if (typeof v === 'boolean') {
return v
}
if (typeof v === 'number') {return v !== 0}
if (typeof v === 'number') {
return v !== 0
}
if (Array.isArray(v)) {return v.some(hasMeaningfulErrorValue)}
if (Array.isArray(v)) {
return v.some(hasMeaningfulErrorValue)
}
if (isRecord(v)) {return Object.keys(v).length > 0}
if (isRecord(v)) {
return Object.keys(v).length > 0
}
return true
}
@@ -261,7 +367,9 @@ function hasErrorSignal(record: Json): boolean {
function valueErrorText(value: unknown): string {
const v = norm(value)
if (typeof v === 'string') {return hasMeaningfulErrorValue(v) ? clipBlock(v, 700, 12) : ''}
if (typeof v === 'string') {
return hasMeaningfulErrorValue(v) ? clipBlock(v, 700, 12) : ''
}
if (Array.isArray(v)) {
return clipBlock(v.map(valueErrorText).filter(Boolean).slice(0, 3).join('; '), 700, 12)
@@ -270,24 +378,32 @@ function valueErrorText(value: unknown): string {
if (isRecord(v)) {
const direct = firstString(v, ERROR_MSG_KEYS)
if (direct) {return clipBlock(direct, 700, 12)}
if (direct) {
return clipBlock(direct, 700, 12)
}
}
return ''
}
function findNestedError(value: unknown, depth: number, seen: Set<unknown>): string {
if (depth > 5) {return ''}
if (depth > 5) {
return ''
}
const v = norm(value)
if (!v || typeof v !== 'object' || seen.has(v)) {return ''}
if (!v || typeof v !== 'object' || seen.has(v)) {
return ''
}
seen.add(v)
if (Array.isArray(v)) {
for (const item of v) {
const nested = findNestedError(item, depth + 1, seen)
if (nested) {return nested}
if (nested) {
return nested
}
}
return ''
@@ -296,22 +412,30 @@ function findNestedError(value: unknown, depth: number, seen: Set<unknown>): str
const record = v as Json
for (const k of ERROR_KEYS) {
if (!hasMeaningfulErrorValue(record[k])) {continue}
if (!hasMeaningfulErrorValue(record[k])) {
continue
}
const text = valueErrorText(record[k])
if (text) {return text}
if (text) {
return text
}
}
if (hasErrorSignal(record)) {
const direct = firstString(record, ERROR_MSG_KEYS)
if (direct) {return clipBlock(direct, 700, 12)}
if (direct) {
return clipBlock(direct, 700, 12)
}
}
for (const k of [...ERROR_KEYS, ...WRAPPER_KEYS, 'details', 'meta']) {
const nested = findNestedError(record[k], depth + 1, seen)
if (nested) {return nested}
if (nested) {
return nested
}
}
return ''
+100
View File
@@ -0,0 +1,100 @@
import { useCallback, useRef } from 'react'
/**
* One-shot enter animation via the Web Animations API.
*
* Returns a callback ref. The animation fires exactly once when the element
* first attaches to the DOM and never replays for an already-mounted node —
* this is deliberate. CSS-transition + `@starting-style` is fragile here
* because:
* - Streaming deltas constantly invalidate ancestor state, which can
* re-trigger transitions on unrelated descendants.
* - `@starting-style` only covers DOM insertion / first-match, but any
* style restart during the message lifecycle replays the transition.
* - Some Chromium versions reset transitions when an attribute on an
* ancestor toggles, even if the descendant's properties never change.
*
* `el.animate(...)` runs against the element directly and is independent of
* CSS rule churn — it plays once, finishes, and is done. If the element
* unmounts and re-mounts, the callback ref runs again and replays it
* (correct behaviour).
*
* `enabled` is captured at mount-time only — flipping it later doesn't
* suddenly play the animation on existing nodes.
*/
const playedAnimationKeys = new Set<string>()
const playedAnimationOrder: string[] = []
const MAX_TRACKED_KEYS = 2048
function hasPlayedAnimation(key: string): boolean {
return playedAnimationKeys.has(key)
}
function rememberPlayedAnimation(key: string): void {
if (playedAnimationKeys.has(key)) {
return
}
playedAnimationKeys.add(key)
playedAnimationOrder.push(key)
if (playedAnimationOrder.length > MAX_TRACKED_KEYS) {
const evicted = playedAnimationOrder.shift()
if (evicted) {
playedAnimationKeys.delete(evicted)
}
}
}
function scheduleMicrotask(cb: () => void): void {
if (typeof queueMicrotask === 'function') {
queueMicrotask(cb)
return
}
void Promise.resolve().then(cb)
}
export function useEnterAnimation(enabled: boolean, animationKey?: string): (el: HTMLElement | null) => void {
const enabledRef = useRef(enabled)
const keyRef = useRef(animationKey)
enabledRef.current = enabled
keyRef.current = animationKey
return useCallback((el: HTMLElement | null) => {
if (!el || !enabledRef.current || typeof window === 'undefined') {
return
}
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
return
}
const key = keyRef.current
if (key && hasPlayedAnimation(key)) {
return
}
el.animate(
[
{ opacity: 0, transform: 'translateY(0.5rem)' },
{ opacity: 1, transform: 'translateY(0)' }
],
{ duration: 220, easing: 'linear', fill: 'both' }
)
if (key) {
// In React StrictMode the first mount can be immediately torn down.
// Only persist "played" once the element survives to the microtask tick.
scheduleMicrotask(() => {
if (el.isConnected) {
rememberPlayedAnimation(key)
}
})
}
}, [])
}