From 39bb71a62baa4e951190f89cf09deea892ed99de Mon Sep 17 00:00:00 2001 From: herjarsa Date: Mon, 17 Aug 2026 11:37:20 +0200 Subject: [PATCH 01/63] fix(ui): bound OpenCode read requests so half-open sockets cannot freeze bootstrap (#2470) The SDK client fetch wrapper now applies a 30s timeout to non-streaming reads. Without it, a socket that neither resolves nor rejects keeps the directory bootstrap concurrency slot busy forever and the UI stays on "loading sessions". Long-lived streams (POST prompts, the /event SSE) are explicitly excluded so they are not cut off mid-flight. The normalized "request timed out" error is added to the retry allowlist alongside undici's "terminated" (the exact failure observed in #2470 when undici tears down a half-open upstream connection); both are transient while the managed OpenCode process restarts. Caller- initiated aborts keep their original error shape so a user-cancelled request is not retried. Tests cover: GET timeout fires after the bound, POST is not timed out, /event SSE is not timed out, caller abort wins, AbortError is not retried, and the SDK normalized error is retried 3x. --- .../src/lib/opencode/client-timeout.test.ts | 157 ++++++++++++++++++ packages/ui/src/lib/opencode/client.ts | 55 +++++- packages/ui/src/sync/retry.test.ts | 38 +++++ packages/ui/src/sync/retry.ts | 6 + packages/ui/src/types/bun-test.d.ts | 11 +- 5 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/lib/opencode/client-timeout.test.ts create mode 100644 packages/ui/src/sync/retry.test.ts diff --git a/packages/ui/src/lib/opencode/client-timeout.test.ts b/packages/ui/src/lib/opencode/client-timeout.test.ts new file mode 100644 index 00000000..fd45a4a7 --- /dev/null +++ b/packages/ui/src/lib/opencode/client-timeout.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +// Regression tests for issue #2470: sessions stuck on "loading sessions" +// forever after managed OpenCode connection goes half-open. +// +// The SDK client fetch wrapper must bound read requests so a socket that +// neither resolves nor rejects fails after `requestTimeoutMs`, releasing the +// directory bootstrap concurrency slot. Long-lived streams must be excluded: +// POST (prompt/shell/summarize/command) and the `/event` SSE stream. + +type CapturedFetch = (input: string | URL | Request, init?: RequestInit) => Promise; +type RuntimeFetch = (input: string | URL | Request, init?: RequestInit) => Promise; + +// `mock(...)` returns a Mock that exposes mockImplementation; keep a typed +// reference so per-test overrides stay type-safe without re-importing. +const runtimeFetchMock = mock(async () => new Response('', { status: 200 })); + +let capturedFetch: CapturedFetch | null = null; + +(mock as unknown as { restore?: () => void }).restore?.(); + +mock.module('@opencode-ai/sdk/v2', () => ({ + createOpencodeClient: mock((opts: { fetch: CapturedFetch }) => { + capturedFetch = opts.fetch; + return {}; + }), +})); + +mock.module('@/contexts/runtimeAPIRegistry', () => ({ + getRegisteredRuntimeAPIs: mock(() => null), +})); + +mock.module('@/lib/runtime-url', () => ({ + getRuntimeUrlResolver: mock(() => ({ api: (path: string) => path })), +})); + +mock.module('@/lib/runtime-switch', () => ({ + getRuntimeApiBaseUrl: mock(() => ''), + getRuntimeKey: mock(() => 'test-runtime'), +})); + +mock.module('@/lib/runtime-fetch', () => ({ + runtimeFetch: runtimeFetchMock, +})); + +mock.module('@/lib/startupTrace', () => ({ + markStartupTrace: mock(() => undefined), +})); + +const { createRuntimeOpencodeClient } = await import( + `./client?timeout-final=${Date.now()}` +); + +beforeEach(() => { + capturedFetch = null; + runtimeFetchMock.mockImplementation(async () => new Response('', { status: 200 })); +}); + +describe('createRuntimeOpencodeClient fetch wrapper (#2470)', () => { + test('AbortSignal.timeout fires inside a test environment (sanity)', async () => { + const sig = AbortSignal.timeout(20); + const fired = await new Promise((resolve) => { + sig.addEventListener('abort', () => resolve(true)); + setTimeout(() => resolve(false), 200); + }); + expect(fired).toBe(true); + }); + + test('createRuntimeOpencodeClient is exported', () => { + expect(typeof createRuntimeOpencodeClient).toBe('function'); + }); + + test('a GET whose socket never settles rejects with normalized "request timed out" error', async () => { + let firedAt = 0; + let calledAt = Date.now(); + runtimeFetchMock.mockImplementation(async (_input: string | URL | Request, init?: RequestInit) => { + calledAt = Date.now(); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + firedAt = Date.now(); + reject(new DOMException('Aborted', 'AbortError')); + }); + setTimeout( + () => reject(new Error('TIMEOUT_TEST_FAIL: signal never fired')), + 1000, + ); + }); + }); + + createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 }); + expect(capturedFetch).not.toBeNull(); + + const start = Date.now(); + await expect( + capturedFetch!('http://opencode.test/api/session'), + ).rejects.toThrow(/request timed out/); + const elapsed = Date.now() - start; + expect(firedAt).toBeGreaterThanOrEqual(calledAt); + expect(elapsed).toBeLessThan(500); + }); + + test('POST requests (long-running prompt/shell/summarize) are NOT timed out', async () => { + runtimeFetchMock.mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 200)); + return new Response('ok', { status: 200 }); + }); + + createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 }); + expect(capturedFetch).not.toBeNull(); + + const response = await capturedFetch!( + 'http://opencode.test/session/prompt', + { method: 'POST' }, + ); + expect(await response.text()).toBe('ok'); + }); + + test('the /event SSE stream is NOT timed out', async () => { + runtimeFetchMock.mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 200)); + return new Response('event: ping\n\n', { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + }); + + createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 }); + expect(capturedFetch).not.toBeNull(); + + const response = await capturedFetch!( + 'http://opencode.test/api/global/event', + ); + expect(response.headers.get('Content-Type')).toBe('text/event-stream'); + }); + + test('caller-provided abort signal still wins (no normalized timeout error)', async () => { + runtimeFetchMock.mockImplementation(async (_input: string | URL | Request, init?: RequestInit) => + new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')); + }); + }), + ); + + createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 }); + expect(capturedFetch).not.toBeNull(); + + const callerController = new AbortController(); + setTimeout(() => callerController.abort(), 5); + + await expect( + capturedFetch!('http://opencode.test/api/session', { + signal: callerController.signal, + }), + ).rejects.toThrow('Aborted'); + }); +}); diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 37e12620..ae4000fb 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -199,10 +199,61 @@ const createTimeoutSignal = (timeoutMs: number): { signal: AbortSignal; cleanup: }; }; -const createRuntimeOpencodeClient = (config: { baseUrl: string; directory?: string }): OpencodeClient => { +/** + * Upper bound for non-streaming OpenCode read requests. Without it, a socket + * that neither resolves nor rejects (the half-open state described in #2470) + * keeps the bootstrap concurrency slot busy forever and the UI stays on + * "loading sessions". Long-lived streams (POST prompts, the /event SSE) are + * explicitly excluded in {@link createRuntimeOpencodeClient}. + */ +const OPENCODE_REQUEST_TIMEOUT_MS = 30_000; + +const isEventStreamUrl = (input: string | URL | Request): boolean => { + const url = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + return url.includes('/event'); +}; + +type RuntimeOpencodeClientConfig = { + baseUrl: string; + directory?: string; + /** Read-request timeout in ms. Overridable so tests can use short value. */ + requestTimeoutMs?: number; +}; + +export const createRuntimeOpencodeClient = (config: RuntimeOpencodeClientConfig): OpencodeClient => { + const requestTimeoutMs = config.requestTimeoutMs ?? OPENCODE_REQUEST_TIMEOUT_MS; return createOpencodeClient({ ...config, - fetch: runtimeFetch, + fetch: async (input: string | URL | Request, init?: RequestInit) => { + const method = String( + init?.method ?? (input instanceof Request ? input.method : 'GET'), + ).toUpperCase(); + if (isEventStreamUrl(input) || method === 'POST') { + return runtimeFetch(input, init); + } + const timeout = createTimeoutSignal(requestTimeoutMs); + const callerSignal = init?.signal; + const supportsAny = typeof AbortSignal !== 'undefined' + && typeof (AbortSignal as { any?: unknown }).any === 'function'; + const signal: AbortSignal = callerSignal && supportsAny + ? (AbortSignal as typeof AbortSignal & { any: (signals: AbortSignal[]) => AbortSignal }) + .any([callerSignal, timeout.signal]) + : (callerSignal ?? timeout.signal); + try { + return await runtimeFetch(input, { ...init, signal }); + } catch (error) { + if (timeout.signal.aborted && !callerSignal?.aborted) { + throw new Error(`OpenCode request timed out after ${requestTimeoutMs}ms`); + } + throw error; + } finally { + timeout.cleanup(); + } + }, }); }; diff --git a/packages/ui/src/sync/retry.test.ts b/packages/ui/src/sync/retry.test.ts new file mode 100644 index 00000000..d1819302 --- /dev/null +++ b/packages/ui/src/sync/retry.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test'; + +import { retry } from './retry'; + +describe('retry transient classification (#2470)', () => { + test("'terminated' (undici half-open socket teardown) is retried", async () => { + let attempts = 0; + await expect( + retry(async () => { + attempts += 1; + throw new TypeError('terminated'); + }), + ).rejects.toThrow('terminated'); + expect(attempts).toBe(3); + }); + + test("normalized 'request timed out' (SDK read timeout) is retried", async () => { + let attempts = 0; + await expect( + retry(async () => { + attempts += 1; + throw new Error('OpenCode request timed out after 30000ms'); + }), + ).rejects.toThrow('request timed out'); + expect(attempts).toBe(3); + }); + + test('caller-initiated abort (AbortError) is NOT retried', async () => { + let attempts = 0; + await expect( + retry(async () => { + attempts += 1; + throw new DOMException('Aborted', 'AbortError'); + }), + ).rejects.toThrow('Aborted'); + expect(attempts).toBe(1); + }); +}); diff --git a/packages/ui/src/sync/retry.ts b/packages/ui/src/sync/retry.ts index 8c7aafea..0b3b0a0d 100644 --- a/packages/ui/src/sync/retry.ts +++ b/packages/ui/src/sync/retry.ts @@ -6,6 +6,10 @@ export interface RetryOptions { retryIf?: (error: unknown) => boolean } +// undici tears down half-open upstream connection with `TypeError: terminated` +// (exact failure from the #2470 logs); the SDK client also rejects reads with +// normalized "request timed out" error after OPENCODE_REQUEST_TIMEOUT_MS. +// Both are transient — managed process may be restarting. const TRANSIENT_MESSAGES = [ "load failed", "network connection was lost", @@ -18,6 +22,8 @@ const TRANSIENT_MESSAGES = [ "opencode api unavailable", "503", "502", + "terminated", + "request timed out", ] function isTransientError(error: unknown): boolean { diff --git a/packages/ui/src/types/bun-test.d.ts b/packages/ui/src/types/bun-test.d.ts index 921149c1..f7304d74 100644 --- a/packages/ui/src/types/bun-test.d.ts +++ b/packages/ui/src/types/bun-test.d.ts @@ -31,8 +31,17 @@ declare module "bun:test" { export function beforeEach(fn: () => void | Promise): void; export function afterEach(fn: () => void | Promise): void; export function afterAll(fn: () => void | Promise): void; - export function mock unknown>(fn?: T): T; + // Mock matches the bun:test runtime mock: T (callable) plus spy methods. + // Tests that need to swap implementations at runtime cast through `Mock`. + export interface Mock unknown> { + (...args: Parameters): ReturnType; + mockImplementation(fn: T): Mock; + mockReturnValue(value: ReturnType): Mock; + mockReset(): Mock; + } + export function mock unknown>(fn?: T): Mock; export namespace mock { function module(moduleName: string, factory: () => Record): void; + function restore(): void; } } From 0e39cfb79a8b60f6c15b72738ca4ec71873cab65 Mon Sep 17 00:00:00 2001 From: vinciyan Date: Wed, 19 Aug 2026 01:46:46 +0000 Subject: [PATCH 02/63] fix: resend connectionStatus at staggered delays to prevent stuck loading screen on slow networks The webview only leaves its initial loading screen once it receives a connectionStatus=connected postMessage. VS Code drops postMessage calls made before the webview's acquireVsCodeApi bridge is ready, so on slow/remote networks the single send in _sendCachedState() can be lost forever, leaving the sidebar stuck on #initial-loading. Re-send connectionStatus at staggered delays (0.5s-20s) when connected, in ChatViewProvider, AgentManagerPanelProvider and SessionEditorPanelProvider. Stops automatically when the view/panel is disposed or replaced. Message is idempotent. Fixes #2996 --- packages/vscode/package.json | 4 +- .../vscode/src/AgentManagerPanelProvider.ts | 37 +++++++++++++++++++ packages/vscode/src/ChatViewProvider.ts | 37 +++++++++++++++++++ .../vscode/src/SessionEditorPanelProvider.ts | 35 ++++++++++++++++++ 4 files changed, 111 insertions(+), 2 deletions(-) diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 1eb579fa..e7e7ce77 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "openchamber", "displayName": "OpenChamber", "description": "%extension.description%", - "version": "1.19.0", + "version": "1.19.1", "publisher": "fedaykindev", "private": true, "repository": { @@ -252,4 +252,4 @@ "react-dom": "^19.1.1", "yaml": "^2.8.1" } -} +} \ No newline at end of file diff --git a/packages/vscode/src/AgentManagerPanelProvider.ts b/packages/vscode/src/AgentManagerPanelProvider.ts index 566f9679..e1b068c6 100644 --- a/packages/vscode/src/AgentManagerPanelProvider.ts +++ b/packages/vscode/src/AgentManagerPanelProvider.ts @@ -23,6 +23,33 @@ export class AgentManagerPanelProvider { private _sseStreams = new Map(); private readonly _webviewDevServerUrl: string | null; + /** + * The webview only leaves its initial loading screen once it receives a + * `connectionStatus: connected` message. VS Code drops postMessage calls + * made before the webview's acquireVsCodeApi bridge is ready (common in + * code-server / slow or flaky networks), so a single send can be lost + * forever. Re-send at staggered delays until the target panel is replaced. + */ + private _scheduleCachedStateRetries(targetPanel: vscode.WebviewPanel | undefined): void { + if (this._cachedStatus !== 'connected') { + return; + } + const panel = targetPanel ?? this._panel; + if (!panel) { + return; + } + const delaysMs = [500, 1500, 3500, 7000, 12000, 20000]; + for (const delayMs of delaysMs) { + setTimeout(() => { + // Only re-send if this exact panel is still the active one. + if (this._panel !== panel) { + return; + } + this._sendCachedState(); + }, delayMs); + } + } + constructor( private readonly _context: vscode.ExtensionContext, private readonly _extensionUri: vscode.Uri, @@ -64,6 +91,9 @@ export class AgentManagerPanelProvider { // Send cached connection status this._sendCachedState(); + // The webview bridge may not be ready yet; keep re-sending so a dropped + // `connectionStatus` can never leave the webview stuck on its loading screen. + this._scheduleCachedStateRetries(this._panel); // Handle panel disposal this._panel.onDidDispose(() => { @@ -126,6 +156,13 @@ export class AgentManagerPanelProvider { // Send to webview if it exists this._sendCachedState(); + + // When we become connected, keep re-sending at staggered delays so the + // webview cannot miss the transition (postMessage is dropped if the + // webview bridge is not ready yet). + if (status === 'connected') { + this._scheduleCachedStateRetries(this._panel); + } } public notifySettingsSynced(settings: unknown): void { diff --git a/packages/vscode/src/ChatViewProvider.ts b/packages/vscode/src/ChatViewProvider.ts index e6e7852c..c55d2563 100644 --- a/packages/vscode/src/ChatViewProvider.ts +++ b/packages/vscode/src/ChatViewProvider.ts @@ -58,6 +58,33 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { private readonly _MESSAGE_TIMEOUT = 5000; // 5 seconds private readonly _MAX_RETRIES = 3; + /** + * The webview only leaves its initial loading screen once it receives a + * `connectionStatus: connected` message. VS Code drops postMessage calls + * made before the webview's acquireVsCodeApi bridge is ready (common in + * code-server / slow or flaky networks), so a single send can be lost + * forever. Re-send at staggered delays until the target view is replaced. + */ + private _scheduleCachedStateRetries(targetView: vscode.WebviewView | undefined): void { + if (this._cachedStatus !== 'connected') { + return; + } + const view = targetView ?? this._view; + if (!view) { + return; + } + const delaysMs = [500, 1500, 3500, 7000, 12000, 20000]; + for (const delayMs of delaysMs) { + setTimeout(() => { + // Only re-send if this exact view is still the active one. + if (this._view !== view) { + return; + } + this._sendCachedState(); + }, delayMs); + } + } + private _createMessageId(): string { return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } @@ -102,6 +129,9 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { // Send cached connection status and API URL (may have been set before webview was resolved) this._sendCachedState(); + // The webview bridge may not be ready yet; keep re-sending so a dropped + // `connectionStatus` can never leave the webview stuck on its loading screen. + this._scheduleCachedStateRetries(webviewView); // Send current active editor file state to the new webview this._lastActiveEditorFilePayload = null; @@ -185,6 +215,13 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { // Send to webview if it exists this._sendCachedState(); + + // When we become connected, keep re-sending at staggered delays so the + // webview cannot miss the transition (postMessage is dropped if the + // webview bridge is not ready yet). + if (status === 'connected') { + this._scheduleCachedStateRetries(this._view); + } } public addTextToInput(text: string) { diff --git a/packages/vscode/src/SessionEditorPanelProvider.ts b/packages/vscode/src/SessionEditorPanelProvider.ts index 76a82b02..8c55e401 100644 --- a/packages/vscode/src/SessionEditorPanelProvider.ts +++ b/packages/vscode/src/SessionEditorPanelProvider.ts @@ -49,6 +49,29 @@ export class SessionEditorPanelProvider { private _lastActiveEditorFilePayload: ActiveEditorFilePayload | null = null; private readonly _webviewDevServerUrl: string | null; + /** + * The webview only leaves its initial loading screen once it receives a + * `connectionStatus: connected` message. VS Code drops postMessage calls + * made before the webview's acquireVsCodeApi bridge is ready (common in + * code-server / slow or flaky networks), so a single send can be lost + * forever. Re-send at staggered delays until the target panel is replaced. + */ + private _scheduleCachedStateRetries(panelId: string, entry: SessionPanelState): void { + if (this._cachedStatus !== 'connected') { + return; + } + const delaysMs = [500, 1500, 3500, 7000, 12000, 20000]; + for (const delayMs of delaysMs) { + setTimeout(() => { + // Only re-send if this exact panel is still registered. + if (this._panels.get(panelId)?.panel !== entry.panel) { + return; + } + this._sendCachedStateToPanel(entry); + }, delayMs); + } + } + constructor( private readonly _context: vscode.ExtensionContext, private readonly _extensionUri: vscode.Uri, @@ -116,6 +139,9 @@ export class SessionEditorPanelProvider { void this.updateTheme(vscode.window.activeColorTheme.kind); this._sendCachedStateToPanel(state); + // The webview bridge may not be ready yet; keep re-sending so a dropped + // `connectionStatus` can never leave the webview stuck on its loading screen. + this._scheduleCachedStateRetries(panelId, state); void this._broadcastActiveEditorFile(); panel.onDidDispose(() => { @@ -187,6 +213,15 @@ export class SessionEditorPanelProvider { for (const entry of this._panels.values()) { this._sendCachedStateToPanel(entry); } + + // When we become connected, keep re-sending at staggered delays so the + // webview cannot miss the transition (postMessage is dropped if the + // webview bridge is not ready yet). + if (status === 'connected') { + for (const [panelId, entry] of this._panels.entries()) { + this._scheduleCachedStateRetries(panelId, entry); + } + } } public notifySettingsSynced(settings: unknown): void { From ae1c6de470309b75010f2e79b99cfabcdb3a7782 Mon Sep 17 00:00:00 2001 From: gaojunran Date: Sat, 22 Aug 2026 02:33:28 +0800 Subject: [PATCH 03/63] feat(ui): virtualize large file preview in FilesView Files above the editable size cap (MAX_VIEW_CHARS) now render their full content through pierre's Virtualizer (viewport-only DOM) with highlighting on the shared Shiki worker pool instead of a 200k-char main-thread-highlighted slice. Also fixes a pierre virtualization deadlock: forcing the virtualized host to viewport height decoupled the IntersectionObserver judgment box from the scroll content height, so a large scroll jump blanked the file (0 lines). The large-file host now keeps no fixed height; the observer always sees it intersecting. Addresses #2868 (part 1: large-file preview virtualization). --- .../ui/src/components/views/FilesView.tsx | 93 ++++++++++++++----- .../views/useFileViewVirtualizer.ts | 49 ++++++++++ 2 files changed, 120 insertions(+), 22 deletions(-) create mode 100644 packages/ui/src/components/views/useFileViewVirtualizer.ts diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 23e73735..b1da1470 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -31,7 +31,9 @@ import { languageByExtension, loadLanguageByExtension } from '@/lib/codemirror/l import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme'; import { shikiHighlightExtension } from '@/lib/codemirror/shikiHighlight'; import { getResolvedShikiTheme } from '@/lib/shiki/appThemeRegistry'; -import { File as PierreFile } from '@pierre/diffs/react'; +import { File as PierreFile, VirtualizerContext, WorkerPoolContext } from '@pierre/diffs/react'; +import { useWorkerPool } from '@/contexts/DiffWorkerProvider'; +import { useFileViewVirtualizer, type FileViewVirtualizer } from './useFileViewVirtualizer'; import { Dialog, DialogContent, @@ -311,6 +313,23 @@ const isFileMissingError = (error: unknown): boolean => { const MAX_VIEW_CHARS = 200_000; type FileLineEnding = '\n' | '\r\n'; +// Fast cache key for pierre's line/highlight caches: content-derived (not a +// revision counter) so polling reloads and out-of-view changes can never hit +// a stale entry. Mirrors the diff viewer's key scheme. Known residual: two +// files identical in total length and in the first/last 200 characters can +// collide and briefly show stale content; same profile as PierreDiffViewer. +function makeContentCacheKey(contents: string): string { + const sample = contents.length > 400 + ? `${contents.slice(0, 200)}${contents.slice(-200)}` + : contents; + let hash = 0x811c9dc5; + for (let i = 0; i < sample.length; i += 1) { + hash ^= sample.charCodeAt(i); + hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0; + } + return `${contents.length}:${hash.toString(16)}`; +} + const detectFileLineEnding = (content: string): FileLineEnding => { let crlf = 0; let lf = 0; @@ -3163,27 +3182,57 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { }); }, [cancel, commentText, deleteDraft, editingDraftId, filesFileDrafts, handleSaveComment, isDragging, lineSelection, selectedFile?.path, setCommentText, startEdit]); - const renderShikiFileView = React.useCallback((file: FileNode, content: string) => { + const mainViewVirtualizer = useFileViewVirtualizer(); + const fullscreenViewVirtualizer = useFileViewVirtualizer(); + const shikiWorkerPool = useWorkerPool('unified'); + // Files above the editable size cap are rendered as a read-only preview; give + // them the full file content plus pierre's viewport virtualization and the + // shared Shiki worker pool so large files stay responsive. + const isLargeFile = fileContent.length > MAX_VIEW_CHARS; + const largeFileCacheKey = React.useMemo( + () => (isLargeFile ? makeContentCacheKey(fileContent) : undefined), + [fileContent, isLargeFile], + ); + + const renderShikiFileView = React.useCallback((file: FileNode, content: string, virtualizer: FileViewVirtualizer) => { + const fileContents = { + name: file.name, + contents: content, + lang: getLanguageFromExtension(file.path) || undefined, + }; + const pierreFile = (key: string) => ( + + ); + + if (!isLargeFile) { + return
{pierreFile(file.path)}
; + } + + // Large files render through pierre's Virtualizer (viewport-only DOM) and + // the shared Shiki worker pool. The pool is created lazily: until it is + // ready the key carries a 'pending' suffix so the file remounts with the + // worker-backed highlighter instead of silently staying on the main thread. return (
- + + + {pierreFile(`${file.path}:${shikiWorkerPool ? 'pool' : 'pending'}`)} + +
); - }, [currentTheme.metadata.variant, pierreTheme, wrapLines]); + }, [currentTheme.metadata.variant, isLargeFile, largeFileCacheKey, pierreTheme, shikiWorkerPool, wrapLines]); const renderFloatingFileControls = ({ exitFullscreenOnly = false, @@ -3854,7 +3903,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { )} )} - + {!selectedFile ? (
{t('filesView.editor.pickFileFromTree')}
) : (fileLoading || isPdfAssetAuthLoading) ? ( @@ -3980,7 +4029,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { ) ) : selectedFile && canUseShikiFileView && textViewMode === 'view' ? ( - renderShikiFileView(selectedFile, draftContent) + renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, mainViewVirtualizer) ) : (
= ({ mode = 'full' }) => {
{renderFloatingFileControls({ exitFullscreenOnly: true })}
- + {(fileLoading || isPdfAssetAuthLoading) ? ( suppressFileLoadingIndicator ?
@@ -4322,7 +4371,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => {
) : canUseShikiFileView && textViewMode === 'view' ? ( - renderShikiFileView(selectedFile, draftContent) + renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, fullscreenViewVirtualizer) ) : (
diff --git a/packages/ui/src/components/views/useFileViewVirtualizer.ts b/packages/ui/src/components/views/useFileViewVirtualizer.ts new file mode 100644 index 00000000..a62fed38 --- /dev/null +++ b/packages/ui/src/components/views/useFileViewVirtualizer.ts @@ -0,0 +1,49 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { Virtualizer } from '@pierre/diffs'; + +/** + * Owns one pierre `Virtualizer` bound to a scrolling container. + * + * The instance is created on first render (the constructor does no DOM work) + * so a `` mounted inside a `VirtualizerContext.Provider` already + * picks the virtualized path on mount. pierre queues `connect()` calls made + * before `setup()` and flushes them once the real scroller element is bound. + * + * The scroller passed to `setScroller` must be the actual scrolling element: + * pierre reads `scrollTop`/`scrollHeight`/client height and applies its scroll + * fix on that element. + */ +export function useFileViewVirtualizer() { + const [virtualizer] = useState(() => new Virtualizer()); + const setupRef = useRef(false); + + const setScroller = useCallback( + (node: HTMLElement | null) => { + if (node == null) { + // The scroller was removed (e.g. mobile tree/files toggle or exiting + // fullscreen). pierre's setup() no-ops when a root is already bound, + // so tear the binding down or the next mount would silently attach to + // the stale element and the virtualized file would never update. + virtualizer.cleanUp(); + setupRef.current = false; + return; + } + if (setupRef.current) return; + setupRef.current = true; + virtualizer.setup(node, node.firstElementChild ?? undefined); + }, + [virtualizer], + ); + + useLayoutEffect( + () => () => { + setupRef.current = false; + virtualizer.cleanUp(); + }, + [virtualizer], + ); + + return { virtualizer, setScroller }; +} + +export type FileViewVirtualizer = ReturnType; From d2d8669564f12be713ca8d02fd3debd781ebd121 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 4 Aug 2026 12:09:37 +0300 Subject: [PATCH 04/63] refactor(chat): replace timeline scroll engine with anchored-turn LegendList Sending a message now parks that message near the top of the viewport and streams the reply into reserved end space below it, instead of jumping to the bottom and chasing it. - swap @tanstack/react-virtual for @legendapp/list in the chat timeline; the streaming tail becomes a normal list row rather than a separately rendered block, so one component owns the scroll position - add timelineScrollAnchoring: pure anchored-turn geometry plus the three scroll modes (following-end / anchoring-new-turn / free-scrolling) - replace useChatAutoFollow with useChatTimelineScroll, which opts out of automatic movement on real gestures via a generation counter instead of the timer windows the old implementation needed to recognise its own writes - move the load-older button, question/permission cards, recap, status row and bottom spacer into the list header/footer, since the list owns its container - extract useScrollShadow so the shadows can attach to that container maintainScrollAtEnd and maintainVisibleContentPosition replace the manual prepend anchor-hold and the mobile quiet-window prepend deferral. Validated: workspace type-check, lint, web build, ui tests per file. Scroll behaviour itself is unverified and needs manual testing on web, desktop and iOS. --- bun.lock | 3 + packages/ui/package.json | 1 + .../ui/src/components/chat/ChatContainer.tsx | 231 +++-- .../ui/src/components/chat/ChatMessage.tsx | 2 +- .../ui/src/components/chat/MessageList.tsx | 750 +++++++------- .../chat/components/TurnActivity.tsx | 2 +- .../scroll/timelineScrollAnchoring.test.ts | 225 +++++ .../lib/scroll/timelineScrollAnchoring.ts | 147 +++ .../components/chat/message/MessageBody.tsx | 2 +- .../chat/message/parts/AssistantTextPart.tsx | 2 +- .../chat/message/parts/JustificationBlock.tsx | 2 +- .../chat/message/parts/ProgressiveGroup.tsx | 2 +- .../chat/message/parts/ReasoningPart.tsx | 2 +- .../chat/message/parts/ToolPart.tsx | 2 +- .../ui/src/components/ui/ScrollShadow.tsx | 114 +-- .../ui/src/components/ui/useScrollShadow.ts | 159 +++ packages/ui/src/hooks/useChatAutoFollow.ts | 938 ------------------ .../ui/src/hooks/useChatTimelineScroll.ts | 691 +++++++++++++ 18 files changed, 1739 insertions(+), 1536 deletions(-) create mode 100644 packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts create mode 100644 packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts create mode 100644 packages/ui/src/components/ui/useScrollShadow.ts delete mode 100644 packages/ui/src/hooks/useChatAutoFollow.ts create mode 100644 packages/ui/src/hooks/useChatTimelineScroll.ts diff --git a/bun.lock b/bun.lock index 98e47d8f..d9713441 100644 --- a/bun.lock +++ b/bun.lock @@ -167,6 +167,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@legendapp/list": "3.2.0", "@lezer/highlight": "^1.2.3", "@opencode-ai/sdk": "1.18.21", "@pierre/diffs": "1.3.0-beta.6", @@ -917,6 +918,8 @@ "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="], + "@legendapp/list": ["@legendapp/list@3.2.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom", "react-native"] }, "sha512-bN+g/oQYjFz+UAyuBN4cmYJAwdJS1TdNcZZOVlh3+VwCQUWrsg0PH46Mvm76gdZSCYMfoFanPY4dKnILcYEzeg=="], + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], "@lezer/common": ["@lezer/common@1.5.1", "", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index 14eaf104..dc5e40f1 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -43,6 +43,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@legendapp/list": "3.2.0", "@lezer/highlight": "^1.2.3", "@opencode-ai/sdk": "1.18.21", "@pierre/diffs": "1.3.0-beta.6", diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index a6384531..9174668b 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -18,8 +18,8 @@ import { StatusRowContainer } from './StatusRowContainer'; import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer'; import ScrollToBottomButton from './components/ScrollToBottomButton'; import { PromptNavigatorRail } from './components/PromptNavigatorRail'; -import { ScrollShadow } from '@/components/ui/ScrollShadow'; -import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow'; +import { useScrollShadow } from '@/components/ui/useScrollShadow'; +import { useChatTimelineScroll, type AnimationHandlers, type ContentChangeReason, type TimelineListHandle } from '@/hooks/useChatTimelineScroll'; import { useChatTimelineController } from './hooks/useChatTimelineController'; import { TimelineDialog } from './TimelineDialog'; import { useChatTurnNavigation } from './hooks/useChatTurnNavigation'; @@ -151,10 +151,16 @@ type ChatViewportProps = { currentSessionKey: string; isDesktopExpandedInput: boolean; isMobile: boolean; - stickyUserHeader: boolean; directory?: string; scrollRef: React.RefObject; messageListRef: React.RefObject; + registerList: (list: TimelineListHandle | null) => void; + anchorMessageId: string | null; + onAnchorReady: (messageId: string, anchorIndex: number) => void; + onAnchorSizeChanged: (messageId: string) => void; + composerOverlayHeight: number; + onIsAtEndChange: (isAtEnd: boolean) => void; + onTimelineDataChange: () => void; pendingRevealWork: boolean; renderedMessages: SessionMessageRecord[]; isLoadingOlder: boolean; @@ -169,7 +175,6 @@ type ChatViewportProps = { } | null; handleMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; - handleHistoryScroll: () => void; scrollToBottom: () => void; sessionQuestions: QuestionRequest[]; sessionPermissions: PermissionRequest[]; @@ -190,10 +195,16 @@ const ChatViewport = React.memo(({ currentSessionKey, isDesktopExpandedInput, isMobile, - stickyUserHeader, directory, scrollRef, messageListRef, + registerList, + anchorMessageId, + onAnchorReady, + onAnchorSizeChanged, + composerOverlayHeight, + onIsAtEndChange, + onTimelineDataChange, pendingRevealWork, renderedMessages, isLoadingOlder, @@ -203,7 +214,6 @@ const ChatViewport = React.memo(({ retryOverlay, handleMessageContentChange, getAnimationHandlers, - handleHistoryScroll, scrollToBottom, sessionQuestions, sessionPermissions, @@ -315,6 +325,60 @@ const ChatViewport = React.memo(({ scrollRef.current?.focus({ preventScroll: true }); }, [scrollRef]); + // Everything that used to sit beside the list inside the scroll container + // now renders as the list's header/footer, so it keeps scrolling with the + // rows exactly as before. + const listHeader = React.useMemo(() => ( + showLoadOlderButton ? ( +
+ +
+ ) : null + ), [isLoadingOlder, onLoadOlder, showLoadOlderButton, t]); + + const listFooter = React.useMemo(() => ( + <> + {(sessionQuestions.length > 0 || sessionPermissions.length > 0) && ( +
+ {sessionQuestions.map((question) => ( + + ))} + {sessionPermissions.map((permission) => ( + + ))} +
+ )} + + + +
+ +
+ +