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
+3 -5
View File
@@ -149,7 +149,6 @@
"@pierre/diffs": "1.3.0-beta.4", "@pierre/diffs": "1.3.0-beta.4",
"@replit/codemirror-vim": "^6.3.0", "@replit/codemirror-vim": "^6.3.0",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@tanstack/react-virtual": "^3.13.18",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@xenova/transformers": "^2.17.2", "@xenova/transformers": "^2.17.2",
"@zumer/snapdom": "^2.12.0", "@zumer/snapdom": "^2.12.0",
@@ -183,6 +182,7 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"strip-json-comments": "^5.0.3", "strip-json-comments": "^5.0.3",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"virtua": "0.49.1",
"yaml": "^2.8.1", "yaml": "^2.8.1",
"zod": "^4.3.6", "zod": "^4.3.6",
"zustand": "^5.0.8", "zustand": "^5.0.8",
@@ -1247,10 +1247,6 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="], "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.19", "", { "dependencies": { "@tanstack/virtual-core": "3.13.19" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-KzwmU1IbE0IvCZSm6OXkS+kRdrgW2c2P3Ho3NC+zZXWK6oObv/L+lcV/2VuJ+snVESRlMJ+w/fg4WXI/JzoNGQ=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.19", "", {}, "sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g=="],
"@textlint/ast-node-types": ["@textlint/ast-node-types@15.5.2", "", {}, "sha512-fCaOxoup5LIyBEo7R1oYWE7V4bSX0KQeHh66twon9e9usaLE3ijgF8QjYsR6joCssdeCHVd0wHm7ppsEyTr6vg=="], "@textlint/ast-node-types": ["@textlint/ast-node-types@15.5.2", "", {}, "sha512-fCaOxoup5LIyBEo7R1oYWE7V4bSX0KQeHh66twon9e9usaLE3ijgF8QjYsR6joCssdeCHVd0wHm7ppsEyTr6vg=="],
"@textlint/linter-formatter": ["@textlint/linter-formatter@15.5.2", "", { "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", "@textlint/module-interop": "15.5.2", "@textlint/resolver": "15.5.2", "@textlint/types": "15.5.2", "chalk": "^4.1.2", "debug": "^4.4.3", "js-yaml": "^4.1.1", "lodash": "^4.17.23", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "table": "^6.9.0", "text-table": "^0.2.0" } }, "sha512-jAw7jWM8+wU9cG6Uu31jGyD1B+PAVePCvnPKC/oov+2iBPKk3ao30zc/Itmi7FvXo4oPaL9PmzPPQhyniPVgVg=="], "@textlint/linter-formatter": ["@textlint/linter-formatter@15.5.2", "", { "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", "@textlint/module-interop": "15.5.2", "@textlint/resolver": "15.5.2", "@textlint/types": "15.5.2", "chalk": "^4.1.2", "debug": "^4.4.3", "js-yaml": "^4.1.1", "lodash": "^4.17.23", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "table": "^6.9.0", "text-table": "^0.2.0" } }, "sha512-jAw7jWM8+wU9cG6Uu31jGyD1B+PAVePCvnPKC/oov+2iBPKk3ao30zc/Itmi7FvXo4oPaL9PmzPPQhyniPVgVg=="],
@@ -3223,6 +3219,8 @@
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"virtua": ["virtua@0.49.1", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vite-plugin-pwa": ["vite-plugin-pwa@1.2.0", "", { "dependencies": { "debug": "^4.3.6", "pretty-bytes": "^6.1.1", "tinyglobby": "^0.2.10", "workbox-build": "^7.4.0", "workbox-window": "^7.4.0" }, "peerDependencies": { "@vite-pwa/assets-generator": "^1.0.0", "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@vite-pwa/assets-generator"] }, "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw=="], "vite-plugin-pwa": ["vite-plugin-pwa@1.2.0", "", { "dependencies": { "debug": "^4.3.6", "pretty-bytes": "^6.1.1", "tinyglobby": "^0.2.10", "workbox-build": "^7.4.0", "workbox-window": "^7.4.0" }, "peerDependencies": { "@vite-pwa/assets-generator": "^1.0.0", "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@vite-pwa/assets-generator"] }, "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw=="],
+1 -1
View File
@@ -44,7 +44,6 @@
"@pierre/diffs": "1.3.0-beta.4", "@pierre/diffs": "1.3.0-beta.4",
"@replit/codemirror-vim": "^6.3.0", "@replit/codemirror-vim": "^6.3.0",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@tanstack/react-virtual": "^3.13.18",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@xenova/transformers": "^2.17.2", "@xenova/transformers": "^2.17.2",
"@zumer/snapdom": "^2.12.0", "@zumer/snapdom": "^2.12.0",
@@ -78,6 +77,7 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"strip-json-comments": "^5.0.3", "strip-json-comments": "^5.0.3",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"virtua": "0.49.1",
"yaml": "^2.8.1", "yaml": "^2.8.1",
"zod": "^4.3.6", "zod": "^4.3.6",
"zustand": "^5.0.8" "zustand": "^5.0.8"
+1 -1
View File
@@ -493,7 +493,7 @@ const MobileTextFile: React.FC<{ path: string; content: string }> = ({ path, con
if (isMarkdownFile(path)) { if (isMarkdownFile(path)) {
return ( return (
<ScrollShadow className="h-full overflow-y-auto px-4 py-4"> <ScrollShadow className="h-full overflow-y-auto px-4 py-4">
<SimpleMarkdownRenderer content={content} /> <SimpleMarkdownRenderer content={content} enableFileReferences={false} />
</ScrollShadow> </ScrollShadow>
); );
} }
@@ -2,7 +2,7 @@ import React from 'react';
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid'; import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
import ReactMarkdown from 'react-markdown'; 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 remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math'; import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex'; import rehypeKatex from 'rehype-katex';
@@ -216,6 +216,10 @@ const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | nul
const menuRef = React.useRef<HTMLDivElement>(null); const menuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => { React.useEffect(() => {
if (!showMenu) {
return;
}
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) { if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setShowMenu(false); setShowMenu(false);
@@ -223,7 +227,7 @@ const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | nul
}; };
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, [showMenu]);
const handleCopy = async (format: 'csv' | 'tsv' | 'markdown') => { const handleCopy = async (format: 'csv' | 'tsv' | 'markdown') => {
const tableEl = tableRef.current?.querySelector('table'); const tableEl = tableRef.current?.querySelector('table');
@@ -308,6 +312,10 @@ const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement |
const menuRef = React.useRef<HTMLDivElement>(null); const menuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => { React.useEffect(() => {
if (!showMenu) {
return;
}
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) { if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setShowMenu(false); setShowMenu(false);
@@ -315,7 +323,7 @@ const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement |
}; };
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, [showMenu]);
const handleDownload = (format: 'csv' | 'markdown') => { const handleDownload = (format: 'csv' | 'markdown') => {
const tableEl = tableRef.current?.querySelector('table'); 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 tableRef = React.useRef<HTMLDivElement>(null);
const { isMobile, isTablet } = useDeviceInfo(); const { isMobile, isTablet } = useDeviceInfo();
const alwaysShowActions = isMobile || isTablet; const alwaysShowActions = isMobile || isTablet;
const [showActions, setShowActions] = React.useState(alwaysShowActions);
React.useEffect(() => {
if (alwaysShowActions) {
setShowActions(true);
}
}, [alwaysShowActions]);
return ( return (
<div className="group my-4 flex flex-col space-y-2" data-markdown="table-wrapper" ref={tableRef}> <div
<div className={cn( className="group my-4 flex flex-col space-y-2"
"flex items-center justify-end gap-1 transition-opacity", data-markdown="table-wrapper"
alwaysShowActions ? "opacity-100" : "opacity-0 group-hover:opacity-100" ref={tableRef}
)}> onMouseEnter={() => setShowActions(true)}
<TableCopyButton tableRef={tableRef} /> onMouseLeave={() => {
<TableDownloadButton tableRef={tableRef} /> if (!alwaysShowActions) {
</div> 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)]"> <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"> <table className={cn('w-full border-collapse text-sm', className)} data-markdown="table">
{children} {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 MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ source, mode }) => {
const { t } = useI18n(); const { t } = useI18n();
const currentTheme = useCurrentMermaidTheme(); const currentTheme = useCurrentMermaidTheme();
const { isMobile, isTablet } = useDeviceInfo(); const { isMobile, isTablet } = useDeviceInfo();
const [copied, setCopied] = React.useState(false); const [copied, setCopied] = React.useState(false);
const [downloaded, setDownloaded] = React.useState(false); const [downloaded, setDownloaded] = React.useState(false);
const [svg, setSvg] = React.useState('');
const [ascii, setAscii] = React.useState('');
const svg = React.useMemo(() => { React.useEffect(() => {
if (mode !== 'svg') return ''; if (typeof window === 'undefined') {
try { try {
return renderMermaidSVG(source, { setSvg(mode === 'svg' ? renderMermaidSVG(source, {
bg: currentTheme.colors.surface.elevated, bg: currentTheme.colors.surface.elevated,
fg: currentTheme.colors.surface.foreground, fg: currentTheme.colors.surface.foreground,
line: currentTheme.colors.interactive.border, line: currentTheme.colors.interactive.border,
accent: currentTheme.colors.primary.base, accent: currentTheme.colors.primary.base,
muted: currentTheme.colors.surface.mutedForeground, muted: currentTheme.colors.surface.mutedForeground,
surface: currentTheme.colors.surface.muted, surface: currentTheme.colors.surface.muted,
border: currentTheme.colors.interactive.border, border: currentTheme.colors.interactive.border,
transparent: true, transparent: true,
font: 'IBM Plex Sans, sans-serif', 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 { }, MERMAID_RENDER_DELAY_MS);
return '';
}
}, [currentTheme, mode, source]);
const ascii = React.useMemo(() => { return () => {
if (mode !== 'ascii') return ''; cancelled = true;
try { window.clearTimeout(timer);
return renderMermaidASCII(source); if (frame !== null) {
} catch { window.cancelAnimationFrame(frame);
return ''; }
} };
}, [mode, source]); }, [currentTheme, mode, source]);
const copyVisibilityClass = isMobile || isTablet ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'; 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'; 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 = { type MarkdownStreamBlock = {
key: string; key: string;
raw: string; raw: string;
@@ -613,14 +687,45 @@ const buildMarkdownCacheKey = (baseKey: string, raw: string, index: number, mode
return `${baseKey}:${index}:${mode}:${raw.length}:${fnv1a32(sample)}`; 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[] => { const streamMarkdownBlocks = (text: string, live: boolean, baseKey: string): MarkdownStreamBlock[] => {
if (!live) { 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'), key: buildMarkdownCacheKey(baseKey, text, 0, 'full'),
raw: text, raw: text,
src: text, src: text,
mode: 'full', mode: 'full',
}]; }];
setMarkdownBlockCacheEntry(cacheKey, blocks);
return blocks;
} }
const healed = healMarkdown(text); const healed = healMarkdown(text);
@@ -742,6 +847,7 @@ const normalizeCodeBlockText = (code: string, language: string): string => {
}; };
const CODE_HIGHLIGHT_SETTLE_MS = 300; const CODE_HIGHLIGHT_SETTLE_MS = 300;
const CODE_HIGHLIGHT_INITIAL_DELAY_MS = 80;
const CODE_HIGHLIGHT_LINE_LIMIT = 1200; const CODE_HIGHLIGHT_LINE_LIMIT = 1200;
const VSCODE_CODE_HIGHLIGHT_LINE_LIMIT = 200; const VSCODE_CODE_HIGHLIGHT_LINE_LIMIT = 200;
const CODE_SHARED_STYLE: React.CSSProperties = { const CODE_SHARED_STYLE: React.CSSProperties = {
@@ -795,23 +901,57 @@ const MarkdownCodeBlock: React.FC<{
syntaxTheme: { [key: string]: React.CSSProperties }; syntaxTheme: { [key: string]: React.CSSProperties };
}> = ({ code, language, syntaxTheme }) => { }> = ({ code, language, syntaxTheme }) => {
const [copied, setCopied] = React.useState(false); 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 [viewMode, setViewMode] = React.useState<'code' | 'preview'>('code');
const prevCodeRef = React.useRef<string>(code); const prevCodeRef = React.useRef<string>(code);
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const { isMobile, isTablet } = useDeviceInfo(); const { isMobile, isTablet } = useDeviceInfo();
const alwaysShowControls = isMobile || isTablet;
const [showControls, setShowControls] = React.useState(alwaysShowControls);
const skipHighlight = exceedsLineLimit(code, getCodeHighlightLineLimit()); const skipHighlight = exceedsLineLimit(code, getCodeHighlightLineLimit());
const canPreview = language === 'html' || language === 'htm'; const canPreview = language === 'html' || language === 'htm';
React.useEffect(() => {
if (alwaysShowControls) {
setShowControls(true);
}
}, [alwaysShowControls]);
React.useEffect(() => { React.useEffect(() => {
if (!canPreview && viewMode !== 'code') { if (!canPreview && viewMode !== 'code') {
setViewMode('code'); setViewMode('code');
} }
}, [canPreview, viewMode]); }, [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. // Defer Prism highlighting while code is actively streaming.
// Initial mount renders highlighted immediately (plays nice with finalized blocks).
React.useEffect(() => { React.useEffect(() => {
if (prevCodeRef.current === code) return; if (prevCodeRef.current === code) return;
prevCodeRef.current = code; prevCodeRef.current = code;
@@ -848,46 +988,57 @@ const MarkdownCodeBlock: React.FC<{
}, [canPreview, code]); }, [canPreview, code]);
return ( 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"> <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> <span className="font-mono text-[13px] text-muted-foreground">{language}</span>
<div className={cn( {showControls ? (
"flex items-center gap-1 transition-opacity", <div className="flex min-h-6 items-center gap-1 transition-opacity">
isMobile || isTablet ? "opacity-100" : "opacity-100 md:opacity-0 md:group-hover:opacity-100" {canPreview ? (
)}> <button
{canPreview ? ( 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 <button
type="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" className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title={viewMode === 'preview' ? 'Show code' : 'Preview'} title={copied ? 'Copied' : 'Copy code'}
aria-pressed={viewMode === 'preview'} aria-label={copied ? 'Copied' : 'Copy code'}
aria-label={viewMode === 'preview' ? 'Show code' : 'Preview HTML'}
> >
{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> </button>
) : null} </div>
{canPreview ? ( ) : (
<button <div className="min-h-6 min-w-6" aria-hidden="true" />
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>
{canPreview && viewMode === 'preview' ? ( {canPreview && viewMode === 'preview' ? (
<div className="h-[320px] md:h-[420px] bg-background"> <div className="h-[320px] md:h-[420px] bg-background">
@@ -1078,7 +1229,7 @@ const MarkdownBlockView: React.FC<{
components: Components; components: Components;
}> = React.memo(({ block, components }) => { }> = React.memo(({ block, components }) => {
return ( 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} {block.src}
</ReactMarkdown> </ReactMarkdown>
); );
@@ -1119,8 +1270,9 @@ const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000;
const FILE_REFERENCE_STAT_CONCURRENCY = 4; const FILE_REFERENCE_STAT_CONCURRENCY = 4;
const FILE_REFERENCE_STAT_CACHE_MAX = 1000; const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200; 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 VSCODE_FILE_REFERENCE_LINK_LIMIT = 40;
const FILE_REFERENCE_ANNOTATION_DELAY_MS = 160;
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>(); const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
let activeFileReferenceStatCount = 0; let activeFileReferenceStatCount = 0;
const pendingFileReferenceStats: Array<() => void> = []; const pendingFileReferenceStats: Array<() => void> = [];
@@ -1462,11 +1614,18 @@ const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
const request = new Promise<boolean>((resolve) => { const request = new Promise<boolean>((resolve) => {
const run = () => { const run = () => {
activeFileReferenceStatCount += 1; activeFileReferenceStatCount += 1;
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}`, { void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
method: 'GET', method: 'GET',
cache: 'no-store', 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)) .catch(() => resolve(false))
.finally(() => { .finally(() => {
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1); activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
@@ -1547,6 +1706,24 @@ const useFileReferenceInteractions = ({
return; 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 = () => { const annotateFileLinks = () => {
if (enabled) { if (enabled) {
wrapBlockCodePathTokens(container); wrapBlockCodePathTokens(container);
@@ -1674,20 +1851,10 @@ const useFileReferenceInteractions = ({
void openFileReference(target); void openFileReference(target);
}; };
annotateFileLinks(); scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
if (annotationDebounceRef.current !== null && typeof window !== 'undefined') { scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
window.clearTimeout(annotationDebounceRef.current);
}
if (typeof window === 'undefined') {
annotateFileLinks();
return;
}
annotationDebounceRef.current = window.setTimeout(() => {
annotationDebounceRef.current = null;
annotateFileLinks();
}, 120);
}); });
observer.observe(container, { observer.observe(container, {
childList: true, childList: true,
+99 -162
View File
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2'; 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 ChatMessage from './ChatMessage';
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare'; import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
@@ -21,10 +21,10 @@ import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata'; import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5; const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5;
const MESSAGE_LIST_OVERSCAN = 6;
const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = []; const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = [];
const EMPTY_UNGROUPED_MESSAGE_IDS = new Set<string>(); 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 => { const estimateHistoryEntryHeight = (entry: RenderEntry | undefined): number => {
if (!entry) { if (!entry) {
@@ -38,6 +38,38 @@ const estimateHistoryEntryHeight = (entry: RenderEntry | undefined): number => {
return 140; 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 useStableEvent = <TArgs extends unknown[], TResult>(handler: (...args: TArgs) => TResult) => {
const handlerRef = React.useRef(handler); const handlerRef = React.useRef(handler);
React.useEffect(() => { React.useEffect(() => {
@@ -959,10 +991,12 @@ MessageListEntry.displayName = 'MessageListEntry';
type StaticHistoryListProps = { type StaticHistoryListProps = {
entries: RenderEntry[]; entries: RenderEntry[];
shouldVirtualize: boolean; shouldVirtualize: boolean;
virtualRows: VirtualItem[];
totalSize: number;
measureElement: (element: HTMLDivElement | null) => void;
contentRef: React.RefObject<HTMLDivElement | null>; contentRef: React.RefObject<HTMLDivElement | null>;
scrollRef?: React.RefObject<HTMLDivElement | null>;
virtualizerRef: React.Ref<VirtualizerHandle>;
virtualizerKey: string;
virtualCache?: CacheSnapshot;
shift: boolean;
onMessageContentChange: (reason?: ContentChangeReason) => void; onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers; getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: () => void; scrollToBottom?: () => void;
@@ -977,7 +1011,7 @@ type StaticHistoryListProps = {
reviewTransferDirection?: ReviewTransferDirection | null; 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) => { const renderEntry = React.useCallback((entry: RenderEntry) => {
return ( return (
<MessageListEntry <MessageListEntry
@@ -1001,13 +1035,6 @@ const StaticHistoryList = React.memo(({ entries, shouldVirtualize, virtualRows,
); );
}, [activeStreamingPhase, chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, reviewTransferDirection, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]); }, [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) { if (!shouldVirtualize) {
return ( return (
<div ref={contentRef} className="relative w-full"> <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 ( return (
<div ref={contentRef} className="relative w-full"> <Virtualizer
{paddingTop > 0 ? <div aria-hidden="true" style={{ height: `${paddingTop}px` }} /> : null} key={virtualizerKey}
{virtualRows.map((virtualRow) => { ref={virtualizerRef}
const entry = entries[virtualRow.index]; data={entries}
if (!entry) { cache={virtualCache}
return null; itemSize={virtualCache ? undefined : estimateHistoryEntryHeight(undefined)}
} bufferSize={MESSAGE_LIST_BUFFER_SIZE}
shift={shift}
return ( scrollRef={scrollRef}
<div >
key={virtualRow.key} {(entry) => (
ref={measureElement} <div key={entry.key} data-turn-entry={entry.key}>
data-index={virtualRow.index} {renderEntry(entry)}
data-turn-entry={entry.key} </div>
> )}
{renderEntry(entry)} </Virtualizer>
</div>
);
})}
{paddingBottom > 0 ? <div aria-hidden="true" style={{ height: `${paddingBottom}px` }} /> : null}
</div>
); );
}); });
@@ -1229,7 +1236,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
}), [messages]); }), [messages]);
const historyContentRef = React.useRef<HTMLDivElement | null>(null); 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 => { const resolveScrollContainer = React.useCallback((): HTMLDivElement | null => {
if (scrollRef?.current) { if (scrollRef?.current) {
return scrollRef.current; return scrollRef.current;
@@ -1332,120 +1339,44 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
const historyEntries = staticRenderEntries; const historyEntries = staticRenderEntries;
const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD; const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
const historyEntryKeys = React.useMemo(() => historyEntries.map((entry) => entry.key), [historyEntries]);
const previousHistoryLenRef = React.useRef(historyEntries.length); const virtualCache = React.useMemo(
const previousFirstEntryKeyRef = React.useRef(historyEntries[0]?.key); () => (shouldVirtualizeHistory ? readTimelineCache(sessionKey, historyEntryKeys) : undefined),
[historyEntryKeys, sessionKey, shouldVirtualizeHistory],
React.useLayoutEffect(() => { );
const previousLen = previousHistoryLenRef.current; const virtualCacheSessionRef = React.useRef(sessionKey);
const currentLen = historyEntries.length; const virtualCacheKeysRef = React.useRef(historyEntryKeys);
const previousFirstKey = previousFirstEntryKeyRef.current; const setHistoryVirtualizer = React.useCallback((handle: VirtualizerHandle | null) => {
const currentFirstKey = historyEntries[0]?.key; if (!handle) {
writeTimelineCache(
previousHistoryLenRef.current = currentLen; virtualCacheSessionRef.current,
previousFirstEntryKeyRef.current = currentFirstKey; virtualCacheKeysRef.current,
historyVirtualizerRef.current,
const grew = currentLen > previousLen; );
const firstChanged = previousFirstKey !== currentFirstKey; historyVirtualizerRef.current = null;
if (!shouldVirtualizeHistory || isLoadingOlder || disableStaging || !grew || !firstChanged || previousLen === 0) {
return; return;
} }
const prependedCount = currentLen - previousLen; historyVirtualizerRef.current = handle;
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);
}
};
}, []); }, []);
const historyVirtualRows = React.useMemo( React.useEffect(() => {
() => (shouldVirtualizeHistory ? historyVirtualizer.getVirtualItems() : EMPTY_VIRTUAL_ROWS), virtualCacheSessionRef.current = sessionKey;
[historyVirtualizer, shouldVirtualizeHistory], virtualCacheKeysRef.current = historyEntryKeys;
); }, [historyEntryKeys, sessionKey]);
React.useEffect(() => {
const virtualizerForCleanup = historyVirtualizerRef.current;
return () => {
writeTimelineCache(virtualCacheSessionRef.current, virtualCacheKeysRef.current, virtualizerForCleanup);
};
}, []);
const allEntries = React.useMemo(() => { const allEntries = React.useMemo(() => {
return trailingStreamingEntry ? [...historyEntries, trailingStreamingEntry] : historyEntries; return trailingStreamingEntry ? [...historyEntries, trailingStreamingEntry] : historyEntries;
}, [historyEntries, trailingStreamingEntry]); }, [historyEntries, trailingStreamingEntry]);
const stableHistoryContentChange = useStableEvent((reason?: ContentChangeReason) => { const stableHistoryContentChange = useStableEvent((reason?: ContentChangeReason) => {
scheduleVirtualMeasure();
onMessageContentChange(reason); onMessageContentChange(reason);
}); });
@@ -1540,10 +1471,14 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
return false; return false;
} }
const virtualizerBehavior = behavior === 'smooth' ? 'smooth' : 'auto'; const virtualizer = historyVirtualizerRef.current;
historyVirtualizer.scrollToIndex(index, { align: 'start', behavior: virtualizerBehavior }); if (!virtualizer) {
return false;
}
virtualizer.scrollToIndex(index, { align: 'start', smooth: behavior === 'smooth' });
return true; return true;
}, [historyEntries.length, historyVirtualizer, shouldVirtualizeHistory]); }, [historyEntries.length, shouldVirtualizeHistory]);
const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => { const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => {
const container = resolveScrollContainer(); const container = resolveScrollContainer();
@@ -1682,7 +1617,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
scrollToBottom: () => { scrollToBottom: () => {
if (shouldVirtualizeHistory && historyEntries.length > 0) { if (shouldVirtualizeHistory && historyEntries.length > 0) {
historyVirtualizer.scrollToIndex(historyEntries.length - 1, { align: 'end' }); historyVirtualizerRef.current?.scrollToIndex(historyEntries.length - 1, { align: 'end' });
return; return;
} }
const container = resolveScrollContainer(); const container = resolveScrollContainer();
@@ -1703,7 +1638,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
return () => { return () => {
objectRef.current = null; 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; const disableFadeIn = false;
@@ -1714,10 +1649,12 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
<StaticHistoryList <StaticHistoryList
entries={historyEntries} entries={historyEntries}
shouldVirtualize={shouldVirtualizeHistory} shouldVirtualize={shouldVirtualizeHistory}
virtualRows={historyVirtualRows}
totalSize={historyVirtualizer.getTotalSize()}
measureElement={historyVirtualizer.measureElement}
contentRef={historyContentRef} contentRef={historyContentRef}
scrollRef={scrollRef}
virtualizerRef={setHistoryVirtualizer}
virtualizerKey={sessionKey}
virtualCache={virtualCache}
shift={isLoadingOlder || disableStaging}
onMessageContentChange={stableHistoryContentChange} onMessageContentChange={stableHistoryContentChange}
getAnimationHandlers={stableGetAnimationHandlers} getAnimationHandlers={stableGetAnimationHandlers}
scrollToBottom={stableScrollToBottom} scrollToBottom={stableScrollToBottom}
@@ -2,13 +2,9 @@ import React from 'react';
import type { ChatMessageEntry } from '../lib/turns/types'; import type { ChatMessageEntry } from '../lib/turns/types';
import type { MessageListHandle } from '../MessageList'; import type { MessageListHandle } from '../MessageList';
import { TURN_WINDOW_DEFAULTS } from '../lib/turns/constants';
import { import {
buildTurnWindowModel, buildTurnWindowModel,
clampTurnStart,
getInitialTurnStart,
updateTurnWindowModelIncremental, updateTurnWindowModelIncremental,
windowMessagesByTurn,
type TurnWindowModel, type TurnWindowModel,
} from '../lib/turns/windowTurns'; } from '../lib/turns/windowTurns';
import type { TurnHistorySignals } from '../lib/turns/historySignals'; import type { TurnHistorySignals } from '../lib/turns/historySignals';
@@ -152,20 +148,17 @@ export const useChatTimelineController = ({
return nextModel; return nextModel;
}, [messages, sessionId]); }, [messages, sessionId]);
const [turnStart, setTurnStart] = React.useState(() => getInitialTurnStart(turnWindowModel.turnCount));
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false); const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
const [pendingRevealWork, setPendingRevealWork] = React.useState(false); const [pendingRevealWork, setPendingRevealWork] = React.useState(false);
const [activeTurnId, setActiveTurnId] = React.useState<string | null>(null); const [activeTurnId, setActiveTurnId] = React.useState<string | null>(null);
const turnModelRef = React.useRef(turnWindowModel); const turnModelRef = React.useRef(turnWindowModel);
const turnStartRef = React.useRef(turnStart);
const isPinnedRef = React.useRef(isPinned); const isPinnedRef = React.useRef(isPinned);
const isLoadingOlderRef = React.useRef(isLoadingOlder); const isLoadingOlderRef = React.useRef(isLoadingOlder);
const pendingRevealWorkRef = React.useRef(pendingRevealWork); const pendingRevealWorkRef = React.useRef(pendingRevealWork);
const sessionIdRef = React.useRef<string | null>(sessionId); const sessionIdRef = React.useRef<string | null>(sessionId);
const messagesRef = React.useRef(messages); const messagesRef = React.useRef(messages);
const historyMetaRef = React.useRef<SessionHistoryMeta | null>(historyMeta); const historyMetaRef = React.useRef<SessionHistoryMeta | null>(historyMeta);
const previousTurnCountRef = React.useRef(turnWindowModel.turnCount);
const initializedSessionRef = React.useRef<string | null>(null); const initializedSessionRef = React.useRef<string | null>(null);
const pendingRenderResolversRef = React.useRef<Array<() => void>>([]); const pendingRenderResolversRef = React.useRef<Array<() => void>>([]);
const pendingScrollRequestRef = React.useRef<PendingScrollRequest | null>(null); const pendingScrollRequestRef = React.useRef<PendingScrollRequest | null>(null);
@@ -174,7 +167,7 @@ export const useChatTimelineController = ({
const historySignals = React.useMemo(() => { const historySignals = React.useMemo(() => {
const defaultLimit = getMemoryLimits().HISTORICAL_MESSAGES; const defaultLimit = getMemoryLimits().HISTORICAL_MESSAGES;
const hasBufferedTurns = turnStart > 0; const hasBufferedTurns = false;
const hasMoreAboveTurns = historyMeta const hasMoreAboveTurns = historyMeta
? !historyMeta.complete ? !historyMeta.complete
: messages.length >= defaultLimit; : messages.length >= defaultLimit;
@@ -183,14 +176,13 @@ export const useChatTimelineController = ({
hasBufferedTurns, hasBufferedTurns,
hasMoreAboveTurns, hasMoreAboveTurns,
historyLoading, historyLoading,
canLoadEarlier: hasBufferedTurns || hasMoreAboveTurns, canLoadEarlier: hasMoreAboveTurns,
}; };
}, [historyMeta, messages.length, turnStart]); }, [historyMeta, messages.length]);
const historySignalsRef = React.useRef(historySignals); const historySignalsRef = React.useRef(historySignals);
turnModelRef.current = turnWindowModel; turnModelRef.current = turnWindowModel;
turnStartRef.current = turnStart;
isPinnedRef.current = isPinned; isPinnedRef.current = isPinned;
isLoadingOlderRef.current = isLoadingOlder; isLoadingOlderRef.current = isLoadingOlder;
pendingRevealWorkRef.current = pendingRevealWork; pendingRevealWorkRef.current = pendingRevealWork;
@@ -232,41 +224,10 @@ export const useChatTimelineController = ({
} }
historyInteractionRef.current = false; historyInteractionRef.current = false;
initializedSessionRef.current = sessionId; initializedSessionRef.current = sessionId;
setTurnStart(getInitialTurnStart(turnWindowModel.turnCount));
setIsLoadingOlder(false); setIsLoadingOlder(false);
setPendingRevealWork(false); setPendingRevealWork(false);
setActiveTurnId(null); setActiveTurnId(null);
previousTurnCountRef.current = turnWindowModel.turnCount; }, [sessionId]);
}, [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]);
const resolvePendingRenderWaiters = React.useCallback(() => { const resolvePendingRenderWaiters = React.useCallback(() => {
const resolvers = pendingRenderResolversRef.current; const resolvers = pendingRenderResolversRef.current;
@@ -277,12 +238,6 @@ export const useChatTimelineController = ({
resolvers.forEach((resolve) => resolve()); 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> => { const waitForNextRenderCommitOrTimeout = React.useCallback((): Promise<void> => {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -338,7 +293,7 @@ export const useChatTimelineController = ({
? turnModelRef.current.turnIndexById.get(pending.id) ? turnModelRef.current.turnIndexById.get(pending.id)
: turnModelRef.current.messageToTurnIndex.get(pending.id); : turnModelRef.current.messageToTurnIndex.get(pending.id);
if (typeof targetIndex === 'number' && targetIndex >= turnStartRef.current) { if (typeof targetIndex === 'number') {
resolvePendingScrollRequest(false); resolvePendingScrollRequest(false);
} }
}, [messageListRef, resolvePendingScrollRequest]); }, [messageListRef, resolvePendingScrollRequest]);
@@ -354,14 +309,12 @@ export const useChatTimelineController = ({
}; };
}, [resolvePendingRenderWaiters, resolvePendingScrollRequest]); }, [resolvePendingRenderWaiters, resolvePendingScrollRequest]);
const renderedMessages = React.useMemo(() => { const renderedMessages = messages;
return windowMessagesByTurn(messages, turnWindowModel, turnStart);
}, [messages, turnStart, turnWindowModel]);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
resolvePendingRenderWaiters(); resolvePendingRenderWaiters();
attemptPendingScrollRequest(); attemptPendingScrollRequest();
}, [attemptPendingScrollRequest, renderedMessages, resolvePendingRenderWaiters, turnStart]); }, [attemptPendingScrollRequest, renderedMessages, resolvePendingRenderWaiters]);
// --- Synchronous scroll compensation for load-more / reveal --- // --- Synchronous scroll compensation for load-more / reveal ---
// fetchOlderHistory and revealBufferedTurns store a snapshot here // fetchOlderHistory and revealBufferedTurns store a snapshot here
@@ -401,35 +354,7 @@ export const useChatTimelineController = ({
} }
}, [renderedMessages, scrollRef, restoreViewportAnchor]); }, [renderedMessages, scrollRef, restoreViewportAnchor]);
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => { const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => false, []);
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 fetchOlderHistory = React.useCallback(async (input: { const fetchOlderHistory = React.useCallback(async (input: {
preserveViewport: boolean; preserveViewport: boolean;
@@ -518,15 +443,11 @@ export const useChatTimelineController = ({
} }
try { try {
if (await revealBufferedTurns()) {
return;
}
void (await fetchOlderHistory({ preserveViewport: true })); void (await fetchOlderHistory({ preserveViewport: true }));
} finally { } finally {
settleHistoryInteraction(); settleHistoryInteraction();
} }
}, [beginHistoryInteraction, fetchOlderHistory, releaseAutoFollow, revealBufferedTurns, settleHistoryInteraction]); }, [beginHistoryInteraction, fetchOlderHistory, releaseAutoFollow, settleHistoryInteraction]);
const handleHistoryScroll = React.useCallback(() => { const handleHistoryScroll = React.useCallback(() => {
const container = scrollRef.current; const container = scrollRef.current;
@@ -636,10 +557,6 @@ export const useChatTimelineController = ({
return false; return false;
} }
if (turnIndex < turnStartRef.current) {
setTurnStart(turnIndex);
}
const result = await new Promise<boolean>((resolve) => { const result = await new Promise<boolean>((resolve) => {
pendingScrollRequestRef.current = { pendingScrollRequestRef.current = {
sessionId: sessionIdRef.current ?? sessionId ?? '', sessionId: sessionIdRef.current ?? sessionId ?? '',
@@ -685,10 +602,6 @@ export const useChatTimelineController = ({
return false; return false;
} }
if (turnIndex < turnStartRef.current) {
setTurnStart(turnIndex);
}
const result = await new Promise<boolean>((resolve) => { const result = await new Promise<boolean>((resolve) => {
pendingScrollRequestRef.current = { pendingScrollRequestRef.current = {
sessionId: sessionIdRef.current ?? sessionId ?? '', sessionId: sessionIdRef.current ?? sessionId ?? '',
@@ -712,32 +625,16 @@ export const useChatTimelineController = ({
}, [attemptPendingScrollRequest, releaseAutoFollow, sessionId]); }, [attemptPendingScrollRequest, releaseAutoFollow, sessionId]);
const resumeToBottom = React.useCallback(async () => { const resumeToBottom = React.useCallback(async () => {
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
setPendingRevealWork(false); setPendingRevealWork(false);
setIsLoadingOlder(false); setIsLoadingOlder(false);
const shouldWaitForRender = nextStart !== turnStartRef.current;
if (shouldWaitForRender) {
setTurnStart(nextStart);
await waitForNextRenderCommit();
}
goToBottom('smooth'); goToBottom('smooth');
}, [goToBottom, waitForNextRenderCommit]); }, [goToBottom]);
const resumeToBottomInstant = React.useCallback(async () => { const resumeToBottomInstant = React.useCallback(async () => {
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
setPendingRevealWork(false); setPendingRevealWork(false);
setIsLoadingOlder(false); setIsLoadingOlder(false);
const shouldWaitForRender = nextStart !== turnStartRef.current;
if (shouldWaitForRender) {
setTurnStart(nextStart);
await waitForNextRenderCommit();
}
goToBottom('instant'); goToBottom('instant');
}, [goToBottom, waitForNextRenderCommit]); }, [goToBottom]);
const handleActiveTurnChange = React.useCallback((turnId: string | null) => { const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
setActiveTurnId(turnId); setActiveTurnId(turnId);
@@ -745,7 +642,7 @@ export const useChatTimelineController = ({
return { return {
turnIds: turnWindowModel.turnIds, turnIds: turnWindowModel.turnIds,
turnStart, turnStart: 0,
renderedMessages, renderedMessages,
historySignals, historySignals,
isLoadingOlder, 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 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; export const TURN_TEXT_THROTTLE_DEFAULT_MS = 100;
@@ -1,5 +1,4 @@
import type { ChatMessageEntry } from './types'; import type { ChatMessageEntry } from './types';
import { TURN_WINDOW_DEFAULTS } from './constants';
const resolveMessageRole = (message: ChatMessageEntry): string => { const resolveMessageRole = (message: ChatMessageEntry): string => {
const role = (message.info as { clientRole?: string | null; role?: string | null }).clientRole ?? message.info.role; 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, 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 ?? ''} content={errorMessage ?? ''}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
className="[&_.markdown-content>*:first-child]:mt-0 [&_.markdown-content>*:last-child]:mb-0" className="[&_.markdown-content>*:first-child]:mt-0 [&_.markdown-content>*:last-child]:mb-0"
enableFileReferences={false}
/> />
</div> </div>
</div> </div>
@@ -972,6 +972,7 @@ const MermaidPreviewDialog: React.FC<{
allowMermaidWheelZoom allowMermaidWheelZoom
className="markdown-mermaid-fullscreen h-full [&_[data-markdown='mermaid-block']_button]:hidden" className="markdown-mermaid-fullscreen h-full [&_[data-markdown='mermaid-block']_button]:hidden"
mermaidControls={MERMAID_CONTROLS} mermaidControls={MERMAID_CONTROLS}
enableFileReferences={false}
/> />
</div> </div>
)} )}
@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import type { AnimationPlaybackControls } from 'motion';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { PatchDiff } from '@pierre/diffs/react'; import { PatchDiff } from '@pierre/diffs/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -201,29 +200,59 @@ const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> =
return <>{formatDuration(start, end, now)}</>; 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 flushDeferredToolBodyMounts = () => {
const [shouldRender, setShouldRender] = React.useState(isExpanded); 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(() => { React.useEffect(() => {
if (typeof window === 'undefined') { if (!isExpanded) {
setShouldRender(isExpanded);
return;
}
if (isExpanded) {
setShouldRender(true);
return;
}
const timer = window.setTimeout(() => {
setShouldRender(false); setShouldRender(false);
}, EXPANDED_CONTENT_TRANSITION_MS); return;
}
return () => { return scheduleDeferredToolBodyMount(() => {
window.clearTimeout(timer); setShouldRender(true);
}; });
}, [isExpanded]); }, [isExpanded]);
return shouldRender; return shouldRender;
@@ -1573,6 +1602,7 @@ interface ToolExpandedContentProps {
state: ToolStateUnion; state: ToolStateUnion;
syntaxTheme: { [key: string]: React.CSSProperties }; syntaxTheme: { [key: string]: React.CSSProperties };
currentDirectory: string; currentDirectory: string;
isExpanded: boolean;
onShowPopup?: (content: ToolPopupContent) => void; onShowPopup?: (content: ToolPopupContent) => void;
} }
@@ -1581,6 +1611,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
state, state,
syntaxTheme, syntaxTheme,
currentDirectory, currentDirectory,
isExpanded,
onShowPopup, onShowPopup,
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
@@ -1873,7 +1904,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
</blockquote> </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', 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); const onContentChangeRef = React.useRef(onContentChange);
onContentChangeRef.current = onContentChange; onContentChangeRef.current = onContentChange;
const expandedContentRef = React.useRef<HTMLDivElement>(null); const expandedContentRef = React.useRef<HTMLDivElement>(null);
const expandedContentAnimationRef = React.useRef<AnimationPlaybackControls | null>(null);
const expandedContentMountedRef = React.useRef(false);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (isTaskTool) { if (isTaskTool) {
@@ -1992,9 +2021,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
return; return;
} }
expandedContentMountedRef.current = true;
expandedContentAnimationRef.current?.stop();
expandedContentAnimationRef.current = null;
element.style.height = isExpanded ? 'auto' : '0px'; element.style.height = isExpanded ? 'auto' : '0px';
element.style.overflow = isExpanded ? 'visible' : 'hidden'; element.style.overflow = isExpanded ? 'visible' : 'hidden';
@@ -2003,13 +2029,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
} }
}, [isExpanded, isTaskTool, shouldNotifyStructuralChange]); }, [isExpanded, isTaskTool, shouldNotifyStructuralChange]);
React.useEffect(() => {
return () => {
expandedContentAnimationRef.current?.stop();
expandedContentAnimationRef.current = null;
};
}, []);
const stateWithData = state as ToolStateWithMetadata; const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata; const metadata = stateWithData.metadata;
const partMetadata = (part as unknown as { metadata?: unknown }).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 iconStyle = !isTaskTool && isError ? TOOL_ERROR_ICON_STYLE : TOOL_NORMAL_ICON_STYLE;
const titleStyle = !isTaskTool && isError ? TOOL_ERROR_TITLE_STYLE : TOOL_NORMAL_TITLE_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) { if (!shouldTreatAsFinalized && !isActive && !isTaskTool) {
return null; return null;
@@ -2795,7 +2815,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
</div> </div>
{} {}
{isTaskTool && (taskSummaryEntries.length > 0 || isActive || shouldTreatAsFinalized || taskSessionId) ? ( {shouldRenderTaskSummary ? (
<TaskToolSummary <TaskToolSummary
entries={taskSummaryEntries} entries={taskSummaryEntries}
isExpanded={isExpanded} isExpanded={isExpanded}
@@ -2822,10 +2842,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
{shouldRenderExpandedContent ? ( {shouldRenderExpandedContent ? (
<div <div
className="relative ml-2 pl-3" className="relative ml-2 pl-3"
style={{
opacity: isExpanded ? 1 : 0,
transform: isExpanded ? 'translateY(0)' : 'translateY(-4px)',
}}
> >
<span <span
aria-hidden="true" aria-hidden="true"
@@ -2837,6 +2853,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
state={state} state={state}
syntaxTheme={syntaxTheme} syntaxTheme={syntaxTheme}
currentDirectory={currentDirectory} currentDirectory={currentDirectory}
isExpanded={isExpanded}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
/> />
</div> </div>
@@ -272,7 +272,8 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
"[&_[data-component='markdown-code']_code]:inline", "[&_[data-component='markdown-code']_code]:inline",
] ]
)} )}
disableLinkSafety disableLinkSafety
enableFileReferences={false}
/> />
) : ( ) : (
plainTextContent plainTextContent
@@ -3,14 +3,14 @@
* *
* Replaces per-line <SyntaxHighlighter> with: * Replaces per-line <SyntaxHighlighter> with:
* 1. ONE Prism.highlight() call to tokenize all code at once * 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). * 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. * For a 2000-line file, ~2000 SyntaxHighlighter instances → ~30 plain <div>s.
*/ */
import React from 'react'; import React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { Virtualizer } from 'virtua';
import Prism from 'prismjs'; import Prism from 'prismjs';
// Ensure common languages are loaded (react-syntax-highlighter lazy-loads them, // Ensure common languages are loaded (react-syntax-highlighter lazy-loads them,
@@ -244,52 +244,31 @@ const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
lineStyles, lineStyles,
}) => { }) => {
const parentRef = React.useRef<HTMLDivElement>(null); const parentRef = React.useRef<HTMLDivElement>(null);
const viewportHeight = `min(${lines.length * ROW_HEIGHT}px, ${maxHeight})`;
const virtualizer = useVirtualizer({
count: lines.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 20, // render 20 extra rows above/below viewport
});
return ( return (
<div <div
ref={parentRef} ref={parentRef}
className="typography-code font-mono w-full min-w-0 oc-virtualized-prism" 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} {prismThemeCss ? <style>{prismThemeCss}</style> : null}
<div <Virtualizer
style={{ data={lines}
height: `${virtualizer.getTotalSize()}px`, itemSize={ROW_HEIGHT}
width: '100%', bufferSize={ROW_HEIGHT * 20}
position: 'relative', scrollRef={parentRef}
}}
> >
{virtualizer.getVirtualItems().map((vItem) => { {(line, index) => (
const line = lines[vItem.index]; <Row
return ( key={index}
<div line={line}
key={vItem.index} language={language}
style={{ showLineNumbers={showLineNumbers}
position: 'absolute', style={lineStyles?.(line)}
top: 0, />
left: 0, )}
width: '100%', </Virtualizer>
height: `${vItem.size}px`,
transform: `translateY(${vItem.start}px)`,
}}
>
<Row
line={line}
language={language}
showLineNumbers={showLineNumbers}
style={lineStyles?.(line)}
/>
</div>
);
})}
</div>
</div> </div>
); );
}); });
@@ -608,6 +608,7 @@ const SkillsInstalledPage: React.FC = () => {
content={skillMarkdown} content={skillMarkdown}
className="typography-markdown-body" className="typography-markdown-body"
stripFrontmatter stripFrontmatter
enableFileReferences={false}
/> />
</div> </div>
</ScrollableOverlay> </ScrollableOverlay>
@@ -1,12 +1,12 @@
import React from 'react'; import React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { Virtualizer } from 'virtua';
import type { Session } from '@opencode-ai/sdk/v2'; import type { Session } from '@opencode-ai/sdk/v2';
// Archived buckets routinely grow into the hundreds/thousands; virtualize // Archived buckets routinely grow into the hundreds/thousands; virtualize
// when we cross this row count so the DOM stays bounded. // when we cross this row count so the DOM stays bounded.
const ARCHIVED_VIRTUALIZE_THRESHOLD = 50; const ARCHIVED_VIRTUALIZE_THRESHOLD = 50;
// Compact rows in the archived bucket without nested subagents render // 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; const ARCHIVED_ROW_ESTIMATE_PX = 28;
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
@@ -274,9 +274,10 @@ export function SessionGroupSection(props: Props): React.ReactNode {
&& visibleSessions.length >= ARCHIVED_VIRTUALIZE_THRESHOLD; && visibleSessions.length >= ARCHIVED_VIRTUALIZE_THRESHOLD;
const archivedVirtualContainerRef = React.useRef<HTMLDivElement | null>(null); const archivedVirtualContainerRef = React.useRef<HTMLDivElement | null>(null);
const archivedScrollRef = React.useRef<HTMLElement | null>(null);
const [archivedScrollEl, setArchivedScrollEl] = React.useState<HTMLElement | null>(null); const [archivedScrollEl, setArchivedScrollEl] = React.useState<HTMLElement | null>(null);
// Offset of the virtual container from the scroll element's content origin. // 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, // to translate scrollTop into container-relative coordinates. Without this,
// when the scroll element is an ancestor (the sidebar's ScrollableOverlay), // when the scroll element is an ancestor (the sidebar's ScrollableOverlay),
// the virtualizer assumes the container starts at the top of the scroll // 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; // routes its scroll through `ScrollableOverlay` higher up the tree;
// threading a ref through every intermediate component would be invasive // threading a ref through every intermediate component would be invasive
// for this single use case. // 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 // Resolve the scrolling ancestor and measure the virtual container's offset
// from its content origin, both on every render. The container ref is null // 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 // 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(() => { React.useLayoutEffect(() => {
if (!shouldVirtualizeArchived) { if (!shouldVirtualizeArchived) {
if (archivedScrollEl !== null) setArchivedScrollEl(null); if (archivedScrollEl !== null) setArchivedScrollEl(null);
archivedScrollRef.current = null;
if (archivedScrollMargin !== 0) setArchivedScrollMargin(0); if (archivedScrollMargin !== 0) setArchivedScrollMargin(0);
return; return;
} }
@@ -332,6 +325,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
el = el.parentElement; el = el.parentElement;
} }
if (scrollEl !== archivedScrollEl) { if (scrollEl !== archivedScrollEl) {
archivedScrollRef.current = scrollEl;
setArchivedScrollEl(scrollEl); setArchivedScrollEl(scrollEl);
// setState triggers a re-render; bail out and let the next pass // setState triggers a re-render; bail out and let the next pass
// measure the margin against the fresh element. // 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)); 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) { if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) {
return null; return null;
} }
@@ -576,41 +547,16 @@ export function SessionGroupSection(props: Props): React.ReactNode {
> >
{renderFolderItems()} {renderFolderItems()}
{shouldVirtualizeArchived ? ( {shouldVirtualizeArchived ? (
<div <div ref={archivedVirtualContainerRef}>
ref={archivedVirtualContainerRef} <Virtualizer
style={{ data={visibleSessions}
position: 'relative', itemSize={ARCHIVED_ROW_ESTIMATE_PX}
// Reserve scroll height for all archived rows so the parent bufferSize={ARCHIVED_ROW_ESTIMATE_PX * 8}
// scroll thumb reflects the full list. Individual rows are scrollRef={archivedScrollRef}
// absolutely positioned by translateY. startMargin={archivedScrollMargin}
height: archivedTotalSize > 0 ? archivedTotalSize : undefined, >
width: '100%', {(node) => renderSessionNode(node, 0, group.directory, projectId, true) as React.ReactElement}
}} </Virtualizer>
>
{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> </div>
) : ( ) : (
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true)) visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true))
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { Virtualizer } from 'virtua';
import { import {
parseJsonToTree, parseJsonToTree,
@@ -204,14 +204,6 @@ const JsonTreeViewer = React.forwardRef<{ expandAll: () => void; collapseAll: ()
const shouldVirtualize = flatNodes.length > VIRTUALIZE_THRESHOLD; const shouldVirtualize = flatNodes.length > VIRTUALIZE_THRESHOLD;
const parentRef = React.useRef<HTMLDivElement>(null); 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) => { const handleToggle = React.useCallback((id: string) => {
setCollapsedPaths((prev) => { setCollapsedPaths((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -246,37 +238,21 @@ const JsonTreeViewer = React.forwardRef<{ expandAll: () => void; collapseAll: ()
className={className} className={className}
style={{ maxHeight, overflow: 'auto' }} style={{ maxHeight, overflow: 'auto' }}
> >
<div <Virtualizer
style={{ data={flatNodes}
height: `${virtualizer.getTotalSize()}px`, itemSize={ROW_HEIGHT}
width: '100%', bufferSize={ROW_HEIGHT * 20}
position: 'relative', scrollRef={parentRef}
}}
> >
{virtualizer.getVirtualItems().map((virtualRow) => { {(flatNode) => (
const flatNode = flatNodes[virtualRow.index]; <JsonRow
if (!flatNode) return null; key={flatNode.node.id}
return ( flatNode={flatNode}
<div onToggle={handleToggle}
key={flatNode.node.id} onCopyPath={onCopyPath}
style={{ />
position: 'absolute', )}
top: 0, </Virtualizer>
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<JsonRow
flatNode={flatNode}
onToggle={handleToggle}
onCopyPath={onCopyPath}
/>
</div>
);
})}
</div>
</div> </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>
) : ( ) : (
<div className="divide-y divide-[var(--surface-subtle)]"> <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>
</div> </div>
))} ))}
+12 -10
View File
@@ -3789,11 +3789,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</div> </div>
} }
> >
<SimpleMarkdownRenderer <SimpleMarkdownRenderer
content={fileContent} content={fileContent}
className="typography-markdown-body" className="typography-markdown-body"
stripFrontmatter stripFrontmatter
/> enableFileReferences={false}
/>
</ErrorBoundary> </ErrorBoundary>
</div> </div>
) : selectedFile && isHtml && htmlViewMode === 'preview' ? ( ) : selectedFile && isHtml && htmlViewMode === 'preview' ? (
@@ -4129,11 +4130,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</div> </div>
} }
> >
<SimpleMarkdownRenderer <SimpleMarkdownRenderer
content={fileContent} content={fileContent}
className="typography-markdown-body" className="typography-markdown-body"
stripFrontmatter stripFrontmatter
/> enableFileReferences={false}
/>
</ErrorBoundary> </ErrorBoundary>
</div> </div>
) : canUseShikiFileView && textViewMode === 'view' ? ( ) : canUseShikiFileView && textViewMode === 'view' ? (
@@ -787,7 +787,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
</div> </div>
} }
> >
<SimpleMarkdownRenderer content={content} className="typography-markdown-body" /> <SimpleMarkdownRenderer content={content} className="typography-markdown-body" enableFileReferences={false} />
</ErrorBoundary> </ErrorBoundary>
</div> </div>
) : ( ) : (
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { Virtualizer, type VirtualizerHandle } from 'virtua';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@@ -195,30 +195,16 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
const rowCount = rows.length; const rowCount = rows.length;
const shouldVirtualize = rowCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD; const shouldVirtualize = rowCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD;
const rowVirtualizer = useVirtualizer({ const rowVirtualizerRef = React.useRef<VirtualizerHandle | null>(null);
count: rowCount, const [visibleStartIndex, setVisibleStartIndex] = React.useState(0);
getScrollElement: () => scrollRef.current,
estimateSize: () => CHANGE_ROW_ESTIMATE_PX,
overscan: 12,
enabled: shouldVirtualize,
});
// Remeasure when the container transitions from display:none (hidden tab) back const updateVisibleStartIndex = React.useCallback((offset: number) => {
// to visible layout, otherwise stale zero-height measurements render no rows. const virtualizer = rowVirtualizerRef.current;
React.useEffect(() => { const next = virtualizer
if (!shouldVirtualize) return; ? virtualizer.findItemIndex(offset)
const el = scrollRef.current; : Math.floor(offset / CHANGE_ROW_ESTIMATE_PX);
if (!el) return; setVisibleStartIndex((previous) => (previous === next ? previous : next));
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]
);
React.useEffect(() => { React.useEffect(() => {
if (!onVisiblePathsChange) { if (!onVisiblePathsChange) {
@@ -246,11 +232,16 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
} }
onVisiblePathsChange( onVisiblePathsChange(
virtualRows rows
.map((item) => collectFromRow(rows[item.index])) .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)) .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) => { const toggleGroupCollapsed = React.useCallback((groupId: string) => {
setCollapsedGroups((previous) => { 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" className="overlay-scrollbar-target overlay-scrollbar-container min-h-0 w-full flex-1 overflow-x-hidden overflow-y-auto"
> >
{shouldVirtualize ? ( {shouldVirtualize ? (
<div className="relative w-full" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}> <Virtualizer
{virtualRows.map((item) => { ref={rowVirtualizerRef}
const row = rows[item.index]; data={rows}
if (!row) return null; itemSize={CHANGE_ROW_ESTIMATE_PX}
return ( bufferSize={CHANGE_ROW_ESTIMATE_PX * 12}
<div scrollRef={scrollRef}
key={row.key} onScroll={updateVisibleStartIndex}
ref={rowVirtualizer.measureElement} >
data-index={item.index} {(row, index) => (
className={cn( <div
'absolute left-0 top-0 w-full', key={row.key}
showDivider(item.index) && className={cn(
'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60' 'relative',
)} showDivider(index) &&
style={{ transform: `translateY(${item.start}px)` }} 'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
> )}
{renderRow(row, item.index === 0)} >
</div> {renderRow(row, index === 0)}
); </div>
})} )}
</div> </Virtualizer>
) : ( ) : (
<div role="list" aria-label={t('gitView.changes.changedFilesAria')}> <div role="list" aria-label={t('gitView.changes.changedFilesAria')}>
{rows.map((row, index) => ( {rows.map((row, index) => (
@@ -1527,6 +1527,7 @@ export const PullRequestSection: React.FC<{
<SimpleMarkdownRenderer <SimpleMarkdownRenderer
content={pr.body} content={pr.body}
className="typography-markdown-body text-muted-foreground break-words mt-1" 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"> <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', 'typography-markdown-body text-foreground break-words [&_a]:no-underline [&_a:hover]:no-underline',
selfMentionHighlightClass, selfMentionHighlightClass,
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
enableFileReferences={false}
/> />
</div> </div>
</div> </div>
@@ -0,0 +1,59 @@
import { describe, expect, it, mock } from 'bun:test';
mock.module('fs', () => ({
promises: {
realpath: mock(async () => {
const error = new Error('missing');
error.code = 'ENOENT';
throw error;
}),
stat: mock(async () => {
const error = new Error('missing');
error.code = 'ENOENT';
throw error;
}),
},
default: {
promises: {
realpath: mock(async () => {
const error = new Error('missing');
error.code = 'ENOENT';
throw error;
}),
stat: mock(async () => {
const error = new Error('missing');
error.code = 'ENOENT';
throw error;
}),
},
},
}));
mock.module('vscode', () => ({
Uri: {
file: (fsPath) => ({ fsPath }),
},
workspace: {
workspaceFolders: [{ uri: { fsPath: '/workspace' } }],
},
}));
const { tryHandleLocalFsProxy } = await import('./bridge-localfs-proxy-runtime');
describe('bridge local fs proxy', () => {
it('returns a quiet optional stat miss for missing files', async () => {
const response = await tryHandleLocalFsProxy('GET', '/api/fs/stat?path=%2Fmissing.ts&optional=true');
expect(response?.status).toBe(200);
expect(JSON.parse(Buffer.from(response?.bodyBase64 ?? '', 'base64').toString('utf8'))).toEqual({
path: '/missing.ts',
exists: false,
});
});
it('keeps regular stat miss behavior without optional flag', async () => {
const response = await tryHandleLocalFsProxy('GET', '/api/fs/stat?path=%2Fmissing.ts');
expect(response?.status).toBe(404);
});
});
@@ -65,8 +65,19 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string)
} }
const targetPath = parsed.searchParams.get('path') || ''; const targetPath = parsed.searchParams.get('path') || '';
const optional = parsed.searchParams.get('optional') === 'true';
const resolution: FsReadPathResolution = await resolveFileReadPath(targetPath); const resolution: FsReadPathResolution = await resolveFileReadPath(targetPath);
if (!resolution.ok) { if (!resolution.ok) {
if (fsProxyPath === '/api/fs/stat' && optional && resolution.status === 404) {
return {
status: 200,
headers: {
'content-type': 'application/json',
'cache-control': 'no-store',
},
bodyBase64: base64EncodeUtf8(JSON.stringify({ path: targetPath, exists: false })),
};
}
return buildProxyJsonError(resolution.status, resolution.error); return buildProxyJsonError(resolution.status, resolution.error);
} }
@@ -116,6 +127,16 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string)
} catch (error) { } catch (error) {
const err = error as NodeJS.ErrnoException; const err = error as NodeJS.ErrnoException;
if (err?.code === 'ENOENT') { if (err?.code === 'ENOENT') {
if (fsProxyPath === '/api/fs/stat' && optional) {
return {
status: 200,
headers: {
'content-type': 'application/json',
'cache-control': 'no-store',
},
bodyBase64: base64EncodeUtf8(JSON.stringify({ path: targetPath, exists: false })),
};
}
return buildProxyJsonError(404, 'File not found'); return buildProxyJsonError(404, 'File not found');
} }
if (fsProxyPath === '/api/fs/stat') { if (fsProxyPath === '/api/fs/stat') {
+4
View File
@@ -699,6 +699,7 @@ export const registerFsRoutes = (app, dependencies) => {
app.get('/api/fs/stat', async (req, res) => { app.get('/api/fs/stat', async (req, res) => {
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : ''; const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
const optional = req.query.optional === 'true';
if (!filePath) { if (!filePath) {
return res.status(400).json({ error: 'Path is required' }); return res.status(400).json({ error: 'Path is required' });
} }
@@ -740,6 +741,9 @@ export const registerFsRoutes = (app, dependencies) => {
} catch (error) { } catch (error) {
const err = error; const err = error;
if (err && typeof err === 'object' && err.code === 'ENOENT') { if (err && typeof err === 'object' && err.code === 'ENOENT') {
if (optional) {
return res.json({ path: filePath, exists: false });
}
return res.status(404).json({ error: 'File not found' }); return res.status(404).json({ error: 'File not found' });
} }
if (err && typeof err === 'object' && err.code === 'EACCES') { if (err && typeof err === 'object' && err.code === 'EACCES') {