Files
openchamber/packages/vscode/src/ChatViewProvider.ts
T
Dolphin 25c6e79be7 Fix: normalize Windows drive letter in VS Code extension webview paths (#1052)
Extract normalizeWindowsDriveLetter to shared pathUtils module

Normalize workspaceFolder to uppercase drive letter to match OpenCode
server's path normalization. This fixes session visibility issues
where VS Code extension sessions don't appear in CLI/desktop and vice
versa due to case-sensitive directory comparison on Windows.

Changes:
- Create pathUtils.ts with shared normalizeWindowsDriveLetter function
- Update opencode.ts to import from pathUtils instead of inline definition
- Update ChatViewProvider.ts, AgentManagerPanelProvider.ts,
  SessionEditorPanelProvider.ts to import from pathUtils
2026-04-27 18:34:50 +03:00

413 lines
12 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';
import { openSseProxy } from './sseProxy';
import { resolveWebviewDevServerUrl } from './webviewDevServer';
import { normalizeWindowsDriveLetter } from './pathUtils';
export class ChatViewProvider implements vscode.WebviewViewProvider {
public static readonly viewType = 'openchamber.chatView';
private _view?: vscode.WebviewView;
public isVisible() {
return this._view?.visible ?? false;
}
// 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 readonly _webviewDevServerUrl: string | null;
// Message delivery confirmation and retry
private readonly _pendingMessages = new Set<string>();
private readonly _messageTimeouts = new Map<string, NodeJS.Timeout>();
private readonly _MESSAGE_TIMEOUT = 5000; // 5 seconds
private readonly _MAX_RETRIES = 3;
private _createMessageId(): string {
return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
private _clearPendingMessages(): void {
for (const timeout of this._messageTimeouts.values()) {
clearTimeout(timeout);
}
this._messageTimeouts.clear();
this._pendingMessages.clear();
}
constructor(
private readonly _context: vscode.ExtensionContext,
private readonly _extensionUri: vscode.Uri,
private readonly _openCodeManager?: OpenCodeManager
) {
this._webviewDevServerUrl = resolveWebviewDevServerUrl(this._context);
}
public resolveWebviewView(
webviewView: vscode.WebviewView
) {
this._clearPendingMessages();
this._view = webviewView;
const distUri = vscode.Uri.joinPath(this._extensionUri, 'dist');
webviewView.webview.options = {
enableScripts: true,
localResourceRoots: [this._extensionUri, distUri],
};
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.onDidDispose(() => {
this._clearPendingMessages();
});
webviewView.webview.onDidReceiveMessage(async (message: (BridgeRequest & { _msgId?: string }) | { type: 'bridge:ack'; _msgId: string }) => {
if (message.type === 'bridge:ack' && typeof message._msgId === 'string') {
this._confirmMessage(message._msgId);
return;
}
if (!('id' in message) || typeof message.id !== 'string') {
return;
}
if (message.type === 'restartApi') {
await this._openCodeManager?.restart();
return;
}
if (message.type === 'api:sse:start') {
const response = await this._startSseProxy(message);
void this._sendMessageWithRetry(response);
return;
}
if (message.type === 'api:sse:stop') {
const response = await this._stopSseProxy(message);
void this._sendMessageWithRetry(response);
return;
}
const response = await handleBridgeMessage(message, {
manager: this._openCodeManager,
context: this._context,
});
void this._sendMessageWithRetry(response);
if (message.type === 'api:config/settings:save' && response.success) {
void vscode.commands.executeCommand('openchamber.internal.settingsSynced', response.data);
}
});
}
public updateTheme(kind: vscode.ColorThemeKind) {
if (this._view) {
const themeKind = getThemeKindName(kind);
void getWebviewShikiThemes().then((shikiThemes) => {
this._view?.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();
}
public addTextToInput(text: string) {
if (this._view) {
// Reveal the webview panel
this._view.show(true);
this._view.webview.postMessage({
type: 'command',
command: 'addToContext',
payload: { text }
});
}
}
public addFileMentions(paths: string[]) {
if (!this._view) {
return;
}
const cleanedPaths = paths
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (cleanedPaths.length === 0) {
return;
}
this._view.show(true);
this._view.webview.postMessage({
type: 'command',
command: 'addFileMentions',
payload: { paths: cleanedPaths },
});
}
public createNewSessionWithPrompt(prompt: string) {
if (this._view) {
// Reveal the webview panel
this._view.show(true);
this._view.webview.postMessage({
type: 'command',
command: 'createSessionWithPrompt',
payload: { prompt }
});
}
}
public createNewSession() {
if (this._view) {
// Reveal the webview panel
this._view.show(true);
this._view.webview.postMessage({
type: 'command',
command: 'newSession'
});
}
}
public showSettings() {
if (this._view) {
// Reveal the webview panel
this._view.show(true);
this._view.webview.postMessage({
type: 'command',
command: 'showSettings'
});
}
}
public postMessage(message: unknown): void {
if (this._view) {
this._view.webview.postMessage(message);
}
}
public notifySettingsSynced(settings: unknown): void {
if (!this._view) {
return;
}
this._view.webview.postMessage({
type: 'command',
command: 'settingsSynced',
payload: settings,
});
}
// Message delivery confirmation
private _confirmMessage(messageId: string) {
this._pendingMessages.delete(messageId);
const timeout = this._messageTimeouts.get(messageId);
if (timeout) {
clearTimeout(timeout);
this._messageTimeouts.delete(messageId);
}
}
// Send message with retry mechanism
private async _sendMessageWithRetry(response: BridgeResponse, retryCount: number = 0, messageId?: string): Promise<boolean> {
if (!this._view) {
return false;
}
const pendingMessageId = messageId ?? this._createMessageId();
const existingTimeout = this._messageTimeouts.get(pendingMessageId);
if (existingTimeout) {
clearTimeout(existingTimeout);
this._messageTimeouts.delete(pendingMessageId);
}
try {
const delivered = await this._view.webview.postMessage({
...response,
_msgId: pendingMessageId,
});
if (!delivered) {
throw new Error('Webview rejected message delivery');
}
this._pendingMessages.add(pendingMessageId);
const timeout = setTimeout(() => {
if (!this._pendingMessages.has(pendingMessageId)) {
return;
}
if (retryCount < this._MAX_RETRIES) {
console.warn(`[Message Retry] Message ${pendingMessageId} not confirmed, retrying (${retryCount + 1}/${this._MAX_RETRIES})...`);
void this._sendMessageWithRetry(response, retryCount + 1, pendingMessageId);
return;
}
console.error(`[Message Retry] Message ${pendingMessageId} failed after ${this._MAX_RETRIES} retries`);
this._pendingMessages.delete(pendingMessageId);
this._messageTimeouts.delete(pendingMessageId);
}, this._MESSAGE_TIMEOUT);
this._messageTimeouts.set(pendingMessageId, timeout);
return true;
} catch (error) {
console.error(`[Message Retry] Failed to send message:`, error);
if (retryCount < this._MAX_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, 100 * (retryCount + 1)));
return this._sendMessageWithRetry(response, retryCount + 1, pendingMessageId);
}
this._pendingMessages.delete(pendingMessageId);
this._messageTimeouts.delete(pendingMessageId);
return false;
}
}
private _sendCachedState() {
if (!this._view) {
return;
}
this._view.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 async _startSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
const { id, type, payload } = message;
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
if (!this._openCodeManager) {
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();
try {
const start = await openSseProxy({
manager: this._openCodeManager,
path: normalizedPath,
headers: this._buildSseHeaders(headers),
signal: controller.signal,
onChunk: (chunk) => {
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk });
},
});
this._sseStreams.set(streamId, controller);
start.run
.then(() => {
this._view?.webview.postMessage({ type: 'api:sse:end', streamId });
})
.catch((error) => {
if (!controller.signal.aborted) {
const messageText = error instanceof Error ? error.message : String(error);
this._view?.webview.postMessage({ type: 'api:sse:end', streamId, error: messageText });
}
})
.finally(() => {
this._sseStreams.delete(streamId);
});
return {
id,
type,
success: true,
data: {
status: 200,
headers: start.headers,
streamId,
},
};
} 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 },
};
}
}
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);
}
}
return { id, type, success: true, data: { stopped: true } };
}
private _getHtmlForWebview(webview: vscode.Webview) {
const workspaceFolder = normalizeWindowsDriveLetter(
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''
);
// Use cached values which are updated by onStatusChange callback
const initialStatus = this._cachedStatus;
const cliAvailable = this._openCodeManager?.isCliAvailable() ?? false;
return getWebviewHtml({
webview,
extensionUri: this._extensionUri,
workspaceFolder,
initialStatus,
cliAvailable,
devServerUrl: this._webviewDevServerUrl,
});
}
}