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.
545 lines
17 KiB
TypeScript
545 lines
17 KiB
TypeScript
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';
|
|
|
|
export class AgentManagerPanelProvider {
|
|
public static readonly viewType = 'openchamber.agentManager';
|
|
|
|
private _panel?: vscode.WebviewPanel;
|
|
|
|
// Cache latest status/URL for when webview is resolved after connection is ready
|
|
private _cachedStatus: ConnectionStatus = 'connecting';
|
|
private _cachedError?: string;
|
|
private _sseCounter = 0;
|
|
private _sseStreams = new Map<string, AbortController>();
|
|
private _sseHeartbeats = new Map<string, ReturnType<typeof setInterval>>();
|
|
|
|
constructor(
|
|
private readonly _context: vscode.ExtensionContext,
|
|
private readonly _extensionUri: vscode.Uri,
|
|
private readonly _openCodeManager?: OpenCodeManager
|
|
) {}
|
|
|
|
public createOrShow(): void {
|
|
// If panel exists, reveal it
|
|
if (this._panel) {
|
|
this._panel.reveal(vscode.ViewColumn.One);
|
|
return;
|
|
}
|
|
|
|
const distUri = vscode.Uri.joinPath(this._extensionUri, 'dist');
|
|
|
|
// Create new panel
|
|
this._panel = vscode.window.createWebviewPanel(
|
|
AgentManagerPanelProvider.viewType,
|
|
'Agent Manager',
|
|
vscode.ViewColumn.One,
|
|
{
|
|
enableScripts: true,
|
|
retainContextWhenHidden: true,
|
|
localResourceRoots: [this._extensionUri, distUri],
|
|
}
|
|
);
|
|
|
|
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.
|
|
void this.updateTheme(vscode.window.activeColorTheme.kind);
|
|
|
|
// Send cached connection status
|
|
this._sendCachedState();
|
|
|
|
// Handle panel disposal
|
|
this._panel.onDidDispose(() => {
|
|
// Clean up SSE streams
|
|
for (const controller of this._sseStreams.values()) {
|
|
controller.abort();
|
|
}
|
|
this._sseStreams.clear();
|
|
|
|
for (const heartbeat of this._sseHeartbeats.values()) {
|
|
clearInterval(heartbeat);
|
|
}
|
|
this._sseHeartbeats.clear();
|
|
|
|
this._panel = undefined;
|
|
}, null, this._context.subscriptions);
|
|
|
|
// Handle messages
|
|
this._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);
|
|
this._panel?.webview.postMessage(response);
|
|
return;
|
|
}
|
|
|
|
if (message.type === 'api:sse:stop') {
|
|
const response = await this._stopSseProxy(message);
|
|
this._panel?.webview.postMessage(response);
|
|
return;
|
|
}
|
|
|
|
const response = await handleBridgeMessage(message, {
|
|
manager: this._openCodeManager,
|
|
context: this._context,
|
|
});
|
|
this._panel?.webview.postMessage(response);
|
|
}, null, this._context.subscriptions);
|
|
}
|
|
|
|
public updateTheme(kind: vscode.ColorThemeKind) {
|
|
if (this._panel) {
|
|
const themeKind = getThemeKindName(kind);
|
|
void getWebviewShikiThemes().then((shikiThemes) => {
|
|
this._panel?.webview.postMessage({
|
|
type: 'themeChange',
|
|
theme: { kind: themeKind, shikiThemes },
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
public updateConnectionStatus(status: ConnectionStatus, error?: string) {
|
|
// Cache the latest state
|
|
this._cachedStatus = status;
|
|
this._cachedError = error;
|
|
|
|
// Send to webview if it exists
|
|
this._sendCachedState();
|
|
}
|
|
|
|
private _sendCachedState() {
|
|
if (!this._panel) {
|
|
return;
|
|
}
|
|
|
|
this._panel.webview.postMessage({
|
|
type: 'connectionStatus',
|
|
status: this._cachedStatus,
|
|
error: this._cachedError,
|
|
});
|
|
}
|
|
|
|
private _buildSseHeaders(extra?: Record<string, string>): Record<string, string> {
|
|
return {
|
|
Accept: 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
Connection: 'keep-alive',
|
|
...(extra || {}),
|
|
};
|
|
}
|
|
|
|
private _collectHeaders(headers: Headers): Record<string, string> {
|
|
const result: Record<string, string> = {};
|
|
headers.forEach((value, key) => {
|
|
result[key] = value;
|
|
});
|
|
return result;
|
|
}
|
|
|
|
private async _startSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
|
|
const { id, type, payload } = message;
|
|
const apiBaseUrl = this._openCodeManager?.getApiUrl();
|
|
|
|
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
|
|
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 for OpenCode versions without /global/event.
|
|
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 message = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: { status: 502, headers: { 'content-type': 'application/json' }, streamId: null, error: message },
|
|
};
|
|
}
|
|
|
|
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}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
this._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`;
|
|
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: heartbeatChunk });
|
|
}, 30000);
|
|
this._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;
|
|
|
|
// Reduce webview message pressure by forwarding complete SSE blocks.
|
|
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('');
|
|
this._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('');
|
|
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
|
} else {
|
|
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: sseBuffer });
|
|
}
|
|
}
|
|
} finally {
|
|
try {
|
|
reader.releaseLock();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
this._panel?.webview.postMessage({ type: 'api:sse:end', streamId });
|
|
} catch (error) {
|
|
if (!controller.signal.aborted) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
this._panel?.webview.postMessage({ type: 'api:sse:end', streamId, error: message });
|
|
}
|
|
} finally {
|
|
this._sseStreams.delete(streamId);
|
|
const heartbeat = this._sseHeartbeats.get(streamId);
|
|
if (heartbeat) {
|
|
clearInterval(heartbeat);
|
|
this._sseHeartbeats.delete(streamId);
|
|
}
|
|
}
|
|
})();
|
|
|
|
return {
|
|
id,
|
|
type,
|
|
success: true,
|
|
data: {
|
|
status: response.status,
|
|
headers: responseHeaders,
|
|
streamId,
|
|
},
|
|
};
|
|
}
|
|
|
|
private async _stopSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
|
|
const { id, type, payload } = message;
|
|
const { streamId } = (payload || {}) as { streamId?: string };
|
|
if (typeof streamId === 'string' && streamId.length > 0) {
|
|
const controller = this._sseStreams.get(streamId);
|
|
if (controller) {
|
|
controller.abort();
|
|
this._sseStreams.delete(streamId);
|
|
}
|
|
|
|
const heartbeat = this._sseHeartbeats.get(streamId);
|
|
if (heartbeat) {
|
|
clearInterval(heartbeat);
|
|
this._sseHeartbeats.delete(streamId);
|
|
}
|
|
}
|
|
return { id, type, success: true, data: { stopped: true } };
|
|
}
|
|
|
|
private _getHtmlForWebview(webview: vscode.Webview): string {
|
|
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
|
const cliAvailable = this._openCodeManager?.isCliAvailable() ?? false;
|
|
|
|
return getWebviewHtml({
|
|
webview,
|
|
extensionUri: this._extensionUri,
|
|
workspaceFolder,
|
|
initialStatus: this._cachedStatus,
|
|
cliAvailable,
|
|
panelType: 'agentManager',
|
|
});
|
|
}
|
|
}
|
|
|
|
type SessionActivityPhase = 'idle' | 'busy' | 'cooldown';
|
|
|
|
type SessionActivity = {
|
|
sessionId: string;
|
|
phase: SessionActivityPhase;
|
|
};
|
|
|
|
const parseSseDataPayload = (block: string): Record<string, unknown> | 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<string, unknown>;
|
|
const nestedPayload = record.payload;
|
|
if (nestedPayload && typeof nestedPayload === 'object') {
|
|
return nestedPayload as Record<string, unknown>;
|
|
}
|
|
return record;
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const deriveSessionActivity = (payload: Record<string, unknown> | null): SessionActivity | null => {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const type = payload.type;
|
|
const properties = payload.properties as Record<string, unknown> | undefined;
|
|
|
|
if (type === 'session.status') {
|
|
const status = properties?.status as Record<string, unknown> | 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 };
|
|
}
|
|
}
|
|
|
|
if (type === 'message.updated') {
|
|
const info = properties?.info as Record<string, unknown> | undefined;
|
|
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
|
|
const role = info?.role;
|
|
const finish = info?.finish;
|
|
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
|
return { sessionId, phase: 'cooldown' };
|
|
}
|
|
}
|
|
|
|
if (type === 'message.part.updated') {
|
|
const info = properties?.info as Record<string, unknown> | undefined;
|
|
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
|
|
const role = info?.role;
|
|
const finish = info?.finish;
|
|
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;
|
|
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
|
return { sessionId, phase: 'idle' };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const buildActivityEventBlock = (activity: SessionActivity): string => {
|
|
return `data: ${JSON.stringify({
|
|
type: 'openchamber:session-activity',
|
|
properties: {
|
|
sessionId: activity.sessionId,
|
|
phase: activity.phase,
|
|
},
|
|
})}`;
|
|
};
|
|
|
|
const buildHeartbeatEventBlock = (): string => {
|
|
return `data: ${JSON.stringify({ type: 'openchamber:heartbeat', timestamp: Date.now() })}`;
|
|
};
|
|
|
|
const parseSseBlockForGlobalWrap = (block: string): { id?: string; payload: Record<string, unknown> } | null => {
|
|
if (!block) {
|
|
return null;
|
|
}
|
|
|
|
const lines = block.split('\n');
|
|
const dataLines: string[] = [];
|
|
let eventId: string | undefined;
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('data:')) {
|
|
dataLines.push(line.slice(5).replace(/^\s/, ''));
|
|
continue;
|
|
}
|
|
if (line.startsWith('id:')) {
|
|
const candidate = line.slice(3).trim();
|
|
if (candidate) {
|
|
eventId = candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
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') {
|
|
return null;
|
|
}
|
|
|
|
const record = parsed as Record<string, unknown>;
|
|
const nestedPayload = record.payload;
|
|
const payload = nestedPayload && typeof nestedPayload === 'object'
|
|
? (nestedPayload as Record<string, unknown>)
|
|
: record;
|
|
|
|
return eventId ? { id: eventId, payload } : { payload };
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const wrapSseBlocksAsGlobal = (blocks: string[], directory: string): string[] => {
|
|
const normalizedDirectory = typeof directory === 'string' && directory.trim().length > 0
|
|
? directory.trim().replace(/\\/g, '/')
|
|
: 'global';
|
|
|
|
return blocks.map((block) => {
|
|
const parsed = parseSseBlockForGlobalWrap(block);
|
|
if (!parsed) {
|
|
return block;
|
|
}
|
|
|
|
const envelope = {
|
|
directory: normalizedDirectory,
|
|
payload: parsed.payload,
|
|
};
|
|
|
|
const idPrefix = parsed.id ? `id: ${parsed.id}\n` : '';
|
|
return `${idPrefix}data: ${JSON.stringify(envelope)}`;
|
|
});
|
|
};
|
|
|
|
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;
|
|
};
|