diff --git a/CHANGELOG.md b/CHANGELOG.md index 14f3a583..aeba3f32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Added + +- Coder extension sidebar now displays available app statuses, and let's + the user click them to drop into a session with a running AI Agent. + ## [v1.7.1](https://github.com/coder/vscode-coder/releases/tag/v1.7.1) (2025-04-14) ### Fixed diff --git a/package.json b/package.json index 704aa324..e6be8217 100644 --- a/package.json +++ b/package.json @@ -204,6 +204,12 @@ "title": "Coder: View Logs", "icon": "$(list-unordered)", "when": "coder.authenticated" + }, + { + "command": "coder.openAppStatus", + "title": "Coder: Open App Status", + "icon": "$(robot)", + "when": "coder.authenticated" } ], "menus": { diff --git a/src/commands.ts b/src/commands.ts index d24df729..830347e0 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,6 +1,7 @@ import { Api } from "coder/site/src/api/api" import { getErrorMessage } from "coder/site/src/api/errors" import { User, Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated" +import path from "node:path" import * as vscode from "vscode" import { makeCoderSdk, needToken } from "./api" import { extractAgents } from "./api-helper" @@ -407,6 +408,63 @@ export class Commands { } } + public async openAppStatus(app: { + name?: string + url?: string + agent_name?: string + command?: string + workspace_name: string + }): Promise { + // Launch and run command in terminal if command is provided + if (app.command) { + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Connecting to AI Agent...`, + cancellable: false, + }, + async () => { + const terminal = vscode.window.createTerminal(app.name) + + // If workspace_name is provided, run coder ssh before the command + + const url = this.storage.getUrl() + if (!url) { + throw new Error("No coder url found for sidebar") + } + const binary = await this.storage.fetchBinary(this.restClient, toSafeHost(url)) + const escape = (str: string): string => `"${str.replace(/"/g, '\\"')}"` + terminal.sendText( + `${escape(binary)} ssh --global-config ${escape( + path.dirname(this.storage.getSessionTokenPath(toSafeHost(url))), + )} ${app.workspace_name}`, + ) + await new Promise((resolve) => setTimeout(resolve, 5000)) + terminal.sendText(app.command ?? "") + terminal.show(false) + }, + ) + } + // Check if app has a URL to open + if (app.url) { + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Opening ${app.name || "application"} in browser...`, + cancellable: false, + }, + async () => { + await vscode.env.openExternal(vscode.Uri.parse(app.url!)) + }, + ) + } + + // If no URL or command, show information about the app status + vscode.window.showInformationMessage(`${app.name}`, { + detail: `Agent: ${app.agent_name || "Unknown"}`, + }) + } + /** * Open a workspace belonging to the currently logged-in deployment. * diff --git a/src/extension.ts b/src/extension.ts index a38bf6f5..de586169 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -181,6 +181,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vscode.commands.registerCommand("coder.open", commands.open.bind(commands)) vscode.commands.registerCommand("coder.openDevContainer", commands.openDevContainer.bind(commands)) vscode.commands.registerCommand("coder.openFromSidebar", commands.openFromSidebar.bind(commands)) + vscode.commands.registerCommand("coder.openAppStatus", commands.openAppStatus.bind(commands)) vscode.commands.registerCommand("coder.workspace.update", commands.updateWorkspace.bind(commands)) vscode.commands.registerCommand("coder.createWorkspace", commands.createWorkspace.bind(commands)) vscode.commands.registerCommand("coder.navigateToWorkspace", commands.navigateToWorkspace.bind(commands)) diff --git a/src/workspacesProvider.ts b/src/workspacesProvider.ts index 0709487e..0f821a2f 100644 --- a/src/workspacesProvider.ts +++ b/src/workspacesProvider.ts @@ -1,5 +1,5 @@ import { Api } from "coder/site/src/api/api" -import { Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated" +import { Workspace, WorkspaceAgent, WorkspaceApp } from "coder/site/src/api/typesGenerated" import { EventSource } from "eventsource" import * as path from "path" import * as vscode from "vscode" @@ -146,9 +146,36 @@ export class WorkspaceProvider implements vscode.TreeDataProvider { - return new WorkspaceTreeItem(workspace, this.getWorkspacesQuery === WorkspaceQuery.All, showMetadata) - }) + // Create tree items for each workspace + const workspaceTreeItems = await Promise.all( + resp.workspaces.map(async (workspace) => { + const workspaceTreeItem = new WorkspaceTreeItem( + workspace, + this.getWorkspacesQuery === WorkspaceQuery.All, + showMetadata, + ) + + // Get app status from the workspace agents + const agents = extractAgents(workspace) + agents.forEach((agent) => { + // Check if agent has apps property with status reporting + if (agent.apps && Array.isArray(agent.apps)) { + workspaceTreeItem.appStatus = agent.apps.map((app: WorkspaceApp) => ({ + name: app.display_name, + url: app.url, + agent_id: agent.id, + agent_name: agent.name, + command: app.command, + workspace_name: workspace.name, + })) + } + }) + + return workspaceTreeItem + }), + ) + + return workspaceTreeItems } /** @@ -207,14 +234,58 @@ export class WorkspaceProvider implements vscode.TreeDataProvider new AgentTreeItem(agent, element.workspaceOwner, element.workspaceName, element.watchMetadata), ) + return Promise.resolve(agentTreeItems) } else if (element instanceof AgentTreeItem) { const watcher = this.agentWatchers[element.agent.id] if (watcher?.error) { return Promise.resolve([new ErrorTreeItem(watcher.error)]) } + + const items: vscode.TreeItem[] = [] + + // Add app status section with collapsible header + if (element.agent.apps && element.agent.apps.length > 0) { + const appStatuses = [] + for (const app of element.agent.apps) { + if (app.statuses && app.statuses.length > 0) { + for (const status of app.statuses) { + // Show all statuses, not just ones needing attention. + // We need to do this for now because the reporting isn't super accurate + // yet. + appStatuses.push( + new AppStatusTreeItem({ + name: status.message, + command: app.command, + workspace_name: element.workspaceName, + }), + ) + } + } + } + + // Show the section if it has any items + if (appStatuses.length > 0) { + const appStatusSection = new SectionTreeItem("App Statuses", appStatuses.reverse()) + items.push(appStatusSection) + } + } + const savedMetadata = watcher?.metadata || [] - return Promise.resolve(savedMetadata.map((metadata) => new AgentMetadataTreeItem(metadata))) + + // Add agent metadata section with collapsible header + if (savedMetadata.length > 0) { + const metadataSection = new SectionTreeItem( + "Agent Metadata", + savedMetadata.map((metadata) => new AgentMetadataTreeItem(metadata)), + ) + items.push(metadataSection) + } + + return Promise.resolve(items) + } else if (element instanceof SectionTreeItem) { + // Return the children of the section + return Promise.resolve(element.children) } return Promise.resolve([]) @@ -265,6 +336,19 @@ function monitorMetadata(agentId: WorkspaceAgent["id"], restClient: Api): AgentW return watcher } +/** + * A tree item that represents a collapsible section with child items + */ +class SectionTreeItem extends vscode.TreeItem { + constructor( + label: string, + public readonly children: vscode.TreeItem[], + ) { + super(label, vscode.TreeItemCollapsibleState.Collapsed) + this.contextValue = "coderSectionHeader" + } +} + class ErrorTreeItem extends vscode.TreeItem { constructor(error: unknown) { super("Failed to query metadata: " + errToStr(error, "no error provided"), vscode.TreeItemCollapsibleState.None) @@ -285,6 +369,28 @@ class AgentMetadataTreeItem extends vscode.TreeItem { } } +class AppStatusTreeItem extends vscode.TreeItem { + constructor( + public readonly app: { + name: string + url?: string + command?: string + workspace_name?: string + }, + ) { + super("", vscode.TreeItemCollapsibleState.None) + this.description = app.name + this.contextValue = "coderAppStatus" + + // Add command to handle clicking on the app + this.command = { + command: "coder.openAppStatus", + title: "Open App Status", + arguments: [app], + } + } +} + type CoderOpenableTreeItemType = "coderWorkspaceSingleAgent" | "coderWorkspaceMultipleAgents" | "coderAgent" export class OpenableTreeItem extends vscode.TreeItem { @@ -335,6 +441,15 @@ class AgentTreeItem extends OpenableTreeItem { } export class WorkspaceTreeItem extends OpenableTreeItem { + public appStatus: { + name: string + url?: string + agent_id?: string + agent_name?: string + command?: string + workspace_name?: string + }[] = [] + constructor( public readonly workspace: Workspace, public readonly showOwner: boolean, diff --git a/yarn.lock b/yarn.lock index d5abdc06..51457af0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1714,7 +1714,7 @@ co@3.1.0: "coder@https://github.com/coder/coder#main": version "0.0.0" - resolved "https://github.com/coder/coder#3ac844ad3d341d2910542b83d4f33df7bd0be85e" + resolved "https://github.com/coder/coder#2efb8088f4d923d1884fe8947dc338f9d179693b" collapse-white-space@^1.0.2: version "1.0.6"