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 <artmore@protonmail.com>
This commit is contained in:
𝖎𝖚𝖑𝖎𝖎𝖆
2026-07-31 12:51:15 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 09f0c64839
commit aae889b904
41 changed files with 1690 additions and 203 deletions
+2
View File
@@ -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
+78 -5
View File
@@ -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', {
+1 -1
View File
@@ -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",
@@ -0,0 +1,4 @@
export const resolveStartupUrlProbePlan = ({ development, packagedUi, skipLocalServer }) => ({
probeHmrApi: development === true && packagedUi !== true && skipLocalServer !== true,
probeHmrUi: development === true && packagedUi !== true,
});
@@ -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,
});
});
@@ -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(({
</div>
)}
<MessageList
key={currentSessionId}
key={currentSessionKey}
ref={messageListRef}
sessionKey={currentSessionId}
disableStaging={pendingRevealWork}
@@ -398,6 +402,7 @@ const ChatViewport = React.memo(({
);
}, (prev, next) => {
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<ChatContainerProps> = ({ 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<ChatContainerProps> = ({ 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<ChatContainerProps> = ({ active = true, aut
showScrollButton,
} = useChatAutoFollow({
currentSessionId,
currentSessionKey,
sessionMessageCount,
sessionIsWorking,
isMobile,
@@ -789,6 +804,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
const timelineController = useChatTimelineController({
sessionId: currentSessionId,
sessionKey: currentSessionKey,
messages: viewportMessages,
historyMeta,
scrollRef,
@@ -940,7 +956,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
};
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
const lastScrolledSessionRef = React.useRef<string | null>(null);
const lastScrolledSessionKeyRef = React.useRef<string | null>(null);
const isSessionHydrating =
Boolean(currentSessionId)
@@ -952,10 +968,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ 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<ChatContainerProps> = ({ 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<ChatContainerProps> = ({ active = true, aut
{returnToParentButton}
<ChatViewport
currentSessionId={currentSessionId}
currentSessionKey={currentSessionKey ?? currentSessionId}
isDesktopExpandedInput={isDesktopExpandedInput}
isMobile={isMobile}
stickyUserHeader={stickyUserHeader}
@@ -1483,10 +1483,14 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
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<TanstackVirtualizerInstance | null>(null);
const registerTanstackVirtualizer = React.useCallback((virtualizer: TanstackVirtualizerInstance | null) => {
@@ -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<void>((next) => {
resolve = next;
});
return { promise, resolve };
};
const installMinimalDom = () => {
const descriptors = new Map<string, PropertyDescriptor | undefined>();
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<string, unknown> = {
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<typeof setTimeout>) => 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<void> | 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<void>;
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();
}
});
});
@@ -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<HTMLDivElement | null>;
@@ -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<TurnWindowModel | null>(null);
const previousMessagesRef = React.useRef<ChatMessageEntry[] | null>(null);
const previousTurnWindowKeyRef = React.useRef<string | null>(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<string | null>(sessionId);
const timelineIdentityRef = React.useRef<TimelineIdentityToken>({ key: sessionKey });
if (timelineIdentityRef.current.key !== sessionKey) {
timelineIdentityRef.current = { key: sessionKey };
}
const messagesRef = React.useRef(messages);
const historyMetaRef = React.useRef<SessionHistoryMeta | null>(historyMeta);
const initializedSessionRef = React.useRef<string | null>(null);
const initializedSessionKeyRef = React.useRef<string | null>(null);
const pendingRenderResolversRef = React.useRef<Array<() => void>>([]);
const pendingScrollRequestRef = React.useRef<PendingScrollRequest | null>(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<boolean> => {
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<boolean> => {
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<boolean>((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<boolean> => {
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<boolean>((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);
@@ -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<SessionSidebarProps> = ({
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);
@@ -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 };
});
+14 -10
View File
@@ -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<string | null>(null);
const lastSessionKeyRef = React.useRef<string | null>(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<boolean> => {
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(() => {
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test"
import { getBackgroundNetworkState, runBackgroundNetworkTask } from "./background-network"
const deferred = <T>() => {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((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<string>())
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)
})
})
+61
View File
@@ -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<void> => {
if (backgroundNetworkActive < BACKGROUND_NETWORK_CONCURRENCY) {
backgroundNetworkActive += 1
return Promise.resolve()
}
return new Promise<void>((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 <T>(task: () => Promise<T>): Promise<T> => {
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,
})
+24 -1
View File
@@ -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();
}
});
});
+3 -5
View File
@@ -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;
});
+3 -2
View File
@@ -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<CommandsStore>()(
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(
@@ -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<T> = {
promise: Promise<T>
@@ -20,16 +23,17 @@ const deferred = <T>(): Deferred<T> => {
let activeRequest: Deferred<Session[]>
let archivedRequest: Deferred<Session[]>
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<Session[]>()
archivedRequest = deferred<Session[]>()
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"))
+2 -1
View File
@@ -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<SkillsStore>()(
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}`);
}
+2
View File
@@ -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) {
+6 -3
View File
@@ -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.
-1
View File
@@ -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),
})
@@ -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<SessionLoadPerformanceOutcome>([
"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<SessionLoadPerformanceEvent, "at" | "durationMs" | "outcome">) {
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<Pick<SessionLoadPerformanceEvent, "retryCount" | "recordCount">>,
) => {
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<FirstVisibleSessionPerformanceDependencies>,
) {
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<string>()
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
}
},
}
}
@@ -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<ReturnType<typeof response>>()
const refresh = deferred<ReturnType<typeof response>>()
@@ -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<number, FrameRequestCallback>()
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<number, FrameRequestCallback>()
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"])
})
})
+70 -47
View File
@@ -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<void>,
run: (isCurrent: () => boolean, performance: LoadPerformanceDetails) => Promise<void>,
): Promise<void> {
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<void>
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<void> {
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<FetchedPage> {
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<FetchedPage> {
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<string, Part[]>()
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<string, Part[]>()
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(
+13 -2
View File
@@ -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])
+2 -3
View File
@@ -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(
+23 -1
View File
@@ -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();
@@ -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)
+102 -3
View File
@@ -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);
}
}
};
/**
@@ -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(() => {});
+27 -1
View File
@@ -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',
@@ -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);
};
@@ -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');
});
});
@@ -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();
+4 -3
View File
@@ -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
+19 -2
View File
@@ -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')
+81
View File
@@ -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]
}),
}
}
@@ -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: [],
})
})
+7 -1
View File
@@ -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.
+20 -1
View File
@@ -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 <path> 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.",