Files
openchamber/packages/vscode/src/bridge-proxy-runtime.ts
T
Bohdan Triapitsyn c73ab9cbd5 feat(vscode): startup parity + workspace-grouped session list (#1658)
* perf(vscode): gate API readiness and coalesce duplicate startup reads

Bring the VS Code bridge runtime to parity with the web startup
optimizations (PR #1650), which were web/desktop-only.

waitForApiUrl now hands out the OpenCode API URL only once the manager
reports 'connected', instead of as soon as getApiUrl() exposes
server.url. The URL is available the moment the process is spawned —
before waitForReady confirms it can serve and during a workspace-switch
restart (stale port) — so URL-presence alone let the bridge forward to a
not-yet-ready OpenCode and surface 502s. Gating on connected status
mirrors the web proxy's isOpenCodeReady hold. Also fail fast on 'error'
status so a missing CLI doesn't burn the full 30s timeout.

Coalesce concurrent identical GET reads (config/path/agents/agent/
project/command) at the bridge proxy so the single OpenCode process
serves them once. On cold start the webview's sync bootstrap and config
store fire these reads in parallel with no shared dedup; this is the
extension-host analog of the runtimeFetch coalescer. Shared reads carry
no AbortController so one caller's abort can't strand the others, and the
entry clears as soon as it settles (never serves stale).

* perf(vscode): fade the startup splash once mounted + connected, not on live fetch

The webview's initial-loading overlay held until a successful live
/api/config/providers AND /api/agent fetch completed. After the cache
hydration work those live reads are the slowest cold-start tail — the UI
underneath already paints pickers and the sidebar from cache and refreshes
in the background — so gating the splash on them kept it spinning long
after the app was usable.

Fade the overlay as soon as the UI is mounted and OpenCode is connected.
Per-widget loaders convey any remaining background refresh, matching how
web/desktop (which have no such splash) already behave. Removes the now
-obsolete bootstrapProvidersReady/AgentsReady/Failed tracking and
recordBootstrapFetch. Connection error/disconnected splash messages are
unchanged.

* fix(vscode): include captured OpenCode output in spawn-timeout error

When the managed OpenCode server fails to emit its 'listening' line within
the start timeout, the error discarded everything the process printed to
stdout/stderr — so the status report showed a bare 'Timeout waiting for
server to start' with no clue whether the process hung, crashed silently,
or printed a config/auth error. The exit path already includes the output;
the timeout path now does too (or notes that nothing was printed).

* feat(vscode): workspace-grouped session list with working folders, pinning, and archived toggle

Replace the flat multi-workspace session list with the grouped project view,
using each open VS Code workspace folder as a header (no per-worktree
subgroups). This restores native folder and pin support, which the flat list
silently dropped, and fixes the clipped left padding on session rows.

- Group sessions strictly by open workspace; funnel all non-archived sessions
  into the workspace's group so they no longer fall into the archived bucket.
- Keep the project/group/folder + buttons but make them open a draft in the
  correct workspace and navigate to chat; hide the project actions (...) menu,
  which isn't relevant in VS Code.
- Force the minimal single-line row layout (the second metadata row is
  redundant under workspace headers) and drop the per-row tooltip.
- Add a show/hide archived toggle next to the archive-all control, since the
  VS Code header has no display-mode menu.
- Size the hover action reveal so the timestamp clears the row buttons.
2026-06-15 13:02:26 +03:00

308 lines
11 KiB
TypeScript

import type { BridgeContext, BridgeResponse } from './bridge';
import { waitForApiUrl } from './opencode-ready';
type BridgeMessageInput = {
id: string;
type: string;
payload?: unknown;
};
type ApiProxyRequestPayload = {
method?: string;
path?: string;
headers?: Record<string, string>;
bodyBase64?: string;
};
type ApiSessionMessageRequestPayload = {
path?: string;
headers?: Record<string, string>;
bodyText?: string;
};
type ApiProxyAbortPayload = {
requestID?: string;
};
type ApiProxyResponsePayload = {
status: number;
headers: Record<string, string>;
bodyBase64?: string;
bodyText?: string;
};
const shouldReturnTextBody = (headers: Headers): boolean => {
const contentType = headers.get('content-type')?.toLowerCase() || '';
return contentType.startsWith('application/json')
|| contentType.startsWith('text/')
|| contentType.includes('+json');
};
const collectProxyResponseHeaders = (headers: Headers, deps: Pick<ProxyRuntimeDeps, 'collectHeaders'>): Record<string, string> => {
const result = deps.collectHeaders(headers);
delete result['content-length'];
delete result['content-encoding'];
delete result['transfer-encoding'];
return result;
};
const isSseProxyPath = (requestPath: string): boolean => {
try {
const parsed = new URL(requestPath, 'https://openchamber.invalid');
return parsed.pathname === '/event' || parsed.pathname === '/global/event';
} catch {
return requestPath === '/event' || requestPath === '/global/event';
}
};
type ProxyRuntimeDeps = {
tryHandleLocalFsProxy: (method: string, requestPath: string) => Promise<ApiProxyResponsePayload | null>;
buildUnavailableApiResponse: () => ApiProxyResponsePayload;
sanitizeForwardHeaders: (input: Record<string, string> | undefined) => Record<string, string>;
collectHeaders: (headers: Headers) => Record<string, string>;
base64EncodeUtf8: (text: string) => string;
};
const proxyAbortControllers = new Map<string, AbortController>();
// ---------------------------------------------------------------------------
// In-flight read coalescing (parity with the web runtimeFetch coalescer)
//
// On cold start the webview's two data layers — the sync bootstrap and the
// config store — fire the SAME idempotent reads (config, path, agents, agent,
// project, command) concurrently through the bridge with no shared dedup. That
// saturates the single OpenCode process and delays everything queued behind it
// (e.g. createSession). Coalesce genuinely-concurrent identical GETs to those
// read endpoints so OpenCode does the work once; every caller gets its own
// response payload copy.
//
// Scope is deliberately tight: GET only, an allowlist of read paths. The shared
// fetch runs without a per-request AbortController, so one caller's
// `api:proxy:abort` cannot cancel the read for the others (these reads are fast
// and idempotent — losing abort for them is harmless). The entry is removed as
// soon as the request settles, so this only ever shares overlapping in-flight
// requests; it never serves a stale response.
// ---------------------------------------------------------------------------
const COALESCE_READ_PATH = /^\/(config|path|app\/agents|agent|project|command)(\b|\/|\?|$)/;
const READ_COALESCE = new Map<string, Promise<ApiProxyResponsePayload>>();
const performApiProxyFetch = async (
targetUrl: string,
method: string,
headers: Record<string, string>,
body: Buffer | undefined,
signal: AbortSignal | undefined,
deps: Pick<ProxyRuntimeDeps, 'collectHeaders'>,
): Promise<ApiProxyResponsePayload> => {
try {
const response = await fetch(targetUrl, { method, headers, body, signal });
const responseHeaders = collectProxyResponseHeaders(response.headers, deps);
if (shouldReturnTextBody(response.headers)) {
return { status: response.status, headers: responseHeaders, bodyText: await response.text() };
}
const arrayBuffer = await response.arrayBuffer();
return {
status: response.status,
headers: responseHeaders,
bodyBase64: Buffer.from(arrayBuffer).toString('base64'),
};
} catch (error) {
return {
status: 502,
headers: { 'content-type': 'application/json' },
bodyText: JSON.stringify({
error: error instanceof Error ? error.message : 'Failed to reach OpenCode API',
}),
};
}
};
export async function handleProxyBridgeMessage(
message: BridgeMessageInput,
ctx: BridgeContext | undefined,
deps: ProxyRuntimeDeps,
): Promise<BridgeResponse | null> {
const { id, type, payload } = message;
switch (type) {
case 'api:proxy:abort': {
const { requestID } = (payload || {}) as ApiProxyAbortPayload;
if (typeof requestID === 'string' && requestID.length > 0) {
proxyAbortControllers.get(requestID)?.abort();
proxyAbortControllers.delete(requestID);
}
return { id, type, success: true, data: { aborted: true } };
}
case 'api:proxy': {
const { method, path: requestPath, headers, bodyBase64 } = (payload || {}) as ApiProxyRequestPayload;
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
const normalizedPath =
typeof requestPath === 'string' && requestPath.trim().length > 0
? requestPath.trim().startsWith('/')
? requestPath.trim()
: `/${requestPath.trim()}`
: '/';
if (isSseProxyPath(normalizedPath)) {
const data: ApiProxyResponsePayload = {
status: 400,
headers: { 'content-type': 'application/json' },
bodyText: JSON.stringify({ error: 'SSE requests must use api:sse:start' }),
};
return { id, type, success: true, data };
}
const localFsResponse = await deps.tryHandleLocalFsProxy(normalizedMethod, normalizedPath);
if (localFsResponse) {
return { id, type, success: true, data: localFsResponse };
}
const apiUrl = await waitForApiUrl(ctx?.manager);
if (!apiUrl) {
const data = deps.buildUnavailableApiResponse();
return { id, type, success: true, data };
}
const base = `${apiUrl.replace(/\/+$/, '')}/`;
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
const requestHeaders: Record<string, string> = {
...deps.sanitizeForwardHeaders(headers),
...ctx?.manager?.getOpenCodeAuthHeaders(),
};
const requestBody =
typeof bodyBase64 === 'string' && bodyBase64.length > 0 && normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD'
? Buffer.from(bodyBase64, 'base64')
: undefined;
// Coalesce concurrent identical GET reads to idempotent endpoints so the
// single OpenCode process serves them once. The shared fetch carries no
// AbortController (api:proxy:abort can't cancel these reads), so one
// caller aborting can't strand the others.
const coalesceKey =
normalizedMethod === 'GET' && COALESCE_READ_PATH.test(normalizedPath) ? `GET ${targetUrl}` : null;
if (coalesceKey) {
const existing = READ_COALESCE.get(coalesceKey);
if (existing) {
const shared = await existing;
return { id, type, success: true, data: { ...shared, headers: { ...shared.headers } } };
}
const pending = performApiProxyFetch(targetUrl, 'GET', requestHeaders, undefined, undefined, deps);
READ_COALESCE.set(coalesceKey, pending);
pending.then(
() => READ_COALESCE.delete(coalesceKey),
() => READ_COALESCE.delete(coalesceKey),
);
const data = await pending;
return { id, type, success: true, data };
}
const abortController = new AbortController();
proxyAbortControllers.set(id, abortController);
try {
const data = await performApiProxyFetch(
targetUrl,
normalizedMethod,
requestHeaders,
requestBody,
abortController.signal,
deps,
);
return { id, type, success: true, data };
} finally {
proxyAbortControllers.delete(id);
}
}
case 'api:session:message': {
const apiUrl = await waitForApiUrl(ctx?.manager);
if (!apiUrl) {
const data = deps.buildUnavailableApiResponse();
return { id, type, success: true, data };
}
const { path: requestPath, headers, bodyText } = (payload || {}) as ApiSessionMessageRequestPayload;
const normalizedPath =
typeof requestPath === 'string' && requestPath.trim().length > 0
? requestPath.trim().startsWith('/')
? requestPath.trim()
: `/${requestPath.trim()}`
: '/';
if (!/^\/session\/[^/]+\/message(?:\?.*)?$/.test(normalizedPath)) {
const body = JSON.stringify({ error: 'Invalid session message proxy path' });
const data: ApiProxyResponsePayload = {
status: 400,
headers: { 'content-type': 'application/json' },
bodyBase64: deps.base64EncodeUtf8(body),
};
return { id, type, success: true, data };
}
const base = `${apiUrl.replace(/\/+$/, '')}/`;
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
const requestHeaders: Record<string, string> = {
...deps.sanitizeForwardHeaders(headers),
...ctx?.manager?.getOpenCodeAuthHeaders(),
};
const timeoutSignal = AbortSignal.timeout(45000);
const abortController = new AbortController();
proxyAbortControllers.set(id, abortController);
const onTimeout = () => abortController.abort();
timeoutSignal.addEventListener('abort', onTimeout, { once: true });
try {
const response = await fetch(targetUrl, {
method: 'POST',
headers: requestHeaders,
body: typeof bodyText === 'string' ? bodyText : '',
signal: abortController.signal,
});
const responseHeaders = collectProxyResponseHeaders(response.headers, deps);
if (shouldReturnTextBody(response.headers)) {
const bodyText = await response.text();
const data: ApiProxyResponsePayload = {
status: response.status,
headers: responseHeaders,
bodyText,
};
return { id, type, success: true, data };
}
const arrayBuffer = await response.arrayBuffer();
const data: ApiProxyResponsePayload = {
status: response.status,
headers: responseHeaders,
bodyBase64: Buffer.from(arrayBuffer).toString('base64'),
};
return { id, type, success: true, data };
} catch (error) {
const isTimeout =
error instanceof Error &&
((error as Error & { name?: string }).name === 'TimeoutError' ||
(error as Error & { name?: string }).name === 'AbortError');
const body = JSON.stringify({
error: isTimeout ? 'OpenCode message forward timed out' : error instanceof Error ? error.message : 'OpenCode message forward failed',
});
const data: ApiProxyResponsePayload = {
status: isTimeout ? 504 : 503,
headers: { 'content-type': 'application/json' },
bodyText: body,
};
return { id, type, success: true, data };
} finally {
timeoutSignal.removeEventListener('abort', onTimeout);
proxyAbortControllers.delete(id);
}
}
default:
return null;
}
}