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>
);
});
@@ -608,6 +608,7 @@ const SkillsInstalledPage: React.FC = () => {
content={skillMarkdown}
className="typography-markdown-body"
stripFrontmatter
enableFileReferences={false}
/>
</div>
</ScrollableOverlay>
@@ -1,12 +1,12 @@
import React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Virtualizer } from 'virtua';
import type { Session } from '@opencode-ai/sdk/v2';
// Archived buckets routinely grow into the hundreds/thousands; virtualize
// when we cross this row count so the DOM stays bounded.
const ARCHIVED_VIRTUALIZE_THRESHOLD = 50;
// Compact rows in the archived bucket without nested subagents render
// around 24-32px; tanstack-virtual will measure precisely via the row ref.
// around 24-32px; virtua measures mounted rows and uses this as the initial hint.
const ARCHIVED_ROW_ESTIMATE_PX = 28;
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
@@ -274,9 +274,10 @@ export function SessionGroupSection(props: Props): React.ReactNode {
&& visibleSessions.length >= ARCHIVED_VIRTUALIZE_THRESHOLD;
const archivedVirtualContainerRef = React.useRef<HTMLDivElement | null>(null);
const archivedScrollRef = React.useRef<HTMLElement | null>(null);
const [archivedScrollEl, setArchivedScrollEl] = React.useState<HTMLElement | null>(null);
// Offset of the virtual container from the scroll element's content origin.
// tanstack-virtual reads scrollMargin from useVirtualizer options and uses it
// virtua reads startMargin from Virtualizer options and uses it
// to translate scrollTop into container-relative coordinates. Without this,
// when the scroll element is an ancestor (the sidebar's ScrollableOverlay),
// the virtualizer assumes the container starts at the top of the scroll
@@ -287,15 +288,6 @@ export function SessionGroupSection(props: Props): React.ReactNode {
// routes its scroll through `ScrollableOverlay` higher up the tree;
// threading a ref through every intermediate component would be invasive
// for this single use case.
const archivedVirtualizer = useVirtualizer({
count: visibleSessions.length,
getScrollElement: () => archivedScrollEl,
estimateSize: () => ARCHIVED_ROW_ESTIMATE_PX,
overscan: 8,
enabled: shouldVirtualizeArchived && archivedScrollEl !== null,
scrollMargin: archivedScrollMargin,
});
// Resolve the scrolling ancestor and measure the virtual container's offset
// from its content origin, both on every render. The container ref is null
// while the archived bucket is collapsed (the body isn't mounted), so a
@@ -309,6 +301,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
React.useLayoutEffect(() => {
if (!shouldVirtualizeArchived) {
if (archivedScrollEl !== null) setArchivedScrollEl(null);
archivedScrollRef.current = null;
if (archivedScrollMargin !== 0) setArchivedScrollMargin(0);
return;
}
@@ -332,6 +325,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
el = el.parentElement;
}
if (scrollEl !== archivedScrollEl) {
archivedScrollRef.current = scrollEl;
setArchivedScrollEl(scrollEl);
// setState triggers a re-render; bail out and let the next pass
// measure the margin against the fresh element.
@@ -345,29 +339,6 @@ export function SessionGroupSection(props: Props): React.ReactNode {
setArchivedScrollMargin((prev) => (Math.abs(prev - offset) < 1 ? prev : offset));
});
// Re-measure when the sidebar's scroll container resizes (e.g. window
// resize, sidebar width change). Mirrors the pattern in ChangesSection.
React.useEffect(() => {
if (!shouldVirtualizeArchived || !archivedScrollEl) return;
const observer = new ResizeObserver(() => {
archivedVirtualizer.measure();
});
observer.observe(archivedScrollEl);
return () => observer.disconnect();
}, [shouldVirtualizeArchived, archivedScrollEl, archivedVirtualizer]);
const archivedTotalSize = archivedVirtualizer.getTotalSize();
// Read virtual rows directly in render rather than via useMemo. The
// virtualizer instance is a stable reference across renders, and on a
// pure scroll event neither it nor `archivedTotalSize` change — only the
// virtualizer's internal scroll offset does. Memoizing here would return
// stale rows after every scroll. tanstack-virtual v3 expects callers to
// read getVirtualItems() inline; it's cheap and returns [] when the
// virtualizer is disabled.
const archivedVirtualRows = shouldVirtualizeArchived && archivedScrollEl !== null
? archivedVirtualizer.getVirtualItems()
: [];
if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) {
return null;
}
@@ -576,41 +547,16 @@ export function SessionGroupSection(props: Props): React.ReactNode {
>
{renderFolderItems()}
{shouldVirtualizeArchived ? (
<div
ref={archivedVirtualContainerRef}
style={{
position: 'relative',
// Reserve scroll height for all archived rows so the parent
// scroll thumb reflects the full list. Individual rows are
// absolutely positioned by translateY.
height: archivedTotalSize > 0 ? archivedTotalSize : undefined,
width: '100%',
}}
>
{archivedVirtualRows.map((virtualRow) => {
const node = visibleSessions[virtualRow.index];
if (!node) return null;
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={archivedVirtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
// virtualRow.start is in scroll-element coordinates (offset by
// scrollMargin). Subtract scrollMargin to position within the
// container, which itself starts at scrollMargin in the scroll
// element.
transform: `translateY(${virtualRow.start - archivedScrollMargin}px)`,
}}
>
{renderSessionNode(node, 0, group.directory, projectId, true)}
</div>
);
})}
<div ref={archivedVirtualContainerRef}>
<Virtualizer
data={visibleSessions}
itemSize={ARCHIVED_ROW_ESTIMATE_PX}
bufferSize={ARCHIVED_ROW_ESTIMATE_PX * 8}
scrollRef={archivedScrollRef}
startMargin={archivedScrollMargin}
>
{(node) => renderSessionNode(node, 0, group.directory, projectId, true) as React.ReactElement}
</Virtualizer>
</div>
) : (
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true))
@@ -1,5 +1,5 @@
import React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Virtualizer } from 'virtua';
import {
parseJsonToTree,
@@ -204,14 +204,6 @@ const JsonTreeViewer = React.forwardRef<{ expandAll: () => void; collapseAll: ()
const shouldVirtualize = flatNodes.length > VIRTUALIZE_THRESHOLD;
const parentRef = React.useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: flatNodes.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 20,
enabled: shouldVirtualize,
});
const handleToggle = React.useCallback((id: string) => {
setCollapsedPaths((prev) => {
const next = new Set(prev);
@@ -246,37 +238,21 @@ const JsonTreeViewer = React.forwardRef<{ expandAll: () => void; collapseAll: ()
className={className}
style={{ maxHeight, overflow: 'auto' }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
<Virtualizer
data={flatNodes}
itemSize={ROW_HEIGHT}
bufferSize={ROW_HEIGHT * 20}
scrollRef={parentRef}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const flatNode = flatNodes[virtualRow.index];
if (!flatNode) return null;
return (
<div
key={flatNode.node.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<JsonRow
flatNode={flatNode}
onToggle={handleToggle}
onCopyPath={onCopyPath}
/>
</div>
);
})}
</div>
{(flatNode) => (
<JsonRow
key={flatNode.node.id}
flatNode={flatNode}
onToggle={handleToggle}
onCopyPath={onCopyPath}
/>
)}
</Virtualizer>
</div>
);
}
@@ -369,7 +369,7 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
}
}}
>
<SimpleMarkdownRenderer content={changelog.content} disableLinkSafety={true} />
<SimpleMarkdownRenderer content={changelog.content} disableLinkSafety={true} enableFileReferences={false} />
</div>
) : (
<div className="divide-y divide-[var(--surface-subtle)]">
@@ -395,7 +395,7 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
}
}}
>
<SimpleMarkdownRenderer content={section.content} disableLinkSafety={true} />
<SimpleMarkdownRenderer content={section.content} disableLinkSafety={true} enableFileReferences={false} />
</div>
</div>
))}
+12 -10
View File
@@ -3789,11 +3789,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</div>
}
>
<SimpleMarkdownRenderer
content={fileContent}
className="typography-markdown-body"
stripFrontmatter
/>
<SimpleMarkdownRenderer
content={fileContent}
className="typography-markdown-body"
stripFrontmatter
enableFileReferences={false}
/>
</ErrorBoundary>
</div>
) : selectedFile && isHtml && htmlViewMode === 'preview' ? (
@@ -4129,11 +4130,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</div>
}
>
<SimpleMarkdownRenderer
content={fileContent}
className="typography-markdown-body"
stripFrontmatter
/>
<SimpleMarkdownRenderer
content={fileContent}
className="typography-markdown-body"
stripFrontmatter
enableFileReferences={false}
/>
</ErrorBoundary>
</div>
) : canUseShikiFileView && textViewMode === 'view' ? (
@@ -787,7 +787,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
</div>
}
>
<SimpleMarkdownRenderer content={content} className="typography-markdown-body" />
<SimpleMarkdownRenderer content={content} className="typography-markdown-body" enableFileReferences={false} />
</ErrorBoundary>
</div>
) : (
@@ -1,5 +1,5 @@
import React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Virtualizer, type VirtualizerHandle } from 'virtua';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -195,30 +195,16 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
const rowCount = rows.length;
const shouldVirtualize = rowCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD;
const rowVirtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollRef.current,
estimateSize: () => CHANGE_ROW_ESTIMATE_PX,
overscan: 12,
enabled: shouldVirtualize,
});
const rowVirtualizerRef = React.useRef<VirtualizerHandle | null>(null);
const [visibleStartIndex, setVisibleStartIndex] = React.useState(0);
// Remeasure when the container transitions from display:none (hidden tab) back
// to visible layout, otherwise stale zero-height measurements render no rows.
React.useEffect(() => {
if (!shouldVirtualize) return;
const el = scrollRef.current;
if (!el) return;
const observer = new ResizeObserver(() => rowVirtualizer.measure());
observer.observe(el);
return () => observer.disconnect();
}, [shouldVirtualize, rowVirtualizer]);
const totalSize = rowVirtualizer.getTotalSize();
const virtualRows = React.useMemo(
() => (shouldVirtualize && totalSize >= 0 ? rowVirtualizer.getVirtualItems() : []),
[shouldVirtualize, rowVirtualizer, totalSize]
);
const updateVisibleStartIndex = React.useCallback((offset: number) => {
const virtualizer = rowVirtualizerRef.current;
const next = virtualizer
? virtualizer.findItemIndex(offset)
: Math.floor(offset / CHANGE_ROW_ESTIMATE_PX);
setVisibleStartIndex((previous) => (previous === next ? previous : next));
}, []);
React.useEffect(() => {
if (!onVisiblePathsChange) {
@@ -246,11 +232,16 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
}
onVisiblePathsChange(
virtualRows
.map((item) => collectFromRow(rows[item.index]))
rows
.slice(
visibleStartIndex,
visibleStartIndex + Math.ceil((scrollRef.current?.clientHeight ?? 0) / CHANGE_ROW_ESTIMATE_PX) + VISIBLE_PREFETCH_LIMIT
)
.map((row) => collectFromRow(row))
.filter((value): value is string => Boolean(value))
.slice(0, VISIBLE_PREFETCH_LIMIT)
);
}, [onVisiblePathsChange, rowCount, rows, shouldVirtualize, virtualRows]);
}, [onVisiblePathsChange, rowCount, rows, shouldVirtualize, visibleStartIndex]);
const toggleGroupCollapsed = React.useCallback((groupId: string) => {
setCollapsedGroups((previous) => {
@@ -490,27 +481,27 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
className="overlay-scrollbar-target overlay-scrollbar-container min-h-0 w-full flex-1 overflow-x-hidden overflow-y-auto"
>
{shouldVirtualize ? (
<div className="relative w-full" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
{virtualRows.map((item) => {
const row = rows[item.index];
if (!row) return null;
return (
<div
key={row.key}
ref={rowVirtualizer.measureElement}
data-index={item.index}
className={cn(
'absolute left-0 top-0 w-full',
showDivider(item.index) &&
'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
)}
style={{ transform: `translateY(${item.start}px)` }}
>
{renderRow(row, item.index === 0)}
</div>
);
})}
</div>
<Virtualizer
ref={rowVirtualizerRef}
data={rows}
itemSize={CHANGE_ROW_ESTIMATE_PX}
bufferSize={CHANGE_ROW_ESTIMATE_PX * 12}
scrollRef={scrollRef}
onScroll={updateVisibleStartIndex}
>
{(row, index) => (
<div
key={row.key}
className={cn(
'relative',
showDivider(index) &&
'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
)}
>
{renderRow(row, index === 0)}
</div>
)}
</Virtualizer>
) : (
<div role="list" aria-label={t('gitView.changes.changedFilesAria')}>
{rows.map((row, index) => (
@@ -1527,6 +1527,7 @@ export const PullRequestSection: React.FC<{
<SimpleMarkdownRenderer
content={pr.body}
className="typography-markdown-body text-muted-foreground break-words mt-1"
enableFileReferences={false}
/>
) : (
<div className="typography-micro text-muted-foreground whitespace-pre-wrap break-words mt-1">
@@ -2031,6 +2032,7 @@ export const PullRequestSection: React.FC<{
'typography-markdown-body text-foreground break-words [&_a]:no-underline [&_a:hover]:no-underline',
selfMentionHighlightClass,
].filter(Boolean).join(' ')}
enableFileReferences={false}
/>
</div>
</div>