Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useAuiState, useMessageRuntime } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type MouseEvent, useCallback } from 'react'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { QUICK_REACTIONS, toggleMessageReaction } from '@/store/reactions'
|
||||
import { $reactionsEnabled } from '@/store/reactions-enabled'
|
||||
import { $agentReactions, $localReactions, mergeReactions, setLocalReaction } from '@/store/reactions-local'
|
||||
import type { MessageReaction } from '@/types/hermes'
|
||||
|
||||
// Stable empty identity — a fresh [] per render would re-run every consumer.
|
||||
const EMPTY_REACTIONS: MessageReaction[] = []
|
||||
|
||||
/** The tapback a double-click lands: Apple's first Tapback, and ours. */
|
||||
export const DOUBLE_CLICK_REACTION = QUICK_REACTIONS[0]
|
||||
|
||||
// Double-click means something else on these: links and controls act, inputs
|
||||
// and code blocks select. The gesture only claims plain message body.
|
||||
const NOT_A_TAPBACK = 'a, button, input, pre, select, textarea, [contenteditable="true"], [role="button"]'
|
||||
|
||||
/**
|
||||
* Is this double-click the "heart it" gesture?
|
||||
*
|
||||
* `detail === 2` keeps a triple-click (select-the-paragraph) from re-firing,
|
||||
* and anything the browser already gives a double-click meaning keeps it.
|
||||
*/
|
||||
export function isTapbackDoubleClick(event: { detail: number; target: EventTarget | null }): boolean {
|
||||
if (event.detail !== 2) {
|
||||
return false
|
||||
}
|
||||
|
||||
const target = event.target
|
||||
|
||||
return target instanceof Element ? !target.closest(NOT_A_TAPBACK) : true
|
||||
}
|
||||
|
||||
/** Paint the tapback locally, then persist behind it. */
|
||||
function commitReaction(
|
||||
messageId: string,
|
||||
role: ChatMessage['role'],
|
||||
rowId: number | undefined,
|
||||
reactions: MessageReaction[],
|
||||
emoji: null | string
|
||||
): void {
|
||||
// Flip the UI immediately — a tapback is direct manipulation and must never
|
||||
// wait on a round-trip. Persistence follows in the background.
|
||||
setLocalReaction(messageId, emoji)
|
||||
void toggleMessageReaction({ id: messageId, role, rowId, reactions } as ChatMessage, emoji)
|
||||
}
|
||||
|
||||
/**
|
||||
* A message's reactions and the one way to change them.
|
||||
*
|
||||
* Reads the durable list off `metadata.custom`, layers this window's live
|
||||
* overlays on top (the user's own click, the agent's mid-turn event), and
|
||||
* hands back a `react` that paints locally first and persists behind it.
|
||||
* Shared by the assistant footer slot, the user bubble's picker, and the
|
||||
* double-click gesture so all three apply identical tapback semantics.
|
||||
*/
|
||||
export function useMessageReactions(
|
||||
messageId: string,
|
||||
role: ChatMessage['role']
|
||||
): {
|
||||
enabled: boolean
|
||||
react: (emoji: null | string) => void
|
||||
reactions: MessageReaction[]
|
||||
} {
|
||||
const reactions = useAuiState(s => {
|
||||
const custom = (s.message.metadata?.custom ?? {}) as { reactions?: MessageReaction[] }
|
||||
|
||||
return custom.reactions ?? EMPTY_REACTIONS
|
||||
})
|
||||
|
||||
const rowId = useAuiState(s => {
|
||||
const custom = (s.message.metadata?.custom ?? {}) as { rowId?: number }
|
||||
|
||||
return custom.rowId
|
||||
})
|
||||
|
||||
const enabled = useStore($reactionsEnabled)
|
||||
const localAll = useStore($localReactions)
|
||||
const agentLive = useStore($agentReactions)
|
||||
|
||||
return {
|
||||
enabled,
|
||||
react: useCallback(
|
||||
(emoji: null | string) => commitReaction(messageId, role, rowId, reactions, emoji),
|
||||
[messageId, reactions, role, rowId]
|
||||
),
|
||||
reactions: mergeReactions(reactions, localAll[messageId], rowId === undefined ? undefined : agentLive[rowId])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Double-click a message to heart it — the iMessage gesture.
|
||||
*
|
||||
* Reads the message's reaction state lazily at event time (the same trick the
|
||||
* footer uses for its text): the gesture renders nothing, so subscribing the
|
||||
* perf-sensitive message root to every reaction change would be pure cost.
|
||||
* Returns `undefined` while reactions are off, so the element carries no
|
||||
* listener at all.
|
||||
*/
|
||||
export function useTapbackDoubleClick(
|
||||
messageId: string,
|
||||
role: ChatMessage['role']
|
||||
): ((event: MouseEvent<HTMLElement>) => void) | undefined {
|
||||
const enabled = useStore($reactionsEnabled)
|
||||
const messageRuntime = useMessageRuntime()
|
||||
|
||||
const onDoubleClick = useCallback(
|
||||
(event: MouseEvent<HTMLElement>) => {
|
||||
if (!isTapbackDoubleClick(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Double-click has already selected the word underneath — the tapback,
|
||||
// not a stray selection, is what the gesture meant.
|
||||
window.getSelection()?.removeAllRanges()
|
||||
triggerHaptic('selection')
|
||||
|
||||
const custom = (messageRuntime.getState().metadata?.custom ?? {}) as {
|
||||
reactions?: MessageReaction[]
|
||||
rowId?: number
|
||||
}
|
||||
|
||||
const reactions = custom.reactions ?? EMPTY_REACTIONS
|
||||
|
||||
// Same toggle semantics as the picker: a second double-click retracts.
|
||||
const mine = mergeReactions(reactions, $localReactions.get()[messageId]).find(
|
||||
reaction => reaction.author === 'user'
|
||||
)
|
||||
|
||||
commitReaction(
|
||||
messageId,
|
||||
role,
|
||||
custom.rowId,
|
||||
reactions,
|
||||
mine?.emoji === DOUBLE_CLICK_REACTION ? null : DOUBLE_CLICK_REACTION
|
||||
)
|
||||
},
|
||||
[messageId, messageRuntime, role]
|
||||
)
|
||||
|
||||
return enabled ? onDoubleClick : undefined
|
||||
}
|
||||
@@ -0,0 +1,928 @@
|
||||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
|
||||
import {
|
||||
type ClipboardEvent,
|
||||
type FC,
|
||||
type FocusEvent,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type DragEvent as ReactDragEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
import { ComposerDirectiveActions } from '@/app/chat/composer/directive-actions'
|
||||
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from '@/app/chat/composer/drop-affordance'
|
||||
import {
|
||||
type ComposerInsertMode,
|
||||
focusComposerInput,
|
||||
markActiveComposer,
|
||||
onComposerFocusRequest,
|
||||
onComposerInsertRequest,
|
||||
releaseActiveComposer
|
||||
} from '@/app/chat/composer/focus'
|
||||
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
|
||||
import { rebuildAroundCaret, triggerKeyUpHandler } from '@/app/chat/composer/hooks/use-composer-trigger'
|
||||
import { useComposerUndo } from '@/app/chat/composer/hooks/use-composer-undo'
|
||||
import { useEmojiCompletions } from '@/app/chat/composer/hooks/use-emoji-completions'
|
||||
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
|
||||
import {
|
||||
dragHasAttachments,
|
||||
droppedFileInlineRefs,
|
||||
type InlineRefInput,
|
||||
insertInlineRefsIntoEditor
|
||||
} from '@/app/chat/composer/inline-refs'
|
||||
import { chipTypedPathOnSpace, pathifyRefs } from '@/app/chat/composer/path-refs'
|
||||
import {
|
||||
composerPlainText,
|
||||
insertComposerContentsAtCaret,
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
replaceBeforeCaret,
|
||||
RICH_INPUT_SLOT
|
||||
} from '@/app/chat/composer/rich-editor'
|
||||
import { detectTrigger, openDirectiveScope, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
|
||||
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
|
||||
import { isRedoShortcut, isUndoShortcut } from '@/app/chat/composer/undo-history'
|
||||
import { chipTypedUrlOnSpace, linkifyUrls } from '@/app/chat/composer/url-refs'
|
||||
import {
|
||||
extractDroppedFiles,
|
||||
HERMES_PATHS_MIME,
|
||||
isImagePath,
|
||||
partitionDroppedFiles
|
||||
} from '@/app/chat/hooks/use-composer-actions'
|
||||
import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions'
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import {
|
||||
StickyHumanMessageContainer,
|
||||
StopGlyph,
|
||||
USER_ACTION_ICON_BUTTON_CLASS,
|
||||
USER_ACTION_ICON_SIZE,
|
||||
USER_BUBBLE_BASE_CLASS
|
||||
} from '@/components/assistant-ui/thread/user-message'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime'
|
||||
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Loader2Icon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $terminalBackend } from '@/store/session'
|
||||
import { isSessionRemote } from '@/store/session-states'
|
||||
import { notifyThreadEditClose } from '@/store/thread-scroll'
|
||||
|
||||
interface UserEditComposerProps {
|
||||
cwd: string | null
|
||||
gateway: HermesGateway | null
|
||||
sessionId: string | null
|
||||
}
|
||||
|
||||
export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const aui = useAui()
|
||||
const draft = useAuiState(s => s.composer.text)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
// Capture the original draft immediately before the first edit. The runtime
|
||||
// may hydrate composer.text after this component's first render, so taking a
|
||||
// mount-time snapshot can incorrectly classify every later blur as dirty.
|
||||
const initialDraftRef = useRef<string | null>(null)
|
||||
const draftRef = useRef(draft)
|
||||
const composingRef = useRef(false)
|
||||
const dragDepthRef = useRef(0)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
// See index.tsx: set in keydown when the open popover consumes a nav/control
|
||||
// key so the matching keyup skips refreshTrigger (timing-immune vs reading
|
||||
// `trigger`, which keyup sees as already-null after Escape).
|
||||
const triggerKeyConsumedRef = useRef(false)
|
||||
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
|
||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
// True while OS-drop files are being staged/uploaded into the session. Blocks
|
||||
// submit and shows a spinner so confirming the edit can't race the async
|
||||
// upload and drop the gateway-side ref before it lands in the draft.
|
||||
const [staging, setStaging] = useState(false)
|
||||
const expanded = draft.includes('\n')
|
||||
const canSubmit = draft.trim().length > 0
|
||||
const at = useAtCompletions({ cwd, gateway, sessionId })
|
||||
const slash = useSlashCompletions({ gateway })
|
||||
const emoji = useEmojiCompletions()
|
||||
|
||||
// Timers this composer schedules must not outlive it. Every callback below
|
||||
// touches component state, a ref or the composer core, and this is the one
|
||||
// composer that routinely unmounts mid-flight: confirming an edit tears it
|
||||
// down while the 200ms submit latch is still pending, so the latch resumes
|
||||
// against an unmounted tree. Two of the callbacks already carry defensive
|
||||
// try/catch for the racing-teardown case; clearing the timers removes the
|
||||
// race instead of surviving it.
|
||||
const pendingTimeoutsRef = useRef<Set<number>>(new Set())
|
||||
|
||||
const scheduleTimeout = useCallback((run: () => void, delayMs: number): void => {
|
||||
const id = window.setTimeout(() => {
|
||||
pendingTimeoutsRef.current.delete(id)
|
||||
run()
|
||||
}, delayMs)
|
||||
|
||||
pendingTimeoutsRef.current.add(id)
|
||||
}, [])
|
||||
|
||||
// This is the one composer that routinely unmounts, so it is where the focus
|
||||
// bus leaks: confirming or cancelling an edit tears the composer down while
|
||||
// `'edit'` is still the active target. Release it alongside the thread-scroll
|
||||
// cleanup so keyboard routing falls back to the visible chat composer.
|
||||
//
|
||||
// It also drains whatever `scheduleTimeout` still has pending, which is a
|
||||
// second concern under the same heading rather than a separate one: both
|
||||
// are "this composer is going away", they unmount together by definition,
|
||||
// and a sibling unmount-only effect would only be a second place to forget.
|
||||
useEffect(
|
||||
() => () => {
|
||||
notifyThreadEditClose()
|
||||
releaseActiveComposer('edit')
|
||||
|
||||
for (const id of pendingTimeoutsRef.current) {
|
||||
window.clearTimeout(id)
|
||||
}
|
||||
|
||||
pendingTimeoutsRef.current.clear()
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const focusEditor = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
focusComposerInput(editor)
|
||||
|
||||
if (editor) {
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
|
||||
markActiveComposer('edit')
|
||||
}, [])
|
||||
|
||||
const requestEditFocus = useCallback(() => {
|
||||
setFocusRequestId(id => id + 1)
|
||||
}, [])
|
||||
|
||||
const rememberInitialDraft = useCallback(() => {
|
||||
if (initialDraftRef.current === null) {
|
||||
initialDraftRef.current = draftRef.current
|
||||
}
|
||||
}, [])
|
||||
|
||||
const appendExternalText = useCallback(
|
||||
(text: string, mode: ComposerInsertMode) => {
|
||||
const value = text.trim()
|
||||
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
rememberInitialDraft()
|
||||
const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current
|
||||
const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : ''
|
||||
const next = `${base}${sep}${value}`
|
||||
|
||||
draftRef.current = next
|
||||
aui.composer().setText(next)
|
||||
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
renderComposerContents(editor, next, { trailingCommitted: true })
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
|
||||
setFocusRequestId(id => id + 1)
|
||||
},
|
||||
[aui, rememberInitialDraft]
|
||||
)
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
draftRef.current = draft
|
||||
|
||||
const editor = editorRef.current
|
||||
|
||||
if (
|
||||
editor &&
|
||||
(editor.childNodes.length === 0 || (document.activeElement !== editor && composerPlainText(editor) !== draft))
|
||||
) {
|
||||
// Inert by construction — this repaints on mount or when the editor
|
||||
// isn't the one being typed into. A message opened for edit is finished
|
||||
// text, so a `/command` ending it is committed and chips, matching how
|
||||
// the transcript rendered that same message a moment ago.
|
||||
renderComposerContents(editor, draft, { trailingCommitted: true })
|
||||
|
||||
if (document.activeElement === editor) {
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
}
|
||||
}, [draft])
|
||||
|
||||
useEffect(() => {
|
||||
focusEditor()
|
||||
}, [focusEditor, focusRequestId])
|
||||
|
||||
useEffect(() => {
|
||||
const offFocus = onComposerFocusRequest(({ target }) => {
|
||||
if (target === 'edit') {
|
||||
setFocusRequestId(id => id + 1)
|
||||
}
|
||||
})
|
||||
|
||||
const offInsert = onComposerInsertRequest(({ mode, target, text }) => {
|
||||
if (target === 'edit') {
|
||||
appendExternalText(text, mode)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
offFocus()
|
||||
offInsert()
|
||||
}
|
||||
}, [appendExternalText])
|
||||
|
||||
const syncDraftFromEditor = useCallback(
|
||||
(editor: HTMLDivElement) => {
|
||||
const nextDraft = sanitizeComposerInput(composerPlainText(editor))
|
||||
|
||||
if (nextDraft !== draftRef.current) {
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
}
|
||||
|
||||
return nextDraft
|
||||
},
|
||||
[aui]
|
||||
)
|
||||
|
||||
// Same stack the main composer owns, for the same reason: the editor mutates
|
||||
// through `Range` to dodge Chromium's O(n²) editing pipeline, which also
|
||||
// dodges its undo stack, so a paste was invisible to Cmd+Z. `rememberInitialDraft`
|
||||
// already marks every mutation site (it's the dirty-edit guard), so the undo
|
||||
// points ride along with it.
|
||||
const syncFromEditorRef = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
return editor ? syncDraftFromEditor(editor) : draftRef.current
|
||||
}, [syncDraftFromEditor])
|
||||
|
||||
const { recordUndoPoint, redo, undo, withUndoPoint } = useComposerUndo({
|
||||
editorRef,
|
||||
syncDraftFromEditor: syncFromEditorRef
|
||||
})
|
||||
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const before = textBeforeCaret(editor)
|
||||
const detected = detectTrigger(before ?? composerPlainText(editor))
|
||||
|
||||
if (detected) {
|
||||
const rect = editor.getBoundingClientRect()
|
||||
const spaceAbove = rect.top
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
|
||||
setTriggerPlacement(spaceAbove < 220 && spaceBelow > spaceAbove ? 'bottom' : 'top')
|
||||
}
|
||||
|
||||
setTrigger(detected)
|
||||
|
||||
// Only reset the highlight when the trigger actually changed (opened, or
|
||||
// the query/kind differs). Re-detecting the *same* trigger — e.g. on a
|
||||
// caret move (mouseup) or a stray refresh — must preserve the user's
|
||||
// current selection instead of snapping back to the first item.
|
||||
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
|
||||
setTriggerActive(0)
|
||||
}
|
||||
}, [trigger])
|
||||
|
||||
const closeTrigger = useCallback(() => {
|
||||
setTrigger(null)
|
||||
setTriggerItems([])
|
||||
setTriggerActive(0)
|
||||
}, [])
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null =
|
||||
trigger?.kind === '@'
|
||||
? at.adapter
|
||||
: trigger?.kind === '/'
|
||||
? slash.adapter
|
||||
: trigger?.kind === ':'
|
||||
? emoji.adapter
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!trigger || !triggerAdapter?.search) {
|
||||
setTriggerItems([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setTriggerItems(triggerAdapter.search(trigger.query))
|
||||
}, [trigger, triggerAdapter])
|
||||
|
||||
useEffect(() => {
|
||||
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
|
||||
}, [triggerItems.length])
|
||||
|
||||
const triggerLoading =
|
||||
trigger?.kind === '@'
|
||||
? at.loading
|
||||
: trigger?.kind === '/'
|
||||
? slash.loading
|
||||
: trigger?.kind === ':'
|
||||
? emoji.loading
|
||||
: false
|
||||
|
||||
const replaceTriggerWithChip = useCallback(
|
||||
(item: Unstable_TriggerItem) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor || !trigger) {
|
||||
return
|
||||
}
|
||||
|
||||
rememberInitialDraft()
|
||||
recordUndoPoint()
|
||||
const serialized = hermesDirectiveFormatter.serialize(item)
|
||||
const starter = serialized.endsWith(':')
|
||||
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
||||
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
|
||||
|
||||
const finish = () => {
|
||||
draftRef.current = composerPlainText(editor)
|
||||
aui.composer().setText(draftRef.current)
|
||||
requestEditFocus()
|
||||
starter ? scheduleTimeout(refreshTrigger, 0) : closeTrigger()
|
||||
}
|
||||
|
||||
// In place first, spanning Chromium's split text nodes (see
|
||||
// rangeBeforeCaret). The re-render fallback only runs when the caret
|
||||
// genuinely can't anchor the token — it rebuilds from serialized text,
|
||||
// which re-chips `@` refs but resets the caret to the end.
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
directive
|
||||
? fragment.append(refChipElement(directive[1], directive[2]), document.createTextNode(' '))
|
||||
: fragment.append(document.createTextNode(text))
|
||||
|
||||
if (!replaceBeforeCaret(editor, trigger.tokenLength, fragment)) {
|
||||
rebuildAroundCaret(editor, trigger.tokenLength, text)
|
||||
}
|
||||
|
||||
finish()
|
||||
},
|
||||
[
|
||||
aui,
|
||||
closeTrigger,
|
||||
recordUndoPoint,
|
||||
refreshTrigger,
|
||||
rememberInitialDraft,
|
||||
requestEditFocus,
|
||||
scheduleTimeout,
|
||||
trigger
|
||||
]
|
||||
)
|
||||
|
||||
const insertRefStrings = useCallback(
|
||||
(refs: InlineRefInput[]) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor || refs.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Bank BEFORE the insert — insertInlineRefsIntoEditor mutates in place, so
|
||||
// recording after it would snapshot the state we're trying to undo to.
|
||||
const undone = withUndoPoint(() => insertInlineRefsIntoEditor(editor, refs) !== null)
|
||||
|
||||
if (!undone) {
|
||||
return false
|
||||
}
|
||||
|
||||
rememberInitialDraft()
|
||||
const nextDraft = composerPlainText(editor)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
requestEditFocus()
|
||||
|
||||
return true
|
||||
},
|
||||
[aui, rememberInitialDraft, requestEditFocus, withUndoPoint]
|
||||
)
|
||||
|
||||
const insertDroppedRefs = useCallback(
|
||||
(candidates: ReturnType<typeof extractDroppedFiles>) => insertRefStrings(droppedFileInlineRefs(candidates, cwd)),
|
||||
[cwd, insertRefStrings]
|
||||
)
|
||||
|
||||
// OS/Finder drops carry an absolute path on THIS machine — the gateway can't
|
||||
// read it in remote mode, and an image needs its bytes uploaded for vision.
|
||||
// Stage each through the same file.attach/image.attach_bytes pipeline the main
|
||||
// composer uses, then insert the *gateway-side* ref the agent can resolve —
|
||||
// never the raw local path (the MahmoudR remote-attach bug, which the main
|
||||
// composer fixes but this edit composer used to reproduce).
|
||||
const uploadOsDropRefs = useCallback(
|
||||
async (osDrops: ReturnType<typeof extractDroppedFiles>): Promise<InlineRefInput[]> => {
|
||||
if (!gateway || !sessionId) {
|
||||
// No session to stage into — best-effort inline refs (matches old path).
|
||||
return droppedFileInlineRefs(osDrops, cwd)
|
||||
}
|
||||
|
||||
const remote = isSessionRemote(sessionId)
|
||||
|
||||
const requestGateway = <T,>(method: string, params?: Record<string, unknown>) =>
|
||||
gateway.request<T>(method, params)
|
||||
|
||||
const refs: InlineRefInput[] = []
|
||||
|
||||
for (const candidate of osDrops) {
|
||||
const path = candidate.path || ''
|
||||
|
||||
if (!path) {
|
||||
continue
|
||||
}
|
||||
|
||||
const kind: ComposerAttachment['kind'] =
|
||||
candidate.file?.type.startsWith('image/') || isImagePath(candidate.file?.name || path) ? 'image' : 'file'
|
||||
|
||||
try {
|
||||
const uploaded = await uploadComposerAttachment(
|
||||
{ detail: path, id: attachmentId(kind, path), kind, label: pathLabel(path), path },
|
||||
{ backendCwd: cwd, remote, requestGateway, sessionId, terminalBackend: $terminalBackend.get() }
|
||||
)
|
||||
|
||||
const ref = attachmentDisplayText(uploaded)
|
||||
|
||||
if (ref) {
|
||||
refs.push(ref)
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, t.desktop.dropFiles)
|
||||
}
|
||||
}
|
||||
|
||||
return refs
|
||||
},
|
||||
[cwd, gateway, sessionId, t.desktop.dropFiles]
|
||||
)
|
||||
|
||||
const resetDragState = useCallback(() => {
|
||||
dragDepthRef.current = 0
|
||||
setDragActive(false)
|
||||
}, [])
|
||||
|
||||
const handleDragEnter = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
dragDepthRef.current += 1
|
||||
|
||||
if (!dragActive) {
|
||||
setDragActive(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
const handleDragLeave = (event: ReactDragEvent<HTMLElement>) => {
|
||||
event.preventDefault()
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
|
||||
if (dragDepthRef.current === 0) {
|
||||
setDragActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidates = extractDroppedFiles(event.dataTransfer)
|
||||
|
||||
if (!candidates.length) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
resetDragState()
|
||||
|
||||
// In-app drags (project tree / gutter) are workspace-relative paths that
|
||||
// resolve on the gateway as-is, so they stay inline refs. OS drops need to
|
||||
// be staged + uploaded first, then their gateway-side ref is inserted.
|
||||
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
|
||||
|
||||
if (insertDroppedRefs(inAppRefs)) {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
|
||||
if (osDrops.length) {
|
||||
setStaging(true)
|
||||
void uploadOsDropRefs(osDrops)
|
||||
.then(refs => {
|
||||
if (insertRefStrings(refs)) {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
})
|
||||
.finally(() => setStaging(false))
|
||||
}
|
||||
}
|
||||
|
||||
const flushEditorToDraft = useCallback(
|
||||
(editor: HTMLDivElement) => {
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
||||
editor.replaceChildren()
|
||||
}
|
||||
|
||||
rememberInitialDraft()
|
||||
const nextDraft = syncDraftFromEditor(editor)
|
||||
scheduleTimeout(refreshTrigger, 0)
|
||||
|
||||
return nextDraft
|
||||
},
|
||||
[refreshTrigger, rememberInitialDraft, scheduleTimeout, syncDraftFromEditor]
|
||||
)
|
||||
|
||||
const handleInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
// Native typing/deleting still goes through Chromium's editing pipeline, whose
|
||||
// undo stack we've taken over — bank the pre-edit state here, while
|
||||
// `beforeinput` can still see the old text.
|
||||
const handleBeforeInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
const inputType = (event.nativeEvent as InputEvent).inputType
|
||||
|
||||
if (inputType === 'historyUndo' || inputType === 'historyRedo') {
|
||||
return
|
||||
}
|
||||
|
||||
recordUndoPoint({ coalesce: inputType === 'insertText' || inputType === 'deleteContentBackward' })
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const pastedText = sanitizeComposerInput(event.clipboardData.getData('text'))
|
||||
|
||||
if (!pastedText || DATA_IMAGE_URL_RE.test(pastedText.trim())) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
rememberInitialDraft()
|
||||
recordUndoPoint()
|
||||
|
||||
// Links land as `@url:` chips, same as the main composer — including
|
||||
// consuming an open `@url:` scope rather than stacking a second directive
|
||||
// in front of the chip.
|
||||
insertComposerContentsAtCaret(
|
||||
event.currentTarget,
|
||||
pathifyRefs(linkifyUrls(pastedText)),
|
||||
openDirectiveScope(event.currentTarget)
|
||||
)
|
||||
syncDraftFromEditor(event.currentTarget)
|
||||
}
|
||||
|
||||
const submitEdit = (editor: HTMLDivElement) => {
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextDraft = syncDraftFromEditor(editor)
|
||||
|
||||
if (submitting || staging || !nextDraft.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
// `aui.composer().send()` throws "Composer is not available" when the edit
|
||||
// composer core has been torn down (e.g. a blur-driven cancel raced the
|
||||
// click). Reset `submitting` on failure so the arrow can't wedge on `true`
|
||||
// and leave revert as the only way out (#49903 is the same unguarded-core
|
||||
// hazard on the main composer).
|
||||
try {
|
||||
aui.composer().send()
|
||||
|
||||
// Clear latch after cooldown to allow re-submission. This prevents rapid
|
||||
// double-Enter but doesn't require tracking when onEdit settles (which may
|
||||
// be synchronous or async, and whose promise we don't have access to).
|
||||
scheduleTimeout(() => {
|
||||
setSubmitting(false)
|
||||
}, 200)
|
||||
} catch {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditBlur = useCallback(
|
||||
(event: FocusEvent<HTMLDivElement>) => {
|
||||
const nextTarget = event.relatedTarget
|
||||
|
||||
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
|
||||
return
|
||||
}
|
||||
|
||||
scheduleTimeout(() => {
|
||||
const root = rootRef.current
|
||||
const active = document.activeElement
|
||||
|
||||
if (submitting || (root && active && root.contains(active))) {
|
||||
return
|
||||
}
|
||||
|
||||
const editor = editorRef.current
|
||||
|
||||
// Dirty edit guard: when the user actually typed something, blur must
|
||||
// not cancel the composer — that would discard their in-flight
|
||||
// edits. Compare against the draft captured immediately before the
|
||||
// first edit; when no edit event occurred, the current hydrated draft
|
||||
// is the clean baseline.
|
||||
const initialDraft = initialDraftRef.current ?? draftRef.current
|
||||
|
||||
if (editor && syncDraftFromEditor(editor) !== initialDraft) {
|
||||
closeTrigger()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
closeTrigger()
|
||||
|
||||
// Swallow the unbound-core throw: if the composer core was already torn
|
||||
// down (a send/cancel raced this timer), cancel() throws "Composer is
|
||||
// not available" as an uncaught renderer error. Nothing to cancel then.
|
||||
try {
|
||||
aui.composer().cancel()
|
||||
} catch {
|
||||
// Composer core already gone — the edit is closing anyway.
|
||||
}
|
||||
}, 80)
|
||||
},
|
||||
[aui, closeTrigger, scheduleTimeout, submitting, syncDraftFromEditor]
|
||||
)
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
// Self-heal a stale composition flag (same recovery as the main composer's
|
||||
// handleEditorKeyDown, #44135): compositionend can be missed, and a wedged
|
||||
// composingRef would swallow every Enter until the edit composer remounts.
|
||||
if (composingRef.current && !event.nativeEvent.isComposing) {
|
||||
composingRef.current = false
|
||||
}
|
||||
|
||||
// IME composition: Enter confirms composed text, not a message submission.
|
||||
if (composingRef.current || event.nativeEvent.isComposing) {
|
||||
return
|
||||
}
|
||||
|
||||
// IME commit Enter still carrying keyCode 229 (VK_PROCESSKEY) after
|
||||
// compositionend — same guard as the main composer.
|
||||
if (event.key === 'Enter' && event.keyCode === 229) {
|
||||
return
|
||||
}
|
||||
|
||||
if (trigger && triggerItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx + 1) % triggerItems.length)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
const item = triggerItems[triggerActive]
|
||||
|
||||
if (item) {
|
||||
replaceTriggerWithChip(item)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
closeTrigger()
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Undo/redo before Escape — we own the stack, and a stray Cmd+Z must never
|
||||
// fall through to something that cancels the edit outright.
|
||||
if (isUndoShortcut(event.nativeEvent)) {
|
||||
event.preventDefault()
|
||||
undo()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (isRedoShortcut(event.nativeEvent)) {
|
||||
event.preventDefault()
|
||||
redo()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
aui.composer().cancel()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// A typed link finished with a space chips like a pasted one.
|
||||
if (withUndoPoint(() => chipTypedUrlOnSpace(event))) {
|
||||
event.preventDefault()
|
||||
rememberInitialDraft()
|
||||
syncDraftFromEditor(event.currentTarget)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Same for a bare `@path`.
|
||||
if (withUndoPoint(() => chipTypedPathOnSpace(event))) {
|
||||
event.preventDefault()
|
||||
rememberInitialDraft()
|
||||
syncDraftFromEditor(event.currentTarget)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
submitEdit(event.currentTarget)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = triggerKeyUpHandler(triggerKeyConsumedRef, refreshTrigger)
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Root className="contents" data-slot="aui_edit-composer-root">
|
||||
<StickyHumanMessageContainer>
|
||||
<div
|
||||
className="composer-human-message-container human-execution-message-top relative flex w-full items-start rounded-md bg-(--ui-chat-surface-background)"
|
||||
// A raised box over the transcript field: under window glass it keeps
|
||||
// a near-opaque fill instead of thinning with the field behind it.
|
||||
data-glass-raised=""
|
||||
onBlur={handleEditBlur}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
ref={rootRef}
|
||||
>
|
||||
{trigger && (
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={triggerActive}
|
||||
items={triggerItems}
|
||||
kind={trigger.kind}
|
||||
loading={triggerLoading}
|
||||
onHover={setTriggerActive}
|
||||
onPick={replaceTriggerWithChip}
|
||||
placement={triggerPlacement}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
USER_BUBBLE_BASE_CLASS,
|
||||
'ui-prompt-input__container relative border-(--ui-stroke-secondary) data-[expanded=true]:min-h-20',
|
||||
COMPOSER_DROP_FADE_CLASS,
|
||||
dragActive && COMPOSER_DROP_ACTIVE_CLASS
|
||||
)}
|
||||
data-expanded={expanded ? 'true' : undefined}
|
||||
>
|
||||
<div
|
||||
aria-label={copy.editMessage}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
'ui-prompt-input-editor__input max-h-48 w-full resize-none overflow-y-auto bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] text-foreground/95 outline-none',
|
||||
'**:data-ref-text:cursor-default',
|
||||
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
|
||||
)}
|
||||
contentEditable
|
||||
data-placeholder={copy.editMessage}
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBeforeInput={handleBeforeInput}
|
||||
onBlur={() => scheduleTimeout(closeTrigger, 80)}
|
||||
onCompositionEnd={event => {
|
||||
composingRef.current = false
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onFocus={() => markActiveComposer('edit')}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onMouseUp={refreshTrigger}
|
||||
onPaste={handlePaste}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
spellCheck={false}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
<ComposerDirectiveActions editorRef={editorRef} />
|
||||
<ComposerPrimitive.Input
|
||||
asChild
|
||||
className="sr-only"
|
||||
submitMode="ctrlEnter"
|
||||
tabIndex={-1}
|
||||
unstable_focusOnScrollToBottom={false}
|
||||
>
|
||||
<textarea
|
||||
aria-hidden
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className="sr-only"
|
||||
spellCheck={false}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</ComposerPrimitive.Input>
|
||||
{staging && (
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-2 left-2 inline-flex items-center gap-1 rounded-full bg-background/80 px-1.5 py-0.5 text-[0.62rem] text-muted-foreground backdrop-blur-[1px]"
|
||||
data-slot="aui_edit-staging"
|
||||
>
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
{copy.attachingFile}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
aria-label={copy.sendEdited}
|
||||
className={cn('absolute right-2 bottom-2 size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
disabled={!canSubmit || submitting || staging}
|
||||
onClick={() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
submitEdit(editor)
|
||||
}
|
||||
}}
|
||||
// Keep focus in the editor on click: macOS doesn't focus a button
|
||||
// on mousedown, so without this the arrow-click blurs the editor,
|
||||
// the blur timer cancels the edit (tearing down the composer
|
||||
// core), and the click's send() then throws against a dead core —
|
||||
// the edit silently never sends. The restore button guards the
|
||||
// same way.
|
||||
onPointerDown={event => event.preventDefault()}
|
||||
title={copy.sendEdited}
|
||||
type="button"
|
||||
>
|
||||
{submitting ? StopGlyph : <Codicon name="arrow-up" size={USER_ACTION_ICON_SIZE} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</StickyHumanMessageContainer>
|
||||
</ComposerPrimitive.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
type AppendMessage,
|
||||
AssistantRuntimeProvider,
|
||||
ExportedMessageRepository,
|
||||
type ThreadMessage
|
||||
} from '@assistant-ui/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, 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 '.'
|
||||
stubThreadEnvironment()
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
stubThreadViewportSize()
|
||||
|
||||
function Harness({ onEdit }: { onEdit: (message: AppendMessage) => Promise<void> }) {
|
||||
const repository = ExportedMessageRepository.fromArray([userMessage(), assistantMessage()])
|
||||
|
||||
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
|
||||
messageRepository: repository,
|
||||
isRunning: false,
|
||||
setMessages: () => {},
|
||||
onNew: async () => {},
|
||||
onEdit,
|
||||
onCancel: async () => {},
|
||||
onReload: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread cwd={null} gateway={null} sessionId="session-1" />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Regression for the desktop "editing a message, clicking the arrow does
|
||||
// nothing — I have to click revert" report.
|
||||
//
|
||||
// On macOS a <button> does NOT take DOM focus on mousedown, so clicking the
|
||||
// send arrow blurs the contenteditable (relatedTarget = null). The blur
|
||||
// schedules an 80ms timer that cancels the edit, tearing down the assistant-ui
|
||||
// edit-composer core. When the click's send() then runs, and again when the
|
||||
// blur timer fires, cancel()/send() on a torn-down core throw "Composer is not
|
||||
// available". The throw wedged the arrow (submitting stuck true) so only revert
|
||||
// worked.
|
||||
//
|
||||
// The blur throw fires from a real setTimeout, which jsdom routes to Node as an
|
||||
// `uncaughtException` (not a DOM error event), so a window 'error' probe misses
|
||||
// it. Capture process-level uncaught exceptions for the duration of the gesture
|
||||
// instead — an unguarded throw registers here and fails the test.
|
||||
describe('edit send arrow — macOS click gesture (blur races cancel)', () => {
|
||||
it('sends without an uncaught "Composer is not available" when the arrow-click blurs the editor', async () => {
|
||||
const uncaught: unknown[] = []
|
||||
|
||||
const onUncaught = (err: unknown) => {
|
||||
uncaught.push(err)
|
||||
}
|
||||
|
||||
process.on('uncaughtException', onUncaught)
|
||||
|
||||
try {
|
||||
const onEdit = vi.fn(async () => {})
|
||||
render(<Harness onEdit={onEdit} />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
|
||||
const editor = await screen.findByRole('textbox', { name: 'Edit message' })
|
||||
|
||||
await act(async () => {
|
||||
editor.focus()
|
||||
editor.textContent = 'edited then clicked the arrow'
|
||||
fireEvent.input(editor)
|
||||
})
|
||||
|
||||
const send = await screen.findByRole('button', { name: 'Send edited message' })
|
||||
|
||||
// The real gesture: mousedown on the arrow (no focus on macOS) blurs the
|
||||
// editor to <body>, then the click fires the send, then the blur's 80ms
|
||||
// cancel timer runs on the now-torn-down core. Wait past 80ms so the timer
|
||||
// completes within the captured window.
|
||||
await act(async () => {
|
||||
fireEvent.pointerDown(send)
|
||||
editor.blur()
|
||||
fireEvent.click(send)
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const composerErrors = uncaught.filter(err => /Composer is not available/.test(String(err)))
|
||||
|
||||
expect(composerErrors).toEqual([])
|
||||
} finally {
|
||||
process.off('uncaughtException', onUncaught)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,318 @@
|
||||
import { type AppendMessage, ExportedMessageRepository } from '@assistant-ui/react'
|
||||
// Clicking a user bubble must open the inline edit composer — through the
|
||||
// app's incremental external-store runtime (which reimplements capability
|
||||
// resolution, incl. `edit: onEdit !== undefined`) and the stock runtime.
|
||||
//
|
||||
// Note: this covers the React/runtime wiring only. The Electron-level failure
|
||||
// mode (titlebar -webkit-app-region:drag swallowing clicks on *stuck* sticky
|
||||
// bubbles) is not reproducible in jsdom — see USER_BUBBLE_BASE_CLASS's no-drag
|
||||
// carve-out in thread.tsx.
|
||||
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, 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 '.'
|
||||
stubThreadEnvironment()
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
stubThreadViewportSize()
|
||||
|
||||
async function moveFocusOutside(editor: HTMLElement) {
|
||||
const outside = window.document.createElement('button')
|
||||
window.document.body.append(outside)
|
||||
editor.focus()
|
||||
|
||||
await act(async () => {
|
||||
outside.focus()
|
||||
await new Promise(resolve => window.setTimeout(resolve, 120))
|
||||
})
|
||||
|
||||
outside.remove()
|
||||
}
|
||||
|
||||
// Mirrors chat/index.tsx: incremental runtime + messageRepository + onEdit.
|
||||
function IncrementalHarness({ onEdit }: { onEdit: (message: AppendMessage) => Promise<void> }) {
|
||||
const repository = ExportedMessageRepository.fromArray([userMessage(), assistantMessage()])
|
||||
|
||||
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
|
||||
messageRepository: repository,
|
||||
isRunning: false,
|
||||
setMessages: () => {},
|
||||
onNew: async () => {},
|
||||
onEdit,
|
||||
onCancel: async () => {},
|
||||
onReload: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Control: stock external store runtime.
|
||||
function StockHarness({ onEdit }: { onEdit: () => Promise<void> }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [userMessage(), assistantMessage()],
|
||||
isRunning: false,
|
||||
onNew: async () => {},
|
||||
onEdit
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('click-to-edit user message', () => {
|
||||
it('opens the edit composer with the incremental runtime', async () => {
|
||||
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
|
||||
|
||||
const bubble = await screen.findByRole('button', { name: 'Edit message' })
|
||||
|
||||
fireEvent.click(bubble)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not submit an inline edit while IME composition is active', async () => {
|
||||
const onEdit = vi.fn(async (_message: AppendMessage) => {})
|
||||
|
||||
render(<IncrementalHarness onEdit={onEdit} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
|
||||
|
||||
const editor = await screen.findByRole('textbox', { name: 'Edit message' })
|
||||
const editedText = 'edit me please\u4f60'
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.compositionStart(editor)
|
||||
editor.textContent = editedText
|
||||
fireEvent.input(editor)
|
||||
fireEvent.keyDown(editor, { isComposing: true, key: 'Enter' })
|
||||
})
|
||||
|
||||
expect(onEdit).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.compositionEnd(editor)
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onEdit).toHaveBeenCalledTimes(1))
|
||||
expect(onEdit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: [{ text: editedText, type: 'text' }]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a dirty inline edit open when focus leaves the composer', async () => {
|
||||
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
|
||||
|
||||
const editor = await screen.findByRole('textbox', { name: 'Edit message' })
|
||||
const editedText = 'edited draft that must not be discarded'
|
||||
|
||||
editor.textContent = editedText
|
||||
fireEvent.input(editor)
|
||||
await moveFocusOutside(editor)
|
||||
|
||||
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
|
||||
expect((await screen.findByRole('textbox', { name: 'Edit message' })).textContent).toBe(editedText)
|
||||
})
|
||||
|
||||
it('still cancels an untouched inline edit when focus leaves the composer', async () => {
|
||||
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
|
||||
const editor = await screen.findByRole('textbox', { name: 'Edit message' })
|
||||
|
||||
await moveFocusOutside(editor)
|
||||
|
||||
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('opens the edit composer with the stock runtime', async () => {
|
||||
const { container } = render(<StockHarness onEdit={async () => {}} />)
|
||||
|
||||
const bubble = await screen.findByRole('button', { name: 'Edit message' })
|
||||
|
||||
fireEvent.click(bubble)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
// A long previous prompt is capped at max-h-48 in the edit composer. Without
|
||||
// an overflow rule the overflow is clipped with no way to scroll, hiding the
|
||||
// tail of the prompt. The editor must scroll its own overflow (like the main
|
||||
// composer's editor does).
|
||||
it('keeps the edit composer editor scrollable when the prompt overflows the cap', async () => {
|
||||
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
|
||||
|
||||
const bubble = await screen.findByRole('button', { name: 'Edit message' })
|
||||
|
||||
fireEvent.click(bubble)
|
||||
|
||||
const editor = await waitFor(() => {
|
||||
const node = container.querySelector('[contenteditable="true"]')
|
||||
expect(node).toBeTruthy()
|
||||
|
||||
return node as HTMLElement
|
||||
})
|
||||
|
||||
expect(editor.className).toContain('max-h-48')
|
||||
expect(editor.className).toContain('overflow-y-auto')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Enter submission and latch behavior', () => {
|
||||
it('submits the edit when Enter is pressed', async () => {
|
||||
const onEdit = vi.fn(async () => {})
|
||||
render(<IncrementalHarness onEdit={onEdit} />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
|
||||
|
||||
const editor = await screen.findByRole('textbox', { name: 'Edit message' })
|
||||
const editedText = 'modified text for submission'
|
||||
|
||||
editor.textContent = editedText
|
||||
fireEvent.input(editor)
|
||||
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the submitting latch after onEdit resolves, allowing second edit session', async () => {
|
||||
const onEdit = vi.fn(async () => {})
|
||||
render(<IncrementalHarness onEdit={onEdit} />)
|
||||
|
||||
// First edit session
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
|
||||
|
||||
let editor = await screen.findByRole('textbox', { name: 'Edit message' })
|
||||
editor.textContent = 'first edit'
|
||||
fireEvent.input(editor)
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Wait for the latch cooldown to clear
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
|
||||
// Second edit session - open the editor again
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
|
||||
|
||||
editor = await screen.findByRole('textbox', { name: 'Edit message' })
|
||||
editor.textContent = 'second edit'
|
||||
fireEvent.input(editor)
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
|
||||
// If the latch wasn't cleared, this second submission would be blocked
|
||||
await waitFor(() => {
|
||||
expect(onEdit).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// Confirming an edit unmounts the composer while its 200ms submit latch is
|
||||
// still pending. Left running, the callback resumes on an unmounted tree and
|
||||
// calls setSubmitting, which React turns into work against a torn-down
|
||||
// renderer. Under vitest that surfaces after the test file finishes, as
|
||||
// "ReferenceError: window is not defined" out of resolveUpdatePriority, an
|
||||
// unhandled error that fails a run in which every test passed. The delay is
|
||||
// matched explicitly so unrelated library timers cannot make this pass.
|
||||
it('clears the submit latch timer when confirming the edit unmounts the composer', async () => {
|
||||
const LATCH_MS = 200
|
||||
// jsdom under node hands back a Timeout object rather than a numeric id,
|
||||
// so these are compared by identity rather than by value.
|
||||
const scheduled: unknown[] = []
|
||||
const cleared: unknown[] = []
|
||||
const realSetTimeout = window.setTimeout.bind(window)
|
||||
const realClearTimeout = window.clearTimeout.bind(window)
|
||||
|
||||
vi.spyOn(window, 'setTimeout').mockImplementation(((
|
||||
handler: TimerHandler,
|
||||
timeout?: number,
|
||||
...args: unknown[]
|
||||
) => {
|
||||
const id = realSetTimeout(handler, timeout, ...args)
|
||||
|
||||
if (timeout === LATCH_MS) {
|
||||
scheduled.push(id)
|
||||
}
|
||||
|
||||
return id
|
||||
}) as typeof window.setTimeout)
|
||||
|
||||
// `id` is typed `unknown` for the same reason the arrays above are: what
|
||||
// actually arrives is whatever `setTimeout` returned, and under jsdom that
|
||||
// is a Timeout object rather than the `number` the DOM lib promises.
|
||||
// Declaring it `number` would have documented a shape this never sees.
|
||||
vi.spyOn(window, 'clearTimeout').mockImplementation(((id?: unknown) => {
|
||||
cleared.push(id)
|
||||
realClearTimeout(id as number | undefined)
|
||||
}) as typeof window.clearTimeout)
|
||||
|
||||
const onEdit = vi.fn(async () => {})
|
||||
const view = render(<IncrementalHarness onEdit={onEdit} />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
|
||||
|
||||
const editor = await screen.findByRole('textbox', { name: 'Edit message' })
|
||||
|
||||
editor.textContent = 'an edit whose composer goes away before the latch expires'
|
||||
fireEvent.input(editor)
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(scheduled).toHaveLength(1)
|
||||
|
||||
view.unmount()
|
||||
|
||||
expect(cleared).toContain(scheduled[0])
|
||||
})
|
||||
|
||||
it('inserts a newline on Shift+Enter without submitting', async () => {
|
||||
const onEdit = vi.fn(async () => {})
|
||||
render(<IncrementalHarness onEdit={onEdit} />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
|
||||
|
||||
const editor = await screen.findByRole('textbox', { name: 'Edit message' })
|
||||
|
||||
editor.textContent = 'line one'
|
||||
fireEvent.input(editor)
|
||||
|
||||
fireEvent.keyDown(editor, { key: 'Enter', shiftKey: true })
|
||||
|
||||
// Shift+Enter should not call onEdit
|
||||
await new Promise(resolve => window.setTimeout(resolve, 50))
|
||||
expect(onEdit).not.toHaveBeenCalled()
|
||||
|
||||
// The editor should allow the newline to be inserted (by not preventing default)
|
||||
// We don't simulate actual newline insertion here (requires complex DOM manipulation)
|
||||
// but we verify the guard did not block it
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { hasTextSelection } from './user-message'
|
||||
|
||||
afterEach(() => {
|
||||
window.getSelection()?.removeAllRanges()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
describe('hasTextSelection', () => {
|
||||
it('is false with nothing highlighted', () => {
|
||||
expect(hasTextSelection()).toBe(false)
|
||||
})
|
||||
|
||||
it('is true once the user has a live range', () => {
|
||||
const node = document.createElement('span')
|
||||
node.textContent = 'copy me'
|
||||
document.body.appendChild(node)
|
||||
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(node)
|
||||
const selection = window.getSelection()!
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
expect(hasTextSelection()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { referenceRe, WIRE_REFERENCE_KINDS } from '@/components/assistant-ui/reference-kinds'
|
||||
|
||||
import { UserMessageText } from './user-message-text'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/**
|
||||
* A sent reference must render as the chip the composer showed. These cover the
|
||||
* seam where that used to break: the value's quoting is directive syntax, and a
|
||||
* surface that reads it as markdown splits one reference into two wrong things.
|
||||
*/
|
||||
describe('a sent reference renders as the chip the composer showed', () => {
|
||||
it('chips a backtick-quoted @url: instead of splitting it into code', () => {
|
||||
render(
|
||||
<UserMessageText text="@url:`https://github.com/NousResearch/hermes-agent/pull/74790` urls lose formatting" />
|
||||
)
|
||||
|
||||
expect(screen.queryByTitle('https://github.com/NousResearch/hermes-agent/pull/74790')).not.toBeNull()
|
||||
// The whole reference is one node — no bare `@url:` text left behind.
|
||||
expect(document.body.textContent).not.toContain('@url:')
|
||||
})
|
||||
|
||||
it('chips a backtick-quoted @file: path with spaces', () => {
|
||||
render(<UserMessageText text="see @file:`apps/desktop/my notes.md` please" />)
|
||||
|
||||
expect(screen.queryByTitle('apps/desktop/my notes.md')).not.toBeNull()
|
||||
expect(document.body.textContent).not.toContain('@file:')
|
||||
})
|
||||
|
||||
it('chips every kind that travels in message text', () => {
|
||||
// The guard against WIRE_REFERENCE_KINDS and the pattern's own alternation
|
||||
// drifting apart: add a kind to one and this fails until both agree.
|
||||
for (const kind of WIRE_REFERENCE_KINDS) {
|
||||
expect(`@${kind}:\`some value\``.match(referenceRe()), kind).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('still renders a genuine code span as code', () => {
|
||||
render(<UserMessageText text="run `npm test` first" />)
|
||||
|
||||
const code = document.querySelector('[data-slot="aui_user-inline-code"]')
|
||||
|
||||
expect(code?.textContent).toBe('npm test')
|
||||
})
|
||||
|
||||
it('renders code and a reference side by side', () => {
|
||||
render(<UserMessageText text="run `npm test` on @file:`apps/desktop/a b.ts` now" />)
|
||||
|
||||
expect(document.querySelector('[data-slot="aui_user-inline-code"]')?.textContent).toBe('npm test')
|
||||
expect(screen.queryByTitle('apps/desktop/a b.ts')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a fenced block alone', () => {
|
||||
render(<UserMessageText text={'before\n```ts\nconst x = 1\n```\nafter'} />)
|
||||
|
||||
expect(document.querySelector('[data-slot="aui_user-fence"]')?.textContent).toBe('const x = 1\n')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { FC } from 'react'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
|
||||
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
|
||||
import { referenceRe } from '@/components/assistant-ui/reference-kinds'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// User messages should render the bare-minimum of markdown: backtick `code`
|
||||
// spans and ``` fenced blocks. We deliberately don't pull in the full
|
||||
// assistant Markdown pipeline (Streamdown + KaTeX + syntax highlighter)
|
||||
// because user input rarely contains structured docs and the heavy pipeline
|
||||
// adds a lot of runtime cost per bubble.
|
||||
//
|
||||
// Directive chips (`@file:`, `@image:`, ...) still resolve via DirectiveContent
|
||||
// inside the plain-text segments.
|
||||
|
||||
interface FenceSegment {
|
||||
kind: 'fence'
|
||||
code: string
|
||||
lang: string | null
|
||||
}
|
||||
|
||||
interface InlineSegment {
|
||||
kind: 'inline'
|
||||
text: string
|
||||
}
|
||||
|
||||
interface InlineCodeSegment {
|
||||
kind: 'inline-code'
|
||||
code: string
|
||||
}
|
||||
|
||||
interface InlineTextSegment {
|
||||
kind: 'inline-text'
|
||||
text: string
|
||||
}
|
||||
|
||||
type TopSegment = FenceSegment | InlineSegment
|
||||
type InlineNode = InlineCodeSegment | InlineTextSegment
|
||||
|
||||
const FENCE_RE = /```([^\n`]*)\n([\s\S]*?)```/g
|
||||
|
||||
// Greedy backtick run length so ``code with `backticks` inside`` works.
|
||||
const INLINE_CODE_RE = /(`+)([^`\n][\s\S]*?)\1/g
|
||||
|
||||
// A directive's value is BACKTICK-QUOTED whenever it needs to be (`@url:`
|
||||
// always, and any path with a space), so the inline-code scanner would claim
|
||||
// those backticks first and split one reference into a bare `@url:` plus a code
|
||||
// span — the composer's chip, flattened on send. Directives win: this is syntax
|
||||
// the composer wrote, not something the user typed as code.
|
||||
|
||||
/** Inline-code matches that don't overlap a directive, so a quoted directive
|
||||
* value reaches DirectiveContent whole. */
|
||||
function inlineCodeOutsideDirectives(text: string): RegExpMatchArray[] {
|
||||
const directives = Array.from(text.matchAll(referenceRe())).map(match => ({
|
||||
start: match.index ?? 0,
|
||||
end: (match.index ?? 0) + match[0].length
|
||||
}))
|
||||
|
||||
return Array.from(text.matchAll(INLINE_CODE_RE)).filter(match => {
|
||||
const start = match.index ?? 0
|
||||
const end = start + match[0].length
|
||||
|
||||
return !directives.some(directive => start < directive.end && end > directive.start)
|
||||
})
|
||||
}
|
||||
|
||||
function splitFences(text: string): TopSegment[] {
|
||||
const segments: TopSegment[] = []
|
||||
let cursor = 0
|
||||
|
||||
for (const match of text.matchAll(FENCE_RE)) {
|
||||
const start = match.index ?? 0
|
||||
|
||||
if (start > cursor) {
|
||||
segments.push({ kind: 'inline', text: text.slice(cursor, start) })
|
||||
}
|
||||
|
||||
segments.push({
|
||||
kind: 'fence',
|
||||
lang: (match[1] || '').trim() || null,
|
||||
code: match[2] ?? ''
|
||||
})
|
||||
cursor = start + match[0].length
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
segments.push({ kind: 'inline', text: text.slice(cursor) })
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
function splitInlineCode(text: string): InlineNode[] {
|
||||
const nodes: InlineNode[] = []
|
||||
let cursor = 0
|
||||
|
||||
for (const match of inlineCodeOutsideDirectives(text)) {
|
||||
const start = match.index ?? 0
|
||||
|
||||
if (start > cursor) {
|
||||
nodes.push({ kind: 'inline-text', text: text.slice(cursor, start) })
|
||||
}
|
||||
|
||||
nodes.push({ kind: 'inline-code', code: match[2] })
|
||||
cursor = start + match[0].length
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
nodes.push({ kind: 'inline-text', text: text.slice(cursor) })
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
interface UserMessageTextProps {
|
||||
text: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const UserMessageText: FC<UserMessageTextProps> = ({ className, text }) => {
|
||||
const top = useMemo(() => splitFences(text), [text])
|
||||
|
||||
return (
|
||||
<span className={cn('block', className)} data-slot="aui_user-message-text">
|
||||
{top.map((segment, segmentIndex) => {
|
||||
if (segment.kind === 'fence') {
|
||||
return (
|
||||
<pre
|
||||
className="my-1.5 max-w-full overflow-x-auto rounded-md border border-(--ui-stroke-tertiary) bg-[color-mix(in_srgb,currentColor_5%,transparent)] px-2.5 py-2 font-mono text-[0.86em] leading-snug"
|
||||
data-slot="aui_user-fence"
|
||||
key={`fence-${segmentIndex}`}
|
||||
>
|
||||
<code className="block whitespace-pre">{segment.code}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={`inline-${segmentIndex}`}>
|
||||
<InlineSegmentView text={segment.text} />
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const InlineSegmentView: FC<{ text: string }> = ({ text }) => {
|
||||
const nodes = useMemo(() => splitInlineCode(text), [text])
|
||||
|
||||
return (
|
||||
// styles.css bidi hook (#44150); whitespace-pre-line makes each line its own
|
||||
// UAX#9 paragraph so it resolves direction independently.
|
||||
<span className="wrap-anywhere block whitespace-pre-line" data-slot="aui_user-inline-text">
|
||||
{nodes.map((node, nodeIndex) =>
|
||||
node.kind === 'inline-code' ? (
|
||||
<code
|
||||
className="mx-px rounded bg-[color-mix(in_srgb,currentColor_8%,transparent)] px-1 py-px font-mono text-[0.92em]"
|
||||
data-slot="aui_user-inline-code"
|
||||
key={`code-${nodeIndex}`}
|
||||
>
|
||||
{node.code}
|
||||
</code>
|
||||
) : (
|
||||
// Pass plain-text bits through DirectiveContent so @file:/@url: chips
|
||||
// still render. DirectiveContent already preserves whitespace.
|
||||
<Fragment key={`text-${nodeIndex}`}>
|
||||
<DirectiveContent text={node.text} />
|
||||
</Fragment>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
import { ActionBarPrimitive, BranchPickerPrimitive, MessagePrimitive, useAuiState } from '@assistant-ui/react'
|
||||
import { type FC, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
|
||||
import { messageAttachmentRefs, messageContentText } from '@/components/assistant-ui/thread/content'
|
||||
import { ReactionBadge, ReactionPicker } from '@/components/assistant-ui/thread/message-reactions'
|
||||
import { MessageTimelineTimestamp } from '@/components/assistant-ui/thread/timeline-timestamp'
|
||||
import { type RestoreMessageTarget } from '@/components/assistant-ui/thread/types'
|
||||
import { useMessageReactions } from '@/components/assistant-ui/thread/use-message-reactions'
|
||||
import { UserMessageText } from '@/components/assistant-ui/thread/user-message-text'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { StopFilled } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyThreadEditOpen } from '@/store/thread-scroll'
|
||||
import { isWatchWindow } from '@/store/windows'
|
||||
|
||||
/** True when the user has a live text highlight (drag-select / triple-click). */
|
||||
export function hasTextSelection(): boolean {
|
||||
const selection = window.getSelection()
|
||||
|
||||
return Boolean(selection && !selection.isCollapsed && selection.toString().length > 0)
|
||||
}
|
||||
|
||||
export function StickyHumanMessageContainer({
|
||||
attachments,
|
||||
children,
|
||||
messageId
|
||||
}: {
|
||||
attachments?: ReactNode
|
||||
children: ReactNode
|
||||
messageId?: string
|
||||
}) {
|
||||
return (
|
||||
// Fragment, not a wrapper: a wrapping element becomes the sticky's
|
||||
// containing block (it'd stick within its own height = never). The bubble
|
||||
// and attachments are flow siblings so the bubble pins against the scroller
|
||||
// while attachments below it scroll away.
|
||||
<>
|
||||
<div
|
||||
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-1"
|
||||
data-message-id={messageId}
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{attachments}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Shared "user bubble" base. Both the read-only message and the inline
|
||||
// edit composer render the same bubble surface (rounded glass card);
|
||||
// they only differ in border weight, cursor, and padding-right (the
|
||||
// read-only view reserves room for the restore icon).
|
||||
//
|
||||
// no-drag: sticky bubbles park at --sticky-human-top (~4px), sliding under the
|
||||
// titlebar's [-webkit-app-region:drag] strips (app-shell.tsx). Electron resolves
|
||||
// drag regions at the compositor level — z-index and pointer-events don't help —
|
||||
// so without the carve-out, clicking a stuck bubble drags the window instead of
|
||||
// opening the edit composer.
|
||||
export const USER_BUBBLE_BASE_CLASS =
|
||||
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-y-auto rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
|
||||
|
||||
export const USER_ACTION_ICON_BUTTON_CLASS =
|
||||
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
|
||||
|
||||
export const USER_ACTION_ICON_SIZE = '0.6875rem'
|
||||
export const StopGlyph = <StopFilled aria-hidden className="size-3.5 -translate-y-px" />
|
||||
|
||||
// Background-process notifications are injected into the conversation as user
|
||||
// messages (the agent must react to them, and message-role alternation forbids
|
||||
// a synthetic system row mid-loop). They are NOT something the human typed, so
|
||||
// render them as a compact system-style notice instead of a user bubble.
|
||||
// Shape: see tools/process_registry.py format_process_notification().
|
||||
const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/
|
||||
|
||||
// Agent-to-agent deliveries ("Message from 🤖 <sender>: …", the Bot Mode /
|
||||
// multi-profile convention; optional "(@<handle>)" carries the sender's
|
||||
// profile name for avatar resolution; legacy "[Message from agent
|
||||
// '<sender>'] …" too). They arrive on the user role because the recipient's
|
||||
// turn runs on it, but they are NOT the human speaking — render them as a
|
||||
// compact attributed timeline notice instead of a user bubble.
|
||||
export const AGENT_MESSAGE_RE =
|
||||
/^(?:Message from (?:🤖\s*)?([^:\n(]{1,64}?)(?:\s*\(@([a-z0-9][a-z0-9_-]{0,63})\))?:\s*|\[Message from agent '([^']{1,64})'\]\s*)([\s\S]*)$/u
|
||||
|
||||
// sender handle -> avatar data URL. Module-level so a chat full of notices
|
||||
// from one bot resolves once. Hits are cached for the window's lifetime;
|
||||
// misses only briefly (30s) — an avatar can appear at any moment (bot just
|
||||
// created, art backfill still running), and a permanent negative cache
|
||||
// froze the 🤖 glyph until an app restart.
|
||||
export const agentAvatarCache = new Map<string, null | string>()
|
||||
const agentAvatarMissAt = new Map<string, number>()
|
||||
const AVATAR_MISS_TTL_MS = 30_000
|
||||
const agentAvatarInflight = new Map<string, Promise<null | string>>()
|
||||
|
||||
export async function resolveAgentAvatar(handle: string): Promise<null | string> {
|
||||
const key = handle.trim().toLowerCase()
|
||||
|
||||
if (!key) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (agentAvatarCache.has(key)) {
|
||||
const hit = agentAvatarCache.get(key) ?? null
|
||||
|
||||
if (hit !== null) {
|
||||
return hit
|
||||
}
|
||||
|
||||
// Negative entry: honor it only within the TTL, then re-probe.
|
||||
if (Date.now() - (agentAvatarMissAt.get(key) ?? 0) < AVATAR_MISS_TTL_MS) {
|
||||
return null
|
||||
}
|
||||
|
||||
agentAvatarCache.delete(key)
|
||||
}
|
||||
|
||||
const inflight = agentAvatarInflight.get(key)
|
||||
|
||||
if (inflight) {
|
||||
return inflight
|
||||
}
|
||||
|
||||
const run = (async (): Promise<null | string> => {
|
||||
try {
|
||||
const gateway = $gateway.get()
|
||||
|
||||
if (!gateway) {
|
||||
return null
|
||||
}
|
||||
|
||||
const res = await gateway.request<{ profiles?: Array<{ has_avatar?: boolean; name: string }> }>('profiles.list', {
|
||||
include_sessions: false
|
||||
})
|
||||
|
||||
const profiles = res?.profiles ?? []
|
||||
let profile = profiles.find(p => p.name.toLowerCase() === key)
|
||||
|
||||
// 'hermes' is the conventional alias for the primary profile.
|
||||
if (!profile && key === 'hermes') {
|
||||
profile = profiles.find(p => p.name === 'default')
|
||||
}
|
||||
|
||||
if (!profile?.has_avatar) {
|
||||
return null
|
||||
}
|
||||
|
||||
const asset = await gateway.request<{ data?: string; found?: boolean }>('profiles.get_asset', {
|
||||
asset: 'avatar',
|
||||
name: profile.name
|
||||
})
|
||||
|
||||
return asset?.found && asset.data ? asset.data : null
|
||||
} catch {
|
||||
// Older gateway (no profiles.* RPCs) or transient failure — the 🤖
|
||||
// glyph fallback is always correct.
|
||||
return null
|
||||
} finally {
|
||||
agentAvatarInflight.delete(key)
|
||||
}
|
||||
})()
|
||||
|
||||
agentAvatarInflight.set(key, run)
|
||||
const out = await run
|
||||
agentAvatarCache.set(key, out)
|
||||
|
||||
if (out === null) {
|
||||
agentAvatarMissAt.set(key, Date.now())
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const AgentMessageNote: FC<{ text: string }> = ({ text }) => {
|
||||
const match = AGENT_MESSAGE_RE.exec(text)
|
||||
const sender = (match?.[1] || match?.[3] || 'agent').trim()
|
||||
const handle = (match?.[2] || match?.[3] || sender).trim()
|
||||
const body = (match?.[4] || '').trim()
|
||||
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])
|
||||
|
||||
// Grok-bots shape: an inter-agent delivery is a timeline EVENT, not a
|
||||
// conversation bubble — a subtle centered notice ("Message from 🤖 X"),
|
||||
// with the delivered text one click away instead of shouting in the
|
||||
// transcript. The recipient's reply below it stays a normal assistant
|
||||
// message, so the exchange still reads in order.
|
||||
return (
|
||||
<div
|
||||
className="flex max-w-[min(86%,44rem)] flex-col gap-0.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60"
|
||||
data-slot="aui_agent-message-note"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
{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>
|
||||
)}
|
||||
<span className="wrap-anywhere">Message from {sender}</span>
|
||||
</span>
|
||||
{body && (
|
||||
<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] rounded-lg border border-(--ui-stroke-tertiary) px-3 py-2 text-left text-[0.75rem] leading-5 text-foreground/85">
|
||||
<UserMessageText text={body} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => {
|
||||
const body = text.replace(/^\[IMPORTANT:\s*/, '').replace(/\]$/, '')
|
||||
const newline = body.indexOf('\n')
|
||||
const headline = (newline === -1 ? body : body.slice(0, newline)).trim()
|
||||
const detail = newline === -1 ? '' : body.slice(newline + 1).trim()
|
||||
|
||||
return (
|
||||
<div className="flex max-w-[min(86%,44rem)] flex-col gap-0.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Codicon className="shrink-0 text-muted-foreground/55" name="terminal" size="0.75rem" />
|
||||
<span className="wrap-anywhere">{headline}</span>
|
||||
</span>
|
||||
{detail && (
|
||||
<details className="pl-[1.3125rem]">
|
||||
<summary className="cursor-pointer select-none text-muted-foreground/45 hover:text-muted-foreground/70">
|
||||
output
|
||||
</summary>
|
||||
<pre
|
||||
className="mt-0.5 max-h-48 overflow-auto whitespace-pre-wrap font-mono text-[0.625rem] leading-4 text-muted-foreground/55"
|
||||
data-selectable-text="true"
|
||||
>
|
||||
{detail}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const UserMessage: FC<{
|
||||
onCancel?: () => Promise<void> | void
|
||||
onRequestRestoreConfirm?: (messageId: string, target: RestoreMessageTarget) => void
|
||||
}> = ({ onCancel, onRequestRestoreConfirm }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
const threadRunning = useAuiState(s => s.thread.isRunning)
|
||||
|
||||
const latestUserId = useAuiState(s => {
|
||||
for (let i = s.thread.messages.length - 1; i >= 0; i--) {
|
||||
const message = s.thread.messages[i] as { id?: string; role?: string }
|
||||
|
||||
if (message.role === 'user') {
|
||||
return message.id ?? null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const runtimeUserOrdinal = useAuiState(s => {
|
||||
let ordinal = 0
|
||||
|
||||
for (const message of s.thread.messages) {
|
||||
if (message.role !== 'user') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.id === s.message.id) {
|
||||
return ordinal
|
||||
}
|
||||
|
||||
ordinal += 1
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const attachmentRefs = useAuiState(s => {
|
||||
const custom = (s.message.metadata?.custom ?? {}) as { attachmentRefs?: unknown }
|
||||
|
||||
return messageAttachmentRefs(custom.attachmentRefs)
|
||||
})
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const { enabled: reactionsEnabled, react, reactions: shownReactions } = useMessageReactions(messageId, 'user')
|
||||
|
||||
const pickEmoji = useCallback(
|
||||
(emoji: null | string) => {
|
||||
setPickerOpen(false)
|
||||
react(emoji)
|
||||
},
|
||||
[react]
|
||||
)
|
||||
|
||||
// Sticky human bubbles clamp to ~2 lines with a soft fade so a long prompt
|
||||
// doesn't dominate the viewport while the response streams underneath; the
|
||||
// clamp lifts on hover / focus (see styles.css). We measure the *unclamped*
|
||||
// inner wrapper so the ResizeObserver only fires on real content / width
|
||||
// changes, not on every frame while the outer max-height animates open.
|
||||
const clampInnerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [bodyClamped, setBodyClamped] = useState(false)
|
||||
const lastClampHeightRef = useRef(-1)
|
||||
const lineHeightRef = useRef(0)
|
||||
|
||||
// Watch windows spectate a subagent run driven elsewhere — prompts can't be
|
||||
// edited, restored, or stopped from here. The bubble stays a button that
|
||||
// toggles the 2-line clamp so long prompts are still fully readable.
|
||||
const readOnly = isWatchWindow()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const clampActive = !(readOnly && expanded)
|
||||
|
||||
const measureClamp = useCallback((entries: readonly ResizeObserverEntry[]) => {
|
||||
const inner = clampInnerRef.current
|
||||
const outer = inner?.parentElement
|
||||
|
||||
if (!inner || !outer) {
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer the size the ResizeObserver already computed — reading
|
||||
// `scrollHeight` outside RO timing forces a synchronous layout, and with
|
||||
// many user bubbles observed at once those reads interleave with the
|
||||
// style write below into a read-write-read reflow cascade.
|
||||
const entryHeight = entries.find(entry => entry.target === inner)?.borderBoxSize?.[0]?.blockSize
|
||||
const fullHeight = Math.ceil(entryHeight ?? inner.scrollHeight)
|
||||
|
||||
if (fullHeight === lastClampHeightRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastClampHeightRef.current = fullHeight
|
||||
|
||||
// Line-height is stable for the life of the bubble (font settings don't
|
||||
// change under it) — resolve the computed style once.
|
||||
if (!lineHeightRef.current) {
|
||||
const styles = getComputedStyle(inner)
|
||||
lineHeightRef.current = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20
|
||||
}
|
||||
|
||||
outer.style.setProperty('--human-msg-full', `${fullHeight}px`)
|
||||
setBodyClamped(fullHeight > lineHeightRef.current * 2 + 1)
|
||||
}, [])
|
||||
|
||||
useResizeObserver(measureClamp, clampInnerRef)
|
||||
|
||||
// Injected background-process notification, not a human prompt — render the
|
||||
// compact system-style notice (after all hooks above have run).
|
||||
if (PROCESS_NOTIFICATION_RE.test(messageText.trim())) {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="flex w-full min-w-0 flex-col items-stretch"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
<ProcessNotificationNote text={messageText.trim()} />
|
||||
<MessageTimelineTimestamp className="self-center" />
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
// Agent-to-agent delivery, not a human prompt — attributed inter-agent card.
|
||||
if (AGENT_MESSAGE_RE.test(messageText.trim())) {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="flex w-full min-w-0 flex-col items-stretch pb-(--conversation-turn-gap)"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
<AgentMessageNote text={messageText.trim()} />
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const hasBody = messageText.trim().length > 0
|
||||
const isLatestUser = messageId === latestUserId
|
||||
const showStop = !readOnly && isLatestUser && threadRunning && Boolean(onCancel)
|
||||
// Restore (re-run this exact prompt) is available everywhere the Stop button
|
||||
// isn't — including mid-stream on older prompts, since the action interrupts
|
||||
// the live turn before rewinding.
|
||||
const showRestore = !readOnly && !showStop && Boolean(onRequestRestoreConfirm) && hasBody
|
||||
|
||||
const bubbleClassName = cn(
|
||||
USER_BUBBLE_BASE_CLASS,
|
||||
'cursor-pointer pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors',
|
||||
'border-(--ui-stroke-tertiary) hover:border-(--ui-stroke-secondary)'
|
||||
)
|
||||
|
||||
const bubbleContent = hasBody && (
|
||||
// Render the user's text through a minimal markdown pipeline:
|
||||
// backtick `code` and ``` fenced ``` blocks, with directive chips
|
||||
// (`@file:` etc.) still resolved inside the plain-text spans.
|
||||
<div
|
||||
className={cn(clampActive && 'sticky-human-clamp')}
|
||||
data-clamped={clampActive && bodyClamped ? 'true' : undefined}
|
||||
>
|
||||
{/* Match the edit composer's collapsed line box (min-h-[1.25rem]) so
|
||||
clicking to edit can't grow the bubble by a sub-pixel and reflow the
|
||||
turn 1px. */}
|
||||
<div className="min-h-[1.25rem]" ref={clampInnerRef}>
|
||||
<UserMessageText className="wrap-anywhere" text={messageText} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root asChild>
|
||||
<StickyHumanMessageContainer
|
||||
attachments={
|
||||
// Attachments live BELOW the sticky bubble in normal flow, so they
|
||||
// scroll away behind the pinned bubble instead of riding along with
|
||||
// it. Image refs render as thumbnails, file refs as chips; no border.
|
||||
attachmentRefs.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 -mt-3 mb-2">
|
||||
<DirectiveContent text={attachmentRefs.join(' ')} />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
messageId={messageId}
|
||||
>
|
||||
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
|
||||
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
|
||||
<ReactionPicker
|
||||
onOpenChange={setPickerOpen}
|
||||
onSelect={pickEmoji}
|
||||
open={pickerOpen}
|
||||
selected={shownReactions.find(reaction => reaction.author === 'user')?.emoji}
|
||||
>
|
||||
<div
|
||||
className="relative w-full"
|
||||
// The app context menu skips PLAIN right-clicks here (the
|
||||
// attr below) so this handler keeps the picker gesture; a
|
||||
// link/image/selection inside the bubble still gets the app
|
||||
// menu, and this handler's selection guard keeps ⌘C flows.
|
||||
data-context-menu-skip=""
|
||||
onContextMenu={
|
||||
// Right-click is the desktop stand-in for iOS touch-and-hold —
|
||||
// but only when there's nothing selected. A live highlight
|
||||
// keeps the native Copy menu (and ⌘C) instead of the picker.
|
||||
readOnly || !reactionsEnabled
|
||||
? undefined
|
||||
: event => {
|
||||
if (hasTextSelection()) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
setPickerOpen(true)
|
||||
}
|
||||
}
|
||||
>
|
||||
{readOnly ? (
|
||||
// Spectator transcript: clicking only toggles the clamp so the
|
||||
// full prompt is readable — never opens an edit composer.
|
||||
<button
|
||||
aria-expanded={bodyClamped ? expanded : undefined}
|
||||
className={cn(bubbleClassName, !bodyClamped && 'cursor-default')}
|
||||
onClick={() => {
|
||||
// Drag-select ends on mouseup→click; don't collapse the
|
||||
// clamp just because the highlight finished.
|
||||
if (hasTextSelection() || !bodyClamped) {
|
||||
return
|
||||
}
|
||||
|
||||
triggerHaptic('selection')
|
||||
setExpanded(value => !value)
|
||||
}}
|
||||
title={bodyClamped ? (expanded ? t.common.collapse : copy.expandMessage) : undefined}
|
||||
type="button"
|
||||
>
|
||||
{bubbleContent}
|
||||
</button>
|
||||
) : (
|
||||
// Always editable — clicking opens the edit composer even while a
|
||||
// turn streams; sending the edit reverts (interrupt + rewind).
|
||||
// A live text highlight wins: finishing a drag-select must not
|
||||
// open the editor and throw the selection away.
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<button
|
||||
aria-label={copy.editMessage}
|
||||
className={bubbleClassName}
|
||||
onClick={event => {
|
||||
if (hasTextSelection()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
triggerHaptic('selection')
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
if (hasTextSelection()) {
|
||||
return
|
||||
}
|
||||
|
||||
notifyThreadEditOpen()
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{bubbleContent}
|
||||
</button>
|
||||
</ActionBarPrimitive.Edit>
|
||||
)}
|
||||
{(showStop || showRestore) && (
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
|
||||
{showStop ? (
|
||||
<button
|
||||
aria-label={copy.stop}
|
||||
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void onCancel?.()
|
||||
}}
|
||||
title={copy.stop}
|
||||
type="button"
|
||||
>
|
||||
{StopGlyph}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
aria-label={copy.restoreCheckpoint}
|
||||
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
onRequestRestoreConfirm?.(messageId, {
|
||||
text: messageText,
|
||||
userOrdinal: runtimeUserOrdinal
|
||||
})
|
||||
}}
|
||||
onPointerDown={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
title={copy.restoreFromHere}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="discard" size="0.875rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ReactionPicker>
|
||||
{/* Below the bubble, same register as the assistant action row:
|
||||
same emoji size, same vertical padding, right-aligned to the
|
||||
sent bubble. Overlaying the corner read badly in practice. */}
|
||||
<ReactionBadge
|
||||
className="justify-end gap-1.5 py-1.5 pr-1.5"
|
||||
onRetract={() => react(null)}
|
||||
reactions={shownReactions}
|
||||
/>
|
||||
<MessageTimelineTimestamp className="self-end pr-1.5" />
|
||||
<BranchPickerPrimitive.Root
|
||||
className={cn(
|
||||
'checkpoint-container flex items-center gap-1 pb-0 pt-1 pl-1.5 text-[0.75rem] leading-none text-(--ui-text-tertiary)',
|
||||
readOnly && 'hidden'
|
||||
)}
|
||||
hideWhenSingleBranch
|
||||
>
|
||||
<span aria-hidden className="checkpoint-icon size-1.5 rounded-full border border-current" />
|
||||
<BranchPickerPrimitive.Previous
|
||||
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
||||
title={copy.restorePrevious}
|
||||
>
|
||||
{copy.restoreCheckpoint}
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span className="checkpoint-divider opacity-55">
|
||||
<BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next
|
||||
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
||||
title={copy.restoreNext}
|
||||
>
|
||||
{copy.goForward}
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
</div>
|
||||
</ActionBarPrimitive.Root>
|
||||
</StickyHumanMessageContainer>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user