fix: vscode runtime stability and cli connection

This commit is contained in:
Bohdan Triapitsyn
2025-12-24 03:31:07 +02:00
parent b850a04e21
commit 8e98a72ddf
6 changed files with 525 additions and 236 deletions
@@ -6,7 +6,6 @@ import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { RiAddLine, RiArrowLeftLine, RiSettings3Line } from '@remixicon/react';
import { RiLoader4Line } from '@remixicon/react';
import { SettingsPage } from '@/components/sections/settings/SettingsPage';
type VSCodeView = 'sessions' | 'chat' | 'settings';
@@ -23,14 +22,6 @@ export const VSCodeLayout: React.FC = () => {
'connecting' | 'connected' | 'error' | 'disconnected' | undefined
: 'connecting') || 'connecting'
);
const [connectionError, setConnectionError] = React.useState<string | undefined>(
() => (typeof window !== 'undefined'
? (window as { __OPENCHAMBER_CONNECTION__?: { error?: string } }).__OPENCHAMBER_CONNECTION__?.error
: undefined),
);
const [hasEverConnected, setHasEverConnected] = React.useState<boolean>(() => connectionStatus === 'connected');
const [overlayVisible, setOverlayVisible] = React.useState<boolean>(() => connectionStatus !== 'connected');
const overlayTimer = React.useRef<number | null>(null);
const configInitialized = useConfigStore((state) => state.isInitialized);
const initializeConfig = useConfigStore((state) => state.initializeApp);
const loadSessions = useSessionStore((state) => state.loadSessions);
@@ -62,54 +53,20 @@ export const VSCodeLayout: React.FC = () => {
setCurrentView('chat');
}, [openNewSessionDraft]);
// Listen for connection status changes
React.useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ status?: string; error?: string }>).detail;
const status = detail?.status;
if (status === 'connected' || status === 'connecting' || status === 'error' || status === 'disconnected') {
setConnectionStatus(status);
setConnectionError(detail?.error);
if (status === 'connected') {
setHasEverConnected(true);
}
}
};
window.addEventListener('openchamber:connection-status', handler as EventListener);
return () => window.removeEventListener('openchamber:connection-status', handler as EventListener);
}, []);
const showConnectionOverlay = React.useMemo(() => {
if (hasInitializedOnce && connectionStatus === 'connected' && !isInitializing) {
return false;
}
if (!hasInitializedOnce) {
return connectionStatus !== 'connected' || isInitializing;
}
return connectionStatus === 'error';
}, [connectionStatus, hasInitializedOnce, isInitializing]);
const overlayDelay = hasEverConnected ? 800 : 250;
React.useEffect(() => {
if (overlayTimer.current) {
window.clearTimeout(overlayTimer.current);
overlayTimer.current = null;
}
if (showConnectionOverlay) {
overlayTimer.current = window.setTimeout(() => setOverlayVisible(true), overlayDelay);
} else {
setOverlayVisible(false);
}
return () => {
if (overlayTimer.current) {
window.clearTimeout(overlayTimer.current);
overlayTimer.current = null;
}
};
}, [overlayDelay, showConnectionOverlay]);
// Bootstrap config and sessions when connected
React.useEffect(() => {
const runBootstrap = async () => {
if (isInitializing || hasInitializedOnce || connectionStatus !== 'connected') {
@@ -123,7 +80,7 @@ export const VSCodeLayout: React.FC = () => {
await loadSessions();
setHasInitializedOnce(true);
} catch {
// Ignore bootstrap failures; overlay will remain until next attempt
// Ignore bootstrap failures
} finally {
setIsInitializing(false);
}
@@ -131,6 +88,7 @@ export const VSCodeLayout: React.FC = () => {
void runBootstrap();
}, [connectionStatus, configInitialized, hasInitializedOnce, initializeConfig, isInitializing, loadSessions]);
// Hydrate messages when viewing chat
React.useEffect(() => {
const hydrateMessages = async () => {
if (!hasInitializedOnce || connectionStatus !== 'connected' || currentView !== 'chat' || newSessionDraftOpen) {
@@ -152,8 +110,6 @@ export const VSCodeLayout: React.FC = () => {
void hydrateMessages();
}, [connectionStatus, currentSessionId, currentView, hasInitializedOnce, loadMessages, messages, newSessionDraftOpen]);
return (
<div className="h-full w-full bg-background text-foreground flex flex-col">
{currentView === 'sessions' ? (
@@ -200,21 +156,6 @@ export const VSCodeLayout: React.FC = () => {
</div>
</div>
)}
{overlayVisible && (
<div className="absolute inset-0 z-50 bg-background/90 backdrop-blur-sm flex flex-col items-center justify-center gap-3 text-center px-4">
<RiLoader4Line className="h-7 w-7 animate-spin text-muted-foreground" />
<div className="text-sm font-medium">
{connectionStatus === 'connecting'
? (hasEverConnected ? 'Reconnecting to OpenCode…' : 'Starting OpenCode API…')
: 'Lost connection to OpenCode'}
</div>
{connectionError && (
<div className="text-xs text-muted-foreground max-w-md">
{connectionError}
</div>
)}
</div>
)}
</div>
);
};
+152 -10
View File
@@ -8,6 +8,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
public static readonly viewType = 'openchamber.chatView';
private _view?: vscode.WebviewView;
// Cache latest status/URL for when webview is resolved after connection is ready
private _cachedStatus: ConnectionStatus = 'connecting';
private _cachedError?: string;
private _cachedApiUrl?: string;
constructor(
private readonly _context: vscode.ExtensionContext,
@@ -30,6 +35,9 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview);
// Send theme payload (including optional Shiki theme JSON) after the webview is set up.
void this.updateTheme(vscode.window.activeColorTheme.kind);
// Send cached connection status and API URL (may have been set before webview was resolved)
this._sendCachedState();
webviewView.webview.onDidReceiveMessage(async (message: BridgeRequest) => {
if (message.type === 'restartApi') {
@@ -57,11 +65,31 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
}
public updateConnectionStatus(status: ConnectionStatus, error?: string) {
if (this._view) {
// Cache the latest state
this._cachedStatus = status;
this._cachedError = error;
this._cachedApiUrl = this._openCodeManager?.getApiUrl() || undefined;
// Send to webview if it exists
this._sendCachedState();
}
private _sendCachedState() {
if (!this._view) {
return;
}
this._view.webview.postMessage({
type: 'connectionStatus',
status: this._cachedStatus,
error: this._cachedError,
});
// Send API URL update if we have one
if (this._cachedApiUrl) {
this._view.webview.postMessage({
type: 'connectionStatus',
status,
error,
type: 'apiUrlUpdate',
url: this._cachedApiUrl,
});
}
}
@@ -70,12 +98,21 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
const scriptPath = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview', 'assets', 'index.js');
const scriptUri = webview.asWebviewUri(scriptPath);
const config = vscode.workspace.getConfiguration('openchamber');
const apiUrl = this._openCodeManager?.getApiUrl() || config.get<string>('apiUrl') || 'http://localhost:47339';
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind);
const initialStatus = this._openCodeManager?.getStatus() || 'disconnected';
// Use cached values which are updated by onStatusChange callback
const initialStatus = this._cachedStatus;
const apiUrl = this._cachedApiUrl || '';
const cliAvailable = this._openCodeManager?.isCliAvailable() ?? false;
// 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>
@@ -83,12 +120,91 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src ${webview.cspSource} 'unsafe-inline' 'unsafe-eval'; connect-src * ws: wss: http: https:; img-src ${webview.cspSource} data: https:; font-src ${webview.cspSource} data:;">
<style>
html, body, #root { height: 100%; width: 100%; }
body { margin: 0; padding: 0; overflow: hidden; background: transparent; }
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;
}
/* 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;
}
/* Animation on inner logo only, like OpenChamberLogo.tsx */
#initial-loading .logo-inner {
animation: logoPulse 3s ease-in-out infinite;
}
#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;
}
@keyframes logoPulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
</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 with pulse animation -->
<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>
<div class="status-text" id="loading-status">
${initialStatus === 'connecting' ? 'Starting OpenCode API…' : initialStatus === 'connected' ? 'Initializing…' : 'Connecting…'}
</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
@@ -98,9 +214,35 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
apiUrl: "${apiUrl}",
workspaceFolder: "${workspaceFolder.replace(/\\/g, '\\\\')}",
theme: "${themeKind}",
connectionStatus: "${initialStatus}"
connectionStatus: "${initialStatus}",
cliAvailable: ${cliAvailable}
};
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');
var loadingEl = document.getElementById('initial-loading');
if (statusEl) {
if (msg.status === 'connecting') {
statusEl.textContent = 'Starting OpenCode API…';
} else if (msg.status === 'connected') {
statusEl.textContent = 'Connected!';
// Fade out loading screen once connected and UI is ready
setTimeout(function() {
if (loadingEl) loadingEl.classList.add('fade-out');
}, 300);
} else if (msg.status === 'error') {
statusEl.textContent = msg.error || 'Connection error';
statusEl.classList.add('error-text');
} else {
statusEl.textContent = 'Reconnecting…';
}
}
}
});
</script>
<script type="module" src="${scriptUri}"></script>
</body>
+271 -124
View File
@@ -3,12 +3,17 @@ import { spawn, ChildProcess, spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as net from 'net';
const DEFAULT_PORT = 47339;
const HEALTH_CHECK_INTERVAL = 5000;
const STARTUP_TIMEOUT = 10000;
const SHUTDOWN_TIMEOUT = 3000;
// Optimized timeouts for faster startup
const PORT_DETECTION_TIMEOUT_MS = 10000;
const READY_CHECK_TIMEOUT_MS = 12000;
const READY_CHECK_INTERVAL_MS = 100; // Fast polling during startup
const HEALTH_CHECK_INTERVAL_MS = 5000;
const SHUTDOWN_TIMEOUT_MS = 3000;
// Regex to detect port from CLI output (matches desktop pattern)
const URL_REGEX = /https?:\/\/[^:\s]+:(\d+)(?:\/[^\s"']*)?/gi;
const FALLBACK_PORT_REGEX = /(?:^|\s)(?:127\.0\.0\.1|localhost):(\d+)/i;
const BIN_CANDIDATES = [
process.env.OPENCHAMBER_OPENCODE_PATH,
@@ -19,6 +24,7 @@ const BIN_CANDIDATES = [
'/usr/local/bin/opencode',
'/usr/bin/opencode',
path.join(os.homedir(), '.local/bin/opencode'),
path.join(os.homedir(), '.opencode/bin/opencode'),
].filter(Boolean) as string[];
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
@@ -29,8 +35,9 @@ export interface OpenCodeManager {
restart(): Promise<void>;
setWorkingDirectory(path: string): Promise<{ success: boolean; restarted: boolean; path: string }>;
getStatus(): ConnectionStatus;
getApiUrl(): string;
getApiUrl(): string | null;
getWorkingDirectory(): string;
isCliAvailable(): boolean;
onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable;
}
@@ -43,21 +50,67 @@ function isExecutable(filePath: string): boolean {
}
}
function getLoginShellPath(): string | null {
if (process.platform === 'win32') {
return null;
}
const shell = process.env.SHELL || '/bin/zsh';
try {
const result = spawnSync(shell, ['-lic', 'echo -n "$PATH"'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
if (result.status === 0 && typeof result.stdout === 'string') {
const value = result.stdout.trim();
if (value) {
return value;
}
}
} catch {
// ignore
}
return null;
}
function buildAugmentedPath(): string {
const augmented = new Set<string>();
const loginPath = getLoginShellPath();
if (loginPath) {
for (const segment of loginPath.split(path.delimiter)) {
if (segment) {
augmented.add(segment);
}
}
}
const current = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
for (const segment of current) {
augmented.add(segment);
}
return Array.from(augmented).join(path.delimiter);
}
function resolveCliPath(): string | null {
// First check explicit candidates
for (const candidate of BIN_CANDIDATES) {
if (candidate && isExecutable(candidate)) {
return candidate;
}
}
const envPath = process.env.PATH || '';
for (const segment of envPath.split(path.delimiter)) {
// Then search in augmented PATH
const augmentedPath = buildAugmentedPath();
for (const segment of augmentedPath.split(path.delimiter)) {
const candidate = path.join(segment, 'opencode');
if (isExecutable(candidate)) {
return candidate;
}
}
// Fallback: try login shell detection
if (process.platform !== 'win32') {
const shellCandidates = [
process.env.SHELL,
@@ -88,21 +141,40 @@ function resolveCliPath(): string | null {
return null;
}
async function checkHealth(apiUrl: string): Promise<boolean> {
async function checkHealth(apiUrl: string, quick = false): Promise<boolean> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const candidates = [`${apiUrl}/health`, `${apiUrl}/api/health`];
const timeoutMs = quick ? 1500 : 3000;
const timeout = setTimeout(() => controller.abort(), timeoutMs);
// For quick checks during startup, just check /health
if (quick) {
try {
const response = await fetch(`${apiUrl}/health`, { signal: controller.signal });
clearTimeout(timeout);
return response.ok;
} catch {
clearTimeout(timeout);
return false;
}
}
// Full health check: verify multiple endpoints
const candidates = [`${apiUrl}/health`, `${apiUrl}/config`];
let successCount = 0;
for (const target of candidates) {
try {
const response = await fetch(target, { signal: controller.signal });
if (response.ok) {
clearTimeout(timeout);
return true;
successCount++;
if (successCount >= 2) {
clearTimeout(timeout);
return true;
}
}
} catch {
// try next candidate
// try next
}
}
clearTimeout(timeout);
@@ -112,93 +184,39 @@ async function checkHealth(apiUrl: string): Promise<boolean> {
return false;
}
function hashWorkspaceIdentifier(identifier: string): number {
let hash = 0;
for (let i = 0; i < identifier.length; i++) {
hash = (hash * 31 + identifier.charCodeAt(i)) >>> 0;
}
return hash;
}
async function findAvailablePort(startPort: number, maxAttempts = 20): Promise<number> {
let port = startPort;
for (let i = 0; i < maxAttempts; i += 1) {
const available = await new Promise<boolean>((resolve) => {
const server = net.createServer();
server.once('error', () => {
server.close();
resolve(false);
});
server.listen(port, () => {
server.close(() => resolve(true));
});
});
if (available) {
return port;
}
port += 1;
}
return startPort;
}
export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCodeManager {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCodeManager {
let childProcess: ChildProcess | null = null;
let status: ConnectionStatus = 'disconnected';
let healthCheckInterval: NodeJS.Timeout | null = null;
let lastError: string | undefined;
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
let workingDirectory: string = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
const workspaceKey = `openchamber.api.port.${hashWorkspaceIdentifier(workspaceFolder)}`;
// Port detection state (like desktop)
let detectedPort: number | null = null;
let portWaiters: Array<(port: number) => void> = [];
// Check if user configured a specific API URL
const config = vscode.workspace.getConfiguration('openchamber');
const configuredApiUrl = config.get<string>('apiUrl') || '';
const storedPort = context.workspaceState.get<number>(workspaceKey);
let apiUrl: string = storedPort && Number.isFinite(storedPort)
? `http://localhost:${storedPort}`
: `http://localhost:${DEFAULT_PORT}`;
let desiredPort: number = storedPort && Number.isFinite(storedPort) ? storedPort : DEFAULT_PORT;
const parseApiUrl = (candidate: string): { url: string; port: number } | null => {
const useConfiguredUrl = configuredApiUrl && configuredApiUrl.trim().length > 0;
// Parse configured URL to extract port if specified
let configuredPort: number | null = null;
if (useConfiguredUrl) {
try {
const parsed = new URL(candidate);
const origin = parsed.origin;
const pathname = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname.replace(/\/+$/, '') : '';
const normalized = `${origin}${pathname}`;
const port = parsed.port ? parseInt(parsed.port, 10) : DEFAULT_PORT;
return {
url: normalized,
port: Number.isFinite(port) && port > 0 ? port : DEFAULT_PORT,
};
const parsed = new URL(configuredApiUrl);
if (parsed.port) {
configuredPort = parseInt(parsed.port, 10);
}
} catch {
return null;
// Invalid URL, will use dynamic port
}
};
}
const resolveApi = async () => {
// If user explicitly set a non-default URL, honor it (shared across workspaces).
const parsed = configuredApiUrl ? parseApiUrl(configuredApiUrl) : null;
const isDefault = !parsed || parsed.port === DEFAULT_PORT;
if (!isDefault && parsed) {
return parsed;
}
// Workspace-isolated port selection
const storedPort = context.workspaceState.get<number>(workspaceKey);
const basePort = storedPort && Number.isFinite(storedPort) ? storedPort : DEFAULT_PORT + (hashWorkspaceIdentifier(workspaceFolder) % 1000);
const port = await findAvailablePort(basePort);
void context.workspaceState.update(workspaceKey, port);
return { url: `http://localhost:${port}`, port };
};
const apiConfigPromise = resolveApi().then((result) => {
apiUrl = result.url;
desiredPort = result.port;
return result;
}).catch(() => null);
const cliPath = resolveCliPath();
const cliAvailable = cliPath !== null;
function setStatus(newStatus: ConnectionStatus, error?: string) {
if (status !== newStatus || lastError !== error) {
@@ -208,27 +226,108 @@ export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCod
}
}
async function waitForHealthy(timeoutMs: number): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (await checkHealth(apiUrl)) {
function setDetectedPort(port: number) {
if (detectedPort !== port) {
detectedPort = port;
console.log(`[OpenCode] Detected port: ${port}`);
// Notify all waiters
const waiters = portWaiters;
portWaiters = [];
for (const notify of waiters) {
try {
notify(port);
} catch (e) {
console.warn('[OpenCode] Port waiter error:', e);
}
}
}
}
function detectPortFromOutput(text: string) {
// Match URL pattern first (like desktop)
URL_REGEX.lastIndex = 0;
let match;
while ((match = URL_REGEX.exec(text)) !== null) {
const port = parseInt(match[1], 10);
if (Number.isFinite(port) && port > 0) {
setDetectedPort(port);
return;
}
}
// Fallback pattern
const fallbackMatch = FALLBACK_PORT_REGEX.exec(text);
if (fallbackMatch) {
const port = parseInt(fallbackMatch[1], 10);
if (Number.isFinite(port) && port > 0) {
setDetectedPort(port);
}
}
}
async function waitForPort(timeoutMs: number): Promise<number> {
if (detectedPort !== null) {
return detectedPort;
}
return new Promise((resolve, reject) => {
const onPortDetected = (port: number) => {
clearTimeout(timeout);
resolve(port);
};
const timeout = setTimeout(() => {
portWaiters = portWaiters.filter(cb => cb !== onPortDetected);
reject(new Error('Timed out waiting for OpenCode port detection'));
}, timeoutMs);
portWaiters.push(onPortDetected);
});
}
async function waitForReady(apiUrl: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
// Use quick health check during startup for faster response
if (await checkHealth(apiUrl, true)) {
return true;
}
await new Promise(r => setTimeout(r, 500));
await new Promise(r => setTimeout(r, READY_CHECK_INTERVAL_MS));
}
return false;
}
function getApiUrl(): string | null {
if (useConfiguredUrl && configuredApiUrl) {
return configuredApiUrl.replace(/\/+$/, '');
}
if (detectedPort !== null) {
return `http://localhost:${detectedPort}`;
}
return null;
}
function startHealthCheck() {
stopHealthCheck();
healthCheckInterval = setInterval(async () => {
const apiUrl = getApiUrl();
if (!apiUrl) {
if (status === 'connected') {
setStatus('disconnected');
}
return;
}
const healthy = await checkHealth(apiUrl);
if (healthy && status !== 'connected') {
setStatus('connected');
} else if (!healthy && status === 'connected') {
setStatus('disconnected');
}
}, HEALTH_CHECK_INTERVAL);
}, HEALTH_CHECK_INTERVAL_MS);
}
function stopHealthCheck() {
@@ -238,24 +337,37 @@ export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCod
}
}
async function start(workdir?: string) {
await apiConfigPromise;
async function start(workdir?: string): Promise<void> {
if (typeof workdir === 'string' && workdir.trim().length > 0) {
workingDirectory = workdir.trim();
}
// First check if API is already running
if (await checkHealth(apiUrl)) {
// If user configured an external API URL, just check if it's healthy
if (useConfiguredUrl && configuredApiUrl) {
setStatus('connecting');
const healthy = await checkHealth(configuredApiUrl);
if (healthy) {
setStatus('connected');
startHealthCheck();
return;
}
// If configured URL isn't responding and no CLI, show error
if (!cliAvailable) {
setStatus('error', `OpenCode API at ${configuredApiUrl} is not responding and CLI is not available.`);
return;
}
// Fall through to start CLI with configured port if possible
}
// Check for existing running instance (only if port is known)
const currentUrl = getApiUrl();
if (currentUrl && await checkHealth(currentUrl)) {
setStatus('connected');
startHealthCheck();
return;
}
setStatus('connecting');
const cliPath = resolveCliPath();
if (!cliPath) {
if (!cliAvailable) {
setStatus('error', 'OpenCode CLI not found. Install it or set OPENCODE_BINARY env var.');
vscode.window.showErrorMessage(
'OpenCode CLI not found. Please install it or set the OPENCODE_BINARY environment variable.',
@@ -268,25 +380,39 @@ export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCod
return;
}
setStatus('connecting');
// Reset port detection for fresh start
detectedPort = null;
const spawnCwd = workingDirectory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
// Use port 0 for dynamic assignment unless user configured a specific port
const portArg = configuredPort !== null ? configuredPort.toString() : '0';
try {
childProcess = spawn(cliPath, ['serve', '--port', desiredPort.toString()], {
const augmentedEnv = {
...process.env,
PATH: buildAugmentedPath(),
};
childProcess = spawn(cliPath!, ['serve', '--port', portArg], {
cwd: spawnCwd,
env: {
...process.env,
OPENCODE_PORT: desiredPort.toString(),
},
env: augmentedEnv,
detached: false,
stdio: ['ignore', 'pipe', 'pipe'],
});
childProcess.stdout?.on('data', (data) => {
console.log('[OpenCode]', data.toString());
const text = data.toString();
console.log('[OpenCode]', text.trim());
detectPortFromOutput(text);
});
childProcess.stderr?.on('data', (data) => {
console.error('[OpenCode]', data.toString());
const text = data.toString();
console.error('[OpenCode]', text.trim());
detectPortFromOutput(text);
});
childProcess.on('error', (err) => {
@@ -294,20 +420,37 @@ export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCod
childProcess = null;
});
childProcess.on('exit', () => {
childProcess.on('exit', (code) => {
if (status !== 'disconnected') {
setStatus('disconnected');
setStatus('disconnected', code !== 0 ? `OpenCode exited with code ${code}` : undefined);
}
childProcess = null;
detectedPort = null;
});
// Wait for API to become healthy
const healthy = await waitForHealthy(STARTUP_TIMEOUT);
if (healthy) {
// Wait for port detection (port comes from stdout/stderr)
try {
await waitForPort(PORT_DETECTION_TIMEOUT_MS);
} catch {
setStatus('error', 'OpenCode did not report port in time');
await stop();
return;
}
// Now wait for API to be ready
const apiUrl = getApiUrl();
if (!apiUrl) {
setStatus('error', 'Failed to determine OpenCode API URL');
await stop();
return;
}
const ready = await waitForReady(apiUrl, READY_CHECK_TIMEOUT_MS);
if (ready) {
setStatus('connected');
startHealthCheck();
} else {
setStatus('error', 'OpenCode API did not start in time');
setStatus('error', 'OpenCode API did not become ready in time');
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -315,14 +458,14 @@ export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCod
}
}
async function stop() {
async function stop(): Promise<void> {
stopHealthCheck();
if (childProcess) {
try {
childProcess.kill('SIGTERM');
// Wait a bit for graceful shutdown
await new Promise(r => setTimeout(r, SHUTDOWN_TIMEOUT));
// Wait for graceful shutdown
await new Promise(r => setTimeout(r, SHUTDOWN_TIMEOUT_MS));
if (childProcess && !childProcess.killed && childProcess.exitCode === null) {
childProcess.kill('SIGKILL');
}
@@ -332,16 +475,19 @@ export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCod
childProcess = null;
}
detectedPort = null;
setStatus('disconnected');
}
async function restart() {
async function restart(): Promise<void> {
await stop();
// Brief delay to let OS release resources
await new Promise(r => setTimeout(r, 250));
await start();
}
async function setWorkingDirectory(path: string) {
const target = typeof path === 'string' && path.trim().length > 0 ? path.trim() : workingDirectory;
async function setWorkingDirectory(newPath: string): Promise<{ success: boolean; restarted: boolean; path: string }> {
const target = typeof newPath === 'string' && newPath.trim().length > 0 ? newPath.trim() : workingDirectory;
workingDirectory = target;
await restart();
return { success: true, restarted: true, path: target };
@@ -353,8 +499,9 @@ export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCod
restart,
setWorkingDirectory,
getStatus: () => status,
getApiUrl: () => apiUrl,
getApiUrl,
getWorkingDirectory: () => workingDirectory,
isCliAvailable: () => cliAvailable,
onStatusChange(callback) {
listeners.add(callback);
// Immediately call with current status
@@ -1,5 +1,6 @@
import React from 'react';
import { useSessionStore } from '@openchamber/ui/stores/useSessionStore';
import { useConfigStore } from '@openchamber/ui/stores/useConfigStore';
import { useNavigation } from '../hooks/useNavigation';
import { VSCodeHeader } from './VSCodeHeader';
import { SimpleMessageRenderer } from './SimpleMessageRenderer';
@@ -15,6 +16,7 @@ export function ChatPanel() {
const sendMessage = useSessionStore((s) => s.sendMessage);
const abortCurrentOperation = useSessionStore((s) => s.abortCurrentOperation);
const streamingMessageIds = useSessionStore((s) => s.streamingMessageIds);
const { currentProviderId, currentModelId, currentAgentName } = useConfigStore();
const [inputValue, setInputValue] = React.useState('');
const [isSending, setIsSending] = React.useState(false);
@@ -33,11 +35,17 @@ export function ChatPanel() {
if (!inputValue.trim() || !currentSessionId || isSending) return;
const messageText = inputValue.trim();
if (!currentProviderId || !currentModelId) {
console.warn('Missing provider or model selection for sendMessage');
return;
}
setInputValue('');
setIsSending(true);
try {
await sendMessage(messageText);
await sendMessage(messageText, currentProviderId, currentModelId, currentAgentName);
} catch (error) {
console.error('Failed to send message:', error);
setInputValue(messageText); // Restore input on error
@@ -55,7 +63,7 @@ export function ChatPanel() {
const handleAbort = () => {
if (currentSessionId) {
abortCurrentOperation(currentSessionId);
void abortCurrentOperation();
}
};
@@ -1,5 +1,5 @@
import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk';
import type { Message, Part, ToolPart } from '@opencode-ai/sdk';
interface SimpleMessageRendererProps {
message: { info: Message; parts: Part[] };
@@ -16,7 +16,7 @@ export function SimpleMessageRenderer({ message }: SimpleMessageRendererProps) {
.join('\n');
// Check for tool calls
const toolParts = parts.filter((part) => part.type === 'tool-invocation' || part.type === 'tool-result');
const toolParts = parts.filter((part): part is ToolPart => part.type === 'tool');
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
@@ -57,32 +57,20 @@ export function SimpleMessageRenderer({ message }: SimpleMessageRendererProps) {
);
}
function ToolPartRenderer({ part }: { part: Part }) {
if (part.type === 'tool-invocation') {
const toolName = part.toolInvocation?.toolName || 'tool';
const state = part.toolInvocation?.state || 'pending';
function ToolPartRenderer({ part }: { part: ToolPart }) {
const toolName = typeof part.tool === 'string' ? part.tool : 'tool';
const status = typeof part.state?.status === 'string' ? part.state.status : 'pending';
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{state === 'pending' || state === 'streaming' ? (
<span className="inline-block w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : state === 'result' ? (
<span className="text-green-500"></span>
) : (
<span className="text-red-500"></span>
)}
<span className="font-mono">{toolName}</span>
</div>
);
}
if (part.type === 'tool-result') {
return (
<div className="text-xs text-muted-foreground font-mono truncate">
Result: {typeof part.result === 'string' ? part.result.slice(0, 50) : '...'}
</div>
);
}
return null;
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{status === 'running' || status === 'pending' ? (
<span className="inline-block w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : status === 'completed' ? (
<span className="text-green-500"></span>
) : (
<span className="text-red-500"></span>
)}
<span className="font-mono">{toolName}</span>
</div>
);
}
+71 -8
View File
@@ -18,10 +18,11 @@ declare global {
workspaceFolder: string;
theme: string;
connectionStatus: string;
cliAvailable?: boolean;
};
__OPENCHAMBER_VSCODE_THEME__?: VSCodeThemePayload['theme'];
__OPENCHAMBER_VSCODE_SHIKI_THEMES__?: { light?: Record<string, unknown>; dark?: Record<string, unknown> } | null;
__OPENCHAMBER_CONNECTION__?: { status: ConnectionStatus; error?: string };
__OPENCHAMBER_CONNECTION__?: { status: ConnectionStatus; error?: string; cliAvailable?: boolean };
__OPENCHAMBER_HOME__?: string;
}
}
@@ -33,7 +34,8 @@ window.__OPENCHAMBER_RUNTIME_APIS__ = createVSCodeAPIs();
const bootstrapConnectionStatus = () => {
const initialStatus = (window.__VSCODE_CONFIG__?.connectionStatus as ConnectionStatus | undefined) || 'connecting';
window.__OPENCHAMBER_CONNECTION__ = { status: initialStatus };
const cliAvailable = window.__VSCODE_CONFIG__?.cliAvailable ?? true;
window.__OPENCHAMBER_CONNECTION__ = { status: initialStatus, cliAvailable };
};
bootstrapConnectionStatus();
@@ -43,8 +45,18 @@ const handleConnectionMessage = (event: MessageEvent) => {
if (msg?.type === 'connectionStatus') {
const payload: ConnectionStatus = msg.status;
const error: string | undefined = msg.error;
window.__OPENCHAMBER_CONNECTION__ = { status: payload, 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 } }));
// Hide loading screen when connected
if (payload === 'connected') {
const loadingEl = document.getElementById('initial-loading');
if (loadingEl) {
loadingEl.classList.add('fade-out');
setTimeout(() => loadingEl.remove(), 300);
}
}
}
};
@@ -134,14 +146,55 @@ const normalizeUrl = (input: string | URL) => {
}
};
const apiBaseUrl = window.__VSCODE_CONFIG__?.apiUrl?.replace(/\/+$/, '') || 'http://localhost:47339';
// API URL may be empty during initial load while port is being detected
// The extension will broadcast the URL once it's known
let apiBaseUrl = window.__VSCODE_CONFIG__?.apiUrl?.replace(/\/+$/, '') || '';
// Promise that resolves when API URL is available
let apiUrlResolver: ((url: string) => void) | null = null;
const apiUrlPromise = apiBaseUrl
? Promise.resolve(apiBaseUrl)
: new Promise<string>((resolve) => { apiUrlResolver = resolve; });
// Listen for API URL updates from extension
window.addEventListener('message', (event: MessageEvent) => {
const msg = event.data;
if (msg?.type === 'apiUrlUpdate' && typeof msg.url === 'string') {
const newUrl = msg.url.replace(/\/+$/, '');
apiBaseUrl = newUrl;
console.log('[OpenChamber] API URL updated:', apiBaseUrl);
// Resolve the promise if waiting
if (apiUrlResolver) {
apiUrlResolver(newUrl);
apiUrlResolver = null;
}
}
});
// Helper to wait for API URL with timeout
const waitForApiUrl = async (timeoutMs = 15000): Promise<string> => {
if (apiBaseUrl) return apiBaseUrl;
const timeout = new Promise<string>((_, reject) =>
setTimeout(() => reject(new Error('Timeout waiting for API URL')), timeoutMs)
);
return Promise.race([apiUrlPromise, timeout]);
};
const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
const pathname = url.pathname;
// Health endpoints: always return OK to avoid blocking VS Code UX
// Health endpoints: reflect actual connection status
if (pathname === '/health' || pathname === '/api/health') {
return new Response(JSON.stringify({ status: 'ok', isOpenCodeReady: true }), {
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' },
});
@@ -258,7 +311,14 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const pathname = targetUrl?.pathname || '';
const normalizedPathname = pathname.replace(/\/+/, '/');
if (targetUrl && normalizedPathname === '/health') {
return new Response(JSON.stringify({ status: 'ok', isOpenCodeReady: true }), {
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' },
});
@@ -270,9 +330,12 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
return localResponse;
}
// Wait for API URL to be available before making requests
const baseUrl = await waitForApiUrl();
const rewritten = new URL(targetUrl.href);
rewritten.pathname = targetUrl.pathname.replace(/^\/api/, '');
const fetchTarget = `${apiBaseUrl}${rewritten.pathname}${rewritten.search}`;
const fetchTarget = `${baseUrl}${rewritten.pathname}${rewritten.search}`;
if (input instanceof Request) {
const cloned = input.clone();