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
@@ -151,6 +151,7 @@
|
|||||||
"@remixicon/react": "^4.7.0",
|
"@remixicon/react": "^4.7.0",
|
||||||
"@streamdown/code": "^1.0.2",
|
"@streamdown/code": "^1.0.2",
|
||||||
"@streamdown/mermaid": "^1.0.2",
|
"@streamdown/mermaid": "^1.0.2",
|
||||||
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -163,6 +164,7 @@
|
|||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
@@ -180,6 +182,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@tauri-apps/api": "^2.9.0",
|
"@tauri-apps/api": "^2.9.0",
|
||||||
"@types/node": "^24.3.1",
|
"@types/node": "^24.3.1",
|
||||||
|
"@types/prismjs": "^1.26.6",
|
||||||
"@types/react": "^19.1.10",
|
"@types/react": "^19.1.10",
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.1.7",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
@@ -1105,6 +1108,10 @@
|
|||||||
|
|
||||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="],
|
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="],
|
||||||
|
|
||||||
|
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.18", "", { "dependencies": { "@tanstack/virtual-core": "3.13.18" }, "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-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A=="],
|
||||||
|
|
||||||
|
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.18", "", {}, "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg=="],
|
||||||
|
|
||||||
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
||||||
|
|
||||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.0", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.0", "@tauri-apps/cli-darwin-x64": "2.10.0", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0", "@tauri-apps/cli-linux-arm64-gnu": "2.10.0", "@tauri-apps/cli-linux-arm64-musl": "2.10.0", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.0", "@tauri-apps/cli-linux-x64-gnu": "2.10.0", "@tauri-apps/cli-linux-x64-musl": "2.10.0", "@tauri-apps/cli-win32-arm64-msvc": "2.10.0", "@tauri-apps/cli-win32-ia32-msvc": "2.10.0", "@tauri-apps/cli-win32-x64-msvc": "2.10.0" }, "bin": { "tauri": "tauri.js" } }, "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q=="],
|
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.0", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.0", "@tauri-apps/cli-darwin-x64": "2.10.0", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0", "@tauri-apps/cli-linux-arm64-gnu": "2.10.0", "@tauri-apps/cli-linux-arm64-musl": "2.10.0", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.0", "@tauri-apps/cli-linux-x64-gnu": "2.10.0", "@tauri-apps/cli-linux-x64-musl": "2.10.0", "@tauri-apps/cli-win32-arm64-msvc": "2.10.0", "@tauri-apps/cli-win32-ia32-msvc": "2.10.0", "@tauri-apps/cli-win32-x64-msvc": "2.10.0" }, "bin": { "tauri": "tauri.js" } }, "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q=="],
|
||||||
@@ -1251,6 +1258,8 @@
|
|||||||
|
|
||||||
"@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="],
|
"@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="],
|
||||||
|
|
||||||
|
"@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.2.13", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="],
|
"@types/react": ["@types/react@19.2.13", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="],
|
||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
"@remixicon/react": "^4.7.0",
|
"@remixicon/react": "^4.7.0",
|
||||||
"@streamdown/code": "^1.0.2",
|
"@streamdown/code": "^1.0.2",
|
||||||
"@streamdown/mermaid": "^1.0.2",
|
"@streamdown/mermaid": "^1.0.2",
|
||||||
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
@@ -83,6 +85,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@tauri-apps/api": "^2.9.0",
|
"@tauri-apps/api": "^2.9.0",
|
||||||
"@types/node": "^24.3.1",
|
"@types/node": "^24.3.1",
|
||||||
|
"@types/prismjs": "^1.26.6",
|
||||||
"@types/react": "^19.1.10",
|
"@types/react": "^19.1.10",
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.1.7",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from './toolRenderers';
|
} from './toolRenderers';
|
||||||
import type { ToolPopupContent, DiffViewMode } from './types';
|
import type { ToolPopupContent, DiffViewMode } from './types';
|
||||||
import { DiffViewToggle } from './DiffViewToggle';
|
import { DiffViewToggle } from './DiffViewToggle';
|
||||||
|
import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock';
|
||||||
|
|
||||||
interface ToolOutputDialogProps {
|
interface ToolOutputDialogProps {
|
||||||
popup: ToolPopupContent;
|
popup: ToolPopupContent;
|
||||||
@@ -355,6 +356,104 @@ const ImagePreviewDialog: React.FC<{
|
|||||||
return createPortal(content, document.body);
|
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 ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
|
||||||
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>(isMobile ? 'unified' : 'side-by-side');
|
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>(isMobile ? 'unified' : 'side-by-side');
|
||||||
|
|
||||||
@@ -460,82 +559,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
|||||||
|
|
||||||
{popup.isDiff ? (
|
{popup.isDiff ? (
|
||||||
diffViewMode === 'unified' ? (
|
diffViewMode === 'unified' ? (
|
||||||
<div className="typography-code">
|
<DialogUnifiedDiff popup={popup} syntaxTheme={syntaxTheme} isMobile={isMobile} />
|
||||||
{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>
|
|
||||||
) : popup.diffHunks ? (
|
) : popup.diffHunks ? (
|
||||||
<div className="typography-code">
|
<div className="typography-code">
|
||||||
{popup.diffHunks.map((hunk, hunkIdx) => (
|
{popup.diffHunks.map((hunk, hunkIdx) => (
|
||||||
@@ -741,77 +765,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tool === 'read') {
|
if (tool === 'read') {
|
||||||
const parsedReadOutput = parseReadToolOutput(popup.content);
|
return <DialogReadContent popup={popup} syntaxTheme={syntaxTheme} />;
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
formatInputForDisplay,
|
formatInputForDisplay,
|
||||||
parseReadToolOutput,
|
parseReadToolOutput,
|
||||||
} from '../toolRenderers';
|
} 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 } };
|
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'];
|
input?: ToolStateWithMetadata['input'];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, syntaxTheme, input }) => (
|
const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, syntaxTheme, input }) => {
|
||||||
<div className="typography-code px-1 pb-1 pt-0 space-y-0">
|
const hunks = React.useMemo(() => parseDiffToUnified(diff), [diff]);
|
||||||
{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>
|
|
||||||
|
|
||||||
<div>
|
return (
|
||||||
{hunk.lines.map((line, lineIdx) => (
|
<div className="typography-code px-1 pb-1 pt-0 space-y-0">
|
||||||
<div
|
{hunks.map((hunk, hunkIdx) => {
|
||||||
key={lineIdx}
|
const lang = getLanguageFromExtension(
|
||||||
className={cn(
|
typeof input?.file_path === 'string' ? input.file_path
|
||||||
'typography-code font-mono px-2 py-0.5 flex -mx-2',
|
: typeof input?.filePath === 'string' ? input.filePath
|
||||||
line.type === 'context' && 'bg-transparent',
|
: hunk.file
|
||||||
line.type === 'removed' && 'bg-transparent',
|
) || 'text';
|
||||||
line.type === 'added' && 'bg-transparent'
|
|
||||||
)}
|
const codeLines: CodeLine[] = hunk.lines.map((line) => ({
|
||||||
style={
|
text: line.content,
|
||||||
line.type === 'removed'
|
lineNumber: line.lineNumber || null,
|
||||||
? { backgroundColor: 'var(--tools-edit-removed-bg)' }
|
type: line.type as CodeLine['type'],
|
||||||
: line.type === 'added'
|
}));
|
||||||
? { backgroundColor: 'var(--tools-edit-added-bg)' }
|
|
||||||
: {}
|
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)' }}>
|
||||||
<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)' }}>
|
{`${hunk.file} (line ${hunk.oldStart})`}
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<VirtualizedCodeBlock
|
||||||
</div>
|
lines={codeLines}
|
||||||
</div>
|
language={lang}
|
||||||
))}
|
syntaxTheme={syntaxTheme}
|
||||||
</div>
|
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';
|
DiffPreview.displayName = 'DiffPreview';
|
||||||
|
|
||||||
@@ -649,13 +624,20 @@ interface WriteInputPreviewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const WriteInputPreview: React.FC<WriteInputPreviewProps> = React.memo(({ content, syntaxTheme, filePath, displayPath }) => {
|
const WriteInputPreview: React.FC<WriteInputPreviewProps> = React.memo(({ content, syntaxTheme, filePath, displayPath }) => {
|
||||||
const lines = React.useMemo(() => content.split('\n'), [content]);
|
|
||||||
const language = React.useMemo(
|
const language = React.useMemo(
|
||||||
() => getLanguageFromExtension(filePath ?? '') || detectLanguageFromOutput(content, 'write', filePath ? { filePath } : undefined),
|
() => getLanguageFromExtension(filePath ?? '') || detectLanguageFromOutput(content, 'write', filePath ? { filePath } : undefined),
|
||||||
[content, filePath]
|
[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}`;
|
const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`;
|
||||||
|
|
||||||
return (
|
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)' }}>
|
<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})`}
|
{`${displayPath} (${headerLineLabel})`}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0">
|
<VirtualizedCodeBlock
|
||||||
{lines.map((line, lineIdx) => (
|
lines={codeLines}
|
||||||
<div key={lineIdx} className="typography-code font-mono px-2 py-0.5 flex -mx-1">
|
language={language || 'text'}
|
||||||
<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)' }}>
|
syntaxTheme={syntaxTheme}
|
||||||
{lineIdx + 1}
|
maxHeight="50vh"
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
WriteInputPreview.displayName = 'WriteInputPreview';
|
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 {
|
interface ImagePreviewProps {
|
||||||
content: string;
|
content: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
@@ -969,72 +987,13 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
|
|
||||||
if (hasStringOutput && outputString.trim()) {
|
if (hasStringOutput && outputString.trim()) {
|
||||||
if (part.tool === 'read') {
|
if (part.tool === 'read') {
|
||||||
const parsedReadOutput = parseReadToolOutput(outputString);
|
return <ReadToolVirtualized
|
||||||
const offset = typeof input?.offset === 'number' ? input.offset : 0;
|
outputString={outputString}
|
||||||
const contentForLanguage = parsedReadOutput.lines.map((line) => line.text).join('\n');
|
input={input}
|
||||||
let fallbackLineCursor = offset;
|
syntaxTheme={syntaxTheme}
|
||||||
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
|
toolName={part.tool}
|
||||||
|
renderScrollableBlock={renderScrollableBlock}
|
||||||
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 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';
|
||||||
@@ -3,7 +3,7 @@ import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client'
|
|||||||
import { saveSessionCursor } from '@/lib/messageCursorPersistence';
|
import { saveSessionCursor } from '@/lib/messageCursorPersistence';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useMessageStore } from '@/stores/messageStore';
|
import { useMessageStore } from '@/stores/messageStore';
|
||||||
import { getMessageLimit } from '@/stores/types/sessionTypes';
|
import { getMessageLimit, STUCK_SESSION_TIMEOUT_MS } from '@/stores/types/sessionTypes';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore';
|
import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
@@ -85,6 +85,8 @@ const isIdNewer = (id: string, referenceId: string): boolean => {
|
|||||||
return currentSortable > referenceSortable;
|
return currentSortable > referenceSortable;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_MESSAGE_CACHE_SIZE = 500;
|
||||||
|
const MESSAGE_CACHE_EVICT_COUNT = 100;
|
||||||
const messageCache = new Map<string, { sessionId: string; message: { info: Message; parts: Part[] } | null }>();
|
const messageCache = new Map<string, { sessionId: string; message: { info: Message; parts: Part[] } | null }>();
|
||||||
const getMessageFromStore = (sessionId: string, messageId: string): { info: Message; parts: Part[] } | null => {
|
const getMessageFromStore = (sessionId: string, messageId: string): { info: Message; parts: Part[] } | null => {
|
||||||
const cacheKey = `${sessionId}:${messageId}`;
|
const cacheKey = `${sessionId}:${messageId}`;
|
||||||
@@ -96,7 +98,16 @@ const getMessageFromStore = (sessionId: string, messageId: string): { info: Mess
|
|||||||
const storeState = useSessionStore.getState();
|
const storeState = useSessionStore.getState();
|
||||||
const sessionMessages = storeState.messages.get(sessionId) || [];
|
const sessionMessages = storeState.messages.get(sessionId) || [];
|
||||||
const message = sessionMessages.find(m => m.info.id === messageId) || null;
|
const message = sessionMessages.find(m => m.info.id === messageId) || null;
|
||||||
|
|
||||||
|
if (messageCache.size >= MAX_MESSAGE_CACHE_SIZE) {
|
||||||
|
// Evict oldest entries (Map preserves insertion order)
|
||||||
|
let count = 0;
|
||||||
|
for (const key of messageCache.keys()) {
|
||||||
|
if (count++ >= MESSAGE_CACHE_EVICT_COUNT) break;
|
||||||
|
messageCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
messageCache.set(cacheKey, { sessionId, message });
|
messageCache.set(cacheKey, { sessionId, message });
|
||||||
return message;
|
return message;
|
||||||
};
|
};
|
||||||
@@ -581,6 +592,9 @@ export const useEventStream = () => {
|
|||||||
const prevDirectory = previousSessionDirectoryRef.current;
|
const prevDirectory = previousSessionDirectoryRef.current;
|
||||||
|
|
||||||
if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) {
|
if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) {
|
||||||
|
// Clear the message cache on session switch to free memory
|
||||||
|
messageCache.clear();
|
||||||
|
|
||||||
if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) {
|
if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) {
|
||||||
// Removed: void refreshSessionStatus();
|
// Removed: void refreshSessionStatus();
|
||||||
}
|
}
|
||||||
@@ -1465,8 +1479,9 @@ export const useEventStream = () => {
|
|||||||
|
|
||||||
import('sonner').then(({ toast }) => {
|
import('sonner').then(({ toast }) => {
|
||||||
toast.warning('Permission required', {
|
toast.warning('Permission required', {
|
||||||
|
id: toastKey,
|
||||||
description: sessionTitle,
|
description: sessionTitle,
|
||||||
duration: Infinity,
|
duration: 30000,
|
||||||
action: {
|
action: {
|
||||||
label: 'Open',
|
label: 'Open',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -1536,8 +1551,9 @@ export const useEventStream = () => {
|
|||||||
|
|
||||||
import('sonner').then(({ toast }) => {
|
import('sonner').then(({ toast }) => {
|
||||||
toast.info('Input needed', {
|
toast.info('Input needed', {
|
||||||
|
id: toastKey,
|
||||||
description: sessionTitle,
|
description: sessionTitle,
|
||||||
duration: Infinity,
|
duration: 30000,
|
||||||
action: {
|
action: {
|
||||||
label: 'Open',
|
label: 'Open',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -1638,6 +1654,29 @@ export const useEventStream = () => {
|
|||||||
updateSessionStatus,
|
updateSessionStatus,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// --- Stable callback refs (Part A) ---
|
||||||
|
// Keep refs up to date with the latest version of each callback.
|
||||||
|
// This lets startStream use stable wrappers with empty deps so SSE connections
|
||||||
|
// are NOT torn down on every session switch.
|
||||||
|
const handleEventRef = React.useRef(handleEvent);
|
||||||
|
React.useEffect(() => {
|
||||||
|
handleEventRef.current = handleEvent;
|
||||||
|
}, [handleEvent]);
|
||||||
|
|
||||||
|
const bootstrapStateRef = React.useRef(bootstrapState);
|
||||||
|
React.useEffect(() => {
|
||||||
|
bootstrapStateRef.current = bootstrapState;
|
||||||
|
}, [bootstrapState]);
|
||||||
|
|
||||||
|
// Stable wrappers — identity never changes, so startStream deps stay minimal.
|
||||||
|
const stableHandleEvent = React.useCallback((event: EventData) => {
|
||||||
|
handleEventRef.current(event);
|
||||||
|
}, []); // intentionally empty deps
|
||||||
|
|
||||||
|
const stableBootstrapState = React.useCallback((reason: string) => {
|
||||||
|
return bootstrapStateRef.current(reason);
|
||||||
|
}, []); // intentionally empty deps
|
||||||
|
|
||||||
const shouldHoldConnection = React.useCallback(() => {
|
const shouldHoldConnection = React.useCallback(() => {
|
||||||
const currentVisibility = resolveVisibilityState();
|
const currentVisibility = resolveVisibilityState();
|
||||||
visibilityStateRef.current = currentVisibility;
|
visibilityStateRef.current = currentVisibility;
|
||||||
@@ -1732,12 +1771,12 @@ export const useEventStream = () => {
|
|||||||
// Removed: void refreshSessionStatus();
|
// Removed: void refreshSessionStatus();
|
||||||
|
|
||||||
if (shouldRefresh) {
|
if (shouldRefresh) {
|
||||||
void bootstrapState('sse_reconnected');
|
void stableBootstrapState('sse_reconnected');
|
||||||
} else {
|
} else {
|
||||||
const sessionId = currentSessionIdRef.current;
|
const sessionId = currentSessionIdRef.current;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scheduleSoftResync(sessionId, 'sse_reconnected', getMessageLimit())
|
scheduleSoftResyncRef.current(sessionId, 'sse_reconnected', getMessageLimit())
|
||||||
.then(() => requestSessionMetadataRefresh(sessionId))
|
.then(() => requestSessionMetadataRefresh(sessionId))
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
console.warn('[useEventStream] Failed to resync messages after reconnect:', error);
|
console.warn('[useEventStream] Failed to resync messages after reconnect:', error);
|
||||||
@@ -1776,7 +1815,7 @@ export const useEventStream = () => {
|
|||||||
? { ...baseProperties, directory: event.directory }
|
? { ...baseProperties, directory: event.directory }
|
||||||
: baseProperties;
|
: baseProperties;
|
||||||
|
|
||||||
handleEvent({
|
stableHandleEvent({
|
||||||
type: typeof (payload as { type?: unknown }).type === 'string' ? (payload as { type: string }).type : '',
|
type: typeof (payload as { type?: unknown }).type === 'string' ? (payload as { type: string }).type : '',
|
||||||
properties,
|
properties,
|
||||||
});
|
});
|
||||||
@@ -1808,13 +1847,11 @@ export const useEventStream = () => {
|
|||||||
stopStream,
|
stopStream,
|
||||||
publishStatus,
|
publishStatus,
|
||||||
checkConnection,
|
checkConnection,
|
||||||
scheduleSoftResync,
|
|
||||||
requestSessionMetadataRefresh,
|
requestSessionMetadataRefresh,
|
||||||
handleEvent,
|
stableHandleEvent,
|
||||||
|
stableBootstrapState,
|
||||||
effectiveDirectory,
|
effectiveDirectory,
|
||||||
|
|
||||||
debugConnectionState,
|
debugConnectionState,
|
||||||
bootstrapState
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const scheduleReconnect = React.useCallback((hint?: string) => {
|
const scheduleReconnect = React.useCallback((hint?: string) => {
|
||||||
@@ -2026,8 +2063,27 @@ export const useEventStream = () => {
|
|||||||
}
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
|
// Part B: Idle timeout recovery — scan for sessions stuck in 'busy'/'retry'
|
||||||
|
// with no recent SSE events and force-reset them to 'idle'.
|
||||||
|
const stuckCheckInterval = setInterval(() => {
|
||||||
|
const sessionStatus = useSessionStore.getState().sessionStatus;
|
||||||
|
if (!sessionStatus) return;
|
||||||
|
const now = Date.now();
|
||||||
|
sessionStatus.forEach((status, sessionId) => {
|
||||||
|
if (status.type !== 'busy' && status.type !== 'retry') return;
|
||||||
|
const lastMsgAt = lastMessageEventBySessionRef.current.get(sessionId) ?? 0;
|
||||||
|
const busyTooLong = now - lastMsgAt > STUCK_SESSION_TIMEOUT_MS;
|
||||||
|
const noRecentEvents = now - lastMsgAt > 60000;
|
||||||
|
if (busyTooLong && noRecentEvents) {
|
||||||
|
console.warn('[useEventStream] Session stuck in busy state, forcing idle:', sessionId);
|
||||||
|
updateSessionStatus(sessionId, { type: 'idle' }, 'timeout_recovery');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 30000); // check every 30s
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(startTimer);
|
clearTimeout(startTimer);
|
||||||
|
clearInterval(stuckCheckInterval);
|
||||||
|
|
||||||
void desktopActivityHandler;
|
void desktopActivityHandler;
|
||||||
|
|
||||||
@@ -2086,5 +2142,6 @@ export const useEventStream = () => {
|
|||||||
maybeBootstrapIfStale,
|
maybeBootstrapIfStale,
|
||||||
resyncMessages,
|
resyncMessages,
|
||||||
scheduleSoftResync,
|
scheduleSoftResync,
|
||||||
|
updateSessionStatus,
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,18 +30,6 @@ const cleanupPendingUserMessageMeta = (
|
|||||||
return nextPending;
|
return nextPending;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface QueuedPart {
|
|
||||||
sessionId: string;
|
|
||||||
messageId: string;
|
|
||||||
part: Part;
|
|
||||||
role?: string;
|
|
||||||
currentSessionId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let batchQueue: QueuedPart[] = [];
|
|
||||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
const USER_BATCH_WINDOW_MS = 50;
|
|
||||||
const COMPACTION_WINDOW_MS = 30_000;
|
const COMPACTION_WINDOW_MS = 30_000;
|
||||||
|
|
||||||
const timeoutRegistry = new Map<string, ReturnType<typeof setTimeout>>();
|
const timeoutRegistry = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
@@ -1123,14 +1111,15 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
...memoryState,
|
...memoryState,
|
||||||
backgroundMessageCount: (memoryState.backgroundMessageCount || 0) + 1,
|
backgroundMessageCount: (memoryState.backgroundMessageCount || 0) + 1,
|
||||||
});
|
});
|
||||||
state.sessionMemoryState = newMemoryState;
|
updates.sessionMemoryState = newMemoryState;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actualRole === 'assistant') {
|
if (actualRole === 'assistant') {
|
||||||
const currentMemoryState = state.sessionMemoryState.get(sessionId);
|
const baseMemoryMap = updates.sessionMemoryState ?? state.sessionMemoryState;
|
||||||
|
const currentMemoryState = baseMemoryMap.get(sessionId);
|
||||||
if (currentMemoryState) {
|
if (currentMemoryState) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const nextMemoryState = new Map(state.sessionMemoryState);
|
const nextMemoryState = new Map(baseMemoryMap);
|
||||||
nextMemoryState.set(sessionId, {
|
nextMemoryState.set(sessionId, {
|
||||||
...currentMemoryState,
|
...currentMemoryState,
|
||||||
isStreaming: true,
|
isStreaming: true,
|
||||||
@@ -1138,7 +1127,7 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
lastAccessedAt: now,
|
lastAccessedAt: now,
|
||||||
isZombie: false,
|
isZombie: false,
|
||||||
});
|
});
|
||||||
state.sessionMemoryState = nextMemoryState;
|
updates.sessionMemoryState = nextMemoryState;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1206,7 +1195,7 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
const newMessages = new Map(state.messages);
|
const newMessages = new Map(state.messages);
|
||||||
newMessages.set(sessionId, updatedMessages);
|
newMessages.set(sessionId, updatedMessages);
|
||||||
|
|
||||||
return finalizeAbortState({ messages: newMessages });
|
return finalizeAbortState({ messages: newMessages, ...updates });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actualRole === 'assistant' && messageIndex !== -1) {
|
if (actualRole === 'assistant' && messageIndex !== -1) {
|
||||||
@@ -1314,7 +1303,7 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
const newMessages = new Map(state.messages);
|
const newMessages = new Map(state.messages);
|
||||||
newMessages.set(sessionId, updatedMessages);
|
newMessages.set(sessionId, updatedMessages);
|
||||||
|
|
||||||
return finalizeAbortState({ messages: newMessages });
|
return finalizeAbortState({ messages: newMessages, ...updates });
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((part as any)?.type === 'text') {
|
if ((part as any)?.type === 'text') {
|
||||||
@@ -1326,6 +1315,10 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
if (latestUser) {
|
if (latestUser) {
|
||||||
const latestUserText = latestUser.parts.map((p) => extractTextFromPart(p)).join('').trim();
|
const latestUserText = latestUser.parts.map((p) => extractTextFromPart(p)).join('').trim();
|
||||||
if (latestUserText.length > 0 && latestUserText === textIncoming) {
|
if (latestUserText.length > 0 && latestUserText === textIncoming) {
|
||||||
|
// Cap ignoredAssistantMessageIds size — it's only relevant for active streaming
|
||||||
|
if (ignoredAssistantMessageIds.size > 1000) {
|
||||||
|
ignoredAssistantMessageIds.clear();
|
||||||
|
}
|
||||||
ignoredAssistantMessageIds.add(messageId);
|
ignoredAssistantMessageIds.add(messageId);
|
||||||
(window as any).__messageTracker?.(messageId, 'ignored_assistant_echo');
|
(window as any).__messageTracker?.(messageId, 'ignored_assistant_echo');
|
||||||
return state;
|
return state;
|
||||||
@@ -1481,26 +1474,7 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => {
|
addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => {
|
||||||
|
get()._addStreamingPartImmediate(sessionId, messageId, part, role, currentSessionId);
|
||||||
if (role !== 'user') {
|
|
||||||
get()._addStreamingPartImmediate(sessionId, messageId, part, role, currentSessionId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
batchQueue.push({ sessionId, messageId, part, role, currentSessionId });
|
|
||||||
|
|
||||||
if (!flushTimer) {
|
|
||||||
flushTimer = setTimeout(() => {
|
|
||||||
const itemsToProcess = [...batchQueue];
|
|
||||||
batchQueue = [];
|
|
||||||
flushTimer = null;
|
|
||||||
|
|
||||||
const store = get();
|
|
||||||
for (const item of itemsToProcess) {
|
|
||||||
store._addStreamingPartImmediate(item.sessionId, item.messageId, item.part, item.role, item.currentSessionId);
|
|
||||||
}
|
|
||||||
}, USER_BATCH_WINDOW_MS);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
forceCompleteMessage: (sessionId: string | null | undefined, messageId: string, source: "timeout" | "cooldown" = "timeout") => {
|
forceCompleteMessage: (sessionId: string | null | undefined, messageId: string, source: "timeout" | "cooldown" = "timeout") => {
|
||||||
@@ -2464,6 +2438,28 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
result.abortControllers = nextControllers;
|
result.abortControllers = nextControllers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up module-level registries for evicted session's message IDs
|
||||||
|
for (const messageId of removedIds) {
|
||||||
|
const existingTimeout = timeoutRegistry.get(messageId);
|
||||||
|
if (existingTimeout) {
|
||||||
|
clearTimeout(existingTimeout);
|
||||||
|
timeoutRegistry.delete(messageId);
|
||||||
|
}
|
||||||
|
lastContentRegistry.delete(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up cooldown timer for evicted session
|
||||||
|
const cooldownTimer = streamingCooldownTimers.get(lruSessionId);
|
||||||
|
if (cooldownTimer) {
|
||||||
|
clearTimeout(cooldownTimer);
|
||||||
|
streamingCooldownTimers.delete(lruSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Belt-and-suspenders: cap ignoredAssistantMessageIds size
|
||||||
|
if (ignoredAssistantMessageIds.size > 1000) {
|
||||||
|
ignoredAssistantMessageIds.clear();
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ export interface SessionContextUsage {
|
|||||||
// Background trim is derived automatically as Math.round(limit * 0.6).
|
// Background trim is derived automatically as Math.round(limit * 0.6).
|
||||||
export const DEFAULT_MESSAGE_LIMIT = 200;
|
export const DEFAULT_MESSAGE_LIMIT = 200;
|
||||||
|
|
||||||
|
/** Timeout after which a session stuck in 'busy' or 'retry' with no SSE events is force-reset to idle. */
|
||||||
|
export const STUCK_SESSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
export const MEMORY_CONSTANTS = {
|
export const MEMORY_CONSTANTS = {
|
||||||
MAX_SESSIONS: 3,
|
MAX_SESSIONS: 3,
|
||||||
BACKGROUND_STREAMING_BUFFER: 120,
|
BACKGROUND_STREAMING_BUFFER: 120,
|
||||||
|
|||||||
@@ -851,6 +851,10 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// rAF debounce IDs for useMessageStore -> useSessionStore sync
|
||||||
|
let messageStoreSyncRafId: number | null = null;
|
||||||
|
let userSummaryTitlesRafId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
useSessionManagementStore.subscribe((state, prevState) => {
|
useSessionManagementStore.subscribe((state, prevState) => {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -885,7 +889,8 @@ useSessionManagementStore.subscribe((state, prevState) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useMessageStore.subscribe((state, prevState) => {
|
useMessageStore.subscribe((state, prevState) => {
|
||||||
|
// Early-return equality check stays outside the rAF so we skip scheduling
|
||||||
|
// entirely when nothing relevant changed.
|
||||||
if (
|
if (
|
||||||
state.messages === prevState.messages &&
|
state.messages === prevState.messages &&
|
||||||
state.sessionMemoryState === prevState.sessionMemoryState &&
|
state.sessionMemoryState === prevState.sessionMemoryState &&
|
||||||
@@ -900,48 +905,72 @@ useMessageStore.subscribe((state, prevState) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userSummaryTitles = new Map<string, { title: string; createdAt: number | null }>();
|
// Debounce the expensive sessionStore update to at most once per animation
|
||||||
state.messages.forEach((messageList, sessionId) => {
|
// frame. Multiple messageStore updates within the same frame (e.g. several
|
||||||
if (!Array.isArray(messageList) || messageList.length === 0) {
|
// SSE tokens arriving before the next paint) collapse into a single setState.
|
||||||
return;
|
if (messageStoreSyncRafId !== null) {
|
||||||
|
cancelAnimationFrame(messageStoreSyncRafId);
|
||||||
|
}
|
||||||
|
messageStoreSyncRafId = requestAnimationFrame(() => {
|
||||||
|
messageStoreSyncRafId = null;
|
||||||
|
|
||||||
|
// Read the LATEST state at flush time, not the stale state captured by
|
||||||
|
// the subscription closure.
|
||||||
|
const latest = useMessageStore.getState();
|
||||||
|
|
||||||
|
useSessionStore.setState({
|
||||||
|
messages: latest.messages,
|
||||||
|
sessionMemoryState: latest.sessionMemoryState,
|
||||||
|
messageStreamStates: latest.messageStreamStates,
|
||||||
|
sessionCompactionUntil: latest.sessionCompactionUntil,
|
||||||
|
sessionAbortFlags: latest.sessionAbortFlags,
|
||||||
|
streamingMessageIds: latest.streamingMessageIds,
|
||||||
|
abortControllers: latest.abortControllers,
|
||||||
|
lastUsedProvider: latest.lastUsedProvider,
|
||||||
|
isSyncing: latest.isSyncing,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sidebar titles don't need real-time updates; debounce separately at
|
||||||
|
// 500 ms so the expensive per-message iteration doesn't happen every
|
||||||
|
// frame during streaming.
|
||||||
|
if (userSummaryTitlesRafId !== null) {
|
||||||
|
clearTimeout(userSummaryTitlesRafId);
|
||||||
}
|
}
|
||||||
for (let index = messageList.length - 1; index >= 0; index -= 1) {
|
userSummaryTitlesRafId = setTimeout(() => {
|
||||||
const entry = messageList[index];
|
userSummaryTitlesRafId = null;
|
||||||
if (!entry || !entry.info) {
|
const titleState = useMessageStore.getState();
|
||||||
continue;
|
const userSummaryTitles = new Map<string, { title: string; createdAt: number | null }>();
|
||||||
}
|
titleState.messages.forEach((messageList, sessionId) => {
|
||||||
const info = entry.info as Message & {
|
if (!Array.isArray(messageList) || messageList.length === 0) {
|
||||||
summary?: { title?: string | null } | null;
|
return;
|
||||||
time?: { created?: number | null };
|
}
|
||||||
};
|
for (let index = messageList.length - 1; index >= 0; index -= 1) {
|
||||||
if (info.role === "user") {
|
const entry = messageList[index];
|
||||||
const title = info.summary?.title;
|
if (!entry || !entry.info) {
|
||||||
if (typeof title === "string") {
|
continue;
|
||||||
const trimmed = title.trim();
|
}
|
||||||
if (trimmed.length > 0) {
|
const info = entry.info as Message & {
|
||||||
const createdAt =
|
summary?: { title?: string | null } | null;
|
||||||
info.time && typeof info.time.created === "number"
|
time?: { created?: number | null };
|
||||||
? info.time.created
|
};
|
||||||
: null;
|
if (info.role === "user") {
|
||||||
userSummaryTitles.set(sessionId, { title: trimmed, createdAt });
|
const title = info.summary?.title;
|
||||||
break;
|
if (typeof title === "string") {
|
||||||
|
const trimmed = title.trim();
|
||||||
|
if (trimmed.length > 0) {
|
||||||
|
const createdAt =
|
||||||
|
info.time && typeof info.time.created === "number"
|
||||||
|
? info.time.created
|
||||||
|
: null;
|
||||||
|
userSummaryTitles.set(sessionId, { title: trimmed, createdAt });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
useSessionStore.setState({ userSummaryTitles });
|
||||||
});
|
}, 500);
|
||||||
|
|
||||||
useSessionStore.setState({
|
|
||||||
messages: state.messages,
|
|
||||||
sessionMemoryState: state.sessionMemoryState,
|
|
||||||
messageStreamStates: state.messageStreamStates,
|
|
||||||
sessionCompactionUntil: state.sessionCompactionUntil,
|
|
||||||
sessionAbortFlags: state.sessionAbortFlags,
|
|
||||||
streamingMessageIds: state.streamingMessageIds,
|
|
||||||
abortControllers: state.abortControllers,
|
|
||||||
lastUsedProvider: state.lastUsedProvider,
|
|
||||||
isSyncing: state.isSyncing,
|
|
||||||
userSummaryTitles,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export const touchStreamingLifecycle = (
|
|||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const removeLifecycleEntries = (
|
export const removeLifecycleEntries = (
|
||||||
source: Map<string, MessageStreamLifecycle>,
|
source: Map<string, MessageStreamLifecycle>,
|
||||||
ids: Iterable<string>
|
ids: Iterable<string>
|
||||||
|
|||||||
@@ -5467,7 +5467,7 @@ function setupProxy(app) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: bodyBuffer,
|
body: bodyBuffer,
|
||||||
signal: AbortSignal.timeout(45000),
|
signal: AbortSignal.timeout(LONG_REQUEST_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
|
|
||||||
const upstreamBody = Buffer.from(await upstreamResponse.arrayBuffer());
|
const upstreamBody = Buffer.from(await upstreamResponse.arrayBuffer());
|
||||||
|
|||||||
Reference in New Issue
Block a user