A comment written with no session tab open opens one, and that panel knows its directory long before it has loaded the session list and selected its session. The webview filed the draft on the first snapshot that had a directory, with the session key falling back to "draft" because no session was current yet. The panel's composer reads the session's key, so the chip never appeared, while the editor thread saw the draft in the store snapshot and reported it attached. A session panel now stamps its session on every comment it delivers, and the webview waits until it actually shows that session before filing. The sidebar files on its current session or an open new-session draft, and no longer falls back to "draft" merely because nothing is selected yet. The resolver is a pure module with tests. Claude-Session: https://claude.ai/code/session_01VqV56Hez25hTxXH4ipJfzH
2067 lines
86 KiB
TypeScript
2067 lines
86 KiB
TypeScript
import { createVSCodeAPIs } from './api';
|
|
import { createRemovalTombstones } from './inlineCommentRemovals';
|
|
import { resolveCommentTarget } from './inlineCommentTarget';
|
|
import { onCommand, onThemeChange, postBridgeNotification, proxyApiRequest, proxySessionMessageRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge';
|
|
import { vscodeStreamPerfCount, vscodeStreamPerfMeasure, vscodeStreamPerfObserve } from './api/streamPerf';
|
|
import { extractBodyBase64, extractBodyText, extractJsonBody, hasInitBody } from './requestBodyTransport';
|
|
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
|
import { opencodeClient } from '@openchamber/ui/lib/opencode/client';
|
|
import { sanitizeHeadersForBrowser } from '@openchamber/ui/lib/runtime-fetch';
|
|
import {
|
|
buildVSCodeThemeFromPalette,
|
|
readVSCodeThemePalette,
|
|
type VSCodeThemeKind,
|
|
type VSCodeThemePayload,
|
|
} from '@openchamber/ui/lib/theme/vscode/adapter';
|
|
import { getBootstrapMessages, readStoredLocaleForBootstrap } from '@openchamber/ui/lib/i18n';
|
|
import type { VSCodeActiveEditorFile } from '@/sync/input-store';
|
|
import { usePermissionStore } from '@openchamber/ui/stores/permissionStore';
|
|
import { processVSCodePermissionAutoAccept } from '@openchamber/ui/sync/vscode-permission-auto-accept';
|
|
import type { PermissionRequest } from '@opencode-ai/sdk/v2/client';
|
|
import { focusChatInput } from '@openchamber/ui/components/chat/composer/editor/dom';
|
|
|
|
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;
|
|
workspaceFolders?: Array<{ name: string; path: 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 bootstrapLocale = readStoredLocaleForBootstrap();
|
|
const bootstrapMessages = getBootstrapMessages(bootstrapLocale);
|
|
|
|
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;
|
|
|
|
const maybeHideLoadingOverlay = () => {
|
|
const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status ?? 'connecting';
|
|
|
|
if (!uiMounted) {
|
|
return;
|
|
}
|
|
|
|
if (connectionStatus === 'connected') {
|
|
// 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;
|
|
}
|
|
|
|
if (connectionStatus === 'error') {
|
|
const error = window.__OPENCHAMBER_CONNECTION__?.error;
|
|
setLoadingStatusText(error || bootstrapMessages.connectionError, 'error');
|
|
fadeOutLoadingScreen();
|
|
return;
|
|
}
|
|
|
|
if (connectionStatus === 'disconnected') {
|
|
setLoadingStatusText(bootstrapMessages.disconnected, 'error');
|
|
fadeOutLoadingScreen();
|
|
return;
|
|
}
|
|
|
|
// Connecting — no jargon; the animated logo conveys progress.
|
|
setLoadingStatusText('');
|
|
};
|
|
|
|
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 = new Headers(sanitizeHeadersForBrowser(headers) ?? headers);
|
|
const result: Record<string, string> = {};
|
|
normalized.forEach((value, key) => {
|
|
result[key] = value;
|
|
});
|
|
return result;
|
|
};
|
|
|
|
const getRequestHeaders = (input?: RequestInfo | URL, init?: RequestInit): Record<string, string> => {
|
|
const headersFromRequest = input instanceof Request ? headersToRecord(input.headers) : {};
|
|
const headersFromInit = headersToRecord(init?.headers);
|
|
return { ...headersFromRequest, ...headersFromInit };
|
|
};
|
|
|
|
const getRequestDirectoryHint = (url: URL, input?: RequestInfo | URL, init?: RequestInit): string | undefined => {
|
|
const queryDirectory = url.searchParams.get('directory') || undefined;
|
|
if (queryDirectory) return queryDirectory;
|
|
const headers = getRequestHeaders(input, init);
|
|
const directoryEncoding = Object.entries(headers).find(([key]) => key.toLowerCase() === 'x-opencode-directory-encoding')?.[1];
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
if (key.toLowerCase() === 'x-opencode-directory') {
|
|
// headersToRecord marks encoded directory hints so direct/raw percent
|
|
// sequences from other callers are not decoded accidentally.
|
|
if (directoryEncoding !== 'uri') return value;
|
|
try { return decodeURIComponent(value); } catch { return value; }
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const decodeBase64 = (value: string): ArrayBuffer => {
|
|
const binary = atob(value);
|
|
const buffer = new ArrayBuffer(binary.length);
|
|
const bytes = new Uint8Array(buffer);
|
|
for (let i = 0; i < binary.length; i += 1) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return buffer;
|
|
};
|
|
|
|
const jsonResponse = (body: unknown, status = 200): Response => {
|
|
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
|
};
|
|
|
|
const unsupportedWebRouteResponse = (feature: string): Response => {
|
|
return jsonResponse({ error: `${feature} is not supported in VS Code` }, 501);
|
|
};
|
|
|
|
const pluginConfigErrorStatus = (message: string): number => {
|
|
const lower = message.toLowerCase();
|
|
if (lower.includes('already exists')) return 409;
|
|
if (lower.includes('not found')) return 404;
|
|
if (lower.includes('required') || lower.includes('invalid') || lower.includes('must ')) return 400;
|
|
return 500;
|
|
};
|
|
|
|
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 ArrayBuffer(0);
|
|
return new Response(body, { status: proxied.status, headers: proxied.headers });
|
|
};
|
|
|
|
const isSseApiPath = (pathname: string) => pathname === '/api/event' || pathname === '/api/global/event';
|
|
const isSessionMessageApiPath = (pathname: string) => /^\/api\/session\/[^/]+\/message$/.test(pathname);
|
|
const isApiPath = (pathname: string) => pathname === '/api' || pathname.startsWith('/api/');
|
|
const isLocalRuntimePath = (pathname: string) => isApiPath(pathname) || pathname === '/auth/session';
|
|
|
|
const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: RequestInit | undefined, method: string) => {
|
|
const pathname = url.pathname;
|
|
const normalizedPathname = pathname !== '/' ? pathname.replace(/\/+$/, '') : pathname;
|
|
|
|
if (normalizedPathname === '/api/system/info' && method === 'GET') {
|
|
const config = window.__VSCODE_CONFIG__;
|
|
return jsonResponse({
|
|
openchamberVersion: config?.extensionVersion || 'VS Code Extension',
|
|
runtime: 'vscode',
|
|
platform: config?.platform || '',
|
|
arch: config?.arch || '',
|
|
});
|
|
}
|
|
|
|
if (normalizedPathname === '/api/preview/targets') {
|
|
return unsupportedWebRouteResponse('Preview proxy');
|
|
}
|
|
|
|
if (normalizedPathname.startsWith('/api/openchamber/tunnel/')) {
|
|
return unsupportedWebRouteResponse('Remote tunnel settings');
|
|
}
|
|
|
|
// Archiving a batch of sessions server-side needs an OpenChamber server
|
|
// process; the extension host has none. Answering explicitly keeps the
|
|
// shared UI on its per-session archive path instead of leaving the request
|
|
// to the generic proxy.
|
|
if (normalizedPathname === '/api/openchamber/sessions/archive') {
|
|
return unsupportedWebRouteResponse('Server-side session archiving');
|
|
}
|
|
|
|
if (/^\/api\/projects\/[^/]+\/scheduled-tasks(?:\/[^/]+)?$/.test(normalizedPathname)) {
|
|
return unsupportedWebRouteResponse('Scheduled tasks');
|
|
}
|
|
|
|
if (normalizedPathname === '/api/fs/git-dirs') {
|
|
return unsupportedWebRouteResponse('Nested git repository discovery');
|
|
}
|
|
|
|
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/permission-auto-accept' && method === 'GET') {
|
|
const snapshot = await sendBridgeMessage('api:permission-auto-accept:get');
|
|
return new Response(JSON.stringify(snapshot), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const permissionPolicyMatch = normalizedPathname.match(/^\/api\/permission-auto-accept\/sessions\/([^/]+)$/);
|
|
if (permissionPolicyMatch && method === 'PUT') {
|
|
const bodyText = await extractBodyText(url, init, method);
|
|
const body = bodyText ? JSON.parse(bodyText) as { enabled?: unknown } : {};
|
|
const snapshot = await sendBridgeMessage('api:permission-auto-accept:set', {
|
|
sessionId: decodeURIComponent(permissionPolicyMatch[1]),
|
|
enabled: body.enabled,
|
|
});
|
|
return new Response(JSON.stringify(snapshot), {
|
|
status: 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' },
|
|
});
|
|
}
|
|
|
|
// Dictation runs on the OpenChamber web server (WebSocket + worker); the VS
|
|
// Code bridge has no server process, so report it deterministically
|
|
// unavailable. The mic button hides itself when capture is unsupported.
|
|
if (normalizedPathname === '/api/dictation/status' && method === 'GET') {
|
|
return new Response(JSON.stringify({ provider: 'local', available: false, reasonCode: 'unsupported_runtime', models: [] }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (normalizedPathname.startsWith('/api/dictation/') ) {
|
|
return new Response(JSON.stringify({ error: 'Dictation is 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 = await extractJsonBody(input, init, method);
|
|
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 = await extractJsonBody(input, init, method);
|
|
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 = await extractJsonBody(input, init, method);
|
|
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 = await extractJsonBody(input, init, method);
|
|
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 = method;
|
|
const body = await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
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 = method;
|
|
const body = await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
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 = method;
|
|
const body = await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
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 = method;
|
|
const body = await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
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' } });
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/config/snippets') {
|
|
const verb = method;
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/snippets', { method: verb, 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/snippets/expand') {
|
|
const verb = method === 'GET' && !hasInitBody(init) && !(input instanceof Request) ? 'POST' : method;
|
|
const body = await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/snippets', { 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/snippets/')) {
|
|
const encodedName = pathname.slice('/api/config/snippets/'.length);
|
|
const name = decodeURIComponent(encodedName);
|
|
const verb = method;
|
|
const body = await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/snippets', { 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 = method;
|
|
const body = await extractJsonBody(input, init, method);
|
|
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 = await extractJsonBody(input, init, method);
|
|
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 = await extractJsonBody(input, init, method);
|
|
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 = method;
|
|
const body = await extractJsonBody(input, init, method);
|
|
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 (method === 'GET') {
|
|
const settings = await sendBridgeMessage('api:config/settings:get');
|
|
return new Response(JSON.stringify(settings), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
const body = await extractJsonBody(input, init, method);
|
|
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 = await extractJsonBody(input, init, method);
|
|
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 = await extractJsonBody(input, init, method);
|
|
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 === '/api/config/plugins' && method === 'GET') {
|
|
try {
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
const data = await sendBridgeMessage('api:config/plugins', { method, target: 'list', directory });
|
|
return jsonResponse(data);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return jsonResponse({ error: message }, pluginConfigErrorStatus(message));
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/config/plugins/registry' && method === 'GET') {
|
|
try {
|
|
const rawSpecs = url.searchParams.get('specs') || '';
|
|
const specs = rawSpecs ? rawSpecs.split(',').map((spec) => spec.trim()).filter(Boolean) : [];
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
const data = await sendBridgeMessage('api:config/plugins', {
|
|
method,
|
|
target: 'registry',
|
|
specs,
|
|
refresh: url.searchParams.get('refresh') === 'true',
|
|
directory,
|
|
});
|
|
return jsonResponse(data);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return jsonResponse({ error: message }, pluginConfigErrorStatus(message));
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/config/plugins/entry' && method === 'POST') {
|
|
try {
|
|
const body = await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
const data = await sendBridgeMessage('api:config/plugins', { method, target: 'entry', body, directory });
|
|
return jsonResponse(data);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return jsonResponse({ error: message }, pluginConfigErrorStatus(message));
|
|
}
|
|
}
|
|
|
|
const pluginEntryMatch = pathname.match(/^\/api\/config\/plugins\/entry\/([^/]+)$/);
|
|
if (pluginEntryMatch) {
|
|
try {
|
|
const body = method === 'GET' || method === 'DELETE' ? undefined : await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
const data = await sendBridgeMessage('api:config/plugins', {
|
|
method,
|
|
target: 'entry',
|
|
pluginId: decodeURIComponent(pluginEntryMatch[1]),
|
|
body,
|
|
directory,
|
|
});
|
|
return jsonResponse(data);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return jsonResponse({ error: message }, pluginConfigErrorStatus(message));
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/config/plugins/file' && method === 'POST') {
|
|
try {
|
|
const body = await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
const data = await sendBridgeMessage('api:config/plugins', { method, target: 'file', body, directory });
|
|
return jsonResponse(data);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return jsonResponse({ error: message }, pluginConfigErrorStatus(message));
|
|
}
|
|
}
|
|
|
|
const pluginFileMatch = pathname.match(/^\/api\/config\/plugins\/file\/([^/]+)$/);
|
|
if (pluginFileMatch) {
|
|
try {
|
|
const body = method === 'GET' || method === 'DELETE' ? undefined : await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
const data = await sendBridgeMessage('api:config/plugins', {
|
|
method,
|
|
target: 'file',
|
|
pluginId: decodeURIComponent(pluginFileMatch[1]),
|
|
body,
|
|
directory,
|
|
});
|
|
return jsonResponse(data);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return jsonResponse({ error: message }, pluginConfigErrorStatus(message));
|
|
}
|
|
}
|
|
|
|
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/opencode/version' && method === 'GET') {
|
|
try {
|
|
const data = await sendBridgeMessage('api:opencode/version');
|
|
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({ version: null, error: message }), { status: 502, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/opencode/health' && method === 'GET') {
|
|
const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status;
|
|
return new Response(JSON.stringify({ healthy: connectionStatus === 'connected' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (pathname === '/api/opencode/upgrade-status' && method === 'GET') {
|
|
const data = await sendBridgeMessage('api:opencode/upgrade-status');
|
|
return jsonResponse(data);
|
|
}
|
|
|
|
if (pathname === '/api/opencode/upgrade' && method === 'POST') {
|
|
const body = await extractJsonBody(input, init, method);
|
|
const result = await sendBridgeMessage<{ status: number; body: unknown }>('api:opencode/upgrade', body);
|
|
return jsonResponse(result.body, result.status);
|
|
}
|
|
|
|
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 = await extractJsonBody(input, init, method);
|
|
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 quotaCredentialMatch = pathname.match(/^\/api\/quota\/credentials\/(ollama-cloud|cursor)(?:\/(validate|import))?$/);
|
|
if (quotaCredentialMatch) {
|
|
try {
|
|
const body = method === 'PUT' ? await extractJsonBody(input, init, method) : undefined;
|
|
const bridgeMethod = quotaCredentialMatch[2]?.toUpperCase() || method;
|
|
const data = await sendBridgeMessage('api:quota:credentials', { providerId: quotaCredentialMatch[1], method: bridgeMethod, credential: 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: 400, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
const quotaMatch = pathname.match(/^\/api\/quota\/([^/]+)$/);
|
|
if (quotaMatch && method === '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 && method === '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 && method === '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' } });
|
|
}
|
|
}
|
|
|
|
// Handle custom provider upsert: PUT /api/provider
|
|
if (pathname === '/api/provider' && method === 'PUT') {
|
|
try {
|
|
const body = await extractJsonBody(input, init, method);
|
|
const queryDirectory = url.searchParams.get('directory') || undefined;
|
|
const data = await sendBridgeMessage('api:provider:upsert', {
|
|
...(body && typeof body === 'object' ? body : {}),
|
|
directory: queryDirectory
|
|
?? (body && typeof body === 'object' && typeof body.directory === 'string' ? body.directory : undefined),
|
|
});
|
|
if (data && typeof data === 'object' && 'success' in data && (data as { success?: boolean }).success === false) {
|
|
const message = (data as { error?: string }).error || 'Failed to save provider config';
|
|
return new Response(JSON.stringify({ error: message }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
return new Response(JSON.stringify((data as { data?: unknown })?.data ?? 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);
|
|
let sseStreamCounter = 0;
|
|
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(/\/{2,}/g, '/');
|
|
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 && isLocalRuntimePath(normalizedPathname)) {
|
|
const localResponse = await handleLocalApiRequest(input, targetUrl, init, method);
|
|
if (localResponse) {
|
|
maybeHideLoadingOverlay();
|
|
return localResponse;
|
|
}
|
|
|
|
if (!isApiPath(normalizedPathname)) {
|
|
return originalFetch(input as RequestInfo, init);
|
|
}
|
|
|
|
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)) {
|
|
// Install the listener before the extension opens the upstream stream. A
|
|
// reconnect can replay an event immediately, before the start response
|
|
// has crossed the VS Code bridge.
|
|
const streamId = `sse_webview_${Date.now()}_${++sseStreamCounter}`;
|
|
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(() => {});
|
|
},
|
|
});
|
|
|
|
let start;
|
|
try {
|
|
start = await vscodeStreamPerfMeasure('vscode.webview.sse_start_ms', () => startSseProxy({ path: suffixPath, headers, streamId }));
|
|
} catch (error) {
|
|
await stream.cancel();
|
|
throw error;
|
|
}
|
|
if (!start.streamId) {
|
|
void stream.cancel();
|
|
return new Response(null, { status: start.status || 503, headers: start.headers || {} });
|
|
}
|
|
|
|
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 signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined;
|
|
const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText, signal });
|
|
const response = buildProxiedResponse(proxied);
|
|
maybeHideLoadingOverlay();
|
|
return response;
|
|
}
|
|
|
|
const bodyBase64 = await extractBodyBase64(input, init, method);
|
|
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);
|
|
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('focusChatInput', () => {
|
|
focusChatInput();
|
|
});
|
|
|
|
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).finally(() => {
|
|
focusChatInput();
|
|
});
|
|
});
|
|
});
|
|
|
|
// Comments dropped from their editor thread before the draft reached this
|
|
// store. See the module for why the window exists.
|
|
const removedComments = createRemovalTombstones();
|
|
|
|
onCommand('addLineComment', (payload) => {
|
|
// SAFETY: the payload crossed the extension boundary as JSON; every field is
|
|
// read as unknown here and trusted only after the checks below.
|
|
const record = payload as {
|
|
draftId?: unknown;
|
|
filePath?: unknown;
|
|
relativePath?: unknown;
|
|
source?: unknown;
|
|
side?: unknown;
|
|
startLine?: unknown;
|
|
endLine?: unknown;
|
|
code?: unknown;
|
|
language?: unknown;
|
|
comment?: unknown;
|
|
targetSessionId?: unknown;
|
|
};
|
|
|
|
// The editor thread mints the id so it can track its own draft without a
|
|
// round trip. Absent when the comment came from anywhere else.
|
|
const draftId = typeof record.draftId === 'string' && record.draftId ? record.draftId : undefined;
|
|
// A session panel is told which session the comment is for, so it can wait
|
|
// until it actually shows that session. The sidebar files wherever it is.
|
|
const targetSessionId = typeof record.targetSessionId === 'string' && record.targetSessionId ? record.targetSessionId : undefined;
|
|
const relativePath = typeof record.relativePath === 'string' ? record.relativePath : '';
|
|
const source = record.source === 'diff' ? 'diff' : 'file';
|
|
const side = record.side === 'original' || record.side === 'modified' ? record.side : undefined;
|
|
const startLine = typeof record.startLine === 'number' ? record.startLine : 1;
|
|
const endLine = typeof record.endLine === 'number' ? record.endLine : startLine;
|
|
const code = typeof record.code === 'string' ? record.code : '';
|
|
const language = typeof record.language === 'string' ? record.language : 'text';
|
|
const comment = typeof record.comment === 'string' ? record.comment.trim() : '';
|
|
|
|
if (!relativePath) {
|
|
console.warn('[openchamber] inline comment arrived without a path; dropping', record);
|
|
return;
|
|
}
|
|
|
|
void Promise.all([
|
|
import('@/sync/session-ui-store'),
|
|
import('@/stores/useDirectoryStore'),
|
|
import('@/stores/useInlineCommentDraftStore'),
|
|
]).then(async ([{ useSessionUIStore }, { useDirectoryStore }, { useInlineCommentDraftStore }]) => {
|
|
// Inline drafts are owned by runtime + directory + session. Both halves are
|
|
// read together, from one store snapshot: read apart, a session that
|
|
// finished loading between them would pair its key with the previous
|
|
// session's directory, and the draft would land under a key ChatInput never
|
|
// reads. Directory precedence matches the composer's own.
|
|
const resolveTarget = () => {
|
|
const sessionState = useSessionUIStore.getState();
|
|
const currentSessionId = sessionState.currentSessionId ?? null;
|
|
const draft = sessionState.newSessionDraft;
|
|
return resolveCommentTarget({
|
|
currentSessionId,
|
|
sessionDirectory: currentSessionId ? sessionState.getDirectoryForSession(currentSessionId) ?? null : null,
|
|
draftOpen: Boolean(draft?.open),
|
|
draftDirectory: draft?.open ? draft.bootstrapPendingDirectory ?? draft.directoryOverride ?? null : null,
|
|
currentDirectory: useDirectoryStore.getState().currentDirectory ?? null,
|
|
}, targetSessionId);
|
|
};
|
|
|
|
// A comment can arrive before the chat surface shows its session: a panel
|
|
// opened for the comment knows its directory long before the session list
|
|
// has loaded and the session is selected. Filing before that put the draft
|
|
// under a key this composer never reads. Wait for the surface to land on
|
|
// the session (or an open draft) instead, within the extension's own
|
|
// confirmation deadline.
|
|
let target = resolveTarget();
|
|
for (let attempt = 0; !target && attempt < 80; attempt += 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
target = resolveTarget();
|
|
}
|
|
if (!target) {
|
|
console.warn('[openchamber] chat surface never showed the session; dropping inline comment', { relativePath, startLine, targetSessionId });
|
|
return;
|
|
}
|
|
|
|
// Checked after the wait, which is the window the removal can land in.
|
|
if (removedComments.consume(draftId)) {
|
|
return;
|
|
}
|
|
|
|
useInlineCommentDraftStore.getState().addDraft(target, {
|
|
id: draftId,
|
|
source,
|
|
fileLabel: relativePath,
|
|
startLine,
|
|
endLine,
|
|
side,
|
|
code,
|
|
language,
|
|
text: comment,
|
|
});
|
|
});
|
|
});
|
|
|
|
// The editor's comment threads mirror the composer's drafts, so every change to
|
|
// the draft store is reported as a whole snapshot. Sending the full list rather
|
|
// than add/remove events means a dropped notification cannot leave a thread
|
|
// anchored to a comment that is no longer attached; sending the message empties
|
|
// the list, which clears the threads through the same path.
|
|
void import('@/stores/useInlineCommentDraftStore').then(({ useInlineCommentDraftStore }) => {
|
|
let lastSignature = '';
|
|
|
|
const publish = (drafts: Record<string, Array<{ id: string; text: string }>>) => {
|
|
const flat = Object.values(drafts)
|
|
.flat()
|
|
.map((draft) => ({ id: draft.id, text: draft.text }));
|
|
const signature = JSON.stringify(flat);
|
|
if (signature === lastSignature) return;
|
|
lastSignature = signature;
|
|
postBridgeNotification('inlineComments:sync', { drafts: flat });
|
|
};
|
|
|
|
publish(useInlineCommentDraftStore.getState().drafts);
|
|
useInlineCommentDraftStore.subscribe((state) => publish(state.drafts));
|
|
});
|
|
|
|
onCommand('removeLineComment', (payload) => {
|
|
if (typeof payload !== 'object' || payload === null || !('draftId' in payload)) return;
|
|
const { draftId } = payload;
|
|
if (typeof draftId !== 'string' || !draftId) {
|
|
return;
|
|
}
|
|
|
|
// Recorded even when the draft is already here: the store removal below is
|
|
// the normal path, and this only matters when the draft has not landed yet.
|
|
removedComments.remember(draftId);
|
|
|
|
void Promise.all([
|
|
import('@/stores/useInlineCommentDraftStore'),
|
|
import('@/lib/runtime-switch'),
|
|
]).then(([{ useInlineCommentDraftStore }, { getRuntimeKey }]) => {
|
|
const state = useInlineCommentDraftStore.getState();
|
|
const runtimeKey = getRuntimeKey();
|
|
|
|
// The thread knows its draft id but not which target holds it. Search for
|
|
// the owning key, and only within the current runtime: `removeDraft`
|
|
// recomputes the key from the live runtime, so a target rebuilt from
|
|
// another runtime's key would delete from the wrong place.
|
|
for (const [key, drafts] of Object.entries(state.drafts)) {
|
|
if (!drafts.some((draft) => draft.id === draftId)) continue;
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(key);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!Array.isArray(parsed) || parsed.length !== 3 || !parsed.every((segment) => typeof segment === 'string')) continue;
|
|
const [keyRuntime, directory, sessionKey] = parsed;
|
|
if (keyRuntime !== runtimeKey) continue;
|
|
state.removeDraft({ directory, sessionKey }, draftId);
|
|
return;
|
|
}
|
|
});
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
});
|
|
|
|
const normalizeWorkspaceFoldersPayload = (value: unknown): Array<{ name: string; path: string }> => {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
return value
|
|
.map((entry) => {
|
|
const candidate = entry as { name?: unknown; path?: unknown };
|
|
const name = typeof candidate.name === 'string' ? candidate.name.trim() : '';
|
|
const path = typeof candidate.path === 'string' ? candidate.path.trim() : '';
|
|
return path ? { name, path } : null;
|
|
})
|
|
.filter((entry): entry is { name: string; path: string } => entry !== null);
|
|
};
|
|
|
|
const syncVSCodeWorkspaceProjects = async (
|
|
workspaceFolders: Array<{ name: string; path: string }>,
|
|
activePath?: string,
|
|
) => {
|
|
if (window.__VSCODE_CONFIG__) {
|
|
window.__VSCODE_CONFIG__.workspaceFolders = workspaceFolders;
|
|
}
|
|
const { useProjectsStore } = await import('@/stores/useProjectsStore');
|
|
return useProjectsStore.getState().syncVSCodeWorkspaceFolders(workspaceFolders, activePath);
|
|
};
|
|
|
|
onCommand('workspaceFoldersChanged', (payload) => {
|
|
const record = payload as { workspaceFolders?: unknown } | undefined;
|
|
const workspaceFolders = normalizeWorkspaceFoldersPayload(record?.workspaceFolders);
|
|
void syncVSCodeWorkspaceProjects(workspaceFolders);
|
|
});
|
|
|
|
// Listen for newSession command from extension title bar button
|
|
onCommand('newSession', (payload) => {
|
|
const record = payload as { directory?: unknown; workspaceFolders?: unknown } | undefined;
|
|
const directory = record?.directory;
|
|
const directoryOverride = typeof directory === 'string' && directory.trim().length > 0 ? directory.trim() : undefined;
|
|
const workspaceFolders = normalizeWorkspaceFoldersPayload(record?.workspaceFolders);
|
|
|
|
Promise.all([
|
|
import('@/sync/session-ui-store'),
|
|
syncVSCodeWorkspaceProjects(workspaceFolders, directoryOverride),
|
|
]).then(([{ useSessionUIStore }, selectedProject]) => {
|
|
useSessionUIStore.getState().openNewSessionDraft(
|
|
directoryOverride
|
|
? { directoryOverride, selectedProjectId: selectedProject?.id ?? undefined }
|
|
: undefined
|
|
);
|
|
});
|
|
|
|
// 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' } }));
|
|
});
|
|
|
|
// Run the same full OpenCode reload flow the app uses after an update: shows the
|
|
// reload overlay, restarts the managed OpenCode (via the bridge's /api/config/reload),
|
|
// and refreshes config/data. Triggered by the "Restart API Connection" command.
|
|
onCommand('reloadOpenCode', () => {
|
|
void import('@openchamber/ui/stores/useAgentsStore').then(({ reloadOpenCodeConfiguration }) => {
|
|
void reloadOpenCodeConfiguration().catch(() => undefined);
|
|
});
|
|
});
|
|
|
|
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 errorNotificationCooldowns = 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) => {
|
|
notificationSettingsSyncPromise = null;
|
|
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') {
|
|
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 messages = await opencodeClient.getSessionMessages(sessionId, 5);
|
|
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' | 'subtask' | '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);
|
|
};
|
|
|
|
const getNotificationDirectory = (payload: Record<string, unknown>): string | null => {
|
|
const properties = (payload.properties ?? payload) as Record<string, unknown>;
|
|
const info = properties.info as Record<string, unknown> | undefined;
|
|
return getPayloadString(properties.directory ?? info?.directory) || null;
|
|
};
|
|
|
|
window.addEventListener('openchamber:vscode-notification-event', (event) => {
|
|
const detail = (event as CustomEvent<{ directory?: string; 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'),
|
|
]).then(async ([{ useUIStore }]) => {
|
|
await ensureNotificationSettingsSynced();
|
|
const settings = useUIStore.getState();
|
|
if (!settings.nativeNotificationsEnabled) {
|
|
return;
|
|
}
|
|
const requireHidden = settings.notificationMode !== 'always';
|
|
const messageId = getPayloadString(info?.id);
|
|
const error = properties.error;
|
|
const errorMessage = getPayloadString(
|
|
typeof error === 'object' && error
|
|
? (error as { message?: unknown }).message
|
|
: error,
|
|
);
|
|
const rawLastMessage = extractNotificationLastMessage(record)
|
|
|| errorMessage
|
|
|| await fetchLastAssistantMessageText(sessionId, messageId);
|
|
const lastMessage = prepareNotificationLastMessage(
|
|
rawLastMessage,
|
|
settings,
|
|
);
|
|
const variables = buildNotificationVariables(record, sessionId, lastMessage);
|
|
|
|
const isAssistantMessage = type === 'message.updated' && getPayloadString(info?.role) === 'assistant';
|
|
const finish = isAssistantMessage ? getPayloadString(info?.finish) : '';
|
|
const isCompletion = type === 'session.idle' || finish === 'stop';
|
|
const isError = type === 'session.error' || finish === 'error';
|
|
|
|
if (isCompletion) {
|
|
const session = await opencodeClient.getSession(sessionId, getNotificationDirectory(record)).catch(() => undefined);
|
|
if (!session) return;
|
|
const isSubtask = Boolean(session?.parentID);
|
|
if (isSubtask ? !settings.notifyOnSubtasks : !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, isSubtask ? 'subtask' : '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 (isError) {
|
|
if (!settings.notifyOnError) return;
|
|
const now = Date.now();
|
|
const lastAt = errorNotificationCooldowns.get(sessionId) ?? 0;
|
|
if (now - lastAt < READY_NOTIFICATION_COOLDOWN_MS) return;
|
|
errorNotificationCooldowns.set(sessionId, now);
|
|
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,
|
|
});
|
|
return;
|
|
}
|
|
|
|
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;
|
|
const requestId = getPayloadString(properties.id);
|
|
if (requestId) {
|
|
const accepted = await processVSCodePermissionAutoAccept(
|
|
properties as unknown as PermissionRequest,
|
|
detail?.directory,
|
|
);
|
|
if (accepted) 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({ adoptTheme: false });
|
|
});
|
|
});
|
|
|
|
onCommand('permissionAutoAcceptSynced', (payload) => {
|
|
if (!payload || typeof payload !== 'object') return;
|
|
const snapshot = payload as { sessions?: unknown; revision?: unknown };
|
|
const sessions = snapshot.sessions;
|
|
if (!sessions || typeof sessions !== 'object') return;
|
|
usePermissionStore.getState().applySnapshot({
|
|
sessions: sessions as Record<string, boolean>,
|
|
revision: typeof snapshot.revision === 'number' ? snapshot.revision : undefined,
|
|
});
|
|
});
|
|
|
|
// 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();
|
|
});
|