From aae889b90451bfbc4f69838a950eeb9810d80807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=96=8E=F0=9D=96=9A=F0=9D=96=91=F0=9D=96=8E?= =?UTF-8?q?=F0=9D=96=8E=F0=9D=96=86?= Date: Fri, 31 Jul 2026 12:51:15 +0300 Subject: [PATCH] perf: optimize session loading and desktop startup (#2545) * perf: optimize session loading and startup * fix(chat): stabilize history prepend virtualization * perf: unblock first session open from startup network contention Opening the first session after app start waited seconds for its message fetch. Three independent contributors, each measured via CDP network capture and Chromium net-log against the packaged desktop app: - The active-session watchdog fired an uncapped per-directory status poll and child-session discovery burst at startup, and other subsystems (git checks, global session pages, command/skill discovery) fanned out alongside it, saturating the browser's ~6 HTTP/1.1 sockets per origin. Add a shared background-network gate (concurrency 3) and route the watchdog, poll-shaped git reads (also priority: low), global session pages, command/skill loads, and the background update check through it. - The packaged renderer is cross-origin to the loopback backend, so every API call needs a CORS preflight; a few slow OpenCode-proxied requests held the whole pool while preflights and interactive traffic queued behind them. Lift Chromium's per-host connection cap for loopback via ignore-connections-limit in the Electron shell. - OpenCode initializes each directory lazily on its first request, so the first click paid that cost interactively. Warm the last-used directory and the three most recently opened projects right after OpenCode readiness, sequentially and best-effort, overlapping UI startup. Validation: new background-network tests, lifecycle warmup test, focused store/sync tests, UI type-check and lint, dead-code report, node --check plus electron type-check/lint, and CDP first-open measurements on the packaged app (message fetch socket queue 5.4s -> 0.03s). * fix(ui): keep interactive git reads out of background queue --------- Co-authored-by: Bohdan Triapitsyn --- packages/electron/README.md | 2 + packages/electron/main.mjs | 83 ++++++- packages/electron/package.json | 2 +- packages/electron/startup-url-selection.mjs | 4 + .../electron/startup-url-selection.test.mjs | 48 ++++ .../ui/src/components/chat/ChatContainer.tsx | 31 ++- .../ui/src/components/chat/MessageList.tsx | 12 +- .../hooks/useChatTimelineController.test.ts | 188 +++++++++++++++ .../chat/hooks/useChatTimelineController.ts | 167 +++++++------ .../src/components/session/SessionSidebar.tsx | 18 +- .../sidebar/hooks/useProjectRepoStatus.ts | 11 +- packages/ui/src/hooks/useChatAutoFollow.ts | 24 +- .../ui/src/lib/background-network.test.ts | 42 ++++ packages/ui/src/lib/background-network.ts | 61 +++++ packages/ui/src/lib/gitApiHttp.test.ts | 25 +- packages/ui/src/stores/globalSessions.ts | 8 +- packages/ui/src/stores/useCommandsStore.ts | 5 +- .../useGlobalSessionsStore-races.test.ts | 33 ++- packages/ui/src/stores/useSkillsStore.ts | 3 +- packages/ui/src/stores/useUpdateStore.ts | 2 + packages/ui/src/sync/DOCUMENTATION.md | 9 +- packages/ui/src/sync/child-store.ts | 1 - .../ui/src/sync/session-load-performance.ts | 129 +++++++++- .../src/sync/session-message-loader.test.ts | 221 ++++++++++++++++++ .../ui/src/sync/session-message-loader.ts | 117 ++++++---- packages/ui/src/sync/sync-context.tsx | 15 +- packages/ui/src/sync/use-sync.ts | 5 +- packages/web/server/index.js | 24 +- .../web/server/lib/opencode/DOCUMENTATION.md | 7 +- packages/web/server/lib/opencode/lifecycle.js | 105 ++++++++- .../web/server/lib/opencode/lifecycle.test.js | 91 ++++++++ packages/web/server/lib/opencode/proxy.js | 28 ++- .../lib/opencode/startup-performance.js | 44 ++++ .../lib/opencode/startup-performance.test.js | 73 ++++++ .../lib/opencode/startup-pipeline-runtime.js | 7 + packages/web/server/lib/tts/routes.js | 7 +- packages/web/server/lib/tts/routes.test.js | 21 +- scripts/profile-browser-session-load.mjs | 81 +++++++ scripts/profile-browser-session-load.test.mjs | 110 +++++++++ scripts/profile-browser.md | 8 +- scripts/profile-browser.mjs | 21 +- 41 files changed, 1690 insertions(+), 203 deletions(-) create mode 100644 packages/electron/startup-url-selection.mjs create mode 100644 packages/electron/startup-url-selection.test.mjs create mode 100644 packages/ui/src/lib/background-network.test.ts create mode 100644 packages/ui/src/lib/background-network.ts create mode 100644 packages/web/server/lib/opencode/startup-performance.js create mode 100644 packages/web/server/lib/opencode/startup-performance.test.js create mode 100644 scripts/profile-browser-session-load.mjs create mode 100644 scripts/profile-browser-session-load.test.mjs diff --git a/packages/electron/README.md b/packages/electron/README.md index 4d97ab15..1cb2f2dd 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -19,6 +19,7 @@ The preload bridge exposes desktop-only APIs to the web UI through `window.__OPE | File | Purpose | |------|---------| | `main.mjs` | Electron main process, app lifecycle, windows, menus, deep links, native IPC handlers, updates, local server startup | +| `startup-url-selection.mjs` | Pure bundled/HMR startup probe policy used by main-process URL resolution | | `preload.mjs` | Safe bridge from the rendered UI to Electron IPC | | `ssh-manager.mjs` | SSH host import, connection lifecycle, tunnel/port forwarding helpers | | `scripts/electron-dev.mjs` | Desktop dev launcher with Vite HMR support | @@ -123,6 +124,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u | `OPENCHAMBER_TARGET_ARCH` | Explicit desktop package architecture (`x64` or `arm64`); Linux requires it to match the native host | | `OPENCHAMBER_DESKTOP_NOTIFY=true` | Enables desktop notification flow in the web server | | `OPENCHAMBER_SKIP_API_COMPRESSION=true` | Defaulted by Desktop to reduce local CPU overhead | +| `OPENCHAMBER_STARTUP_PERF=1` | Enables privacy-safe startup phase timings in Desktop/server logs; disabled by default | | `OPENCODE_HOST` / `OPENCODE_PORT` / `OPENCODE_SKIP_START` | Connect Desktop to an external OpenCode server instead of starting one locally | ## Native Features Owned Here diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index eb202147..0b94d190 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -13,6 +13,7 @@ import updaterPkg from 'electron-updater'; import { ElectronSshManager } from './ssh-manager.mjs'; import { createTrayController } from './tray.mjs'; import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs'; +import { resolveStartupUrlProbePlan } from './startup-url-selection.mjs'; import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs'; import { assertUpdaterCapability } from './updater-capability.mjs'; import { checkForDesktopUpdate } from './updater-check.mjs'; @@ -37,6 +38,7 @@ const execFileAsync = promisify(execFile); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const isDev = process.env.OPENCHAMBER_ELECTRON_DEV === '1' || !app.isPackaged; +const electronStartupStartedAt = performance.now(); const DEEP_LINK_PROTOCOL = 'openchamber'; const UI_PROTOCOL = 'openchamber-ui'; @@ -87,6 +89,13 @@ if (isDev) { } app.setAppUserModelId(APP_USER_MODEL_ID); app.commandLine.appendSwitch('proxy-bypass-list', '<-loopback>'); +// Lift Chromium's ~6-connections-per-host cap for the loopback backend. The +// packaged renderer is cross-origin (openchamber-ui:// → http://127.0.0.1), so +// every API call also needs a CORS preflight; during startup a few slow +// OpenCode-proxied requests otherwise hold the whole pool and every other +// request — including opening the first session — queues for seconds behind +// them. Loopback has no per-host connection cost that the cap protects. +app.commandLine.appendSwitch('ignore-connections-limit', '127.0.0.1,localhost'); protocol.registerSchemesAsPrivileged([ { @@ -123,6 +132,32 @@ log.transports.console.level = isDev ? 'debug' : 'warn'; // diagnostics are persisted. Object.assign(console, log.functions); +const STARTUP_PERF_ENABLED_VALUES = new Set(['1', 'true']); +const ELECTRON_STARTUP_PERF_PHASES = new Set([ + 'electron.app.ready', + 'electron.server.start', + 'electron.server.ready', + 'electron.navigation.start', + 'electron.navigation.ready', + 'electron.renderer.dom-ready', + 'electron.renderer.loaded', + 'electron.window.ready-to-show', +]); +const ELECTRON_STARTUP_DOCUMENT_CLASSES = new Set(['splash', 'application']); +const recordElectronStartupPerformance = (phase, details = {}) => { + const enabled = STARTUP_PERF_ENABLED_VALUES.has(String(process.env.OPENCHAMBER_STARTUP_PERF ?? '').toLowerCase()); + if (!enabled || !ELECTRON_STARTUP_PERF_PHASES.has(phase)) return; + const event = { + phase, + at: Date.now(), + totalDurationMs: Math.max(0, performance.now() - electronStartupStartedAt), + }; + if (Number.isFinite(details.durationMs) && details.durationMs >= 0) event.durationMs = details.durationMs; + if (ELECTRON_STARTUP_DOCUMENT_CLASSES.has(details.documentClass)) event.documentClass = details.documentClass; + log.info('[startup-performance]', event); +}; +const classifyStartupDocument = (url) => String(url || '').startsWith('data:') ? 'splash' : 'application'; + const LOG_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; try { const logPath = log.transports.file.getFile().path; @@ -1368,6 +1403,8 @@ const shouldSkipLocalServer = () => { }; const spawnLocalServer = async () => { + const serverStartedAt = performance.now(); + recordElectronStartupPerformance('electron.server.start'); inheritUserShellEnv(); const settings = readSettingsRoot(); @@ -1451,6 +1488,9 @@ const spawnLocalServer = async () => { state.serverHandle = handle; state.sidecarUrl = url; + recordElectronStartupPerformance('electron.server.ready', { + durationMs: performance.now() - serverStartedAt, + }); await mutateSettingsRoot((root) => { root.desktopLocalPort = port; @@ -1740,8 +1780,19 @@ const isBenignNavigationAbort = (error) => { }; const navigateWindow = async (browserWindow, url, { allowAbort = false } = {}) => { + const navigationStartedAt = performance.now(); + const documentClass = classifyStartupDocument(url); + if (browserWindow.__ocLabel === 'main') { + recordElectronStartupPerformance('electron.navigation.start', { documentClass }); + } try { await browserWindow.loadURL(url); + if (browserWindow.__ocLabel === 'main') { + recordElectronStartupPerformance('electron.navigation.ready', { + documentClass, + durationMs: performance.now() - navigationStartedAt, + }); + } } catch (error) { if (allowAbort && isBenignNavigationAbort(error)) { return; @@ -2504,6 +2555,11 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } }); browserWindow.webContents.on('dom-ready', () => { + if (browserWindow.__ocLabel === 'main') { + recordElectronStartupPerformance('electron.renderer.dom-ready', { + documentClass: classifyStartupDocument(browserWindow.webContents.getURL()), + }); + } const initScript = browserWindow.__ocInitScript; if (initScript) { void browserWindow.webContents.executeJavaScript(initScript).catch(() => {}); @@ -2511,6 +2567,11 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } }); browserWindow.webContents.on('did-finish-load', () => { + if (browserWindow.__ocLabel === 'main') { + recordElectronStartupPerformance('electron.renderer.loaded', { + documentClass: classifyStartupDocument(browserWindow.webContents.getURL()), + }); + } browserWindow.webContents.setZoomFactor(1); if (state.mainWindow && browserWindow.id === state.mainWindow.id && pendingDeepLinks.length > 0) { const timer = setTimeout(flushPendingDeepLinks, 400); @@ -2519,6 +2580,11 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } }); browserWindow.once('ready-to-show', () => { + if (browserWindow.__ocLabel === 'main') { + recordElectronStartupPerformance('electron.window.ready-to-show', { + documentClass: classifyStartupDocument(browserWindow.webContents.getURL()), + }); + } browserWindow.show(); browserWindow.focus(); if (useVibrancy) applyMacVibrancy(browserWindow); @@ -2830,16 +2896,22 @@ const resolveInitialUrl = async () => { const hmrUiPort = process.env.OPENCHAMBER_HMR_UI_PORT || '5173'; const hmrApiUrl = `http://127.0.0.1:${hmrApiPort}`; const hmrUiUrl = `http://127.0.0.1:${hmrUiPort}`; + const usePackagedUi = shouldUsePackagedUi(); const skipLocalServer = shouldSkipLocalServer(); + const startupProbePlan = resolveStartupUrlProbePlan({ + development: isDev, + packagedUi: usePackagedUi, + skipLocalServer, + }); const localUrl = skipLocalServer ? null - : isDev && await waitForHealth(hmrApiUrl, 5_000, 100) + : startupProbePlan.probeHmrApi && await waitForHealth(hmrApiUrl, 5_000, 100) ? hmrApiUrl : await spawnLocalServer(); - const localUiUrl = shouldUsePackagedUi() + const localUiUrl = usePackagedUi ? buildPackagedUiUrl('/index.html') - : isDev && await waitForHealth(hmrUiUrl, 8_000, 100) + : startupProbePlan.probeHmrUi && await waitForHealth(hmrUiUrl, 8_000, 100) ? hmrUiUrl : localUrl; @@ -2859,14 +2931,14 @@ const resolveInitialUrl = async () => { apiBaseUrl = envTarget; clientToken = ''; requestHeaders = {}; - initialUrl = shouldUsePackagedUi() ? localUiUrl : envTarget; + initialUrl = usePackagedUi ? localUiUrl : envTarget; } else if (config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID) { const host = config.hosts.find((entry) => entry.id === config.defaultHostId); if (host?.url) { apiBaseUrl = host.apiUrl || host.url; clientToken = host.clientToken || ''; requestHeaders = sanitizeRuntimeRequestHeaders(host.requestHeaders || {}); - initialUrl = shouldUsePackagedUi() ? localUiUrl : host.url; + initialUrl = usePackagedUi ? localUiUrl : host.url; } } @@ -5094,6 +5166,7 @@ app.on('activate', async () => { }); app.whenReady().then(async () => { + recordElectronStartupPerformance('electron.app.ready'); const loginItemSettings = readLoginItemSettings(); const isBackgroundStart = shouldStartInBackground(loginItemSettings); log.info('[electron] app starting', { diff --git a/packages/electron/package.json b/packages/electron/package.json index 026e86cb..40a32331 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -39,7 +39,7 @@ "bundle:main": "bun ./scripts/bundle-main.mjs", "generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs", "rebuild:native": "node ./scripts/rebuild-native.mjs", - "test:architecture": "node --test ./scripts/target-architecture.test.mjs ./scripts/verify-linux-appimage.test.mjs ./scripts/verify-update-manifest.test.mjs", + "test:architecture": "node --test ./startup-url-selection.test.mjs ./scripts/target-architecture.test.mjs ./scripts/verify-linux-appimage.test.mjs ./scripts/verify-update-manifest.test.mjs", "test:updater": "node --test ./updater-capability.test.mjs ./updater-channel.test.mjs ./updater-check.test.mjs ./updater-feed.test.mjs ./scripts/finalize-latest-yml.test.mjs ./scripts/updater-e2e-fixture.test.mjs", "test:linux-desktop": "node --test ./linux-autostart.test.mjs && node ./scripts/smoke-linux-app-discovery.mjs && node ./scripts/smoke-path-open-utils.mjs", "updater:e2e:fixture": "node ./scripts/updater-e2e-fixture.mjs", diff --git a/packages/electron/startup-url-selection.mjs b/packages/electron/startup-url-selection.mjs new file mode 100644 index 00000000..c7827606 --- /dev/null +++ b/packages/electron/startup-url-selection.mjs @@ -0,0 +1,4 @@ +export const resolveStartupUrlProbePlan = ({ development, packagedUi, skipLocalServer }) => ({ + probeHmrApi: development === true && packagedUi !== true && skipLocalServer !== true, + probeHmrUi: development === true && packagedUi !== true, +}); diff --git a/packages/electron/startup-url-selection.test.mjs b/packages/electron/startup-url-selection.test.mjs new file mode 100644 index 00000000..48d3dba8 --- /dev/null +++ b/packages/electron/startup-url-selection.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { resolveStartupUrlProbePlan } from './startup-url-selection.mjs'; + +test('bundled development never probes HMR endpoints', () => { + assert.deepEqual(resolveStartupUrlProbePlan({ + development: true, + packagedUi: true, + skipLocalServer: false, + }), { + probeHmrApi: false, + probeHmrUi: false, + }); +}); + +test('HMR development probes both API and UI endpoints', () => { + assert.deepEqual(resolveStartupUrlProbePlan({ + development: true, + packagedUi: false, + skipLocalServer: false, + }), { + probeHmrApi: true, + probeHmrUi: true, + }); +}); + +test('serverless HMR development skips only the local API probe', () => { + assert.deepEqual(resolveStartupUrlProbePlan({ + development: true, + packagedUi: false, + skipLocalServer: true, + }), { + probeHmrApi: false, + probeHmrUi: true, + }); +}); + +test('production does not probe HMR endpoints', () => { + assert.deepEqual(resolveStartupUrlProbePlan({ + development: false, + packagedUi: false, + skipLocalServer: false, + }), { + probeHmrApi: false, + probeHmrUi: false, + }); +}); diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 603e9b5c..f6c66922 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -55,6 +55,8 @@ import { isFullySyntheticMessage } from '@/lib/messages/synthetic'; import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts'; import { findShellCommandForMessage, isUserShellMarkerMessage } from './lib/shellBridge'; import { resolveChatPromptReadOnly } from './chatPromptReadOnly'; +import { getRuntimeKey } from '@/lib/runtime-switch'; +import { createFirstVisibleSessionPerformanceTracker } from '@/sync/session-load-performance'; const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = []; const IDLE_SESSION_STATUS = { type: 'idle' as const }; @@ -140,6 +142,7 @@ type HydratingToolSkeletonRow = { type ChatViewportProps = { currentSessionId: string; + currentSessionKey: string; isDesktopExpandedInput: boolean; isMobile: boolean; stickyUserHeader: boolean; @@ -178,6 +181,7 @@ type ChatViewportProps = { const ChatViewport = React.memo(({ currentSessionId, + currentSessionKey, isDesktopExpandedInput, isMobile, stickyUserHeader, @@ -345,7 +349,7 @@ const ChatViewport = React.memo(({ )} { return prev.currentSessionId === next.currentSessionId + && prev.currentSessionKey === next.currentSessionKey && prev.isDesktopExpandedInput === next.isDesktopExpandedInput && prev.isMobile === next.isMobile && prev.stickyUserHeader === next.stickyUserHeader @@ -542,14 +547,17 @@ export const ChatContainer: React.FC = ({ active = true, aut const sync = useSync(); const syncDirectory = useSyncDirectory(); const effectiveSessionDirectory = currentSessionDirectory ?? syncDirectory; + const currentSessionKey = currentSessionId + ? JSON.stringify([getRuntimeKey(), effectiveSessionDirectory, currentSessionId]) + : null; const ensureSessionRenderable = React.useCallback( (sessionId: string) => sync.ensureSessionRenderable(sessionId, false, effectiveSessionDirectory), [effectiveSessionDirectory, sync], ); const loadMoreMessages = React.useCallback( // eslint-disable-next-line @typescript-eslint/no-unused-vars - (sessionId: string, _direction: 'up' | 'down') => sync.loadMore(sessionId), - [sync], + (sessionId: string, _direction: 'up' | 'down') => sync.loadMore(sessionId, effectiveSessionDirectory), + [effectiveSessionDirectory, sync], ); // UI store @@ -589,6 +597,12 @@ export const ChatContainer: React.FC = ({ active = true, aut currentSessionId ?? '', effectiveSessionDirectory, ); + const [firstVisiblePerformance] = React.useState(createFirstVisibleSessionPerformanceTracker); + + React.useEffect(() => { + if (!active || !currentSessionKey || !hasRenderableSessionSnapshot || sessionMessages.length === 0) return; + return firstVisiblePerformance.schedule(currentSessionKey, sessionMessages.length); + }, [active, currentSessionKey, firstVisiblePerformance, hasRenderableSessionSnapshot, sessionMessages.length]); // Plan detection - watches messages for plan creation and signals store usePlanDetection(currentSessionId ?? '', sessionMessages); @@ -779,6 +793,7 @@ export const ChatContainer: React.FC = ({ active = true, aut showScrollButton, } = useChatAutoFollow({ currentSessionId, + currentSessionKey, sessionMessageCount, sessionIsWorking, isMobile, @@ -789,6 +804,7 @@ export const ChatContainer: React.FC = ({ active = true, aut const timelineController = useChatTimelineController({ sessionId: currentSessionId, + sessionKey: currentSessionKey, messages: viewportMessages, historyMeta, scrollRef, @@ -940,7 +956,7 @@ export const ChatContainer: React.FC = ({ active = true, aut }; }, [currentSessionId, isDesktopExpandedInput, scrollRef]); - const lastScrolledSessionRef = React.useRef(null); + const lastScrolledSessionKeyRef = React.useRef(null); const isSessionHydrating = Boolean(currentSessionId) @@ -952,10 +968,10 @@ export const ChatContainer: React.FC = ({ active = true, aut React.useEffect(() => { if (!active || !currentSessionId) return; - if (lastScrolledSessionRef.current === currentSessionId) return; + if (lastScrolledSessionKeyRef.current === currentSessionKey) return; const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0; - lastScrolledSessionRef.current = currentSessionId; + lastScrolledSessionKeyRef.current = currentSessionKey; if (hasHashTarget) { // Hash navigation handler will scroll to target; we just release auto-follow. releaseAutoFollow(); @@ -970,7 +986,7 @@ export const ChatContainer: React.FC = ({ active = true, aut } else { window.requestAnimationFrame(run); } - }, [active, currentSessionId, releaseAutoFollow, restoreSnapshot]); + }, [active, currentSessionId, currentSessionKey, releaseAutoFollow, restoreSnapshot]); React.useEffect(() => { if (!active || !currentSessionId) return; @@ -1138,6 +1154,7 @@ export const ChatContainer: React.FC = ({ active = true, aut {returnToParentButton} (({ return { ...entry, nextEntryFirstMessage }; }); }, [staticRenderEntries, trailingEntryFirstMessage]); - // All surfaces virtualize with @tanstack/react-virtual (see the engine - // note at the top of the file). An unvirtualized list is kept only for - // tiny histories where windowing overhead is not worth it. - const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD; + // Mobile always starts with the same virtualized engine it will use after + // pagination. Switching a short list from normal DOM to TanStack during a + // prepend remounts the history subtree, and the newly enabled end-anchored + // virtualizer initializes at the bottom before it has prior keyed state. + // Desktop keeps the small-list threshold where that transition is not tied + // to the explicit mobile load-older interaction. + const shouldVirtualizeHistory = isMobileSurfaceRuntime() + || historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD; const historyEngine: HistoryEngine = shouldVirtualizeHistory ? 'tanstack' : 'none'; const tanstackVirtualizerRef = React.useRef(null); const registerTanstackVirtualizer = React.useCallback((virtualizer: TanstackVirtualizerInstance | null) => { diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts index 4eb0a72e..3813b8dc 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts @@ -1,9 +1,15 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; import { describe, expect, test } from 'bun:test'; +import type { Message } from '@opencode-ai/sdk/v2/client'; import { isOlderHistoryPrependCommit, shouldAutoLoadEarlierForUnderfilledPinnedViewport, + useChatTimelineController, + type UseChatTimelineControllerResult, } from './useChatTimelineController'; +import type { MessageListHandle } from '../MessageList'; const baseInput = { sessionId: 'ses_1', @@ -68,3 +74,185 @@ describe('isOlderHistoryPrependCommit', () => { })).toBe(false); }); }); + +const deferred = () => { + let resolve!: () => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +}; + +const installMinimalDom = () => { + const descriptors = new Map(); + const setGlobal = (name: string, value: unknown) => { + descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); + }; + class ElementStub {} + const documentStub: Record = { + nodeType: 9, + defaultView: globalThis, + activeElement: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }; + const container = { + nodeType: 1, + tagName: 'DIV', + nodeName: 'DIV', + namespaceURI: 'http://www.w3.org/1999/xhtml', + ownerDocument: documentStub, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }; + documentStub.documentElement = container; + documentStub.body = container; + setGlobal('document', documentStub); + setGlobal('window', globalThis); + setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' }); + setGlobal('Element', ElementStub); + setGlobal('HTMLElement', ElementStub); + setGlobal('HTMLIFrameElement', ElementStub); + setGlobal('IS_REACT_ACT_ENVIRONMENT', true); + setGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0)); + setGlobal('cancelAnimationFrame', (id: ReturnType) => clearTimeout(id)); + return { + container: container as unknown as Element, + restore: () => { + for (const [name, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + } + }, + }; +}; + +describe('useChatTimelineController identity lifecycle', () => { + test('preserves the new identity while an old load is waiting for its render', async () => { + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + const pendingA = deferred(); + const pendingB = deferred(); + const calls: string[] = []; + const sessionId = 'shared-session'; + const message = { + info: { id: 'msg_1', sessionID: sessionId, role: 'user', time: { created: 1 } } as Message, + parts: [], + }; + const olderMessage = { + info: { id: 'msg_0', sessionID: sessionId, role: 'user', time: { created: 0 } } as Message, + parts: [], + }; + const assistantMessage = { + info: { id: 'msg_2', sessionID: sessionId, role: 'assistant', time: { created: 2 } } as Message, + parts: [], + }; + const scrollMetrics = { + scrollTop: 100, + scrollHeight: 1000, + clientHeight: 500, + firstElementChild: null, + }; + const scrollElement = scrollMetrics as unknown as HTMLDivElement; + const scrollRef = { current: scrollElement }; + const capturedAnchors: string[] = []; + const restoredAnchors: string[] = []; + const messageListRef = { + current: { + captureViewportAnchor: () => { + const messageId = `anchor-${directory}`; + capturedAnchors.push(messageId); + return { messageId, offsetTop: 0 }; + }, + restoreViewportAnchor: (anchor: { messageId: string }) => { + restoredAnchors.push(anchor.messageId); + return true; + }, + isHistoryVirtualized: () => false, + scrollToTurnId: () => false, + scrollToMessageId: () => false, + } as unknown as MessageListHandle, + }; + let controller!: UseChatTimelineControllerResult; + let directory = 'A'; + let messages = [message]; + let startBOnLayout = false; + let loadB: Promise | null = null; + + const Harness = () => { + const selectedDirectory = directory; + controller = useChatTimelineController({ + sessionId, + sessionKey: `runtime\n${selectedDirectory}\n${sessionId}`, + messages, + historyMeta: { limit: 1, complete: false, loading: false }, + scrollRef, + messageListRef, + loadMoreMessages: async () => { + calls.push(selectedDirectory); + await (selectedDirectory === 'A' ? pendingA.promise : pendingB.promise); + }, + goToBottom: () => undefined, + releaseAutoFollow: () => undefined, + isPinned: false, + showScrollButton: false, + }); + React.useLayoutEffect(() => { + if (selectedDirectory === 'B' && startBOnLayout && !loadB) { + loadB = controller.loadEarlier({ userInitiated: true }); + } + }, [selectedDirectory]); + return null; + }; + + try { + await act(async () => root.render(React.createElement(Harness))); + let loadA!: Promise; + act(() => { + loadA = controller.loadEarlier({ userInitiated: true }); + }); + expect(calls).toEqual(['A']); + + // Let A pass its post-network identity check and enter the render + // waiter before switching. B starts in the same layout commit that + // releases A's waiter, so A must not clear B's new snapshot. + await act(async () => { + pendingA.resolve(); + await Promise.resolve(); + }); + directory = 'B'; + // Growth within the existing user turn means stale A would request + // another A page after its render wait without the second token gate. + messages = [message, assistantMessage]; + startBOnLayout = true; + await act(async () => { + root.render(React.createElement(Harness)); + await loadA; + }); + expect(calls).toEqual(['A', 'B']); + expect(controller.isLoadingOlder).toBe(true); + expect(capturedAnchors).toContain('anchor-B'); + expect(restoredAnchors).toEqual([]); + + await act(async () => { + pendingB.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + messages = [olderMessage, message, assistantMessage]; + scrollMetrics.scrollHeight = 1200; + act(() => { + root.render(React.createElement(Harness)); + }); + await act(async () => { + await loadB; + }); + expect(controller.isLoadingOlder).toBe(false); + expect(calls).toEqual(['A', 'B']); + expect(restoredAnchors).toEqual(['anchor-B']); + } finally { + await act(async () => root.unmount()); + dom.restore(); + } + }); +}); diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index 047a4717..cdab614d 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -13,9 +13,10 @@ import { isVSCodeRuntime } from '@/lib/desktop'; import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; type ViewportAnchor = { messageId: string; offsetTop: number }; +type TimelineIdentityToken = { key: string | null }; type PendingScrollRequest = { - sessionId: string; + identity: TimelineIdentityToken; kind: 'turn' | 'message'; id: string; behavior: ScrollBehavior; @@ -25,6 +26,7 @@ type PendingScrollRequest = { interface UseChatTimelineControllerOptions { sessionId: string | null; + sessionKey: string | null; messages: ChatMessageEntry[]; historyMeta: SessionHistoryMeta | null; scrollRef: React.RefObject; @@ -192,6 +194,7 @@ const hasInsertedBeforeKnownOldest = ( export const useChatTimelineController = ({ sessionId, + sessionKey, messages, historyMeta, scrollRef, @@ -204,8 +207,14 @@ export const useChatTimelineController = ({ }: UseChatTimelineControllerOptions): UseChatTimelineControllerResult => { const previousTurnWindowModelRef = React.useRef(null); const previousMessagesRef = React.useRef(null); + const previousTurnWindowKeyRef = React.useRef(null); const turnWindowModel = React.useMemo(() => { - const key = sessionId ?? "" + const key = sessionKey ?? "" + if (previousTurnWindowKeyRef.current !== sessionKey) { + previousTurnWindowKeyRef.current = sessionKey; + previousTurnWindowModelRef.current = null; + previousMessagesRef.current = null; + } const cached = key ? turnModelCache.get(key) : undefined if (cached && cached.messages === messages) { rememberTurnModel(key, cached) @@ -228,7 +237,7 @@ export const useChatTimelineController = ({ } return nextModel; - }, [messages, sessionId]); + }, [messages, sessionKey]); const [isLoadingOlder, setIsLoadingOlder] = React.useState(false); const [pendingRevealWork, setPendingRevealWork] = React.useState(false); @@ -239,9 +248,13 @@ export const useChatTimelineController = ({ const isLoadingOlderRef = React.useRef(isLoadingOlder); const pendingRevealWorkRef = React.useRef(pendingRevealWork); const sessionIdRef = React.useRef(sessionId); + const timelineIdentityRef = React.useRef({ key: sessionKey }); + if (timelineIdentityRef.current.key !== sessionKey) { + timelineIdentityRef.current = { key: sessionKey }; + } const messagesRef = React.useRef(messages); const historyMetaRef = React.useRef(historyMeta); - const initializedSessionRef = React.useRef(null); + const initializedSessionKeyRef = React.useRef(null); const pendingRenderResolversRef = React.useRef void>>([]); const pendingScrollRequestRef = React.useRef(null); const scrollPinRef = React.useRef<{ turnId: string; expiresAt: number } | null>(null); @@ -298,7 +311,7 @@ export const useChatTimelineController = ({ }, []); React.useLayoutEffect(() => { - if (initializedSessionRef.current === sessionId) { + if (initializedSessionKeyRef.current === sessionKey) { return; } if (historyInteractionTimerRef.current !== null && typeof window !== 'undefined') { @@ -306,12 +319,17 @@ export const useChatTimelineController = ({ historyInteractionTimerRef.current = null; } historyInteractionRef.current = false; - initializedSessionRef.current = sessionId; + initializedSessionKeyRef.current = sessionKey; + const pendingScroll = pendingScrollRequestRef.current; + if (pendingScroll && pendingScroll.identity !== timelineIdentityRef.current) { + pendingScrollRequestRef.current = null; + pendingScroll.resolve(false); + } setIsLoadingOlder(false); setPendingRevealWork(false); scrollPinRef.current = null; setActiveTurnId(null); - }, [sessionId]); + }, [sessionKey]); React.useLayoutEffect(() => { if (!isPinned) { @@ -370,7 +388,7 @@ export const useChatTimelineController = ({ return; } - if (pending.sessionId !== sessionIdRef.current) { + if (pending.identity !== timelineIdentityRef.current) { resolvePendingScrollRequest(false); return; } @@ -426,10 +444,11 @@ export const useChatTimelineController = ({ // before triggering the state change. useLayoutEffect consumes it // after React commits new DOM — before the browser paints. const prePrependScrollRef = React.useRef<{ - sessionId: string | null; + identity: TimelineIdentityToken; height: number; top: number; anchor: ViewportAnchor | null; + historyVirtualized: boolean; oldestId: string | null; newestId: string | null; } | null>(null); @@ -457,7 +476,7 @@ export const useChatTimelineController = ({ React.useLayoutEffect(() => { prePrependScrollRef.current = null; prependTrackingRef.current = null; - }, [sessionId]); + }, [sessionKey]); React.useLayoutEffect(() => { const container = scrollRef.current; @@ -480,7 +499,7 @@ export const useChatTimelineController = ({ }) || hasInsertedBeforeKnownOldest(prev.oldestId, currentOldestId, renderedMessages) : false; - if (snap && snap.sessionId !== sessionIdRef.current) { + if (snap && snap.identity !== timelineIdentityRef.current) { prePrependScrollRef.current = null; snap = null; } @@ -544,18 +563,30 @@ export const useChatTimelineController = ({ return; } - // When the history list is virtualized, virtua runs with `shift` during - // history loads and compensates the prepend internally. Manual - // height-delta compensation on top of that applies the same delta twice - // and throws the viewport far downward. Anchor restore stays allowed — - // it corrects to an absolute element position, so it cannot double up. + // TanStack owns every scroll adjustment for virtualized history. It + // preserves stable keyed items across prepends and reconciles later row + // measurements. Restoring the DOM anchor here as well creates a second + // writer: depending on whether measurement has landed, it can apply the + // same prepend delta twice or fall back to scrollToIndex against the new + // indexes, throwing the viewport far downward. const historyVirtualized = messageListRef.current?.isHistoryVirtualized() ?? false; if (snap && shouldConsumeSnapshot) { prePrependScrollRef.current = null; + if (historyVirtualized) { + // The newly enabled virtualizer has no prior keyed state for the + // threshold-crossing commit, so allow one anchor restore. Once + // already virtualized, TanStack is the sole scroll owner. + if (!snap.historyVirtualized && snap.anchor) { + restoreViewportAnchor(snap.anchor); + } + updateTracking(); + return; + } + const heightDelta = container.scrollHeight - snap.height; const applyHeightDelta = (): boolean => { - if (historyVirtualized || heightDelta <= 0) { + if (heightDelta <= 0) { return false; } container.scrollTop = snap.top + heightDelta; @@ -563,38 +594,21 @@ export const useChatTimelineController = ({ }; // Non-virtualized mobile list only: fight iOS momentum manually. - // The virtualized mobile list (tanstack) defers prepend adjustments - // through touch/momentum in core, so manual writes would double up. - if (isMobileSurfaceRuntime() && !historyVirtualized && heightDelta > 0) { + if (isMobileSurfaceRuntime() && heightDelta > 0) { setScrollTopDefeatingMomentum(container, snap.top + heightDelta); updateTracking(); return; } - // When a viewport anchor is available, delegate to MessageList - // restoreViewportAnchor which falls back to virtualizer-aware - // scrollHistoryIndexIntoView when the element is not in the DOM. - // Note: an unchanged scrollTop after restore is NOT a failure here — - // the virtualized list compensates the prepend internally, so - // staying near snap.top is the correct outcome. + // The unvirtualized list has no internal prepend compensation. if (!(snap.anchor && restoreViewportAnchor(snap.anchor))) { - // Fallback: height-delta compensation applyHeightDelta(); } - if (historyVirtualized && snap.anchor && isMobileSurfaceRuntime()) { - // Mobile only: freshly prepended rows keep re-measuring for a - // few frames and each pass can shift content, so hold the - // anchor until it settles. Desktop must NOT run this — wheel - // scrolling during the hold would fight the re-assertions and - // read as a frozen scroll; the virtualizer's own anchoring is - // enough there. - messageListRef.current?.holdViewportAnchor(snap.anchor); - } } else if (isPrepend && prev && !historyVirtualized) { // Released viewport: preserve the read position by compensating for the // exact height the prepend added above, with no intermediate frame for - // auto-follow to fight. Virtualized lists skip this — virtua `shift` - // already compensated the prepend. + // auto-follow to fight. Virtualized lists skip this because TanStack + // already owns keyed prepend preservation. const delta = container.scrollHeight - prev.scrollHeight; if (delta > 0) { const target = container.scrollTop + delta; @@ -619,13 +633,24 @@ export const useChatTimelineController = ({ const fetchOlderHistory = React.useCallback(async (input: { preserveViewport: boolean; }): Promise => { - if (!sessionIdRef.current || isLoadingOlderRef.current) { + if (!sessionIdRef.current || !timelineIdentityRef.current.key || isLoadingOlderRef.current) { return false; } if (!historySignalsRef.current.hasMoreAboveTurns) { return false; } + const targetSessionId = sessionIdRef.current; + const targetIdentity = timelineIdentityRef.current; + if (!targetSessionId || !targetIdentity.key) { + return false; + } + const clearOwnedPrependSnapshot = () => { + if (prePrependScrollRef.current?.identity === targetIdentity) { + prePrependScrollRef.current = null; + } + }; + const container = scrollRef.current; const beforeMessages = messagesRef.current; const beforeMessageCount = beforeMessages.length; @@ -636,10 +661,11 @@ export const useChatTimelineController = ({ // compensate synchronously when React commits the new messages. if (input.preserveViewport && container) { prePrependScrollRef.current = { - sessionId: sessionIdRef.current, + identity: targetIdentity, height: container.scrollHeight, top: container.scrollTop, anchor: captureViewportAnchor(), + historyVirtualized: messageListRef.current?.isHistoryVirtualized() ?? false, oldestId: beforeOldestMessageId, newestId: beforeMessages[beforeMessages.length - 1]?.info?.id ?? null, }; @@ -649,12 +675,6 @@ export const useChatTimelineController = ({ setIsLoadingOlder(true); try { - const targetSessionId = sessionIdRef.current; - if (!targetSessionId) { - prePrependScrollRef.current = null; - return false; - } - let loadedMessageCount = beforeMessageCount; let loadedOldestMessageId = beforeOldestMessageId; let loadedLimit = beforeLimit; @@ -662,12 +682,16 @@ export const useChatTimelineController = ({ while (true) { await loadMoreMessages(targetSessionId, 'up'); - if (sessionIdRef.current !== targetSessionId) { - prePrependScrollRef.current = null; + if (timelineIdentityRef.current !== targetIdentity) { + clearOwnedPrependSnapshot(); return false; } await waitForNextRenderCommitOrTimeout(); + if (timelineIdentityRef.current !== targetIdentity) { + clearOwnedPrependSnapshot(); + return false; + } const afterMessages = messagesRef.current; const afterMessageCount = afterMessages.length; @@ -685,7 +709,7 @@ export const useChatTimelineController = ({ return true; } if (!messageGrowth) { - prePrependScrollRef.current = null; + clearOwnedPrependSnapshot(); return false; } if (!historySignalsRef.current.hasMoreAboveTurns) { @@ -697,15 +721,18 @@ export const useChatTimelineController = ({ loadedLimit = afterLimit; } } catch (error) { - prePrependScrollRef.current = null; + clearOwnedPrependSnapshot(); throw error; } finally { - setIsLoadingOlder(false); - settleHistoryInteraction(); + if (timelineIdentityRef.current === targetIdentity) { + setIsLoadingOlder(false); + settleHistoryInteraction(); + } } - }, [beginHistoryInteraction, captureViewportAnchor, loadMoreMessages, scrollRef, settleHistoryInteraction, waitForNextRenderCommitOrTimeout]); + }, [beginHistoryInteraction, captureViewportAnchor, loadMoreMessages, messageListRef, scrollRef, settleHistoryInteraction, waitForNextRenderCommitOrTimeout]); const loadEarlier = React.useCallback(async (options?: { userInitiated?: boolean }) => { + const targetIdentity = timelineIdentityRef.current; beginHistoryInteraction(); if (options?.userInitiated) { releaseAutoFollow(); @@ -714,7 +741,9 @@ export const useChatTimelineController = ({ try { void (await fetchOlderHistory({ preserveViewport: true })); } finally { - settleHistoryInteraction(); + if (timelineIdentityRef.current === targetIdentity) { + settleHistoryInteraction(); + } } }, [beginHistoryInteraction, fetchOlderHistory, releaseAutoFollow, settleHistoryInteraction]); @@ -775,7 +804,7 @@ export const useChatTimelineController = ({ loadEarlierIfPinnedViewportUnderfilled, pendingRevealWork, renderedMessages.length, - sessionId, + sessionKey, ]); React.useEffect(() => { @@ -813,21 +842,22 @@ export const useChatTimelineController = ({ } observer.disconnect(); }; - }, [loadEarlierIfPinnedViewportUnderfilled, scrollRef, sessionId]); + }, [loadEarlierIfPinnedViewportUnderfilled, scrollRef, sessionKey]); const scrollToTurn = React.useCallback(async ( turnId: string, options?: { behavior?: ScrollBehavior }, ): Promise => { - if (!turnId || !sessionIdRef.current) { + if (!turnId || !sessionIdRef.current || !timelineIdentityRef.current.key) { return false; } + const targetIdentity = timelineIdentityRef.current; releaseAutoFollow(); setPendingRevealWork(true); try { - if (sessionIdRef.current !== sessionId) { + if (timelineIdentityRef.current !== targetIdentity) { return false; } @@ -838,7 +868,7 @@ export const useChatTimelineController = ({ const result = await new Promise((resolve) => { pendingScrollRequestRef.current = { - sessionId: sessionIdRef.current ?? sessionId ?? '', + identity: targetIdentity, kind: 'turn', id: turnId, behavior: options?.behavior ?? 'auto', @@ -854,23 +884,26 @@ export const useChatTimelineController = ({ return false; } finally { - setPendingRevealWork(false); + if (timelineIdentityRef.current === targetIdentity) { + setPendingRevealWork(false); + } } - }, [attemptPendingScrollRequest, releaseAutoFollow, sessionId]); + }, [attemptPendingScrollRequest, releaseAutoFollow]); const scrollToMessage = React.useCallback(async ( messageId: string, options?: { behavior?: ScrollBehavior }, ): Promise => { - if (!messageId || !sessionIdRef.current) { + if (!messageId || !sessionIdRef.current || !timelineIdentityRef.current.key) { return false; } + const targetIdentity = timelineIdentityRef.current; releaseAutoFollow(); setPendingRevealWork(true); try { - if (sessionIdRef.current !== sessionId) { + if (timelineIdentityRef.current !== targetIdentity) { return false; } @@ -883,7 +916,7 @@ export const useChatTimelineController = ({ const result = await new Promise((resolve) => { pendingScrollRequestRef.current = { - sessionId: sessionIdRef.current ?? sessionId ?? '', + identity: targetIdentity, kind: 'message', id: messageId, behavior: options?.behavior ?? 'auto', @@ -899,9 +932,11 @@ export const useChatTimelineController = ({ return false; } finally { - setPendingRevealWork(false); + if (timelineIdentityRef.current === targetIdentity) { + setPendingRevealWork(false); + } } - }, [attemptPendingScrollRequest, releaseAutoFollow, sessionId]); + }, [attemptPendingScrollRequest, releaseAutoFollow]); const resumeToBottom = React.useCallback(async () => { setPendingRevealWork(false); diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index a504dde9..8a4c2fd4 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -96,6 +96,7 @@ import { buildSessionBootstrapDemands } from './sidebar/sessionBootstrapDemands' import { recordWorktreesSeen } from './sidebar/worktreeFirstSeen'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { streamPerfCount, streamPerfMark } from '@/stores/utils/streamDebug'; +import { runBackgroundNetworkTask } from '@/lib/background-network'; const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse'; const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder'; @@ -583,17 +584,18 @@ const SessionSidebarComponent: React.FC = ({ const projectPath = normalizePath(project.path); if (!projectPath) continue; try { - // Use store-cached isGitRepo when available; fall back to - // a direct check for projects the Git store hasn't seen yet. - // Forcing `ensureStatus` here also warms the store so the - // PR/render paths downstream can read isGitRepo for free. - const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo; - const isGitRepo = cachedIsGitRepo ?? await checkIsGitRepository(projectPath); - if (!isGitRepo) { + const worktrees = await runBackgroundNetworkTask(async () => { + // Use store-cached isGitRepo when available; fall back to + // a direct check for projects the Git store hasn't seen yet. + const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo; + const isGitRepo = cachedIsGitRepo ?? await checkIsGitRepository(projectPath); + if (!isGitRepo) return null; + return listProjectWorktrees({ id: project.id, path: projectPath }); + }); + if (worktrees === null) { worktreesByProject.delete(projectPath); continue; } - const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath }); if (cancelled) return; if (worktrees.length === 0) { worktreesByProject.delete(projectPath); diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts b/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts index 2d3cb253..f1feb9af 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts @@ -3,6 +3,7 @@ import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { mapWithConcurrency } from '@/lib/concurrency'; import { useGitStore } from '@/stores/useGitStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { runBackgroundNetworkTask } from '@/lib/background-network'; type Project = { id: string; path: string; normalizedPath: string }; const ROOT_BRANCH_TTL_MS = 5 * 60_000; @@ -36,7 +37,7 @@ export const useProjectRepoStatus = (args: Args): void => { // Trigger ensureStatus for each project to populate store normalizedProjects.forEach((project) => { - void ensureStatus(project.normalizedPath, git); + void runBackgroundNetworkTask(() => ensureStatus(project.normalizedPath, git)); }); }, [enabled, normalizedProjects, git, ensureStatus, setProjectRepoStatus]); @@ -129,9 +130,11 @@ export const useProjectRepoStatus = (args: Args): void => { const entries = await mapWithConcurrency(pending, 2, async (project) => { const inputBranch = gitRepoStatus.get(project.normalizedPath)?.branch?.trim() ?? ''; const inputKey = `${project.normalizedPath}\0${inputBranch}`; - const branch = await getRootBranch( - project.normalizedPath, - inputBranch ? { knownBranch: inputBranch } : undefined, + const branch = await runBackgroundNetworkTask(() => + getRootBranch( + project.normalizedPath, + inputBranch ? { knownBranch: inputBranch } : undefined, + ) ).catch(() => null); return { id: project.id, inputKey, branch }; }); diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index 194f6ede..7bfc13ca 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -20,6 +20,7 @@ export interface AnimationHandlers { interface UseChatAutoFollowOptions { currentSessionId: string | null; + currentSessionKey: string | null; sessionMessageCount: number; sessionIsWorking: boolean; isMobile: boolean; @@ -156,6 +157,7 @@ const nestedScrollableCanConsumeUp = (root: HTMLElement, target: EventTarget | n export const useChatAutoFollow = ({ currentSessionId, + currentSessionKey, sessionMessageCount, sessionIsWorking, isMobile, @@ -186,8 +188,10 @@ export const useChatAutoFollow = ({ sessionMessageCountRef.current = sessionMessageCount; const currentSessionIdRef = React.useRef(currentSessionId); currentSessionIdRef.current = currentSessionId; + const currentSessionKeyRef = React.useRef(currentSessionKey); + currentSessionKeyRef.current = currentSessionKey; - const lastSessionIdRef = React.useRef(null); + const lastSessionKeyRef = React.useRef(null); // Programmatic-scroll marker: the bottom position we last // wrote and when. A scroll event whose scrollTop matches `top` within a few @@ -468,14 +472,14 @@ export const useChatAutoFollow = ({ }, [flushSave]); const restoreSnapshot = React.useCallback(async (): Promise => { - const sessionId = currentSessionIdRef.current; - if (!sessionId) return false; + const sessionKey = currentSessionKeyRef.current; + if (!sessionKey) return false; const container = scrollRef.current; if (!container) { // ChatViewport not mounted yet (e.g., session still hydrating). // Record the request so the container-attach effect can replay it. - pendingInitialRestoreRef.current = sessionId; + pendingInitialRestoreRef.current = sessionKey; setStateValue('following'); return false; } @@ -496,18 +500,18 @@ export const useChatAutoFollow = ({ // ── session change ─────────────────────────────────────────────────────── React.useEffect(() => { - if (!currentSessionId || currentSessionId === lastSessionIdRef.current) { + if (!currentSessionId || !currentSessionKey || currentSessionKey === lastSessionKeyRef.current) { return; } - lastSessionIdRef.current = currentSessionId; + lastSessionKeyRef.current = currentSessionKey; MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId); flushSave(); autoRef.current = null; // Drop any pending restore request inherited from a different session. - if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current !== currentSessionId) { + if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current !== currentSessionKey) { pendingInitialRestoreRef.current = null; } - }, [currentSessionId, flushSave]); + }, [currentSessionId, currentSessionKey, flushSave]); // When work begins and we are still // following, pin to the bottom. When work stops, keep following alive for a @@ -547,10 +551,10 @@ export const useChatAutoFollow = ({ // preventing a visible flash of content at the wrong scroll position. React.useLayoutEffect(() => { if (!containerEl) return; - if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current === currentSessionId) { + if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current === currentSessionKey) { void restoreSnapshot(); } - }, [containerEl, currentSessionId, restoreSnapshot]); + }, [containerEl, currentSessionKey, restoreSnapshot]); // ── scroll event handling ──────────────────────────────────────────────── const handleScrollEvent = React.useCallback(() => { diff --git a/packages/ui/src/lib/background-network.test.ts b/packages/ui/src/lib/background-network.test.ts new file mode 100644 index 00000000..33b986b8 --- /dev/null +++ b/packages/ui/src/lib/background-network.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test" +import { getBackgroundNetworkState, runBackgroundNetworkTask } from "./background-network" + +const deferred = () => { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} + +describe("runBackgroundNetworkTask", () => { + test("caps concurrent tasks at the limit and drains waiters in order", async () => { + const { limit } = getBackgroundNetworkState() + const gates = Array.from({ length: limit + 2 }, () => deferred()) + const started: number[] = [] + const results = gates.map((gate, index) => runBackgroundNetworkTask(() => { + started.push(index) + return gate.promise + })) + + await Promise.resolve() + expect(started).toEqual(Array.from({ length: limit }, (_, index) => index)) + expect(getBackgroundNetworkState().active).toBe(limit) + expect(getBackgroundNetworkState().waiting).toBe(2) + + gates[0].resolve("a") + await results[0] + expect(started).toContain(limit) + + for (const [index, gate] of gates.entries()) gate.resolve(`v${index}`) + expect(await Promise.all(results)).toEqual(["a", ...gates.slice(1).map((_, index) => `v${index + 1}`)]) + expect(getBackgroundNetworkState().active).toBe(0) + expect(getBackgroundNetworkState().waiting).toBe(0) + }) + + test("releases the slot when a task rejects", async () => { + await expect(runBackgroundNetworkTask(() => Promise.reject(new Error("boom")))).rejects.toThrow("boom") + expect(getBackgroundNetworkState().active).toBe(0) + const value = await runBackgroundNetworkTask(() => Promise.resolve(42)) + expect(value).toBe(42) + }) +}) diff --git a/packages/ui/src/lib/background-network.ts b/packages/ui/src/lib/background-network.ts new file mode 100644 index 00000000..1aa56ffa --- /dev/null +++ b/packages/ui/src/lib/background-network.ts @@ -0,0 +1,61 @@ +/** + * Shared concurrency gate for background network traffic. + * + * The browser allows only ~6 concurrent HTTP/1.1 connections per origin, and + * every runtime (web, desktop loopback, VS Code, mobile host) funnels API + * traffic through one origin. During startup many subsystems fan out at once — + * per-directory session/status polls, git checks per project and worktree, + * command/skill discovery, global session pages — and several of those calls + * are slow while the OpenCode server is still warming up. Uncapped, they + * occupy the whole connection pool and interactive traffic (opening a session + * and fetching its messages) queues for seconds behind them. + * + * Every poll/prefetch-shaped background call should run through + * {@link runBackgroundNetworkTask} so the aggregate background footprint stays + * bounded and sockets remain free for the critical path. GitHub PR status has + * its own dedicated gate (see useGitHubPrStatusStore) because a single PR + * request can hold a socket for up to 12s and must not starve other + * background work either; the two caps combined still leave sockets free. + */ + +const BACKGROUND_NETWORK_CONCURRENCY = 3 + +let backgroundNetworkActive = 0 +const backgroundNetworkWaiters: Array<() => void> = [] + +const acquireBackgroundNetworkSlot = (): Promise => { + if (backgroundNetworkActive < BACKGROUND_NETWORK_CONCURRENCY) { + backgroundNetworkActive += 1 + return Promise.resolve() + } + return new Promise((resolve) => { + backgroundNetworkWaiters.push(resolve) + }) +} + +const releaseBackgroundNetworkSlot = (): void => { + const next = backgroundNetworkWaiters.shift() + if (next) { + // Hand the slot directly to the next waiter — keep the active count steady. + next() + return + } + backgroundNetworkActive = Math.max(0, backgroundNetworkActive - 1) +} + +/** Run one background network call under the shared concurrency gate. */ +export const runBackgroundNetworkTask = async (task: () => Promise): Promise => { + await acquireBackgroundNetworkSlot() + try { + return await task() + } finally { + releaseBackgroundNetworkSlot() + } +} + +/** Test-only visibility into the gate. */ +export const getBackgroundNetworkState = () => ({ + active: backgroundNetworkActive, + waiting: backgroundNetworkWaiters.length, + limit: BACKGROUND_NETWORK_CONCURRENCY, +}) diff --git a/packages/ui/src/lib/gitApiHttp.test.ts b/packages/ui/src/lib/gitApiHttp.test.ts index 2d0d113e..4a58391c 100644 --- a/packages/ui/src/lib/gitApiHttp.test.ts +++ b/packages/ui/src/lib/gitApiHttp.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test } from 'bun:test'; -import { getGitStatus, gitFetch, stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from './gitApiHttp'; +import { + getGitBranches, + getGitStatus, + gitFetch, + stageGitFile, + stageGitFiles, + unstageGitFile, + unstageGitFiles, +} from './gitApiHttp'; type FetchCall = { input: RequestInfo | URL; @@ -160,3 +168,18 @@ describe('gitApiHttp status cache', () => { } }); }); + +describe('gitApiHttp request priority', () => { + test('leaves low-level reads outside the background policy', async () => { + installWindowMock(); + const calls = installFetchMock(); + try { + await getGitBranches('/repo-interactive'); + + expect(calls).toHaveLength(1); + expect(calls[0].init?.priority).toBe(undefined); + } finally { + restoreMocks(); + } + }); +}); diff --git a/packages/ui/src/stores/globalSessions.ts b/packages/ui/src/stores/globalSessions.ts index 3ef34f01..4a6b3bc1 100644 --- a/packages/ui/src/stores/globalSessions.ts +++ b/packages/ui/src/stores/globalSessions.ts @@ -1,7 +1,7 @@ import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2"; +import { runBackgroundNetworkTask } from '@/lib/background-network'; import { retry } from "@/sync/retry"; import { stripSessionListDetails } from "@/sync/sanitize"; -import { getRuntimeKey } from "@/lib/runtime-switch"; import { startSessionLoadPerformanceEvent } from "@/sync/session-load-performance"; export type GlobalSessionRecord = Session & { @@ -103,11 +103,9 @@ export async function listGlobalSessionPages( let attempts = 0; const finishPerformanceEvent = startSessionLoadPerformanceEvent({ operation, - runtimeKey: getRuntimeKey(), - directory: options.directory, caller: cursor === undefined ? "initial-page" : "pagination", }); - const { response, payload } = await retry( + const { response, payload } = await runBackgroundNetworkTask(() => retry( async () => { attempts += 1; const response = await apiClient.experimental.session.list({ @@ -122,7 +120,7 @@ export async function listGlobalSessionPages( return { response, payload }; }, { attempts: 3, delay: 500, retryIf: () => true }, - ).catch((error) => { + )).catch((error) => { finishPerformanceEvent("error", { retryCount: Math.max(0, attempts - 1) }); throw error; }); diff --git a/packages/ui/src/stores/useCommandsStore.ts b/packages/ui/src/stores/useCommandsStore.ts index 9e1dc608..63640002 100644 --- a/packages/ui/src/stores/useCommandsStore.ts +++ b/packages/ui/src/stores/useCommandsStore.ts @@ -11,6 +11,7 @@ import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/ import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { useProjectsStore } from "@/stores/useProjectsStore"; import { runtimeFetch } from "@/lib/runtime-fetch"; +import { runBackgroundNetworkTask } from '@/lib/background-network'; export type CommandScope = 'user' | 'project'; @@ -169,10 +170,10 @@ export const useCommandsStore = create()( const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; // Ensure the list is scoped to the same directory we use for config source detection. - const commands = await opencodeClient.withDirectory( + const commands = await runBackgroundNetworkTask(() => opencodeClient.withDirectory( directory, () => opencodeClient.listCommandsWithDetails() - ); + )); const configurableCommands = commands.filter((cmd) => cmd.source !== 'skill'); const commandsWithScope = await Promise.all( diff --git a/packages/ui/src/stores/useGlobalSessionsStore-races.test.ts b/packages/ui/src/stores/useGlobalSessionsStore-races.test.ts index eb949319..02c2fc54 100644 --- a/packages/ui/src/stores/useGlobalSessionsStore-races.test.ts +++ b/packages/ui/src/stores/useGlobalSessionsStore-races.test.ts @@ -1,5 +1,8 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test" -import type { Session } from "@opencode-ai/sdk/v2" +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2" + +import { opencodeClient } from "@/lib/opencode/client" +import { useGlobalSessionsStore } from "./useGlobalSessionsStore" type Deferred = { promise: Promise @@ -20,16 +23,17 @@ const deferred = (): Deferred => { let activeRequest: Deferred let archivedRequest: Deferred -mock.module("@/lib/opencode/client", () => ({ - opencodeClient: { getSdkClient: () => ({}), getDirectory: () => "/source", setDirectory: () => undefined }, -})) -mock.module("@/stores/globalSessions", () => ({ - listGlobalSessionPages: (_sdk: unknown, options: { archived?: boolean }) => ( - options.archived ? archivedRequest.promise : activeRequest.promise - ), -})) - -const { useGlobalSessionsStore } = await import("./useGlobalSessionsStore") +const sdk = { + experimental: { + session: { + list: async (options: { archived?: boolean }) => ({ + data: await (options.archived ? archivedRequest.promise : activeRequest.promise), + response: { headers: new Headers() }, + }), + }, + }, +} as unknown as OpencodeClient +const originalGetSdkClient = opencodeClient.getSdkClient const session = (id: string, title = id, archived?: number): Session => ({ id, @@ -41,9 +45,14 @@ describe("global session mutation reconciliation", () => { beforeEach(() => { activeRequest = deferred() archivedRequest = deferred() + opencodeClient.getSdkClient = () => sdk useGlobalSessionsStore.getState().resetForRuntimeSwitch() }) + afterEach(() => { + opencodeClient.getSdkClient = originalGetSdkClient + }) + test("keeps a session created after a full load starts", async () => { const loading = useGlobalSessionsStore.getState().loadSessions() useGlobalSessionsStore.getState().upsertSession(session("created")) diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index f94c1f2a..2ffe69dd 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -9,6 +9,7 @@ import { } from "@/lib/configUpdate"; import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { runtimeFetch } from "@/lib/runtime-fetch"; +import { runBackgroundNetworkTask } from "@/lib/background-network"; import { opencodeClient } from '@/lib/opencode/client'; @@ -221,7 +222,7 @@ export const useSkillsStore = create()( try { const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; - const response = await runtimeFetch(`/api/config/skills${queryParams}`); + const response = await runBackgroundNetworkTask(() => runtimeFetch(`/api/config/skills${queryParams}`, { priority: 'low' })); if (!response.ok) { throw new Error(`Failed to list skills: ${response.status}`); } diff --git a/packages/ui/src/stores/useUpdateStore.ts b/packages/ui/src/stores/useUpdateStore.ts index 703caa39..cc7d5bf4 100644 --- a/packages/ui/src/stores/useUpdateStore.ts +++ b/packages/ui/src/stores/useUpdateStore.ts @@ -147,6 +147,8 @@ async function checkForWebUpdates(runtime: ClientRuntime, currentVersion?: strin const response = await runtimeFetch(`/api/openchamber/update-check?${params.toString()}`, { method: 'GET', headers: { Accept: 'application/json' }, + // Background check — keep sockets free for interactive traffic at startup. + priority: 'low', }); if (!response.ok) { diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index efa5934d..41425e96 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -128,6 +128,8 @@ Cross-directory selectors subscribe to the narrow child-store field they aggrega Session display order is independent from streaming-frequency `time.updated` publications. `session-ordering.ts` promotes a session exactly when its authoritative activity phase crosses `settled` (`idle`/`error`) and `active` (`busy`/`retry`) in either direction. Repeated busy/retry or idle/error events are no-ops. The first authoritative status snapshot establishes a baseline without synthetic promotions; later snapshots reconcile missed transitions. Root sessions compare lifecycle rank only with other roots, while child sessions compare lifecycle rank only with siblings sharing the same `parentID`, so child activity never moves its root conversation. Pins remain the first ordering bucket. The timestamp/creation fallback is frozen when a session first participates in ordering, so later metadata-only updates cannot reorder it; creation time and ID provide deterministic ties. Runtime switches clear all phases, baselines, and ranks. +The active-session watchdog in `sync-context.tsx` (per-directory status polls and child-session discovery lists) runs its network calls through the shared background-network gate in `@/lib/background-network`, alongside poll-shaped git reads, global session pages, and command/skill discovery. Background fan-out must stay under that gate so the browser's per-origin connection pool keeps free sockets for interactive traffic — an uncapped startup burst previously queued the first session-open message fetch for seconds. + Imperative cross-directory session lookups use the cached ID index from `getAllSyncSessionMap()`. The index is rebuilt only when a child store's `state.session` reference changes; permission lineage checks must reuse it instead of rebuilding a full session map per call. VS Code does not run the server permission-auto-accept runtime. The extension host persists and broadcasts authoritative policy, while its foreground UI runtime resolves missing child-session lineage through the OpenCode API before deciding whether to suppress and answer a `permission.asked` event. Enabling the policy and reconnect/bootstrap both reconcile pending requests in the session directory, including requests inherited by child sessions. Unknown lineage and exhausted reply retries fail closed and leave the request available for manual action. A later `permission.replied` event invalidates any older deferred ask so the async policy check cannot resurrect a resolved request. With every OpenChamber webview closed or suspended no responder runs; this is an intentional VS Code limitation. Other runtimes remain fully server-owned. @@ -164,15 +166,16 @@ Rules: 4. Async commits are generation-checked. Runtime switches, forced refreshes, eviction, and disposal must reject stale completion. 5. Prefetch coverage and persisted directory data are runtime-scoped. Legacy persisted directory entries may seed startup continuity, but they are not live truth. 6. Message and part materialization preserves references for unchanged records and maintains direct message-to-parts lookup. Consumers subscribe to the selected session's records rather than broad message/part containers. -7. The ref-stable loader is disposed only after the current task when its provider unmounts. This lets React Strict Mode's development setup → cleanup → setup probe retain a usable loader for child effects, while real disposal still invalidates the preceding lifecycle's work. +7. Pagination demand must carry the selected session's effective directory. It must not fall back to the sync provider directory because the visible session may belong to another worktree. +8. The ref-stable loader is disposed only after the current task when its provider unmounts. This lets React Strict Mode's development setup → cleanup → setup probe retain a usable loader for child effects, while real disposal still invalidates the preceding lifecycle's work. -Initial loads use smaller pages on constrained VS Code/mobile surfaces. Older pages are fetched through the same loader and merged with optimistic records before publication. +Initial loads use smaller pages on constrained VS Code/mobile surfaces. Prefetch resolves only the initial renderable page; it does not eagerly download older history. The mounted chat timeline requests older pages when its viewport is underfilled or the user scrolls toward history, while mobile uses its explicit load-older action. Timeline caches, pending work, prepend snapshots, and stale checks use runtime + directory + session identity so equal session IDs in different worktrees cannot share lifecycle state. Older pages are fetched through the same loader and merged with optimistic records before publication. ## Loading diagnostics Session loading instrumentation is disabled by default. Set `localStorage.openchamber_session_load_perf` to `"1"`, reproduce the interaction, then inspect `window.__openchamberSessionLoadPerformance.events`. -The bounded event buffer records bootstrap, message, and global-list operations with queue/duration, caller, outcome, retry count, and record count where applicable. Instrumentation is diagnostic only; unit/type/lint checks do not replace production runtime profiling at representative project/session scale. +The bounded event buffer records only controlled bootstrap, message, and global-list operation/caller labels with queue/duration, outcome, retry count, and downloaded record count where applicable. Message-page events also record the requested limit and whether a cursor was present. When diagnostics are enabled, the selected chat records its first painted renderable message snapshot once per recent session identity and immediately clears the corresponding browser performance entry after emitting the trace mark. Canceled frames retain no measured identity, so returning to that session can schedule a replacement measurement; completed identity tracking uses the same 1,000-entry ceiling as the event buffer. Exported events never retain runtime keys, directories, session IDs, credentials, or message content. Initial-message expansion counts every downloaded page, not only the accepted page. The browser profiler independently validates the known labels and finite numeric fields before export. Instrumentation is diagnostic only; unit/type/lint checks do not replace production runtime profiling at representative project/session scale. High-frequency sync diagnostics are separately disabled by default. Set `localStorage.openchamber_sync_perf` to `"1"` before reload to enable fixed numeric counters for pipeline traffic, reducer publications, streaming reconciliations, entries/messages visited, targeted heartbeat work, and persistence serialization/write volume. The hot path performs only a null check while disabled; counters never retain IDs, payloads, or user content. diff --git a/packages/ui/src/sync/child-store.ts b/packages/ui/src/sync/child-store.ts index 662edb88..cfd23584 100644 --- a/packages/ui/src/sync/child-store.ts +++ b/packages/ui/src/sync/child-store.ts @@ -525,7 +525,6 @@ export class ChildStoreManager { this.notifyBootstrapSubscribers() const finishPerformanceEvent = startSessionLoadPerformanceEvent({ operation: "bootstrap.directory", - directory: next.directory, caller: next.reason, queuedMs: Math.max(0, Date.now() - next.enqueuedAt), }) diff --git a/packages/ui/src/sync/session-load-performance.ts b/packages/ui/src/sync/session-load-performance.ts index dcd27479..d683bc8f 100644 --- a/packages/ui/src/sync/session-load-performance.ts +++ b/packages/ui/src/sync/session-load-performance.ts @@ -1,15 +1,51 @@ const STORAGE_KEY = "openchamber_session_load_perf" const MAX_EVENTS = 1_000 +const ALLOWED_OPERATIONS = new Set([ + "bootstrap.directory", + "bootstrap.sessions.all", + "bootstrap.sessions.archived", + "bootstrap.sessions.roots", + "global-sessions.active", + "global-sessions.archived", + "session-messages.initial", + "session-messages.older", + "session-messages.page", + "session-messages.refresh", + "session-messages.visible", + "session-prefetch", +]) +const ALLOWED_CALLERS = new Set([ + "action-demand", + "current-directory", + "initial", + "initial-page", + "known-project", + "known-worktree", + "older", + "pagination", + "prefetch", + "project-expanded", + "refresh", + "selected-session", + "server-connected", + "worktree-expanded", +]) +const ALLOWED_OUTCOMES = new Set([ + "complete", + "error", + "stale", + "deduplicated", + "canceled", +]) type SessionLoadPerformanceOutcome = "complete" | "error" | "stale" | "deduplicated" | "canceled" type SessionLoadPerformanceEvent = { operation: string - runtimeKey?: string - directory?: string - sessionID?: string caller?: string queuedMs?: number + requestLimit?: number + cursorPresent?: boolean durationMs: number outcome: SessionLoadPerformanceOutcome retryCount?: number @@ -27,7 +63,7 @@ declare global { } } -const enabled = (): boolean => { +const isSessionLoadPerformanceEnabled = (): boolean => { if (typeof window === "undefined") return false try { return window.localStorage.getItem(STORAGE_KEY) === "1" @@ -40,23 +76,100 @@ const now = (): number => typeof performance !== "undefined" && typeof performan ? performance.now() : Date.now() +const nonNegativeNumber = (value: unknown): number | undefined => ( + typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined +) +const nonNegativeInteger = (value: unknown): number | undefined => ( + Number.isInteger(value) && Number(value) >= 0 ? Number(value) : undefined +) + export function startSessionLoadPerformanceEvent(input: Omit) { - if (!enabled()) return () => undefined + if ( + !isSessionLoadPerformanceEnabled() + || !ALLOWED_OPERATIONS.has(input.operation) + || (input.caller !== undefined && !ALLOWED_CALLERS.has(input.caller)) + ) return () => undefined const startedAt = now() return ( outcome: SessionLoadPerformanceOutcome, details?: Partial>, ) => { - if (typeof window === "undefined") return + if (typeof window === "undefined" || !ALLOWED_OUTCOMES.has(outcome)) return const state = window.__openchamberSessionLoadPerformance ?? { events: [] } + const queuedMs = nonNegativeNumber(input.queuedMs) + const requestLimit = nonNegativeInteger(input.requestLimit) + const retryCount = nonNegativeInteger(details?.retryCount ?? input.retryCount) + const recordCount = nonNegativeInteger(details?.recordCount ?? input.recordCount) state.events.push({ - ...input, - ...details, + operation: input.operation, + ...(input.caller !== undefined ? { caller: input.caller } : {}), + ...(queuedMs !== undefined ? { queuedMs } : {}), + ...(requestLimit !== undefined ? { requestLimit } : {}), + ...(typeof input.cursorPresent === "boolean" ? { cursorPresent: input.cursorPresent } : {}), outcome, durationMs: Math.max(0, now() - startedAt), + ...(retryCount !== undefined ? { retryCount } : {}), + ...(recordCount !== undefined ? { recordCount } : {}), at: Date.now(), }) if (state.events.length > MAX_EVENTS) state.events.splice(0, state.events.length - MAX_EVENTS) window.__openchamberSessionLoadPerformance = state } } + +type FirstVisibleSessionPerformanceDependencies = { + enabled: () => boolean + requestFrame: (callback: FrameRequestCallback) => number + cancelFrame: (frame: number) => void + markVisible: () => void + startEvent: typeof startSessionLoadPerformanceEvent +} + +const FIRST_VISIBLE_MARK = "openchamber.chat.first_message_visible" + +export function createFirstVisibleSessionPerformanceTracker( + dependencies?: Partial, +) { + const enabled = dependencies?.enabled ?? isSessionLoadPerformanceEnabled + const requestFrame = dependencies?.requestFrame ?? ((callback) => window.requestAnimationFrame(callback)) + const cancelFrame = dependencies?.cancelFrame ?? ((frame) => window.cancelAnimationFrame(frame)) + const markVisible = dependencies?.markVisible ?? (() => { + performance.mark(FIRST_VISIBLE_MARK) + performance.clearMarks(FIRST_VISIBLE_MARK) + }) + const startEvent = dependencies?.startEvent ?? startSessionLoadPerformanceEvent + const measuredKeys = new Set() + let pending: { key: string; frame: number } | null = null + + return { + schedule(key: string, recordCount: number): () => void { + if (!enabled() || measuredKeys.has(key)) return () => undefined + if (pending) { + cancelFrame(pending.frame) + pending = null + } + const finishPerformanceEvent = startEvent({ + operation: "session-messages.visible", + caller: "selected-session", + recordCount, + }) + const frame = requestFrame(() => { + if (pending?.key !== key || pending.frame !== frame) return + pending = null + measuredKeys.add(key) + if (measuredKeys.size > MAX_EVENTS) { + measuredKeys.delete(measuredKeys.values().next().value!) + } + markVisible() + finishPerformanceEvent("complete") + }) + pending = { key, frame } + + return () => { + if (pending?.key !== key || pending.frame !== frame) return + cancelFrame(frame) + pending = null + } + }, + } +} diff --git a/packages/ui/src/sync/session-message-loader.test.ts b/packages/ui/src/sync/session-message-loader.test.ts index 62325e10..091a16a0 100644 --- a/packages/ui/src/sync/session-message-loader.test.ts +++ b/packages/ui/src/sync/session-message-loader.test.ts @@ -2,6 +2,10 @@ import { describe, expect, test } from "bun:test" import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client" import { ChildStoreManager } from "./child-store" import { SessionMessageLoader } from "./session-message-loader" +import { + createFirstVisibleSessionPerformanceTracker, + startSessionLoadPerformanceEvent, +} from "./session-load-performance" const createRecord = (sessionID: string, id = "msg_1") => ({ info: { id, sessionID, role: "user", time: { created: 1 } } as Message, @@ -56,6 +60,34 @@ describe("SessionMessageLoader", () => { childStores.disposeAll() }) + test("leaves older history loading to explicit viewport demand", async () => { + const calls: Array<{ limit?: number; before?: string }> = [] + const { childStores, loader } = createLoader(async ({ sessionID, limit, before }) => { + calls.push({ limit, before }) + return before + ? response([createRecord(sessionID, "msg_older")]) + : response([createRecord(sessionID, "msg_latest")], "older-cursor") + }) + const target = { directory: "/repo", sessionID: "session-a" } + + await loader.ensure(target, { reason: "prefetch" }) + await Promise.resolve() + + expect(calls).toEqual([{ limit: 50, before: undefined }]) + expect(loader.getSnapshot(target).cursor).toBe("older-cursor") + + await loader.loadOlder(target) + + expect(calls).toEqual([ + { limit: 50, before: undefined }, + { limit: 100, before: "older-cursor" }, + ]) + expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]?.map((message) => message.id)) + .toEqual(["msg_latest", "msg_older"].sort()) + loader.dispose() + childStores.disposeAll() + }) + test("runs a requested tail refresh after an older in-flight load", async () => { const initial = deferred>() const refresh = deferred>() @@ -128,6 +160,34 @@ describe("SessionMessageLoader", () => { childStores.disposeAll() }) + test("loads older history with the selected directory's cursor for duplicate session IDs", async () => { + const providerDirectory = "/repo/provider" + const selectedDirectory = "/repo/selected-worktree" + const sessionID = "shared" + const calls: Array<{ directory?: string; before?: string }> = [] + const { childStores, loader } = createLoader(async ({ directory, before }) => { + calls.push({ directory, before }) + return before + ? response([createRecord(sessionID, `older-${directory}`)]) + : response([createRecord(sessionID, `latest-${directory}`)], `${directory}-cursor`) + }) + + await Promise.all([ + loader.ensure({ directory: providerDirectory, sessionID }), + loader.ensure({ directory: selectedDirectory, sessionID }), + ]) + calls.length = 0 + + await loader.loadOlder({ directory: selectedDirectory, sessionID }) + + expect(calls).toEqual([{ + directory: selectedDirectory, + before: `${selectedDirectory}-cursor`, + }]) + loader.dispose() + childStores.disposeAll() + }) + test("exposes a retryable error without clearing an existing snapshot", async () => { let fail = true const { childStores, loader } = createLoader(async ({ sessionID }) => { @@ -209,4 +269,165 @@ describe("SessionMessageLoader", () => { loader.dispose() childStores.disposeAll() }) + + test("reports retries and every downloaded initial expansion record", async () => { + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window") + const diagnosticWindow = { + location: { search: "" }, + localStorage: { + getItem: (key: string) => key === "openchamber_session_load_perf" ? "1" : null, + }, + } as unknown as Window + Object.defineProperty(globalThis, "window", { configurable: true, value: diagnosticWindow }) + + const target = { directory: "/repo", sessionID: "session-a" } + let calls = 0 + const { childStores, loader } = createLoader(async () => { + calls += 1 + if (calls === 1) return {} + if (calls === 2) { + const assistant = createRecord(target.sessionID, "msg_assistant") + assistant.info = { ...assistant.info, role: "assistant" } as Message + return response([assistant], "older") + } + return response([createRecord(target.sessionID, "msg_user")]) + }) + + try { + await loader.ensure(target) + + const events = diagnosticWindow.__openchamberSessionLoadPerformance?.events ?? [] + const initialEvent = events.find((event) => event.operation === "session-messages.initial") + const pageEvents = events.filter((event) => event.operation === "session-messages.page") + expect(calls).toBe(3) + expect(pageEvents.map((event) => event.requestLimit)).toEqual([50, 100]) + expect(pageEvents.map((event) => event.cursorPresent)).toEqual([false, false]) + expect(pageEvents.map((event) => event.recordCount)).toEqual([1, 1]) + expect(initialEvent?.outcome).toBe("complete") + expect(initialEvent?.retryCount).toBe(1) + expect(initialEvent?.recordCount).toBe(2) + expect("runtimeKey" in initialEvent!).toBe(false) + expect("directory" in initialEvent!).toBe(false) + expect("sessionID" in initialEvent!).toBe(false) + } finally { + loader.dispose() + childStores.disposeAll() + if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow) + else Reflect.deleteProperty(globalThis, "window") + } + }) +}) + +describe("session load performance diagnostics", () => { + test("rejects unknown raw labels and preserves approved input counts", () => { + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window") + const diagnosticWindow = { + localStorage: { + getItem: (key: string) => key === "openchamber_session_load_perf" ? "1" : null, + }, + } as unknown as Window + Object.defineProperty(globalThis, "window", { configurable: true, value: diagnosticWindow }) + + try { + const finishUnknown = startSessionLoadPerformanceEvent({ + operation: "secret-operation", + caller: "secret-caller", + recordCount: 999, + }) + finishUnknown("complete") + const finishVisible = startSessionLoadPerformanceEvent({ + operation: "session-messages.visible", + caller: "selected-session", + recordCount: 30, + }) + finishVisible("complete") + + expect(diagnosticWindow.__openchamberSessionLoadPerformance?.events).toHaveLength(1) + const event = diagnosticWindow.__openchamberSessionLoadPerformance?.events[0] + expect(event?.operation).toBe("session-messages.visible") + expect(event?.caller).toBe("selected-session") + expect(event?.recordCount).toBe(30) + expect(JSON.stringify(diagnosticWindow.__openchamberSessionLoadPerformance)).not.toContain("secret") + } finally { + if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow) + else Reflect.deleteProperty(globalThis, "window") + } + }) + + test("does not schedule visibility work while diagnostics are disabled", () => { + let requestedFrames = 0 + let visibleMarks = 0 + const tracker = createFirstVisibleSessionPerformanceTracker({ + enabled: () => false, + requestFrame: () => { + requestedFrames += 1 + return 1 + }, + cancelFrame: () => undefined, + markVisible: () => { + visibleMarks += 1 + }, + }) + + tracker.schedule("session-a", 10) + + expect(requestedFrames).toBe(0) + expect(visibleMarks).toBe(0) + }) + + test("reschedules an identity when its pending visibility frame was canceled", () => { + let nextFrame = 0 + const frames = new Map() + const marks: string[] = [] + const tracker = createFirstVisibleSessionPerformanceTracker({ + enabled: () => true, + requestFrame: (callback) => { + nextFrame += 1 + frames.set(nextFrame, callback) + return nextFrame + }, + cancelFrame: (frame) => { + frames.delete(frame) + }, + markVisible: () => marks.push("visible"), + startEvent: () => () => undefined, + }) + + const cancelFirstA = tracker.schedule("session-a", 10) + cancelFirstA() + const cancelB = tracker.schedule("session-b", 10) + cancelB() + tracker.schedule("session-a", 10) + frames.get(3)?.(0) + + expect(marks).toEqual(["visible"]) + }) + + test("does not remeasure a completed identity after another session", () => { + let nextFrame = 0 + const frames = new Map() + const marks: string[] = [] + const tracker = createFirstVisibleSessionPerformanceTracker({ + enabled: () => true, + requestFrame: (callback) => { + nextFrame += 1 + frames.set(nextFrame, callback) + return nextFrame + }, + cancelFrame: (frame) => { + frames.delete(frame) + }, + markVisible: () => marks.push("visible"), + startEvent: () => () => undefined, + }) + + tracker.schedule("session-a", 10) + frames.get(1)?.(0) + tracker.schedule("session-b", 10) + frames.get(2)?.(0) + tracker.schedule("session-a", 10) + + expect(nextFrame).toBe(2) + expect(marks).toEqual(["visible", "visible"]) + }) }) diff --git a/packages/ui/src/sync/session-message-loader.ts b/packages/ui/src/sync/session-message-loader.ts index 644d9782..cc891833 100644 --- a/packages/ui/src/sync/session-message-loader.ts +++ b/packages/ui/src/sync/session-message-loader.ts @@ -61,6 +61,11 @@ type FetchedPage = { complete: boolean } +type LoadPerformanceDetails = { + retryCount: number + recordCount: number +} + type LoaderConfiguration = { sdk: OpencodeClient runtimeKey: string @@ -201,15 +206,8 @@ export class SessionMessageLoader { } if (options?.force) this.bumpGeneration(entry) const kind: SessionMessageLoadKind = options?.reason === "prefetch" ? "prefetch" : "initial" - return this.startLoad(normalized, entry, store, kind, async (isCurrent) => { - await this.loadInitial(normalized, entry, store, isCurrent) - if (!isMobileSurfaceRuntime() && isCurrent()) { - queueMicrotask(() => { - if (isCurrent() && entry.snapshot.cursor && !entry.snapshot.complete) { - void this.loadOlder(normalized) - } - }) - } + return this.startLoad(normalized, entry, store, kind, async (isCurrent, performance) => { + await this.loadInitial(normalized, entry, store, isCurrent, performance) }) } @@ -225,8 +223,8 @@ export class SessionMessageLoader { if (entry.snapshot.complete || !entry.snapshot.cursor) return Promise.resolve() const store = this.childStores.ensureChild(normalized.directory, { bootstrap: false }) const cursor = entry.snapshot.cursor - return this.startLoad(normalized, entry, store, "older", async (isCurrent) => { - const page = await this.fetchPage(normalized, HISTORY_MESSAGE_PAGE_SIZE, cursor) + return this.startLoad(normalized, entry, store, "older", async (isCurrent, performance) => { + const page = await this.fetchPage(normalized, HISTORY_MESSAGE_PAGE_SIZE, cursor, "older", performance) if (!isCurrent()) return const committed = this.commitPage(normalized, entry, store, page, "prepend", isCurrent) if (!committed || !isCurrent()) return @@ -279,11 +277,11 @@ export class SessionMessageLoader { } const store = this.childStores.ensureChild(normalized.directory, { bootstrap: false }) this.bumpGeneration(entry) - return this.startLoad(normalized, entry, store, "refresh", async (isCurrent) => { + return this.startLoad(normalized, entry, store, "refresh", async (isCurrent, performance) => { const previousCoverage = entry.snapshot.resolved ? { cursor: entry.snapshot.cursor, complete: entry.snapshot.complete } : null - const page = await this.fetchPage(normalized, Math.max(1, limit)) + const page = await this.fetchPage(normalized, Math.max(1, limit), undefined, "refresh", performance) if (!isCurrent()) return const committed = this.commitPage(normalized, entry, store, page, "merge", isCurrent) if (!committed || !isCurrent()) return @@ -455,15 +453,12 @@ export class SessionMessageLoader { entry: LoaderEntry, store: { getState: () => DirectoryStore; setState: DirectoryStoreSetter }, kind: SessionMessageLoadKind, - run: (isCurrent: () => boolean) => Promise, + run: (isCurrent: () => boolean, performance: LoadPerformanceDetails) => Promise, ): Promise { const generation = entry.snapshot.generation const sdkEpoch = this.sdkEpoch const finishPerformanceEvent = startSessionLoadPerformanceEvent({ operation: kind === "prefetch" ? "session-prefetch" : `session-messages.${kind}`, - runtimeKey: this.runtimeKey, - directory: target.directory, - sessionID: target.sessionID, caller: kind, }) const isCurrent = () => ( @@ -472,21 +467,22 @@ export class SessionMessageLoader { && entry.snapshot.generation === generation && this.childStores.getChild(target.directory) === store ) + const performance = { retryCount: 0, recordCount: 0 } this.patchEntry(entry, { status: "loading", loadingKind: kind, error: null }) let loadPromise: Promise try { - loadPromise = run(isCurrent) + loadPromise = run(isCurrent, performance) } catch (error) { loadPromise = Promise.reject(error) } const promise = loadPromise - .then(() => finishPerformanceEvent(isCurrent() ? "complete" : "stale")) + .then(() => finishPerformanceEvent(isCurrent() ? "complete" : "stale", performance)) .catch((error: unknown) => { if (!isCurrent()) { - finishPerformanceEvent("stale") + finishPerformanceEvent("stale", performance) return } - finishPerformanceEvent("error") + finishPerformanceEvent("error", performance) this.patchEntry(entry, { status: "error", loadingKind: null, @@ -505,10 +501,11 @@ export class SessionMessageLoader { entry: LoaderEntry, store: { getState: () => DirectoryStore; setState: DirectoryStoreSetter }, isCurrent: () => boolean, + performance?: LoadPerformanceDetails, ): Promise { const storeMessageCount = store.getState().message[target.sessionID]?.length ?? 0 const firstLimit = Math.max(entry.snapshot.limit, storeMessageCount, getInitialPageSize()) - const firstPage = await this.fetchPage(target, firstLimit) + const firstPage = await this.fetchPage(target, firstLimit, undefined, "initial-page", performance) if (!isCurrent()) return const deferFirstCommit = !firstPage.complete && !hasUserMessage(firstPage.session) let committed = deferFirstCommit @@ -519,7 +516,7 @@ export class SessionMessageLoader { if (deferFirstCommit) { for (const limit of getInitialExpansionLimits()) { if (limit <= firstLimit || !isCurrent()) continue - const expandedPage = await this.fetchPage(target, limit) + const expandedPage = await this.fetchPage(target, limit, undefined, "initial-page", performance) if (!isCurrent()) return acceptedPage = expandedPage const boundaryFound = hasUserMessage(expandedPage.session) @@ -547,32 +544,58 @@ export class SessionMessageLoader { this.persistCoverage(target, entry.snapshot) } - private async fetchPage(target: SessionMessageTarget, limit: number, before?: string): Promise { - const result = await retry(async () => { - const response = await this.sdk.session.messages({ - sessionID: target.sessionID, - directory: target.directory, - limit, - before, - }) - assertSdkSuccess(response, "session.messages") - if (!Array.isArray(response.data)) { - const error = new Error("session.messages returned no data") as Error & { status?: number } - error.status = 503 - throw error - } - return { data: response.data, response: response.response } + private async fetchPage( + target: SessionMessageTarget, + limit: number, + before?: string, + caller: "initial-page" | "older" | "refresh" = "initial-page", + performance?: LoadPerformanceDetails, + ): Promise { + const finishPagePerformance = startSessionLoadPerformanceEvent({ + operation: "session-messages.page", + caller, + requestLimit: limit, + cursorPresent: before !== undefined, }) - const records = result.data.filter((record: { info?: { id?: string } }) => Boolean(record?.info?.id)) - const session = records - .map((record: { info: Message }) => stripMessageDiffSnapshots(record.info)) - .sort((left: Message, right: Message) => cmp(left.id, right.id)) - const partsByMessageID = new Map() - for (const record of records as Array<{ info: { id: string }; parts?: Part[] }>) { - partsByMessageID.set(record.info.id, sortParts(record.parts ?? [])) + let attempts = 0 + let recordCount = 0 + try { + const result = await retry(async () => { + attempts += 1 + const response = await this.sdk.session.messages({ + sessionID: target.sessionID, + directory: target.directory, + limit, + before, + }) + assertSdkSuccess(response, "session.messages") + const data = response.data + if (!Array.isArray(data)) { + const error = new Error("session.messages returned no data") as Error & { status?: number } + error.status = 503 + throw error + } + return { data, response: response.response } + }) + const records = result.data.filter((record: { info?: { id?: string } }) => Boolean(record?.info?.id)) + recordCount = records.length + if (performance) performance.recordCount += recordCount + const session = records + .map((record: { info: Message }) => stripMessageDiffSnapshots(record.info)) + .sort((left: Message, right: Message) => cmp(left.id, right.id)) + const partsByMessageID = new Map() + for (const record of records as Array<{ info: { id: string }; parts?: Part[] }>) { + partsByMessageID.set(record.info.id, sortParts(record.parts ?? [])) + } + const cursor = result.response?.headers?.get?.("x-next-cursor") ?? undefined + finishPagePerformance("complete", { retryCount: Math.max(0, attempts - 1), recordCount }) + return { session, partsByMessageID, cursor, complete: !cursor } + } catch (error) { + finishPagePerformance("error", { retryCount: Math.max(0, attempts - 1), recordCount }) + throw error + } finally { + if (performance) performance.retryCount += Math.max(0, attempts - 1) } - const cursor = result.response?.headers?.get?.("x-next-cursor") ?? undefined - return { session, partsByMessageID, cursor, complete: !cursor } } private commitPage( diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 465c64ae..a413c105 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -31,6 +31,7 @@ import { bootstrapGlobal, bootstrapDirectory } from "./bootstrap" import { retry } from "./retry" import { touchStreamingSession, updateChangedStreamingSessions, updateStreamingState } from "./streaming" import { countSyncPerformance } from "./performance-diagnostics" +import { runBackgroundNetworkTask } from "@/lib/background-network" import { setActionRefs } from "./session-actions" import { setSyncRefs, getAllSyncSessions } from "./sync-refs" import { stripSessionDiffSnapshots } from "./sanitize" @@ -242,6 +243,16 @@ const ACTIVE_SESSION_STATUS_POLL_INTERVAL_MS = 5_000 const ACTIVE_SESSION_STALE_EVENT_MS = 20_000 const ACTIVE_SESSION_FULL_RESYNC_COOLDOWN_MS = 15_000 const CHILD_SESSION_DISCOVERY_INTERVAL_MS = 15_000 + +// Active-session watchdog network calls run under the shared +// background-network gate (see lib/background-network.ts). The watchdog walks +// every initialized child store each tick and fires a status poll plus a +// child-session discovery list per directory with active candidates — on +// startup with many cache-hydrated directories that is dozens of simultaneous +// requests, which would otherwise queue interactive traffic (opening a +// session) behind them on the browser's ~6 sockets per origin. Later ticks +// still cover every directory via the per-directory timestamps. + const requestSignature = (items: Array<{ id: string }> | undefined): string => { if (!items || items.length === 0) return "" return items @@ -2072,7 +2083,7 @@ export function SyncProvider(props: { if (parentSessionIds.length === 0) return try { const scopedClient = opencodeClient.getScopedSdkClient(directory) - const result = await scopedClient.session.list({ directory, limit: 200 }) + const result: unknown = await runBackgroundNetworkTask(() => scopedClient.session.list({ directory, limit: 200 })) const allSessions = ((result as { data?: unknown }).data ?? []) as Session[] const state = store.getState() const existingIds = new Set(state.session.map((s) => s.id)) @@ -2121,7 +2132,7 @@ export function SyncProvider(props: { polling.add(directory) try { const before = store.getState() - const statuses = await resyncDirectorySessionStatuses(directory, store, candidateSessionIds, "monotonic") + const statuses = await runBackgroundNetworkTask(() => resyncDirectorySessionStatuses(directory, store, candidateSessionIds, "monotonic")) if (!statuses) return const needsSnapshot = candidateSessionIds.some((sessionId) => ( needsSnapshotAfterStatusPoll(before, sessionId, statuses[sessionId]) diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index dd7ee58a..5e43751c 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -313,12 +313,11 @@ export function useSync() { // Load more (pagination) const loadMore = useCallback( - async (sessionID: string, directoryOverride?: string) => { - const targetDirectory = directoryOverride || directory + async (sessionID: string, targetDirectory: string) => { touch(sessionID, targetDirectory) await messageLoader.loadOlder({ directory: targetDirectory, sessionID }) }, - [directory, messageLoader, touch], + [messageLoader, touch], ) const prefetchSession = useCallback( diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 30c87a40..1c386b3c 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1059,6 +1059,26 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({ buildManagedOpenCodePath, getManagedOpenCodeShellEnvSnapshot: getLoginShellEnvSnapshot, getActiveSessionCount, + // Most-recently-used directories first: OpenCode initializes each directory + // lazily on first request (seconds on large session stores), so the + // lifecycle warms these right after readiness — before the UI's first + // interactive request would otherwise pay that cost. + getWarmupDirectories: async () => { + const settings = await readSettingsFromDiskMigrated().catch(() => null); + if (!settings) return []; + const directories = []; + if (typeof settings.lastDirectory === 'string' && settings.lastDirectory) { + directories.push(settings.lastDirectory); + } + const projects = Array.isArray(settings.projects) ? [...settings.projects] : []; + projects.sort((a, b) => (b?.lastOpenedAt ?? 0) - (a?.lastOpenedAt ?? 0)); + for (const project of projects) { + if (typeof project?.path === 'string' && project.path) { + directories.push(project.path); + } + } + return [...new Set(directories)]; + }, getManagedOpenCodeEnv: async () => { const settings = await readSettingsFromDiskMigrated().catch(() => null); const managedEnv = settings?.agentControlToolEnabled === false @@ -1397,7 +1417,9 @@ async function main(options = {}) { console.log(`Starting OpenChamber on port ${port === 0 ? 'auto' : port}`); - const sayTTSCapability = await detectSayTtsCapability(process); + // Voice enumeration is independent from route registration. Start it now, + // but do not hold server listen or managed OpenCode startup on `say -v "?"`. + const sayTTSCapability = detectSayTtsCapability(process); const app = express(); const serverStartedAt = new Date().toISOString(); diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 8773e001..c57ecb8b 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -10,7 +10,7 @@ This module provides OpenCode server integration utilities for the web server ru - `packages/web/server/lib/opencode/cli-options.js`: CLI/environment option parsing for server startup arguments. - `packages/web/server/lib/opencode/cli-entry-runtime.js`: CLI entrypoint runtime that detects direct execution, parses CLI options, and starts server bootstrap. - `packages/web/server/lib/opencode/routes.js`: OpenCode/provider settings and auth-related route registration. -- `packages/web/server/lib/opencode/lifecycle.js`: OpenCode process lifecycle runtime (startup, restart, readiness, health monitoring). +- `packages/web/server/lib/opencode/lifecycle.js`: OpenCode process lifecycle runtime (startup, restart, readiness, health monitoring). After readiness it warms the most recently used directories (`getWarmupDirectories` dep, sequential and best-effort) because OpenCode initializes each directory lazily on first request and that cost would otherwise be paid by the user's first interactive session open. - `packages/web/server/lib/opencode/env-runtime.js`: OpenCode CLI/binary resolution and shell environment runtime. - `packages/web/server/lib/opencode/env-config.js`: OpenCode-related environment variable parsing and validation (host/port/hostname). - `packages/web/server/lib/opencode/hmr-state-runtime.js`: HMR-persistent runtime state initialization, auth-state bootstrap, and HMR sync helpers. @@ -29,6 +29,7 @@ This module provides OpenCode server integration utilities for the web server ru - `packages/web/server/lib/opencode/upgrade-capability.js`: authoritative upgrade ownership policy for the active OpenCode runtime. Bundled, external, and unresolved runtimes fail closed; only managed non-bundled runtimes delegate upgrades to OpenCode. - `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: tunnel service/routes composition runtime and active-port wiring for main server startup. - `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: server startup tail orchestration runtime for terminal/proxy/static/start-listen flow. +- `packages/web/server/lib/opencode/startup-performance.js`: opt-in startup phase diagnostics with fixed labels and numeric metadata allowlists. - `packages/web/server/lib/agent-tool/runtime.js`: managed OpenCode custom-tool materialization, environment injection, loopback authentication, and fixed CLI action dispatch. - `packages/web/server/lib/system-prompt/runtime.js`: opt-in managed OpenCode system-prompt optimizer materialization and plugin injection. - `packages/web/server/lib/opencode/server-utils-runtime.js`: shared server runtime utilities for OpenCode proxy wiring, OpenCode port/readiness helpers, and snapshot fetchers. @@ -121,6 +122,10 @@ runtime. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot be replaced by injected values. External OpenCode processes receive no OpenChamber tool injection. +Set `OPENCHAMBER_STARTUP_PERF=1` to emit bounded startup phase records for server listen, managed OpenCode preparation/readiness, and proxy readiness holds. Every OpenCode bootstrap emits one terminal `opencode.bootstrap.ready` or `opencode.bootstrap.error` event, including reused and external server paths. Records contain controlled phase/outcome/route labels and timing values only; they never contain request URLs, runtime keys, directories, session IDs, credentials, or content. + +macOS `say` voice enumeration starts concurrently with server composition. The server listener and managed OpenCode startup do not wait for it; `/api/tts/say/status` awaits the same authoritative capability promise when queried before enumeration completes. + Transport-triggered health checks share the periodic monitor's failure accounting interval. Rapid WS reconnect callbacks therefore cannot exhaust the managed-process restart threshold using one cached unhealthy result; an exited managed process still restarts immediately. ## Public exports (env-runtime.js) diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index 48d11e0f..046a710e 100644 --- a/packages/web/server/lib/opencode/lifecycle.js +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -1,6 +1,7 @@ import { spawn, spawnSync } from 'node:child_process'; import net from 'node:net'; import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.js'; +import { recordStartupPerformance } from './startup-performance.js'; const parsePositiveInt = (value, fallback) => { const parsed = Number.parseInt(String(value ?? ''), 10); @@ -15,6 +16,10 @@ const HEALTH_CHECK_MAX_CONSECUTIVE_FAILURES = parsePositiveInt( const HEALTH_CHECK_INTERVAL_OVERRIDE_MS = parsePositiveInt(process.env.OPENCHAMBER_OPENCODE_HEALTH_INTERVAL_MS, 0); const HEALTH_CHECK_RESULT_CACHE_MS = parsePositiveInt(process.env.OPENCHAMBER_OPENCODE_HEALTH_CACHE_MS, 750); const OPENCODE_HEALTH_PATH = '/global/health'; +// Last-used directory plus the three most recently opened projects — deeper +// tails are unlikely to be the user's first click and just add background work. +const WARMUP_DIRECTORY_LIMIT = 4; +const WARMUP_REQUEST_TIMEOUT_MS = 30000; export const createOpenCodeLifecycleRuntime = (deps) => { const { @@ -40,6 +45,8 @@ export const createOpenCodeLifecycleRuntime = (deps) => { getManagedOpenCodeShellEnvSnapshot, getManagedOpenCodeEnv = async () => ({}), getActiveSessionCount = () => 0, + reapManagedOrphanedProcesses = reapOrphanedProcesses, + getWarmupDirectories = async () => [], now = Date.now, } = deps; @@ -466,7 +473,10 @@ export const createOpenCodeLifecycleRuntime = (deps) => { const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - const startOpenCodeOnce = async () => { + const startOpenCodeOnce = async (attempt) => { + const attemptStartedAt = performance.now(); + let phaseStartedAt = attemptStartedAt; + recordStartupPerformance('opencode.attempt.start', { attempt }); const desiredPort = env.ENV_CONFIGURED_OPENCODE_PORT ?? 0; const spawnPort = await resolveManagedOpenCodePort(desiredPort, env.ENV_CONFIGURED_OPENCODE_HOSTNAME); console.log( @@ -477,6 +487,12 @@ export const createOpenCodeLifecycleRuntime = (deps) => { await applyOpencodeBinaryFromSettings({ strict: true }); ensureOpencodeCliEnv(); + recordStartupPerformance('opencode.binary.ready', { + attempt, + durationMs: performance.now() - phaseStartedAt, + totalDurationMs: performance.now() - attemptStartedAt, + }); + phaseStartedAt = performance.now(); const openCodePassword = await ensureLocalOpenCodeServerPassword({ rotateManaged: true }); let envPath = process.env.PATH; if (typeof buildManagedOpenCodePath === 'function') { @@ -488,6 +504,12 @@ export const createOpenCodeLifecycleRuntime = (deps) => { ? getManagedOpenCodeShellEnvSnapshot() || {} : {}; const managedOpenCodeEnv = await getManagedOpenCodeEnv(); + recordStartupPerformance('opencode.environment.ready', { + attempt, + durationMs: performance.now() - phaseStartedAt, + totalDurationMs: performance.now() - attemptStartedAt, + }); + phaseStartedAt = performance.now(); try { const serverInstance = await createManagedOpenCodeServerProcess({ @@ -508,6 +530,12 @@ export const createOpenCodeLifecycleRuntime = (deps) => { if (!serverInstance || !serverInstance.url) { throw new Error('OpenCode server started but URL is missing'); } + recordStartupPerformance('opencode.process.ready', { + attempt, + durationMs: performance.now() - phaseStartedAt, + totalDurationMs: performance.now() - attemptStartedAt, + }); + phaseStartedAt = performance.now(); const url = new URL(serverInstance.url); const port = parseInt(url.port, 10); @@ -521,6 +549,13 @@ export const createOpenCodeLifecycleRuntime = (deps) => { state.lastOpenCodeError = null; state.openCodeNotReadySince = 0; + recordStartupPerformance('opencode.health.ready', { + attempt, + durationMs: performance.now() - phaseStartedAt, + totalDurationMs: performance.now() - attemptStartedAt, + outcome: 'ready', + }); + return serverInstance; } @@ -534,6 +569,11 @@ export const createOpenCodeLifecycleRuntime = (deps) => { state.lastOpenCodeError = message; state.openCodePort = null; syncToHmrState(); + recordStartupPerformance('opencode.attempt.error', { + attempt, + totalDurationMs: performance.now() - attemptStartedAt, + outcome: 'error', + }); console.error(`Failed to start OpenCode: ${message}`); throw error; } @@ -543,7 +583,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => { let lastError = null; for (let attempt = 1; attempt <= START_OPEN_CODE_MAX_ATTEMPTS; attempt += 1) { try { - return await startOpenCodeOnce(); + return await startOpenCodeOnce(attempt); } catch (error) { lastError = error; if (error?.code === 'OPENCODE_BINARY_INVALID') { @@ -792,12 +832,20 @@ export const createOpenCodeLifecycleRuntime = (deps) => { }; const bootstrapOpenCodeAtStartup = async () => { + const bootstrapStartedAt = performance.now(); + let bootstrapError = null; + recordStartupPerformance('opencode.bootstrap.start'); try { // Before doing anything, reap any OpenCode process WE spawned in a prior // run that was orphaned by a crash/hard-exit. Verified + scoped to our own // pids, so it never touches a live instance's or the user's own server. try { - const { reaped } = await reapOrphanedProcesses({ log: (msg) => console.log(msg) }); + const orphanReapStartedAt = performance.now(); + const { reaped } = await reapManagedOrphanedProcesses({ log: (msg) => console.log(msg) }); + recordStartupPerformance('opencode.orphan-reap.ready', { + durationMs: performance.now() - orphanReapStartedAt, + totalDurationMs: performance.now() - bootstrapStartedAt, + }); if (reaped > 0) console.log(`[lifecycle] startup reaped ${reaped} orphaned OpenCode process(es)`); } catch (error) { console.warn('[lifecycle] orphan reap failed:', error?.message ?? error); @@ -851,13 +899,64 @@ export const createOpenCodeLifecycleRuntime = (deps) => { try { await waitForOpenCodeReady(); } catch (error) { + bootstrapError = error; console.error(`OpenCode readiness check failed: ${error.message}`); } } catch (error) { + bootstrapError = error; console.error(`Failed to start OpenCode: ${error.message}`); console.log('Continuing without OpenCode integration...'); state.lastOpenCodeError = error.message; } + recordStartupPerformance( + bootstrapError ? 'opencode.bootstrap.error' : 'opencode.bootstrap.ready', + { + totalDurationMs: performance.now() - bootstrapStartedAt, + outcome: bootstrapError ? 'error' : 'ready', + }, + ); + if (!bootstrapError) { + void warmOpenCodeDirectories(); + } + }; + + // OpenCode initializes each project directory lazily on its first + // directory-scoped request, and that initialization takes seconds on large + // session stores. Without warming, the user's first session open pays it + // interactively (the chat waits on the message fetch until the directory + // finishes initializing). Warm the most recently used directories right + // after readiness so the work overlaps UI startup instead. Sequential and + // best-effort: a failed or slow directory never blocks the others for long, + // and a restart invalidates the pass via the port/readiness guard. + const warmOpenCodeDirectories = async () => { + let directories = []; + try { + directories = await getWarmupDirectories(); + } catch { + return; + } + if (!Array.isArray(directories) || directories.length === 0) return; + + const warmedPort = state.openCodePort; + for (const directory of directories.slice(0, WARMUP_DIRECTORY_LIMIT)) { + if (typeof directory !== 'string' || !directory) continue; + if (!state.isOpenCodeReady || state.openCodePort !== warmedPort) return; + let timeout = null; + try { + const controller = new AbortController(); + timeout = setTimeout(() => controller.abort(), WARMUP_REQUEST_TIMEOUT_MS); + const url = `${buildOpenCodeUrl('/session/status', '')}?directory=${encodeURIComponent(directory)}`; + await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, + signal: controller.signal, + }); + } catch { + // Best-effort — the directory stays lazy and the UI's own request warms it. + } finally { + if (timeout) clearTimeout(timeout); + } + } }; /** diff --git a/packages/web/server/lib/opencode/lifecycle.test.js b/packages/web/server/lib/opencode/lifecycle.test.js index 6436f6e9..aadb5483 100644 --- a/packages/web/server/lib/opencode/lifecycle.test.js +++ b/packages/web/server/lib/opencode/lifecycle.test.js @@ -2,11 +2,15 @@ import { EventEmitter } from 'node:events'; import { afterEach, describe, expect, it, vi } from 'vitest'; const spawnMock = vi.fn(); +const recordStartupPerformanceMock = vi.fn(); vi.mock('node:child_process', () => ({ spawn: spawnMock, spawnSync: vi.fn(), })); +vi.mock('./startup-performance.js', () => ({ + recordStartupPerformance: recordStartupPerformanceMock, +})); const { createOpenCodeLifecycleRuntime } = await import('./lifecycle.js'); @@ -16,6 +20,7 @@ const originalFetch = globalThis.fetch; afterEach(() => { spawnMock.mockReset(); + recordStartupPerformanceMock.mockReset(); globalThis.fetch = originalFetch; if (typeof originalOpencodeBinary === 'string') { process.env.OPENCODE_BINARY = originalOpencodeBinary; @@ -108,6 +113,92 @@ const createRuntime = (overrides = {}, stateOverrides = {}) => { }; describe('OpenCode lifecycle', () => { + it('records an authoritative ready terminal event for external startup', async () => { + globalThis.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ healthy: true }), + })); + const runtime = createRuntime({ + env: { + ENV_CONFIGURED_OPENCODE_PORT: 45678, + ENV_CONFIGURED_OPENCODE_HOST: null, + ENV_EFFECTIVE_PORT: 45678, + ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1', + ENV_SKIP_OPENCODE_START: true, + }, + reapManagedOrphanedProcesses: vi.fn(async () => ({ reaped: 0 })), + }); + + await runtime.bootstrapOpenCodeAtStartup(); + + expect(recordStartupPerformanceMock).toHaveBeenCalledWith('opencode.bootstrap.ready', { + totalDurationMs: expect.any(Number), + outcome: 'ready', + }); + expect(recordStartupPerformanceMock).not.toHaveBeenCalledWith( + 'opencode.bootstrap.error', + expect.anything(), + ); + const terminalEvents = recordStartupPerformanceMock.mock.calls.filter(([phase]) => ( + phase === 'opencode.bootstrap.ready' || phase === 'opencode.bootstrap.error' + )); + expect(terminalEvents).toHaveLength(1); + }); + + it('warms recently used directories after a successful bootstrap', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ healthy: true }), + })); + globalThis.fetch = fetchMock; + const runtime = createRuntime({ + env: { + ENV_CONFIGURED_OPENCODE_PORT: 45678, + ENV_CONFIGURED_OPENCODE_HOST: null, + ENV_EFFECTIVE_PORT: 45678, + ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1', + ENV_SKIP_OPENCODE_START: true, + }, + reapManagedOrphanedProcesses: vi.fn(async () => ({ reaped: 0 })), + getWarmupDirectories: vi.fn(async () => ['/tmp/worktree-a', '/tmp/project-b']), + }); + + await runtime.bootstrapOpenCodeAtStartup(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const warmupUrls = fetchMock.mock.calls + .map(([url]) => String(url)) + .filter((url) => url.includes('/session/status')); + expect(warmupUrls).toEqual([ + 'http://127.0.0.1:45678/session/status?directory=%2Ftmp%2Fworktree-a', + 'http://127.0.0.1:45678/session/status?directory=%2Ftmp%2Fproject-b', + ]); + }); + + it('records an authoritative error terminal event when bootstrap fails', async () => { + const runtime = createRuntime({ + syncFromHmrState: vi.fn(() => { + throw new Error('bootstrap failed'); + }), + reapManagedOrphanedProcesses: vi.fn(async () => ({ reaped: 0 })), + }); + + await runtime.bootstrapOpenCodeAtStartup(); + + expect(recordStartupPerformanceMock).toHaveBeenCalledWith('opencode.bootstrap.error', { + totalDurationMs: expect.any(Number), + outcome: 'error', + }); + expect(recordStartupPerformanceMock).not.toHaveBeenCalledWith( + 'opencode.bootstrap.ready', + expect.anything(), + ); + const terminalEvents = recordStartupPerformanceMock.mock.calls.filter(([phase]) => ( + phase === 'opencode.bootstrap.ready' || phase === 'opencode.bootstrap.error' + )); + expect(terminalEvents).toHaveLength(1); + }); + it('does not count rapid transport-triggered checks as independent health failures', async () => { const close = vi.fn(async () => {}); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/packages/web/server/lib/opencode/proxy.js b/packages/web/server/lib/opencode/proxy.js index 6662f7bd..04fecf54 100644 --- a/packages/web/server/lib/opencode/proxy.js +++ b/packages/web/server/lib/opencode/proxy.js @@ -7,6 +7,7 @@ import { } from '../../proxy-headers.js'; import { createRealpathCache } from '../path-realpath-cache.js'; import { DEFAULT_UPSTREAM_STALL_TIMEOUT_MS } from '../event-stream/upstream-reader.js'; +import { recordStartupPerformance } from './startup-performance.js'; const DEFAULT_SSE_HEARTBEAT_INTERVAL_MS = 20_000; @@ -598,6 +599,12 @@ export const registerOpenCodeProxy = (app, deps) => { !runtimeState.openCodePort ); }; + const classifyReadinessRoute = (requestPath) => { + if (/^\/session\/[^/]+\/message(?:\/|$)/.test(requestPath)) return 'session-messages'; + if (requestPath === '/session' || requestPath.startsWith('/session/')) return 'session'; + if (requestPath === '/event' || requestPath === '/global/event') return 'events'; + return 'other'; + }; app.use('/api', async (req, res, next) => { if ( @@ -617,16 +624,35 @@ export const registerOpenCodeProxy = (app, deps) => { return next(); } + const holdStartedAt = performance.now(); + const routeClass = classifyReadinessRoute(req.path); const deadline = Date.now() + Math.min(OPEN_CODE_READY_GRACE_MS, READINESS_HOLD_MAX_MS); while (Date.now() < deadline) { // Client gave up (closed/aborted) — stop holding. - if (res.writableEnded || req.aborted) return; + if (res.writableEnded || req.aborted) { + recordStartupPerformance('proxy.readiness-hold', { + durationMs: performance.now() - holdStartedAt, + outcome: 'aborted', + routeClass, + }); + return; + } await sleep(READINESS_HOLD_POLL_MS); if (!isStillWaiting(getRuntime())) { + recordStartupPerformance('proxy.readiness-hold', { + durationMs: performance.now() - holdStartedAt, + outcome: 'ready', + routeClass, + }); return next(); } } + recordStartupPerformance('proxy.readiness-hold', { + durationMs: performance.now() - holdStartedAt, + outcome: 'timeout', + routeClass, + }); if (!res.headersSent) { res.status(503).json({ error: 'OpenCode is restarting', diff --git a/packages/web/server/lib/opencode/startup-performance.js b/packages/web/server/lib/opencode/startup-performance.js new file mode 100644 index 00000000..88d51ca2 --- /dev/null +++ b/packages/web/server/lib/opencode/startup-performance.js @@ -0,0 +1,44 @@ +const ENABLED_VALUES = new Set(['1', 'true']); +const ALLOWED_PHASES = new Set([ + 'web.pipeline.start', + 'web.listener.ready', + 'opencode.bootstrap.start', + 'opencode.bootstrap.ready', + 'opencode.bootstrap.error', + 'opencode.orphan-reap.ready', + 'opencode.attempt.start', + 'opencode.binary.ready', + 'opencode.environment.ready', + 'opencode.process.ready', + 'opencode.health.ready', + 'opencode.attempt.error', + 'proxy.readiness-hold', +]); +const ALLOWED_OUTCOMES = new Set(['ready', 'timeout', 'aborted', 'error']); +const ALLOWED_ROUTE_CLASSES = new Set(['session-messages', 'session', 'events', 'other']); + +const finiteNonNegative = (value) => Number.isFinite(value) && value >= 0 ? value : undefined; +const nonNegativeInteger = (value) => Number.isInteger(value) && value >= 0 ? value : undefined; + +const isStartupPerformanceEnabled = () => ( + ENABLED_VALUES.has(String(process.env.OPENCHAMBER_STARTUP_PERF ?? '').toLowerCase()) +); + +export const recordStartupPerformance = (phase, details = {}) => { + if (!isStartupPerformanceEnabled() || !ALLOWED_PHASES.has(phase)) return; + + const event = { + phase, + at: Date.now(), + }; + const durationMs = finiteNonNegative(details.durationMs); + const totalDurationMs = finiteNonNegative(details.totalDurationMs); + const attempt = nonNegativeInteger(details.attempt); + if (durationMs !== undefined) event.durationMs = durationMs; + if (totalDurationMs !== undefined) event.totalDurationMs = totalDurationMs; + if (attempt !== undefined) event.attempt = attempt; + if (ALLOWED_OUTCOMES.has(details.outcome)) event.outcome = details.outcome; + if (ALLOWED_ROUTE_CLASSES.has(details.routeClass)) event.routeClass = details.routeClass; + + console.info('[startup-performance]', event); +}; diff --git a/packages/web/server/lib/opencode/startup-performance.test.js b/packages/web/server/lib/opencode/startup-performance.test.js new file mode 100644 index 00000000..f4b84192 --- /dev/null +++ b/packages/web/server/lib/opencode/startup-performance.test.js @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { recordStartupPerformance } from './startup-performance.js'; + +describe('startup performance diagnostics', () => { + const previousValue = process.env.OPENCHAMBER_STARTUP_PERF; + + afterEach(() => { + if (previousValue === undefined) delete process.env.OPENCHAMBER_STARTUP_PERF; + else process.env.OPENCHAMBER_STARTUP_PERF = previousValue; + vi.restoreAllMocks(); + }); + + it('is disabled by default', () => { + delete process.env.OPENCHAMBER_STARTUP_PERF; + const info = vi.spyOn(console, 'info').mockImplementation(() => {}); + + recordStartupPerformance('opencode.health.ready', { durationMs: 5 }); + + expect(info).not.toHaveBeenCalled(); + }); + + it('records only approved labels and numeric metadata', () => { + process.env.OPENCHAMBER_STARTUP_PERF = '1'; + const info = vi.spyOn(console, 'info').mockImplementation(() => {}); + + recordStartupPerformance('proxy.readiness-hold', { + durationMs: 75, + totalDurationMs: 100, + attempt: 1, + outcome: 'ready', + routeClass: 'session-messages', + sessionID: 'secret-session', + directory: '/secret/directory', + token: 'secret-token', + }); + + expect(info).toHaveBeenCalledOnce(); + const event = info.mock.calls[0][1]; + expect(event).toMatchObject({ + phase: 'proxy.readiness-hold', + durationMs: 75, + totalDurationMs: 100, + attempt: 1, + outcome: 'ready', + routeClass: 'session-messages', + }); + expect(Number.isFinite(event.at)).toBe(true); + expect(JSON.stringify(event)).not.toContain('secret'); + }); + + it('rejects unknown phases and invalid field values', () => { + process.env.OPENCHAMBER_STARTUP_PERF = 'true'; + const info = vi.spyOn(console, 'info').mockImplementation(() => {}); + + recordStartupPerformance('secret.phase', { durationMs: 1 }); + recordStartupPerformance('opencode.bootstrap.error', { + durationMs: -1, + attempt: 1.5, + outcome: 'secret-outcome', + routeClass: 'secret-route', + }); + + expect(info).toHaveBeenCalledOnce(); + expect(info.mock.calls[0][1]).toEqual(expect.objectContaining({ + phase: 'opencode.bootstrap.error', + })); + expect(info.mock.calls[0][1]).not.toHaveProperty('durationMs'); + expect(info.mock.calls[0][1]).not.toHaveProperty('attempt'); + expect(info.mock.calls[0][1]).not.toHaveProperty('outcome'); + expect(info.mock.calls[0][1]).not.toHaveProperty('routeClass'); + }); +}); diff --git a/packages/web/server/lib/opencode/startup-pipeline-runtime.js b/packages/web/server/lib/opencode/startup-pipeline-runtime.js index 48a6e7fe..7af87050 100644 --- a/packages/web/server/lib/opencode/startup-pipeline-runtime.js +++ b/packages/web/server/lib/opencode/startup-pipeline-runtime.js @@ -1,3 +1,5 @@ +import { recordStartupPerformance } from './startup-performance.js'; + export const createStartupPipelineRuntime = (dependencies) => { const { createTerminalRuntime, @@ -7,6 +9,8 @@ export const createStartupPipelineRuntime = (dependencies) => { } = dependencies; const run = async (options) => { + const pipelineStartedAt = performance.now(); + recordStartupPerformance('web.pipeline.start'); const { app, server, @@ -129,6 +133,9 @@ export const createStartupPipelineRuntime = (dependencies) => { startupTunnelRequest, onTunnelReady, }); + recordStartupPerformance('web.listener.ready', { + durationMs: performance.now() - pipelineStartedAt, + }); tunnelRuntimeContext.setActivePort(startupResult.activePort); scheduleOpenCodeApiDetection(); void bootstrapOpenCodeAtStartup(); diff --git a/packages/web/server/lib/tts/routes.js b/packages/web/server/lib/tts/routes.js index 86f1d0be..5d2c4618 100644 --- a/packages/web/server/lib/tts/routes.js +++ b/packages/web/server/lib/tts/routes.js @@ -145,9 +145,10 @@ export function registerTtsRoutes(app, { sayTTSCapability }) { } }); - // macOS 'say' command TTS status endpoint - returns cached capability from startup - app.get('/api/tts/say/status', (_req, res) => { - res.json(sayTTSCapability); + // The startup probe runs concurrently with server bootstrap. An unusually + // early status request waits for that same authoritative result. + app.get('/api/tts/say/status', async (_req, res) => { + res.json(await sayTTSCapability); }); // macOS 'say' command TTS speak endpoint diff --git a/packages/web/server/lib/tts/routes.test.js b/packages/web/server/lib/tts/routes.test.js index ec30ea55..f4940265 100644 --- a/packages/web/server/lib/tts/routes.test.js +++ b/packages/web/server/lib/tts/routes.test.js @@ -5,17 +5,34 @@ import request from 'supertest'; import { registerTtsRoutes } from './routes.js'; import { normalizeCustomOpenAIBaseURL } from './base-url.js'; -const createApp = () => { +const createApp = (sayTTSCapability = null) => { const app = express(); app.use(express.json()); registerTtsRoutes(app, { resolveZenModel: async () => 'gpt-5-nano', - sayTTSCapability: null, + sayTTSCapability, }); return app; }; describe('tts routes', () => { + it('waits for the authoritative macOS say capability', async () => { + let resolveCapability; + const capability = new Promise((resolve) => { + resolveCapability = resolve; + }); + const pending = request(createApp(capability)).get('/api/tts/say/status'); + + resolveCapability({ available: true, voices: [{ name: 'Samantha', locale: 'en_US' }] }); + + const response = await pending; + expect(response.status).toBe(200); + expect(response.body).toEqual({ + available: true, + voices: [{ name: 'Samantha', locale: 'en_US' }], + }); + }); + it('returns local note fallback while model summarization is retired', async () => { const response = await request(createApp()) .post('/api/text/summarize') diff --git a/scripts/profile-browser-session-load.mjs b/scripts/profile-browser-session-load.mjs new file mode 100644 index 00000000..85229a35 --- /dev/null +++ b/scripts/profile-browser-session-load.mjs @@ -0,0 +1,81 @@ +export function projectSessionLoadPerformance(events, recordingStartedAt) { + const sourceEvents = Array.isArray(events) ? events : [] + if (!Number.isFinite(recordingStartedAt)) { + return { bufferAtCapacity: sourceEvents.length >= 1000, events: [] } + } + const allowedOperations = new Set([ + "bootstrap.directory", + "bootstrap.sessions.all", + "bootstrap.sessions.archived", + "bootstrap.sessions.roots", + "global-sessions.active", + "global-sessions.archived", + "session-messages.initial", + "session-messages.older", + "session-messages.page", + "session-messages.refresh", + "session-messages.visible", + "session-prefetch", + ]) + const allowedCallers = new Set([ + "action-demand", + "current-directory", + "initial", + "initial-page", + "known-project", + "known-worktree", + "older", + "pagination", + "prefetch", + "project-expanded", + "refresh", + "selected-session", + "server-connected", + "worktree-expanded", + ]) + const allowedOutcomes = new Set(["complete", "error", "stale", "deduplicated", "canceled"]) + const optionalNonNegativeNumber = (value) => Number.isFinite(value) && value >= 0 ? value : undefined + const optionalNonNegativeInteger = (value) => Number.isInteger(value) && value >= 0 ? value : undefined + return { + bufferAtCapacity: sourceEvents.length >= 1000, + events: sourceEvents.flatMap((event) => { + const { + operation, + caller, + queuedMs, + requestLimit, + cursorPresent, + durationMs, + outcome, + retryCount, + recordCount, + at, + } = event && typeof event === "object" ? event : {} + if (!allowedOperations.has(operation) + || !allowedCallers.has(caller) + || !allowedOutcomes.has(outcome) + || !Number.isFinite(durationMs) + || durationMs < 0 + || !Number.isFinite(at)) { + return [] + } + const projected = { + operation, + caller, + durationMs, + outcome, + offsetMs: Math.max(0, at - recordingStartedAt), + } + const safeQueuedMs = optionalNonNegativeNumber(queuedMs) + const safeRequestLimit = optionalNonNegativeInteger(requestLimit) + const safeRetryCount = optionalNonNegativeInteger(retryCount) + const safeRecordCount = optionalNonNegativeInteger(recordCount) + if (safeQueuedMs !== undefined) projected.queuedMs = safeQueuedMs + if (safeRequestLimit !== undefined) projected.requestLimit = safeRequestLimit + if (typeof cursorPresent === "boolean") projected.cursorPresent = cursorPresent + if (safeRetryCount !== undefined) projected.retryCount = safeRetryCount + if (safeRecordCount !== undefined) projected.recordCount = safeRecordCount + return [projected] + }), + } +} diff --git a/scripts/profile-browser-session-load.test.mjs b/scripts/profile-browser-session-load.test.mjs new file mode 100644 index 00000000..df801c2b --- /dev/null +++ b/scripts/profile-browser-session-load.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { projectSessionLoadPerformance } from "./profile-browser-session-load.mjs" + +test("session-load summary exports only the approved diagnostic fields", () => { + const projectInBrowser = Function( + "events", + "recordingStartedAt", + `return (${projectSessionLoadPerformance.toString()})(events, recordingStartedAt)`, + ) + const projected = projectInBrowser([{ + operation: "session-messages.initial", + caller: "initial", + queuedMs: 3, + requestLimit: 50, + cursorPresent: false, + durationMs: 17, + outcome: "complete", + retryCount: 1, + recordCount: 50, + at: 1_250, + runtimeKey: "secret-runtime", + directory: "/secret/worktree", + sessionID: "secret-session", + message: "secret-message", + content: "secret-content", + authorization: "Bearer secret-token", + token: "secret-token", + password: "secret-password", + cookie: "secret-cookie", + credentials: { apiKey: "secret-api-key" }, + }, { + operation: "session-messages.older", + caller: "older", + queuedMs: "secret-queued", + durationMs: 5, + outcome: "complete", + retryCount: { value: "secret-retry" }, + recordCount: Number.POSITIVE_INFINITY, + at: 1_300, + }, { + operation: "secret-operation", + caller: "secret-caller", + durationMs: { secret: "secret-duration" }, + outcome: "secret-outcome", + at: 1_300, + }], 1_000) + + assert.deepEqual(projected, { + bufferAtCapacity: false, + events: [{ + operation: "session-messages.initial", + caller: "initial", + queuedMs: 3, + requestLimit: 50, + cursorPresent: false, + durationMs: 17, + outcome: "complete", + retryCount: 1, + recordCount: 50, + offsetMs: 250, + }, { + operation: "session-messages.older", + caller: "older", + durationMs: 5, + outcome: "complete", + offsetMs: 300, + }], + }) + + const serialized = JSON.stringify(projected) + for (const secret of [ + "secret-runtime", + "/secret/worktree", + "secret-session", + "secret-message", + "secret-content", + "secret-token", + "secret-password", + "secret-cookie", + "secret-api-key", + "secret-operation", + "secret-caller", + "secret-duration", + "secret-outcome", + "secret-queued", + "secret-retry", + ]) { + assert.equal(serialized.includes(secret), false) + } +}) + +test("session-load summary reports when the source buffer is at capacity", () => { + const events = Array.from({ length: 1000 }, () => ({ at: 1_000 })) + assert.equal(projectSessionLoadPerformance(events, 1_000).bufferAtCapacity, true) +}) + +test("session-load summary rejects an invalid recording timestamp", () => { + assert.deepEqual(projectSessionLoadPerformance([{ + operation: "session-messages.initial", + caller: "initial", + durationMs: 1, + outcome: "complete", + at: 1_000, + }], Number.NaN), { + bufferAtCapacity: false, + events: [], + }) +}) diff --git a/scripts/profile-browser.md b/scripts/profile-browser.md index 82102516..b4a33b9e 100644 --- a/scripts/profile-browser.md +++ b/scripts/profile-browser.md @@ -41,7 +41,12 @@ sensitive URL parameters. The trace applies the same key and URL-parameter redaction, but profiling artifacts can still reveal project paths and endpoint names. Do not publish them without review. -The capture bypasses the PWA service worker and reloads without the browser cache before recording, so repeated optimization runs execute the current local build instead of a previously cached bundle. Network recording begins after that reload, so startup asset downloads are not included in the HAR totals. +`summary.json.sessionLoadPerformance.events` contains the bounded session-loading +operation timeline without runtime keys, directories, session IDs, message +content, or credentials. It includes recording-relative timing, caller, outcome, +retry count, and downloaded record count where available. + +The capture bypasses the PWA service worker and reloads without the browser cache before recording, so repeated optimization runs execute the current local build instead of a previously cached bundle. By default, network recording begins after that preparation reload. Pass `--reload` to perform another cache-bypassing reload after recording starts and include startup requests in the HAR and session-load timeline. Useful options: @@ -49,6 +54,7 @@ Useful options: bun run profile:browser -- --duration 120 bun run profile:browser -- --url http://localhost:4173 bun run profile:browser -- --output /tmp/openchamber-profile +bun run profile:browser -- --reload --no-prompt --duration 60 ``` Run `bun run profile:browser -- --help` for all options. diff --git a/scripts/profile-browser.mjs b/scripts/profile-browser.mjs index 1455d1a3..c4ccbe8b 100644 --- a/scripts/profile-browser.mjs +++ b/scripts/profile-browser.mjs @@ -9,6 +9,8 @@ import { join, resolve } from "node:path" import { createInterface } from "node:readline/promises" import process from "node:process" +import { projectSessionLoadPerformance } from "./profile-browser-session-load.mjs" + const HELP = `Usage: bun run profile:browser -- [options] Options: @@ -19,6 +21,7 @@ Options: --profile-dir Reusable isolated Chrome profile --headless Run without a visible browser --no-prompt Start after a 5 second preparation delay + --reload Reload after recording starts to capture startup --help Show this help The command records a Chrome performance trace, a redacted HAR, browser metrics, @@ -33,12 +36,14 @@ const parseArgs = (argv) => { profileDir: join(homedir(), ".openchamber", "browser-profile-google-chrome"), headless: false, prompt: true, + reload: false, } for (let index = 0; index < argv.length; index += 1) { const value = argv[index] if (value === "--help") return { ...options, help: true } if (value === "--headless") options.headless = true else if (value === "--no-prompt") options.prompt = false + else if (value === "--reload") options.reload = true else if (value === "--url") options.url = argv[++index] else if (value === "--duration") options.duration = Number(argv[++index]) else if (value === "--output") options.output = argv[++index] @@ -374,6 +379,7 @@ const main = async () => { await evaluateValue(client, ` localStorage.setItem("openchamber_sync_perf", "1") localStorage.setItem("openchamber_stream_perf", "1") + localStorage.setItem("openchamber_session_load_perf", "1") `) const reloaded = client.once("Page.loadEventFired", 30_000) await client.send("Page.reload", { ignoreCache: true }) @@ -392,6 +398,7 @@ const main = async () => { await evaluateValue(client, `window.__openchamberSyncPerformance?.reset()`) await evaluateValue(client, `window.__openchamberStreamPerformance?.setEnabled(true)`) await evaluateValue(client, `window.__openchamberStreamPerformance?.reset()`) + await evaluateValue(client, `if (window.__openchamberSessionLoadPerformance) window.__openchamberSessionLoadPerformance.events.length = 0`) const records = new Map() const traceEvents = [] const startedAt = new Date().toISOString() @@ -440,11 +447,21 @@ const main = async () => { }) console.log(`Recording for ${options.duration} seconds. Use OpenChamber normally during this window.`) - await wait(options.duration * 1000) + const recordingStartedAt = Date.now() + if (options.reload) { + const recordedReload = client.once("Page.loadEventFired", 30_000) + await client.send("Page.reload", { ignoreCache: true }) + await recordedReload + } + await wait(Math.max(0, options.duration * 1000 - (Date.now() - recordingStartedAt))) const afterMetrics = metricMap((await client.send("Performance.getMetrics")).metrics) const afterHeap = await client.send("Runtime.getHeapUsage") const syncCounters = await evaluateValue(client, `window.__openchamberSyncPerformance?.getSnapshot() ?? null`) const streamPerformance = await evaluateValue(client, `window.__openchamberStreamPerformance?.getSnapshot() ?? null`) + const sessionLoadPerformance = await evaluateValue( + client, + `(${projectSessionLoadPerformance.toString()})(window.__openchamberSessionLoadPerformance?.events ?? [], ${JSON.stringify(recordingStartedAt)})`, + ) const traceCompleteEvent = client.once("Tracing.tracingComplete", 120_000) let traceComplete = true try { @@ -482,6 +499,8 @@ const main = async () => { heapAfter: afterHeap, syncCounters, streamPerformance, + sessionLoadPerformance, + includesRecordedReload: options.reload, traceComplete, traceFileComplete: false, privacy: "Headers and sensitive URL parameters are redacted. Response bodies are not captured.",