From 3cd6d051cb89509716390ad0fa4e2aadbae3b589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Ng=C3=B4=20Th=C6=B0=E1=BB=A3ng?= <83950837+nguyenngothuong@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:05:53 +0700 Subject: [PATCH] perf: fix streaming lag, memory leaks, stuck spinners, and proxy timeout (#483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 --- bun.lock | 9 + packages/ui/package.json | 3 + .../chat/message/ToolOutputDialog.tsx | 248 ++++++------- .../chat/message/parts/ToolPart.tsx | 297 +++++++-------- .../message/parts/VirtualizedCodeBlock.tsx | 340 ++++++++++++++++++ packages/ui/src/hooks/useEventStream.ts | 79 +++- packages/ui/src/stores/messageStore.ts | 72 ++-- packages/ui/src/stores/types/sessionTypes.ts | 3 + packages/ui/src/stores/useSessionStore.ts | 109 +++--- .../ui/src/stores/utils/streamingUtils.ts | 1 - packages/web/server/index.js | 2 +- 11 files changed, 756 insertions(+), 407 deletions(-) create mode 100644 packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx diff --git a/bun.lock b/bun.lock index b9c3d3fe..bb1e6340 100644 --- a/bun.lock +++ b/bun.lock @@ -151,6 +151,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", @@ -163,6 +164,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", @@ -180,6 +182,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", @@ -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=="], + "@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/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/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-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index 41e1a256..ce2acaed 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx index ebc392a4..ac01b4d1 100644 --- a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx +++ b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx @@ -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; + isMobile: boolean; +}> = React.memo(({ popup, syntaxTheme, isMobile }) => { + const hunks = React.useMemo(() => parseDiffToUnified(popup.content), [popup.content]); + + return ( +
+ {hunks.map((hunk, hunkIdx) => { + const inputFile = (typeof popup.metadata?.input === 'object' && popup.metadata.input !== null) + ? ((popup.metadata.input as Record).file_path || (popup.metadata.input as Record).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 ( +
+
+ {`${hunk.file} (line ${hunk.oldStart})`} +
+ + 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 + } + /> +
+ ); + })} +
+ ); +}); + +DialogUnifiedDiff.displayName = 'DialogUnifiedDiff'; + +const DialogReadContent: React.FC<{ + popup: ToolPopupContent; + syntaxTheme: Record; +}> = 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) : {}; + 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 ( + + ); +}); + +DialogReadContent.displayName = 'DialogReadContent'; + const ToolOutputDialog: React.FC = ({ popup, onOpenChange, syntaxTheme, isMobile }) => { const [diffViewMode, setDiffViewMode] = React.useState(isMobile ? 'unified' : 'side-by-side'); @@ -460,82 +559,7 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange {popup.isDiff ? ( diffViewMode === 'unified' ? ( -
- {parseDiffToUnified(popup.content).map((hunk, hunkIdx) => ( -
-
- {`${hunk.file} (line ${hunk.oldStart})`} -
-
- {hunk.lines.map((line, lineIdx) => ( -
- - {line.lineNumber || ''} - -
- {(() => { - const inputFile = (typeof popup.metadata?.input === 'object' && popup.metadata.input !== null) - ? ((popup.metadata.input as Record).file_path || (popup.metadata.input as Record).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 ( - - {line.content} - - ); - })()} -
-
- ))} -
-
- ))} -
+ ) : popup.diffHunks ? (
{popup.diffHunks.map((hunk, hunkIdx) => ( @@ -741,77 +765,7 @@ const ToolOutputDialog: React.FC = ({ 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) : {}; - const offset = typeof inputObj.offset === 'number' ? inputObj.offset : 0; - let fallbackLineCursor = offset; - const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null); - - return ( -
- {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 ( -
- - {shouldShowLineNumber ? effectiveLineNumber : ''} - -
- {line.isInfo ? ( -
{line.text}
- ) : ( - - {line.text} - - )} -
-
- ); - })} -
- ); + return ; } return ( diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index d7858cbc..38d16afa 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -27,6 +27,7 @@ import { formatInputForDisplay, parseReadToolOutput, } from '../toolRenderers'; +import { VirtualizedCodeBlock, type CodeLine } from './VirtualizedCodeBlock'; type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record; input?: Record; output?: string; error?: string; time?: { start: number; end?: number } }; @@ -570,74 +571,48 @@ interface DiffPreviewProps { input?: ToolStateWithMetadata['input']; } -const DiffPreview: React.FC = React.memo(({ diff, syntaxTheme, input }) => ( -
- {parseDiffToUnified(diff).map((hunk, hunkIdx) => ( -
-
- {`${hunk.file} (line ${hunk.oldStart})`} -
+const DiffPreview: React.FC = React.memo(({ diff, syntaxTheme, input }) => { + const hunks = React.useMemo(() => parseDiffToUnified(diff), [diff]); -
- {hunk.lines.map((line, lineIdx) => ( -
- - {line.lineNumber || ''} - -
- - {line.content} - -
+ return ( +
+ {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 ( +
+
+ {`${hunk.file} (line ${hunk.oldStart})`}
- ))} -
-
- ))} -
-)); + + 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 + } + /> +
+ ); + })} +
+ ); +}); DiffPreview.displayName = 'DiffPreview'; @@ -649,13 +624,20 @@ interface WriteInputPreviewProps { } const WriteInputPreview: React.FC = 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 = React.memo(({ conten
{`${displayPath} (${headerLineLabel})`}
-
- {lines.map((line, lineIdx) => ( -
- - {lineIdx + 1} - -
- - {line || ' '} - -
-
- ))} -
+
); }); WriteInputPreview.displayName = 'WriteInputPreview'; +// ── PERF-007: Read tool output with virtualised highlighting ───────── +interface ReadToolVirtualizedProps { + outputString: string; + input?: Record; + 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 = 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); + }, [parsedReadOutput, toolName, input]); + + return renderScrollableBlock( + , + { className: 'p-1' } + ) as React.ReactElement; +}); + +ReadToolVirtualized.displayName = 'ReadToolVirtualized'; + interface ImagePreviewProps { content: string; filePath: string; @@ -969,72 +987,13 @@ const ToolExpandedContent: React.FC = 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( -
- {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 ( -
- - {shouldShowLineNumber ? effectiveLineNumber : ''} - -
- {line.isInfo ? ( -
{line.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.text} - - )} -
-
- ); - })} -
, - { className: 'p-1' } - ); + return ; } return renderScrollableBlock( diff --git a/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx new file mode 100644 index 00000000..44698064 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx @@ -0,0 +1,340 @@ +/** + * VirtualizedCodeBlock — PERF-007 + * + * Replaces per-line 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
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; + /** 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 => { + 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 = { + 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(); + +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, '>'); +} + +// ── Component ──────────────────────────────────────────────────────── +export const VirtualizedCodeBlock: React.FC = 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 ( +
+ {prismThemeCss ? : null} + {lines.map((line, idx) => ( + + ))} +
+ ); + } + + // ── Large file: virtualise ── + return ( + + ); +}); + +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 = React.memo(({ + lines, + language, + prismThemeCss, + maxHeight, + showLineNumbers, + lineStyles, +}) => { + const parentRef = React.useRef(null); + + const virtualizer = useVirtualizer({ + count: lines.length, + getScrollElement: () => parentRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 20, // render 20 extra rows above/below viewport + }); + + return ( +
+ {prismThemeCss ? : null} +
+ {virtualizer.getVirtualItems().map((vItem) => { + const line = lines[vItem.index]; + return ( +
+ +
+ ); + })} +
+
+ ); +}); + +VirtualizedRows.displayName = 'VirtualizedRows'; + +// ── Single row ─────────────────────────────────────────────────────── +interface RowProps { + line: CodeLine; + language: string; + showLineNumbers: boolean; + style?: React.CSSProperties; +} + +const Row: React.FC = React.memo(({ line, language, showLineNumbers, style }) => { + const html = React.useMemo(() => highlightLine(line.text, language), [line.text, language]); + + return ( +
+ {showLineNumbers && ( + + {!line.isInfo && line.lineNumber != null ? line.lineNumber : ''} + + )} +
+ {line.isInfo ? ( +
+ {line.text} +
+ ) : ( +
+ )} +
+
+ ); +}); + +Row.displayName = 'VirtualizedCodeBlock.Row'; diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts index 0840ad5b..b346aeae 100644 --- a/packages/ui/src/hooks/useEventStream.ts +++ b/packages/ui/src/hooks/useEventStream.ts @@ -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(); 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, ]); }; diff --git a/packages/ui/src/stores/messageStore.ts b/packages/ui/src/stores/messageStore.ts index fc17a9d4..18ab1be9 100644 --- a/packages/ui/src/stores/messageStore.ts +++ b/packages/ui/src/stores/messageStore.ts @@ -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 | null = null; - -const USER_BATCH_WINDOW_MS = 50; const COMPACTION_WINDOW_MS = 30_000; const timeoutRegistry = new Map>(); @@ -1123,14 +1111,15 @@ export const useMessageStore = create()( ...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()( lastAccessedAt: now, isZombie: false, }); - state.sessionMemoryState = nextMemoryState; + updates.sessionMemoryState = nextMemoryState; } } @@ -1206,7 +1195,7 @@ export const useMessageStore = create()( 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()( 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()( 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()( }, 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()( 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; }); }, diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index 315e5100..db737f2d 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -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, diff --git a/packages/ui/src/stores/useSessionStore.ts b/packages/ui/src/stores/useSessionStore.ts index ca30555b..d0e738a6 100644 --- a/packages/ui/src/stores/useSessionStore.ts +++ b/packages/ui/src/stores/useSessionStore.ts @@ -851,6 +851,10 @@ export const useSessionStore = create()( ), ); +// rAF debounce IDs for useMessageStore -> useSessionStore sync +let messageStoreSyncRafId: number | null = null; +let userSummaryTitlesRafId: ReturnType | 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(); - 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(); + 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); }); }); diff --git a/packages/ui/src/stores/utils/streamingUtils.ts b/packages/ui/src/stores/utils/streamingUtils.ts index 51cdf8e7..438fab59 100644 --- a/packages/ui/src/stores/utils/streamingUtils.ts +++ b/packages/ui/src/stores/utils/streamingUtils.ts @@ -19,7 +19,6 @@ export const touchStreamingLifecycle = ( return next; }; - export const removeLifecycleEntries = ( source: Map, ids: Iterable diff --git a/packages/web/server/index.js b/packages/web/server/index.js index c65c902d..4f8ed243 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -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());