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
@@ -493,7 +493,7 @@ const MobileTextFile: React.FC<{ path: string; content: string }> = ({ path, con
|
||||
if (isMarkdownFile(path)) {
|
||||
return (
|
||||
<ScrollShadow className="h-full overflow-y-auto px-4 py-4">
|
||||
<SimpleMarkdownRenderer content={content} />
|
||||
<SimpleMarkdownRenderer content={content} enableFileReferences={false} />
|
||||
</ScrollShadow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user