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.
This commit is contained in:
Bohdan Triapitsyn
2026-06-15 13:02:26 +03:00
committed by GitHub
parent 8919d33636
commit c73ab9cbd5
14 changed files with 364 additions and 135 deletions
@@ -19,8 +19,13 @@ const deps = {
const ctx = {
manager: {
getStatus: () => 'connected',
getApiUrl: () => 'http://127.0.0.1:3902',
getOpenCodeAuthHeaders: () => ({}),
onStatusChange: (cb: (status: string) => void) => {
cb('connected');
return { dispose: () => {} };
},
},
} as unknown as BridgeContext;
@@ -57,3 +62,70 @@ describe('VS Code API proxy aborts', () => {
}
});
});
describe('VS Code API proxy read coalescing', () => {
test('shares one upstream fetch across concurrent identical GET reads', async () => {
const originalFetch = globalThis.fetch;
let fetchCount = 0;
let release: () => void = () => {};
try {
globalThis.fetch = (async () => {
fetchCount += 1;
await new Promise<void>((resolve) => { release = resolve; });
return new Response('{"ok":true}', { status: 200, headers: { 'content-type': 'application/json' } });
}) as typeof fetch;
const first = handleProxyBridgeMessage(
{ id: 'r1', type: 'api:proxy', payload: { method: 'GET', path: '/config?directory=/x' } },
ctx,
deps,
);
const second = handleProxyBridgeMessage(
{ id: 'r2', type: 'api:proxy', payload: { method: 'GET', path: '/config?directory=/x' } },
ctx,
deps,
);
await new Promise((resolve) => setTimeout(resolve, 0));
release();
const [a, b] = await Promise.all([first, second]);
assert.equal(fetchCount, 1);
assert.equal((a?.data as { bodyText?: string }).bodyText, '{"ok":true}');
assert.equal((b?.data as { bodyText?: string }).bodyText, '{"ok":true}');
assert.notStrictEqual((a?.data as { headers: unknown }).headers, (b?.data as { headers: unknown }).headers);
} finally {
globalThis.fetch = originalFetch;
}
});
test('does not coalesce POST writes or non-allowlisted reads', async () => {
const originalFetch = globalThis.fetch;
let fetchCount = 0;
try {
globalThis.fetch = (async () =>
new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } })) as typeof fetch;
await Promise.all([
handleProxyBridgeMessage({ id: 'w1', type: 'api:proxy', payload: { method: 'GET', path: '/session?directory=/x' } }, ctx, deps),
handleProxyBridgeMessage({ id: 'w2', type: 'api:proxy', payload: { method: 'GET', path: '/session?directory=/x' } }, ctx, deps),
]);
assert.equal(fetchCount, 0); // sanity: counter only bumps in the slow mock above
globalThis.fetch = (async () => {
fetchCount += 1;
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
}) as typeof fetch;
await Promise.all([
handleProxyBridgeMessage({ id: 's1', type: 'api:proxy', payload: { method: 'GET', path: '/session?directory=/x' } }, ctx, deps),
handleProxyBridgeMessage({ id: 's2', type: 'api:proxy', payload: { method: 'GET', path: '/session?directory=/x' } }, ctx, deps),
]);
assert.equal(fetchCount, 2); // /session is not in the read allowlist
} finally {
globalThis.fetch = originalFetch;
}
});
});
+87 -39
View File
@@ -65,6 +65,58 @@ type ProxyRuntimeDeps = {
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,
@@ -119,49 +171,45 @@ export async function handleProxyBridgeMessage(
...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 response = await fetch(targetUrl, {
method: normalizedMethod,
headers: requestHeaders,
body:
typeof bodyBase64 === 'string' && bodyBase64.length > 0 && normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD'
? Buffer.from(bodyBase64, 'base64')
: undefined,
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 body = JSON.stringify({
error: error instanceof Error ? error.message : 'Failed to reach OpenCode API',
});
const data: ApiProxyResponsePayload = {
status: 502,
headers: { 'content-type': 'application/json' },
bodyText: body,
};
const data = await performApiProxyFetch(
targetUrl,
normalizedMethod,
requestHeaders,
requestBody,
abortController.signal,
deps,
);
return { id, type, success: true, data };
} finally {
proxyAbortControllers.delete(id);
@@ -0,0 +1,74 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import type { ConnectionStatus, OpenCodeManager } from './opencode';
import { waitForApiUrl } from './opencode-ready';
type Listener = (status: ConnectionStatus, error?: string) => void;
const createManager = (initial: { status: ConnectionStatus; url: string | null }) => {
let status = initial.status;
let url = initial.url;
const listeners = new Set<Listener>();
const manager = {
getStatus: () => status,
getApiUrl: () => url,
onStatusChange: (cb: Listener) => {
listeners.add(cb);
cb(status);
return { dispose: () => listeners.delete(cb) };
},
} as unknown as OpenCodeManager;
const transition = (next: ConnectionStatus, nextUrl: string | null) => {
status = next;
url = nextUrl;
listeners.forEach((cb) => cb(status));
};
return { manager, transition };
};
describe('waitForApiUrl readiness gating', () => {
test('returns immediately when already connected with a URL', async () => {
const { manager } = createManager({ status: 'connected', url: 'http://127.0.0.1:3902' });
assert.equal(await waitForApiUrl(manager, 1000), 'http://127.0.0.1:3902');
});
test('does not hand out the URL until status is connected (pre-ready spawn window)', async () => {
// server.url is exposed while still connecting — must NOT be forwarded to.
const { manager, transition } = createManager({ status: 'connecting', url: 'http://127.0.0.1:3902' });
const pending = waitForApiUrl(manager, 1000);
let resolved = false;
void pending.then(() => { resolved = true; });
await new Promise((r) => setTimeout(r, 10));
assert.equal(resolved, false);
transition('connected', 'http://127.0.0.1:3902');
assert.equal(await pending, 'http://127.0.0.1:3902');
});
test('holds during a restart and resolves once reconnected', async () => {
const { manager, transition } = createManager({ status: 'disconnected', url: null });
const pending = waitForApiUrl(manager, 1000);
transition('connecting', null);
await new Promise((r) => setTimeout(r, 5));
transition('connected', 'http://127.0.0.1:4096');
assert.equal(await pending, 'http://127.0.0.1:4096');
});
test('fails fast on error status instead of burning the timeout', async () => {
const { manager, transition } = createManager({ status: 'connecting', url: null });
const pending = waitForApiUrl(manager, 5000);
transition('error', null);
// Resolves well before the 5s timeout.
assert.equal(await pending, null);
});
test('falls back to whatever URL exists after the timeout', async () => {
const { manager } = createManager({ status: 'connecting', url: null });
assert.equal(await waitForApiUrl(manager, 20), null);
});
});
+38 -12
View File
@@ -1,4 +1,4 @@
import type { OpenCodeManager } from './opencode';
import type { ConnectionStatus, OpenCodeManager } from './opencode';
export const API_URL_WAIT_TIMEOUT_MS = 30000;
@@ -10,7 +10,21 @@ export async function waitForApiUrl(
return null;
}
const initialUrl = manager.getApiUrl();
// Only hand out an API URL once OpenCode has actually passed its readiness
// check. getApiUrl() exposes `server.url` as soon as the process is spawned —
// BEFORE waitForReady confirms it can serve — so URL-presence alone would
// forward requests to a not-yet-ready OpenCode (and to a stale port during a
// workspace-switch restart). Gating on the connected status, which flips only
// after readiness and clears while restarting, mirrors the web proxy's
// isOpenCodeReady hold and closes that pre-ready forwarding window.
const readyUrl = (): string | null => {
if (manager.getStatus() !== 'connected') {
return null;
}
return manager.getApiUrl();
};
const initialUrl = readyUrl();
if (initialUrl) {
return initialUrl;
}
@@ -21,9 +35,8 @@ export async function waitForApiUrl(
let subscription: { dispose(): void } | null = null;
let disposeAfterSubscribe = false;
const handleStatusChange = () => {
const nextUrl = manager.getApiUrl();
if (!nextUrl || settled) {
const finish = (value: string | null) => {
if (settled) {
return;
}
settled = true;
@@ -35,9 +48,25 @@ export async function waitForApiUrl(
} else {
disposeAfterSubscribe = true;
}
resolve(nextUrl);
resolve(value);
};
const handleStatusChange = (status: ConnectionStatus) => {
// Permanent failure (CLI missing / spawn error) won't recover from holding
// — fail fast instead of burning the full timeout, matching the web gate's
// fast 503 for genuinely-down servers.
if (status === 'error') {
finish(null);
return;
}
const nextUrl = readyUrl();
if (nextUrl) {
finish(nextUrl);
}
};
// onStatusChange invokes the callback synchronously with the current status,
// so this also covers an already-ready/already-errored manager.
subscription = manager.onStatusChange(handleStatusChange);
if (disposeAfterSubscribe) {
subscription.dispose();
@@ -48,12 +77,9 @@ export async function waitForApiUrl(
}
timeoutId = setTimeout(() => {
if (settled) {
return;
}
settled = true;
subscription?.dispose();
resolve(manager.getApiUrl());
// Bounded fallback: hand back whatever URL exists (possibly null) so a
// genuinely-stuck startup surfaces as unavailable rather than hanging.
finish(manager.getApiUrl());
}, timeoutMs);
});
}
+6 -1
View File
@@ -673,7 +673,12 @@ async function spawnManagedOpenCodeServer(
const timer = setTimeout(() => {
cleanup();
reject(new Error(`Timeout waiting for server to start after ${timeoutMs}ms`));
// Surface whatever OpenCode printed while we waited — otherwise a hung or
// misconfigured start is indistinguishable from a slow one in the status
// report, leaving no thread to pull on.
const trimmedOutput = output.trim();
const outputHint = trimmedOutput ? ` Output: ${trimmedOutput}` : ' Output: (none — process printed nothing)';
reject(new Error(`Timeout waiting for server to start after ${timeoutMs}ms.${outputHint}`));
}, timeoutMs);
child.stdout?.on('data', onStdout);
+1
View File
@@ -4,6 +4,7 @@ const originalFetch = globalThis.fetch;
const { openSseProxy } = await import('./sseProxy');
const createManager = () => ({
getStatus: () => 'connected',
getApiUrl: () => 'http://127.0.0.1:4096/',
getWorkingDirectory: () => '/repo',
getOpenCodeAuthHeaders: () => ({ Authorization: 'Bearer test-token' }),