diff --git a/extension/README.md b/extension/README.md
index c068d7a96d6..21444ab588e 100644
--- a/extension/README.md
+++ b/extension/README.md
@@ -190,7 +190,7 @@ You can configure the extension under **Settings → Aspire**, or jump there wit
|---------|---------|-------------|
| `aspire.aspireCliExecutablePath` | `""` | Path to the Aspire CLI. Leave empty to use `aspire` from PATH. |
| `aspire.dashboardBrowser` | `openExternalBrowser` | Which browser to open the dashboard in — system default, or Chrome/Edge/Firefox as a debug session |
-| `aspire.enableAspireDashboardAutoLaunch` | `true` | Open the dashboard automatically when you start debugging |
+| `aspire.enableAspireDashboardAutoLaunch` | `launch` | Controls what happens with the dashboard when debugging starts: `launch` (auto-open), `notification` (show link), or `off` |
| `aspire.registerMcpServerInWorkspace` | `false` | Register the Aspire MCP server (see [below](#mcp-server-integration)) |
There are more settings for things like verbose logging, startup prompts, and polling intervals — run **Aspire: Extension settings** from the Command Palette to see them all.
diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf
index 5ad10af80d2..bd6672f38fd 100644
--- a/extension/loc/xlf/aspire-vscode.xlf
+++ b/extension/loc/xlf/aspire-vscode.xlf
@@ -52,6 +52,9 @@
+
+ Automatically open the dashboard in the browser.
+
Build failed for project {0} with error: {1}.
@@ -82,6 +85,9 @@
Configure launch.json file
+
+ Controls what happens with the Aspire Dashboard when debugging starts.
+
Create a new project
@@ -109,6 +115,9 @@
Dismiss
+
+ Do nothing — the dashboard URL is still printed in the terminal.
+
Do you want to select the default apphost for this workspace?
@@ -124,9 +133,6 @@
Enable apphost discovery on extension activation and prompt to setup .aspire/settings.json.appHostPath if it does not exist in the workspace.
-
- Enable automatic launch of the Aspire Dashboard when using the Aspire debugger.
-
Enable console debug logging for Aspire CLI commands executed by the extension.
@@ -197,7 +203,7 @@
Include environment variables when logging debug session configurations. This can help diagnose environment-related issues but may expose sensitive information in logs.
- Initialize Aspire
+ Initialize Aspire in an existing codebase
Install Aspire CLI (daily)
@@ -223,6 +229,9 @@
Launch Aspire Dashboard
+
+ Settings
+
Launch Chrome as a debug session (auto-closes when debugging ends).
@@ -338,7 +347,7 @@
Run {0}
- Running apphosts
+ Running AppHosts
Scaffold a new Aspire project from a starter template. The template includes an apphost orchestrator, a sample API, and a web frontend.
[Create new project](command:aspire-vscode.new)
@@ -355,6 +364,9 @@
Select the default apphost to launch when starting an Aspire debug session
+
+ Show a notification with a link to open the dashboard.
+
Show global apphosts
@@ -388,6 +400,9 @@
The apphost is not compatible. Consider upgrading the apphost or Aspire CLI.
+
+ The apphost process has terminated. To view console output, select the apphost session from the debug console dropdown.
+
The browser to use when auto-launching the Aspire Dashboard.
diff --git a/extension/package.json b/extension/package.json
index 0c1355e59d2..c5a37e9d557 100644
--- a/extension/package.json
+++ b/extension/package.json
@@ -470,9 +470,15 @@
"scope": "window"
},
"aspire.enableAspireDashboardAutoLaunch": {
- "type": "boolean",
- "default": true,
+ "type": "string",
+ "enum": ["launch", "notification", "off"],
+ "default": "launch",
"description": "%configuration.aspire.enableAspireDashboardAutoLaunch%",
+ "enumDescriptions": [
+ "%configuration.aspire.enableAspireDashboardAutoLaunch.launch%",
+ "%configuration.aspire.enableAspireDashboardAutoLaunch.notification%",
+ "%configuration.aspire.enableAspireDashboardAutoLaunch.off%"
+ ],
"scope": "window"
},
"aspire.dashboardBrowser": {
diff --git a/extension/package.nls.json b/extension/package.nls.json
index 58dbc1b38dc..886b11ae286 100644
--- a/extension/package.nls.json
+++ b/extension/package.nls.json
@@ -24,7 +24,10 @@
"configuration.aspire.aspireCliExecutablePath": "The path to the Aspire CLI executable. If not set, the extension will attempt to use 'aspire' from the system PATH.",
"configuration.aspire.enableAspireCliDebugLogging": "Enable console debug logging for Aspire CLI commands executed by the extension.",
"configuration.aspire.enableAspireDcpDebugLogging": "Enable Developer Control Plane (DCP) debug logging for Aspire applications. Logs will be stored in the workspace's .aspire/dcp/logs-{debugSessionId} folder.",
- "configuration.aspire.enableAspireDashboardAutoLaunch": "Enable automatic launch of the Aspire Dashboard when using the Aspire debugger.",
+ "configuration.aspire.enableAspireDashboardAutoLaunch": "Controls what happens with the Aspire Dashboard when debugging starts.",
+ "configuration.aspire.enableAspireDashboardAutoLaunch.launch": "Automatically open the dashboard in the browser.",
+ "configuration.aspire.enableAspireDashboardAutoLaunch.notification": "Show a notification with a link to open the dashboard.",
+ "configuration.aspire.enableAspireDashboardAutoLaunch.off": "Do nothing — the dashboard URL is still printed in the terminal.",
"configuration.aspire.dashboardBrowser": "The browser to use when auto-launching the Aspire Dashboard.",
"configuration.aspire.dashboardBrowser.openExternalBrowser": "Use the system default browser (cannot auto-close).",
"configuration.aspire.dashboardBrowser.debugChrome": "Launch Chrome as a debug session (auto-closes when debugging ends).",
diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts
index 4b48f955263..d65d03d95f7 100644
--- a/extension/src/loc/strings.ts
+++ b/extension/src/loc/strings.ts
@@ -9,6 +9,7 @@ export const codespacesUrl = (url: string) => vscode.l10n.t('Codespaces: {0}', u
export const directLink = vscode.l10n.t('Open local URL');
export const codespacesLink = vscode.l10n.t('Open codespaces URL');
export const openAspireDashboard = vscode.l10n.t('Launch Aspire Dashboard');
+export const settingsLabel = vscode.l10n.t('Settings');
export const aspireDashboard = vscode.l10n.t('Aspire Dashboard');
export const noWorkspaceOpen = vscode.l10n.t('No workspace is open. Please open a folder or workspace before running this command.');
export const failedToShowPromptEmpty = vscode.l10n.t('Failed to show prompt, text was empty.');
diff --git a/extension/src/server/interactionService.ts b/extension/src/server/interactionService.ts
index a546a1401b6..7cdbd04e08a 100644
--- a/extension/src/server/interactionService.ts
+++ b/extension/src/server/interactionService.ts
@@ -2,7 +2,7 @@ import { MessageConnection } from 'vscode-jsonrpc';
import * as vscode from 'vscode';
import * as fs from 'fs/promises';
import { getRelativePathToWorkspace, isFolderOpenInWorkspace } from '../utils/workspace';
-import { yesLabel, noLabel, directLink, codespacesLink, openAspireDashboard, failedToShowPromptEmpty, incompatibleAppHostError, aspireHostingSdkVersion, aspireCliVersion, requiredCapability, fieldRequired, aspireDebugSessionNotInitialized, errorMessage, failedToStartDebugSession, dashboard, codespaces, selectDirectoryTitle, selectFileTitle } from '../loc/strings';
+import { yesLabel, noLabel, directLink, codespacesLink, openAspireDashboard, settingsLabel, failedToShowPromptEmpty, incompatibleAppHostError, aspireHostingSdkVersion, aspireCliVersion, requiredCapability, fieldRequired, aspireDebugSessionNotInitialized, errorMessage, failedToStartDebugSession, dashboard, codespaces, selectDirectoryTitle, selectFileTitle } from '../loc/strings';
import { ICliRpcClient } from './rpcClient';
import { ProgressNotifier } from './progressNotifier';
import { applyTextStyle, formatText } from '../utils/strings';
@@ -334,11 +334,27 @@ export class InteractionService implements IInteractionService {
this.writeDebugSessionMessage(codespacesUrl, true, AnsiColors.Blue);
}
- // If aspire.enableAspireDashboardAutoLaunch is true, the dashboard will be launched automatically and we do not need
- // to show an information message.
+ // Refresh the Aspire panel so it picks up dashboard URLs for the running app host
+ vscode.commands.executeCommand('aspire-vscode.refreshRunningAppHosts');
+
+ // If aspire.enableAspireDashboardAutoLaunch is 'launch', the dashboard will be launched automatically.
+ // If 'notification', a notification is shown with a link. If 'off', do nothing.
const aspireConfig = vscode.workspace.getConfiguration('aspire');
- const enableDashboardAutoLaunch = aspireConfig.get('enableAspireDashboardAutoLaunch', true);
- if (enableDashboardAutoLaunch) {
+ const rawDashboardAutoLaunch = aspireConfig.get('enableAspireDashboardAutoLaunch', 'launch');
+
+ // Handle legacy boolean values from before this setting was changed to an enum
+ let dashboardAutoLaunch: 'launch' | 'notification' | 'off';
+ if (rawDashboardAutoLaunch === true) {
+ dashboardAutoLaunch = 'launch';
+ } else if (rawDashboardAutoLaunch === false) {
+ dashboardAutoLaunch = 'notification';
+ } else if (rawDashboardAutoLaunch === 'launch' || rawDashboardAutoLaunch === 'notification' || rawDashboardAutoLaunch === 'off') {
+ dashboardAutoLaunch = rawDashboardAutoLaunch;
+ } else {
+ dashboardAutoLaunch = 'launch';
+ }
+
+ if (dashboardAutoLaunch === 'launch') {
// Open the dashboard URL in the configured browser. Prefer codespaces URL if available.
const urlToOpen = codespacesUrl || baseUrl;
const debugSession = this._getAspireDebugSession();
@@ -349,6 +365,10 @@ export class InteractionService implements IInteractionService {
return;
}
+ if (dashboardAutoLaunch === 'off') {
+ return;
+ }
+
const actions: vscode.MessageItem[] = [
{ title: directLink }
];
@@ -357,6 +377,8 @@ export class InteractionService implements IInteractionService {
actions.push({ title: codespacesLink });
}
+ actions.push({ title: settingsLabel });
+
// Delay 1 second to allow a slight pause between progress notification and message
setTimeout(() => {
// Don't await - fire and forget to avoid blocking
@@ -376,6 +398,9 @@ export class InteractionService implements IInteractionService {
else if (selected.title === codespacesLink && codespacesUrl) {
vscode.env.openExternal(vscode.Uri.parse(codespacesUrl));
}
+ else if (selected.title === settingsLabel) {
+ vscode.commands.executeCommand('workbench.action.openSettings', 'aspire.enableAspireDashboardAutoLaunch');
+ }
});
}, 1000);
}
diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts
index 2fa15746210..43aad8b5ae0 100644
--- a/extension/src/test/appHostTreeView.test.ts
+++ b/extension/src/test/appHostTreeView.test.ts
@@ -4,7 +4,7 @@ import { getResourceContextValue, getResourceIcon } from '../views/AspireAppHost
import type { ResourceJson } from '../views/AppHostDataRepository';
function makeResource(overrides: Partial = {}): ResourceJson {
- return {
+ const base: ResourceJson = {
name: 'my-service',
displayName: null,
resourceType: 'Project',
@@ -14,8 +14,9 @@ function makeResource(overrides: Partial = {}): ResourceJson {
dashboardUrl: null,
urls: null,
commands: null,
- ...overrides,
+ properties: null,
};
+ return { ...base, ...overrides } as ResourceJson;
}
suite('shortenPath', () => {
@@ -97,6 +98,7 @@ suite('getResourceContextValue', () => {
}));
assert.strictEqual(result, 'resource:canRestart');
});
+
});
suite('getResourceIcon', () => {
diff --git a/extension/src/test/rpc/interactionServiceTests.test.ts b/extension/src/test/rpc/interactionServiceTests.test.ts
index f2b073aa7c9..e8de2f7b703 100644
--- a/extension/src/test/rpc/interactionServiceTests.test.ts
+++ b/extension/src/test/rpc/interactionServiceTests.test.ts
@@ -189,11 +189,11 @@ suite('InteractionService endpoints', () => {
stub.restore();
});
- test("displayDashboardUrls writes URLs to output channel and shows info message when autoLaunch disabled", async () => {
+ test("displayDashboardUrls writes URLs to output channel and shows info message when autoLaunch is notification", async () => {
const stub = sinon.stub(extensionLogOutputChannel, 'info');
const showInformationMessageStub = sinon.stub(vscode.window, 'showInformationMessage').resolves();
const getConfigurationStub = sinon.stub(vscode.workspace, 'getConfiguration').returns({
- get: (key: string, defaultValue?: any) => key === 'enableAspireDashboardAutoLaunch' ? false : defaultValue
+ get: (key: string, defaultValue?: any) => key === 'enableAspireDashboardAutoLaunch' ? 'notification' : defaultValue
} as any);
const testInfo = await createTestRpcServer();
@@ -212,17 +212,17 @@ suite('InteractionService endpoints', () => {
assert.ok(outputLines.some(line => line.includes(baseUrl)), 'Output should contain base URL');
assert.ok(outputLines.some(line => line.includes(codespacesUrl)), 'Output should contain codespaces URL');
- assert.equal(showInformationMessageStub.callCount, 1, 'Should show info message when autoLaunch is disabled');
+ assert.equal(showInformationMessageStub.callCount, 1, 'Should show info message when autoLaunch is notification');
stub.restore();
showInformationMessageStub.restore();
getConfigurationStub.restore();
});
- test("displayDashboardUrls writes URLs but does not show info message when autoLaunch enabled", async () => {
+ test("displayDashboardUrls writes URLs but does not show info message when autoLaunch is launch", async () => {
const stub = sinon.stub(extensionLogOutputChannel, 'info');
const showInformationMessageStub = sinon.stub(vscode.window, 'showInformationMessage').resolves();
const getConfigurationStub = sinon.stub(vscode.workspace, 'getConfiguration').returns({
- get: (key: string, defaultValue?: any) => key === 'enableAspireDashboardAutoLaunch' ? true : defaultValue
+ get: (key: string, defaultValue?: any) => key === 'enableAspireDashboardAutoLaunch' ? 'launch' : defaultValue
} as any);
const testInfo = await createTestRpcServer();
@@ -239,7 +239,7 @@ suite('InteractionService endpoints', () => {
// No need to wait since no setTimeout should be called when autoLaunch is enabled
assert.ok(outputLines.some(line => line.includes(baseUrl)), 'Output should contain base URL');
assert.ok(outputLines.some(line => line.includes(codespacesUrl)), 'Output should contain codespaces URL');
- assert.equal(showInformationMessageStub.callCount, 0, 'Should not show info message when autoLaunch is enabled');
+ assert.equal(showInformationMessageStub.callCount, 0, 'Should not show info message when autoLaunch is launch');
stub.restore();
showInformationMessageStub.restore();
getConfigurationStub.restore();
diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts
index 97098feb359..94b830d7a50 100644
--- a/extension/src/views/AppHostDataRepository.ts
+++ b/extension/src/views/AppHostDataRepository.ts
@@ -27,6 +27,7 @@ export interface ResourceJson {
dashboardUrl: string | null;
urls: ResourceUrlJson[] | null;
commands: Record | null;
+ properties: Record | null;
}
export interface AppHostDisplayInfo {
@@ -64,7 +65,6 @@ export class AppHostDataRepository {
private _describeRestarting = false;
private _describeRestartDelay = 5000;
private _describeRestartTimer: ReturnType | undefined;
- private static readonly _maxDescribeRestartDelay = 60000;
// ── Global mode state (ps polling) ──
private _appHosts: AppHostDisplayInfo[] = [];
@@ -273,7 +273,7 @@ export class AppHostDataRepository {
// Auto-restart with exponential backoff
const delay = this._describeRestartDelay;
- this._describeRestartDelay = Math.min(this._describeRestartDelay * 2, AppHostDataRepository._maxDescribeRestartDelay);
+ this._describeRestartDelay = Math.min(this._describeRestartDelay * 2, this._getPollingIntervalMs());
extensionLogOutputChannel.info(`Restarting describe --follow in ${delay}ms`);
this._describeRestartTimer = setTimeout(() => {
this._describeRestartTimer = undefined;
diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts
index bc80b18ff78..b98fc02ef5d 100644
--- a/extension/src/views/AspireAppHostTreeProvider.ts
+++ b/extension/src/views/AspireAppHostTreeProvider.ts
@@ -25,6 +25,14 @@ import {
type TreeElement = AppHostItem | DetailItem | ResourcesGroupItem | ResourceItem | WorkspaceResourcesItem;
+function sortResources(resources: ResourceJson[]): ResourceJson[] {
+ return [...resources].sort((a, b) => {
+ const nameA = (a.displayName ?? a.name).toLowerCase();
+ const nameB = (b.displayName ?? b.name).toLowerCase();
+ return nameA.localeCompare(nameB);
+ });
+}
+
function appHostIcon(path?: string): vscode.ThemeIcon {
const icon = path?.endsWith('.csproj') ? 'server-process' : 'file-code';
return new vscode.ThemeIcon(icon, new vscode.ThemeColor('aspire.brandPurple'));
@@ -71,12 +79,19 @@ class ResourcesGroupItem extends vscode.TreeItem {
}
}
+function getParentResourceName(resource: ResourceJson): string | null {
+ return resource.properties?.['resource.parentName'] ?? null;
+}
+
class ResourceItem extends vscode.TreeItem {
- constructor(public readonly resource: ResourceJson, public readonly appHostPid: number | null) {
+ constructor(public readonly resource: ResourceJson, public readonly appHostPid: number | null, hasChildren: boolean) {
const state = resource.state ?? '';
const label = state ? resourceStateLabel(resource.displayName ?? resource.name, state) : (resource.displayName ?? resource.name);
const hasUrls = resource.urls && resource.urls.filter(u => !u.isInternal).length > 0;
- super(label, hasUrls ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None);
+ const collapsible = hasChildren
+ ? vscode.TreeItemCollapsibleState.Expanded
+ : hasUrls ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None;
+ super(label, collapsible);
this.id = appHostPid !== null ? `resource:${appHostPid}:${resource.name}` : `resource:workspace:${resource.name}`;
this.iconPath = getResourceIcon(resource);
this.description = resource.resourceType;
@@ -220,14 +235,17 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider !getParentResourceName(r));
+ for (const resource of sortResources(topLevel)) {
+ const hasChildren = element.resources.some(r => getParentResourceName(r) === resource.name);
+ items.push(new ResourceItem(resource, null, hasChildren));
}
return items;
}
if (element instanceof ResourceItem) {
- return this._getUrlChildren(element);
+ return this._getResourceChildren(element, [...this._repository.workspaceResources]);
}
return [];
@@ -277,16 +295,39 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider new ResourceItem(r, element.appHostPid));
+ const topLevel = element.resources.filter(r => !getParentResourceName(r));
+ return sortResources(topLevel).map(r => {
+ const hasChildren = element.resources.some(c => getParentResourceName(c) === r.name);
+ return new ResourceItem(r, element.appHostPid, hasChildren);
+ });
}
if (element instanceof ResourceItem) {
- return this._getUrlChildren(element);
+ const allResources = this._repository.viewMode === 'workspace'
+ ? [...this._repository.workspaceResources]
+ : this._repository.appHosts.find(a => a.appHostPid === element.appHostPid)?.resources ?? [];
+ return this._getResourceChildren(element, allResources);
}
return [];
}
+ private _getResourceChildren(element: ResourceItem, allResources: readonly ResourceJson[]): TreeElement[] {
+ const items: TreeElement[] = [];
+
+ // Add child resources
+ const children = allResources.filter(r => getParentResourceName(r) === element.resource.name);
+ for (const child of sortResources(children)) {
+ const hasChildren = allResources.some(r => getParentResourceName(r) === child.name);
+ items.push(new ResourceItem(child, element.appHostPid, hasChildren));
+ }
+
+ // Add URL children
+ items.push(...this._getUrlChildren(element));
+
+ return items;
+ }
+
private _getUrlChildren(element: ResourceItem): TreeElement[] {
const urls = element.resource.urls?.filter(u => !u.isInternal) ?? [];
return urls.map(url => new DetailItem(
diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs
index c8a02f12456..6a766f82ec7 100644
--- a/src/Aspire.Cli/Commands/RunCommand.cs
+++ b/src/Aspire.Cli/Commands/RunCommand.cs
@@ -252,7 +252,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell
// Now wait for the backchannel to be established
var backchannel = await InteractionService.ShowStatusAsync(
- isExtensionHost ? InteractionServiceStrings.BuildingAppHost : RunCommandStrings.ConnectingToAppHost,
+ RunCommandStrings.ConnectingToAppHost,
async () => await backchannelCompletionSource.Task.WaitAsync(cancellationToken));
// Set up log capture - writes to unified CLI log file