diff --git a/packages/vscode/src/ChatViewProvider.ts b/packages/vscode/src/ChatViewProvider.ts index 7c01a5f4..545be6e8 100644 --- a/packages/vscode/src/ChatViewProvider.ts +++ b/packages/vscode/src/ChatViewProvider.ts @@ -142,7 +142,13 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { }); } } - + + public postMessage(message: unknown): void { + if (this._view) { + this._view.webview.postMessage(message); + } + } + private _sendCachedState() { if (!this._view) { return; diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index db022360..d1a7974f 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -3,6 +3,7 @@ import { ChatViewProvider } from './ChatViewProvider'; import { AgentManagerPanelProvider } from './AgentManagerPanelProvider'; import { SessionEditorPanelProvider } from './SessionEditorPanelProvider'; import { createOpenCodeManager, type OpenCodeManager } from './opencode'; +import { startGlobalEventWatcher, stopGlobalEventWatcher, setChatViewProvider } from './sessionActivityWatcher'; let chatViewProvider: ChatViewProvider | undefined; let agentManagerProvider: AgentManagerPanelProvider | undefined; @@ -539,6 +540,15 @@ export async function activate(context: vscode.ExtensionContext) { chatViewProvider?.updateConnectionStatus(status, error); agentManagerProvider?.updateConnectionStatus(status, error); sessionEditorProvider?.updateConnectionStatus(status, error); + + // Start/stop global event watcher based on connection status + // Mirrors web server and desktop Tauri behavior + if (status === 'connected' && chatViewProvider && openCodeManager) { + setChatViewProvider(chatViewProvider); + void startGlobalEventWatcher(openCodeManager, chatViewProvider); + } else if (status === 'disconnected' || status === 'error') { + stopGlobalEventWatcher(); + } }) ); @@ -548,6 +558,7 @@ export async function activate(context: vscode.ExtensionContext) { } export async function deactivate() { + stopGlobalEventWatcher(); await openCodeManager?.stop(); openCodeManager = undefined; chatViewProvider = undefined; diff --git a/packages/vscode/src/sessionActivityWatcher.ts b/packages/vscode/src/sessionActivityWatcher.ts new file mode 100644 index 00000000..8f82b68e --- /dev/null +++ b/packages/vscode/src/sessionActivityWatcher.ts @@ -0,0 +1,280 @@ +import type { OpenCodeManager } from './opencode'; + +// Session activity tracking (mirrors web server and desktop Tauri behavior) +type ActivityPhase = 'idle' | 'busy' | 'cooldown'; + +interface SessionActivity { + sessionId: string; + phase: ActivityPhase; +} + +const sessionActivityPhases = new Map(); +const sessionActivityCooldowns = new Map(); +const SESSION_COOLDOWN_DURATION_MS = 2000; + +let globalEventWatcherAbortController: AbortController | null = null; +let chatViewProvider: { postMessage: (message: unknown) => void } | null = null; + +const setSessionActivityPhase = (sessionId: string, phase: ActivityPhase): void => { + if (!sessionId) return; + + // Cancel existing cooldown timer + const existingTimer = sessionActivityCooldowns.get(sessionId); + if (existingTimer) { + clearTimeout(existingTimer); + sessionActivityCooldowns.delete(sessionId); + } + + const current = sessionActivityPhases.get(sessionId); + if (current?.phase === phase) return; // No change + + sessionActivityPhases.set(sessionId, { phase, updatedAt: Date.now() }); + + // Notify webview if available + if (chatViewProvider) { + chatViewProvider.postMessage({ + type: 'openchamber:session-activity', + properties: { + sessionId, + phase, + }, + }); + } + + // Schedule transition from cooldown to idle + if (phase === 'cooldown') { + const timer = setTimeout(() => { + const now = sessionActivityPhases.get(sessionId); + if (now?.phase === 'cooldown') { + sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: Date.now() }); + if (chatViewProvider) { + chatViewProvider.postMessage({ + type: 'openchamber:session-activity', + properties: { + sessionId, + phase: 'idle', + }, + }); + } + } + sessionActivityCooldowns.delete(sessionId); + }, SESSION_COOLDOWN_DURATION_MS); + sessionActivityCooldowns.set(sessionId, timer); + } +}; + +const deriveSessionActivity = (payload: Record): SessionActivity | null => { + if (!payload || typeof payload !== 'object') { + return null; + } + + const type = payload.type as string; + const properties = (payload.properties ?? payload) as Record; + + if (type === 'session.status') { + const status = properties?.status as Record | undefined; + const sessionId = (properties?.sessionID ?? properties?.sessionId) as string; + const statusType = status?.type as string; + + if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') { + const phase = statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle'; + return { sessionId, phase }; + } + } + + if (type === 'message.updated' || type === 'message.part.updated') { + const info = properties?.info as Record | undefined; + const sessionId = (info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId) as string; + const role = info?.role as string; + const finish = info?.finish as string; + if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') { + return { sessionId, phase: 'cooldown' }; + } + } + + if (type === 'session.idle') { + const sessionId = (properties?.sessionID ?? properties?.sessionId) as string; + if (typeof sessionId === 'string' && sessionId.length > 0) { + return { sessionId, phase: 'idle' }; + } + } + + return null; +}; + +const parseSseDataPayload = (block: string): Record | null => { + if (!block) { + return null; + } + + const lines = block.split('\n'); + const dataLines: string[] = []; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).replace(/^\s/, '')); + } + } + + if (dataLines.length === 0) { + return null; + } + + const payloadText = dataLines.join('\n').trim(); + if (!payloadText) { + return null; + } + + try { + return JSON.parse(payloadText) as Record; + } catch { + return null; + } +}; + +const waitForOpenCodePort = async (manager: OpenCodeManager, timeoutMs = 30000): Promise => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const apiUrl = manager.getApiUrl(); + if (apiUrl) { + try { + const url = new URL(apiUrl); + if (url.port) { + return parseInt(url.port, 10); + } + } catch { + // ignore + } + } + await new Promise(r => setTimeout(r, 500)); + } + return null; +}; + +const buildOpenCodeUrl = (pathname: string, baseUrl: string): string => { + const normalized = baseUrl.replace(/\/+$/, ''); + return `${normalized}${pathname}`; +}; + +export const startGlobalEventWatcher = async ( + manager: OpenCodeManager, + provider: { postMessage: (message: unknown) => void } +): Promise => { + if (globalEventWatcherAbortController) { + return; + } + + chatViewProvider = provider; + + const port = await waitForOpenCodePort(manager); + if (!port) { + console.warn('[VSCode:Activity] OpenCode port unavailable; will retry'); + setTimeout(() => startGlobalEventWatcher(manager, provider), 2000); + return; + } + + globalEventWatcherAbortController = new AbortController(); + const signal = globalEventWatcherAbortController.signal; + + let attempt = 0; + + const run = async (): Promise => { + while (!signal.aborted) { + attempt += 1; + let upstream: Response | null = null; + let reader: ReadableStreamDefaultReader | null = null; + + try { + const baseUrl = manager.getApiUrl(); + if (!baseUrl) { + throw new Error('OpenCode API URL not available'); + } + + const url = buildOpenCodeUrl('/global/event', baseUrl); + upstream = await fetch(url, { + headers: { + Accept: 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + signal, + }); + + if (!upstream.ok || !upstream.body) { + throw new Error(`bad status ${upstream.status}`); + } + + console.log('[VSCode:Activity] connected'); + + const decoder = new TextDecoder(); + reader = upstream.body.getReader(); + let buffer = ''; + + while (!signal.aborted) { + const { value, done } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n'); + + let separatorIndex: number; + while ((separatorIndex = buffer.indexOf('\n\n')) !== -1) { + const block = buffer.slice(0, separatorIndex); + buffer = buffer.slice(separatorIndex + 2); + const payload = parseSseDataPayload(block); + if (payload) { + const activity = deriveSessionActivity(payload); + if (activity) { + setSessionActivityPhase(activity.sessionId, activity.phase); + } + } + } + } + } catch (error) { + if (signal.aborted) { + return; + } + console.warn('[VSCode:Activity] disconnected', error instanceof Error ? error.message : error); + } finally { + try { + if (reader) { + await reader.cancel(); + } else if (upstream?.body && !(upstream.body as ReadableStream).locked) { + await upstream.body.cancel(); + } + } catch { + // ignore + } + } + + const backoffMs = Math.min(1000 * Math.pow(2, Math.min(attempt, 5)), 30000); + await new Promise(r => setTimeout(r, backoffMs)); + } + }; + + void run(); +}; + +export const stopGlobalEventWatcher = (): void => { + if (!globalEventWatcherAbortController) { + return; + } + try { + globalEventWatcherAbortController.abort(); + } catch { + // ignore + } + globalEventWatcherAbortController = null; + chatViewProvider = null; + + // Clear all cooldown timers + for (const timer of sessionActivityCooldowns.values()) { + clearTimeout(timer); + } + sessionActivityCooldowns.clear(); +}; + +export const setChatViewProvider = (provider: { postMessage: (message: unknown) => void } | null): void => { + chatViewProvider = provider; +};