mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
fix: virtualize long desktop transcripts
Large desktop sessions were still paying too much render and paint cost on tab switch, while the previous content-visibility approach could defer WebView painting enough to show white gaps during scroll. This moves long transcripts to a measured dynamic-height window, keeps small transcripts fully mounted, and caches completed Markdown parses so remounted assistant messages do not pay repeated parse cost. Constraint: Tauri/WebView must not depend on content-visibility for transcript correctness because deferred offscreen paint can surface as blank regions Rejected: Keep content-visibility:auto on every chat item | it improves synthetic DOM cost but matches the user's white-gap failure mode Rejected: Fixed-height transcript virtualization | variable-height chat rows and tool groups can create spacer gaps and broken scroll positions Confidence: high Scope-risk: moderate Directive: Do not reintroduce deferred-paint transcript optimization without testing the real Tauri/WebView scroll path on large sessions Tested: bun run check:desktop Tested: Chrome DevTools real-session smoke for 055015e4-6093-418e-9944-19bba25ecb69, switch trace max RunTask about 158ms and scroll blankSamples=0 across 34 sampled positions Not-tested: Packaged Tauri binary smoke on WebKit/WebView2
This commit is contained in:
parent
385435a04d
commit
d116067d9c
@ -27,7 +27,7 @@ export function AssistantMessage({ content, isStreaming, branchAction }: Props)
|
||||
<div className={`rounded-[20px] rounded-tl-[8px] border border-[var(--color-border)]/60 bg-[var(--color-surface)] px-4 py-3 text-sm text-[var(--color-text-primary)] shadow-sm ${
|
||||
documentLayout ? 'w-full' : 'max-w-full'
|
||||
}`}>
|
||||
<MarkdownRenderer content={content} variant={documentLayout ? 'document' : 'default'} />
|
||||
<MarkdownRenderer content={content} variant={documentLayout ? 'document' : 'default'} cache={!isStreaming} />
|
||||
{!isStreaming && <InlineImageGallery text={content} />}
|
||||
{isStreaming && (
|
||||
<span className="ml-0.5 inline-block h-4 w-0.5 animate-shimmer bg-[var(--color-brand)] align-text-bottom" />
|
||||
|
||||
@ -123,7 +123,7 @@ describe('MessageList nested tool calls', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps full long transcripts mounted so variable-height messages cannot leave spacer gaps', () => {
|
||||
it('windows long transcripts instead of mounting every historical message at once', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
@ -147,13 +147,14 @@ describe('MessageList nested tool calls', () => {
|
||||
|
||||
const { container } = render(<MessageList />)
|
||||
|
||||
expect(screen.getByText('assistant transcript line 0')).toBeTruthy()
|
||||
expect(screen.getByText('assistant transcript line 219')).toBeTruthy()
|
||||
expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBe(220)
|
||||
expect(container.querySelector('[data-virtual-message-item]')).toBeNull()
|
||||
expect(screen.queryByText('assistant transcript line 0')).toBeNull()
|
||||
expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBeLessThan(220)
|
||||
expect(container.querySelector('[data-virtual-message-item]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-virtual-spacer="top"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('marks transcript items as offscreen-renderable without virtualizing variable-height rows', () => {
|
||||
it('keeps small transcripts fully mounted without deferred browser painting', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
@ -180,12 +181,40 @@ describe('MessageList nested tool calls', () => {
|
||||
|
||||
expect(renderItems).toHaveLength(2)
|
||||
for (const item of renderItems) {
|
||||
expect(item.className).toContain('[content-visibility:auto]')
|
||||
expect(item.className).toContain('[contain-intrinsic-size:auto_180px]')
|
||||
expect(item.className).not.toContain('content-visibility')
|
||||
expect(item.className).not.toContain('contain-intrinsic-size')
|
||||
}
|
||||
expect(container.querySelector('[data-virtual-message-item]')).toBeNull()
|
||||
})
|
||||
|
||||
it('virtualizes short message lists when their content is very large', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'user-huge',
|
||||
type: 'user_text',
|
||||
content: '超长设计内容 '.repeat(24_000),
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-tail',
|
||||
type: 'assistant_text',
|
||||
content: 'latest assistant reply',
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<MessageList />)
|
||||
|
||||
expect(container.querySelector('[data-virtual-message-item]')).not.toBeNull()
|
||||
expect(screen.getByText('latest assistant reply')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('filters duplicate unresolved AskUserQuestion cards while a matching permission is pending', () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
@ -533,7 +562,7 @@ describe('MessageList nested tool calls', () => {
|
||||
expect(screen.queryByText('local_agent')).toBeNull()
|
||||
})
|
||||
|
||||
it('restores the full transcript when scrolling away from latest', async () => {
|
||||
it('renders the historical window when scrolling away from latest', async () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
@ -559,11 +588,11 @@ describe('MessageList nested tool calls', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByText('assistant transcript line 0')).toBeTruthy()
|
||||
expect(screen.getByText('assistant transcript line 219')).toBeTruthy()
|
||||
expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBe(220)
|
||||
expect(screen.queryByText('assistant transcript line 219')).toBeNull()
|
||||
expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBeLessThan(220)
|
||||
})
|
||||
|
||||
it('keeps long histories with tool-call groups mounted while scrolling history', async () => {
|
||||
it('keeps tool-call groups reachable while scrolling virtualized history', async () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
@ -601,7 +630,7 @@ describe('MessageList nested tool calls', () => {
|
||||
Object.defineProperty(scrollArea, 'scrollHeight', { configurable: true, value: 222 * 112 })
|
||||
await waitForProgrammaticScrollReset()
|
||||
|
||||
expect(screen.getByText('Read')).toBeTruthy()
|
||||
expect(screen.queryByText('Read')).toBeNull()
|
||||
expect(screen.getByText('assistant transcript line 219')).toBeTruthy()
|
||||
|
||||
scrollArea.scrollTop = 0
|
||||
@ -610,8 +639,8 @@ describe('MessageList nested tool calls', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByText('Read')).toBeTruthy()
|
||||
expect(screen.getByText('assistant transcript line 219')).toBeTruthy()
|
||||
expect(container.querySelector('[data-virtual-message-item]')).toBeNull()
|
||||
expect(screen.queryByText('assistant transcript line 219')).toBeNull()
|
||||
expect(container.querySelector('[data-virtual-message-item]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders sub-agent tool calls inline beneath the parent agent tool call', () => {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, FileStack, LoaderCircle, MessageCircle, Settings, Target, XCircle } from 'lucide-react'
|
||||
import { ApiError } from '../../api/client'
|
||||
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
|
||||
@ -784,6 +785,14 @@ type MessageListProps = {
|
||||
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48
|
||||
const SCROLL_BOTTOM_SENTINEL = 1_000_000_000
|
||||
const MAX_SCROLL_SNAPSHOTS = 100
|
||||
const VIRTUALIZE_MIN_RENDER_ITEMS = 120
|
||||
const VIRTUALIZE_MIN_CONTENT_CHARS = 120_000
|
||||
const VIRTUAL_IDLE_OVERSCAN_PX = 360
|
||||
const VIRTUAL_SCROLL_OVERSCAN_PX = 7200
|
||||
const VIRTUAL_SCROLL_IDLE_DELAY_MS = 180
|
||||
const VIRTUAL_DEFAULT_VIEWPORT_HEIGHT = 720
|
||||
const VIRTUAL_MIN_ITEM_HEIGHT = 48
|
||||
const VIRTUAL_MAX_ITEM_HEIGHT = 24_000
|
||||
const EMPTY_MESSAGES: UIMessage[] = []
|
||||
const CHAT_SCROLL_AREA_CLASS = [
|
||||
'chat-scroll-area',
|
||||
@ -801,8 +810,6 @@ const CHAT_SCROLL_AREA_CLASS = [
|
||||
].join(' ')
|
||||
const CHAT_RENDER_ITEM_CLASS = [
|
||||
'chat-render-item',
|
||||
'[content-visibility:auto]',
|
||||
'[contain-intrinsic-size:auto_180px]',
|
||||
].join(' ')
|
||||
|
||||
type SessionScrollSnapshot = {
|
||||
@ -810,6 +817,29 @@ type SessionScrollSnapshot = {
|
||||
wasAtBottom: boolean
|
||||
}
|
||||
|
||||
type VirtualViewport = {
|
||||
scrollTop: number
|
||||
viewportHeight: number
|
||||
}
|
||||
|
||||
type VirtualTranscriptItem = {
|
||||
item: RenderItem
|
||||
index: number
|
||||
}
|
||||
|
||||
type VirtualTranscriptWindow = {
|
||||
enabled: boolean
|
||||
beforeHeight: number
|
||||
afterHeight: number
|
||||
items: VirtualTranscriptItem[]
|
||||
}
|
||||
|
||||
type VirtualRenderItemMetric = {
|
||||
signature: string
|
||||
contentWeight: number
|
||||
estimatedHeight: number
|
||||
}
|
||||
|
||||
const sessionScrollSnapshots = new Map<string, SessionScrollSnapshot>()
|
||||
|
||||
function isNearScrollBottom(element: HTMLElement) {
|
||||
@ -859,6 +889,279 @@ function setScrollToBottomWithoutLayoutRead(element: HTMLElement, behavior: Scro
|
||||
}
|
||||
}
|
||||
|
||||
function clampNumber(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function getRenderItemKey(item: RenderItem) {
|
||||
return item.kind === 'tool_group' ? item.id : item.message.id
|
||||
}
|
||||
|
||||
function isRecordValue(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function getShallowStringWeight(value: unknown, depth = 0): number {
|
||||
if (typeof value === 'string') return value.length
|
||||
if (!value || depth > 1) return 0
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, 12).reduce((total, item) => total + getShallowStringWeight(item, depth + 1), 0)
|
||||
}
|
||||
if (!isRecordValue(value)) return 0
|
||||
|
||||
let total = 0
|
||||
for (const item of Object.values(value).slice(0, 24)) {
|
||||
total += getShallowStringWeight(item, depth + 1)
|
||||
if (total >= VIRTUALIZE_MIN_CONTENT_CHARS) return total
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
function getMessageContentWeight(message: UIMessage): number {
|
||||
switch (message.type) {
|
||||
case 'user_text':
|
||||
case 'assistant_text':
|
||||
case 'thinking':
|
||||
case 'system':
|
||||
return message.content.length
|
||||
case 'tool_use':
|
||||
return getShallowStringWeight(message.input) + (message.partialInput?.length ?? 0)
|
||||
case 'tool_result':
|
||||
return getShallowStringWeight(message.content)
|
||||
case 'permission_request':
|
||||
return getShallowStringWeight(message.input) + (message.description?.length ?? 0)
|
||||
case 'error':
|
||||
return message.message.length
|
||||
case 'compact_summary':
|
||||
return message.title.length + (message.summary?.length ?? 0)
|
||||
case 'goal_event':
|
||||
return (message.objective?.length ?? 0) + (message.message?.length ?? 0)
|
||||
case 'memory_event':
|
||||
return (message.message?.length ?? 0) + message.files.reduce((total, file) => total + file.path.length + (file.summary?.length ?? 0), 0)
|
||||
case 'background_task':
|
||||
return getShallowStringWeight(message.task)
|
||||
case 'task_summary':
|
||||
return message.tasks.reduce((total, task) => total + task.subject.length + (task.activeForm?.length ?? 0), 0)
|
||||
}
|
||||
}
|
||||
|
||||
function getRenderItemContentWeight(item: RenderItem): number {
|
||||
if (item.kind === 'message') return getMessageContentWeight(item.message)
|
||||
return item.toolCalls.reduce((total, toolCall) => total + getMessageContentWeight(toolCall), 0)
|
||||
}
|
||||
|
||||
function shouldVirtualizeRenderItems(metrics: VirtualRenderItemMetric[]) {
|
||||
if (metrics.length >= VIRTUALIZE_MIN_RENDER_ITEMS) return true
|
||||
|
||||
let totalWeight = 0
|
||||
for (const metric of metrics) {
|
||||
totalWeight += metric.contentWeight
|
||||
if (totalWeight >= VIRTUALIZE_MIN_CONTENT_CHARS) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function countLineBreaksCapped(content: string, maxLines: number) {
|
||||
let lineBreaks = 0
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
if (content.charCodeAt(index) === 10) {
|
||||
lineBreaks += 1
|
||||
if (lineBreaks >= maxLines) return lineBreaks
|
||||
}
|
||||
}
|
||||
return lineBreaks
|
||||
}
|
||||
|
||||
function estimateTextHeight(content: string, baseHeight: number) {
|
||||
const sample = content.length > 12_000 ? content.slice(0, 12_000) : content
|
||||
const sampledLineBreaks = countLineBreaksCapped(sample, 900)
|
||||
const explicitLines = content.length > sample.length
|
||||
? Math.ceil((sampledLineBreaks + 1) * (content.length / sample.length))
|
||||
: sampledLineBreaks + 1
|
||||
const wrappedLines = Math.ceil(content.length / 76)
|
||||
const estimated = baseHeight + Math.max(explicitLines, wrappedLines) * 22
|
||||
return clampNumber(estimated, VIRTUAL_MIN_ITEM_HEIGHT, VIRTUAL_MAX_ITEM_HEIGHT)
|
||||
}
|
||||
|
||||
function estimateMessageHeight(message: UIMessage): number {
|
||||
switch (message.type) {
|
||||
case 'user_text':
|
||||
return estimateTextHeight(message.content, message.attachments?.length ? 140 : 74)
|
||||
case 'assistant_text':
|
||||
return estimateTextHeight(message.content, 96)
|
||||
case 'thinking':
|
||||
return estimateTextHeight(message.content, 88)
|
||||
case 'tool_use':
|
||||
return clampNumber(92 + Math.ceil(getMessageContentWeight(message) / 120) * 18, 72, 2200)
|
||||
case 'tool_result':
|
||||
return clampNumber(88 + Math.ceil(getMessageContentWeight(message) / 120) * 18, 64, 2200)
|
||||
case 'background_task':
|
||||
case 'goal_event':
|
||||
case 'memory_event':
|
||||
case 'permission_request':
|
||||
case 'task_summary':
|
||||
return 110
|
||||
case 'compact_summary':
|
||||
return message.summary ? clampNumber(92 + Math.ceil(message.summary.length / 90) * 20, 80, 1800) : 70
|
||||
case 'error':
|
||||
case 'system':
|
||||
return 64
|
||||
}
|
||||
}
|
||||
|
||||
function estimateRenderItemHeight(item: RenderItem): number {
|
||||
if (item.kind === 'message') return estimateMessageHeight(item.message)
|
||||
const textWeight = getRenderItemContentWeight(item)
|
||||
return clampNumber(92 + item.toolCalls.length * 78 + Math.ceil(textWeight / 140) * 16, 88, 2600)
|
||||
}
|
||||
|
||||
function getMessageMetricSignature(message: UIMessage): string {
|
||||
switch (message.type) {
|
||||
case 'user_text':
|
||||
return `${message.type}:${message.content.length}:${message.attachments?.length ?? 0}:${message.pending ? 1 : 0}`
|
||||
case 'assistant_text':
|
||||
case 'thinking':
|
||||
case 'system':
|
||||
return `${message.type}:${message.content.length}`
|
||||
case 'tool_use':
|
||||
return `${message.type}:${message.toolName}:${message.toolUseId}:${message.partialInput?.length ?? 0}:${message.isPending ? 1 : 0}`
|
||||
case 'tool_result':
|
||||
return `${message.type}:${message.toolUseId}:${message.isError ? 1 : 0}`
|
||||
case 'compact_summary':
|
||||
return `${message.type}:${message.phase ?? ''}:${message.title.length}:${message.summary?.length ?? 0}`
|
||||
case 'goal_event':
|
||||
return `${message.type}:${message.action}:${message.status ?? ''}:${message.objective?.length ?? 0}:${message.message?.length ?? 0}`
|
||||
case 'memory_event':
|
||||
return `${message.type}:${message.event}:${message.files.length}:${message.message?.length ?? 0}`
|
||||
case 'background_task':
|
||||
return `${message.type}:${message.task.taskId}:${message.task.status}:${message.task.updatedAt}`
|
||||
case 'permission_request':
|
||||
return `${message.type}:${message.requestId}:${message.toolUseId ?? ''}:${message.description?.length ?? 0}`
|
||||
case 'error':
|
||||
return `${message.type}:${message.code}:${message.message.length}`
|
||||
case 'task_summary':
|
||||
return `${message.type}:${message.tasks.length}:${message.tasks.map((task) => task.id).join(',')}`
|
||||
}
|
||||
}
|
||||
|
||||
function getRenderItemMetricSignature(item: RenderItem): string {
|
||||
if (item.kind === 'message') return getMessageMetricSignature(item.message)
|
||||
return item.toolCalls.map(getMessageMetricSignature).join('|')
|
||||
}
|
||||
|
||||
function findVirtualStartIndex(offsets: number[], target: number) {
|
||||
let low = 0
|
||||
let high = offsets.length - 1
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2)
|
||||
if ((offsets[mid + 1] ?? offsets[mid] ?? 0) < target) {
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid
|
||||
}
|
||||
}
|
||||
return Math.max(0, low)
|
||||
}
|
||||
|
||||
function findVirtualEndIndex(offsets: number[], target: number) {
|
||||
let low = 0
|
||||
let high = offsets.length - 1
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2)
|
||||
if ((offsets[mid] ?? 0) <= target) {
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid
|
||||
}
|
||||
}
|
||||
return clampNumber(low + 1, 0, offsets.length - 1)
|
||||
}
|
||||
|
||||
function buildVirtualTranscriptWindow(
|
||||
renderItems: RenderItem[],
|
||||
itemKeys: string[],
|
||||
metrics: VirtualRenderItemMetric[],
|
||||
measuredHeights: Map<string, number>,
|
||||
viewport: VirtualViewport,
|
||||
overscanPx: number,
|
||||
): VirtualTranscriptWindow {
|
||||
if (!shouldVirtualizeRenderItems(metrics)) {
|
||||
return {
|
||||
enabled: false,
|
||||
beforeHeight: 0,
|
||||
afterHeight: 0,
|
||||
items: renderItems.map((item, index) => ({ item, index })),
|
||||
}
|
||||
}
|
||||
|
||||
const offsets = new Array<number>(renderItems.length + 1)
|
||||
offsets[0] = 0
|
||||
for (let index = 0; index < renderItems.length; index += 1) {
|
||||
const item = renderItems[index]!
|
||||
const measuredHeight = measuredHeights.get(itemKeys[index]!)
|
||||
const height = measuredHeight && measuredHeight > 0
|
||||
? measuredHeight
|
||||
: metrics[index]?.estimatedHeight ?? estimateRenderItemHeight(item)
|
||||
offsets[index + 1] = offsets[index]! + height
|
||||
}
|
||||
|
||||
const totalHeight = offsets[renderItems.length] ?? 0
|
||||
const viewportHeight = viewport.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
|
||||
const maxScrollTop = Math.max(0, totalHeight - viewportHeight)
|
||||
const scrollTop = clampNumber(viewport.scrollTop, 0, maxScrollTop)
|
||||
const windowTop = Math.max(0, scrollTop - overscanPx)
|
||||
const windowBottom = Math.min(totalHeight, scrollTop + viewportHeight + overscanPx)
|
||||
const startIndex = findVirtualStartIndex(offsets, windowTop)
|
||||
const endIndex = Math.min(renderItems.length, findVirtualEndIndex(offsets, windowBottom))
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
beforeHeight: offsets[startIndex] ?? 0,
|
||||
afterHeight: totalHeight - (offsets[endIndex] ?? totalHeight),
|
||||
items: renderItems.slice(startIndex, endIndex).map((item, offset) => ({
|
||||
item,
|
||||
index: startIndex + offset,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const MeasuredRenderItem = memo(function MeasuredRenderItem({
|
||||
itemKey,
|
||||
onHeightChange,
|
||||
children,
|
||||
}: {
|
||||
itemKey: string
|
||||
onHeightChange: (itemKey: string, height: number) => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = itemRef.current
|
||||
if (!node) return undefined
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') return undefined
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry && Number.isFinite(entry.contentRect.height) && entry.contentRect.height > 0) {
|
||||
onHeightChange(itemKey, Math.ceil(entry.contentRect.height))
|
||||
}
|
||||
})
|
||||
observer.observe(node)
|
||||
return () => observer.disconnect()
|
||||
}, [itemKey, onHeightChange])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={itemRef}
|
||||
data-virtual-message-item={itemKey}
|
||||
className={CHAT_RENDER_ITEM_CLASS}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export function MessageList({ sessionId, compact = false }: MessageListProps = {}) {
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const resolvedSessionId = sessionId ?? activeTabId
|
||||
@ -890,8 +1193,13 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
(chatState === 'thinking' && Boolean(activeThinkingId))
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollContentRef = useRef<HTMLDivElement>(null)
|
||||
const virtualItemHeightsRef = useRef(new Map<string, number>())
|
||||
const virtualItemMetricCacheRef = useRef(new Map<string, VirtualRenderItemMetric>())
|
||||
const virtualScrollIdleTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null)
|
||||
const shouldAutoScrollRef = useRef(true)
|
||||
const isProgrammaticScrollingRef = useRef(false)
|
||||
const ignoreProgrammaticScrollUntilRef = useRef(0)
|
||||
const ignoreProgrammaticScrollTopRef = useRef<number | null>(null)
|
||||
const lastSessionIdRef = useRef<string | null | undefined>(resolvedSessionId)
|
||||
const lastTailMessageIdBySessionRef = useRef(new Map<string, string | null>())
|
||||
const t = useTranslation()
|
||||
@ -903,6 +1211,12 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
const [rewindingTurnId, setRewindingTurnId] = useState<string | null>(null)
|
||||
const [turnUndoConfirmTargetId, setTurnUndoConfirmTargetId] = useState<string | null>(null)
|
||||
const [showJumpToLatest, setShowJumpToLatest] = useState(false)
|
||||
const [virtualViewport, setVirtualViewport] = useState<VirtualViewport>({
|
||||
scrollTop: SCROLL_BOTTOM_SENTINEL,
|
||||
viewportHeight: VIRTUAL_DEFAULT_VIEWPORT_HEIGHT,
|
||||
})
|
||||
const [isVirtualScrollActive, setIsVirtualScrollActive] = useState(false)
|
||||
const [measuredItemsVersion, setMeasuredItemsVersion] = useState(0)
|
||||
const branchActionsDisabled =
|
||||
isMemberSession ||
|
||||
chatState !== 'idle' ||
|
||||
@ -913,15 +1227,60 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
const hasCompactingDivider = messages.some((message) =>
|
||||
message.type === 'compact_summary' && message.phase === 'compacting')
|
||||
|
||||
useEffect(() => () => {
|
||||
if (virtualScrollIdleTimerRef.current) {
|
||||
window.clearTimeout(virtualScrollIdleTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const syncVirtualViewportFromContainer = useCallback((container: HTMLElement) => {
|
||||
const nextScrollTop = container.scrollTop
|
||||
const nextViewportHeight = container.clientHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
|
||||
setVirtualViewport((current) => {
|
||||
if (
|
||||
Math.abs(current.scrollTop - nextScrollTop) < 1 &&
|
||||
Math.abs(current.viewportHeight - nextViewportHeight) < 1
|
||||
) {
|
||||
return current
|
||||
}
|
||||
return {
|
||||
scrollTop: nextScrollTop,
|
||||
viewportHeight: nextViewportHeight,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const markVirtualScrollActive = useCallback(() => {
|
||||
setIsVirtualScrollActive(true)
|
||||
if (virtualScrollIdleTimerRef.current) {
|
||||
window.clearTimeout(virtualScrollIdleTimerRef.current)
|
||||
}
|
||||
virtualScrollIdleTimerRef.current = window.setTimeout(() => {
|
||||
setIsVirtualScrollActive(false)
|
||||
virtualScrollIdleTimerRef.current = null
|
||||
}, VIRTUAL_SCROLL_IDLE_DELAY_MS)
|
||||
}, [])
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior) => {
|
||||
shouldAutoScrollRef.current = true
|
||||
isProgrammaticScrollingRef.current = true
|
||||
ignoreProgrammaticScrollUntilRef.current = performance.now() + 250
|
||||
setIsVirtualScrollActive(false)
|
||||
if (virtualScrollIdleTimerRef.current) {
|
||||
window.clearTimeout(virtualScrollIdleTimerRef.current)
|
||||
virtualScrollIdleTimerRef.current = null
|
||||
}
|
||||
const container = scrollContainerRef.current
|
||||
let requestedScrollTop: number | null = null
|
||||
if (container) {
|
||||
setScrollToBottomWithoutLayoutRead(container, behavior)
|
||||
requestedScrollTop = container.scrollTop
|
||||
ignoreProgrammaticScrollTopRef.current = requestedScrollTop
|
||||
}
|
||||
setVirtualViewport((current) => ({
|
||||
scrollTop: SCROLL_BOTTOM_SENTINEL,
|
||||
viewportHeight: current.viewportHeight,
|
||||
}))
|
||||
if (container && resolvedSessionId) {
|
||||
sessionScrollSnapshots.set(resolvedSessionId, {
|
||||
scrollTop: container.scrollTop,
|
||||
@ -952,12 +1311,37 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
})
|
||||
}, [resolvedSessionId])
|
||||
|
||||
const handleVirtualItemHeightChange = useCallback((itemKey: string, height: number) => {
|
||||
const measuredHeight = clampNumber(height, VIRTUAL_MIN_ITEM_HEIGHT, VIRTUAL_MAX_ITEM_HEIGHT)
|
||||
const previousHeight = virtualItemHeightsRef.current.get(itemKey)
|
||||
if (previousHeight !== undefined && Math.abs(previousHeight - measuredHeight) < 1) return
|
||||
|
||||
virtualItemHeightsRef.current.set(itemKey, measuredHeight)
|
||||
setMeasuredItemsVersion((version) => version + 1)
|
||||
|
||||
if (shouldAutoScrollRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
if (shouldAutoScrollRef.current) scrollToBottom('auto')
|
||||
})
|
||||
}
|
||||
}, [scrollToBottom])
|
||||
|
||||
const updateAutoScrollState = useCallback(() => {
|
||||
// Ignore scroll events triggered by our own programmatic scrolling to
|
||||
// prevent the jump-to-latest button from flickering during auto-scroll.
|
||||
if (isProgrammaticScrollingRef.current) return
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return
|
||||
const shouldIgnoreRecentProgrammaticScroll =
|
||||
performance.now() < ignoreProgrammaticScrollUntilRef.current &&
|
||||
ignoreProgrammaticScrollTopRef.current !== null &&
|
||||
Math.abs(container.scrollTop - ignoreProgrammaticScrollTopRef.current) < 1
|
||||
if (isProgrammaticScrollingRef.current || shouldIgnoreRecentProgrammaticScroll) {
|
||||
syncVirtualViewportFromContainer(container)
|
||||
return
|
||||
}
|
||||
flushSync(() => {
|
||||
syncVirtualViewportFromContainer(container)
|
||||
})
|
||||
const isAtBottom = isNearScrollBottom(container)
|
||||
shouldAutoScrollRef.current = isAtBottom
|
||||
setShowJumpToLatest(!isAtBottom)
|
||||
@ -965,17 +1349,27 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
if (resolvedSessionId) {
|
||||
rememberSessionScroll(resolvedSessionId, container)
|
||||
}
|
||||
}, [resolvedSessionId])
|
||||
}, [resolvedSessionId, syncVirtualViewportFromContainer])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (lastSessionIdRef.current !== resolvedSessionId) {
|
||||
const snapshot = resolvedSessionId ? sessionScrollSnapshots.get(resolvedSessionId) : undefined
|
||||
shouldAutoScrollRef.current = snapshot?.wasAtBottom ?? true
|
||||
lastSessionIdRef.current = resolvedSessionId
|
||||
virtualItemHeightsRef.current.clear()
|
||||
virtualItemMetricCacheRef.current.clear()
|
||||
setIsVirtualScrollActive(false)
|
||||
setMeasuredItemsVersion((version) => version + 1)
|
||||
|
||||
const container = scrollContainerRef.current
|
||||
if (container && snapshot && !snapshot.wasAtBottom) {
|
||||
ignoreProgrammaticScrollUntilRef.current = performance.now() + 250
|
||||
ignoreProgrammaticScrollTopRef.current = snapshot.scrollTop
|
||||
setScrollTopWithoutLayoutRead(container, snapshot.scrollTop)
|
||||
setVirtualViewport((current) => ({
|
||||
scrollTop: snapshot.scrollTop,
|
||||
viewportHeight: container.clientHeight || current.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT,
|
||||
}))
|
||||
setShowJumpToLatest(true)
|
||||
} else {
|
||||
scrollToBottom('auto')
|
||||
@ -1043,11 +1437,63 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
() => buildTurnCardInsertionMap(renderItems, turnChangeCards),
|
||||
[renderItems, turnChangeCards],
|
||||
)
|
||||
const renderItemKeys = useMemo(
|
||||
() => renderItems.map(getRenderItemKey),
|
||||
[renderItems],
|
||||
)
|
||||
const renderItemMetrics = useMemo(
|
||||
() => renderItems.map((item, index) => {
|
||||
const key = renderItemKeys[index]!
|
||||
const signature = getRenderItemMetricSignature(item)
|
||||
const cached = virtualItemMetricCacheRef.current.get(key)
|
||||
if (cached?.signature === signature) return cached
|
||||
|
||||
const metric = {
|
||||
signature,
|
||||
contentWeight: getRenderItemContentWeight(item),
|
||||
estimatedHeight: estimateRenderItemHeight(item),
|
||||
}
|
||||
virtualItemMetricCacheRef.current.set(key, metric)
|
||||
return metric
|
||||
}),
|
||||
[renderItemKeys, renderItems],
|
||||
)
|
||||
const virtualOverscanPx = isVirtualScrollActive
|
||||
? VIRTUAL_SCROLL_OVERSCAN_PX
|
||||
: VIRTUAL_IDLE_OVERSCAN_PX
|
||||
const virtualTranscriptWindow = useMemo(
|
||||
() => buildVirtualTranscriptWindow(
|
||||
renderItems,
|
||||
renderItemKeys,
|
||||
renderItemMetrics,
|
||||
virtualItemHeightsRef.current,
|
||||
virtualViewport,
|
||||
virtualOverscanPx,
|
||||
),
|
||||
[measuredItemsVersion, renderItemKeys, renderItemMetrics, renderItems, virtualOverscanPx, virtualViewport],
|
||||
)
|
||||
const confirmTurnCard = useMemo(
|
||||
() => turnChangeCards.find((card) => card.target.messageId === turnUndoConfirmTargetId) ?? null,
|
||||
[turnChangeCards, turnUndoConfirmTargetId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const liveKeys = new Set(renderItemKeys)
|
||||
let removed = false
|
||||
for (const key of virtualItemHeightsRef.current.keys()) {
|
||||
if (!liveKeys.has(key)) {
|
||||
virtualItemHeightsRef.current.delete(key)
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
for (const key of virtualItemMetricCacheRef.current.keys()) {
|
||||
if (!liveKeys.has(key)) {
|
||||
virtualItemMetricCacheRef.current.delete(key)
|
||||
}
|
||||
}
|
||||
if (removed) setMeasuredItemsVersion((version) => version + 1)
|
||||
}, [renderItemKeys])
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedSessionId || completedTurnTargets.length === 0 || isMemberSession) {
|
||||
setTurnChangeCards([])
|
||||
@ -1197,81 +1643,118 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
}
|
||||
}, [addToast, branchSession, branchingMessageId, resolvedSessionId, t])
|
||||
|
||||
const renderTranscriptItem = (item: RenderItem, index: number) => {
|
||||
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
{item.kind === 'tool_group' ? (
|
||||
<ToolCallGroup
|
||||
toolCalls={item.toolCalls}
|
||||
resultMap={toolResultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
agentTaskNotifications={agentTaskNotifications}
|
||||
isStreaming={
|
||||
chatState === 'tool_executing' &&
|
||||
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MessageBlock
|
||||
sessionId={resolvedSessionId}
|
||||
message={item.message}
|
||||
activeThinkingId={activeThinkingId}
|
||||
agentTaskNotifications={agentTaskNotifications}
|
||||
toolResult={
|
||||
item.message.type === 'tool_use'
|
||||
? (() => {
|
||||
const result = toolResultMap.get(item.message.toolUseId)
|
||||
return result ? { content: result.content, isError: result.isError } : null
|
||||
})()
|
||||
: null
|
||||
}
|
||||
branchAction={
|
||||
(() => {
|
||||
const branchTarget = branchableMessageTargets.get(item.message.id)
|
||||
if (!branchTarget) return undefined
|
||||
return {
|
||||
label: t('chat.branchFromHere'),
|
||||
loading: branchingMessageId === branchTarget.uiMessageId,
|
||||
onBranch: () => { void handleBranchMessage(branchTarget) },
|
||||
}
|
||||
})()
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{resolvedSessionId && cardsForItem.map((card) => (
|
||||
<CurrentTurnChangeCard
|
||||
key={`turn-change-${card.target.messageId}`}
|
||||
sessionId={resolvedSessionId}
|
||||
targetUserMessageId={card.checkpoint.target.targetUserMessageId}
|
||||
checkpoint={card.checkpoint}
|
||||
workDir={card.workDir}
|
||||
error={turnActionErrors[card.target.messageId] ?? null}
|
||||
isUndoing={rewindingTurnId === card.target.messageId}
|
||||
isLatest={card.isLatest}
|
||||
onUndo={() => {
|
||||
setTurnUndoConfirmTargetId(card.target.messageId)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={updateAutoScrollState}
|
||||
onWheel={markVirtualScrollActive}
|
||||
onTouchMove={markVirtualScrollActive}
|
||||
onPointerDown={markVirtualScrollActive}
|
||||
className={`${CHAT_SCROLL_AREA_CLASS} h-full overflow-y-auto ${compact ? 'px-3 py-3 pb-5' : 'px-4 py-4'}`}
|
||||
>
|
||||
<div
|
||||
ref={scrollContentRef}
|
||||
className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}
|
||||
>
|
||||
{renderItems.map((item, index) => {
|
||||
const itemKey = item.kind === 'tool_group' ? item.id : item.message.id
|
||||
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
|
||||
{virtualTranscriptWindow.enabled && virtualTranscriptWindow.beforeHeight > 0 ? (
|
||||
<div
|
||||
data-virtual-spacer="top"
|
||||
aria-hidden="true"
|
||||
style={{ height: virtualTranscriptWindow.beforeHeight }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
return (
|
||||
{virtualTranscriptWindow.items.map(({ item, index }) => {
|
||||
const itemKey = getRenderItemKey(item)
|
||||
const content = renderTranscriptItem(item, index)
|
||||
|
||||
return virtualTranscriptWindow.enabled ? (
|
||||
<MeasuredRenderItem
|
||||
key={itemKey}
|
||||
itemKey={itemKey}
|
||||
onHeightChange={handleVirtualItemHeightChange}
|
||||
>
|
||||
{content}
|
||||
</MeasuredRenderItem>
|
||||
) : (
|
||||
<div key={itemKey} className={CHAT_RENDER_ITEM_CLASS}>
|
||||
{item.kind === 'tool_group' ? (
|
||||
<ToolCallGroup
|
||||
toolCalls={item.toolCalls}
|
||||
resultMap={toolResultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
agentTaskNotifications={agentTaskNotifications}
|
||||
isStreaming={
|
||||
chatState === 'tool_executing' &&
|
||||
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MessageBlock
|
||||
sessionId={resolvedSessionId}
|
||||
message={item.message}
|
||||
activeThinkingId={activeThinkingId}
|
||||
agentTaskNotifications={agentTaskNotifications}
|
||||
toolResult={
|
||||
item.message.type === 'tool_use'
|
||||
? (() => {
|
||||
const result = toolResultMap.get(item.message.toolUseId)
|
||||
return result ? { content: result.content, isError: result.isError } : null
|
||||
})()
|
||||
: null
|
||||
}
|
||||
branchAction={
|
||||
(() => {
|
||||
const branchTarget = branchableMessageTargets.get(item.message.id)
|
||||
if (!branchTarget) return undefined
|
||||
return {
|
||||
label: t('chat.branchFromHere'),
|
||||
loading: branchingMessageId === branchTarget.uiMessageId,
|
||||
onBranch: () => { void handleBranchMessage(branchTarget) },
|
||||
}
|
||||
})()
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{resolvedSessionId && cardsForItem.map((card) => (
|
||||
<CurrentTurnChangeCard
|
||||
key={`turn-change-${card.target.messageId}`}
|
||||
sessionId={resolvedSessionId}
|
||||
targetUserMessageId={card.checkpoint.target.targetUserMessageId}
|
||||
checkpoint={card.checkpoint}
|
||||
workDir={card.workDir}
|
||||
error={turnActionErrors[card.target.messageId] ?? null}
|
||||
isUndoing={rewindingTurnId === card.target.messageId}
|
||||
isLatest={card.isLatest}
|
||||
onUndo={() => {
|
||||
setTurnUndoConfirmTargetId(card.target.messageId)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{virtualTranscriptWindow.enabled && virtualTranscriptWindow.afterHeight > 0 ? (
|
||||
<div
|
||||
data-virtual-spacer="bottom"
|
||||
aria-hidden="true"
|
||||
style={{ height: virtualTranscriptWindow.afterHeight }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{streamingText.trim() && (
|
||||
<AssistantMessage content={streamingText} isStreaming={chatState === 'streaming'} />
|
||||
)}
|
||||
|
||||
@ -12,6 +12,7 @@ type Props = {
|
||||
content: string
|
||||
variant?: 'default' | 'document' | 'compact'
|
||||
className?: string
|
||||
cache?: boolean
|
||||
onLinkClick?: (href: string, event: ReactMouseEvent<HTMLDivElement>) => boolean | void
|
||||
}
|
||||
|
||||
@ -304,6 +305,38 @@ function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[]
|
||||
return { html, codeBlocks, mathBlocks }
|
||||
}
|
||||
|
||||
type MarkdownParseResult = ReturnType<typeof parseMarkdown>
|
||||
|
||||
const MARKDOWN_PARSE_CACHE_MAX_ENTRIES = 48
|
||||
const MARKDOWN_PARSE_CACHE_MAX_CHARS = 1_800_000
|
||||
const markdownParseCache = new Map<string, MarkdownParseResult>()
|
||||
let markdownParseCacheChars = 0
|
||||
|
||||
function getCachedMarkdownParse(content: string): MarkdownParseResult {
|
||||
const cached = markdownParseCache.get(content)
|
||||
if (cached) {
|
||||
markdownParseCache.delete(content)
|
||||
markdownParseCache.set(content, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
const parsed = parseMarkdown(content)
|
||||
markdownParseCache.set(content, parsed)
|
||||
markdownParseCacheChars += content.length
|
||||
|
||||
while (
|
||||
markdownParseCache.size > MARKDOWN_PARSE_CACHE_MAX_ENTRIES ||
|
||||
markdownParseCacheChars > MARKDOWN_PARSE_CACHE_MAX_CHARS
|
||||
) {
|
||||
const oldestContent = markdownParseCache.keys().next().value
|
||||
if (typeof oldestContent !== 'string') break
|
||||
markdownParseCache.delete(oldestContent)
|
||||
markdownParseCacheChars -= oldestContent.length
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
const BASE_PROSE_CLASSES = `markdown-prose prose prose-sm min-w-0 max-w-none break-words [overflow-wrap:anywhere] text-[var(--color-text-primary)]
|
||||
prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold
|
||||
prose-p:my-2 prose-p:leading-relaxed
|
||||
@ -363,8 +396,11 @@ function getProseClasses(variant: 'default' | 'document' | 'compact', className?
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({ content, variant = 'default', className, onLinkClick }: Props) {
|
||||
const { html, codeBlocks, mathBlocks } = useMemo(() => parseMarkdown(content), [content])
|
||||
export function MarkdownRenderer({ content, variant = 'default', className, cache = true, onLinkClick }: Props) {
|
||||
const { html, codeBlocks, mathBlocks } = useMemo(
|
||||
() => cache ? getCachedMarkdownParse(content) : parseMarkdown(content),
|
||||
[cache, content],
|
||||
)
|
||||
const proseClasses = useMemo(
|
||||
() => getProseClasses(variant, className),
|
||||
[variant, className],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user