diff --git a/vscode/bun.lock b/vscode/bun.lock index 5312e4915c..301a478814 100644 --- a/vscode/bun.lock +++ b/vscode/bun.lock @@ -6,7 +6,7 @@ "name": "mux", "devDependencies": { "@types/node": "^20.0.0", - "@types/vscode": "^1.85.0", + "@types/vscode": "^1.106.0", "@vscode/vsce": "^2.22.0", "esbuild": "^0.27.0", "typescript": "^5.3.0", @@ -90,7 +90,7 @@ "@types/node": ["@types/node@20.19.24", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA=="], - "@types/vscode": ["@types/vscode@1.105.0", "", {}, "sha512-Lotk3CTFlGZN8ray4VxJE7axIyLZZETQJVWi/lYoUVQuqfRxlQhVOfoejsD2V3dVXPSbS15ov5ZyowMAzgUqcw=="], + "@types/vscode": ["@types/vscode@1.107.0", "", {}, "sha512-XS8YE1jlyTIowP64+HoN30OlC1H9xqSlq1eoLZUgFEC8oUTO6euYZxti1xRiLSfZocs4qytTzR6xCBYtioQTCg=="], "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.2", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg=="], diff --git a/vscode/package.json b/vscode/package.json index 14a25fc193..c0f586ea73 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -9,7 +9,7 @@ "ui" ], "engines": { - "vscode": "^1.85.0" + "vscode": "^1.106.0" }, "categories": [ "Other" @@ -22,6 +22,8 @@ "development" ], "activationEvents": [ + "onStartupFinished", + "onView:mux.chatView", "onCommand:mux.openWorkspace", "onCommand:mux.configureConnection" ], @@ -37,6 +39,24 @@ "title": "mux: Configure Connection" } ], + "viewsContainers": { + "secondarySidebar": [ + { + "id": "muxSecondary", + "title": "mux", + "icon": "icon.png" + } + ] + }, + "views": { + "muxSecondary": [ + { + "id": "mux.chatView", + "name": "Chat", + "type": "webview" + } + ] + }, "configuration": { "title": "mux", "properties": { @@ -68,7 +88,7 @@ }, "devDependencies": { "@types/node": "^20.0.0", - "@types/vscode": "^1.85.0", + "@types/vscode": "^1.106.0", "@vscode/vsce": "^2.22.0", "esbuild": "^0.27.0", "typescript": "^5.3.0" diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 099a17186d..1ad6494ce2 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -1,10 +1,18 @@ import * as vscode from "vscode"; +import assert from "node:assert"; +import { randomBytes } from "node:crypto"; import { formatRelativeTime } from "mux/browser/utils/ui/dateTime"; +import type { WorkspaceChatMessage } from "mux/common/orpc/types"; -import { getAllWorkspacesFromFiles, getAllWorkspacesFromApi, WorkspaceWithContext } from "./muxConfig"; +import { + getAllWorkspacesFromFiles, + getAllWorkspacesFromApi, + getWorkspacePath, + WorkspaceWithContext, +} from "./muxConfig"; import { checkAuth, checkServerReachable } from "./api/connectionCheck"; -import { createApiClient } from "./api/client"; +import { createApiClient, type ApiClient } from "./api/client"; import { clearAuthTokenOverride, discoverServerConfig, @@ -19,6 +27,157 @@ let didShowFallbackPrompt = false; const ACTION_FIX_CONNECTION_CONFIG = "Fix connection config"; const ACTION_USE_LOCAL_FILES = "Use local file access"; + +const PENDING_AUTO_SELECT_STATE_KEY = "mux.pendingAutoSelectWorkspace"; +const SELECTED_WORKSPACE_STATE_KEY = "mux.selectedWorkspaceId"; +const PENDING_AUTO_SELECT_TTL_MS = 5 * 60_000; + +interface PendingAutoSelectState { + workspaceId: string; + expectedWorkspaceUri: string; + createdAtMs: number; +} + +interface UiWorkspace { + id: string; + label: string; + description: string; + streaming: boolean; + runtimeType: string; +} + +interface UiConnectionStatus { + mode: "api" | "file"; + baseUrl?: string; + error?: string; +} + +type WebviewToExtensionMessage = + | { type: "ready" } + | { type: "refreshWorkspaces" } + | { type: "selectWorkspace"; workspaceId: string | null } + | { type: "openWorkspace"; workspaceId: string } + | { type: "sendMessage"; workspaceId: string; text: string } + | { type: "configureConnection" }; + +type ExtensionToWebviewMessage = + | { type: "connectionStatus"; status: UiConnectionStatus } + | { type: "workspaces"; workspaces: UiWorkspace[] } + | { type: "setSelectedWorkspace"; workspaceId: string | null } + | { type: "chatReset"; workspaceId: string } + | { type: "chatEvent"; workspaceId: string; event: WorkspaceChatMessage } + | { type: "uiNotice"; level: "info" | "error"; message: string }; + +function toUiWorkspace(workspace: WorkspaceWithContext): UiWorkspace { + assert(workspace, "toUiWorkspace requires workspace"); + + const isLegacyWorktree = + workspace.runtimeConfig.type === "local" && + "srcBaseDir" in workspace.runtimeConfig && + Boolean(workspace.runtimeConfig.srcBaseDir); + + const runtimeType = + workspace.runtimeConfig.type === "ssh" + ? "ssh" + : workspace.runtimeConfig.type === "worktree" || isLegacyWorktree + ? "worktree" + : "local"; + + const sshSuffix = workspace.runtimeConfig.type === "ssh" ? ` (ssh: ${workspace.runtimeConfig.host})` : ""; + + return { + id: workspace.id, + label: `[${workspace.projectName}] ${workspace.name}${sshSuffix}`, + description: workspace.projectPath, + streaming: workspace.extensionMetadata?.streaming ?? false, + runtimeType, + }; +} + +function getNonce(): string { + return randomBytes(16).toString("base64"); +} + +function getOpenFolderUri(workspace: WorkspaceWithContext): vscode.Uri { + assert(workspace, "getOpenFolderUri requires workspace"); + + if (workspace.runtimeConfig.type === "ssh") { + const host = workspace.runtimeConfig.host; + const remotePath = getWorkspacePath(workspace); + return vscode.Uri.parse(`vscode-remote://ssh-remote+${host}${remotePath}`); + } + + const workspacePath = getWorkspacePath(workspace); + return vscode.Uri.file(workspacePath); +} + +async function setPendingAutoSelectWorkspace( + context: vscode.ExtensionContext, + workspace: WorkspaceWithContext +): Promise { + assert(context, "setPendingAutoSelectWorkspace requires context"); + assert(workspace, "setPendingAutoSelectWorkspace requires workspace"); + + const expectedUri = getOpenFolderUri(workspace); + const state: PendingAutoSelectState = { + workspaceId: workspace.id, + expectedWorkspaceUri: expectedUri.toString(), + createdAtMs: Date.now(), + }; + + await context.globalState.update(PENDING_AUTO_SELECT_STATE_KEY, state); +} + +async function getPendingAutoSelectWorkspace( + context: vscode.ExtensionContext +): Promise { + assert(context, "getPendingAutoSelectWorkspace requires context"); + + const pending = context.globalState.get(PENDING_AUTO_SELECT_STATE_KEY); + if (!pending) { + return null; + } + + if ( + typeof pending.workspaceId !== "string" || + typeof pending.expectedWorkspaceUri !== "string" || + typeof pending.createdAtMs !== "number" + ) { + await context.globalState.update(PENDING_AUTO_SELECT_STATE_KEY, undefined); + return null; + } + + if (Date.now() - pending.createdAtMs > PENDING_AUTO_SELECT_TTL_MS) { + await context.globalState.update(PENDING_AUTO_SELECT_STATE_KEY, undefined); + return null; + } + + return pending; +} + +async function clearPendingAutoSelectWorkspace(context: vscode.ExtensionContext): Promise { + assert(context, "clearPendingAutoSelectWorkspace requires context"); + await context.globalState.update(PENDING_AUTO_SELECT_STATE_KEY, undefined); +} + +function getPrimaryWorkspaceFolderUri(): vscode.Uri | null { + const folder = vscode.workspace.workspaceFolders?.[0]; + return folder?.uri ?? null; +} + +async function revealChatView(): Promise { + try { + await vscode.commands.executeCommand("workbench.view.extension.muxSecondary"); + } catch { + // Ignore - command may not exist in older VS Code or if view container isn't registered. + } + + try { + await vscode.commands.executeCommand("mux.chatView.focus"); + } catch { + // Ignore - focus command may not exist for webview views. + } +} const ACTION_CANCEL = "Cancel"; type ApiConnectionFailure = @@ -48,9 +207,11 @@ function getWarningSuffix(failure: ApiConnectionFailure): string { return "Using local file access can cause inconsistencies."; } -async function tryGetWorkspacesFromApi( +async function tryGetApiClient( context: vscode.ExtensionContext -): Promise<{ workspaces: WorkspaceWithContext[] } | { failure: ApiConnectionFailure }> { +): Promise<{ client: ApiClient; baseUrl: string } | { failure: ApiConnectionFailure }> { + assert(context, "tryGetApiClient requires context"); + try { const discovery = await discoverServerConfig(context); const client = createApiClient({ baseUrl: discovery.baseUrl, authToken: discovery.authToken }); @@ -86,8 +247,10 @@ async function tryGetWorkspacesFromApi( }; } - const workspaces = await getAllWorkspacesFromApi(client); - return { workspaces }; + return { + client, + baseUrl: discovery.baseUrl, + }; } catch (error) { return { failure: { @@ -99,6 +262,18 @@ async function tryGetWorkspacesFromApi( } } +async function tryGetWorkspacesFromApi( + context: vscode.ExtensionContext +): Promise<{ workspaces: WorkspaceWithContext[] } | { failure: ApiConnectionFailure }> { + const api = await tryGetApiClient(context); + if ("failure" in api) { + return api; + } + + const workspaces = await getAllWorkspacesFromApi(api.client); + return { workspaces }; +} + async function getWorkspacesForCommand( context: vscode.ExtensionContext ): Promise { @@ -308,6 +483,7 @@ async function openWorkspaceCommand(context: vscode.ExtensionContext) { } // Open the selected workspace + await setPendingAutoSelectWorkspace(context, selected.workspace); await openWorkspace(selected.workspace); } @@ -408,10 +584,845 @@ async function configureConnectionCommand(context: vscode.ExtensionContext): Pro } } + + + +async function getWorkspacesForSidebar( + context: vscode.ExtensionContext +): Promise<{ workspaces: WorkspaceWithContext[]; status: UiConnectionStatus }> { + assert(context, "getWorkspacesForSidebar requires context"); + + const modeSetting: ConnectionMode = getConnectionModeSetting(); + + if (modeSetting === "file-only") { + const workspaces = await getAllWorkspacesFromFiles(); + return { workspaces, status: { mode: "file" } }; + } + + const api = await tryGetApiClient(context); + if ("failure" in api) { + const failure = api.failure; + + if (modeSetting === "server-only") { + return { + workspaces: [], + status: { + mode: "file", + baseUrl: failure.baseUrl, + error: `${describeFailure(failure)}. (${failure.error})`, + }, + }; + } + + const workspaces = await getAllWorkspacesFromFiles(); + return { + workspaces, + status: { + mode: "file", + baseUrl: failure.baseUrl, + error: `${describeFailure(failure)}. ${getWarningSuffix(failure)} (${failure.error})`, + }, + }; + } + + const workspaces = await getAllWorkspacesFromApi(api.client); + return { + workspaces, + status: { + mode: "api", + baseUrl: api.baseUrl, + }, + }; +} + +function findWorkspaceIdMatchingCurrentFolder(workspaces: WorkspaceWithContext[]): string | null { + assert(Array.isArray(workspaces), "findWorkspaceIdMatchingCurrentFolder requires workspaces array"); + + const folderUri = getPrimaryWorkspaceFolderUri(); + if (!folderUri) { + return null; + } + + const folderUriString = folderUri.toString(); + for (const workspace of workspaces) { + const expected = getOpenFolderUri(workspace).toString(); + if (expected === folderUriString) { + return workspace.id; + } + } + + return null; +} + +function renderChatViewHtml(webview: vscode.Webview): string { + const nonce = getNonce(); + + const csp = [ + "default-src 'none'", + `img-src ${webview.cspSource} https: data:`, + `style-src ${webview.cspSource} 'nonce-${nonce}'`, + `script-src 'nonce-${nonce}'`, + ].join("; "); + + return ` + + + + + + + + +
+
+
Loading mux…
+
+ + + +
+
+ +
+
+ +
+ +
+ + +
+
+ + + +`; +} + +class MuxChatViewProvider implements vscode.WebviewViewProvider, vscode.Disposable { + private view: vscode.WebviewView | undefined; + private isWebviewReady = false; + + private connectionStatus: UiConnectionStatus = { mode: "file" }; + private workspaces: WorkspaceWithContext[] = []; + private workspacesById = new Map(); + + private selectedWorkspaceId: string | null; + private subscribedWorkspaceId: string | null = null; + private subscriptionAbort: AbortController | null = null; + + constructor(private readonly context: vscode.ExtensionContext) { + this.selectedWorkspaceId = context.workspaceState.get(SELECTED_WORKSPACE_STATE_KEY) ?? null; + } + + dispose(): void { + this.subscriptionAbort?.abort(); + this.subscriptionAbort = null; + this.subscribedWorkspaceId = null; + } + + async setSelectedWorkspaceId(workspaceId: string | null): Promise { + if (workspaceId !== null) { + assert(typeof workspaceId === "string", "workspaceId must be string or null"); + } + + if (workspaceId === this.selectedWorkspaceId) { + this.postMessage({ type: "setSelectedWorkspace", workspaceId }); + await this.updateChatSubscription(); + return; + } + + this.selectedWorkspaceId = workspaceId; + await this.context.workspaceState.update( + SELECTED_WORKSPACE_STATE_KEY, + workspaceId ? workspaceId : undefined + ); + + this.postMessage({ type: "setSelectedWorkspace", workspaceId }); + await this.updateChatSubscription(); + } + + resolveWebviewView(view: vscode.WebviewView): void { + this.view = view; + this.isWebviewReady = false; + + view.webview.options = { + enableScripts: true, + }; + + view.webview.html = renderChatViewHtml(view.webview); + + view.webview.onDidReceiveMessage((msg: unknown) => { + void this.onWebviewMessage(msg); + }); + + view.onDidDispose(() => { + this.view = undefined; + this.isWebviewReady = false; + this.dispose(); + }); + } + + private postMessage(message: ExtensionToWebviewMessage): void { + if (!this.view || !this.isWebviewReady) { + return; + } + + void this.view.webview.postMessage(message); + } + + private async onWebviewMessage(raw: unknown): Promise { + if (typeof raw !== "object" || raw === null || !("type" in raw)) { + return; + } + + const msg = raw as { type: unknown; [key: string]: unknown }; + if (typeof msg.type !== "string") { + return; + } + + const type = msg.type as WebviewToExtensionMessage["type"]; + + if (type === "ready") { + this.isWebviewReady = true; + await this.refreshWorkspaces(); + this.postMessage({ type: "setSelectedWorkspace", workspaceId: this.selectedWorkspaceId }); + await this.updateChatSubscription(); + return; + } + + if (type === "refreshWorkspaces") { + await this.refreshWorkspaces(); + return; + } + + if (type === "selectWorkspace") { + const workspaceId = typeof msg.workspaceId === "string" ? msg.workspaceId : null; + await this.setSelectedWorkspaceId(workspaceId); + return; + } + + if (type === "openWorkspace") { + if (typeof msg.workspaceId !== "string") { + return; + } + await this.openWorkspaceFromView(msg.workspaceId); + return; + } + + if (type === "sendMessage") { + if (typeof msg.workspaceId !== "string" || typeof msg.text !== "string") { + return; + } + await this.sendMessage(msg.workspaceId, msg.text); + return; + } + + if (type === "configureConnection") { + await configureConnectionCommand(this.context); + await this.refreshWorkspaces(); + return; + } + } + + private async refreshWorkspaces(): Promise { + const result = await getWorkspacesForSidebar(this.context); + + this.connectionStatus = result.status; + this.workspaces = result.workspaces; + this.workspacesById = new Map(this.workspaces.map((w) => [w.id, w])); + + this.postMessage({ type: "connectionStatus", status: this.connectionStatus }); + this.postMessage({ type: "workspaces", workspaces: this.workspaces.map(toUiWorkspace) }); + + if (!this.selectedWorkspaceId) { + const match = findWorkspaceIdMatchingCurrentFolder(this.workspaces); + if (match) { + await this.setSelectedWorkspaceId(match); + } + } + + await this.updateChatSubscription(); + } + + private async updateChatSubscription(): Promise { + if (!this.isWebviewReady || !this.view) { + return; + } + + const workspaceId = this.selectedWorkspaceId; + if (!workspaceId || this.connectionStatus.mode !== "api") { + this.subscriptionAbort?.abort(); + this.subscriptionAbort = null; + this.subscribedWorkspaceId = null; + return; + } + + if (this.subscribedWorkspaceId === workspaceId && this.subscriptionAbort && !this.subscriptionAbort.signal.aborted) { + return; + } + + this.subscriptionAbort?.abort(); + + const controller = new AbortController(); + this.subscriptionAbort = controller; + this.subscribedWorkspaceId = workspaceId; + + this.postMessage({ type: "chatReset", workspaceId }); + + const api = await tryGetApiClient(this.context); + if ("failure" in api) { + // Drop back to file mode (chat disabled). + this.connectionStatus = { + mode: "file", + baseUrl: api.failure.baseUrl, + error: `${describeFailure(api.failure)}. (${api.failure.error})`, + }; + this.postMessage({ type: "connectionStatus", status: this.connectionStatus }); + this.postMessage({ + type: "uiNotice", + level: "error", + message: this.connectionStatus.error ?? "mux server unavailable", + }); + + controller.abort(); + if (this.subscriptionAbort === controller) { + this.subscriptionAbort = null; + this.subscribedWorkspaceId = null; + } + return; + } + + try { + const iterator = await api.client.workspace.onChat({ workspaceId }, { signal: controller.signal }); + + for await (const event of iterator) { + if (controller.signal.aborted) { + return; + } + + // Defensive: selection could change without abort (rare race). + if (this.selectedWorkspaceId !== workspaceId) { + return; + } + + this.postMessage({ type: "chatEvent", workspaceId, event }); + } + } catch (error) { + if (controller.signal.aborted) { + return; + } + + this.postMessage({ + type: "uiNotice", + level: "error", + message: `Chat subscription error: ${formatError(error)}`, + }); + } finally { + if (this.subscriptionAbort === controller) { + this.subscriptionAbort = null; + this.subscribedWorkspaceId = null; + } + } + } + + private async sendMessage(workspaceId: string, text: string): Promise { + assert(typeof workspaceId === "string", "sendMessage requires workspaceId"); + assert(typeof text === "string", "sendMessage requires text"); + + const trimmed = text.trim(); + if (!trimmed) { + return; + } + + if (this.connectionStatus.mode !== "api") { + this.postMessage({ + type: "uiNotice", + level: "error", + message: "Chat requires a running mux server.", + }); + return; + } + + const api = await tryGetApiClient(this.context); + if ("failure" in api) { + this.postMessage({ + type: "uiNotice", + level: "error", + message: `${describeFailure(api.failure)}. (${api.failure.error})`, + }); + return; + } + + const result = await api.client.workspace.sendMessage({ + workspaceId, + message: trimmed, + }); + + if (!result.success) { + const errorString = + typeof result.error === "string" ? result.error : JSON.stringify(result.error, null, 2); + this.postMessage({ + type: "uiNotice", + level: "error", + message: `Send failed: ${errorString}`, + }); + } + } + + private async openWorkspaceFromView(workspaceId: string): Promise { + assert(typeof workspaceId === "string", "openWorkspaceFromView requires workspaceId"); + + const workspace = this.workspacesById.get(workspaceId); + if (!workspace) { + this.postMessage({ + type: "uiNotice", + level: "error", + message: "Workspace not found. Refresh and try again.", + }); + return; + } + + await setPendingAutoSelectWorkspace(this.context, workspace); + await openWorkspace(workspace); + } +} + +async function maybeAutoRevealChatViewFromPendingSelection( + context: vscode.ExtensionContext, + provider: MuxChatViewProvider +): Promise { + const pending = await getPendingAutoSelectWorkspace(context); + if (!pending) { + return; + } + + const folderUri = getPrimaryWorkspaceFolderUri(); + if (!folderUri) { + return; + } + + if (folderUri.toString() !== pending.expectedWorkspaceUri) { + return; + } + + await clearPendingAutoSelectWorkspace(context); + await provider.setSelectedWorkspaceId(pending.workspaceId); + await revealChatView(); +} + /** * Activate the extension */ -export function activate(context: vscode.ExtensionContext) { +export async function activate(context: vscode.ExtensionContext): Promise { + const chatViewProvider = new MuxChatViewProvider(context); + + context.subscriptions.push(chatViewProvider); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider("mux.chatView", chatViewProvider, { + webviewOptions: { + retainContextWhenHidden: true, + }, + }) + ); + context.subscriptions.push( vscode.commands.registerCommand("mux.openWorkspace", () => openWorkspaceCommand(context)) ); @@ -419,6 +1430,8 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.commands.registerCommand("mux.configureConnection", () => configureConnectionCommand(context)) ); + + await maybeAutoRevealChatViewFromPendingSelection(context, chatViewProvider); } /**