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:
Nguyễn Ngô Thượng
2026-02-23 12:05:53 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 989593ed72
commit 3cd6d051cb
11 changed files with 756 additions and 407 deletions
+3
View File
@@ -54,6 +54,7 @@
"@remixicon/react": "^4.7.0",
"@streamdown/code": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@tanstack/react-virtual": "^3.13.18",
"@types/react-syntax-highlighter": "^15.5.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -66,6 +67,7 @@
"http-proxy-middleware": "^3.0.5",
"motion": "^12.23.24",
"next-themes": "^0.4.6",
"prismjs": "^1.30.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-syntax-highlighter": "^15.6.6",
@@ -83,6 +85,7 @@
"@tailwindcss/postcss": "^4.0.0",
"@tauri-apps/api": "^2.9.0",
"@types/node": "^24.3.1",
"@types/prismjs": "^1.26.6",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// ── 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';
+68 -11
View File
@@ -3,7 +3,7 @@ import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client'
import { saveSessionCursor } from '@/lib/messageCursorPersistence';
import { useSessionStore } from '@/stores/useSessionStore';
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 { useUIStore, type EventStreamStatus } from '@/stores/useUIStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
@@ -85,6 +85,8 @@ const isIdNewer = (id: string, referenceId: string): boolean => {
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 getMessageFromStore = (sessionId: string, messageId: string): { info: Message; parts: Part[] } | null => {
const cacheKey = `${sessionId}:${messageId}`;
@@ -96,7 +98,16 @@ const getMessageFromStore = (sessionId: string, messageId: string): { info: Mess
const storeState = useSessionStore.getState();
const sessionMessages = storeState.messages.get(sessionId) || [];
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 });
return message;
};
@@ -581,6 +592,9 @@ export const useEventStream = () => {
const prevDirectory = previousSessionDirectoryRef.current;
if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) {
// Clear the message cache on session switch to free memory
messageCache.clear();
if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) {
// Removed: void refreshSessionStatus();
}
@@ -1465,8 +1479,9 @@ export const useEventStream = () => {
import('sonner').then(({ toast }) => {
toast.warning('Permission required', {
id: toastKey,
description: sessionTitle,
duration: Infinity,
duration: 30000,
action: {
label: 'Open',
onClick: () => {
@@ -1536,8 +1551,9 @@ export const useEventStream = () => {
import('sonner').then(({ toast }) => {
toast.info('Input needed', {
id: toastKey,
description: sessionTitle,
duration: Infinity,
duration: 30000,
action: {
label: 'Open',
onClick: () => {
@@ -1638,6 +1654,29 @@ export const useEventStream = () => {
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 currentVisibility = resolveVisibilityState();
visibilityStateRef.current = currentVisibility;
@@ -1732,12 +1771,12 @@ export const useEventStream = () => {
// Removed: void refreshSessionStatus();
if (shouldRefresh) {
void bootstrapState('sse_reconnected');
void stableBootstrapState('sse_reconnected');
} else {
const sessionId = currentSessionIdRef.current;
if (sessionId) {
setTimeout(() => {
scheduleSoftResync(sessionId, 'sse_reconnected', getMessageLimit())
scheduleSoftResyncRef.current(sessionId, 'sse_reconnected', getMessageLimit())
.then(() => requestSessionMetadataRefresh(sessionId))
.catch((error: unknown) => {
console.warn('[useEventStream] Failed to resync messages after reconnect:', error);
@@ -1776,7 +1815,7 @@ export const useEventStream = () => {
? { ...baseProperties, directory: event.directory }
: baseProperties;
handleEvent({
stableHandleEvent({
type: typeof (payload as { type?: unknown }).type === 'string' ? (payload as { type: string }).type : '',
properties,
});
@@ -1808,13 +1847,11 @@ export const useEventStream = () => {
stopStream,
publishStatus,
checkConnection,
scheduleSoftResync,
requestSessionMetadataRefresh,
handleEvent,
stableHandleEvent,
stableBootstrapState,
effectiveDirectory,
debugConnectionState,
bootstrapState
]);
const scheduleReconnect = React.useCallback((hint?: string) => {
@@ -2026,8 +2063,27 @@ export const useEventStream = () => {
}
}, 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 () => {
clearTimeout(startTimer);
clearInterval(stuckCheckInterval);
void desktopActivityHandler;
@@ -2086,5 +2142,6 @@ export const useEventStream = () => {
maybeBootstrapIfStale,
resyncMessages,
scheduleSoftResync,
updateSessionStatus,
]);
};
+34 -38
View File
@@ -30,18 +30,6 @@ const cleanupPendingUserMessageMeta = (
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 timeoutRegistry = new Map<string, ReturnType<typeof setTimeout>>();
@@ -1123,14 +1111,15 @@ export const useMessageStore = create<MessageStore>()(
...memoryState,
backgroundMessageCount: (memoryState.backgroundMessageCount || 0) + 1,
});
state.sessionMemoryState = newMemoryState;
updates.sessionMemoryState = newMemoryState;
}
if (actualRole === 'assistant') {
const currentMemoryState = state.sessionMemoryState.get(sessionId);
const baseMemoryMap = updates.sessionMemoryState ?? state.sessionMemoryState;
const currentMemoryState = baseMemoryMap.get(sessionId);
if (currentMemoryState) {
const now = Date.now();
const nextMemoryState = new Map(state.sessionMemoryState);
const nextMemoryState = new Map(baseMemoryMap);
nextMemoryState.set(sessionId, {
...currentMemoryState,
isStreaming: true,
@@ -1138,7 +1127,7 @@ export const useMessageStore = create<MessageStore>()(
lastAccessedAt: now,
isZombie: false,
});
state.sessionMemoryState = nextMemoryState;
updates.sessionMemoryState = nextMemoryState;
}
}
@@ -1206,7 +1195,7 @@ export const useMessageStore = create<MessageStore>()(
const newMessages = new Map(state.messages);
newMessages.set(sessionId, updatedMessages);
return finalizeAbortState({ messages: newMessages });
return finalizeAbortState({ messages: newMessages, ...updates });
}
if (actualRole === 'assistant' && messageIndex !== -1) {
@@ -1314,7 +1303,7 @@ export const useMessageStore = create<MessageStore>()(
const newMessages = new Map(state.messages);
newMessages.set(sessionId, updatedMessages);
return finalizeAbortState({ messages: newMessages });
return finalizeAbortState({ messages: newMessages, ...updates });
}
if ((part as any)?.type === 'text') {
@@ -1326,6 +1315,10 @@ export const useMessageStore = create<MessageStore>()(
if (latestUser) {
const latestUserText = latestUser.parts.map((p) => extractTextFromPart(p)).join('').trim();
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);
(window as any).__messageTracker?.(messageId, 'ignored_assistant_echo');
return state;
@@ -1481,26 +1474,7 @@ export const useMessageStore = create<MessageStore>()(
},
addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => {
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);
}
get()._addStreamingPartImmediate(sessionId, messageId, part, role, currentSessionId);
},
forceCompleteMessage: (sessionId: string | null | undefined, messageId: string, source: "timeout" | "cooldown" = "timeout") => {
@@ -2464,6 +2438,28 @@ export const useMessageStore = create<MessageStore>()(
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;
});
},
@@ -56,6 +56,9 @@ export interface SessionContextUsage {
// Background trim is derived automatically as Math.round(limit * 0.6).
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 = {
MAX_SESSIONS: 3,
BACKGROUND_STREAMING_BUFFER: 120,
+69 -40
View File
@@ -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) => {
if (
@@ -885,7 +889,8 @@ useSessionManagementStore.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 (
state.messages === prevState.messages &&
state.sessionMemoryState === prevState.sessionMemoryState &&
@@ -900,48 +905,72 @@ useMessageStore.subscribe((state, prevState) => {
return;
}
const userSummaryTitles = new Map<string, { title: string; createdAt: number | null }>();
state.messages.forEach((messageList, sessionId) => {
if (!Array.isArray(messageList) || messageList.length === 0) {
return;
// Debounce the expensive sessionStore update to at most once per animation
// frame. Multiple messageStore updates within the same frame (e.g. several
// SSE tokens arriving before the next paint) collapse into a single setState.
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) {
const entry = messageList[index];
if (!entry || !entry.info) {
continue;
}
const info = entry.info as Message & {
summary?: { title?: string | null } | null;
time?: { created?: number | null };
};
if (info.role === "user") {
const title = info.summary?.title;
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;
userSummaryTitlesRafId = setTimeout(() => {
userSummaryTitlesRafId = null;
const titleState = useMessageStore.getState();
const userSummaryTitles = new Map<string, { title: string; createdAt: number | null }>();
titleState.messages.forEach((messageList, sessionId) => {
if (!Array.isArray(messageList) || messageList.length === 0) {
return;
}
for (let index = messageList.length - 1; index >= 0; index -= 1) {
const entry = messageList[index];
if (!entry || !entry.info) {
continue;
}
const info = entry.info as Message & {
summary?: { title?: string | null } | null;
time?: { created?: number | null };
};
if (info.role === "user") {
const title = info.summary?.title;
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({
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,
});
useSessionStore.setState({ userSummaryTitles });
}, 500);
});
});
@@ -19,7 +19,6 @@ export const touchStreamingLifecycle = (
return next;
};
export const removeLifecycleEntries = (
source: Map<string, MessageStreamLifecycle>,
ids: Iterable<string>
+1 -1
View File
@@ -5467,7 +5467,7 @@ function setupProxy(app) {
method: 'POST',
headers,
body: bodyBuffer,
signal: AbortSignal.timeout(45000),
signal: AbortSignal.timeout(LONG_REQUEST_TIMEOUT_MS),
});
const upstreamBody = Buffer.from(await upstreamResponse.arrayBuffer());