perf: fix streaming lag, memory leaks, stuck spinners, and proxy timeout (#483)
* perf: fix streaming lag, memory leaks, and proxy timeout - PERF-001: Batch all streaming parts via requestAnimationFrame instead of per-token Zustand set() calls (~100/sec → 1 per frame) - PERF-002: Fix direct state.sessionMemoryState mutation inside set() callback - PERF-003: Debounce messageStore→sessionStore subscription via rAF + 500ms title computation delay - PERF-004: Stabilize SSE callbacks with refs to prevent reconnection storms; add 5-min stuck session idle timeout - PERF-005: Bound messageCache (500 max, LRU eviction), cap registry Maps, cleanup on session eviction - PERF-006: Replace toast duration: Infinity with 30s + id-based dedup - Fix proxy timeout: POST /session/:id/message 45s → 4min (matches CLI) - Add vitest + jsdom test infrastructure (61 tests across 7 files) Addresses: #476 (stuck spinner), #358 (34GB memory leak), #190 (browser lag) * perf: virtualize tool output rendering (read, edit, write) - PERF-007: Replace per-line <SyntaxHighlighter> with VirtualizedCodeBlock: - ONE Prism.highlight() call for entire file instead of N per-line calls - @tanstack/react-virtual renders only visible rows (~30 vs 2000+) - Applied to: ToolPart (read, DiffPreview, WriteInputPreview) and ToolOutputDialog (unified diff, read content) - PERF-008: Memoize parseReadToolOutput/parseDiffToUnified via useMemo to prevent re-parsing on every re-render A 2000-line file read now mounts ~30 DOM nodes instead of 2000 SyntaxHighlighter instances, eliminating the main-thread blocking that caused UI freezes during file operations. * fix: resolve lint errors (unused vars in tests and VirtualizedCodeBlock) * chore: trim PR scope to core perf fixes * fix: restore tool-card highlight stability --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
989593ed72
commit
3cd6d051cb
@@ -23,6 +23,7 @@ import {
|
||||
} from './toolRenderers';
|
||||
import type { ToolPopupContent, DiffViewMode } from './types';
|
||||
import { DiffViewToggle } from './DiffViewToggle';
|
||||
import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock';
|
||||
|
||||
interface ToolOutputDialogProps {
|
||||
popup: ToolPopupContent;
|
||||
@@ -355,6 +356,104 @@ const ImagePreviewDialog: React.FC<{
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
// ── PERF-007: Virtualised sub-components for dialog ──────────────────
|
||||
|
||||
const DialogUnifiedDiff: React.FC<{
|
||||
popup: ToolPopupContent;
|
||||
syntaxTheme: Record<string, React.CSSProperties>;
|
||||
isMobile: boolean;
|
||||
}> = React.memo(({ popup, syntaxTheme, isMobile }) => {
|
||||
const hunks = React.useMemo(() => parseDiffToUnified(popup.content), [popup.content]);
|
||||
|
||||
return (
|
||||
<div className="typography-code">
|
||||
{hunks.map((hunk, hunkIdx) => {
|
||||
const inputFile = (typeof popup.metadata?.input === 'object' && popup.metadata.input !== null)
|
||||
? ((popup.metadata.input as Record<string, unknown>).file_path || (popup.metadata.input as Record<string, unknown>).filePath)
|
||||
: null;
|
||||
const fileStr = typeof inputFile === 'string' ? inputFile : '';
|
||||
const hunkFileStr = typeof hunk.file === 'string' ? hunk.file : '';
|
||||
const lang = getLanguageFromExtension(fileStr || hunkFileStr || '') || 'text';
|
||||
|
||||
const codeLines: CodeLine[] = hunk.lines.map((line) => ({
|
||||
text: line.content,
|
||||
lineNumber: line.lineNumber || null,
|
||||
type: line.type as CodeLine['type'],
|
||||
}));
|
||||
|
||||
return (
|
||||
<div key={hunkIdx} className="border-b border-border/20 last:border-b-0">
|
||||
<div className={cn('bg-muted/20 px-3 py-2 font-medium text-muted-foreground border-b border-border/10 sticky top-0 z-10 break-words -mx-3', isMobile ? 'typography-micro' : 'typography-markdown')}>
|
||||
{`${hunk.file} (line ${hunk.oldStart})`}
|
||||
</div>
|
||||
<VirtualizedCodeBlock
|
||||
lines={codeLines}
|
||||
language={lang}
|
||||
syntaxTheme={syntaxTheme}
|
||||
maxHeight="70vh"
|
||||
lineStyles={(line) =>
|
||||
line.type === 'removed'
|
||||
? { backgroundColor: 'var(--tools-edit-removed-bg)', color: 'var(--tools-edit-removed)' }
|
||||
: line.type === 'added'
|
||||
? { backgroundColor: 'var(--tools-edit-added-bg)', color: 'var(--tools-edit-added)' }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DialogUnifiedDiff.displayName = 'DialogUnifiedDiff';
|
||||
|
||||
const DialogReadContent: React.FC<{
|
||||
popup: ToolPopupContent;
|
||||
syntaxTheme: Record<string, React.CSSProperties>;
|
||||
}> = React.memo(({ popup, syntaxTheme }) => {
|
||||
const parsedReadOutput = React.useMemo(() => parseReadToolOutput(popup.content), [popup.content]);
|
||||
|
||||
const codeLines: CodeLine[] = React.useMemo(() => {
|
||||
const inputMeta = popup.metadata?.input;
|
||||
const inputObj = typeof inputMeta === 'object' && inputMeta !== null ? (inputMeta as Record<string, unknown>) : {};
|
||||
const offset = typeof inputObj.offset === 'number' ? inputObj.offset : 0;
|
||||
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
|
||||
let fallbackLineCursor = offset;
|
||||
|
||||
return parsedReadOutput.lines.map((line) => {
|
||||
if (line.lineNumber !== null) {
|
||||
fallbackLineCursor = line.lineNumber;
|
||||
}
|
||||
const shouldAssignFallback =
|
||||
parsedReadOutput.type === 'file'
|
||||
&& !hasExplicitLineNumbers
|
||||
&& line.lineNumber === null
|
||||
&& !line.isInfo;
|
||||
const effectiveLineNumber = line.lineNumber ?? (shouldAssignFallback
|
||||
? (fallbackLineCursor += 1)
|
||||
: null);
|
||||
|
||||
return {
|
||||
text: line.text,
|
||||
lineNumber: effectiveLineNumber,
|
||||
isInfo: line.isInfo,
|
||||
};
|
||||
});
|
||||
}, [parsedReadOutput, popup.metadata?.input]);
|
||||
|
||||
return (
|
||||
<VirtualizedCodeBlock
|
||||
lines={codeLines}
|
||||
language={popup.language || 'text'}
|
||||
syntaxTheme={syntaxTheme}
|
||||
maxHeight="70vh"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DialogReadContent.displayName = 'DialogReadContent';
|
||||
|
||||
const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
|
||||
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>(isMobile ? 'unified' : 'side-by-side');
|
||||
|
||||
@@ -460,82 +559,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
|
||||
{popup.isDiff ? (
|
||||
diffViewMode === 'unified' ? (
|
||||
<div className="typography-code">
|
||||
{parseDiffToUnified(popup.content).map((hunk, hunkIdx) => (
|
||||
<div key={hunkIdx} className="border-b border-border/20 last:border-b-0">
|
||||
<div
|
||||
className={cn('bg-muted/20 px-3 py-2 font-medium text-muted-foreground border-b border-border/10 sticky top-0 z-10 break-words -mx-3', isMobile ? 'typography-micro' : 'typography-markdown')}
|
||||
>
|
||||
{`${hunk.file} (line ${hunk.oldStart})`}
|
||||
</div>
|
||||
<div>
|
||||
{hunk.lines.map((line, lineIdx) => (
|
||||
<div
|
||||
key={lineIdx}
|
||||
className={cn(
|
||||
'typography-code font-mono px-3 py-0.5 flex',
|
||||
line.type === 'context' && 'bg-transparent',
|
||||
line.type === 'removed' && 'bg-transparent',
|
||||
line.type === 'added' && 'bg-transparent'
|
||||
)}
|
||||
style={{
|
||||
lineHeight: '1.1',
|
||||
...(line.type === 'removed'
|
||||
? { backgroundColor: 'var(--tools-edit-removed-bg)' }
|
||||
: line.type === 'added'
|
||||
? { backgroundColor: 'var(--tools-edit-added-bg)' }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<span className="w-12 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5" style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}>
|
||||
{line.lineNumber || ''}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{(() => {
|
||||
const inputFile = (typeof popup.metadata?.input === 'object' && popup.metadata.input !== null)
|
||||
? ((popup.metadata.input as Record<string, unknown>).file_path || (popup.metadata.input as Record<string, unknown>).filePath)
|
||||
: null;
|
||||
const fileStr = typeof inputFile === 'string' ? inputFile : '';
|
||||
const hunkFile = (typeof hunk === 'object' && hunk !== null && 'file' in hunk) ? (hunk as UnifiedDiffHunk).file : null;
|
||||
const hunkFileStr = typeof hunkFile === 'string' ? hunkFile : '';
|
||||
const finalFile = fileStr || hunkFileStr || '';
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={getLanguageFromExtension(finalFile) || 'text'}
|
||||
PreTag="div"
|
||||
wrapLines
|
||||
wrapLongLines
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{line.content}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogUnifiedDiff popup={popup} syntaxTheme={syntaxTheme} isMobile={isMobile} />
|
||||
) : popup.diffHunks ? (
|
||||
<div className="typography-code">
|
||||
{popup.diffHunks.map((hunk, hunkIdx) => (
|
||||
@@ -741,77 +765,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
}
|
||||
|
||||
if (tool === 'read') {
|
||||
const parsedReadOutput = parseReadToolOutput(popup.content);
|
||||
|
||||
const inputMeta = popup.metadata?.input;
|
||||
const inputObj = typeof inputMeta === 'object' && inputMeta !== null ? (inputMeta as Record<string, unknown>) : {};
|
||||
const offset = typeof inputObj.offset === 'number' ? inputObj.offset : 0;
|
||||
let fallbackLineCursor = offset;
|
||||
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{parsedReadOutput.lines.map((line, idx: number) => {
|
||||
if (line.lineNumber !== null) {
|
||||
fallbackLineCursor = line.lineNumber;
|
||||
}
|
||||
|
||||
const shouldAssignFallbackLineNumber =
|
||||
parsedReadOutput.type === 'file'
|
||||
&& !hasExplicitLineNumbers
|
||||
&& line.lineNumber === null
|
||||
&& !line.isInfo;
|
||||
|
||||
const effectiveLineNumber = line.lineNumber ?? (shouldAssignFallbackLineNumber
|
||||
? (fallbackLineCursor += 1)
|
||||
: null);
|
||||
|
||||
const shouldShowLineNumber = !line.isInfo && effectiveLineNumber !== null;
|
||||
|
||||
return (
|
||||
<div key={idx} className={`typography-code font-mono flex ${line.isInfo ? 'text-muted-foreground/70 italic' : ''}`}>
|
||||
<span className="w-12 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5" style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}>
|
||||
{shouldShowLineNumber ? effectiveLineNumber : ''}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{line.isInfo ? (
|
||||
<div className="whitespace-pre-wrap break-words">{line.text}</div>
|
||||
) : (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={popup.language || 'text'}
|
||||
PreTag="div"
|
||||
wrapLines
|
||||
wrapLongLines
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: 'inherit',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{line.text}
|
||||
</SyntaxHighlighter>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
return <DialogReadContent popup={popup} syntaxTheme={syntaxTheme} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
formatInputForDisplay,
|
||||
parseReadToolOutput,
|
||||
} from '../toolRenderers';
|
||||
import { VirtualizedCodeBlock, type CodeLine } from './VirtualizedCodeBlock';
|
||||
|
||||
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
|
||||
|
||||
@@ -570,74 +571,48 @@ interface DiffPreviewProps {
|
||||
input?: ToolStateWithMetadata['input'];
|
||||
}
|
||||
|
||||
const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, syntaxTheme, input }) => (
|
||||
<div className="typography-code px-1 pb-1 pt-0 space-y-0">
|
||||
{parseDiffToUnified(diff).map((hunk, hunkIdx) => (
|
||||
<div key={hunkIdx} className="-mx-1 px-1 last:border-b-0" style={{ borderBottomWidth: '1px', borderBottomColor: 'var(--tools-border)' }}>
|
||||
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground break-words -mx-1" style={{ borderBottomWidth: '1px', borderBottomColor: 'var(--tools-border)' }}>
|
||||
{`${hunk.file} (line ${hunk.oldStart})`}
|
||||
</div>
|
||||
const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, syntaxTheme, input }) => {
|
||||
const hunks = React.useMemo(() => parseDiffToUnified(diff), [diff]);
|
||||
|
||||
<div>
|
||||
{hunk.lines.map((line, lineIdx) => (
|
||||
<div
|
||||
key={lineIdx}
|
||||
className={cn(
|
||||
'typography-code font-mono px-2 py-0.5 flex -mx-2',
|
||||
line.type === 'context' && 'bg-transparent',
|
||||
line.type === 'removed' && 'bg-transparent',
|
||||
line.type === 'added' && 'bg-transparent'
|
||||
)}
|
||||
style={
|
||||
line.type === 'removed'
|
||||
? { backgroundColor: 'var(--tools-edit-removed-bg)' }
|
||||
: line.type === 'added'
|
||||
? { backgroundColor: 'var(--tools-edit-added-bg)' }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<span className="w-10 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5" style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}>
|
||||
{line.lineNumber || ''}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={getLanguageFromExtension(typeof input?.file_path === 'string' ? input.file_path : typeof input?.filePath === 'string' ? input.filePath : hunk.file) || 'text'}
|
||||
PreTag="div"
|
||||
wrapLines
|
||||
wrapLongLines
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
overflowWrap: 'anywhere',
|
||||
color: line.type === 'removed' ? 'var(--tools-edit-removed)' : line.type === 'added' ? 'var(--tools-edit-added)' : 'inherit',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: 'inherit',
|
||||
color: line.type === 'removed' ? 'var(--tools-edit-removed)' : line.type === 'added' ? 'var(--tools-edit-added)' : 'inherit',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{line.content}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
return (
|
||||
<div className="typography-code px-1 pb-1 pt-0 space-y-0">
|
||||
{hunks.map((hunk, hunkIdx) => {
|
||||
const lang = getLanguageFromExtension(
|
||||
typeof input?.file_path === 'string' ? input.file_path
|
||||
: typeof input?.filePath === 'string' ? input.filePath
|
||||
: hunk.file
|
||||
) || 'text';
|
||||
|
||||
const codeLines: CodeLine[] = hunk.lines.map((line) => ({
|
||||
text: line.content,
|
||||
lineNumber: line.lineNumber || null,
|
||||
type: line.type as CodeLine['type'],
|
||||
}));
|
||||
|
||||
return (
|
||||
<div key={hunkIdx} className="-mx-1 px-1 last:border-b-0" style={{ borderBottomWidth: '1px', borderBottomColor: 'var(--tools-border)' }}>
|
||||
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground break-words -mx-1" style={{ borderBottomWidth: '1px', borderBottomColor: 'var(--tools-border)' }}>
|
||||
{`${hunk.file} (line ${hunk.oldStart})`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
<VirtualizedCodeBlock
|
||||
lines={codeLines}
|
||||
language={lang}
|
||||
syntaxTheme={syntaxTheme}
|
||||
maxHeight="50vh"
|
||||
lineStyles={(line) =>
|
||||
line.type === 'removed'
|
||||
? { backgroundColor: 'var(--tools-edit-removed-bg)', color: 'var(--tools-edit-removed)' }
|
||||
: line.type === 'added'
|
||||
? { backgroundColor: 'var(--tools-edit-added-bg)', color: 'var(--tools-edit-added)' }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DiffPreview.displayName = 'DiffPreview';
|
||||
|
||||
@@ -649,13 +624,20 @@ interface WriteInputPreviewProps {
|
||||
}
|
||||
|
||||
const WriteInputPreview: React.FC<WriteInputPreviewProps> = React.memo(({ content, syntaxTheme, filePath, displayPath }) => {
|
||||
const lines = React.useMemo(() => content.split('\n'), [content]);
|
||||
const language = React.useMemo(
|
||||
() => getLanguageFromExtension(filePath ?? '') || detectLanguageFromOutput(content, 'write', filePath ? { filePath } : undefined),
|
||||
[content, filePath]
|
||||
);
|
||||
|
||||
const lineCount = Math.max(lines.length, 1);
|
||||
const codeLines: CodeLine[] = React.useMemo(() => {
|
||||
const rawLines = content.split('\n');
|
||||
return rawLines.map((text, idx) => ({
|
||||
text: text || ' ',
|
||||
lineNumber: idx + 1,
|
||||
}));
|
||||
}, [content]);
|
||||
|
||||
const lineCount = Math.max(codeLines.length, 1);
|
||||
const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`;
|
||||
|
||||
return (
|
||||
@@ -663,47 +645,83 @@ const WriteInputPreview: React.FC<WriteInputPreviewProps> = React.memo(({ conten
|
||||
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground rounded-lg mb-1" style={{ borderWidth: '1px', borderColor: 'var(--tools-border)' }}>
|
||||
{`${displayPath} (${headerLineLabel})`}
|
||||
</div>
|
||||
<div className="space-y-0">
|
||||
{lines.map((line, lineIdx) => (
|
||||
<div key={lineIdx} className="typography-code font-mono px-2 py-0.5 flex -mx-1">
|
||||
<span className="w-10 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5" style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}>
|
||||
{lineIdx + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={language || 'text'}
|
||||
PreTag="div"
|
||||
wrapLines
|
||||
wrapLongLines
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' },
|
||||
}}
|
||||
>
|
||||
{line || ' '}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<VirtualizedCodeBlock
|
||||
lines={codeLines}
|
||||
language={language || 'text'}
|
||||
syntaxTheme={syntaxTheme}
|
||||
maxHeight="50vh"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
WriteInputPreview.displayName = 'WriteInputPreview';
|
||||
|
||||
// ── PERF-007: Read tool output with virtualised highlighting ─────────
|
||||
interface ReadToolVirtualizedProps {
|
||||
outputString: string;
|
||||
input?: Record<string, unknown>;
|
||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||
toolName: string;
|
||||
renderScrollableBlock: (
|
||||
content: React.ReactNode,
|
||||
options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string }
|
||||
) => React.ReactNode;
|
||||
}
|
||||
|
||||
const ReadToolVirtualized: React.FC<ReadToolVirtualizedProps> = React.memo(({
|
||||
outputString,
|
||||
input,
|
||||
syntaxTheme,
|
||||
toolName,
|
||||
renderScrollableBlock,
|
||||
}) => {
|
||||
const parsedReadOutput = React.useMemo(() => parseReadToolOutput(outputString), [outputString]);
|
||||
const offset = typeof input?.offset === 'number' ? input.offset : 0;
|
||||
|
||||
const codeLines: CodeLine[] = React.useMemo(() => {
|
||||
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
|
||||
let fallbackLineCursor = offset;
|
||||
|
||||
return parsedReadOutput.lines.map((line) => {
|
||||
if (line.lineNumber !== null) {
|
||||
fallbackLineCursor = line.lineNumber;
|
||||
}
|
||||
const shouldAssignFallback =
|
||||
parsedReadOutput.type === 'file'
|
||||
&& !hasExplicitLineNumbers
|
||||
&& line.lineNumber === null
|
||||
&& !line.isInfo;
|
||||
const effectiveLineNumber = line.lineNumber ?? (shouldAssignFallback
|
||||
? (fallbackLineCursor += 1)
|
||||
: null);
|
||||
|
||||
return {
|
||||
text: line.text,
|
||||
lineNumber: effectiveLineNumber,
|
||||
isInfo: line.isInfo,
|
||||
};
|
||||
});
|
||||
}, [parsedReadOutput, offset]);
|
||||
|
||||
const language = React.useMemo(() => {
|
||||
const contentForLanguage = parsedReadOutput.lines.map((l) => l.text).join('\n');
|
||||
return detectLanguageFromOutput(contentForLanguage, toolName, input as Record<string, unknown>);
|
||||
}, [parsedReadOutput, toolName, input]);
|
||||
|
||||
return renderScrollableBlock(
|
||||
<VirtualizedCodeBlock
|
||||
lines={codeLines}
|
||||
language={language}
|
||||
syntaxTheme={syntaxTheme}
|
||||
maxHeight="55vh"
|
||||
/>,
|
||||
{ className: 'p-1' }
|
||||
) as React.ReactElement;
|
||||
});
|
||||
|
||||
ReadToolVirtualized.displayName = 'ReadToolVirtualized';
|
||||
|
||||
interface ImagePreviewProps {
|
||||
content: string;
|
||||
filePath: string;
|
||||
@@ -969,72 +987,13 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
|
||||
if (hasStringOutput && outputString.trim()) {
|
||||
if (part.tool === 'read') {
|
||||
const parsedReadOutput = parseReadToolOutput(outputString);
|
||||
const offset = typeof input?.offset === 'number' ? input.offset : 0;
|
||||
const contentForLanguage = parsedReadOutput.lines.map((line) => line.text).join('\n');
|
||||
let fallbackLineCursor = offset;
|
||||
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
|
||||
|
||||
return renderScrollableBlock(
|
||||
<div className="typography-code w-full min-w-0 space-y-1">
|
||||
{parsedReadOutput.lines.map((line, idx) => {
|
||||
if (line.lineNumber !== null) {
|
||||
fallbackLineCursor = line.lineNumber;
|
||||
}
|
||||
const shouldAssignFallbackLineNumber =
|
||||
parsedReadOutput.type === 'file'
|
||||
&& !hasExplicitLineNumbers
|
||||
&& line.lineNumber === null
|
||||
&& !line.isInfo;
|
||||
const effectiveLineNumber = line.lineNumber ?? (shouldAssignFallbackLineNumber
|
||||
? (fallbackLineCursor += 1)
|
||||
: null);
|
||||
const shouldShowLineNumber = !line.isInfo && effectiveLineNumber !== null;
|
||||
|
||||
return (
|
||||
<div key={idx} className={cn('typography-code font-mono flex w-full min-w-0', line.isInfo && 'text-muted-foreground/70 italic')}>
|
||||
<span className="w-10 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5" style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}>
|
||||
{shouldShowLineNumber ? effectiveLineNumber : ''}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{line.isInfo ? (
|
||||
<div className="whitespace-pre-wrap break-words">{line.text}</div>
|
||||
) : (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={detectLanguageFromOutput(contentForLanguage, part.tool, input as Record<string, unknown>)}
|
||||
PreTag="div"
|
||||
wrapLines
|
||||
wrapLongLines
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{line.text}
|
||||
</SyntaxHighlighter>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
{ className: 'p-1' }
|
||||
);
|
||||
return <ReadToolVirtualized
|
||||
outputString={outputString}
|
||||
input={input}
|
||||
syntaxTheme={syntaxTheme}
|
||||
toolName={part.tool}
|
||||
renderScrollableBlock={renderScrollableBlock}
|
||||
/>;
|
||||
}
|
||||
|
||||
return renderScrollableBlock(
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* VirtualizedCodeBlock — PERF-007
|
||||
*
|
||||
* Replaces per-line <SyntaxHighlighter> with:
|
||||
* 1. ONE Prism.highlight() call to tokenize all code at once
|
||||
* 2. @tanstack/react-virtual to only render visible rows
|
||||
*
|
||||
* This drops mount cost from O(N * Prism) to O(1 * Prism) + O(visible_rows).
|
||||
* For a 2000-line file, ~2000 SyntaxHighlighter instances → ~30 plain <div>s.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import Prism from 'prismjs';
|
||||
|
||||
// Ensure common languages are loaded (react-syntax-highlighter lazy-loads them,
|
||||
// but we call Prism directly so we need them registered).
|
||||
import 'prismjs/components/prism-markup';
|
||||
import 'prismjs/components/prism-markup-templating';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
import 'prismjs/components/prism-javascript';
|
||||
import 'prismjs/components/prism-jsx';
|
||||
import 'prismjs/components/prism-tsx';
|
||||
import 'prismjs/components/prism-css';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-bash';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/components/prism-rust';
|
||||
import 'prismjs/components/prism-go';
|
||||
import 'prismjs/components/prism-java';
|
||||
import 'prismjs/components/prism-c';
|
||||
import 'prismjs/components/prism-cpp';
|
||||
import 'prismjs/components/prism-csharp';
|
||||
import 'prismjs/components/prism-ruby';
|
||||
import 'prismjs/components/prism-yaml';
|
||||
import 'prismjs/components/prism-toml';
|
||||
import 'prismjs/components/prism-markdown';
|
||||
import 'prismjs/components/prism-sql';
|
||||
import 'prismjs/components/prism-diff';
|
||||
import 'prismjs/components/prism-docker';
|
||||
import 'prismjs/components/prism-swift';
|
||||
import 'prismjs/components/prism-kotlin';
|
||||
import 'prismjs/components/prism-lua';
|
||||
import 'prismjs/components/prism-php';
|
||||
import 'prismjs/components/prism-scss';
|
||||
|
||||
// ── Threshold: files smaller than this render without virtualization ──
|
||||
const VIRTUALIZE_THRESHOLD = 80;
|
||||
const ROW_HEIGHT = 20; // px — matches typography-code line-height
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
export interface CodeLine {
|
||||
text: string;
|
||||
lineNumber?: number | null;
|
||||
isInfo?: boolean;
|
||||
/** For diff lines */
|
||||
type?: 'context' | 'added' | 'removed';
|
||||
}
|
||||
|
||||
interface VirtualizedCodeBlockProps {
|
||||
lines: CodeLine[];
|
||||
language: string;
|
||||
syntaxTheme: Record<string, React.CSSProperties>;
|
||||
/** Max visible height in CSS (default: 60vh) */
|
||||
maxHeight?: string;
|
||||
/** Show line numbers (default: true) */
|
||||
showLineNumbers?: boolean;
|
||||
/** Styles per line type (for diffs) */
|
||||
lineStyles?: (line: CodeLine) => React.CSSProperties | undefined;
|
||||
}
|
||||
|
||||
const toKebabCase = (value: string): string => value.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
|
||||
|
||||
const styleObjectToCss = (style: React.CSSProperties): string => {
|
||||
return Object.entries(style)
|
||||
.filter(([, v]) => v !== undefined && v !== null)
|
||||
.map(([k, v]) => `${toKebabCase(k)}:${String(v)};`)
|
||||
.join('');
|
||||
};
|
||||
|
||||
const buildSelectorList = (rawKey: string): string[] => {
|
||||
return rawKey
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.flatMap((selector) => {
|
||||
if (selector.startsWith('.token')) {
|
||||
return [`.oc-virtualized-prism ${selector}`];
|
||||
}
|
||||
if (selector.startsWith('token.')) {
|
||||
return [`.oc-virtualized-prism .${selector}`];
|
||||
}
|
||||
if (/^[a-z0-9_-]+$/i.test(selector)) {
|
||||
return [`.oc-virtualized-prism .token.${selector}`];
|
||||
}
|
||||
if (selector.includes('token')) {
|
||||
return [`.oc-virtualized-prism ${selector}`];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
const buildPrismThemeCss = (theme: Record<string, React.CSSProperties>): string => {
|
||||
const rules: string[] = [];
|
||||
Object.entries(theme).forEach(([rawKey, style]) => {
|
||||
const selectors = buildSelectorList(rawKey);
|
||||
if (selectors.length === 0) {
|
||||
return;
|
||||
}
|
||||
const css = styleObjectToCss(style);
|
||||
if (!css) {
|
||||
return;
|
||||
}
|
||||
rules.push(`${selectors.join(',')}{${css}}`);
|
||||
});
|
||||
return rules.join('\n');
|
||||
};
|
||||
|
||||
const LANGUAGE_ALIASES: Record<string, string> = {
|
||||
text: 'plain',
|
||||
plaintext: 'plain',
|
||||
shell: 'bash',
|
||||
sh: 'bash',
|
||||
zsh: 'bash',
|
||||
patch: 'diff',
|
||||
dockerfile: 'docker',
|
||||
js: 'javascript',
|
||||
ts: 'typescript',
|
||||
};
|
||||
|
||||
const normalizeLanguage = (language: string): string => {
|
||||
const lower = language.toLowerCase();
|
||||
return LANGUAGE_ALIASES[lower] ?? lower;
|
||||
};
|
||||
|
||||
const HIGHLIGHT_CACHE_MAX = 5000;
|
||||
const highlightCache = new Map<string, string>();
|
||||
|
||||
const highlightLine = (text: string, language: string): string => {
|
||||
const normalizedLanguage = normalizeLanguage(language);
|
||||
const cacheKey = `${normalizedLanguage}\n${text}`;
|
||||
const cached = highlightCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const grammar = Prism.languages[normalizedLanguage] ?? Prism.languages.text;
|
||||
if (!grammar) {
|
||||
const escaped = escapeHtml(text);
|
||||
highlightCache.set(cacheKey, escaped);
|
||||
return escaped;
|
||||
}
|
||||
|
||||
try {
|
||||
const highlighted = Prism.highlight(text, grammar, normalizedLanguage);
|
||||
if (highlightCache.size >= HIGHLIGHT_CACHE_MAX) {
|
||||
const oldestKey = highlightCache.keys().next().value;
|
||||
if (typeof oldestKey === 'string') {
|
||||
highlightCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
highlightCache.set(cacheKey, highlighted);
|
||||
return highlighted;
|
||||
} catch {
|
||||
const escaped = escapeHtml(text);
|
||||
highlightCache.set(cacheKey, escaped);
|
||||
return escaped;
|
||||
}
|
||||
};
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────
|
||||
export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.memo((props) => {
|
||||
const {
|
||||
lines,
|
||||
language,
|
||||
syntaxTheme,
|
||||
maxHeight = '60vh',
|
||||
showLineNumbers = true,
|
||||
lineStyles,
|
||||
} = props;
|
||||
const prismThemeCss = React.useMemo(() => buildPrismThemeCss(syntaxTheme), [syntaxTheme]);
|
||||
|
||||
const shouldVirtualize = lines.length > VIRTUALIZE_THRESHOLD;
|
||||
|
||||
// ── Small file: render directly (no virtualizer overhead) ──
|
||||
if (!shouldVirtualize) {
|
||||
return (
|
||||
<div
|
||||
className="typography-code font-mono w-full min-w-0 oc-virtualized-prism"
|
||||
style={{ maxHeight, overflowY: 'auto' }}
|
||||
>
|
||||
{prismThemeCss ? <style>{prismThemeCss}</style> : null}
|
||||
{lines.map((line, idx) => (
|
||||
<Row
|
||||
key={idx}
|
||||
line={line}
|
||||
language={language}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={lineStyles?.(line)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Large file: virtualise ──
|
||||
return (
|
||||
<VirtualizedRows
|
||||
lines={lines}
|
||||
language={language}
|
||||
prismThemeCss={prismThemeCss}
|
||||
maxHeight={maxHeight}
|
||||
showLineNumbers={showLineNumbers}
|
||||
lineStyles={lineStyles}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
VirtualizedCodeBlock.displayName = 'VirtualizedCodeBlock';
|
||||
|
||||
// ── Virtualised container (extracted so the hook is top-level) ────────
|
||||
interface VirtualizedRowsProps {
|
||||
lines: CodeLine[];
|
||||
language: string;
|
||||
prismThemeCss: string;
|
||||
maxHeight: string;
|
||||
showLineNumbers: boolean;
|
||||
lineStyles?: (line: CodeLine) => React.CSSProperties | undefined;
|
||||
}
|
||||
|
||||
const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
|
||||
lines,
|
||||
language,
|
||||
prismThemeCss,
|
||||
maxHeight,
|
||||
showLineNumbers,
|
||||
lineStyles,
|
||||
}) => {
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: lines.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 20, // render 20 extra rows above/below viewport
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="typography-code font-mono w-full min-w-0 oc-virtualized-prism"
|
||||
style={{ maxHeight, overflowY: 'auto' }}
|
||||
>
|
||||
{prismThemeCss ? <style>{prismThemeCss}</style> : null}
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const line = lines[vItem.index];
|
||||
return (
|
||||
<div
|
||||
key={vItem.index}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${vItem.size}px`,
|
||||
transform: `translateY(${vItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<Row
|
||||
line={line}
|
||||
language={language}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={lineStyles?.(line)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
VirtualizedRows.displayName = 'VirtualizedRows';
|
||||
|
||||
// ── Single row ───────────────────────────────────────────────────────
|
||||
interface RowProps {
|
||||
line: CodeLine;
|
||||
language: string;
|
||||
showLineNumbers: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const Row: React.FC<RowProps> = React.memo(({ line, language, showLineNumbers, style }) => {
|
||||
const html = React.useMemo(() => highlightLine(line.text, language), [line.text, language]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="typography-code font-mono flex w-full min-w-0"
|
||||
style={style}
|
||||
>
|
||||
{showLineNumbers && (
|
||||
<span
|
||||
className="w-10 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5"
|
||||
style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}
|
||||
>
|
||||
{!line.isInfo && line.lineNumber != null ? line.lineNumber : ''}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
{line.isInfo ? (
|
||||
<div className="whitespace-pre-wrap break-words text-muted-foreground/70 italic">
|
||||
{line.text}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="whitespace-pre-wrap break-all"
|
||||
style={{ overflowWrap: 'anywhere' }}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Row.displayName = 'VirtualizedCodeBlock.Row';
|
||||
Reference in New Issue
Block a user