Loading screens: drop the internal-jargon progress text on the opencode reload overlay (ConfigUpdateOverlay) and the vscode init splash — show text only on errors. Replace it with a glow pulse on the OpenCode mark on the cube's top face; OpenChamberLogo's isAnimated prop was a no-op and now actually animates. vscode shows the glow on the inline splash logo and the React app stops writing 'Loading data (… Providers, … Agents)…'. Restart API: 'OpenChamber: Restart API Connection' now runs the same full reload flow used after an OpenCode update — the command asks the chat webview to call reloadOpenCodeConfiguration() (overlay + managed restart via the bridge + config/data refresh) instead of a bare manager restart, falling back to the old restart when no webview is open. Mounts ConfigUpdateOverlay in the vscode app so the overlay actually shows there.
312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
import * as vscode from 'vscode';
|
|
import * as os from 'os';
|
|
import { getThemeKindName } from './theme';
|
|
import type { ConnectionStatus } from './opencode';
|
|
|
|
export type PanelType = 'chat' | 'agentManager';
|
|
|
|
export interface WebviewHtmlOptions {
|
|
webview: vscode.Webview;
|
|
extensionUri: vscode.Uri;
|
|
workspaceFolder: string;
|
|
initialStatus: ConnectionStatus;
|
|
cliAvailable: boolean;
|
|
panelType?: PanelType;
|
|
initialSessionId?: string;
|
|
viewMode?: 'sidebar' | 'editor';
|
|
devServerUrl?: string | null;
|
|
extensionVersion?: string;
|
|
}
|
|
|
|
const asCspToken = (value: string | null | undefined): string | null => {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
};
|
|
|
|
const toOrigin = (value: string | null | undefined): string | null => {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
try {
|
|
return new URL(value).origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const uniqueTokens = (values: Array<string | null | undefined>): string => {
|
|
return Array.from(new Set(values.map(asCspToken).filter((value): value is string => Boolean(value)))).join(' ');
|
|
};
|
|
|
|
export function getWebviewHtml(options: WebviewHtmlOptions): string {
|
|
const {
|
|
webview,
|
|
extensionUri,
|
|
workspaceFolder,
|
|
initialStatus,
|
|
cliAvailable,
|
|
panelType = 'chat',
|
|
initialSessionId,
|
|
viewMode = 'sidebar',
|
|
devServerUrl,
|
|
extensionVersion = '',
|
|
} = options;
|
|
|
|
const scriptPath = vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'assets', 'index.js');
|
|
const scriptUri = webview.asWebviewUri(scriptPath);
|
|
const normalizedDevServerUrl = asCspToken(devServerUrl)?.replace(/\/$/, '') ?? null;
|
|
const devServerOrigin = toOrigin(normalizedDevServerUrl);
|
|
const styleSrc = uniqueTokens([webview.cspSource, "'unsafe-inline'", devServerOrigin]);
|
|
const scriptSrc = uniqueTokens([webview.cspSource, "'unsafe-inline'", "'unsafe-eval'", devServerOrigin]);
|
|
const connectSrc = uniqueTokens(['*', 'ws:', 'wss:', 'http:', 'https:', devServerOrigin]);
|
|
const imgSrc = uniqueTokens([webview.cspSource, 'data:', 'https:', devServerOrigin]);
|
|
const fontSrc = uniqueTokens([webview.cspSource, 'data:', devServerOrigin]);
|
|
|
|
const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind);
|
|
|
|
// Use VS Code CSS variables for proper theme integration
|
|
// These variables are automatically provided by VS Code to webviews
|
|
//
|
|
// Logo geometry matches OpenChamberLogo.tsx:
|
|
// edge=48, cos30=0.866, sin30=0.5, centerY=50
|
|
// top=(50, 2), left=(8.432, 26), right=(91.568, 26), center=(50, 50)
|
|
// bottomLeft=(8.432, 74), bottomRight=(91.568, 74), bottom=(50, 98)
|
|
// topFaceCenterY = (2 + 26 + 50 + 26) / 4 = 26
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${styleSrc}; script-src ${scriptSrc}; connect-src ${connectSrc}; img-src ${imgSrc}; font-src ${fontSrc};">
|
|
<style>
|
|
html, body, #root { height: 100%; width: 100%; margin: 0; padding: 0; }
|
|
body {
|
|
overflow: hidden;
|
|
background: var(--vscode-editor-background, var(--vscode-sideBar-background));
|
|
font-family: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
|
|
color: var(--vscode-foreground);
|
|
}
|
|
|
|
/* Initial loading screen styles - uses VS Code theme variables */
|
|
#initial-loading {
|
|
position: fixed;
|
|
inset: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 16px;
|
|
z-index: 9999;
|
|
background: var(--vscode-editor-background, var(--vscode-sideBar-background));
|
|
transition: opacity 0.3s ease-out;
|
|
}
|
|
#initial-loading.fade-out {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
/* Glow pulse on the OpenCode mark on the cube's top face — signals loading without text. */
|
|
@keyframes oc-logo-glow {
|
|
0%, 100% { filter: drop-shadow(0 0 0 transparent); }
|
|
50% { filter: drop-shadow(0 0 4px var(--vscode-foreground)); }
|
|
}
|
|
#initial-loading .logo-inner {
|
|
animation: oc-logo-glow 1.8s ease-in-out infinite;
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
#initial-loading .logo-inner { animation: none; }
|
|
}
|
|
/* Logo colors use VS Code foreground color */
|
|
#initial-loading .logo-stroke {
|
|
stroke: var(--vscode-foreground);
|
|
}
|
|
#initial-loading .logo-fill {
|
|
fill: var(--vscode-foreground);
|
|
opacity: 0.15;
|
|
}
|
|
#initial-loading .logo-fill-solid {
|
|
fill: var(--vscode-foreground);
|
|
}
|
|
#initial-loading .logo-fill-dim {
|
|
fill: var(--vscode-foreground);
|
|
opacity: 0.4;
|
|
}
|
|
#initial-loading .status-text {
|
|
font-size: 13px;
|
|
color: var(--vscode-descriptionForeground, var(--vscode-foreground));
|
|
text-align: center;
|
|
}
|
|
#initial-loading .error-text {
|
|
font-size: 12px;
|
|
color: var(--vscode-errorForeground, #f48771);
|
|
text-align: center;
|
|
max-width: 280px;
|
|
}
|
|
</style>
|
|
<title>OpenChamber</title>
|
|
</head>
|
|
<body>
|
|
<!-- Initial loading screen with simplified OpenChamber logo -->
|
|
<div id="initial-loading">
|
|
<svg class="logo" width="70" height="70" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<!-- Left face -->
|
|
<path class="logo-fill logo-stroke" d="M50 50 L8.432 26 L8.432 74 L50 98 Z" stroke-width="2" stroke-linejoin="round"/>
|
|
<!-- Right face -->
|
|
<path class="logo-fill logo-stroke" d="M50 50 L91.568 26 L91.568 74 L50 98 Z" stroke-width="2" stroke-linejoin="round"/>
|
|
<!-- Top face (no fill, stroke only) -->
|
|
<path class="logo-stroke" d="M50 2 L8.432 26 L50 50 L91.568 26 Z" fill="none" stroke-width="2" stroke-linejoin="round"/>
|
|
|
|
<!-- OpenCode logo on top face -->
|
|
<g class="logo-inner" transform="matrix(0.866, 0.5, -0.866, 0.5, 50, 26) scale(0.75)">
|
|
<path class="logo-fill-solid" fill-rule="evenodd" clip-rule="evenodd" d="M-16 -20 L16 -20 L16 20 L-16 20 Z M-8 -12 L-8 12 L8 12 L8 -12 Z"/>
|
|
<path class="logo-fill-dim" d="M-8 -4 L8 -4 L8 12 L-8 12 Z"/>
|
|
</g>
|
|
</svg>
|
|
<!-- Status text stays empty while things are fine; populated only on error. -->
|
|
<div class="status-text" id="loading-status"></div>
|
|
${!cliAvailable ? `<div class="error-text">OpenCode CLI not found. Please install it first.</div>` : ''}
|
|
</div>
|
|
|
|
<div id="root"></div>
|
|
<script>
|
|
// Polyfill process for Node.js modules running in browser
|
|
window.process = window.process || { env: { NODE_ENV: 'production' }, platform: '', version: '', browser: true };
|
|
|
|
window.__VSCODE_CONFIG__ = {
|
|
workspaceFolder: "${workspaceFolder.replace(/\\/g, '\\\\')}",
|
|
theme: "${themeKind}",
|
|
connectionStatus: "${initialStatus}",
|
|
cliAvailable: ${cliAvailable},
|
|
extensionVersion: "${extensionVersion.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}",
|
|
platform: "${os.platform()}",
|
|
arch: "${os.arch()}",
|
|
panelType: "${panelType}",
|
|
viewMode: "${viewMode}",
|
|
initialSessionId: ${initialSessionId ? `"${initialSessionId.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` : 'null'},
|
|
};
|
|
window.__OPENCHAMBER_HOME__ = "${workspaceFolder.replace(/\\/g, '\\\\')}";
|
|
|
|
// Handle connection status updates to update loading screen
|
|
window.addEventListener('message', function(event) {
|
|
var msg = event.data;
|
|
if (msg && msg.type === 'connectionStatus') {
|
|
var statusEl = document.getElementById('loading-status');
|
|
if (statusEl) {
|
|
// Only show text when something is wrong — progress states stay silent
|
|
// (the animated logo already signals "working").
|
|
if (msg.status === 'error') {
|
|
statusEl.textContent = msg.error || 'Connection error';
|
|
statusEl.classList.add('error-text');
|
|
} else {
|
|
statusEl.textContent = '';
|
|
statusEl.classList.remove('error-text');
|
|
}
|
|
}
|
|
}
|
|
});
|
|
</script>
|
|
<script type="module">
|
|
const prodEntryUrl = ${JSON.stringify(scriptUri.toString())};
|
|
const devServerUrl = ${normalizedDevServerUrl ? JSON.stringify(normalizedDevServerUrl) : 'null'};
|
|
|
|
const loadProductionBundle = () => {
|
|
const script = document.createElement('script');
|
|
script.type = 'module';
|
|
script.src = prodEntryUrl;
|
|
document.body.appendChild(script);
|
|
};
|
|
|
|
if (!devServerUrl) {
|
|
loadProductionBundle();
|
|
} else {
|
|
const baseUrl = devServerUrl;
|
|
|
|
const statusEl = document.getElementById('loading-status');
|
|
const setStatus = (text) => {
|
|
if (statusEl) {
|
|
statusEl.textContent = text;
|
|
}
|
|
};
|
|
|
|
const retryDelayMs = 500;
|
|
let attempt = 0;
|
|
|
|
const waitForRootMount = (timeoutMs) => {
|
|
const root = document.getElementById('root');
|
|
if (!root) {
|
|
return Promise.resolve(false);
|
|
}
|
|
|
|
if (root.childNodes.length > 0) {
|
|
return Promise.resolve(true);
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const observer = new MutationObserver(() => {
|
|
if (root.childNodes.length > 0) {
|
|
observer.disconnect();
|
|
clearTimeout(timer);
|
|
resolve(true);
|
|
}
|
|
});
|
|
|
|
observer.observe(root, { childList: true, subtree: true });
|
|
const timer = window.setTimeout(() => {
|
|
observer.disconnect();
|
|
resolve(root.childNodes.length > 0);
|
|
}, timeoutMs);
|
|
});
|
|
};
|
|
|
|
const tryLoadDevBundle = () => {
|
|
const viteClientUrl = baseUrl + '/@vite/client';
|
|
const reactRefreshUrl = baseUrl + '/@react-refresh';
|
|
const devEntryUrl = baseUrl + '/main.tsx';
|
|
const hostLabel = (() => {
|
|
try {
|
|
return new URL(baseUrl).host;
|
|
} catch {
|
|
return baseUrl;
|
|
}
|
|
})();
|
|
|
|
setStatus('Starting webview dev server (' + hostLabel + ')...');
|
|
|
|
Promise.resolve()
|
|
.then(() => import(viteClientUrl))
|
|
.then(() => import(reactRefreshUrl))
|
|
.then((mod) => {
|
|
const runtime = mod && mod.default ? mod.default : null;
|
|
if (runtime && typeof runtime.injectIntoGlobalHook === 'function') {
|
|
runtime.injectIntoGlobalHook(window);
|
|
window.$RefreshReg$ = () => {};
|
|
window.$RefreshSig$ = () => (type) => type;
|
|
window.__vite_plugin_react_preamble_installed__ = true;
|
|
}
|
|
})
|
|
.then(() => import(devEntryUrl))
|
|
.then(() => waitForRootMount(4000))
|
|
.then((mounted) => {
|
|
if (!mounted) {
|
|
throw new Error('Dev bundle loaded but app did not mount');
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
attempt += 1;
|
|
console.warn('[OpenChamber] VS Code webview dev bundle unavailable, retrying...', error);
|
|
setStatus('Waiting for webview dev server (' + hostLabel + ')... attempt ' + attempt);
|
|
window.setTimeout(() => {
|
|
tryLoadDevBundle();
|
|
}, retryDelayMs);
|
|
});
|
|
};
|
|
|
|
tryLoadDevBundle();
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|