perf: migrate chat rendering to virtua (#1651)

* refactor: migrate chat history virtualization to virtua

* refactor: render loaded chat history directly

* refactor: finish virtua migration

* perf: defer tool body rendering

* perf: queue deferred tool body mounts

* perf: quiet and defer markdown file probes

* perf: defer markdown code highlighting

* perf: stabilize markdown plugin lists

* perf: defer mermaid markdown rendering

* perf: delay markdown file reference annotation

* perf: attach markdown table listeners on demand

* perf: trim markdown render overhead
This commit is contained in:
Bohdan Triapitsyn
2026-06-15 03:29:40 +03:00
committed by GitHub
parent e372c8d8cb
commit a45376d585
24 changed files with 621 additions and 671 deletions
@@ -2,7 +2,7 @@ import React from 'react';
import 'katex/dist/katex.min.css';
import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
import ReactMarkdown from 'react-markdown';
import type { Components } from 'react-markdown';
import type { Components, Options as ReactMarkdownOptions } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
@@ -216,6 +216,10 @@ const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | nul
const menuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!showMenu) {
return;
}
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setShowMenu(false);
@@ -223,7 +227,7 @@ const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | nul
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
}, [showMenu]);
const handleCopy = async (format: 'csv' | 'tsv' | 'markdown') => {
const tableEl = tableRef.current?.querySelector('table');
@@ -308,6 +312,10 @@ const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement |
const menuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!showMenu) {
return;
}
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setShowMenu(false);
@@ -315,7 +323,7 @@ const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement |
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
}, [showMenu]);
const handleDownload = (format: 'csv' | 'markdown') => {
const tableEl = tableRef.current?.querySelector('table');
@@ -364,16 +372,35 @@ const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }>
const tableRef = React.useRef<HTMLDivElement>(null);
const { isMobile, isTablet } = useDeviceInfo();
const alwaysShowActions = isMobile || isTablet;
const [showActions, setShowActions] = React.useState(alwaysShowActions);
React.useEffect(() => {
if (alwaysShowActions) {
setShowActions(true);
}
}, [alwaysShowActions]);
return (
<div className="group my-4 flex flex-col space-y-2" data-markdown="table-wrapper" ref={tableRef}>
<div className={cn(
"flex items-center justify-end gap-1 transition-opacity",
alwaysShowActions ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}>
<TableCopyButton tableRef={tableRef} />
<TableDownloadButton tableRef={tableRef} />
</div>
<div
className="group my-4 flex flex-col space-y-2"
data-markdown="table-wrapper"
ref={tableRef}
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => {
if (!alwaysShowActions) {
setShowActions(false);
}
}}
onFocusCapture={() => setShowActions(true)}
>
{showActions ? (
<div className="flex items-center justify-end gap-1 transition-opacity">
<TableCopyButton tableRef={tableRef} />
<TableDownloadButton tableRef={tableRef} />
</div>
) : (
<div className="h-6" aria-hidden="true" />
)}
<div className="overflow-x-auto rounded-lg border border-border/80 bg-[var(--surface-elevated)]">
<table className={cn('w-full border-collapse text-sm', className)} data-markdown="table">
{children}
@@ -383,40 +410,83 @@ const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }>
);
};
const MERMAID_RENDER_DELAY_MS = 80;
const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ source, mode }) => {
const { t } = useI18n();
const currentTheme = useCurrentMermaidTheme();
const { isMobile, isTablet } = useDeviceInfo();
const [copied, setCopied] = React.useState(false);
const [downloaded, setDownloaded] = React.useState(false);
const [svg, setSvg] = React.useState('');
const [ascii, setAscii] = React.useState('');
const svg = React.useMemo(() => {
if (mode !== 'svg') return '';
try {
return renderMermaidSVG(source, {
bg: currentTheme.colors.surface.elevated,
fg: currentTheme.colors.surface.foreground,
line: currentTheme.colors.interactive.border,
accent: currentTheme.colors.primary.base,
muted: currentTheme.colors.surface.mutedForeground,
surface: currentTheme.colors.surface.muted,
border: currentTheme.colors.interactive.border,
transparent: true,
font: 'IBM Plex Sans, sans-serif',
React.useEffect(() => {
if (typeof window === 'undefined') {
try {
setSvg(mode === 'svg' ? renderMermaidSVG(source, {
bg: currentTheme.colors.surface.elevated,
fg: currentTheme.colors.surface.foreground,
line: currentTheme.colors.interactive.border,
accent: currentTheme.colors.primary.base,
muted: currentTheme.colors.surface.mutedForeground,
surface: currentTheme.colors.surface.muted,
border: currentTheme.colors.interactive.border,
transparent: true,
font: 'IBM Plex Sans, sans-serif',
}) : '');
setAscii(mode === 'ascii' ? renderMermaidASCII(source) : '');
} catch {
setSvg('');
setAscii('');
}
return;
}
let cancelled = false;
let frame: number | null = null;
setSvg('');
setAscii('');
const timer = window.setTimeout(() => {
frame = window.requestAnimationFrame(() => {
frame = null;
if (cancelled) {
return;
}
try {
if (mode === 'svg') {
setSvg(renderMermaidSVG(source, {
bg: currentTheme.colors.surface.elevated,
fg: currentTheme.colors.surface.foreground,
line: currentTheme.colors.interactive.border,
accent: currentTheme.colors.primary.base,
muted: currentTheme.colors.surface.mutedForeground,
surface: currentTheme.colors.surface.muted,
border: currentTheme.colors.interactive.border,
transparent: true,
font: 'IBM Plex Sans, sans-serif',
}));
return;
}
setAscii(renderMermaidASCII(source));
} catch {
setSvg('');
setAscii('');
}
});
} catch {
return '';
}
}, [currentTheme, mode, source]);
}, MERMAID_RENDER_DELAY_MS);
const ascii = React.useMemo(() => {
if (mode !== 'ascii') return '';
try {
return renderMermaidASCII(source);
} catch {
return '';
}
}, [mode, source]);
return () => {
cancelled = true;
window.clearTimeout(timer);
if (frame !== null) {
window.cancelAnimationFrame(frame);
}
};
}, [currentTheme, mode, source]);
const copyVisibilityClass = isMobile || isTablet ? 'opacity-100' : 'opacity-0 group-hover:opacity-100';
@@ -573,6 +643,10 @@ const stripLeadingFrontmatter = (markdown: string): string => {
export type MarkdownVariant = 'assistant' | 'tool' | 'reasoning';
const MARKDOWN_REMARK_PLUGINS: ReactMarkdownOptions['remarkPlugins'] = [remarkGfm, remarkMath];
const MARKDOWN_REHYPE_PLUGINS: ReactMarkdownOptions['rehypePlugins'] = [[rehypeKatex, { throwOnError: false, errorColor: 'var(--destructive)' }]];
const MARKDOWN_BLOCK_CACHE_MAX_ENTRIES = 240;
type MarkdownStreamBlock = {
key: string;
raw: string;
@@ -613,14 +687,45 @@ const buildMarkdownCacheKey = (baseKey: string, raw: string, index: number, mode
return `${baseKey}:${index}:${mode}:${raw.length}:${fnv1a32(sample)}`;
};
const MARKDOWN_BLOCK_CACHE = new Map<string, MarkdownStreamBlock[]>();
const getMarkdownBlockCacheEntry = (key: string): MarkdownStreamBlock[] | undefined => {
const cached = MARKDOWN_BLOCK_CACHE.get(key);
if (!cached) {
return undefined;
}
MARKDOWN_BLOCK_CACHE.delete(key);
MARKDOWN_BLOCK_CACHE.set(key, cached);
return cached;
};
const setMarkdownBlockCacheEntry = (key: string, blocks: MarkdownStreamBlock[]): void => {
while (MARKDOWN_BLOCK_CACHE.size >= MARKDOWN_BLOCK_CACHE_MAX_ENTRIES) {
const oldest = MARKDOWN_BLOCK_CACHE.keys().next().value;
if (typeof oldest !== 'string') {
break;
}
MARKDOWN_BLOCK_CACHE.delete(oldest);
}
MARKDOWN_BLOCK_CACHE.set(key, blocks);
};
const streamMarkdownBlocks = (text: string, live: boolean, baseKey: string): MarkdownStreamBlock[] => {
if (!live) {
return [{
const cacheKey = `${baseKey}:final:${text.length}:${fnv1a32(text.length > 800 ? `${text.slice(0, 400)}${text.slice(-400)}` : text)}`;
const cached = getMarkdownBlockCacheEntry(cacheKey);
if (cached) {
return cached;
}
const blocks: MarkdownStreamBlock[] = [{
key: buildMarkdownCacheKey(baseKey, text, 0, 'full'),
raw: text,
src: text,
mode: 'full',
}];
setMarkdownBlockCacheEntry(cacheKey, blocks);
return blocks;
}
const healed = healMarkdown(text);
@@ -742,6 +847,7 @@ const normalizeCodeBlockText = (code: string, language: string): string => {
};
const CODE_HIGHLIGHT_SETTLE_MS = 300;
const CODE_HIGHLIGHT_INITIAL_DELAY_MS = 80;
const CODE_HIGHLIGHT_LINE_LIMIT = 1200;
const VSCODE_CODE_HIGHLIGHT_LINE_LIMIT = 200;
const CODE_SHARED_STYLE: React.CSSProperties = {
@@ -795,23 +901,57 @@ const MarkdownCodeBlock: React.FC<{
syntaxTheme: { [key: string]: React.CSSProperties };
}> = ({ code, language, syntaxTheme }) => {
const [copied, setCopied] = React.useState(false);
const [highlight, setHighlight] = React.useState(true);
const [highlight, setHighlight] = React.useState(false);
const [viewMode, setViewMode] = React.useState<'code' | 'preview'>('code');
const prevCodeRef = React.useRef<string>(code);
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const { isMobile, isTablet } = useDeviceInfo();
const alwaysShowControls = isMobile || isTablet;
const [showControls, setShowControls] = React.useState(alwaysShowControls);
const skipHighlight = exceedsLineLimit(code, getCodeHighlightLineLimit());
const canPreview = language === 'html' || language === 'htm';
React.useEffect(() => {
if (alwaysShowControls) {
setShowControls(true);
}
}, [alwaysShowControls]);
React.useEffect(() => {
if (!canPreview && viewMode !== 'code') {
setViewMode('code');
}
}, [canPreview, viewMode]);
React.useEffect(() => {
if (skipHighlight) {
setHighlight(false);
return;
}
if (typeof window === 'undefined') {
setHighlight(true);
return;
}
let frame: number | null = null;
const timer = window.setTimeout(() => {
frame = window.requestAnimationFrame(() => {
frame = null;
setHighlight(true);
});
}, CODE_HIGHLIGHT_INITIAL_DELAY_MS);
return () => {
window.clearTimeout(timer);
if (frame !== null) {
window.cancelAnimationFrame(frame);
}
};
}, [skipHighlight]);
// Defer Prism highlighting while code is actively streaming.
// Initial mount renders highlighted immediately (plays nice with finalized blocks).
React.useEffect(() => {
if (prevCodeRef.current === code) return;
prevCodeRef.current = code;
@@ -848,46 +988,57 @@ const MarkdownCodeBlock: React.FC<{
}, [canPreview, code]);
return (
<div data-component="markdown-code" className="my-4 group overflow-hidden rounded-2xl border border-border/80 bg-[var(--surface-elevated)]">
<div
data-component="markdown-code"
className="my-4 group overflow-hidden rounded-2xl border border-border/80 bg-[var(--surface-elevated)]"
onMouseEnter={() => setShowControls(true)}
onMouseLeave={() => {
if (!alwaysShowControls) {
setShowControls(false);
}
}}
onFocusCapture={() => setShowControls(true)}
>
<div className="flex items-center justify-between border-b border-border/70 px-3 py-1.5">
<span className="font-mono text-[13px] text-muted-foreground">{language}</span>
<div className={cn(
"flex items-center gap-1 transition-opacity",
isMobile || isTablet ? "opacity-100" : "opacity-100 md:opacity-0 md:group-hover:opacity-100"
)}>
{canPreview ? (
{showControls ? (
<div className="flex min-h-6 items-center gap-1 transition-opacity">
{canPreview ? (
<button
type="button"
onClick={() => setViewMode((mode) => (mode === 'preview' ? 'code' : 'preview'))}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title={viewMode === 'preview' ? 'Show code' : 'Preview'}
aria-pressed={viewMode === 'preview'}
aria-label={viewMode === 'preview' ? 'Show code' : 'Preview HTML'}
>
{viewMode === 'preview' ? <Icon name="code" className="size-3.5" /> : <Icon name="eye" className="size-3.5" />}
</button>
) : null}
{canPreview ? (
<button
type="button"
onClick={handleDownload}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Download HTML"
aria-label="Download HTML"
>
<Icon name="download" className="size-3.5" />
</button>
) : null}
<button
type="button"
onClick={() => setViewMode((mode) => (mode === 'preview' ? 'code' : 'preview'))}
onClick={() => { void handleCopy(); }}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title={viewMode === 'preview' ? 'Show code' : 'Preview'}
aria-pressed={viewMode === 'preview'}
aria-label={viewMode === 'preview' ? 'Show code' : 'Preview HTML'}
title={copied ? 'Copied' : 'Copy code'}
aria-label={copied ? 'Copied' : 'Copy code'}
>
{viewMode === 'preview' ? <Icon name="code" className="size-3.5" /> : <Icon name="eye" className="size-3.5" />}
{copied ? <Icon name="check" className="size-3.5" /> : <Icon name="file-copy" className="size-3.5" />}
</button>
) : null}
{canPreview ? (
<button
type="button"
onClick={handleDownload}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Download HTML"
aria-label="Download HTML"
>
<Icon name="download" className="size-3.5" />
</button>
) : null}
<button
type="button"
onClick={() => { void handleCopy(); }}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title={copied ? 'Copied' : 'Copy code'}
aria-label={copied ? 'Copied' : 'Copy code'}
>
{copied ? <Icon name="check" className="size-3.5" /> : <Icon name="file-copy" className="size-3.5" />}
</button>
</div>
</div>
) : (
<div className="min-h-6 min-w-6" aria-hidden="true" />
)}
</div>
{canPreview && viewMode === 'preview' ? (
<div className="h-[320px] md:h-[420px] bg-background">
@@ -1078,7 +1229,7 @@ const MarkdownBlockView: React.FC<{
components: Components;
}> = React.memo(({ block, components }) => {
return (
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[[rehypeKatex, { throwOnError: false, errorColor: 'var(--destructive)' }]]} components={components}>
<ReactMarkdown remarkPlugins={MARKDOWN_REMARK_PLUGINS} rehypePlugins={MARKDOWN_REHYPE_PLUGINS} components={components}>
{block.src}
</ReactMarkdown>
);
@@ -1119,8 +1270,9 @@ const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000;
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
const FILE_REFERENCE_LINK_LIMIT = 200;
const FILE_REFERENCE_LINK_LIMIT = 80;
const VSCODE_FILE_REFERENCE_LINK_LIMIT = 40;
const FILE_REFERENCE_ANNOTATION_DELAY_MS = 160;
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
let activeFileReferenceStatCount = 0;
const pendingFileReferenceStats: Array<() => void> = [];
@@ -1462,11 +1614,18 @@ const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
const request = new Promise<boolean>((resolve) => {
const run = () => {
activeFileReferenceStatCount += 1;
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}`, {
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
method: 'GET',
cache: 'no-store',
})
.then((response) => resolve(response.ok))
.then(async (response) => {
if (!response.ok) {
resolve(false);
return;
}
const payload = await response.json().catch(() => null) as { exists?: unknown } | null;
resolve(payload?.exists !== false);
})
.catch(() => resolve(false))
.finally(() => {
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
@@ -1547,6 +1706,24 @@ const useFileReferenceInteractions = ({
return;
}
const scheduleAnnotation = (delayMs = 0) => {
if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(annotationDebounceRef.current);
}
if (typeof window === 'undefined') {
annotateFileLinks();
return;
}
annotationDebounceRef.current = window.setTimeout(() => {
annotationDebounceRef.current = null;
window.requestAnimationFrame(() => {
if (!cancelled) {
annotateFileLinks();
}
});
}, delayMs);
};
const annotateFileLinks = () => {
if (enabled) {
wrapBlockCodePathTokens(container);
@@ -1674,20 +1851,10 @@ const useFileReferenceInteractions = ({
void openFileReference(target);
};
annotateFileLinks();
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
const observer = new MutationObserver(() => {
if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(annotationDebounceRef.current);
}
if (typeof window === 'undefined') {
annotateFileLinks();
return;
}
annotationDebounceRef.current = window.setTimeout(() => {
annotationDebounceRef.current = null;
annotateFileLinks();
}, 120);
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
});
observer.observe(container, {
childList: true,
+99 -162
View File
@@ -1,6 +1,6 @@
import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2';
import { measureElement as measureVirtualElement, type VirtualItem, useVirtualizer } from '@tanstack/react-virtual';
import { Virtualizer, type CacheSnapshot, type VirtualizerHandle } from 'virtua';
import ChatMessage from './ChatMessage';
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
@@ -21,10 +21,10 @@ import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5;
const MESSAGE_LIST_OVERSCAN = 6;
const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = [];
const EMPTY_UNGROUPED_MESSAGE_IDS = new Set<string>();
const EMPTY_VIRTUAL_ROWS: VirtualItem[] = [];
const MESSAGE_LIST_BUFFER_SIZE = 900;
const TIMELINE_CACHE_LIMIT = 16;
const estimateHistoryEntryHeight = (entry: RenderEntry | undefined): number => {
if (!entry) {
@@ -38,6 +38,38 @@ const estimateHistoryEntryHeight = (entry: RenderEntry | undefined): number => {
return 140;
};
const sameKeys = (a: readonly string[] | undefined, b: readonly string[] | undefined): boolean => {
if (a === b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
return a.every((key, index) => key === b[index]);
};
const timelineCache = new Map<string, { keys: readonly string[]; cache: CacheSnapshot }>();
const readTimelineCache = (sessionKey: string, keys: readonly string[]): CacheSnapshot | undefined => {
const entry = timelineCache.get(sessionKey);
if (!entry) return undefined;
if (sameKeys(entry.keys, keys)) return entry.cache;
timelineCache.delete(sessionKey);
return undefined;
};
const writeTimelineCache = (
sessionKey: string,
keys: readonly string[],
handle: VirtualizerHandle | null | undefined,
): void => {
if (!handle || keys.length === 0) return;
timelineCache.delete(sessionKey);
timelineCache.set(sessionKey, { keys: keys.slice(), cache: handle.cache });
while (timelineCache.size > TIMELINE_CACHE_LIMIT) {
const oldest = timelineCache.keys().next().value;
if (typeof oldest !== 'string') break;
timelineCache.delete(oldest);
}
};
const useStableEvent = <TArgs extends unknown[], TResult>(handler: (...args: TArgs) => TResult) => {
const handlerRef = React.useRef(handler);
React.useEffect(() => {
@@ -959,10 +991,12 @@ MessageListEntry.displayName = 'MessageListEntry';
type StaticHistoryListProps = {
entries: RenderEntry[];
shouldVirtualize: boolean;
virtualRows: VirtualItem[];
totalSize: number;
measureElement: (element: HTMLDivElement | null) => void;
contentRef: React.RefObject<HTMLDivElement | null>;
scrollRef?: React.RefObject<HTMLDivElement | null>;
virtualizerRef: React.Ref<VirtualizerHandle>;
virtualizerKey: string;
virtualCache?: CacheSnapshot;
shift: boolean;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: () => void;
@@ -977,7 +1011,7 @@ type StaticHistoryListProps = {
reviewTransferDirection?: ReviewTransferDirection | null;
};
const StaticHistoryList = React.memo(({ entries, shouldVirtualize, virtualRows, totalSize, measureElement, contentRef, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, activeStreamingPhase, reviewTransferDirection }: StaticHistoryListProps) => {
const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, scrollRef, virtualizerRef, virtualizerKey, virtualCache, shift, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, activeStreamingPhase, reviewTransferDirection }: StaticHistoryListProps) => {
const renderEntry = React.useCallback((entry: RenderEntry) => {
return (
<MessageListEntry
@@ -1001,13 +1035,6 @@ const StaticHistoryList = React.memo(({ entries, shouldVirtualize, virtualRows,
);
}, [activeStreamingPhase, chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, reviewTransferDirection, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
const paddingTop = shouldVirtualize && virtualRows.length > 0
? virtualRows[0]?.start ?? 0
: 0;
const paddingBottom = shouldVirtualize && virtualRows.length > 0
? Math.max(0, totalSize - (virtualRows[virtualRows.length - 1]?.end ?? 0))
: 0;
if (!shouldVirtualize) {
return (
<div ref={contentRef} className="relative w-full">
@@ -1023,43 +1050,23 @@ const StaticHistoryList = React.memo(({ entries, shouldVirtualize, virtualRows,
);
}
if (virtualRows.length === 0 && entries.length > 0) {
return (
<div ref={contentRef} className="relative w-full">
{entries.map((entry) => (
<div
key={entry.key}
data-turn-entry={entry.key}
>
{renderEntry(entry)}
</div>
))}
</div>
);
}
return (
<div ref={contentRef} className="relative w-full">
{paddingTop > 0 ? <div aria-hidden="true" style={{ height: `${paddingTop}px` }} /> : null}
{virtualRows.map((virtualRow) => {
const entry = entries[virtualRow.index];
if (!entry) {
return null;
}
return (
<div
key={virtualRow.key}
ref={measureElement}
data-index={virtualRow.index}
data-turn-entry={entry.key}
>
{renderEntry(entry)}
</div>
);
})}
{paddingBottom > 0 ? <div aria-hidden="true" style={{ height: `${paddingBottom}px` }} /> : null}
</div>
<Virtualizer
key={virtualizerKey}
ref={virtualizerRef}
data={entries}
cache={virtualCache}
itemSize={virtualCache ? undefined : estimateHistoryEntryHeight(undefined)}
bufferSize={MESSAGE_LIST_BUFFER_SIZE}
shift={shift}
scrollRef={scrollRef}
>
{(entry) => (
<div key={entry.key} data-turn-entry={entry.key}>
{renderEntry(entry)}
</div>
)}
</Virtualizer>
);
});
@@ -1229,7 +1236,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
}), [messages]);
const historyContentRef = React.useRef<HTMLDivElement | null>(null);
const pendingVirtualMeasureFrameRef = React.useRef<number | null>(null);
const historyVirtualizerRef = React.useRef<VirtualizerHandle | null>(null);
const resolveScrollContainer = React.useCallback((): HTMLDivElement | null => {
if (scrollRef?.current) {
return scrollRef.current;
@@ -1332,120 +1339,44 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
const historyEntries = staticRenderEntries;
const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
const previousHistoryLenRef = React.useRef(historyEntries.length);
const previousFirstEntryKeyRef = React.useRef(historyEntries[0]?.key);
React.useLayoutEffect(() => {
const previousLen = previousHistoryLenRef.current;
const currentLen = historyEntries.length;
const previousFirstKey = previousFirstEntryKeyRef.current;
const currentFirstKey = historyEntries[0]?.key;
previousHistoryLenRef.current = currentLen;
previousFirstEntryKeyRef.current = currentFirstKey;
const grew = currentLen > previousLen;
const firstChanged = previousFirstKey !== currentFirstKey;
if (!shouldVirtualizeHistory || isLoadingOlder || disableStaging || !grew || !firstChanged || previousLen === 0) {
const historyEntryKeys = React.useMemo(() => historyEntries.map((entry) => entry.key), [historyEntries]);
const virtualCache = React.useMemo(
() => (shouldVirtualizeHistory ? readTimelineCache(sessionKey, historyEntryKeys) : undefined),
[historyEntryKeys, sessionKey, shouldVirtualizeHistory],
);
const virtualCacheSessionRef = React.useRef(sessionKey);
const virtualCacheKeysRef = React.useRef(historyEntryKeys);
const setHistoryVirtualizer = React.useCallback((handle: VirtualizerHandle | null) => {
if (!handle) {
writeTimelineCache(
virtualCacheSessionRef.current,
virtualCacheKeysRef.current,
historyVirtualizerRef.current,
);
historyVirtualizerRef.current = null;
return;
}
const prependedCount = currentLen - previousLen;
const shiftedOldFirst = historyEntries[prependedCount]?.key;
if (shiftedOldFirst !== previousFirstKey) {
return;
}
// Prepend detected: new entries added at the beginning of the list.
// The virtualizer renders based on the current scroll offset which
// now maps to different items. Compensate so the user sees the
// prepended content (scroll to top) or stays on the same content.
let prependedHeight = 0;
for (let i = 0; i < prependedCount; i++) {
prependedHeight += estimateHistoryEntryHeight(historyEntries[i]);
}
const scrollEl = resolveScrollContainer();
if (!scrollEl || prependedHeight <= 0) return;
scrollEl.scrollTop += prependedHeight;
});
const historyVirtualizer = useVirtualizer({
count: historyEntries.length,
getScrollElement: resolveScrollContainer,
estimateSize: (index) => estimateHistoryEntryHeight(historyEntries[index]),
getItemKey: (index) => historyEntries[index]?.key ?? String(index),
measureElement: measureVirtualElement,
useAnimationFrameWithResizeObserver: true,
overscan: MESSAGE_LIST_OVERSCAN,
enabled: shouldVirtualizeHistory,
});
React.useLayoutEffect(() => {
const historyContent = historyContentRef.current;
if (!historyContent || !shouldVirtualizeHistory) {
return;
}
if (typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver(() => {
historyVirtualizer.measure();
});
observer.observe(historyContent);
return () => {
observer.disconnect();
};
}, [historyEntries.length, shouldVirtualizeHistory, historyVirtualizer]);
React.useEffect(() => {
if (!shouldVirtualizeHistory) {
return;
}
historyVirtualizer.measure();
}, [historyEntries.length, historyVirtualizer, shouldVirtualizeHistory]);
const scheduleVirtualMeasure = React.useCallback(() => {
if (!shouldVirtualizeHistory) {
return;
}
if (typeof window === 'undefined') {
historyVirtualizer.measure();
return;
}
if (pendingVirtualMeasureFrameRef.current !== null) {
return;
}
pendingVirtualMeasureFrameRef.current = window.requestAnimationFrame(() => {
pendingVirtualMeasureFrameRef.current = null;
historyVirtualizer.measure();
});
}, [historyVirtualizer, shouldVirtualizeHistory]);
React.useEffect(() => {
return () => {
if (pendingVirtualMeasureFrameRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(pendingVirtualMeasureFrameRef.current);
}
};
historyVirtualizerRef.current = handle;
}, []);
const historyVirtualRows = React.useMemo(
() => (shouldVirtualizeHistory ? historyVirtualizer.getVirtualItems() : EMPTY_VIRTUAL_ROWS),
[historyVirtualizer, shouldVirtualizeHistory],
);
React.useEffect(() => {
virtualCacheSessionRef.current = sessionKey;
virtualCacheKeysRef.current = historyEntryKeys;
}, [historyEntryKeys, sessionKey]);
React.useEffect(() => {
const virtualizerForCleanup = historyVirtualizerRef.current;
return () => {
writeTimelineCache(virtualCacheSessionRef.current, virtualCacheKeysRef.current, virtualizerForCleanup);
};
}, []);
const allEntries = React.useMemo(() => {
return trailingStreamingEntry ? [...historyEntries, trailingStreamingEntry] : historyEntries;
}, [historyEntries, trailingStreamingEntry]);
const stableHistoryContentChange = useStableEvent((reason?: ContentChangeReason) => {
scheduleVirtualMeasure();
onMessageContentChange(reason);
});
@@ -1540,10 +1471,14 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
return false;
}
const virtualizerBehavior = behavior === 'smooth' ? 'smooth' : 'auto';
historyVirtualizer.scrollToIndex(index, { align: 'start', behavior: virtualizerBehavior });
const virtualizer = historyVirtualizerRef.current;
if (!virtualizer) {
return false;
}
virtualizer.scrollToIndex(index, { align: 'start', smooth: behavior === 'smooth' });
return true;
}, [historyEntries.length, historyVirtualizer, shouldVirtualizeHistory]);
}, [historyEntries.length, shouldVirtualizeHistory]);
const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => {
const container = resolveScrollContainer();
@@ -1682,7 +1617,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
scrollToBottom: () => {
if (shouldVirtualizeHistory && historyEntries.length > 0) {
historyVirtualizer.scrollToIndex(historyEntries.length - 1, { align: 'end' });
historyVirtualizerRef.current?.scrollToIndex(historyEntries.length - 1, { align: 'end' });
return;
}
const container = resolveScrollContainer();
@@ -1703,7 +1638,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
return () => {
objectRef.current = null;
};
}, [findMessageElement, historyEntries.length, historyVirtualizer, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, shouldVirtualizeHistory, trailingStreamingEntry, turnIndexMap, ref]);
}, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, shouldVirtualizeHistory, trailingStreamingEntry, turnIndexMap, ref]);
const disableFadeIn = false;
@@ -1714,10 +1649,12 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
<StaticHistoryList
entries={historyEntries}
shouldVirtualize={shouldVirtualizeHistory}
virtualRows={historyVirtualRows}
totalSize={historyVirtualizer.getTotalSize()}
measureElement={historyVirtualizer.measureElement}
contentRef={historyContentRef}
scrollRef={scrollRef}
virtualizerRef={setHistoryVirtualizer}
virtualizerKey={sessionKey}
virtualCache={virtualCache}
shift={isLoadingOlder || disableStaging}
onMessageContentChange={stableHistoryContentChange}
getAnimationHandlers={stableGetAnimationHandlers}
scrollToBottom={stableScrollToBottom}
@@ -2,13 +2,9 @@ import React from 'react';
import type { ChatMessageEntry } from '../lib/turns/types';
import type { MessageListHandle } from '../MessageList';
import { TURN_WINDOW_DEFAULTS } from '../lib/turns/constants';
import {
buildTurnWindowModel,
clampTurnStart,
getInitialTurnStart,
updateTurnWindowModelIncremental,
windowMessagesByTurn,
type TurnWindowModel,
} from '../lib/turns/windowTurns';
import type { TurnHistorySignals } from '../lib/turns/historySignals';
@@ -152,20 +148,17 @@ export const useChatTimelineController = ({
return nextModel;
}, [messages, sessionId]);
const [turnStart, setTurnStart] = React.useState(() => getInitialTurnStart(turnWindowModel.turnCount));
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
const [pendingRevealWork, setPendingRevealWork] = React.useState(false);
const [activeTurnId, setActiveTurnId] = React.useState<string | null>(null);
const turnModelRef = React.useRef(turnWindowModel);
const turnStartRef = React.useRef(turnStart);
const isPinnedRef = React.useRef(isPinned);
const isLoadingOlderRef = React.useRef(isLoadingOlder);
const pendingRevealWorkRef = React.useRef(pendingRevealWork);
const sessionIdRef = React.useRef<string | null>(sessionId);
const messagesRef = React.useRef(messages);
const historyMetaRef = React.useRef<SessionHistoryMeta | null>(historyMeta);
const previousTurnCountRef = React.useRef(turnWindowModel.turnCount);
const initializedSessionRef = React.useRef<string | null>(null);
const pendingRenderResolversRef = React.useRef<Array<() => void>>([]);
const pendingScrollRequestRef = React.useRef<PendingScrollRequest | null>(null);
@@ -174,7 +167,7 @@ export const useChatTimelineController = ({
const historySignals = React.useMemo(() => {
const defaultLimit = getMemoryLimits().HISTORICAL_MESSAGES;
const hasBufferedTurns = turnStart > 0;
const hasBufferedTurns = false;
const hasMoreAboveTurns = historyMeta
? !historyMeta.complete
: messages.length >= defaultLimit;
@@ -183,14 +176,13 @@ export const useChatTimelineController = ({
hasBufferedTurns,
hasMoreAboveTurns,
historyLoading,
canLoadEarlier: hasBufferedTurns || hasMoreAboveTurns,
canLoadEarlier: hasMoreAboveTurns,
};
}, [historyMeta, messages.length, turnStart]);
}, [historyMeta, messages.length]);
const historySignalsRef = React.useRef(historySignals);
turnModelRef.current = turnWindowModel;
turnStartRef.current = turnStart;
isPinnedRef.current = isPinned;
isLoadingOlderRef.current = isLoadingOlder;
pendingRevealWorkRef.current = pendingRevealWork;
@@ -232,41 +224,10 @@ export const useChatTimelineController = ({
}
historyInteractionRef.current = false;
initializedSessionRef.current = sessionId;
setTurnStart(getInitialTurnStart(turnWindowModel.turnCount));
setIsLoadingOlder(false);
setPendingRevealWork(false);
setActiveTurnId(null);
previousTurnCountRef.current = turnWindowModel.turnCount;
}, [sessionId, turnWindowModel.turnCount]);
React.useLayoutEffect(() => {
setTurnStart((current) => clampTurnStart(current, turnWindowModel.turnCount));
}, [turnWindowModel.turnCount]);
React.useLayoutEffect(() => {
const previousTurnCount = previousTurnCountRef.current;
const nextTurnCount = turnWindowModel.turnCount;
if (previousTurnCount === nextTurnCount) {
return;
}
setTurnStart((current) => {
const previousInitial = getInitialTurnStart(previousTurnCount);
const nextInitial = getInitialTurnStart(nextTurnCount);
if (
!historyInteractionRef.current
&& !isLoadingOlderRef.current
&& !pendingRevealWorkRef.current
&& isPinnedRef.current
&& current === previousInitial
) {
return nextInitial;
}
return clampTurnStart(current, nextTurnCount);
});
previousTurnCountRef.current = nextTurnCount;
}, [turnWindowModel.turnCount]);
}, [sessionId]);
const resolvePendingRenderWaiters = React.useCallback(() => {
const resolvers = pendingRenderResolversRef.current;
@@ -277,12 +238,6 @@ export const useChatTimelineController = ({
resolvers.forEach((resolve) => resolve());
}, []);
const waitForNextRenderCommit = React.useCallback((): Promise<void> => {
return new Promise<void>((resolve) => {
pendingRenderResolversRef.current.push(resolve);
});
}, []);
const waitForNextRenderCommitOrTimeout = React.useCallback((): Promise<void> => {
return new Promise<void>((resolve) => {
if (typeof window === 'undefined') {
@@ -338,7 +293,7 @@ export const useChatTimelineController = ({
? turnModelRef.current.turnIndexById.get(pending.id)
: turnModelRef.current.messageToTurnIndex.get(pending.id);
if (typeof targetIndex === 'number' && targetIndex >= turnStartRef.current) {
if (typeof targetIndex === 'number') {
resolvePendingScrollRequest(false);
}
}, [messageListRef, resolvePendingScrollRequest]);
@@ -354,14 +309,12 @@ export const useChatTimelineController = ({
};
}, [resolvePendingRenderWaiters, resolvePendingScrollRequest]);
const renderedMessages = React.useMemo(() => {
return windowMessagesByTurn(messages, turnWindowModel, turnStart);
}, [messages, turnStart, turnWindowModel]);
const renderedMessages = messages;
React.useLayoutEffect(() => {
resolvePendingRenderWaiters();
attemptPendingScrollRequest();
}, [attemptPendingScrollRequest, renderedMessages, resolvePendingRenderWaiters, turnStart]);
}, [attemptPendingScrollRequest, renderedMessages, resolvePendingRenderWaiters]);
// --- Synchronous scroll compensation for load-more / reveal ---
// fetchOlderHistory and revealBufferedTurns store a snapshot here
@@ -401,35 +354,7 @@ export const useChatTimelineController = ({
}
}, [renderedMessages, scrollRef, restoreViewportAnchor]);
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => {
if (turnStartRef.current <= 0 || pendingRevealWorkRef.current) {
return false;
}
beginHistoryInteraction();
const container = scrollRef.current;
if (container) {
prePrependScrollRef.current = {
height: container.scrollHeight,
top: container.scrollTop,
anchor: captureViewportAnchor(),
};
}
setPendingRevealWork(true);
setTurnStart((current) => {
const next = current - TURN_WINDOW_DEFAULTS.batchTurns;
return next > 0 ? next : 0;
});
try {
await waitForNextRenderCommit();
return true;
} finally {
setPendingRevealWork(false);
settleHistoryInteraction();
}
}, [beginHistoryInteraction, captureViewportAnchor, scrollRef, settleHistoryInteraction, waitForNextRenderCommit]);
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => false, []);
const fetchOlderHistory = React.useCallback(async (input: {
preserveViewport: boolean;
@@ -518,15 +443,11 @@ export const useChatTimelineController = ({
}
try {
if (await revealBufferedTurns()) {
return;
}
void (await fetchOlderHistory({ preserveViewport: true }));
} finally {
settleHistoryInteraction();
}
}, [beginHistoryInteraction, fetchOlderHistory, releaseAutoFollow, revealBufferedTurns, settleHistoryInteraction]);
}, [beginHistoryInteraction, fetchOlderHistory, releaseAutoFollow, settleHistoryInteraction]);
const handleHistoryScroll = React.useCallback(() => {
const container = scrollRef.current;
@@ -636,10 +557,6 @@ export const useChatTimelineController = ({
return false;
}
if (turnIndex < turnStartRef.current) {
setTurnStart(turnIndex);
}
const result = await new Promise<boolean>((resolve) => {
pendingScrollRequestRef.current = {
sessionId: sessionIdRef.current ?? sessionId ?? '',
@@ -685,10 +602,6 @@ export const useChatTimelineController = ({
return false;
}
if (turnIndex < turnStartRef.current) {
setTurnStart(turnIndex);
}
const result = await new Promise<boolean>((resolve) => {
pendingScrollRequestRef.current = {
sessionId: sessionIdRef.current ?? sessionId ?? '',
@@ -712,32 +625,16 @@ export const useChatTimelineController = ({
}, [attemptPendingScrollRequest, releaseAutoFollow, sessionId]);
const resumeToBottom = React.useCallback(async () => {
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
setPendingRevealWork(false);
setIsLoadingOlder(false);
const shouldWaitForRender = nextStart !== turnStartRef.current;
if (shouldWaitForRender) {
setTurnStart(nextStart);
await waitForNextRenderCommit();
}
goToBottom('smooth');
}, [goToBottom, waitForNextRenderCommit]);
}, [goToBottom]);
const resumeToBottomInstant = React.useCallback(async () => {
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
setPendingRevealWork(false);
setIsLoadingOlder(false);
const shouldWaitForRender = nextStart !== turnStartRef.current;
if (shouldWaitForRender) {
setTurnStart(nextStart);
await waitForNextRenderCommit();
}
goToBottom('instant');
}, [goToBottom, waitForNextRenderCommit]);
}, [goToBottom]);
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
setActiveTurnId(turnId);
@@ -745,7 +642,7 @@ export const useChatTimelineController = ({
return {
turnIds: turnWindowModel.turnIds,
turnStart,
turnStart: 0,
renderedMessages,
historySignals,
isLoadingOlder,
@@ -2,10 +2,4 @@ export const ACTIVITY_STANDALONE_TOOL_NAMES = new Set<string>(['task']);
export const HIDDEN_INTERNAL_TOOL_NAMES = new Set<string>(['todowrite', 'todoread']);
export const TURN_WINDOW_DEFAULTS = {
initialTurns: 7,
batchTurns: 7,
prefetchBuffer: 16,
} as const;
export const TURN_TEXT_THROTTLE_DEFAULT_MS = 100;
@@ -1,5 +1,4 @@
import type { ChatMessageEntry } from './types';
import { TURN_WINDOW_DEFAULTS } from './constants';
const resolveMessageRole = (message: ChatMessageEntry): string => {
const role = (message.info as { clientRole?: string | null; role?: string | null }).clientRole ?? message.info.role;
@@ -203,46 +202,3 @@ export const buildTurnWindowModel = (messages: ChatMessageEntry[]): TurnWindowMo
turnCount: turnIds.length,
};
};
export const getInitialTurnStart = (
turnCount: number,
initialTurns = TURN_WINDOW_DEFAULTS.initialTurns,
): number => {
if (turnCount <= 0) {
return 0;
}
return turnCount > initialTurns ? turnCount - initialTurns : 0;
};
export const clampTurnStart = (turnStart: number, turnCount: number): number => {
if (turnCount <= 0) {
return 0;
}
if (turnStart <= 0) {
return 0;
}
return Math.min(turnStart, turnCount - 1);
};
export const getTurnWindowSliceStart = (
model: Pick<TurnWindowModel, 'turnMessageStartIndexes'>,
turnStart: number,
): number => {
if (turnStart <= 0) {
return 0;
}
const from = model.turnMessageStartIndexes[turnStart];
return typeof from === 'number' ? from : 0;
};
export const windowMessagesByTurn = (
messages: ChatMessageEntry[],
model: Pick<TurnWindowModel, 'turnMessageStartIndexes'>,
turnStart: number,
): ChatMessageEntry[] => {
const sliceStart = getTurnWindowSliceStart(model, turnStart);
if (sliceStart <= 0) {
return messages;
}
return messages.slice(sliceStart);
};
@@ -2028,6 +2028,7 @@ const AssistantMessageBody = React.memo(({
content={errorMessage ?? ''}
onShowPopup={onShowPopup}
className="[&_.markdown-content>*:first-child]:mt-0 [&_.markdown-content>*:last-child]:mb-0"
enableFileReferences={false}
/>
</div>
</div>
@@ -972,6 +972,7 @@ const MermaidPreviewDialog: React.FC<{
allowMermaidWheelZoom
className="markdown-mermaid-fullscreen h-full [&_[data-markdown='mermaid-block']_button]:hidden"
mermaidControls={MERMAID_CONTROLS}
enableFileReferences={false}
/>
</div>
)}
@@ -1,6 +1,5 @@
import React from 'react';
import type { AnimationPlaybackControls } from 'motion';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { PatchDiff } from '@pierre/diffs/react';
import { cn } from '@/lib/utils';
@@ -201,29 +200,59 @@ const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> =
return <>{formatDuration(start, end, now)}</>;
};
const EXPANDED_CONTENT_TRANSITION_MS = 0;
const deferredToolBodyMounts: Array<{ active: boolean; fn: () => void }> = [];
let deferredToolBodyFrame: number | undefined;
const useAnimatedExpandedContent = (isExpanded: boolean) => {
const [shouldRender, setShouldRender] = React.useState(isExpanded);
const flushDeferredToolBodyMounts = () => {
while (deferredToolBodyMounts.length > 0) {
const item = deferredToolBodyMounts.pop();
if (!item) {
break;
}
if (item.active) {
item.fn();
deferredToolBodyFrame = deferredToolBodyMounts.length > 0
? window.requestAnimationFrame(flushDeferredToolBodyMounts)
: undefined;
return;
}
}
deferredToolBodyFrame = undefined;
};
const scheduleDeferredToolBodyMount = (fn: () => void) => {
if (typeof window === 'undefined') {
fn();
return () => undefined;
}
const item = { active: true, fn };
deferredToolBodyMounts.push(item);
if (deferredToolBodyFrame === undefined) {
deferredToolBodyFrame = window.requestAnimationFrame(() => {
deferredToolBodyFrame = window.requestAnimationFrame(flushDeferredToolBodyMounts);
});
}
return () => {
item.active = false;
};
};
const useDeferredExpandedContent = (isExpanded: boolean) => {
const [shouldRender, setShouldRender] = React.useState(false);
React.useEffect(() => {
if (typeof window === 'undefined') {
setShouldRender(isExpanded);
return;
}
if (isExpanded) {
setShouldRender(true);
return;
}
const timer = window.setTimeout(() => {
if (!isExpanded) {
setShouldRender(false);
}, EXPANDED_CONTENT_TRANSITION_MS);
return;
}
return () => {
window.clearTimeout(timer);
};
return scheduleDeferredToolBodyMount(() => {
setShouldRender(true);
});
}, [isExpanded]);
return shouldRender;
@@ -1573,6 +1602,7 @@ interface ToolExpandedContentProps {
state: ToolStateUnion;
syntaxTheme: { [key: string]: React.CSSProperties };
currentDirectory: string;
isExpanded: boolean;
onShowPopup?: (content: ToolPopupContent) => void;
}
@@ -1581,6 +1611,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
state,
syntaxTheme,
currentDirectory,
isExpanded,
onShowPopup,
}) => {
const { t } = useI18n();
@@ -1873,7 +1904,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
</blockquote>
),
{
maxHeightClass: 'max-h-60',
maxHeightClass: isWriteLikeTool && writeLikeInputPatch && isExpanded ? 'max-h-[50vh]' : 'max-h-60',
className: part.tool === 'bash' ? 'tool-input-surface p-0 rounded-none' : 'tool-input-surface',
}
)}
@@ -1979,8 +2010,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
const onContentChangeRef = React.useRef(onContentChange);
onContentChangeRef.current = onContentChange;
const expandedContentRef = React.useRef<HTMLDivElement>(null);
const expandedContentAnimationRef = React.useRef<AnimationPlaybackControls | null>(null);
const expandedContentMountedRef = React.useRef(false);
React.useLayoutEffect(() => {
if (isTaskTool) {
@@ -1992,9 +2021,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
return;
}
expandedContentMountedRef.current = true;
expandedContentAnimationRef.current?.stop();
expandedContentAnimationRef.current = null;
element.style.height = isExpanded ? 'auto' : '0px';
element.style.overflow = isExpanded ? 'visible' : 'hidden';
@@ -2003,13 +2029,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
}
}, [isExpanded, isTaskTool, shouldNotifyStructuralChange]);
React.useEffect(() => {
return () => {
expandedContentAnimationRef.current?.stop();
expandedContentAnimationRef.current = null;
};
}, []);
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const partMetadata = (part as unknown as { metadata?: unknown }).metadata;
@@ -2662,7 +2681,8 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
const iconStyle = !isTaskTool && isError ? TOOL_ERROR_ICON_STYLE : TOOL_NORMAL_ICON_STYLE;
const titleStyle = !isTaskTool && isError ? TOOL_ERROR_TITLE_STYLE : TOOL_NORMAL_TITLE_STYLE;
const shouldRenderExpandedContent = useAnimatedExpandedContent(isExpanded);
const shouldRenderTaskSummary = useDeferredExpandedContent(isTaskTool && (taskSummaryEntries.length > 0 || isActive || shouldTreatAsFinalized || !!taskSessionId));
const shouldRenderExpandedContent = useDeferredExpandedContent(!isTaskTool && isExpanded);
if (!shouldTreatAsFinalized && !isActive && !isTaskTool) {
return null;
@@ -2795,7 +2815,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
</div>
{}
{isTaskTool && (taskSummaryEntries.length > 0 || isActive || shouldTreatAsFinalized || taskSessionId) ? (
{shouldRenderTaskSummary ? (
<TaskToolSummary
entries={taskSummaryEntries}
isExpanded={isExpanded}
@@ -2822,10 +2842,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
{shouldRenderExpandedContent ? (
<div
className="relative ml-2 pl-3"
style={{
opacity: isExpanded ? 1 : 0,
transform: isExpanded ? 'translateY(0)' : 'translateY(-4px)',
}}
>
<span
aria-hidden="true"
@@ -2837,6 +2853,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
state={state}
syntaxTheme={syntaxTheme}
currentDirectory={currentDirectory}
isExpanded={isExpanded}
onShowPopup={onShowPopup}
/>
</div>
@@ -272,7 +272,8 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
"[&_[data-component='markdown-code']_code]:inline",
]
)}
disableLinkSafety
disableLinkSafety
enableFileReferences={false}
/>
) : (
plainTextContent
@@ -3,14 +3,14 @@
*
* Replaces per-line <SyntaxHighlighter> with:
* 1. ONE Prism.highlight() call to tokenize all code at once
* 2. @tanstack/react-virtual to only render visible rows
* 2. virtua to only render visible rows
*
* This drops mount cost from O(N * Prism) to O(1 * Prism) + O(visible_rows).
* For a 2000-line file, ~2000 SyntaxHighlighter instances ~30 plain <div>s.
*/
import React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Virtualizer } from 'virtua';
import Prism from 'prismjs';
// Ensure common languages are loaded (react-syntax-highlighter lazy-loads them,
@@ -244,52 +244,31 @@ const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
lineStyles,
}) => {
const parentRef = React.useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: lines.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 20, // render 20 extra rows above/below viewport
});
const viewportHeight = `min(${lines.length * ROW_HEIGHT}px, ${maxHeight})`;
return (
<div
ref={parentRef}
className="typography-code font-mono w-full min-w-0 oc-virtualized-prism"
style={{ maxHeight, overflow: 'auto' }}
style={{ height: viewportHeight, maxHeight, overflow: 'auto' }}
>
{prismThemeCss ? <style>{prismThemeCss}</style> : null}
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
<Virtualizer
data={lines}
itemSize={ROW_HEIGHT}
bufferSize={ROW_HEIGHT * 20}
scrollRef={parentRef}
>
{virtualizer.getVirtualItems().map((vItem) => {
const line = lines[vItem.index];
return (
<div
key={vItem.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${vItem.size}px`,
transform: `translateY(${vItem.start}px)`,
}}
>
<Row
line={line}
language={language}
showLineNumbers={showLineNumbers}
style={lineStyles?.(line)}
/>
</div>
);
})}
</div>
{(line, index) => (
<Row
key={index}
line={line}
language={language}
showLineNumbers={showLineNumbers}
style={lineStyles?.(line)}
/>
)}
</Virtualizer>
</div>
);
});