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:
committed by
GitHub
parent
e372c8d8cb
commit
a45376d585
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user