Improve chat session switching and history pagination, with most of the aggressive limits scoped to the VS Code webview where the freezes were observed. Session history loading and pagination: - Reduce the VS Code message page size to 30 records so switching sessions does not immediately hydrate large histories into the webview. - Keep manual Load older messages in VS Code fixed at 30 records per request instead of growing the request size over time. - Add a bounded VS Code initial-tail expansion path from 30 to 50, 80, and 120 records only when the initial page has no user-message turn boundary, preventing large final turns from rendering as an empty chat. - Lower the normal web message page size from 200 to 150 for a mild shared optimization without adopting the aggressive VS Code limits. - Make session pagination metadata reactive per session so ChatContainer receives cursor updates from materialization and reconnect paths without requiring a switch away and back. - Write pagination metadata before publishing newly materialized messages so the first render sees the correct has-more state. - Store cursor information from direct materialization and reconnect message fetches in the shared session prefetch metadata cache. VS Code cache and memory pressure reductions: - Use a shared per-directory session recency map so cache eviction is based on app-level recency instead of whichever useSync instance happened to run. - Limit VS Code warm session cache retention to 4 sessions and evict heavy inactive message caches after switching away from a large session. - Disable sidebar session prefetch in VS Code because warming extra sessions was increasing webview memory and GC pressure during navigation. - Remove dropdown background message prefetch so opening the switcher does not start additional session materialization work. - Drop cached session-message-record snapshots when evicting session data so stale derived records do not remain after the raw session cache is cleared. - Add bounded LRU caching for session message record snapshots, with much smaller VS Code limits and a VS Code cap that avoids caching snapshots above 30 messages. - Bound the turn-window model cache in VS Code and avoid caching turn models for sessions above the VS Code message-page size. Chat render-path reductions: - Reuse ChatContainer's already-materialized message records in plan detection instead of adding a second active-session message subscription. - Add a no-op guard when marking session plan availability so repeated detections do not create new Map references and fan out renders. - Add no-op guards for session switcher and dropdown open state updates to avoid unnecessary store updates and renders. - Convert several session-specific hooks to useSyncExternalStore with empty-session no-subscribe behavior so empty IDs do not subscribe to broad store updates. - Remount the chat viewport when the current session changes, isolating per-session viewport and list state. - Change the virtualized message-list fallback to render only a tail window when the virtualizer has not produced rows yet, instead of rendering an entire large history. VS Code layout and header improvements: - Remove the broad useSessions subscription from the VS Code layout header path and subscribe only to the active session title and initial-session existence. - Unmount the compact VS Code session sidebar when the user is in chat view instead of keeping the hidden session list mounted and subscribed. - Compute the latest assistant model and latest context-token usage in a single reverse scan of current-session messages instead of scanning the same list twice. - Remove switcher git-status warmup work so the switcher reads already-loaded branch labels without starting extra background git status requests. Markdown and file-reference safeguards: - Skip expensive syntax highlighting for very large code blocks, with a 200-line cap in VS Code and a softer 1200-line cap in web. - Add an LRU cap to file-reference stat lookups so the cache cannot grow without bound across many rendered messages. - Limit the number of file references annotated per render to 40 in VS Code and 200 in web to prevent large assistant outputs from spawning too many stat checks. - Clear file-link annotations when file-reference mode is disabled so stale attributes and handlers do not remain on previously annotated nodes. Assistant-message action and preview reductions: - Skip preview URL scanning on VS Code, mobile, and mini-chat surfaces so assistant text and tool output are not scanned where the preview action is unavailable. - Skip Save-as-Plan project lookup on VS Code, mini-chat, and mobile surfaces. - Hide Save-as-Plan and Start MultiRun assistant-message actions on VS Code, mini-chat, and mobile surfaces. - Resolve the current session directory on demand for assistant actions instead of subscribing each assistant message to the full session list. Tool and task rendering optimizations: - Prefer finalized task metadata summaries without fetching child-session messages when the summary is already present. - Avoid polling or final-fetching task child sessions once a final metadata summary is available. - Use VS Code-specific task child fetch limits of 30 records for initial, active, and idle fetches. - Parse diff stats by scanning patch text line-by-line instead of splitting large patches into arrays. - Count write-tool lines by scanning content instead of allocating a split array for large files. - Avoid trimming large patch strings just to test whether they contain content. - Memoize diff and write statistics so unchanged tool parts do not recalculate them on every render. VS Code bridge improvements: - Return JSON and text proxy responses through the VS Code bridge as bodyText instead of base64 so the webview avoids synchronous base64 decoding for common API responses. - Keep binary responses on the base64 path while making bodyBase64 optional in the bridge contract. - Strip content-length, content-encoding, and transfer-encoding headers from proxied responses because the bridge reconstructs the Response body. Validation: - bun run type-check - bun run lint - bun run vscode:build
221 lines
7.6 KiB
TypeScript
221 lines
7.6 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 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;
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
export async function handleProxyBridgeMessage(
|
|
message: BridgeMessageInput,
|
|
ctx: BridgeContext | undefined,
|
|
deps: ProxyRuntimeDeps,
|
|
): Promise<BridgeResponse | null> {
|
|
const { id, type, payload } = message;
|
|
|
|
switch (type) {
|
|
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()}`
|
|
: '/';
|
|
|
|
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(),
|
|
};
|
|
|
|
if (normalizedPath === '/event' || normalizedPath === '/global/event') {
|
|
if (!requestHeaders.Accept) {
|
|
requestHeaders.Accept = 'text/event-stream';
|
|
}
|
|
requestHeaders['Cache-Control'] = requestHeaders['Cache-Control'] || 'no-cache';
|
|
requestHeaders.Connection = requestHeaders.Connection || 'keep-alive';
|
|
}
|
|
|
|
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,
|
|
});
|
|
|
|
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,
|
|
};
|
|
return { id, type, success: true, data };
|
|
}
|
|
}
|
|
|
|
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(),
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(targetUrl, {
|
|
method: 'POST',
|
|
headers: requestHeaders,
|
|
body: typeof bodyText === 'string' ? bodyText : '',
|
|
signal: AbortSignal.timeout(45000),
|
|
});
|
|
|
|
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 };
|
|
}
|
|
}
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|