Files
openchamber/packages/vscode/webview/main.tsx
T
Bohdan Triapitsyn 51c8d52ab5 perf(ui): improve VS Code chat session switching
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
2026-05-21 15:45:44 +03:00

1683 lines
67 KiB
TypeScript

import { createVSCodeAPIs } from './api';
import { onCommand, onThemeChange, proxyApiRequest, proxySessionMessageRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge';
import { vscodeStreamPerfCount, vscodeStreamPerfMeasure, vscodeStreamPerfObserve } from './api/streamPerf';
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
import {
buildVSCodeThemeFromPalette,
readVSCodeThemePalette,
type VSCodeThemeKind,
type VSCodeThemePayload,
} from '@openchamber/ui/lib/theme/vscode/adapter';
import type { VSCodeActiveEditorFile } from '@/sync/input-store';
type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected';
type PanelType = 'chat' | 'agentManager';
declare const __OPENCHAMBER_WEBVIEW_BUILD_TIME__: string;
declare global {
interface Window {
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
__VSCODE_CONFIG__?: {
apiUrl?: string;
workspaceFolder: string;
theme: string;
connectionStatus: string;
cliAvailable?: boolean;
extensionVersion?: string;
platform?: string;
arch?: string;
panelType?: PanelType;
viewMode?: 'sidebar' | 'editor';
initialSessionId?: string | null;
};
__OPENCHAMBER_VSCODE_THEME__?: VSCodeThemePayload['theme'];
__OPENCHAMBER_VSCODE_SHIKI_THEMES__?: { light?: Record<string, unknown>; dark?: Record<string, unknown> } | null;
__OPENCHAMBER_CONNECTION__?: { status: ConnectionStatus; error?: string; cliAvailable?: boolean };
__OPENCHAMBER_HOME__?: string;
__OPENCHAMBER_PANEL_TYPE__?: PanelType;
__OPENCHAMBER_VSCODE_WINDOW_FOCUSED__?: boolean;
}
}
console.log('[OpenChamber] VS Code webview starting...');
console.log('[OpenChamber] VS Code webview build:', __OPENCHAMBER_WEBVIEW_BUILD_TIME__);
console.log('[OpenChamber] Config:', window.__VSCODE_CONFIG__);
try {
if (window.localStorage.getItem('openchamber_stream_debug') === '1') {
console.log('[OpenChamber] Debug: openchamber_stream_debug=1');
}
} catch {
// ignore
}
window.__OPENCHAMBER_RUNTIME_APIS__ = createVSCodeAPIs();
const bootstrapConnectionStatus = () => {
const initialStatus = (window.__VSCODE_CONFIG__?.connectionStatus as ConnectionStatus | undefined) || 'connecting';
const cliAvailable = window.__VSCODE_CONFIG__?.cliAvailable ?? true;
window.__OPENCHAMBER_CONNECTION__ = { status: initialStatus, cliAvailable };
};
bootstrapConnectionStatus();
// Expose panel type globally for the VS Code app root to conditionally render.
window.__OPENCHAMBER_PANEL_TYPE__ = (window.__VSCODE_CONFIG__?.panelType as PanelType) || 'chat';
const handleConnectionMessage = (event: MessageEvent) => {
const msg = event.data;
if (msg?.type === 'connectionStatus') {
const payload: ConnectionStatus = msg.status;
const error: string | undefined = msg.error;
const prevCliAvailable = window.__OPENCHAMBER_CONNECTION__?.cliAvailable ?? true;
window.__OPENCHAMBER_CONNECTION__ = { status: payload, error, cliAvailable: prevCliAvailable };
window.dispatchEvent(new CustomEvent('openchamber:connection-status', { detail: { status: payload, error } }));
}
};
window.addEventListener('message', handleConnectionMessage);
window.addEventListener('openchamber:connection-status', () => {
maybeHideLoadingOverlay();
});
const fadeOutLoadingScreen = () => {
const loadingEl = document.getElementById('initial-loading');
if (!loadingEl) return;
loadingEl.classList.add('fade-out');
setTimeout(() => {
try {
loadingEl.remove();
} catch {
// ignore
}
}, 300);
};
const setLoadingStatusText = (text: string, variant: 'normal' | 'error' = 'normal') => {
const statusEl = document.getElementById('loading-status');
if (!statusEl) return;
statusEl.textContent = text;
if (variant === 'error') {
statusEl.classList.add('error-text');
} else {
statusEl.classList.remove('error-text');
}
};
const waitForUiMount = (timeoutMs = 8000): Promise<boolean> => {
if (typeof document === 'undefined') return Promise.resolve(false);
const root = document.getElementById('root');
if (!root) return Promise.resolve(false);
const hasContent = () => root.childNodes.length > 0;
if (hasContent()) return Promise.resolve(true);
return new Promise((resolve) => {
const observer = new MutationObserver(() => {
if (hasContent()) {
observer.disconnect();
clearTimeout(timeout);
resolve(true);
}
});
observer.observe(root, { childList: true, subtree: true });
const timeout = setTimeout(() => {
observer.disconnect();
resolve(false);
}, timeoutMs);
});
};
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';
if (!uiMounted) {
return;
}
if (connectionStatus === 'connected') {
if (bootstrapFailed) {
setLoadingStatusText('OpenCode connected, but initial data load failed.', 'error');
fadeOutLoadingScreen();
return;
}
if (bootstrapProvidersReady && bootstrapAgentsReady) {
fadeOutLoadingScreen();
return;
}
const providersText = bootstrapProvidersReady ? '✓ Providers' : '… Providers';
const agentsText = bootstrapAgentsReady ? '✓ Agents' : '… Agents';
setLoadingStatusText(`Loading data (${providersText}, ${agentsText})…`);
return;
}
if (connectionStatus === 'error') {
const error = window.__OPENCHAMBER_CONNECTION__?.error;
setLoadingStatusText(error || 'Connection error', 'error');
fadeOutLoadingScreen();
return;
}
if (connectionStatus === 'disconnected') {
setLoadingStatusText('Disconnected', 'error');
fadeOutLoadingScreen();
return;
}
setLoadingStatusText('Starting OpenCode API…');
};
const applyInitialTheme = (theme: { metadata?: { variant?: string }; colors?: { surface?: { background?: string; foreground?: string } } }) => {
if (typeof document === 'undefined' || !theme) return;
const variant = theme.metadata?.variant === 'dark' ? 'dark' : 'light';
const root = document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(variant);
const background = theme.colors?.surface?.background;
if (background) {
document.body.style.backgroundColor = background;
let meta = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement | null;
if (!meta) {
meta = document.createElement('meta');
meta.setAttribute('name', 'theme-color');
document.head.appendChild(meta);
}
meta.setAttribute('content', background);
}
};
const emitVSCodeTheme = (preferredKind?: VSCodeThemeKind) => {
const palette = readVSCodeThemePalette(preferredKind);
if (!palette) {
return;
}
const theme = buildVSCodeThemeFromPalette(palette);
window.__OPENCHAMBER_VSCODE_THEME__ = theme;
applyInitialTheme(theme);
window.dispatchEvent(new CustomEvent<VSCodeThemePayload>('openchamber:vscode-theme', {
detail: { theme, palette },
}));
};
emitVSCodeTheme(window.__VSCODE_CONFIG__?.theme as VSCodeThemeKind | undefined);
const scheduleThemeRecompute = (kind?: VSCodeThemeKind) => {
// VS Code updates webview CSS variables asynchronously around theme changes.
// Re-read on the next frames so we don't snapshot the old palette.
requestAnimationFrame(() => {
emitVSCodeTheme(kind);
requestAnimationFrame(() => emitVSCodeTheme(kind));
});
};
onThemeChange((payload) => {
const kind = (typeof payload === 'string'
? payload
: typeof payload === 'object' && payload
? payload.kind
: undefined) as VSCodeThemeKind | undefined;
if (typeof payload === 'object' && payload?.shikiThemes !== undefined) {
window.__OPENCHAMBER_VSCODE_SHIKI_THEMES__ = payload.shikiThemes;
window.dispatchEvent(
new CustomEvent('openchamber:vscode-shiki-themes', {
detail: { shikiThemes: payload.shikiThemes },
}),
);
}
scheduleThemeRecompute(kind);
});
const workspaceFolder = window.__VSCODE_CONFIG__?.workspaceFolder;
if (workspaceFolder) {
const normalizeWorkspacePath = (value: string) => {
const normalized = value
.replace(/\\/g, '/')
.replace(/^([a-z]):\//, (_, letter: string) => `${letter.toUpperCase()}:/`)
.replace(/^\/([a-z]):\//, (_, letter: string) => `/${letter.toUpperCase()}:/`);
if (normalized === '/') {
return '/';
}
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
};
const normalizedWorkspaceFolder = normalizeWorkspacePath(workspaceFolder);
window.__OPENCHAMBER_HOME__ = normalizedWorkspaceFolder;
try {
window.localStorage.setItem('lastDirectory', normalizedWorkspaceFolder);
window.localStorage.setItem('homeDirectory', normalizedWorkspaceFolder);
// VS Code defaults: show dotfiles, hide gitignored
if (window.localStorage.getItem('directoryTreeShowHidden') === null) {
window.localStorage.setItem('directoryTreeShowHidden', 'true');
}
if (window.localStorage.getItem('filesViewShowGitignored') === null) {
window.localStorage.setItem('filesViewShowGitignored', 'false');
}
} catch (error) {
console.warn('Failed to persist workspace folder', error);
}
}
const normalizeUrl = (input: string | URL) => {
try {
return typeof input === 'string' ? new URL(input, window.location.href) : new URL(input.toString(), window.location.href);
} catch {
return null;
}
};
const headersToRecord = (headers: HeadersInit | undefined): Record<string, string> => {
if (!headers) return {};
const normalized = headers instanceof Headers ? headers : new Headers(headers);
const result: Record<string, string> = {};
normalized.forEach((value, key) => {
result[key] = value;
});
return result;
};
const decodeBase64 = (value: string): Uint8Array => {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
};
const isNullBodyStatus = (status: number): boolean => status === 204 || status === 205 || status === 304;
const buildProxiedResponse = (
proxied: { status: number; headers: Record<string, string>; bodyBase64?: string; bodyText?: string }
): Response => {
if (isNullBodyStatus(proxied.status)) {
return new Response(null, { status: proxied.status, headers: proxied.headers });
}
if (typeof proxied.bodyText === 'string') {
return new Response(proxied.bodyText, { status: proxied.status, headers: proxied.headers });
}
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array();
return new Response(body, { status: proxied.status, headers: proxied.headers });
};
const encodeBase64 = (bytes: Uint8Array): string => {
const CHUNK = 0x8000;
let binary = '';
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
};
const extractBodyBase64 = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise<string | undefined> => {
if (method === 'GET' || method === 'HEAD') return undefined;
if (input instanceof Request) {
const cloned = input.clone();
const buffer = await cloned.arrayBuffer();
const bytes = new Uint8Array(buffer);
return bytes.length > 0 ? encodeBase64(bytes) : undefined;
}
const body = init?.body;
if (!body) return undefined;
if (typeof body === 'string') {
return encodeBase64(new TextEncoder().encode(body));
}
if (body instanceof URLSearchParams) {
return encodeBase64(new TextEncoder().encode(body.toString()));
}
if (body instanceof Blob) {
const buffer = await body.arrayBuffer();
const bytes = new Uint8Array(buffer);
return bytes.length > 0 ? encodeBase64(bytes) : undefined;
}
console.warn('[OpenChamber] Unsupported request body type for proxy request:', body);
return undefined;
};
const extractBodyText = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise<string> => {
if (method === 'GET' || method === 'HEAD') return '';
if (input instanceof Request) {
const cloned = input.clone();
return await cloned.text();
}
const body = init?.body;
if (!body) return '';
if (typeof body === 'string') {
return body;
}
if (body instanceof URLSearchParams) {
return body.toString();
}
if (body instanceof Blob) {
return await body.text();
}
console.warn('[OpenChamber] Unsupported request body type for direct session proxy:', body);
return '';
};
const isSseApiPath = (pathname: string) => pathname === '/api/event' || pathname === '/api/global/event';
const isSessionMessageApiPath = (pathname: string) => /^\/api\/session\/[^/]+\/message$/.test(pathname);
const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
const pathname = url.pathname;
const normalizedPathname = pathname !== '/' ? pathname.replace(/\/+$/, '') : pathname;
const method = ((init?.method || 'GET') as string).toUpperCase();
if (normalizedPathname === '/api/sessions/snapshot' && method === 'GET') {
const activity = await sendBridgeMessage<Record<string, { type: 'idle' | 'busy' | 'cooldown' }>>('api:session-activity:get')
.catch(() => ({}));
return new Response(
JSON.stringify({
statusSessions: {},
attentionSessions: {},
activitySessions: activity || {},
serverTime: Date.now(),
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
);
}
if (/^\/api\/sessions\/[^/]+\/(view|unview)$/.test(normalizedPathname) && method === 'POST') {
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (normalizedPathname === '/api/notifications/auto-accept' && method === 'POST') {
const bodyText = await extractBodyText(url, init, method);
const body = bodyText
? JSON.parse(bodyText) as { sessionId?: unknown; enabled?: unknown }
: {};
const result = await sendBridgeMessage<{ success?: boolean }>('api:notifications/auto-accept', body)
.catch(() => ({ success: false }));
return new Response(JSON.stringify(result), {
status: result?.success === false ? 400 : 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (/^\/api\/sessions\/[^/]+\/message-sent$/.test(normalizedPathname) && method === 'POST') {
const sessionId = normalizedPathname.split('/')[3] || '';
return new Response(
JSON.stringify({
success: true,
sessionId,
messageSent: true,
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
);
}
if (normalizedPathname === '/api/session-activity' && method === 'GET') {
const activity = await sendBridgeMessage<Record<string, { type: 'idle' | 'busy' | 'cooldown' }>>('api:session-activity:get')
.catch(() => ({}));
return new Response(JSON.stringify(activity || {}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (normalizedPathname === '/api/sessions/status' && method === 'GET') {
return new Response(
JSON.stringify({
sessions: {},
serverTime: Date.now(),
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
);
}
if (normalizedPathname === '/api/sessions/attention' && method === 'GET') {
return new Response(
JSON.stringify({
sessions: {},
serverTime: Date.now(),
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
);
}
if (/^\/api\/sessions\/[^/]+\/status$/.test(normalizedPathname) && method === 'GET') {
const sessionId = normalizedPathname.split('/')[3] || '';
return new Response(
JSON.stringify({
error: 'Session not found or no state available',
sessionId,
}),
{
status: 404,
headers: { 'Content-Type': 'application/json' },
},
);
}
if (/^\/api\/sessions\/[^/]+\/attention$/.test(normalizedPathname) && method === 'GET') {
const sessionId = normalizedPathname.split('/')[3] || '';
return new Response(
JSON.stringify({
error: 'Session not found or no attention state available',
sessionId,
}),
{
status: 404,
headers: { 'Content-Type': 'application/json' },
},
);
}
if (normalizedPathname === '/api/tts/status' && method === 'GET') {
return new Response(JSON.stringify({ available: false }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (normalizedPathname === '/api/tts/say/status' && method === 'GET') {
return new Response(JSON.stringify({ available: false, voices: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if ((pathname === '/api/tts/speak' || pathname === '/api/tts/say/speak') && method === 'POST') {
return new Response(JSON.stringify({ error: 'TTS endpoints are not available in VS Code runtime' }), {
status: 501,
headers: { 'Content-Type': 'application/json' },
});
}
// Health endpoints: reflect actual connection status
if (pathname === '/health' || pathname === '/api/health') {
const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status;
const isReady = connectionStatus === 'connected';
const cliAvailable = window.__OPENCHAMBER_CONNECTION__?.cliAvailable ?? true;
return new Response(JSON.stringify({
status: isReady ? 'ok' : 'connecting',
isOpenCodeReady: isReady,
cliAvailable,
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (pathname.startsWith('/api/fs/list')) {
const targetPath = url.searchParams.get('path') || '';
const respectGitignore = url.searchParams.get('respectGitignore') === 'true';
const data = await sendBridgeMessage('api:fs:list', { path: targetPath, respectGitignore });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname.startsWith('/api/fs/mkdir')) {
const body = init?.body ? JSON.parse(init.body as string) : {};
const data = await sendBridgeMessage('api:fs:mkdir', { path: body.path });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname.startsWith('/api/fs/home')) {
const data = await sendBridgeMessage('api:fs/home');
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname.startsWith('/api/vscode/pick-files')) {
const data = await sendBridgeMessage('api:files/pick');
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname.startsWith('/api/vscode/drop-files') && method === 'POST') {
const body = init?.body ? JSON.parse(init.body as string) : {};
const uris = Array.isArray((body as { uris?: unknown[] }).uris)
? (body as { uris: unknown[] }).uris.filter((value): value is string => typeof value === 'string')
: [];
const data = await sendBridgeMessage('api:files/drop', { uris });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname.startsWith('/api/vscode/save-image') && method === 'POST') {
const body = init?.body ? JSON.parse(init.body as string) : {};
const fileName = typeof (body as { fileName?: unknown }).fileName === 'string'
? (body as { fileName: string }).fileName
: undefined;
const dataUrl = typeof (body as { dataUrl?: unknown }).dataUrl === 'string'
? (body as { dataUrl: string }).dataUrl
: undefined;
const data = await sendBridgeMessage('api:files/save-image', { fileName, dataUrl });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname.startsWith('/api/vscode/save-markdown') && method === 'POST') {
const body = init?.body ? JSON.parse(init.body as string) : {};
const fileName = typeof (body as { fileName?: unknown }).fileName === 'string'
? (body as { fileName: string }).fileName
: undefined;
const content = typeof (body as { content?: unknown }).content === 'string'
? (body as { content: string }).content
: undefined;
const data = await sendBridgeMessage('api:files/save-markdown', { fileName, content });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname.startsWith('/api/config/agents/')) {
const encodedName = pathname.slice('/api/config/agents/'.length);
const name = decodeURIComponent(encodedName);
const verb = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {};
const queryDirectory = url.searchParams.get('directory') || undefined;
const headerDirectory = (() => {
const headers = init?.headers;
if (!headers) return undefined;
if (headers instanceof Headers) {
return headers.get('x-opencode-directory') || undefined;
}
if (Array.isArray(headers)) {
const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory');
return found?.[1] || undefined;
}
if (typeof headers === 'object') {
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') {
return value;
}
}
}
return undefined;
})();
const directory = queryDirectory || headerDirectory;
try {
const data = await sendBridgeMessage('api:config/agents', { method: verb, name, body, directory });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
if (pathname.startsWith('/api/config/commands/')) {
const encodedName = pathname.slice('/api/config/commands/'.length);
const name = decodeURIComponent(encodedName);
const verb = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {};
const queryDirectory = url.searchParams.get('directory') || undefined;
const headerDirectory = (() => {
const headers = init?.headers;
if (!headers) return undefined;
if (headers instanceof Headers) {
return headers.get('x-opencode-directory') || undefined;
}
if (Array.isArray(headers)) {
const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory');
return found?.[1] || undefined;
}
if (typeof headers === 'object') {
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') {
return value;
}
}
}
return undefined;
})();
const directory = queryDirectory || headerDirectory;
try {
const data = await sendBridgeMessage('api:config/commands', { method: verb, name, body, directory });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
if (pathname === '/api/config/mcp') {
const verb = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {};
const queryDirectory = url.searchParams.get('directory') || undefined;
const headerDirectory = (() => {
const headers = init?.headers;
if (!headers) return undefined;
if (headers instanceof Headers) {
return headers.get('x-opencode-directory') || undefined;
}
if (Array.isArray(headers)) {
const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory');
return found?.[1] || undefined;
}
if (typeof headers === 'object') {
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') {
return value;
}
}
}
return undefined;
})();
const directory = queryDirectory || headerDirectory;
try {
const data = await sendBridgeMessage('api:config/mcp', { method: verb, body, directory });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
if (pathname.startsWith('/api/config/mcp/')) {
const encodedName = pathname.slice('/api/config/mcp/'.length);
const name = decodeURIComponent(encodedName);
const verb = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {};
const queryDirectory = url.searchParams.get('directory') || undefined;
const headerDirectory = (() => {
const headers = init?.headers;
if (!headers) return undefined;
if (headers instanceof Headers) {
return headers.get('x-opencode-directory') || undefined;
}
if (Array.isArray(headers)) {
const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory');
return found?.[1] || undefined;
}
if (typeof headers === 'object') {
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') {
return value;
}
}
}
return undefined;
})();
const directory = queryDirectory || headerDirectory;
try {
const data = await sendBridgeMessage('api:config/mcp', { method: verb, name, body, directory });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
// Skills file operations: /api/config/skills/:name/files/:filePath
const skillsFilesMatch = pathname.match(/^\/api\/config\/skills\/([^/]+)\/files\/(.+)$/);
if (skillsFilesMatch) {
const name = decodeURIComponent(skillsFilesMatch[1]);
const filePath = decodeURIComponent(skillsFilesMatch[2]);
const verb = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {};
try {
const data = await sendBridgeMessage('api:config/skills/files', {
method: verb,
name,
filePath,
content: body.content
});
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
const skillsCatalogStatusFromPayload = (payload: unknown): number => {
if (!payload || typeof payload !== 'object') return 200;
const data = payload as { ok?: boolean; error?: { kind?: string } };
if (data.ok === false) {
const kind = data.error?.kind;
if (kind === 'conflicts') return 409;
if (kind === 'authRequired') return 401;
return 400;
}
return 200;
};
// Skills catalog: /api/config/skills/catalog
if (pathname === '/api/config/skills/catalog') {
const refresh = url.searchParams.get('refresh') === 'true';
try {
const data = await sendBridgeMessage('api:config/skills:catalog', { refresh });
return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ ok: false, error: { kind: 'unknown', message } }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
// Skills scan: /api/config/skills/scan
if (pathname === '/api/config/skills/scan') {
const body = init?.body ? JSON.parse(init.body as string) : {};
try {
const data = await sendBridgeMessage('api:config/skills:scan', body);
return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ ok: false, error: { kind: 'unknown', message } }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
// Skills install: /api/config/skills/install
if (pathname === '/api/config/skills/install') {
const body = init?.body ? JSON.parse(init.body as string) : {};
try {
const data = await sendBridgeMessage('api:config/skills:install', body);
return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ ok: false, error: { kind: 'unknown', message } }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
// Skills CRUD: /api/config/skills/:name or /api/config/skills
if (pathname === '/api/config/skills') {
try {
const data = await sendBridgeMessage('api:config/skills', { method: 'GET' });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
if (pathname.startsWith('/api/config/skills/')) {
const encodedName = pathname.slice('/api/config/skills/'.length);
const name = decodeURIComponent(encodedName);
const verb = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {};
try {
const data = await sendBridgeMessage('api:config/skills', { method: verb, name, body });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
if (pathname.startsWith('/api/config/settings')) {
if ((init?.method || 'GET').toUpperCase() === 'GET') {
const settings = await sendBridgeMessage('api:config/settings:get');
return new Response(JSON.stringify(settings), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
const body = init?.body ? JSON.parse(init.body as string) : {};
const updated = await sendBridgeMessage('api:config/settings:save', body);
return new Response(JSON.stringify(updated), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (normalizedPathname === '/api/behavior/agents-md') {
if (method === 'GET') {
const data = await sendBridgeMessage('api:behavior/agents-md:get');
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (method === 'PUT') {
const body = init?.body ? JSON.parse(init.body as string) : {};
const data = await sendBridgeMessage('api:behavior/agents-md:save', body);
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
}
if (pathname === '/api/magic-prompts') {
if (method === 'GET') {
const data = await sendBridgeMessage('api:magic-prompts:get');
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (method === 'DELETE') {
const data = await sendBridgeMessage('api:magic-prompts:reset-all');
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
}
if (pathname.startsWith('/api/magic-prompts/')) {
const id = decodeURIComponent(pathname.slice('/api/magic-prompts/'.length));
if (method === 'PUT') {
const body = init?.body ? JSON.parse(init.body as string) : {};
const data = await sendBridgeMessage('api:magic-prompts:save', { id, text: body?.text });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (method === 'DELETE') {
const data = await sendBridgeMessage('api:magic-prompts:reset', { id });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
}
if (pathname === '/api/config/opencode-resolution' && method === 'GET') {
try {
const data = await sendBridgeMessage('api:config/opencode-resolution:get');
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
if (pathname.startsWith('/api/config/reload')) {
await sendBridgeMessage('api:config/reload');
return new Response(JSON.stringify({ restarted: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname.startsWith('/api/openchamber/models-metadata')) {
try {
const data = await sendBridgeMessage('api:models/metadata');
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
console.warn('[OpenChamber] Failed to fetch models metadata via bridge, returning empty set:', error);
return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
}
if (pathname === '/api/zen/models' && method === 'GET') {
try {
const data = await sendBridgeMessage('api:zen:models');
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message, models: [] }), { status: 502, headers: { 'Content-Type': 'application/json' } });
}
}
if (pathname.startsWith('/api/openchamber/update-check')) {
try {
const currentVersion = url.searchParams.get('currentVersion') || undefined;
const instanceMode = url.searchParams.get('instanceMode') || 'local';
const deviceClass = url.searchParams.get('deviceClass') || 'desktop';
const platform = url.searchParams.get('platform') || window.__VSCODE_CONFIG__?.platform || undefined;
const arch = url.searchParams.get('arch') || window.__VSCODE_CONFIG__?.arch || undefined;
const reportUsageRaw = (url.searchParams.get('reportUsage') || 'true').toLowerCase();
const reportUsage = !(reportUsageRaw === 'false' || reportUsageRaw === '0' || reportUsageRaw === 'no');
const data = await sendBridgeMessage('api:openchamber:update-check', {
currentVersion,
instanceMode,
deviceClass,
platform,
arch,
reportUsage,
});
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ available: false, error: message }), { status: 502, headers: { 'Content-Type': 'application/json' } });
}
}
if (pathname === '/auth/session') {
// VS Code host is trusted; mirror web server shape to keep UI logic happy
const body = {
authenticated: true,
requireSetup: false,
authenticatedAt: Date.now(),
};
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname.startsWith('/api/opencode/directory')) {
const body = init?.body ? JSON.parse(init.body as string) : {};
const result = await sendBridgeMessage('api:opencode/directory', { path: body.path });
return new Response(JSON.stringify(result), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/api/quota/providers') {
try {
const data = await sendBridgeMessage('api:quota:providers');
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
const quotaMatch = pathname.match(/^\/api\/quota\/([^/]+)$/);
if (quotaMatch && (init?.method || 'GET').toUpperCase() === 'GET') {
const providerId = decodeURIComponent(quotaMatch[1]);
try {
const data = await sendBridgeMessage('api:quota:get', { providerId });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
// Handle provider auth deletion: DELETE /api/provider/:providerId/auth
const providerAuthMatch = pathname.match(/^\/api\/provider\/([^/]+)\/auth$/);
if (providerAuthMatch && (init?.method || 'GET').toUpperCase() === 'DELETE') {
const providerId = decodeURIComponent(providerAuthMatch[1]);
const scope = url.searchParams.get('scope') || 'auth';
const queryDirectory = url.searchParams.get('directory') || undefined;
try {
const data = await sendBridgeMessage('api:provider/auth:delete', { providerId, scope, directory: queryDirectory });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
// Handle provider source lookup: GET /api/provider/:providerId/source
const providerSourceMatch = pathname.match(/^\/api\/provider\/([^/]+)\/source$/);
if (providerSourceMatch && (init?.method || 'GET').toUpperCase() === 'GET') {
const providerId = decodeURIComponent(providerSourceMatch[1]);
const queryDirectory = url.searchParams.get('directory') || undefined;
try {
const data = await sendBridgeMessage('api:provider/source:get', { providerId, directory: queryDirectory });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
return null;
};
const originalFetch = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const targetUrl = typeof input === 'string' || input instanceof URL ? normalizeUrl(input) : normalizeUrl((input as Request).url);
const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
const pathname = targetUrl?.pathname || '';
const normalizedPathname = pathname.replace(/\/+/, '/');
if (targetUrl && normalizedPathname === '/health') {
const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status;
const isReady = connectionStatus === 'connected';
const cliAvailable = window.__OPENCHAMBER_CONNECTION__?.cliAvailable ?? true;
return new Response(JSON.stringify({
status: isReady ? 'ok' : 'connecting',
isOpenCodeReady: isReady,
cliAvailable,
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (targetUrl && targetUrl.pathname.startsWith('/api/')) {
const localResponse = await handleLocalApiRequest(targetUrl, init);
if (localResponse) {
recordBootstrapFetch(targetUrl.pathname, localResponse.ok);
maybeHideLoadingOverlay();
return localResponse;
}
const suffixPath = `${targetUrl.pathname.replace(/^\/api/, '')}${targetUrl.search}`;
const headersFromRequest = input instanceof Request ? headersToRecord(input.headers) : {};
const headersFromInit = headersToRecord(init?.headers);
const headers = { ...headersFromRequest, ...headersFromInit };
if (isSseApiPath(targetUrl.pathname)) {
const start = await vscodeStreamPerfMeasure('vscode.webview.sse_start_ms', () => startSseProxy({ path: suffixPath, headers }));
if (!start.streamId) {
return new Response(null, { status: start.status || 503, headers: start.headers || {} });
}
const streamId = start.streamId;
const signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined;
const encoder = new TextEncoder();
let unsubscribe: (() => void) | null = null;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const onMessage = (event: MessageEvent) => {
const msg = event.data as { type?: string; streamId?: string; chunk?: string; error?: string };
if (!msg || msg.streamId !== streamId) return;
if (msg.type === 'api:sse:chunk' && typeof msg.chunk === 'string') {
vscodeStreamPerfCount('vscode.webview.sse_chunk');
vscodeStreamPerfObserve('vscode.webview.sse_chunk_bytes', msg.chunk.length);
controller.enqueue(encoder.encode(msg.chunk));
return;
}
if (msg.type === 'api:sse:end') {
vscodeStreamPerfCount('vscode.webview.sse_end');
unsubscribe?.();
unsubscribe = null;
if (typeof msg.error === 'string' && msg.error.length > 0) {
controller.error(new Error(msg.error));
} else {
controller.close();
}
void stopSseProxy({ streamId }).catch(() => {});
}
};
window.addEventListener('message', onMessage);
unsubscribe = () => window.removeEventListener('message', onMessage);
if (signal) {
const onAbort = () => {
unsubscribe?.();
unsubscribe = null;
try {
controller.error(new DOMException('Aborted', 'AbortError'));
} catch {
controller.close();
}
void stopSseProxy({ streamId }).catch(() => {});
};
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
},
cancel() {
unsubscribe?.();
unsubscribe = null;
void stopSseProxy({ streamId }).catch(() => {});
},
});
return new Response(stream, { status: start.status || 200, headers: start.headers || { 'content-type': 'text/event-stream' } });
}
if (method === 'POST' && isSessionMessageApiPath(targetUrl.pathname)) {
const bodyText = await extractBodyText(input, init, method);
const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText });
const response = buildProxiedResponse(proxied);
recordBootstrapFetch(targetUrl.pathname, response.ok);
maybeHideLoadingOverlay();
return response;
}
const bodyBase64 = await extractBodyBase64(input, init, method);
const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64 });
const response = buildProxiedResponse(proxied);
recordBootstrapFetch(targetUrl.pathname, response.ok);
maybeHideLoadingOverlay();
return response;
}
if (targetUrl && targetUrl.hostname.includes('models.dev')) {
try {
const data = await sendBridgeMessage('api:models/metadata');
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
console.warn('[OpenChamber] models.dev request failed via bridge, returning empty metadata:', error);
return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
}
return originalFetch(input as RequestInfo, init);
};
onCommand('addContextSelection', (payload) => {
const { filePath, filename, text } = payload as { filePath?: unknown; filename?: unknown; text?: unknown };
if (typeof filePath !== 'string' || typeof filename !== 'string' || typeof text !== 'string') {
return;
}
const trimmedPath = filePath.trim();
const trimmedFilename = filename.trim();
if (!trimmedPath || !trimmedFilename || !text.trim()) {
return;
}
import('@/sync/input-store').then(({ useInputStore }) => {
const file = new File([new Blob([text], { type: 'text/plain' })], trimmedFilename, { type: 'text/plain' });
void useInputStore.getState().addVSCodeSelectionAttachment(trimmedPath, file);
});
});
onCommand('addFileMentions', (payload) => {
const rawPaths = Array.isArray((payload as { paths?: unknown[] })?.paths)
? (payload as { paths: unknown[] }).paths
: [];
const paths = rawPaths
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (paths.length === 0) {
return;
}
const mentionText = paths.map((relativePath) => `@${relativePath}`).join(' ');
import('@/sync/input-store').then(({ useInputStore }) => {
useInputStore.getState().setPendingInputText(mentionText, 'append-inline');
});
});
onCommand('addFileAttachments', (payload) => {
const rawFiles = Array.isArray((payload as { files?: unknown[] })?.files)
? (payload as { files: unknown[] }).files
: [];
const files = rawFiles
.map((entry) => {
const record = entry as { filePath?: unknown; fileName?: unknown; fileSize?: unknown };
const filePath = typeof record.filePath === 'string' ? record.filePath.trim() : '';
const fileName = typeof record.fileName === 'string' ? record.fileName.trim() : '';
const fileSize = typeof record.fileSize === 'number' && Number.isFinite(record.fileSize) ? record.fileSize : null;
return filePath && fileName ? { filePath, fileName, fileSize } : null;
})
.filter((entry): entry is { filePath: string; fileName: string; fileSize: number | null } => entry !== null);
if (files.length === 0) {
return;
}
import('@/sync/input-store').then(({ useInputStore }) => {
const inputStore = useInputStore.getState();
for (const file of files) {
inputStore.addVSCodeFileAttachment(file.filePath, file.fileName, file.fileSize);
}
});
});
// Listen for createSessionWithPrompt command from extension (Explain, Improve Code)
onCommand('createSessionWithPrompt', (payload) => {
const { prompt } = payload as { prompt: string };
Promise.all([
import('@/sync/session-ui-store'),
import('@/stores/useConfigStore'),
import('@/sync/input-store'),
]).then(([{ useSessionUIStore }, { useConfigStore }, { useInputStore }]) => {
const sessionStore = useSessionUIStore.getState();
const configStore = useConfigStore.getState();
// Get current provider/model/agent configuration
const { currentProviderId, currentModelId, currentAgentName } = configStore;
if (currentProviderId && currentModelId) {
if (!sessionStore.currentSessionId) {
sessionStore.openNewSessionDraft();
}
// Send the message - this will create the session from the draft and send
sessionStore.sendMessage(
prompt,
currentProviderId,
currentModelId,
currentAgentName ?? undefined,
undefined, // attachments
undefined, // agentMentionName
undefined // additionalParts
).catch((error: unknown) => {
console.error('[OpenChamber] Failed to send prompt:', error);
});
} else {
// If no provider/model configured, just set the text and let user send manually
useInputStore.getState().setPendingInputText(prompt);
}
});
});
// Listen for newSession command from extension title bar button
onCommand('newSession', () => {
import('@/sync/session-ui-store').then(({ useSessionUIStore }) => {
useSessionUIStore.getState().openNewSessionDraft();
});
// Also dispatch event to navigate to chat view in VSCodeLayout
window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'chat' } }));
});
// Listen for showSettings command from extension title bar button
onCommand('showSettings', () => {
// Dispatch event to navigate to settings view in VSCodeLayout
window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'settings' } }));
});
const getNotificationClaimKey = (payload: { title?: unknown; body?: unknown; sessionId?: unknown; tag?: unknown } | undefined): string => {
const tag = typeof payload?.tag === 'string' ? payload.tag.trim() : '';
if (tag) return tag;
return [payload?.sessionId, payload?.title, payload?.body]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
.join('|');
};
const claimOpenChamberNotification = async (payload: { title?: unknown; body?: unknown; sessionId?: unknown; tag?: unknown } | undefined): Promise<boolean> => {
const key = getNotificationClaimKey(payload);
if (!key) return true;
try {
const result = await sendBridgeMessage<{ claimed?: boolean }>('api:notifications:claim', { key });
return result?.claimed === true;
} catch {
return true;
}
};
const showOpenChamberNotification = (payload: { title?: unknown; body?: unknown; sessionId?: unknown; tag?: unknown; requireHidden?: unknown } | undefined) => {
if (typeof Notification === 'undefined') {
return false;
}
const show = async () => {
const isVSCodeWindowFocused = window.__OPENCHAMBER_VSCODE_WINDOW_FOCUSED__ ?? document.hasFocus();
if (payload?.requireHidden === true && isVSCodeWindowFocused) {
return false;
}
if (Notification.permission !== 'granted') {
return false;
}
const title = typeof payload?.title === 'string' && payload.title.trim().length > 0
? payload.title.trim()
: 'OpenChamber';
const body = typeof payload?.body === 'string' ? payload.body : '';
const sessionId = typeof payload?.sessionId === 'string' && payload.sessionId.trim().length > 0
? payload.sessionId.trim()
: '';
if (!await claimOpenChamberNotification({ ...payload, title, body, sessionId })) {
return false;
}
const notification = new Notification(title, { body });
notification.onclick = () => {
if (sessionId) {
import('@/sync/session-ui-store').then(({ useSessionUIStore }) => {
useSessionUIStore.getState().setCurrentSession(sessionId);
});
}
window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'chat' } }));
};
return true;
};
if (Notification.permission === 'default') {
void Notification.requestPermission().then((permission) => {
if (permission === 'granted') {
void show();
}
});
return true;
}
void show();
return true;
};
onCommand('showNotification', (payload) => {
showOpenChamberNotification(payload as { title?: unknown; body?: unknown; sessionId?: unknown; requireHidden?: unknown } | undefined);
});
onCommand('windowFocusChanged', (payload) => {
if (typeof payload === 'object' && payload && typeof (payload as { focused?: unknown }).focused === 'boolean') {
window.__OPENCHAMBER_VSCODE_WINDOW_FOCUSED__ = (payload as { focused: boolean }).focused;
}
});
const readyNotificationCooldowns = new Map<string, number>();
const READY_NOTIFICATION_COOLDOWN_MS = 5000;
const DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH = 250;
let notificationSettingsSyncPromise: Promise<void> | null = null;
const getPayloadString = (value: unknown): string => typeof value === 'string' ? value.trim() : '';
const normalizeNotificationPlainText = (text: string): string => text
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`]*)`/g, '$1')
.replace(/^[\t ]*[-*+]\s+/gm, '')
.replace(/^#{1,6}\s+/gm, '')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/__(.*?)__/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/_(.*?)_/g, '$1')
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
.replace(/\s*\n\s*/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const truncateNotificationText = (text: string, maxLength: number): string => (
text.length <= maxLength ? text : `${text.slice(0, maxLength)}...`
);
const resolvePositiveNotificationNumber = (value: unknown, fallback: number): number => (
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
);
const ensureNotificationSettingsSynced = async () => {
if (!notificationSettingsSyncPromise) {
notificationSettingsSyncPromise = import('@/lib/persistence')
.then(({ syncDesktopSettings }) => syncDesktopSettings())
.catch((error) => {
console.warn('[OpenChamber] Failed to sync notification settings:', error);
});
}
await notificationSettingsSyncPromise;
};
const prepareNotificationLastMessage = (
message: string,
settings: { maxLastMessageLength: number },
): string => {
const maxLength = resolvePositiveNotificationNumber(settings.maxLastMessageLength, DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH);
return truncateNotificationText(normalizeNotificationPlainText(message), maxLength);
};
const resolveTemplate = (template: string, variables: Record<string, string>): string => (
template.replace(/\{(\w+)\}/g, (_match, key: string) => variables[key] ?? '')
);
const shouldApplyTemplateMessage = (template: string, resolved: string, variables: Record<string, string>) => {
if (!resolved) return false;
if (template.includes('{last_message}')) {
return variables.last_message.trim().length > 0;
}
return true;
};
const formatNotificationLabel = (raw: string, fallback: string): string => {
if (!raw) return fallback;
return raw.split(/[-_\s]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' ');
};
const extractNotificationTextFromParts = (parts: unknown): string => {
if (!Array.isArray(parts)) return '';
return parts
.map((part) => {
if (!part || typeof part !== 'object') return '';
const entry = part as { type?: unknown; text?: unknown; content?: unknown };
if (entry.type === 'text' || typeof entry.text === 'string' || typeof entry.content === 'string') {
return typeof entry.text === 'string' ? entry.text : typeof entry.content === 'string' ? entry.content : '';
}
return '';
})
.filter(Boolean)
.join('\n')
.trim();
};
const extractNotificationLastMessage = (payload: Record<string, unknown>): string => {
const properties = (payload.properties ?? payload) as Record<string, unknown>;
const info = properties.info as Record<string, unknown> | undefined;
if (!info) return '';
return extractNotificationTextFromParts(info.parts ?? properties.parts) || extractNotificationTextFromParts(info.content);
};
const fetchLastAssistantMessageText = async (sessionId: string, messageId?: string): Promise<string> => {
if (!sessionId) return '';
try {
const response = await fetch(`/api/session/${encodeURIComponent(sessionId)}/message?limit=5`, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(3000),
});
if (!response.ok) return '';
const messages = await response.json().catch(() => null) as unknown;
if (!Array.isArray(messages)) return '';
let target = messageId
? messages.find((message) => {
const info = message && typeof message === 'object'
? (message as { info?: { id?: unknown; role?: unknown } }).info
: undefined;
return info?.id === messageId && info?.role === 'assistant';
})
: null;
if (!target) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
const info = message && typeof message === 'object'
? (message as { info?: { role?: unknown; finish?: unknown } }).info
: undefined;
if (info?.role === 'assistant' && info?.finish === 'stop') {
target = message;
break;
}
}
}
if (!target || typeof target !== 'object') return '';
const message = target as { parts?: unknown; content?: unknown; info?: { parts?: unknown; content?: unknown } };
return extractNotificationTextFromParts(message.parts ?? message.info?.parts)
|| extractNotificationTextFromParts(message.content ?? message.info?.content);
} catch {
return '';
}
};
const getNotificationTemplate = (
settings: { notificationTemplates?: Record<string, { title?: string; message?: string }> },
key: 'completion' | 'error' | 'question',
fallback: { title: string; message: string },
) => {
const candidate = settings.notificationTemplates?.[key];
return {
title: typeof candidate?.title === 'string' ? candidate.title : fallback.title,
message: typeof candidate?.message === 'string' ? candidate.message : fallback.message,
};
};
const buildNotificationVariables = (payload: Record<string, unknown>, sessionId: string, lastMessage: string): Record<string, string> => {
const properties = (payload.properties ?? payload) as Record<string, unknown>;
const info = properties.info as Record<string, unknown> | undefined;
const pathInfo = info?.path as { root?: unknown; cwd?: unknown } | undefined;
const worktree = getPayloadString(pathInfo?.root ?? pathInfo?.cwd);
const modelId = getPayloadString(info?.modelID ?? info?.modelId ?? (info?.model as { modelID?: unknown } | undefined)?.modelID);
return {
project_name: worktree.split(/[\\/]/).filter(Boolean).pop() || '',
worktree,
branch: '',
session_name: getPayloadString(properties.sessionTitle ?? (properties.session as { title?: unknown } | undefined)?.title ?? info?.sessionTitle),
agent_name: formatNotificationLabel(getPayloadString(info?.agent ?? info?.mode), 'Agent'),
model_name: formatNotificationLabel(modelId, 'Assistant'),
last_message: lastMessage,
session_id: sessionId,
};
};
const getNotificationSessionId = (payload: Record<string, unknown>): string => {
const properties = (payload.properties ?? payload) as Record<string, unknown>;
const info = properties.info as Record<string, unknown> | undefined;
return getPayloadString(info?.sessionID ?? info?.sessionId ?? properties.sessionID ?? properties.sessionId ?? properties.session);
};
window.addEventListener('openchamber:vscode-notification-event', (event) => {
const detail = (event as CustomEvent<{ payload?: unknown }>).detail;
const payload = detail?.payload;
if (!payload || typeof payload !== 'object') {
return;
}
const record = payload as Record<string, unknown>;
const type = getPayloadString(record.type);
const properties = (record.properties ?? record) as Record<string, unknown>;
const info = properties.info as Record<string, unknown> | undefined;
const sessionId = getNotificationSessionId(record);
if (!sessionId) {
return;
}
Promise.all([
import('@/stores/useUIStore'),
import('@/stores/permissionStore'),
]).then(async ([{ useUIStore }, { usePermissionStore }]) => {
const localSettings = useUIStore.getState();
await ensureNotificationSettingsSynced();
const syncedSettings = useUIStore.getState();
const settings = {
...syncedSettings,
nativeNotificationsEnabled: localSettings.nativeNotificationsEnabled,
notificationMode: localSettings.notificationMode,
notifyOnCompletion: localSettings.notifyOnCompletion,
notifyOnError: localSettings.notifyOnError,
notifyOnQuestion: localSettings.notifyOnQuestion,
notificationTemplates: localSettings.notificationTemplates,
summarizeLastMessage: localSettings.summarizeLastMessage,
summaryThreshold: localSettings.summaryThreshold,
summaryLength: localSettings.summaryLength,
maxLastMessageLength: localSettings.maxLastMessageLength,
};
if (!settings.nativeNotificationsEnabled) {
return;
}
const requireHidden = settings.notificationMode !== 'always';
const messageId = getPayloadString(info?.id);
const rawLastMessage = extractNotificationLastMessage(record) || await fetchLastAssistantMessageText(sessionId, messageId);
const lastMessage = prepareNotificationLastMessage(
rawLastMessage,
settings,
);
const variables = buildNotificationVariables(record, sessionId, lastMessage);
if (type === 'message.updated' && getPayloadString(info?.role) === 'assistant') {
const finish = getPayloadString(info?.finish);
if (finish === 'stop') {
if (!settings.notifyOnCompletion) return;
const now = Date.now();
const lastAt = readyNotificationCooldowns.get(sessionId) ?? 0;
if (now - lastAt < READY_NOTIFICATION_COOLDOWN_MS) return;
readyNotificationCooldowns.set(sessionId, now);
const template = getNotificationTemplate(settings, 'completion', { title: '{agent_name} is ready', message: '{model_name} completed the task' });
const title = resolveTemplate(template.title, variables) || 'Agent is ready';
const body = resolveTemplate(template.message, variables);
showOpenChamberNotification({
title,
body: shouldApplyTemplateMessage(template.message, body, variables) ? body : `${variables.model_name} completed the task`,
sessionId,
requireHidden,
});
return;
}
if (finish === 'error') {
if (!settings.notifyOnError) return;
const template = getNotificationTemplate(settings, 'error', { title: 'Tool error', message: '{last_message}' });
const title = resolveTemplate(template.title, variables) || 'Tool error';
const body = resolveTemplate(template.message, variables);
showOpenChamberNotification({
title,
body: shouldApplyTemplateMessage(template.message, body, variables) ? body : 'An error occurred',
sessionId,
requireHidden,
});
}
}
if (type === 'question.asked') {
if (!settings.notifyOnQuestion) return;
const questions = Array.isArray(properties.questions) ? properties.questions : [];
const firstQuestion = questions[0] as Record<string, unknown> | undefined;
const header = getPayloadString(firstQuestion?.header);
const questionText = getPayloadString(firstQuestion?.question);
const questionVariables = { ...variables, last_message: questionText || header };
const template = getNotificationTemplate(settings, 'question', { title: 'Input needed', message: '{last_message}' });
const title = resolveTemplate(template.title, questionVariables) || (/plan\s*mode/i.test(header) ? 'Switch to plan mode' : /build\s*agent/i.test(header) ? 'Switch to build mode' : header || 'Input needed');
const body = resolveTemplate(template.message, questionVariables);
showOpenChamberNotification({
title,
body: shouldApplyTemplateMessage(template.message, body, questionVariables) ? body : questionText || 'Agent is waiting for your response',
sessionId,
requireHidden,
});
return;
}
if (type === 'permission.asked') {
if (!settings.notifyOnQuestion) return;
if (usePermissionStore.getState().isSessionAutoAccepting(sessionId)) return;
const permission = getPayloadString(properties.permission);
const sessionTitle = getPayloadString(properties.sessionTitle);
const fallbackMessage = sessionTitle || permission || 'Agent is waiting for your approval';
const permissionVariables = { ...variables, last_message: fallbackMessage };
const template = getNotificationTemplate(settings, 'question', { title: 'Permission required', message: '{last_message}' });
const title = resolveTemplate(template.title, permissionVariables) || 'Permission required';
const body = resolveTemplate(template.message, permissionVariables);
showOpenChamberNotification({
title,
body: shouldApplyTemplateMessage(template.message, body, permissionVariables) ? body : fallbackMessage,
sessionId,
requireHidden,
});
}
});
});
// Listen for settings sync command from extension (broadcast to all VS Code webviews)
onCommand('settingsSynced', () => {
import('@openchamber/ui/lib/persistence').then(({ syncDesktopSettings }) => {
void syncDesktopSettings();
});
});
// Listen for active editor file changes from the extension
onCommand('activeEditorFile', (payload) => {
import('@/sync/input-store').then(({ useInputStore }) => {
useInputStore.getState().setActiveEditorFile((payload as VSCodeActiveEditorFile | null) ?? null);
});
});
import('@openchamber/ui/apps/renderVSCodeApp')
.then(async ({ renderVSCodeApp }) => {
renderVSCodeApp(window.__OPENCHAMBER_RUNTIME_APIS__ ?? createVSCodeAPIs());
await waitForUiMount();
uiMounted = true;
maybeHideLoadingOverlay();
})
.catch((error) => {
console.error('[OpenChamber] Failed to bootstrap UI:', error);
// If the UI bundle fails to load, remove the overlay so the user at least sees errors in the root.
uiMounted = true;
fadeOutLoadingScreen();
});