From 032229c7a2e970d0632bb7eae4afb71df822a484 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 14 Jan 2026 17:13:47 +0200 Subject: [PATCH] feat(vscode): add session editor panel (#146) Add command to open active session in editor panel. Introduce editor view mode for displaying sessions alongside files. Update toolbar navigation for editor and sidebar modes. --- .../ui/src/components/layout/VSCodeLayout.tsx | 89 ++- packages/vscode/package.json | 15 +- .../vscode/src/AgentManagerPanelProvider.ts | 5 + .../vscode/src/SessionEditorPanelProvider.ts | 531 ++++++++++++++++++ packages/vscode/src/extension.ts | 42 ++ packages/vscode/src/webviewHtml.ts | 17 +- packages/vscode/webview/main.tsx | 2 + 7 files changed, 692 insertions(+), 9 deletions(-) create mode 100644 packages/vscode/src/SessionEditorPanelProvider.ts diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index 7b1d0ca5..0b9c11fe 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -6,6 +6,7 @@ import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { McpDropdown } from '@/components/mcp/McpDropdown'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { RiAddLine, RiArrowLeftLine, RiRobot2Line, RiSettings3Line } from '@remixicon/react'; // Width threshold for mobile vs desktop layout in settings @@ -14,11 +15,41 @@ const MOBILE_WIDTH_THRESHOLD = 550; type VSCodeView = 'sessions' | 'chat' | 'settings'; export const VSCodeLayout: React.FC = () => { + const runtimeApis = useRuntimeAPIs(); + + const viewMode = React.useMemo<'sidebar' | 'editor'>(() => { + const configured = + typeof window !== 'undefined' + ? (window as unknown as { __VSCODE_CONFIG__?: { viewMode?: unknown } }).__VSCODE_CONFIG__?.viewMode + : null; + return configured === 'editor' ? 'editor' : 'sidebar'; + }, []); + + const initialSessionId = React.useMemo(() => { + const configured = + typeof window !== 'undefined' + ? (window as unknown as { __VSCODE_CONFIG__?: { initialSessionId?: unknown } }).__VSCODE_CONFIG__?.initialSessionId + : null; + if (typeof configured === 'string' && configured.trim().length > 0) { + return configured.trim(); + } + return null; + }, []); + + const hasAppliedInitialSession = React.useRef(false); + const [currentView, setCurrentView] = React.useState('chat'); const [containerWidth, setContainerWidth] = React.useState(0); const containerRef = React.useRef(null); const currentSessionId = useSessionStore((state) => state.currentSessionId); const sessions = useSessionStore((state) => state.sessions); + + const activeSessionTitle = React.useMemo(() => { + if (!currentSessionId) { + return null; + } + return sessions.find((session) => session.id === currentSessionId)?.title || 'Session'; + }, [currentSessionId, sessions]); const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open)); const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>( @@ -43,17 +74,31 @@ export const VSCodeLayout: React.FC = () => { } }, [currentSessionId]); + React.useEffect(() => { + const vscodeApi = runtimeApis.vscode; + if (!vscodeApi) { + return; + } + + void vscodeApi.executeCommand('openchamber.setActiveSession', currentSessionId, activeSessionTitle); + }, [activeSessionTitle, currentSessionId, runtimeApis.vscode]); + // If the active session disappears (e.g., deleted), show a new chat view React.useEffect(() => { + if (viewMode === 'editor') { + return; + } + if (!currentSessionId && !newSessionDraftOpen) { openNewSessionDraft(); } - }, [currentSessionId, newSessionDraftOpen, openNewSessionDraft]); + }, [currentSessionId, newSessionDraftOpen, openNewSessionDraft, viewMode]); const handleBackToSessions = React.useCallback(() => { setCurrentView('sessions'); }, []); + // Listen for connection status changes React.useEffect(() => { // Catch up with the latest status even if the extension posted the connection message @@ -147,6 +192,27 @@ export const VSCodeLayout: React.FC = () => { void runBootstrap(); }, [connectionStatus, configInitialized, hasInitializedOnce, initializeConfig, isInitializing, loadSessions]); + React.useEffect(() => { + if (viewMode !== 'editor') { + return; + } + if (hasAppliedInitialSession.current) { + return; + } + if (!initialSessionId) { + return; + } + if (!hasInitializedOnce || connectionStatus !== 'connected') { + return; + } + if (!sessions.some((session) => session.id === initialSessionId)) { + return; + } + + hasAppliedInitialSession.current = true; + void useSessionStore.getState().setCurrentSession(initialSessionId); + }, [connectionStatus, hasInitializedOnce, initialSessionId, sessions, viewMode]); + // Hydrate messages when viewing chat React.useEffect(() => { const hydrateMessages = async () => { @@ -191,10 +257,23 @@ export const VSCodeLayout: React.FC = () => { return (
- {currentView === 'sessions' ? ( + {viewMode === 'editor' ? (
- session.id === currentSessionId)?.title || 'Chat'} + showMcp + showContextUsage + /> +
+ + + +
+
+ ) : currentView === 'sessions' ? ( +
+
{ s.id === currentSessionId)?.title || 'Chat'} + : sessions.find((session) => session.id === currentSessionId)?.title || 'Chat'} showBack onBack={handleBackToSessions} showMcp diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 13b719e9..81e046f1 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -87,6 +87,12 @@ "title": "Open Agent Manager", "icon": "$(circuit-board)" }, + { + "command": "openchamber.openActiveSessionInEditor", + "category": "OpenChamber", + "title": "Open Active Session in Editor", + "icon": "$(link-external)" + }, { "command": "openchamber.addToContext", "category": "OpenChamber", @@ -141,14 +147,19 @@ "group": "navigation@1" }, { - "command": "openchamber.openAgentManager", + "command": "openchamber.openActiveSessionInEditor", "when": "view == openchamber.chatView", "group": "navigation@2" }, { - "command": "openchamber.showSettings", + "command": "openchamber.openAgentManager", "when": "view == openchamber.chatView", "group": "navigation@3" + }, + { + "command": "openchamber.showSettings", + "when": "view == openchamber.chatView", + "group": "navigation@4" } ], "openchamber.submenu": [ diff --git a/packages/vscode/src/AgentManagerPanelProvider.ts b/packages/vscode/src/AgentManagerPanelProvider.ts index 0e3bc457..6d6b2581 100644 --- a/packages/vscode/src/AgentManagerPanelProvider.ts +++ b/packages/vscode/src/AgentManagerPanelProvider.ts @@ -44,6 +44,11 @@ export class AgentManagerPanelProvider { } ); + this._panel.iconPath = { + light: vscode.Uri.joinPath(this._extensionUri, 'assets', 'icon.svg'), + dark: vscode.Uri.joinPath(this._extensionUri, 'assets', 'icon-titlebar.svg'), + }; + this._panel.webview.html = this._getHtmlForWebview(this._panel.webview); // Send theme payload (including optional Shiki theme JSON) after the webview is set up. diff --git a/packages/vscode/src/SessionEditorPanelProvider.ts b/packages/vscode/src/SessionEditorPanelProvider.ts new file mode 100644 index 00000000..d7594c51 --- /dev/null +++ b/packages/vscode/src/SessionEditorPanelProvider.ts @@ -0,0 +1,531 @@ +import * as vscode from 'vscode'; +import { handleBridgeMessage, type BridgeRequest, type BridgeResponse } from './bridge'; +import { getThemeKindName } from './theme'; +import type { OpenCodeManager, ConnectionStatus } from './opencode'; +import { getWebviewShikiThemes } from './shikiThemes'; +import { getWebviewHtml } from './webviewHtml'; + +type SessionPanelState = { + panel: vscode.WebviewPanel; + sseStreams: Map; + sseHeartbeats: Map>; +}; + +export class SessionEditorPanelProvider { + public static readonly viewType = 'openchamber.sessionEditor'; + + private _cachedStatus: ConnectionStatus = 'connecting'; + private _cachedError?: string; + private _sseCounter = 0; + private _panels = new Map(); + + constructor( + private readonly _context: vscode.ExtensionContext, + private readonly _extensionUri: vscode.Uri, + private readonly _openCodeManager?: OpenCodeManager + ) {} + + public createOrShow(sessionId: string, title?: string): void { + if (!sessionId || typeof sessionId !== 'string') { + return; + } + + const sessionTitle = title && title.trim().length > 0 ? title.trim() : 'Session'; + + const existing = this._panels.get(sessionId); + if (existing) { + existing.panel.title = sessionTitle; + existing.panel.reveal(existing.panel.viewColumn ?? vscode.ViewColumn.Active); + return; + } + + const distUri = vscode.Uri.joinPath(this._extensionUri, 'dist'); + + const panel = vscode.window.createWebviewPanel( + SessionEditorPanelProvider.viewType, + sessionTitle, + vscode.ViewColumn.Beside, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [this._extensionUri, distUri], + } + ); + + panel.iconPath = { + light: vscode.Uri.joinPath(this._extensionUri, 'assets', 'icon.svg'), + dark: vscode.Uri.joinPath(this._extensionUri, 'assets', 'icon-titlebar.svg'), + }; + + const state: SessionPanelState = { + panel, + sseStreams: new Map(), + sseHeartbeats: new Map(), + }; + + this._panels.set(sessionId, state); + + panel.webview.html = this._getHtmlForWebview(panel.webview, sessionId); + + void this.updateTheme(vscode.window.activeColorTheme.kind); + this._sendCachedStateToPanel(state); + + panel.onDidDispose(() => { + this._disposePanel(sessionId); + }, null, this._context.subscriptions); + + panel.webview.onDidReceiveMessage(async (message: BridgeRequest) => { + if (message.type === 'restartApi') { + await this._openCodeManager?.restart(); + return; + } + + if (message.type === 'api:sse:start') { + const response = await this._startSseProxy(message, state); + state.panel.webview.postMessage(response); + return; + } + + if (message.type === 'api:sse:stop') { + const response = await this._stopSseProxy(message, state); + state.panel.webview.postMessage(response); + return; + } + + const response = await handleBridgeMessage(message, { + manager: this._openCodeManager, + context: this._context, + }); + state.panel.webview.postMessage(response); + }, null, this._context.subscriptions); + } + + public updateTheme(kind: vscode.ColorThemeKind) { + const themeKind = getThemeKindName(kind); + void getWebviewShikiThemes().then((shikiThemes) => { + for (const entry of this._panels.values()) { + entry.panel.webview.postMessage({ + type: 'themeChange', + theme: { kind: themeKind, shikiThemes }, + }); + } + }); + } + + public updateConnectionStatus(status: ConnectionStatus, error?: string) { + this._cachedStatus = status; + this._cachedError = error; + + for (const entry of this._panels.values()) { + this._sendCachedStateToPanel(entry); + } + } + + private _sendCachedStateToPanel(entry: SessionPanelState) { + entry.panel.webview.postMessage({ + type: 'connectionStatus', + status: this._cachedStatus, + error: this._cachedError, + }); + } + + private _disposePanel(sessionId: string) { + const entry = this._panels.get(sessionId); + if (!entry) return; + + for (const controller of entry.sseStreams.values()) { + controller.abort(); + } + entry.sseStreams.clear(); + + for (const heartbeat of entry.sseHeartbeats.values()) { + clearInterval(heartbeat); + } + entry.sseHeartbeats.clear(); + + this._panels.delete(sessionId); + } + + private _buildSseHeaders(extra?: Record): Record { + return { + Accept: 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + ...(extra || {}), + }; + } + + private _collectHeaders(headers: Headers): Record { + const result: Record = {}; + headers.forEach((value, key) => { + result[key] = value; + }); + return result; + } + + private async _startSseProxy(message: BridgeRequest, entry: SessionPanelState): Promise { + const { id, type, payload } = message; + const apiBaseUrl = this._openCodeManager?.getApiUrl(); + + const { path, headers } = (payload || {}) as { path?: string; headers?: Record }; + const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event'; + const shouldInjectActivity = normalizedPath === '/event' || normalizedPath === '/global/event'; + + if (!apiBaseUrl) { + return { + id, + type, + success: true, + data: { status: 503, headers: { 'content-type': 'application/json' }, streamId: null }, + }; + } + + const streamId = `sse_${++this._sseCounter}_${Date.now()}`; + const controller = new AbortController(); + + const base = `${apiBaseUrl.replace(/\/+$/, '')}/`; + const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); + + let response: Response; + let wrapAsGlobal = false; + + const requestHeaders = this._buildSseHeaders(headers || {}); + + try { + response = await fetch(targetUrl, { + method: 'GET', + headers: requestHeaders, + signal: controller.signal, + }); + + // Fallback: OpenCode versions without /global/event. + // VS Code is single-workspace, so we can wrap /event into { directory, payload }. + if ((!response.ok || !response.body) && normalizedPath === '/global/event') { + const fallbackUrl = new URL('event', base).toString(); + response = await fetch(fallbackUrl, { + method: 'GET', + headers: requestHeaders, + signal: controller.signal, + }); + if (response.ok && response.body) { + wrapAsGlobal = true; + } + } + } catch (error) { + const messageText = error instanceof Error ? error.message : String(error); + return { + id, + type, + success: true, + data: { status: 502, headers: { 'content-type': 'application/json' }, streamId: null, error: messageText }, + }; + } + + const responseHeaders = this._collectHeaders(response.headers); + const responseBody = response.body; + if (!response.ok || !responseBody) { + return { + id, + type, + success: true, + data: { + status: response.status, + headers: responseHeaders, + streamId: null, + error: `SSE failed: ${response.status}`, + }, + }; + } + + entry.sseStreams.set(streamId, controller); + + const fallbackDirectory = this._openCodeManager?.getWorkingDirectory() + || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + || 'global'; + + if (shouldInjectActivity) { + const heartbeatTimer = setInterval(() => { + if (controller.signal.aborted) { + return; + } + const heartbeatChunk = `${buildHeartbeatEventBlock()}\n\n`; + entry.panel.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: heartbeatChunk }); + }, 30000); + entry.sseHeartbeats.set(streamId, heartbeatTimer); + } + + (async () => { + try { + const reader = responseBody.getReader(); + const decoder = new TextDecoder(); + let sseBuffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (controller.signal.aborted) break; + if (value && value.length > 0) { + const chunk = decoder.decode(value, { stream: true }); + if (!chunk) continue; + + sseBuffer += shouldInjectActivity ? chunk.replace(/\r\n/g, '\n') : chunk; + const blocks = sseBuffer.split('\n\n'); + sseBuffer = blocks.pop() ?? ''; + if (blocks.length > 0) { + const routedBlocks = wrapAsGlobal + ? wrapSseBlocksAsGlobal(blocks, fallbackDirectory) + : blocks; + const outboundBlocks = shouldInjectActivity ? expandSseBlocksWithActivity(routedBlocks) : routedBlocks; + const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join(''); + entry.panel.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined }); + } + } + } + + const tail = decoder.decode(); + if (tail) { + sseBuffer += shouldInjectActivity ? tail.replace(/\r\n/g, '\n') : tail; + } + if (sseBuffer) { + if (shouldInjectActivity) { + const baseBlocks = wrapAsGlobal + ? wrapSseBlocksAsGlobal([sseBuffer], fallbackDirectory) + : [sseBuffer]; + const outboundBlocks = expandSseBlocksWithActivity(baseBlocks); + const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join(''); + entry.panel.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined }); + } else { + entry.panel.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: sseBuffer }); + } + } + } finally { + try { + reader.releaseLock(); + } catch { + // ignore + } + } + + entry.panel.webview.postMessage({ type: 'api:sse:end', streamId }); + } catch (error) { + if (!controller.signal.aborted) { + const messageText = error instanceof Error ? error.message : String(error); + entry.panel.webview.postMessage({ type: 'api:sse:end', streamId, error: messageText }); + } + } finally { + entry.sseStreams.delete(streamId); + const heartbeat = entry.sseHeartbeats.get(streamId); + if (heartbeat) { + clearInterval(heartbeat); + entry.sseHeartbeats.delete(streamId); + } + } + })(); + + return { + id, + type, + success: true, + data: { + status: response.status, + headers: responseHeaders, + streamId, + }, + }; + } + + private async _stopSseProxy(message: BridgeRequest, entry: SessionPanelState): Promise { + const { id, type, payload } = message; + const { streamId } = (payload || {}) as { streamId?: string }; + if (typeof streamId === 'string' && streamId.length > 0) { + const controller = entry.sseStreams.get(streamId); + if (controller) { + controller.abort(); + entry.sseStreams.delete(streamId); + } + + const heartbeat = entry.sseHeartbeats.get(streamId); + if (heartbeat) { + clearInterval(heartbeat); + entry.sseHeartbeats.delete(streamId); + } + } + return { id, type, success: true, data: { stopped: true } }; + } + + private _getHtmlForWebview(webview: vscode.Webview, sessionId: string) { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + const initialStatus = this._cachedStatus; + const cliAvailable = this._openCodeManager?.isCliAvailable() ?? false; + + return getWebviewHtml({ + webview, + extensionUri: this._extensionUri, + workspaceFolder, + initialStatus, + cliAvailable, + panelType: 'chat', + initialSessionId: sessionId, + viewMode: 'editor', + }); + } +} + +type SessionActivityPhase = 'idle' | 'busy' | 'cooldown'; + +type SessionActivity = { + sessionId: string; + phase: SessionActivityPhase; +}; + +const parseSseDataPayload = (block: string): Record | null => { + if (!block) { + return null; + } + + const dataLines = block + .split('\n') + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).replace(/^\s/, '')); + + if (dataLines.length === 0) { + return null; + } + + const payloadText = dataLines.join('\n').trim(); + if (!payloadText) { + return null; + } + + try { + const parsed = JSON.parse(payloadText) as unknown; + if (parsed && typeof parsed === 'object') { + const record = parsed as Record; + const nestedPayload = record.payload; + if (nestedPayload && typeof nestedPayload === 'object') { + return nestedPayload as Record; + } + return record; + } + return null; + } catch { + return null; + } +}; + +const deriveSessionActivity = (payload: Record | null): SessionActivity | null => { + if (!payload || typeof payload !== 'object') { + return null; + } + + const type = payload.type; + const properties = payload.properties as Record | undefined; + + if (type === 'session.status') { + const status = properties?.status as Record | undefined; + const sessionId = properties?.sessionID ?? properties?.sessionId; + const statusType = status?.type; + + if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') { + const phase: SessionActivityPhase = statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle'; + return { sessionId, phase }; + } + + return null; + } + + if (type === 'message.updated') { + const info = payload.info as Record | undefined; + const role = info?.role; + const finish = info?.finish; + const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId; + + if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') { + return { sessionId, phase: 'cooldown' }; + } + + return null; + } + + if (type === 'message.complete') { + const info = payload.info as Record | undefined; + const role = info?.role; + const finish = info?.finish; + const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId; + + if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') { + return { sessionId, phase: 'cooldown' }; + } + + return null; + } + + if (type === 'session.idle') { + const sessionId = properties?.sessionID ?? properties?.sessionId; + if (typeof sessionId === 'string' && sessionId.length > 0) { + return { sessionId, phase: 'idle' }; + } + return null; + } + + return null; +}; + +const buildActivityEventBlock = (activity: SessionActivity): string => { + return [ + 'event: message', + `data: ${JSON.stringify({ + type: 'openchamber:session-activity', + properties: { + sessionId: activity.sessionId, + phase: activity.phase, + at: Date.now(), + }, + })}`, + ].join('\n'); +}; + +const buildHeartbeatEventBlock = (): string => { + return [ + 'event: message', + `data: ${JSON.stringify({ + type: 'openchamber:heartbeat', + properties: { + at: Date.now(), + }, + })}`, + ].join('\n'); +}; + +const expandSseBlocksWithActivity = (blocks: string[]): string[] => { + const expanded: string[] = []; + + for (const block of blocks) { + expanded.push(block); + + const activity = deriveSessionActivity(parseSseDataPayload(block)); + if (activity) { + expanded.push(buildActivityEventBlock(activity)); + } + } + + return expanded; +}; + +const wrapSseBlocksAsGlobal = (blocks: string[], directory: string): string[] => { + return blocks.map((block) => { + const payload = parseSseDataPayload(block); + const wrappedPayload = payload + ? { directory, payload } + : { directory, payload: null }; + + const updatedLines = block + .split('\n') + .filter((line) => !line.startsWith('data:')); + + updatedLines.push(`data: ${JSON.stringify(wrappedPayload)}`); + + return updatedLines.join('\n'); + }); +}; diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 40f18156..fc2e1dc3 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -1,13 +1,18 @@ import * as vscode from 'vscode'; import { ChatViewProvider } from './ChatViewProvider'; import { AgentManagerPanelProvider } from './AgentManagerPanelProvider'; +import { SessionEditorPanelProvider } from './SessionEditorPanelProvider'; import { createOpenCodeManager, type OpenCodeManager } from './opencode'; let chatViewProvider: ChatViewProvider | undefined; let agentManagerProvider: AgentManagerPanelProvider | undefined; +let sessionEditorProvider: SessionEditorPanelProvider | undefined; let openCodeManager: OpenCodeManager | undefined; let outputChannel: vscode.OutputChannel | undefined; +let activeSessionId: string | null = null; +let activeSessionTitle: string | null = null; + const SETTINGS_KEY = 'openchamber.settings'; const formatIso = (value: number | null | undefined) => { @@ -153,6 +158,7 @@ export async function activate(context: vscode.ExtensionContext) { // Create Agent Manager panel provider agentManagerProvider = new AgentManagerPanelProvider(context, context.extensionUri, openCodeManager); + sessionEditorProvider = new SessionEditorPanelProvider(context, context.extensionUri, openCodeManager); context.subscriptions.push( vscode.commands.registerCommand('openchamber.openAgentManager', () => { @@ -160,6 +166,38 @@ export async function activate(context: vscode.ExtensionContext) { }) ); + context.subscriptions.push( + vscode.commands.registerCommand('openchamber.setActiveSession', (sessionId: unknown, title?: unknown) => { + if (typeof sessionId === 'string' && sessionId.trim().length > 0) { + activeSessionId = sessionId.trim(); + activeSessionTitle = typeof title === 'string' && title.trim().length > 0 ? title.trim() : null; + return; + } + + activeSessionId = null; + activeSessionTitle = null; + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('openchamber.openActiveSessionInEditor', () => { + if (!activeSessionId) { + vscode.window.showInformationMessage('OpenChamber: No active session'); + return; + } + sessionEditorProvider?.createOrShow(activeSessionId, activeSessionTitle ?? undefined); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('openchamber.openSessionInEditor', (sessionId: string, title?: string) => { + if (typeof sessionId !== 'string' || sessionId.trim().length === 0) { + return; + } + sessionEditorProvider?.createOrShow(sessionId.trim(), title); + }) + ); + context.subscriptions.push( vscode.commands.registerCommand('openchamber.restartApi', async () => { try { @@ -437,6 +475,7 @@ export async function activate(context: vscode.ExtensionContext) { vscode.window.onDidChangeActiveColorTheme((theme) => { chatViewProvider?.updateTheme(theme.kind); agentManagerProvider?.updateTheme(theme.kind); + sessionEditorProvider?.updateTheme(theme.kind); }) ); @@ -452,6 +491,7 @@ export async function activate(context: vscode.ExtensionContext) { ) { chatViewProvider?.updateTheme(vscode.window.activeColorTheme.kind); agentManagerProvider?.updateTheme(vscode.window.activeColorTheme.kind); + sessionEditorProvider?.updateTheme(vscode.window.activeColorTheme.kind); } }) ); @@ -461,6 +501,7 @@ export async function activate(context: vscode.ExtensionContext) { openCodeManager.onStatusChange((status, error) => { chatViewProvider?.updateConnectionStatus(status, error); agentManagerProvider?.updateConnectionStatus(status, error); + sessionEditorProvider?.updateConnectionStatus(status, error); }) ); @@ -474,6 +515,7 @@ export async function deactivate() { openCodeManager = undefined; chatViewProvider = undefined; agentManagerProvider = undefined; + sessionEditorProvider = undefined; outputChannel?.dispose(); outputChannel = undefined; } diff --git a/packages/vscode/src/webviewHtml.ts b/packages/vscode/src/webviewHtml.ts index e31e7649..2d141f50 100644 --- a/packages/vscode/src/webviewHtml.ts +++ b/packages/vscode/src/webviewHtml.ts @@ -11,10 +11,21 @@ export interface WebviewHtmlOptions { initialStatus: ConnectionStatus; cliAvailable: boolean; panelType?: PanelType; + initialSessionId?: string; + viewMode?: 'sidebar' | 'editor'; } export function getWebviewHtml(options: WebviewHtmlOptions): string { - const { webview, extensionUri, workspaceFolder, initialStatus, cliAvailable, panelType = 'chat' } = options; + const { + webview, + extensionUri, + workspaceFolder, + initialStatus, + cliAvailable, + panelType = 'chat', + initialSessionId, + viewMode = 'sidebar', + } = options; const scriptPath = vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'assets', 'index.js'); const scriptUri = webview.asWebviewUri(scriptPath); @@ -131,7 +142,9 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string { theme: "${themeKind}", connectionStatus: "${initialStatus}", cliAvailable: ${cliAvailable}, - panelType: "${panelType}" + panelType: "${panelType}", + viewMode: "${viewMode}", + initialSessionId: ${initialSessionId ? `"${initialSessionId.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` : 'null'}, }; window.__OPENCHAMBER_HOME__ = "${workspaceFolder.replace(/\\/g, '\\\\')}"; diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index ed888fcf..deeb8057 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -23,6 +23,8 @@ declare global { connectionStatus: string; cliAvailable?: boolean; panelType?: PanelType; + viewMode?: 'sidebar' | 'editor'; + initialSessionId?: string | null; }; __OPENCHAMBER_VSCODE_THEME__?: VSCodeThemePayload['theme']; __OPENCHAMBER_VSCODE_SHIKI_THEMES__?: { light?: Record; dark?: Record } | null;