declare const acquireVsCodeApi: () => { postMessage: (message: unknown) => void; getState: () => unknown; setState: (state: unknown) => void; }; interface VSCodeAPI { postMessage: (message: unknown) => void; } let vscodeApi: VSCodeAPI | null = null; let noopWarned = false; const noopVSCodeApi: VSCodeAPI = { postMessage: (message) => { // acquireVsCodeApi() can return undefined in broken/non-standard webview slots // (Cursor after extension update, VSCodium, headless). Drop the message instead // of throwing TypeError: Cannot read properties of undefined (reading 'postMessage'). if (!noopWarned) { noopWarned = true; console.warn('[openchamber] VS Code API unavailable; dropping postMessage', message); } }, }; function getVSCodeAPI(): VSCodeAPI { if (!vscodeApi) { const acquired = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : undefined; vscodeApi = acquired ?? noopVSCodeApi; } return vscodeApi; } interface BridgeRequest { id: string; type: string; payload?: unknown; } interface BridgeResponse { id: string; type: string; success: boolean; data?: unknown; error?: string; } const pendingRequests = new Map void; reject: (reason: Error) => void; timeout?: ReturnType; onAbort?: () => void; }>(); let requestIdCounter = 0; window.addEventListener('message', (event: MessageEvent) => { const response = event.data; if (!response || typeof response.id !== 'string') return; const messageId = (response as BridgeResponse & { _msgId?: unknown })._msgId; if (typeof messageId === 'string' && messageId.length > 0) { getVSCodeAPI().postMessage({ type: 'bridge:ack', _msgId: messageId }); } const pending = pendingRequests.get(response.id); if (pending) { pendingRequests.delete(response.id); if (pending.timeout) { clearTimeout(pending.timeout); } if (pending.onAbort) { pending.onAbort(); } if (response.success) { pending.resolve(response.data); } else { pending.reject(new Error(response.error || 'Unknown error')); } } }); export function sendBridgeMessage(type: string, payload?: unknown): Promise { return sendBridgeMessageWithOptions(type, payload); } /** * Tells the extension something without waiting for an answer. * * Requests are tracked until a response arrives, so a message the extension * never replies to would leak a pending entry on every call. State the webview * pushes outward (editor comment threads following the composer's drafts) has * no answer to wait for, so it does not go through the request path at all. */ export function postBridgeNotification(type: string, payload: Payload): void { getVSCodeAPI().postMessage({ type, payload }); } export function sendBridgeMessageWithOptions( type: string, payload?: unknown, options?: { timeoutMs?: number; signal?: AbortSignal; onAbort?: (id: string) => void } ): Promise { return new Promise((resolve, reject) => { const id = `req_${++requestIdCounter}_${Date.now()}`; const request: BridgeRequest = { id, type, payload }; const pending: { resolve: (value: unknown) => void; reject: (reason: Error) => void; timeout?: ReturnType; onAbort?: () => void; } = { resolve: resolve as (value: unknown) => void, reject, }; if (options?.signal) { const abort = () => { if (!pendingRequests.has(id)) return; pendingRequests.delete(id); if (pending.timeout) { clearTimeout(pending.timeout); } options.onAbort?.(id); reject(new DOMException('Aborted', 'AbortError')); }; if (options.signal.aborted) { reject(new DOMException('Aborted', 'AbortError')); return; } options.signal.addEventListener('abort', abort, { once: true }); pending.onAbort = () => options.signal?.removeEventListener('abort', abort); } pendingRequests.set(id, pending); const timeoutMs = typeof options?.timeoutMs === 'number' ? options.timeoutMs : 30000; if (Number.isFinite(timeoutMs) && timeoutMs > 0) { pending.timeout = setTimeout(() => { if (pendingRequests.has(id)) { pendingRequests.delete(id); if (pending.onAbort) { pending.onAbort(); } reject(new Error(`Request ${type} timed out`)); } }, timeoutMs); } getVSCodeAPI().postMessage(request); }); } export type ProxiedApiResponse = { status: number; headers: Record; bodyBase64?: string; bodyText?: string; }; export async function proxyApiRequest(options: { method: string; path: string; headers?: Record; bodyBase64?: string; signal?: AbortSignal; }): Promise { // Do not impose a bridge-level timeout. Let the original fetch's AbortSignal // (or OpenCode server response timing) control the lifecycle. const { signal, ...payload } = options; return sendBridgeMessageWithOptions('api:proxy', payload, { timeoutMs: 0, signal, onAbort: (requestID) => getVSCodeAPI().postMessage({ id: `abort_${requestID}`, type: 'api:proxy:abort', payload: { requestID } }), }); } export async function proxySessionMessageRequest(options: { path: string; headers?: Record; bodyText: string; signal?: AbortSignal; }): Promise { // Keep parity with server-side direct forwarder: let extension host control timeout. const { signal, ...payload } = options; return sendBridgeMessageWithOptions('api:session:message', payload, { timeoutMs: 0, signal, onAbort: (requestID) => getVSCodeAPI().postMessage({ id: `abort_${requestID}`, type: 'api:proxy:abort', payload: { requestID } }), }); } export type ProxiedSseStartResponse = { status: number; headers: Record; streamId: string | null; error?: string; }; export async function startSseProxy(options: { path: string; headers?: Record; streamId?: string; }): Promise { return sendBridgeMessage('api:sse:start', options); } export async function stopSseProxy(options: { streamId: string }): Promise<{ stopped: boolean }> { return sendBridgeMessage<{ stopped: boolean }>('api:sse:stop', options); } export async function executeVSCodeCommand(command: string, args?: unknown[]): Promise<{ result?: unknown }> { return sendBridgeMessage<{ result?: unknown }>('vscode:command', { command, args }); } export async function openVSCodeExternalUrl(url: string): Promise { await sendBridgeMessage('vscode:openExternalUrl', { url }); } type CommandHandler = (payload: unknown) => void; const commandHandlers = new Map(); export function onCommand(command: string, handler: CommandHandler): () => void { commandHandlers.set(command, handler); return () => commandHandlers.delete(command); } window.addEventListener('message', (event: MessageEvent) => { const message = event.data; if (message?.type === 'command' && message.command) { const handler = commandHandlers.get(message.command); if (handler) { handler(message.payload); } } }); type ThemeChangePayload = | 'light' | 'dark' | { kind?: 'light' | 'dark' | 'high-contrast'; shikiThemes?: { light?: Record; dark?: Record } | null; }; type ThemeChangeHandler = (theme: ThemeChangePayload) => void; let themeChangeHandler: ThemeChangeHandler | null = null; export function onThemeChange(handler: ThemeChangeHandler): () => void { themeChangeHandler = handler; return () => { themeChangeHandler = null; }; } window.addEventListener('message', (event: MessageEvent) => { const message = event.data; if (message?.type === 'themeChange' && themeChangeHandler) { themeChangeHandler(message.theme); } });