diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index 2c450b28..5ac2264b 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -14,6 +14,7 @@ import { ArchiveAllDropdown } from '@/components/session/ArchiveAllDropdown'; import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown'; import { SessionsTabTitle } from '@/components/session/SessionsTabTitle'; import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { cn } from '@/lib/utils'; import { DropdownMenu, @@ -546,7 +547,6 @@ export const VSCodeLayout: React.FC = () => { mobileVariant allowReselect hideDirectoryControls - showOnlyMainWorkspace />
{ allowReselect onSessionSelected={() => setCurrentView('chat')} hideDirectoryControls - showOnlyMainWorkspace />
@@ -640,6 +639,8 @@ interface VSCodeHeaderProps { const VSCodeHeader: React.FC = ({ title, showBack, onBack, onArchiveAll, onNewSession, onSettings, onAgentManager, showMcp, showContextUsage, showRateLimits, enableSessionSwitcher }) => { const { t } = useI18n(); + const showArchivedSessions = useSessionDisplayStore((state) => state.showArchivedSessions); + const toggleArchivedSessions = useSessionDisplayStore((state) => state.toggleArchivedSessions); const getCurrentModel = useConfigStore((state) => state.getCurrentModel); const providers = useConfigStore((state) => state.providers); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); @@ -817,6 +818,21 @@ const VSCodeHeader: React.FC = ({ title, showBack, onBack, on )}
+ {onArchiveAll && ( + + )} {onArchiveAll && } {onNewSession && (
+ {/* VS Code already shows project context via workspace headers, so + the per-row metadata tooltip is redundant noise there. */} + {!isVSCode ? (
@@ -1021,6 +1032,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { ) : null}
+ ) : null} ) : (
{showCreateButtons && onNewSession ? ( diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 1ac95bcb..45ef5731 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,5 +1,9 @@ ## [Unreleased] +- Startup: the extension opens faster — recent sessions, models, providers, and projects appear instantly from cache and refresh in the background, and the loading screen no longer lingers after the interface is ready. +- Startup: requests made while OpenCode is still starting now wait briefly for it to become ready instead of failing, and if OpenCode fails to start the error now includes what it reported. +- Sessions: the list now groups sessions under their workspace, so pinning sessions and moving them into folders work as expected. +- Sessions: session rows now use a cleaner single-line layout, project-level actions are hidden, and a new control next to "archive all" toggles archived sessions on or off. - Chat: custom-answer question textareas resize more steadily while typing (thanks to @bigcoder84). - Chat/Performance: long conversations now use virtualized rendering to keep large histories responsive. - Chat/Input: tab-completing a mention no longer changes the selected agent (thanks to @Quat3rnion). diff --git a/packages/vscode/src/bridge-proxy-runtime.test.ts b/packages/vscode/src/bridge-proxy-runtime.test.ts index 17c832bb..4b2bc432 100644 --- a/packages/vscode/src/bridge-proxy-runtime.test.ts +++ b/packages/vscode/src/bridge-proxy-runtime.test.ts @@ -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((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; + } + }); +}); diff --git a/packages/vscode/src/bridge-proxy-runtime.ts b/packages/vscode/src/bridge-proxy-runtime.ts index 34a486a1..714b46e3 100644 --- a/packages/vscode/src/bridge-proxy-runtime.ts +++ b/packages/vscode/src/bridge-proxy-runtime.ts @@ -65,6 +65,58 @@ type ProxyRuntimeDeps = { const proxyAbortControllers = new Map(); +// --------------------------------------------------------------------------- +// 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>(); + +const performApiProxyFetch = async ( + targetUrl: string, + method: string, + headers: Record, + body: Buffer | undefined, + signal: AbortSignal | undefined, + deps: Pick, +): Promise => { + 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); diff --git a/packages/vscode/src/opencode-ready.test.ts b/packages/vscode/src/opencode-ready.test.ts new file mode 100644 index 00000000..569e4177 --- /dev/null +++ b/packages/vscode/src/opencode-ready.test.ts @@ -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(); + + 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); + }); +}); diff --git a/packages/vscode/src/opencode-ready.ts b/packages/vscode/src/opencode-ready.ts index b7f721da..0434dcdd 100644 --- a/packages/vscode/src/opencode-ready.ts +++ b/packages/vscode/src/opencode-ready.ts @@ -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); }); } diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index 583cad4f..edcb47a9 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -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); diff --git a/packages/vscode/src/sseProxy.test.js b/packages/vscode/src/sseProxy.test.js index e1d95767..bec29432 100644 --- a/packages/vscode/src/sseProxy.test.js +++ b/packages/vscode/src/sseProxy.test.js @@ -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' }), diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 9bfd26ab..2efd5cca 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -138,36 +138,6 @@ const waitForUiMount = (timeoutMs = 8000): Promise => { }; let uiMounted = false; -let bootstrapProvidersReady = false; -let bootstrapAgentsReady = false; -let bootstrapFailed = false; - -const recordBootstrapFetch = (pathname: string, ok: boolean) => { - if (!pathname.startsWith('/api/')) return; - - // Don't mark as failed while still connecting — early 503s are expected - const isConnected = window.__OPENCHAMBER_CONNECTION__?.status === 'connected'; - - if (pathname.startsWith('/api/config/providers')) { - if (ok) { - bootstrapProvidersReady = true; - // Reset failed flag — a successful retry supersedes earlier 503s - if (bootstrapAgentsReady || !isConnected) bootstrapFailed = false; - } else if (isConnected) { - bootstrapFailed = true; - } - return; - } - - if (pathname === '/api/agent' || pathname.startsWith('/api/agent?')) { - if (ok) { - bootstrapAgentsReady = true; - if (bootstrapProvidersReady || !isConnected) bootstrapFailed = false; - } else if (isConnected) { - bootstrapFailed = true; - } - } -}; const maybeHideLoadingOverlay = () => { const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status ?? 'connecting'; @@ -177,19 +147,14 @@ const maybeHideLoadingOverlay = () => { } if (connectionStatus === 'connected') { - if (bootstrapFailed) { - setLoadingStatusText(bootstrapMessages.initialDataLoadFailed, 'error'); - fadeOutLoadingScreen(); - return; - } - - if (bootstrapProvidersReady && bootstrapAgentsReady) { - fadeOutLoadingScreen(); - return; - } - - // Still loading providers/agents — stay silent (the animated logo signals work). - setLoadingStatusText(''); + // The UI hydrates pickers and the sidebar from cache and refreshes + // providers/agents in the background, so once it's mounted and OpenCode is + // connected there's real interactive content underneath the splash. Don't + // keep the overlay up waiting on the live provider/agent fetches — on a cold + // start those are the slowest tail, and gating on them makes the splash + // linger long after the app is usable. Per-widget loaders convey any + // remaining background work. + fadeOutLoadingScreen(); return; } @@ -1120,7 +1085,6 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { if (targetUrl && isLocalRuntimePath(normalizedPathname)) { const localResponse = await handleLocalApiRequest(input, targetUrl, init, method); if (localResponse) { - recordBootstrapFetch(targetUrl.pathname, localResponse.ok); maybeHideLoadingOverlay(); return localResponse; } @@ -1208,7 +1172,6 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined; const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText, signal }); const response = buildProxiedResponse(proxied); - recordBootstrapFetch(targetUrl.pathname, response.ok); maybeHideLoadingOverlay(); return response; } @@ -1217,7 +1180,6 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined; const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64, signal }); const response = buildProxiedResponse(proxied); - recordBootstrapFetch(targetUrl.pathname, response.ok); maybeHideLoadingOverlay(); return response; }