diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index 547a2916..83085a16 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -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( - () => (typeof window !== 'undefined' - ? (window as { __OPENCHAMBER_CONNECTION__?: { error?: string } }).__OPENCHAMBER_CONNECTION__?.error - : undefined), - ); - const [hasEverConnected, setHasEverConnected] = React.useState(() => connectionStatus === 'connected'); - const [overlayVisible, setOverlayVisible] = React.useState(() => connectionStatus !== 'connected'); - const overlayTimer = React.useRef(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 (
{currentView === 'sessions' ? ( @@ -200,21 +156,6 @@ export const VSCodeLayout: React.FC = () => {
)} - {overlayVisible && ( -
- -
- {connectionStatus === 'connecting' - ? (hasEverConnected ? 'Reconnecting to OpenCode…' : 'Starting OpenCode API…') - : 'Lost connection to OpenCode'} -
- {connectionError && ( -
- {connectionError} -
- )} -
- )} ); }; diff --git a/packages/vscode/src/ChatViewProvider.ts b/packages/vscode/src/ChatViewProvider.ts index 81691d24..cb7f2172 100644 --- a/packages/vscode/src/ChatViewProvider.ts +++ b/packages/vscode/src/ChatViewProvider.ts @@ -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('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 ` @@ -83,12 +120,91 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { OpenChamber + +
+ +
+ ${initialStatus === 'connecting' ? 'Starting OpenCode API…' : initialStatus === 'connected' ? 'Initializing…' : 'Connecting…'} +
+ ${!cliAvailable ? `
OpenCode CLI not found. Please install it first.
` : ''} +
+
diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index cbacf582..aa0ee538 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -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; 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(); + + 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 { +async function checkHealth(apiUrl: string, quick = false): Promise { 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 { 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 { - let port = startPort; - for (let i = 0; i < maxAttempts; i += 1) { - const available = await new Promise((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('apiUrl') || ''; - - const storedPort = context.workspaceState.get(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(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 { - 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 { + 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 { + 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 { 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 { 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 { 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 diff --git a/packages/vscode/webview/components/ChatPanel.tsx b/packages/vscode/webview/components/ChatPanel.tsx index d51a706d..d8a7cef2 100644 --- a/packages/vscode/webview/components/ChatPanel.tsx +++ b/packages/vscode/webview/components/ChatPanel.tsx @@ -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(); } }; diff --git a/packages/vscode/webview/components/SimpleMessageRenderer.tsx b/packages/vscode/webview/components/SimpleMessageRenderer.tsx index 286b8630..db3adaa3 100644 --- a/packages/vscode/webview/components/SimpleMessageRenderer.tsx +++ b/packages/vscode/webview/components/SimpleMessageRenderer.tsx @@ -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 (
@@ -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 ( -
- {state === 'pending' || state === 'streaming' ? ( - - ) : state === 'result' ? ( - - ) : ( - - )} - {toolName} -
- ); - } - - if (part.type === 'tool-result') { - return ( -
- Result: {typeof part.result === 'string' ? part.result.slice(0, 50) : '...'} -
- ); - } - - return null; + return ( +
+ {status === 'running' || status === 'pending' ? ( + + ) : status === 'completed' ? ( + + ) : ( + + )} + {toolName} +
+ ); } diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index ec2e4e6f..67549cb4 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -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; dark?: Record } | 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((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 => { + if (apiBaseUrl) return apiBaseUrl; + + const timeout = new Promise((_, 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();