Files
openchamber/packages/vscode/src/webviewHtml.ts
T
Bohdan Triapitsyn c3a83b3181 fix(vscode): default to the VS Code display language before a saved locale
The splash and the UI bundle both started in English on a fresh install
even when VS Code ran in a supported language, because only OpenChamber's
own saved locale was consulted. The HTML now exposes VS Code's display
language; the splash and detectInitialLocale use it until the user picks
a locale, which still wins.

Claude-Session: https://claude.ai/code/session_01VqV56Hez25hTxXH4ipJfzH
2026-09-05 15:08:15 +03:00

412 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as vscode from 'vscode';
import * as os from 'os';
import { getThemeKindName } from './theme';
import type { ConnectionStatus } from './opencode';
import type { WorkspaceFolderCandidate } from './workspaceResolver';
type PanelType = 'chat' | 'agentManager';
export interface WebviewHtmlOptions {
webview: vscode.Webview;
extensionUri: vscode.Uri;
workspaceFolder: string;
workspaceFolders?: WorkspaceFolderCandidate[];
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,
workspaceFolders = [],
initialStatus,
cliAvailable,
panelType = 'chat',
initialSessionId,
viewMode = 'sidebar',
devServerUrl,
extensionVersion = '',
} = options;
const workspaceFoldersJson = JSON.stringify(workspaceFolders).replace(/</g, '\\u003c');
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]);
// fflate's async browser inflater creates blob-backed workers. Keep blob:
// scoped to worker-src so document decompression works without allowing blob scripts.
const workerSrc = uniqueTokens([webview.cspSource, 'blob:', 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}; worker-src ${workerSrc};">
<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" id="cli-missing-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, '\\\\')}",
workspaceFolders: ${workspaceFoldersJson},
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, '\\\\')}";
// VS Code's display language. The UI bundle uses it as the default locale
// until the user picks one; the splash below picks its strings from it too.
window.__OPENCHAMBER_HOST_LANGUAGE__ = ${JSON.stringify(vscode.env.language)};
// OpenChamber's own saved locale wins; before one exists, VS Code's display
// language decides, so a fresh install in a supported language never boots
// in English.
function resolveBootstrapLanguage() {
try {
var rawLocale = window.localStorage.getItem('openchamber.i18n.v1');
if (rawLocale) {
var parsedLocale = JSON.parse(rawLocale);
if (parsedLocale && typeof parsedLocale.locale === 'string') return parsedLocale.locale.toLowerCase();
}
} catch {}
return String(window.__OPENCHAMBER_HOST_LANGUAGE__ || '').toLowerCase();
}
function getBootstrapMessages() {
var locale = 'en';
var detected = resolveBootstrapLanguage();
if (detected.indexOf('fr') === 0) {
locale = 'fr';
} else if (detected.indexOf('tr') === 0) {
locale = 'tr';
}
if (locale === 'fr') {
return {
startingApi: 'Démarrage de lAPI OpenCode…',
initializing: 'Initialisation…',
connecting: 'Connexion…',
connected: 'Connecté !',
connectionError: 'Erreur de connexion',
reconnecting: 'Reconnexion…',
cliNotFound: 'Linterface en ligne de commande OpenCode est introuvable. Veuillez linstaller dabord.',
};
}
if (locale === 'tr') {
return {
startingApi: 'OpenCode API başlatılıyor…',
initializing: 'Başlatılıyor…',
connecting: 'Bağlanıyor…',
connected: 'Bağlandı!',
connectionError: 'Bağlantı hatası',
reconnecting: 'Yeniden bağlanıyor…',
cliNotFound: 'OpenCode CLI bulunamadı. Lütfen önce kurun.',
};
}
return {
startingApi: 'Starting OpenCode API…',
initializing: 'Initializing…',
connecting: 'Connecting…',
connected: 'Connected!',
connectionError: 'Connection error',
reconnecting: 'Reconnecting…',
cliNotFound: 'OpenCode CLI not found. Please install it first.',
};
}
(function applyBootstrapLocale() {
var statusEl = document.getElementById('loading-status');
var cliMissingEl = document.getElementById('cli-missing-text');
var messages = getBootstrapMessages();
if (cliMissingEl) {
cliMissingEl.textContent = messages.cliNotFound;
}
if (statusEl) {
statusEl.textContent = '';
}
})();
// Handle connection status updates to update loading screen
window.addEventListener('message', function(event) {
var msg = event.data;
if (msg && msg.type === 'connectionStatus') {
var messages = getBootstrapMessages();
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 || messages.connectionError;
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 getDevMessages = () => {
const detected = resolveBootstrapLanguage();
if (detected.indexOf('fr') === 0) {
return {
startingDevServer: (host) => 'Démarrage du serveur de développement de la webview (' + host + ')...',
waitingDevServer: (host, attempt) => 'En attente du serveur de développement de la webview (' + host + ')... tentative ' + attempt,
};
}
if (detected.indexOf('tr') === 0) {
return {
startingDevServer: (host) => 'Webview dev sunucusu başlatılıyor (' + host + ')...',
waitingDevServer: (host, attempt) => 'Webview dev sunucusu bekleniyor (' + host + ')... deneme ' + attempt,
};
}
return {
startingDevServer: (host) => 'Starting webview dev server (' + host + ')...',
waitingDevServer: (host, attempt) => 'Waiting for webview dev server (' + host + ')... attempt ' + attempt,
};
};
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;
}
})();
const devMessages = getDevMessages();
setStatus(devMessages.startingDevServer(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(devMessages.waitingDevServer(hostLabel, attempt));
window.setTimeout(() => {
tryLoadDevBundle();
}, retryDelayMs);
});
};
tryLoadDevBundle();
}
</script>
</body>
</html>`;
}