From 68161d903a154a60045f61518cabdd05011650b4 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:47:43 +0200 Subject: [PATCH 01/24] Reconcile external sessions in a single catalog pass (#331472) * Reconcile external sessions in a single catalog pass Changing `chat.agentSessions.showExternal` took 30-50s to take effect on a profile with ~600 sessions. `_reconcileExternalSessions` walked the whole session catalog once per mode on a mode change, and each walk opens every registered session's database. The session list kept showing the old setting for that whole window, so a later reconciliation looked like sessions randomly disappearing. Derive both the outgoing and incoming visible sets from a single `listSessions(All)` pass. `All` is a superset of every mode and `_shouldIncludeSession` is a pure predicate over the rows, so this is equivalent. Also add logging that was missing to diagnose this: - each window logs what it mirrors into the shared host root config, and from which configuration target (these keys are last-writer-wins across windows) - external-sessions mode transitions - reconciliation duration and published/retracted/visible counts - catalog pass timing, promoted to info above 1s Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback - Redact mirrored configuration values in the log. The sync registry accepts arbitrary values and anticipates machine-local settings (`localOnly`), and `chat.tools.autoApprove.edits` already mirrors user-authored glob patterns, so only closed-set values (booleans, numbers, declared enum members) are printed verbatim; everything else logs its type. - Add a regression test asserting a mode change performs a single catalog pass, covering the `Recent` transition. Verified it fails against the previous two-pass implementation. - Correct the `_resolveModeChangeVisibility` JSDoc, which wrongly called `_shouldIncludeSession` pure, and shorten it. - Condense the remaining inline comments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/remoteAgentHostProtocolClient.ts | 8 ++- .../common/agentHostConfigurationSync.ts | 17 +++++ .../platform/agentHost/node/agentService.ts | 68 ++++++++++++++++--- .../common/agentHostConfigurationSync.test.ts | 26 ++++++- .../agentHost/test/node/agentService.test.ts | 40 +++++++++++ 5 files changed, 147 insertions(+), 12 deletions(-) diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 82028582f5377c..e1d8a40e2f4922 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -17,7 +17,7 @@ import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; import { FileSystemProviderErrorCode, toFileSystemProviderErrorCode } from '../../files/common/files.js'; -import { IConfigurationService } from '../../configuration/common/configuration.js'; +import { ConfigurationTargetToString, IConfigurationService } from '../../configuration/common/configuration.js'; import { AgentSession, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agent.js'; import { IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult } from '../common/agentService.js'; import { AMBIENT_AGENT_HOST_AUTHORITY } from '../common/agentHostConnectionsService.js'; @@ -40,7 +40,7 @@ import { ILoadEstimator, LoadEstimator } from '../../../base/parts/ipc/common/ip import { ITelemetryService, TelemetryLevel, TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, getAgentHostTerminalAutoApproveRulesConfig, GLOBAL_AUTO_APPROVE_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; -import { getAgentHostConfigurationSyncEntries, resolveAgentHostConfigurationSyncPatch, resolveAgentHostConfigurationSyncValue } from '../common/agentHostConfigurationSync.js'; +import { formatAgentHostConfigurationSyncValueForLog, getAgentHostConfigurationSyncEntries, resolveAgentHostConfigurationSyncPatch, resolveAgentHostConfigurationSyncValue } from '../common/agentHostConfigurationSync.js'; import { managedPermissionsConfigurationIds, resolveManagedSettingsPermissions, type IAgentHostManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; import { AgentHostClientConnectionKind, toAgentHostClientMeta } from '../common/agentHostTelemetry.js'; import type { OtlpExportLogsParams } from '../common/state/protocol/channels-otlp/notifications.js'; @@ -354,6 +354,8 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC return; } const patch: Record = {}; + // These keys are host-level and last-writer-wins across windows. + const mirrored: string[] = []; for (const entry of getAgentHostConfigurationSyncEntries(this._resourceIdentity === LOCAL_AGENT_HOST_RESOURCE_IDENTITY)) { if (!e.affectsConfiguration(entry.settingId)) { continue; @@ -361,9 +363,11 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC const value = resolveAgentHostConfigurationSyncValue(this._configurationService, entry); if (value !== undefined) { patch[entry.sync.key] = value; + mirrored.push(`${entry.sync.key}=${formatAgentHostConfigurationSyncValueForLog(entry.settingId, value)} (${entry.settingId})`); } } if (Object.keys(patch).length) { + this._logService.info(`[RemoteAgentHostProtocol] Mirroring configuration to host root config from ${ConfigurationTargetToString(e.source)}: ${mirrored.join(', ')}`); this._dispatchRootConfig(patch); } if (e.affectsConfiguration(GLOBAL_AUTO_APPROVE_SETTING_ID)) { diff --git a/src/vs/platform/agentHost/common/agentHostConfigurationSync.ts b/src/vs/platform/agentHost/common/agentHostConfigurationSync.ts index 99853192a49e15..5519df0b6e735c 100644 --- a/src/vs/platform/agentHost/common/agentHostConfigurationSync.ts +++ b/src/vs/platform/agentHost/common/agentHostConfigurationSync.ts @@ -128,6 +128,23 @@ export function resolveAgentHostConfigurationSyncValue(configurationService: ICo return entry.sync.transform ? entry.sync.transform(value) : value; } +/** + * Renders a mirrored value for logging, redacting anything that could carry + * user content. Mirrored settings are registry-driven and may hold paths or + * arbitrary strings, so only closed-set values (booleans, numbers, and declared + * enum members) are printed verbatim. + */ +export function formatAgentHostConfigurationSyncValueForLog(settingId: string, value: unknown): string { + if (typeof value === 'boolean' || typeof value === 'number') { + return String(value); + } + const property = getPropertySchema(settingId); + if (typeof value === 'string' && property?.enum?.includes(value)) { + return value; + } + return `<${Array.isArray(value) ? 'array' : typeof value}>`; +} + /** * Builds the full root-config patch mirroring every applicable setting. Used on * connect and reconnect, where the host may be a freshly restarted process that diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 8f95a5d41687e9..bab9b35df50fb1 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -119,6 +119,8 @@ import { AgentHostCheckpointService } from './agentHostCheckpointService.js'; const SESSION_GC_GRACE_MS = 30_000; const DAY_MS = 24 * 60 * 60 * 1000; const RECENT_EXTERNAL_SESSION_LIMIT = 2; +/** A catalog pass slower than this is logged at info, since it delays every session-list refresh. */ +const SLOW_LIST_SESSIONS_THRESHOLD_MS = 1_000; type AgentHostLegacyMigrationEvent = { provider: string; @@ -601,6 +603,7 @@ export class AgentService extends Disposable implements IAgentService { if (nextMode !== externalSessionsMode) { const previousMode = externalSessionsMode; externalSessionsMode = nextMode; + this._logService.info(`[AgentService] ${AgentHostShowExternalSessionsConfigKey} changed '${previousMode}' -> '${nextMode}'; queueing session list reconciliation`); this._queueSessionListReconciliation(previousMode); } // Agent Merge tools are only advertised while the feature is on, so a @@ -1623,6 +1626,7 @@ export class AgentService extends Disposable implements IAgentService { private async _computeSessions(mode: AgentHostExternalSessionsMode): Promise { this._logService.trace('[AgentService] listSessions computation started'); + const startedAt = Date.now(); // The first list waits for registration-time legacy migration if it is still in flight. await this._awaitInitialProviderMigration(); // The registry is the source of truth for top-level sessions. Internal @@ -1833,7 +1837,14 @@ export class AgentService extends Disposable implements IAgentService { } this._logHiddenSessions(hiddenByExternalMode, combined.length, mode); - this._logService.trace(`[AgentService] listSessions returned ${visible.length} sessions (${additions.length} state-manager fallback)`); + // A catalog pass opens every registered session's database, so it can be slow. + const duration = Date.now() - startedAt; + const message = `[AgentService] listSessions computed ${visible.length} of ${combined.length} session(s) for mode '${mode}' in ${duration}ms (${additions.length} state-manager fallback)`; + if (duration >= SLOW_LIST_SESSIONS_THRESHOLD_MS) { + this._logService.info(message); + } else { + this._logService.trace(message); + } return visible; } @@ -1976,16 +1987,13 @@ export class AgentService extends Disposable implements IAgentService { } private async _reconcileExternalSessions(previousMode?: AgentHostExternalSessionsMode): Promise { + const startedAt = Date.now(); const previouslyBroadcast = new Set(this._broadcastExternalSessions); - if (previousMode !== undefined) { - for (const session of await this.listSessions(previousMode)) { - if (readSessionExternal(session._meta)) { - previouslyBroadcast.add(session.session.toString()); - } - } - } - const listed = await this.listSessions(); + const listed = previousMode !== undefined + ? this._resolveModeChangeVisibility(await this.listSessions(AgentHostExternalSessionsMode.All), previousMode, previouslyBroadcast) + : await this.listSessions(); const visible = new Set(); + let published = 0; for (const metadata of listed) { if (!readSessionExternal(metadata._meta)) { continue; @@ -1993,6 +2001,7 @@ export class AgentService extends Disposable implements IAgentService { const key = metadata.session.toString(); visible.add(key); if (!previouslyBroadcast.has(key)) { + published++; if (this._stateManager.getSessionState(key)) { this._stateManager.setSessionSummaryPublished(key, true); } else { @@ -2003,8 +2012,10 @@ export class AgentService extends Disposable implements IAgentService { } } } + let retracted = 0; for (const key of previouslyBroadcast) { if (!visible.has(key)) { + retracted++; if (this._stateManager.getSessionState(key)) { this._stateManager.setSessionSummaryPublished(key, false); } else { @@ -2017,6 +2028,45 @@ export class AgentService extends Disposable implements IAgentService { for (const key of visible) { this._broadcastExternalSessions.add(key); } + const duration = Date.now() - startedAt; + const message = `[AgentService] External session reconciliation done in ${duration}ms (mode: '${this._getExternalSessionsMode()}'${previousMode !== undefined ? `, previous: '${previousMode}'` : ''}): ${published} published, ${retracted} retracted, ${visible.size} visible`; + // A prompt no-op pass is steady-state noise. + if (published > 0 || retracted > 0 || duration >= SLOW_LIST_SESSIONS_THRESHOLD_MS) { + this._logService.info(message); + } else { + this._logService.trace(message); + } + } + + /** + * Derives both the previous and current mode's visible sets from one catalog + * pass, since {@link AgentHostExternalSessionsMode.All} is a superset of every + * mode and the mode is just a parameter to {@link _shouldIncludeSession}. + * Adds what `previousMode` had published into `previouslyBroadcast`. + */ + private _resolveModeChangeVisibility( + superset: readonly IAgentSessionMetadata[], + previousMode: AgentHostExternalSessionsMode, + previouslyBroadcast: Set, + ): IAgentSessionMetadata[] { + const now = this._now(); + const recentKeysFor = (mode: AgentHostExternalSessionsMode) => mode === AgentHostExternalSessionsMode.Recent + ? this._getRecentSessionKeys(superset, now) + : undefined; + + const previousRecentKeys = recentKeysFor(previousMode); + for (const session of superset) { + if (readSessionExternal(session._meta) && this._shouldIncludeSession(session, previousMode, now, previousRecentKeys)) { + previouslyBroadcast.add(session.session.toString()); + } + } + + const mode = this._getExternalSessionsMode(); + const recentKeys = recentKeysFor(mode); + const visible = superset.filter(session => this._shouldIncludeSession(session, mode, now, recentKeys)); + // The pass ran as `All`, so report the mode actually in effect instead. + this._logHiddenSessions(superset.length - visible.length, superset.length, mode); + return visible; } private async _announceSurfacedSession(meta: IAgentSessionMetadata, provider: string): Promise { diff --git a/src/vs/platform/agentHost/test/common/agentHostConfigurationSync.test.ts b/src/vs/platform/agentHost/test/common/agentHostConfigurationSync.test.ts index 0a72be33801e46..cef88f67fda5ce 100644 --- a/src/vs/platform/agentHost/test/common/agentHostConfigurationSync.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostConfigurationSync.test.ts @@ -8,12 +8,14 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { IConfigurationService, IConfigurationValue } from '../../../configuration/common/configuration.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../configuration/common/configurationRegistry.js'; import { Registry } from '../../../registry/common/platform.js'; -import { getAgentHostConfigurationSyncEntries, getGlobalConfigurationValue, inspectValue, resolveAgentHostConfigurationSyncPatch } from '../../common/agentHostConfigurationSync.js'; +import { formatAgentHostConfigurationSyncValueForLog, getAgentHostConfigurationSyncEntries, getGlobalConfigurationValue, inspectValue, resolveAgentHostConfigurationSyncPatch } from '../../common/agentHostConfigurationSync.js'; const ALL_HOSTS_SETTING = 'test.agentHostSync.allHosts'; const LOCAL_ONLY_SETTING = 'test.agentHostSync.localOnly'; const HIDDEN_SETTING = 'test.agentHostSync.hidden'; const UNSYNCED_SETTING = 'test.agentHostSync.unsynced'; +const ENUM_SETTING = 'test.agentHostSync.enum'; +const FREEFORM_SETTING = 'test.agentHostSync.freeform'; /** * Stands in for `IConfigurationService` with per-layer control over `inspect`, @@ -58,6 +60,17 @@ suite('AgentHostConfigurationSync', () => { type: 'boolean' as const, default: true, }, + [ENUM_SETTING]: { + type: 'string' as const, + enum: ['none', 'all'], + default: 'none', + agentHost: { key: 'enumValue' }, + }, + [FREEFORM_SETTING]: { + type: 'string' as const, + default: '', + agentHost: { key: 'freeformValue' }, + }, }, }; @@ -116,6 +129,17 @@ suite('AgentHostConfigurationSync', () => { ]); }); + test('formats closed-set values for logging and redacts everything else', () => { + assert.deepStrictEqual([ + formatAgentHostConfigurationSyncValueForLog(ALL_HOSTS_SETTING, true), + formatAgentHostConfigurationSyncValueForLog(ENUM_SETTING, 'all'), + formatAgentHostConfigurationSyncValueForLog(ENUM_SETTING, 'c:\\Users\\someone\\secret'), + formatAgentHostConfigurationSyncValueForLog(FREEFORM_SETTING, 'c:\\Users\\someone\\secret'), + formatAgentHostConfigurationSyncValueForLog(UNSYNCED_SETTING, { '**/secret/**': true }), + formatAgentHostConfigurationSyncValueForLog(UNSYNCED_SETTING, ['c:\\Users\\someone']), + ], ['true', 'all', '', '', '', '']); + }); + test('builds a patch applying transforms, including for hidden settings', () => { const configurationService = createConfigurationService({ [ALL_HOSTS_SETTING]: { defaultValue: true }, diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 652a32ea2a8867..996217099ca470 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -2944,6 +2944,46 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('a mode change reconciles with a single catalog pass', async () => { + const day = 24 * 60 * 60 * 1000; + const now = Date.now(); + const svc = createExternalSessionService(() => now); + const agent = disposables.add(new TimedExternalAgent('copilot')); + agent.addSession('recent', now); + agent.addSession('yesterday', now - day); + agent.addSession('last-week', now - 6 * day); + svc.registerProvider(agent); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.All, 1); + await waitForSessionListReconciliation(svc); + + // Each `listSessions` is one walk over every registered session's + // database, so the modes it is asked for are the catalog passes. + const listedModes: (AgentHostExternalSessionsMode | undefined)[] = []; + const listSessions = svc.listSessions; + svc.listSessions = mode => { + listedModes.push(mode); + return Reflect.apply(listSessions, svc, [mode]); + }; + + // `Recent` is the mode whose visibility depends on the whole catalog, + // so it is the one most likely to regress into a second pass. + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Recent, 2); + // Await the transition's own reconciliation: publishing into `Recent` + // moves summaries, which queues a further pass of its own. + await (svc as unknown as { _sessionListReconciliation: Promise })._sessionListReconciliation; + const transitionModes = [...listedModes]; + await waitForSessionListReconciliation(svc); + svc.listSessions = listSessions; + + assert.deepStrictEqual({ + transitionModes, + visible: (await svc.listSessions()).map(session => AgentSession.id(session.session)).sort(), + }, { + transitionModes: [AgentHostExternalSessionsMode.All], + visible: ['recent', 'yesterday'], + }); + }); + test('recent replaces the oldest visible external session when a newer session is discovered', async () => { const now = Date.now(); const svc = createExternalSessionService(() => now); From 7233723102cc77bcc915c78d031b833d7ca8b718 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 18 Aug 2026 19:21:29 +0200 Subject: [PATCH 02/24] sessions: support deep session and chat links (#331492) * sessions: support deep session and chat links Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: address deep link review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: harden deep link presentations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/preview/markdownEditorProvider.ts | 2 +- .../src/preview/markdownEditorRichLinks.ts | 15 +- .../agentHost/common/openSessionLink.ts | 15 +- .../test/common/openSessionLink.test.ts | 19 +- .../dataChannel/common/dataChannel.ts | 2 + .../openSessionLinkOpener.contribution.ts | 6 +- .../browser/openSessionLinkOpener.test.ts | 162 ++++++++++++++++-- .../agentHost/stateToProgressAdapter.ts | 3 +- .../chatMarkdownDecorationsRenderer.ts | 3 +- .../widget/chatContentParts/chatRichLink.ts | 5 +- .../chatContentParts/media/chatRichLink.css | 150 ++++++++++------ .../stateToProgressAdapter.test.ts | 4 + .../chatMarkdownContentPart.test.ts | 33 +++- .../chatContentParts/chatRichLink.test.ts | 80 +++++++++ .../dataChannel/browser/dataChannelService.ts | 15 +- .../test/browser/dataChannelService.test.ts | 17 ++ .../vscode.proposed.linkPresentation.d.ts | 1 + 17 files changed, 448 insertions(+), 84 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatRichLink.test.ts create mode 100644 src/vs/workbench/services/dataChannel/test/browser/dataChannelService.test.ts diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts index 6f7672cb5d0d88..b3ae2f31be9fcd 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts @@ -766,7 +766,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT id: rule.id, source: rule.uriPattern.source, flags: rule.uriPattern.flags, - initialKind: rule.initialKind, + initialKind: rule.initialKind === 'chat' ? 'session' : rule.initialKind, })), }); diff --git a/extensions/markdown-language-features/src/preview/markdownEditorRichLinks.ts b/extensions/markdown-language-features/src/preview/markdownEditorRichLinks.ts index 44569bfbd00e80..9e09616d74b59e 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorRichLinks.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorRichLinks.ts @@ -106,15 +106,26 @@ class ApiLinkPresentationEntry extends Disposable { } const watcher = this._register(vscode.window.createLinkPresentationWatcher(rule.id, resource)); - publishPresentation(watcher.presentation); - this._register(watcher.onDidChangePresentation(() => publishPresentation(watcher.presentation))); + publishPresentation(toMarkdownEditorPresentation(watcher.presentation)); + this._register(watcher.onDidChangePresentation(() => publishPresentation(toMarkdownEditorPresentation(watcher.presentation)))); } catch (error) { logger.trace('Markdown rich link', `Failed to resolve ${href}`, error); if (!this.isDisposed) { publishPresentation(undefined); } } + + } +} + +function toMarkdownEditorPresentation(presentation: vscode.LinkPresentationData | undefined): LinkPresentation | undefined { + if (!presentation) { + return undefined; } + return { + ...presentation, + kind: presentation.kind === 'chat' ? 'session' : presentation.kind, + }; } async function resolveLinkResource(href: string, documentUri: vscode.Uri, linkOpener: MdLinkOpener): Promise { diff --git a/src/vs/platform/agentHost/common/openSessionLink.ts b/src/vs/platform/agentHost/common/openSessionLink.ts index e82e5c9ce37abe..f806de687113c0 100644 --- a/src/vs/platform/agentHost/common/openSessionLink.ts +++ b/src/vs/platform/agentHost/common/openSessionLink.ts @@ -25,15 +25,17 @@ export const AGENT_HOST_SESSION_LINK_PATTERN = /^agent-host-session:\/\/[^/?#]+\ export type AgentSessionLinkStatus = 'untitled' | 'inProgress' | 'needsInput' | 'completed' | 'error'; -export function createAgentSessionLinkPresentation(title: string, description: string | undefined, status: AgentSessionLinkStatus): ILinkPresentation { +export function createAgentSessionLinkPresentation(title: string, description: string | undefined, status: AgentSessionLinkStatus, kind: 'session' | 'chat' = 'session'): ILinkPresentation { const presentationStatus = getAgentSessionLinkPresentationStatus(status); return { - kind: 'session', + kind, title, ...(description ? { detail: description } : {}), status: presentationStatus, tooltip: localize('agentSessionLink.tooltip', "{0} · {1}", title, presentationStatus.label), - ariaLabel: localize('agentSessionLink.ariaLabel', "Agent session {0}, {1}", title, presentationStatus.label), + ariaLabel: kind === 'chat' + ? localize('agentChatLink.ariaLabel', "Agent chat {0}, {1}", title, presentationStatus.label) + : localize('agentSessionLink.ariaLabel', "Agent session {0}, {1}", title, presentationStatus.label), }; } @@ -124,7 +126,12 @@ export function parseOpenSessionLinkChatId(uri: URI | string): string | undefine return undefined; } const match = /(?:^|&)chat=([^&]+)/.exec(parsed.query); - const chatId = match ? decodeURIComponent(match[1]) : undefined; + let chatId: string | undefined; + try { + chatId = match ? decodeURIComponent(match[1]) : undefined; + } catch { + return undefined; + } return chatId === DEFAULT_CHAT_ID ? undefined : chatId; } diff --git a/src/vs/platform/agentHost/test/common/openSessionLink.test.ts b/src/vs/platform/agentHost/test/common/openSessionLink.test.ts index c944199643c42c..430b75d80de2d5 100644 --- a/src/vs/platform/agentHost/test/common/openSessionLink.test.ts +++ b/src/vs/platform/agentHost/test/common/openSessionLink.test.ts @@ -56,6 +56,7 @@ suite('openSessionLink', () => { test('parseOpenSessionLinkChatId treats chat=default as absent', () => { assert.strictEqual(parseOpenSessionLinkChatId('agent-host-session://copilotcli/abc-123?chat=default'), undefined); assert.strictEqual(parseOpenSessionLinkChatId('agent-host-session://copilotcli/abc-123?chat=peer1'), 'peer1'); + assert.strictEqual(parseOpenSessionLinkChatId('agent-host-session://copilotcli/abc-123?chat=%ZZ'), undefined); }); test('buildOpenSessionLinkForChatResource maps chat resources to session links', () => { @@ -78,9 +79,11 @@ suite('openSessionLink', () => { }); test('creates generic link presentations for agent sessions', () => { - assert.deepStrictEqual( - createAgentSessionLinkPresentation('Implement rich links', 'Updating core', 'needsInput'), - { + assert.deepStrictEqual({ + session: createAgentSessionLinkPresentation('Implement rich links', 'Updating core', 'needsInput'), + chat: createAgentSessionLinkPresentation('Investigate tests', 'Updating core', 'completed', 'chat'), + }, { + session: { kind: 'session', title: 'Implement rich links', detail: 'Updating core', @@ -88,6 +91,14 @@ suite('openSessionLink', () => { tooltip: 'Implement rich links · Needs input', ariaLabel: 'Agent session Implement rich links, Needs input', }, - ); + chat: { + kind: 'chat', + title: 'Investigate tests', + detail: 'Updating core', + status: { kind: 'success', label: 'Completed' }, + tooltip: 'Investigate tests · Completed', + ariaLabel: 'Agent chat Investigate tests, Completed', + }, + }); }); }); diff --git a/src/vs/platform/dataChannel/common/dataChannel.ts b/src/vs/platform/dataChannel/common/dataChannel.ts index 64fc69d03c853c..624ce7ef81b0db 100644 --- a/src/vs/platform/dataChannel/common/dataChannel.ts +++ b/src/vs/platform/dataChannel/common/dataChannel.ts @@ -38,6 +38,7 @@ export type LinkPresentationKind = | 'file' | 'folder' | 'session' + | 'chat' | 'repository' | 'branch'; @@ -123,6 +124,7 @@ function isLinkPresentationKind(value: unknown): value is LinkPresentationKind { || value === 'file' || value === 'folder' || value === 'session' + || value === 'chat' || value === 'repository' || value === 'branch'; } diff --git a/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts b/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts index 6b847a3670adcd..120369b98d91b8 100644 --- a/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts @@ -8,6 +8,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { derivedOpts, IObservable, IReader, observableSignalFromEvent } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; import { IAgentHostConnectionsService } from '../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { AGENT_HOST_SESSION_LINK_PATTERN, AgentSessionLinkStatus, createAgentSessionLinkPresentation, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../../../platform/agentHost/common/openSessionLink.js'; import { ILinkPresentation, ILinkPresentationService, ILinkPresentationWatcher } from '../../../../platform/dataChannel/common/dataChannel.js'; @@ -62,7 +63,6 @@ export class OpenSessionLinkOpenerContribution extends Disposable implements IWo } const chatId = parseOpenSessionLinkChatId(resource); if (chatId) { - // Peer chats carry their chatId in the session resource's fragment. await this._sessionsService.openChat(session, session.resource.with({ fragment: chatId })); return true; } @@ -102,11 +102,13 @@ export function readSessionState( reader: IReader, ): ILinkPresentation { const chat = findChat(session, chatId, reader); + const sessionTitle = session.title.read(reader); const description = session.description.read(reader)?.value; return createAgentSessionLinkPresentation( - chat?.title.read(reader) ?? session.title.read(reader), + chat?.title.read(reader) ?? (chatId ? localize('agentChatLink.unresolvedTitle', "Chat · {0}", sessionTitle) : sessionTitle), description, sessionStatusName(chat?.status.read(reader) ?? session.status.read(reader)), + chatId ? 'chat' : 'session', ); } diff --git a/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts b/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts index 6a156ac7587460..e39597b2a52eb7 100644 --- a/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts @@ -4,15 +4,157 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; +import { Event } from '../../../../../base/common/event.js'; +import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { SessionStatus } from '../../../../services/sessions/common/session.js'; -import { ISessionLinkChatState, ISessionLinkState, readSessionState } from '../../browser/openSessionLinkOpener.contribution.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { buildOpenSessionLinkUri } from '../../../../../platform/agentHost/common/openSessionLink.js'; +import { ILinkPresentationProvider, ILinkPresentationService } from '../../../../../platform/dataChannel/common/dataChannel.js'; +import { IOpener, IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionLinkChatState, ISessionLinkState, OpenSessionLinkOpenerContribution, readSessionState } from '../../browser/openSessionLinkOpener.contribution.js'; suite('OpenSessionLinkOpenerContribution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + test('opens deep session and chat links in the Agents window', async () => { + let registeredOpener: IOpener | undefined; + const openerService = new class extends mock() { + override registerOpener(opener: IOpener): IDisposable { + registeredOpener = opener; + return Disposable.None; + } + }; + const sessionResource = URI.parse('copilotcli:/session-1'); + const session = upcastPartial({ resource: sessionResource }); + const sessionsManagementService = new class extends mock() { + override getSessions(): ISession[] { + return [session]; + } + }; + const opened: string[] = []; + const sessionsService = new class extends mock() { + override async openSession(resource: URI): Promise { + opened.push(`session:${resource.toString()}`); + } + + override async openChat(_session: ISession, resource: URI): Promise { + opened.push(`chat:${resource.toString()}`); + } + }; + const connectionsService = new class extends mock() { + override resolveSessionResource() { + return undefined; + } + }; + const linkPresentationService = new class extends mock() { + override registerLinkPresentationProvider(): IDisposable { + return Disposable.None; + } + }; + store.add(new OpenSessionLinkOpenerContribution( + openerService, + sessionsManagementService, + sessionsService, + connectionsService, + linkPresentationService, + )); + + if (!registeredOpener) { + throw new Error('Expected the contribution to register an opener'); + } + + assert.deepStrictEqual({ + results: [ + await registeredOpener.open(buildOpenSessionLinkUri(sessionResource)), + await registeredOpener.open(buildOpenSessionLinkUri(sessionResource, 'chat-2')), + ], + opened, + }, { + results: [true, true], + opened: [ + 'session:copilotcli:/session-1', + 'chat:copilotcli:/session-1#chat-2', + ], + }); + }); + + test('uses a contextual placeholder without opening the linked chat', () => { + const sessionResource = URI.parse('copilotcli:/session-1'); + const chatResource = sessionResource.with({ fragment: 'chat-2' }); + const chat = upcastPartial({ + resource: chatResource, + title: observableValue('chatTitle', 'Resolved chat'), + status: observableValue('chatStatus', SessionStatus.Completed), + }); + const chats = observableValue('chats', []); + const session = upcastPartial({ + resource: sessionResource, + title: observableValue('sessionTitle', 'Parent session'), + description: observableValue('sessionDescription', undefined), + status: observableValue('sessionStatus', SessionStatus.Completed), + chats, + }); + const sessionsManagementService = new class extends mock() { + override readonly onDidChangeSessions = Event.None; + + override getSessions(): ISession[] { + return [session]; + } + }; + let presentationProvider: ILinkPresentationProvider | undefined; + const linkPresentationService = new class extends mock() { + override registerLinkPresentationProvider(_registration: Parameters[0], provider: ILinkPresentationProvider): IDisposable { + presentationProvider = provider; + return Disposable.None; + } + }; + store.add(new OpenSessionLinkOpenerContribution( + new class extends mock() { + override registerOpener(): IDisposable { return Disposable.None; } + }, + sessionsManagementService, + new class extends mock() { }, + new class extends mock() { + override resolveSessionResource() { return undefined; } + }, + linkPresentationService, + )); + + const watcher = presentationProvider?.createLinkPresentationWatcher(URI.parse(buildOpenSessionLinkUri(sessionResource, 'chat-2'))); + if (!watcher) { + throw new Error('Expected the contribution to register a link presentation provider'); + } + store.add(watcher); + + const placeholder = watcher.presentation.get(); + chats.set([chat], undefined); + assert.deepStrictEqual({ + placeholder, + resolved: watcher.presentation.get(), + }, { + placeholder: { + kind: 'chat', + title: 'Chat · Parent session', + status: { kind: 'success', label: 'Completed' }, + tooltip: 'Chat · Parent session · Completed', + ariaLabel: 'Agent chat Chat · Parent session, Completed', + }, + resolved: { + kind: 'chat', + title: 'Resolved chat', + status: { kind: 'success', label: 'Completed' }, + tooltip: 'Resolved chat · Completed', + ariaLabel: 'Agent chat Resolved chat, Completed', + }, + }); + }); + test('reactively reads the targeted chat state', () => { const chatStatus = observableValue('chatStatus', SessionStatus.Completed); const chat: ISessionLinkChatState = { @@ -37,28 +179,28 @@ suite('OpenSessionLinkOpenerContribution', () => { assert.deepStrictEqual(values, [ { - kind: 'session', - title: 'Parent session', + kind: 'chat', + title: 'Chat · Parent session', detail: 'Session details', status: { kind: 'pending', label: 'Working' }, - tooltip: 'Parent session · Working', - ariaLabel: 'Agent session Parent session, Working', + tooltip: 'Chat · Parent session · Working', + ariaLabel: 'Agent chat Chat · Parent session, Working', }, { - kind: 'session', + kind: 'chat', title: 'Peer chat', detail: 'Session details', status: { kind: 'success', label: 'Completed' }, tooltip: 'Peer chat · Completed', - ariaLabel: 'Agent session Peer chat, Completed', + ariaLabel: 'Agent chat Peer chat, Completed', }, { - kind: 'session', + kind: 'chat', title: 'Peer chat', detail: 'Session details', status: { kind: 'warning', label: 'Needs input' }, tooltip: 'Peer chat · Needs input', - ariaLabel: 'Agent session Peer chat, Needs input', + ariaLabel: 'Agent chat Peer chat, Needs input', }, ]); }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 9d7f6ec3d15e4d..172b242e22cb6d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -27,7 +27,7 @@ import { getBrowserViewAttachmentMetadata, isBrowserViewAttachment } from '../.. import { readAgentMessageDelegationMeta } from '../../../../../../platform/agentHost/common/meta/agentMessageDelegationMeta.js'; import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, readAgentSystemNotificationMeta } from '../../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js'; import { isViewUnreviewedCommentsTool, isAddCommentTool } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; -import { isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../../../../../platform/agentHost/common/openSessionLink.js'; +import { AGENT_HOST_SESSION_LINK_SCHEME, isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../../../../../platform/agentHost/common/openSessionLink.js'; import { parsePartialToolInputForDisplay } from '../../../../../../platform/agentHost/common/partialToolInput.js'; import { MessageAttachmentKind, type FileEdit, type MessageAttachment, type StringOrMarkdown, type TextRange } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { normalizeFileEdit } from '../../../../../../platform/agentHost/common/fileEditDiff.js'; @@ -1888,6 +1888,7 @@ const EXTERNAL_LINK_SCHEMES: ReadonlySet = new Set([ 'copilot-skill', product.urlProtocol, AGENT_HOST_SCHEME, + AGENT_HOST_SESSION_LINK_SCHEME, ]); /** diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts index 72dd05506aa108..a60b2eb843948e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts @@ -13,6 +13,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ILinkPresentationService } from '../../../../../../platform/dataChannel/common/dataChannel.js'; +import { AGENT_HOST_SESSION_LINK_SCHEME } from '../../../../../../platform/agentHost/common/openSessionLink.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; @@ -172,7 +173,7 @@ export class ChatMarkdownDecorationsRenderer extends Disposable { this.renderFileWidget(content, href, a, store); } else if (href.startsWith('command:')) { this.injectKeybindingHint(a, href, this.keybindingService); - } else if (richLinksEnabled) { + } else if (richLinksEnabled || href.toLowerCase().startsWith(`${AGENT_HOST_SESSION_LINK_SCHEME}:`)) { this.richLinkDecorator.value.decorate(a, href, store); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatRichLink.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatRichLink.ts index 33668ac3fc5428..c8b845c5484ac4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatRichLink.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatRichLink.ts @@ -169,7 +169,8 @@ const richLinkIcons: Readonly> = { commit: 'git-commit', file: 'file', folder: 'folder', - session: 'comment-discussion', + session: 'agent', + chat: 'comment-discussion', repository: 'repo', branch: 'git-branch', }; @@ -276,8 +277,6 @@ function hasLeadingLifecycleStatus(presentation: IChatLinkPresentation): boolean return statusKind === 'open' || statusKind === 'closed' || statusKind === 'notPlanned'; case 'pullRequest': return statusKind === 'open' || statusKind === 'closed' || statusKind === 'merged' || statusKind === 'draft'; - case 'session': - return statusKind !== undefined; default: return false; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatRichLink.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatRichLink.css index f5a8a39b516564..da77f9dd6e92f1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatRichLink.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatRichLink.css @@ -314,81 +314,135 @@ line-height: inherit; } -.chat-rich-link[data-chat-rich-link-kind='session'] { +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +) { align-items: center; gap: var(--vscode-spacing-size40); - padding: 1px var(--vscode-spacing-size60); - border-color: var(--vscode-chat-requestBorder, var(--vscode-input-border)); + max-width: 100%; + padding: var(--vscode-spacing-size20) var(--vscode-spacing-size100); + border-color: var(--vscode-button-secondaryBorder, var(--vscode-button-border, transparent)); background: var(--vscode-button-secondaryBackground); - color: var(--vscode-descriptionForeground); + color: var(--vscode-button-secondaryForeground); + font-size: var(--vscode-fontSize-label1); font-weight: var(--vscode-fontWeight-regular); + line-height: 16px; } -.chat-rich-link[data-chat-rich-link-kind='session']:hover { - border-color: var(--vscode-chat-requestBorder, var(--vscode-input-border)); - background: var(--vscode-toolbar-hoverBackground); +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +):visited { + color: var(--vscode-button-secondaryForeground); } -.chat-rich-link[data-chat-rich-link-kind='session'] :is(.chat-rich-link-label, .chat-rich-link-title) { - color: inherit; - font-weight: var(--vscode-fontWeight-regular); +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +):is(:hover, :active) { + border-color: var(--vscode-button-secondaryBorder, var(--vscode-button-border, transparent)); + background: var(--vscode-button-secondaryHoverBackground); + color: var(--vscode-button-secondaryForeground); } -.chat-rich-link[data-chat-rich-link-kind='session'] .chat-rich-link-primary-status { - align-self: center; +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +) .chat-rich-link-icon { + display: inline-flex; + flex: 0 0 var(--vscode-codiconFontSize); align-items: center; - width: var(--vscode-spacing-size120); - height: var(--vscode-spacing-size120); - margin: 0; - padding: 0; - border: 0; - border-radius: 0; - background: transparent; - font-size: 1em; - line-height: inherit; + justify-content: center; + width: var(--vscode-codiconFontSize); + height: var(--vscode-codiconFontSize); + color: inherit; + font-size: var(--vscode-codiconFontSize); + line-height: var(--vscode-codiconFontSize); } -.chat-rich-link[data-chat-rich-link-kind='session'] .chat-rich-link-primary-status .chat-rich-link-status-label { - display: none; +.interactive-item-container .value .rendered-markdown .chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +) .chat-rich-link-icon { + position: static; + top: auto; } -.chat-rich-link[data-chat-rich-link-kind='session'] .chat-rich-link-status-icon:not(.monaco-pixel-spinner, [hidden]) { - display: inline-flex; - align-items: center; - justify-content: center; - width: var(--vscode-spacing-size120); - height: var(--vscode-spacing-size120); - font-size: var(--vscode-codiconFontSize-compact); - line-height: var(--vscode-spacing-size120); +.interactive-item-container .value .rendered-markdown a.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +) { + color: var(--vscode-button-secondaryForeground); } -.chat-rich-link .chat-rich-link-status-icon[hidden] { - display: none; +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +) :is(.chat-rich-link-label, .chat-rich-link-title) { + flex: 1 1 auto; + min-width: 0; + max-width: 100%; + overflow: hidden; + color: inherit; + font-weight: inherit; + text-overflow: ellipsis; + white-space: nowrap; } -.chat-rich-link[data-chat-rich-link-kind='session'][data-chat-rich-link-status='neutral'] .chat-rich-link-primary-status, -.chat-rich-link[data-chat-rich-link-kind='session'][data-chat-rich-link-status='success'] .chat-rich-link-primary-status, -.chat-rich-link[data-chat-rich-link-kind='session'][data-chat-rich-link-status='pending'] .chat-rich-link-primary-status { - color: var(--vscode-descriptionForeground); +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +) :is( + .chat-rich-link-detail, + .chat-rich-link-reference, + .chat-rich-link-changes, + .chat-rich-link-secondary-status +) { + display: none; } -.chat-rich-link[data-chat-rich-link-kind='session'] .monaco-pixel-spinner { - width: var(--vscode-spacing-size120); - height: var(--vscode-spacing-size120); +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +) .chat-rich-link-primary-status { + display: none; + align-items: center; + height: var(--vscode-codiconFontSize-compact); + margin-left: 0; + font-size: var(--vscode-codiconFontSize-compact); + line-height: var(--vscode-codiconFontSize-compact); } -.chat-rich-link[data-chat-rich-link-kind='session'][data-chat-rich-link-status='warning'] .chat-rich-link-primary-status, -.chat-rich-link[data-chat-rich-link-kind='session'][data-chat-rich-link-status='warning'] { - color: var(--vscode-list-warningForeground); +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +):is( + [data-chat-rich-link-status='pending'], + [data-chat-rich-link-status='warning'], + [data-chat-rich-link-status='error'] +) .chat-rich-link-primary-status { + display: inline-flex; } -.chat-rich-link[data-chat-rich-link-kind='session'][data-chat-rich-link-status='warning'] { - border-color: var(--vscode-list-warningForeground); - background-color: color-mix(in srgb, var(--vscode-list-warningForeground) 12%, transparent); +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +) .chat-rich-link-primary-status .chat-rich-link-status-label { + display: none; } -.chat-rich-link[data-chat-rich-link-kind='session'][data-chat-rich-link-status='error'] .chat-rich-link-primary-status { - color: var(--vscode-errorForeground); +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +) .chat-rich-link-primary-status :is( + .chat-rich-link-status-icon, + .monaco-pixel-spinner +) { + width: var(--vscode-codiconFontSize-compact); + height: var(--vscode-codiconFontSize-compact); + font-size: var(--vscode-codiconFontSize-compact); + line-height: var(--vscode-codiconFontSize-compact); } .hc-black .chat-rich-link, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 2d9f98dd47adf0..db3724f8c51494 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -262,6 +262,8 @@ suite('stateToProgressAdapter', () => { rewriteAgentHostLinkTarget('C:relative', 'my-host'), rewriteAgentHostLinkTarget('git:foo', 'my-host'), rewriteAgentHostLinkTarget('urn:isbn:123', 'my-host'), + rewriteAgentHostLinkTarget('agent-host-session://copilotcli/session-1', 'my-host'), + rewriteAgentHostLinkTarget('agent-host-session://copilotcli/session-1?chat=chat-2', 'my-host'), ], [ 'vscode-browser://example.com', @@ -269,6 +271,8 @@ suite('stateToProgressAdapter', () => { 'C:relative', 'git:foo', 'urn:isbn:123', + 'agent-host-session://copilotcli/session-1', + 'agent-host-session://copilotcli/session-1?chat=chat-2', ], ); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts index 088c73fa107a0c..877a598d6f163c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts @@ -144,6 +144,15 @@ suite('ChatMarkdownContentPart', () => { instantiationService = workbenchInstantiationService(undefined, disposables); chatSessionsService = new MockChatSessionsService(); instantiationService.stub(IChatSessionsService, chatSessionsService); + instantiationService.stub(ILinkPresentationService, { + _serviceBrand: undefined, + onDidChangeLinkPresentationRules: Event.None, + linkPresentationRules: [], + registerLinkPresentationProvider: () => ({ dispose: () => { } }), + registerExtensionLinkPresentationProvider: () => ({ dispose: () => { } }), + getLinkPresentationRule: () => undefined, + createLinkPresentationWatcher: () => undefined, + }); renderedCodeBlocks.length = 0; renderedCodeBlockOutputs.length = 0; outputStateCache = new Map(); @@ -235,7 +244,7 @@ suite('ChatMarkdownContentPart', () => { resolveChatResponseUri: (_resource, href) => rewriteAgentHostLinkTarget(href, 'my-host'), })); - const part = createMarkdownPart('`[foo.ts](/code.ts)` [a[b].ts](/remote/a.ts "/remote/a.ts"), [a\\*b.ts](/remote/b.ts), [line.ts](/remote/line.ts:42), [column.ts](/remote/column.ts:42:7), [windows.ts](C:/remote/windows.ts:42), [unc.ts](//server/share/unc.ts:42), [skill](/remote/skill/SKILL.md), and [file-uri.ts](file:///remote/file-uri.ts:42). ![image](/remote/image.png)'); + const part = createMarkdownPart('`[foo.ts](/code.ts)` [a[b].ts](/remote/a.ts "/remote/a.ts"), [a\\*b.ts](/remote/b.ts), [line.ts](/remote/line.ts:42), [column.ts](/remote/column.ts:42:7), [windows.ts](C:/remote/windows.ts:42), [unc.ts](//server/share/unc.ts:42), [skill](/remote/skill/SKILL.md), [file-uri.ts](file:///remote/file-uri.ts:42), [session](agent-host-session://copilotcli/session-1), and [chat](agent-host-session://copilotcli/session-1?chat=chat-2). ![image](/remote/image.png)'); const links = Array.from(part.domNode.querySelectorAll('a')); const skillUri = toAgentHostUri(URI.file('/remote/skill/SKILL.md'), 'my-host'); assert.deepStrictEqual( @@ -253,6 +262,8 @@ suite('ChatMarkdownContentPart', () => { { text: 'unc.ts', href: toAgentHostUri(URI.file('//server/share/unc.ts').with({ fragment: 'L42' }), 'my-host').toString() }, { text: 'skill', href: skillUri.with({ query: `${skillUri.query}&vscodeLinkType=skill` }).toString() }, { text: 'file-uri.ts', href: toAgentHostUri(URI.file('/remote/file-uri.ts').with({ fragment: 'L42' }), 'my-host').toString() }, + { text: 'session', href: 'agent-host-session://copilotcli/session-1' }, + { text: 'chat', href: 'agent-host-session://copilotcli/session-1?chat=chat-2' }, ], imageSource: null, }, @@ -268,12 +279,17 @@ suite('ChatMarkdownContentPart', () => { assert.ok(part.domNode.textContent?.includes('Hello, world!')); }); - test('gates rich link rendering behind the chat setting', () => { - const rule = { + test('always renders Agent Host session links as rich links', () => { + const pullRequestRule = { id: 'test.linkPresentation', uriPattern: /^https:\/\/github\.com\/microsoft\/vscode\/pull\/1$/, initialKind: 'pullRequest' as const, }; + const sessionRule = { + id: 'test.agentSessionLinkPresentation', + uriPattern: /^agent-host-session:\/\/copilotcli\/session-1(?:\?chat=chat-2)?$/, + initialKind: 'session' as const, + }; const presentation = observableValue('test.linkPresentation', { kind: 'pullRequest', title: 'Test pull request', @@ -283,12 +299,12 @@ suite('ChatMarkdownContentPart', () => { instantiationService.stub(ILinkPresentationService, { _serviceBrand: undefined, onDidChangeLinkPresentationRules: Event.None, - linkPresentationRules: [rule], + linkPresentationRules: [pullRequestRule, sessionRule], registerLinkPresentationProvider: () => ({ dispose: () => { } }), registerExtensionLinkPresentationProvider: () => ({ dispose: () => { } }), getLinkPresentationRule: resource => { ruleChecks++; - return rule.uriPattern.test(resource.toString(true)) ? rule : undefined; + return [pullRequestRule, sessionRule].find(rule => rule.uriPattern.test(resource.toString(true))); }, createLinkPresentationWatcher: () => { watcherCreations++; @@ -299,20 +315,23 @@ suite('ChatMarkdownContentPart', () => { const configurationService = instantiationService.get(IConfigurationService) as TestConfigurationService; configurationService.setUserConfiguration(ChatConfiguration.RichLinks, false); const disabledPart = createMarkdownPart('[pull request](https://github.com/microsoft/vscode/pull/1)'); + const sessionPart = createMarkdownPart('[session](agent-host-session://copilotcli/session-1) [chat](agent-host-session://copilotcli/session-1?chat=chat-2)'); configurationService.setUserConfiguration(ChatConfiguration.RichLinks, true); const enabledPart = createMarkdownPart('[pull request](https://github.com/microsoft/vscode/pull/1)'); assert.deepStrictEqual({ disabledRichLinks: disabledPart.domNode.querySelectorAll('.chat-rich-link').length, + agentHostRichLinks: sessionPart.domNode.querySelectorAll('.chat-rich-link').length, enabledRichLinks: enabledPart.domNode.querySelectorAll('.chat-rich-link').length, ruleChecks, watcherCreations, }, { disabledRichLinks: 0, + agentHostRichLinks: 2, enabledRichLinks: 1, - ruleChecks: 1, - watcherCreations: 1, + ruleChecks: 3, + watcherCreations: 3, }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatRichLink.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatRichLink.test.ts new file mode 100644 index 00000000000000..62d93cf121fd6b --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatRichLink.test.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { mainWindow } from '../../../../../../../base/browser/window.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { ChatRichLink } from '../../../../browser/widget/chatContentParts/chatRichLink.js'; + +suite('ChatRichLink', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('renders semantic icons for session and chat pills', () => { + const sessionAnchor = mainWindow.document.createElement('a'); + const chatAnchor = mainWindow.document.createElement('a'); + const sessionLink = store.add(ChatRichLink.mount(sessionAnchor, mainWindow.document.createElement('span'))); + const chatLink = store.add(ChatRichLink.mount(chatAnchor, mainWindow.document.createElement('span'))); + + sessionLink.update({ + kind: 'session', + title: 'Session', + status: { kind: 'success', label: 'Completed' }, + }); + chatLink.update({ + kind: 'chat', + title: 'New Chat', + status: { kind: 'success', label: 'Completed' }, + }); + + assert.deepStrictEqual({ + sessionIcon: sessionAnchor.firstElementChild?.className, + chatIcon: chatAnchor.firstElementChild?.className, + }, { + sessionIcon: 'chat-rich-link-icon codicon codicon-agent', + chatIcon: 'chat-rich-link-icon codicon codicon-comment-discussion', + }); + }); + + test('renders compact non-success status indicators', () => { + const pendingAnchor = mainWindow.document.createElement('a'); + const warningAnchor = mainWindow.document.createElement('a'); + const errorAnchor = mainWindow.document.createElement('a'); + const completedAnchor = mainWindow.document.createElement('a'); + const pendingLink = store.add(ChatRichLink.mount(pendingAnchor, mainWindow.document.createElement('span'))); + const warningLink = store.add(ChatRichLink.mount(warningAnchor, mainWindow.document.createElement('span'))); + const errorLink = store.add(ChatRichLink.mount(errorAnchor, mainWindow.document.createElement('span'))); + const completedLink = store.add(ChatRichLink.mount(completedAnchor, mainWindow.document.createElement('span'))); + + pendingLink.update({ kind: 'session', title: 'Working', status: { kind: 'pending', label: 'Working' } }); + warningLink.update({ kind: 'session', title: 'Needs input', status: { kind: 'warning', label: 'Needs input' } }); + errorLink.update({ kind: 'chat', title: 'Failed chat', status: { kind: 'error', label: 'Error' } }); + completedLink.update({ kind: 'chat', title: 'Completed chat', status: { kind: 'success', label: 'Completed' } }); + + assert.deepStrictEqual({ + pending: { + status: pendingAnchor.dataset.chatRichLinkStatus, + spinner: pendingAnchor.querySelector('.chat-rich-link-primary-status .monaco-pixel-spinner') !== null, + }, + warning: { + status: warningAnchor.dataset.chatRichLinkStatus, + spinner: warningAnchor.querySelector('.chat-rich-link-primary-status .monaco-pixel-spinner') !== null, + }, + error: { + status: errorAnchor.dataset.chatRichLinkStatus, + icon: errorAnchor.querySelector('.chat-rich-link-primary-status .chat-rich-link-status-icon')?.className, + }, + completed: { + status: completedAnchor.dataset.chatRichLinkStatus, + icon: completedAnchor.querySelector('.chat-rich-link-primary-status .chat-rich-link-status-icon')?.className, + }, + }, { + pending: { status: 'pending', spinner: true }, + warning: { status: 'warning', spinner: true }, + error: { status: 'error', icon: 'chat-rich-link-status-icon codicon codicon-error' }, + completed: { status: 'success', icon: 'chat-rich-link-status-icon codicon codicon-pass-filled' }, + }); + }); +}); diff --git a/src/vs/workbench/services/dataChannel/browser/dataChannelService.ts b/src/vs/workbench/services/dataChannel/browser/dataChannelService.ts index 43ec883f1b67f9..0d12497b3eba6e 100644 --- a/src/vs/workbench/services/dataChannel/browser/dataChannelService.ts +++ b/src/vs/workbench/services/dataChannel/browser/dataChannelService.ts @@ -60,6 +60,19 @@ interface ICachedLinkPresentation { readonly presentation: ILinkPresentation; } +export const linkPresentationProviderInitialKinds: LinkPresentationKind[] = [ + 'resource', + 'issue', + 'pullRequest', + 'commit', + 'file', + 'folder', + 'session', + 'chat', + 'repository', + 'branch', +]; + const linkPresentationProviderExtensionPoint = ExtensionsRegistry.registerExtensionPoint({ extensionPoint: 'linkPresentationProviders', jsonSchema: { @@ -80,7 +93,7 @@ const linkPresentationProviderExtensionPoint = ExtensionsRegistry.registerExtens }, initialKind: { type: 'string', - enum: ['resource', 'issue', 'pullRequest', 'commit', 'file', 'folder', 'session', 'repository', 'branch'], + enum: linkPresentationProviderInitialKinds, description: localize('linkPresentationProvider.initialKind', "The initial semantic kind shown while the provider resolves its first presentation."), }, enablement: { diff --git a/src/vs/workbench/services/dataChannel/test/browser/dataChannelService.test.ts b/src/vs/workbench/services/dataChannel/test/browser/dataChannelService.test.ts new file mode 100644 index 00000000000000..ad5672937ff1d6 --- /dev/null +++ b/src/vs/workbench/services/dataChannel/test/browser/dataChannelService.test.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { linkPresentationProviderInitialKinds } from '../../browser/dataChannelService.js'; + +suite('DataChannelService', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('link presentation contribution supports chat initial kind', () => { + assert.ok(linkPresentationProviderInitialKinds.includes('chat')); + }); +}); diff --git a/src/vscode-dts/vscode.proposed.linkPresentation.d.ts b/src/vscode-dts/vscode.proposed.linkPresentation.d.ts index 5e5569881c91c5..b9f0f48932935d 100644 --- a/src/vscode-dts/vscode.proposed.linkPresentation.d.ts +++ b/src/vscode-dts/vscode.proposed.linkPresentation.d.ts @@ -16,6 +16,7 @@ declare module 'vscode' { | 'file' | 'folder' | 'session' + | 'chat' | 'repository' | 'branch'; From 813910b40fd9484597ed89ffb7f19606aa6547c4 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:43:06 +0200 Subject: [PATCH 03/24] Agents - refactor "New Session" and "New Session From" actions into a split button (#331481) * Agents - refactor "New Session" and "New Session From" actions into a split button * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Pull request feedback --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/vs/sessions/SESSIONS_LIST.md | 4 +- src/vs/sessions/browser/menus.ts | 1 + .../browser/sessionsChatAccessibilityHelp.ts | 2 +- .../createSessionFromPullRequestAction.ts | 5 +- .../sessions/browser/media/sessionsList.css | 6 ++- .../sessions/browser/views/sessionsList.ts | 50 ++++++++++++++++++- .../browser/views/sessionsViewActions.ts | 32 +++++++++--- .../test/browser/sessionsList.test.ts | 4 ++ 8 files changed, 87 insertions(+), 17 deletions(-) diff --git a/src/vs/sessions/SESSIONS_LIST.md b/src/vs/sessions/SESSIONS_LIST.md index 476ce1d2705d83..46c1e48a82fa2e 100644 --- a/src/vs/sessions/SESSIONS_LIST.md +++ b/src/vs/sessions/SESSIONS_LIST.md @@ -65,7 +65,7 @@ Each quick chat is its **own single-chat session** (New Quick Chat = a new sessi Two grouping modes (user-switchable): -- **By Workspace** (default) — user groups and one section per workspace label share a single, freely-reorderable user-managed order below Pinned. By default groups come first and workspaces are alphabetical ("Unknown" workspace last) until the user drags them. A workspace header includes a **Create Session from Pull Request** icon action unless the section is backed only by `github-remote-file` cloud workspaces. The Quick Pick opens immediately in a disabled busy state while repository identity resolves. Identity comes from hydrated session metadata when available; otherwise the action opens the checkout through `IGitService`, waits for its repository-state remotes to hydrate, and parses the GitHub remote. Closing the picker cancels that wait. The picker then runs fresh Waiting for My Review and Assigned to Me queries in parallel with the lightweight first-100 catalog query. Each group query returns complete rows, so Waiting can render without waiting for the full catalog; groups append in final display order so visible entries never move during enrichment. PRs that already have a local or remote-host checkout session are excluded; an existing `github-remote-file` cloud-agent session does not prevent creating a separate worktree session for the same PR. Typing a query that matches none of the loaded entries fetches subsequent pages until a match is found or the catalog is exhausted. After selection, the picker remains busy while it loads the PR details and all paged file patches, issue comments, and review comments and waits for the folder to advertise a worktree-capable session type; Escape cancels this wait. The provisional session then activates immediately and starts a worktree that tracks the PR head branch. The initial request and response are retained as hidden model context, while the PR JSON appears as a one-time context pill that moves into the first visible request. +- **By Workspace** (default) — user groups and one section per workspace label share a single, freely-reorderable user-managed order below Pinned. By default groups come first and workspaces are alphabetical ("Unknown" workspace last) until the user drags them. A workspace header presents **New Session** as the primary half of a split button, with **Create Session from Pull Request** in its dropdown unless the section is backed only by `github-remote-file` cloud workspaces. When the pull-request action is unavailable, **New Session** remains a standalone action. The Quick Pick opens immediately in a disabled busy state while repository identity resolves. Identity comes from hydrated session metadata when available; otherwise the action opens the checkout through `IGitService`, waits for its repository-state remotes to hydrate, and parses the GitHub remote. Closing the picker cancels that wait. The picker then runs fresh Waiting for My Review and Assigned to Me queries in parallel with the lightweight first-100 catalog query. Each group query returns complete rows, so Waiting can render without waiting for the full catalog; groups append in final display order so visible entries never move during enrichment. PRs that already have a local or remote-host checkout session are excluded; an existing `github-remote-file` cloud-agent session does not prevent creating a separate worktree session for the same PR. Typing a query that matches none of the loaded entries fetches subsequent pages until a match is found or the catalog is exhausted. After selection, the picker remains busy while it loads the PR details and all paged file patches, issue comments, and review comments and waits for the folder to advertise a worktree-capable session type; Escape cancels this wait. The provisional session then activates immediately and starts a worktree that tracks the PR head branch. The initial request and response are retained as hidden model context, while the PR JSON appears as a one-time context pill that moves into the first visible request. - **By Date** — user groups form a contiguous, user-ordered block directly below Pinned; the non-grouped sessions follow in the fixed date sections (Recent, Older), where Recent holds up to 10 sessions from the last 7 days and Older holds the rest. Groups never mix into the date sections. User groups are **fully user-managed**: their order is owned by `ISessionSectionOrderService`, defaults to newest-first, and is shared across both grouping modes (it no longer derives from the recency of a group's member sessions). Groups remain visible and persisted until explicitly deleted. A group with no currently-visible member rows renders a muted **"No session" placeholder row** like the empty Chats section; its hover briefly explains that sessions can be added through the session context menu or drag and drop. This includes genuinely empty groups and groups whose members currently render in Pinned or are hidden by a filter. Archiving a session removes its group membership, so a group whose last member is marked done becomes empty and can be deleted. @@ -197,7 +197,7 @@ The Open Pull Request action shared by the session context menu and header uses | Menu | Constant | Where it appears | Use for | |------|----------|------------------|---------| -| `SessionSectionToolbar` | `SessionSectionToolbarMenuId` | Toolbar on section headers (Pinned, workspace groups, Done) | Section-scoped actions like "New Session for Workspace", GitHub-backed "Create Session from Pull Request", and the selected "Archive All"/"Mark All as Done" action. The Done section restores/unarchives sessions individually (or via multi-selection) rather than with a section-wide action. Section headers also show a collapsible chevron on hover/focus; the chevron uses the same ghost icon hover background token as toolbar icon buttons. | +| `SessionSectionToolbar` | `SessionSectionToolbarMenuId` | Toolbar on section headers (Pinned, workspace groups, Done) | Section-scoped actions like the workspace `DropdownWithPrimaryActionViewItem` whose fixed primary action is "New Session" and whose dropdown contains actions contributed to `Menus.SessionSectionNewSession`, including the GitHub-backed "Create Session from Pull Request" action. When that menu is empty, the toolbar renders the ordinary "New Session" action. The toolbar also contains the selected "Archive All"/"Mark All as Done" action. The Done section restores/unarchives sessions individually (or via multi-selection) rather than with a section-wide action. Section headers also show a collapsible chevron on hover/focus; while a section action dropdown is open, both the toolbar and chevron remain visible. The chevron uses the same ghost icon hover background token as toolbar icon buttons. | ### Group Header Menu diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index fd2fdd285f1350..1e7f84f1f6cace 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -26,6 +26,7 @@ export const Menus = { PanelTitle: new MenuId('SessionsPanelTitle'), SidebarTitle: new MenuId('SessionsSidebarTitle'), SidebarSessionsHeader: new MenuId('SessionsSidebarSessionsHeader'), + SessionSectionNewSession: new MenuId('SessionsSessionSectionNewSession'), SessionsViewExternalFilter: new MenuId('SessionsViewExternalFilter'), AuxiliaryBarTitle: new MenuId('SessionsAuxiliaryBarTitle'), SidebarFooter: new MenuId('SessionsSidebarFooter'), diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index b6dc7e8277cfd6..17b8bfa94906fe 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -40,7 +40,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.feedbackAttachment', "When a feedback comments attachment appears above the input, focus it and press Enter or Space. A single comment opens directly. Multiple comments open a tree grouped by file; use the arrow keys to navigate, Enter to reveal a comment, and Escape to close the tree.")); content.push(localize('sessionsChat.inputBackground', "Press Alt+Enter to start the session in the background without navigating into it. The started session appears in the Chat Sessions view.")); content.push(localize('sessionsChat.workspace', "Shift+Tab to navigate to the workspace picker and choose a workspace for your session.")); - content.push(localize('sessionsChat.pullRequestSession', "In a repository section of the sessions list, activate Create Session from Pull Request to open a searchable pull request picker. Pull requests are grouped by review and assignment status. Use the arrow keys to navigate, Enter to create the session, and Escape to close the picker.")); + content.push(localize('sessionsChat.pullRequestSession', "In a repository section where New Session is a split button, focus New Session and press Right Arrow to reach its dropdown, then activate Create Session from Pull Request to open a searchable pull request picker. Pull requests are grouped by review and assignment status. Use the arrow keys to navigate, Enter to create the session, and Escape to close the picker.")); content.push(localize('sessionsChat.githubReferences', "Pull request and issue pills in the session header open their GitHub item in the GitHub Pull Requests extension when it is available. Pills that represent several items open a keyboard-accessible picker.")); content.push(localize('sessionsChat.failingChecksPullRequest', "When the active session has failing checks, use Reveal in the banner above the input to open its pull request, or use Fix Checks to ask the agent to address the failures.")); content.push(localize('sessionsChat.pickFolderQuickPick', "To choose a folder from a searchable list instead, use the New Session in Folder command{0}.", '')); diff --git a/src/vs/sessions/contrib/github/browser/createSessionFromPullRequestAction.ts b/src/vs/sessions/contrib/github/browser/createSessionFromPullRequestAction.ts index 0b80529110523f..229ebdb7c5c44e 100644 --- a/src/vs/sessions/contrib/github/browser/createSessionFromPullRequestAction.ts +++ b/src/vs/sessions/contrib/github/browser/createSessionFromPullRequestAction.ts @@ -24,7 +24,8 @@ import { ISessionsManagementService } from '../../../services/sessions/common/se import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { CLOSE_MOBILE_SIDEBAR_DRAWER_COMMAND_ID } from '../../../browser/workbench.js'; -import { ISessionSection, SessionSectionHasNonCloudRepositoryContext, SessionSectionToolbarMenuId, SessionSectionTypeContext } from '../../sessions/browser/views/sessionsList.js'; +import { Menus } from '../../../browser/menus.js'; +import { ISessionSection, SessionSectionHasNonCloudRepositoryContext, SessionSectionTypeContext } from '../../sessions/browser/views/sessionsList.js'; import { IGitHubService } from './githubService.js'; import { IGitHubPullRequestSummary } from '../common/types.js'; import { createPullRequestBootstrapPrompt, createPullRequestContextAttachment, createPullRequestQuickPickItems, createPullRequestSessionMetadata, getExistingPullRequests, getGitHubRepositoryFromRemotes, hasExistingPullRequest, IPullRequestQuickPickItem, mergePullRequestSummaries, pullRequestMatchesQuery, resolvePullRequestSessionRepository } from './pullRequestPicker.js'; @@ -40,7 +41,7 @@ registerAction2(class CreateSessionFromPullRequestAction extends Action2 { icon: Codicon.gitPullRequestCreate, precondition: ChatContextKeys.enabled, menu: { - id: SessionSectionToolbarMenuId, + id: Menus.SessionSectionNewSession, group: 'navigation', order: 2, when: ContextKeyExpr.and( diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index a8bb7ec1c77de3..9dfb5575735ff6 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -539,7 +539,8 @@ } .monaco-list-row:hover .session-section .session-section-toolbar, -.monaco-list-row.focused .session-section .session-section-toolbar { +.monaco-list-row.focused .session-section .session-section-toolbar, +.monaco-list-row .session-section.dropdown-active .session-section-toolbar { display: block; } @@ -562,7 +563,8 @@ } .monaco-list-row:hover .session-section .session-section-chevron.collapsible, -.monaco-list-row.focused .session-section .session-section-chevron.collapsible { +.monaco-list-row.focused .session-section .session-section-chevron.collapsible, +.monaco-list-row .session-section.dropdown-active .session-section-chevron.collapsible { display: flex; align-items: center; } diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index f4ec28af9774c2..ae58d94bb8cefd 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -26,6 +26,8 @@ import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { localize } from '../../../../../nls.js'; import { MenuId, IMenuService, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; +import { DropdownWithPrimaryActionViewItem } from '../../../../../platform/actions/browser/dropdownWithPrimaryActionViewItem.js'; +import { getFlatContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { MarshalledId } from '../../../../../base/common/marshallingIds.js'; @@ -48,7 +50,7 @@ import { AgentSessionApprovalModel, agentSessionApprovalId, IAgentSessionApprova import { IVoicePlaybackService } from '../../../../../workbench/contrib/chat/common/voicePlaybackService.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; -import { ActionRunner, IAction, Separator, SubmenuAction, toAction } from '../../../../../base/common/actions.js'; +import { Action, ActionRunner, IAction, Separator, SubmenuAction, toAction } from '../../../../../base/common/actions.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { HoverStyle } from '../../../../../base/browser/ui/hover/hover.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; @@ -87,6 +89,7 @@ import { ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/ import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../automationsConstants.js'; +import { Menus } from '../../../../browser/menus.js'; const $ = DOM.$; @@ -98,6 +101,7 @@ export const SessionItemToolbarMenuId = new MenuId('SessionItemToolbar'); export const SessionItemContextMenuId = MenuId.SessionItemContextMenu; export const SessionSectionToolbarMenuId = new MenuId('SessionSectionToolbar'); export const SessionGroupToolbarMenuId = new MenuId('SessionGroupToolbar'); +export const NEW_SESSION_FOR_WORKSPACE_ACTION_ID = 'sessionsView.sectionNewSession'; /** Controls whether the empty default Chats group is shown in the sessions list. */ export const SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING = 'sessions.list.showEmptyDefaultGroups'; @@ -953,11 +957,17 @@ export class SessionSectionRenderer implements ITreeRenderer, private readonly uriIdentityService: IUriIdentityService, private readonly customViewService: ICustomViewService, + private readonly menuService: IMenuService, ) { } renderTemplate(container: HTMLElement): ISessionSectionTemplate { const disposables = new DisposableStore(); const elementDisposables = disposables.add(new DisposableStore()); + const actionViewItemDisposables = disposables.add(new DisposableStore()); + const dropdownAction = disposables.add(new Action( + 'sessionsView.sectionNewSession.moreActions', + localize('newSessionForWorkspaceMoreActions', "More Actions"), + )); container.classList.add('session-section'); const icon = DOM.append(container, $('span.session-section-icon')); @@ -974,6 +984,42 @@ export class SessionSectionRenderer implements ITreeRenderer { + actionViewItemDisposables.clear(); + + if (action.id !== NEW_SESSION_FOR_WORKSPACE_ACTION_ID || !(action instanceof MenuItemAction)) { + return undefined; + } + + const dropdownActions = getFlatContextMenuActions(this.menuService.getMenuActions( + Menus.SessionSectionNewSession, + contextKeyService, + { shouldForwardArgs: true }, + )); + if (dropdownActions.length === 0) { + return undefined; + } + + const item = scopedInstantiationService.createInstance( + DropdownWithPrimaryActionViewItem, + action, + dropdownAction, + dropdownActions, + '', + { + hoverDelegate: options.hoverDelegate, + menuAsChild: false + }, + ); + + actionViewItemDisposables.add(item.onDidChangeDropdownVisibility(visible => + container.classList.toggle('dropdown-active', visible))); + + actionViewItemDisposables.add(toDisposable(() => + container.classList.remove('dropdown-active'))); + + return item; + }, })); return { container, icon, statusIndicator, label, count, toolbarContainer, toolbar, chevron, contextKeyService, elementDisposables, disposables }; @@ -2010,7 +2056,7 @@ export class SessionsList extends Disposable implements ISessionsList { this.tree.setFocus([element], event); this.tree.setSelection([element], event); }; - const sectionRenderer = new SessionSectionRenderer(true /* hideSectionCount */, selectHeader, instantiationService, contextKeyService, this.automationService, this.automationSessions, this.uriIdentityService, this.customViewService); + const sectionRenderer = new SessionSectionRenderer(true /* hideSectionCount */, selectHeader, instantiationService, contextKeyService, this.automationService, this.automationSessions, this.uriIdentityService, this.customViewService, this.menuService); this._sectionRenderer = sectionRenderer; const groupRenderer = new SessionGroupRenderer({ commitEdit: (group, name) => this.commitGroupEdit(group, name), diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 6ec54b77698dc3..e3d5e8e40a77e6 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -24,7 +24,7 @@ import { EditorsVisibleContext, EditorAreaFocusContext, IsSessionsWindowContext import { SessionsCategories } from '../../../../common/categories.js'; import { RENAME_SESSION_COMMAND_ID, UNARCHIVE_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; import { SessionSupportsDeleteContext, SessionSupportsRenameContext, IsNewChatSessionContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsReadContext } from '../../../../common/contextkeys.js'; -import { SessionItemToolbarMenuId, SessionItemContextMenuId, SessionSectionToolbarMenuId, SessionGroupToolbarMenuId, SessionSectionTypeContext, SessionGroupHasVisibleSessionsContext, SessionGroupIsEmptyContext, IsSessionPinnedContext, SessionsGrouping, SessionsSorting, ISessionSection, ISessionGroupItem } from './sessionsList.js'; +import { SessionItemToolbarMenuId, SessionItemContextMenuId, SessionSectionToolbarMenuId, SessionGroupToolbarMenuId, SessionSectionTypeContext, SessionSectionHasNonCloudRepositoryContext, SessionGroupHasVisibleSessionsContext, SessionGroupIsEmptyContext, IsSessionPinnedContext, SessionsGrouping, SessionsSorting, ISessionSection, ISessionGroupItem, NEW_SESSION_FOR_WORKSPACE_ACTION_ID } from './sessionsList.js'; import { ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ISessionGroupsService } from '../../../../services/sessions/browser/sessionGroupsService.js'; import { IsWorkspaceGroupCappedContext, SessionsViewFilterOptionsSubMenu, SessionsViewFilterSubMenu, SessionsViewGroupingContext, SessionsViewId, SessionsView, SessionsViewSortingContext, openSessionToTheSide } from './sessionsView.js'; @@ -439,15 +439,31 @@ registerAction2(class FindSessionAction extends Action2 { registerAction2(class NewSessionForWorkspaceAction extends Action2 { constructor() { super({ - id: 'sessionsView.sectionNewSession', + id: NEW_SESSION_FOR_WORKSPACE_ACTION_ID, title: localize2('newSessionForWorkspace', "New Session"), icon: Codicon.plus, - menu: [{ - id: SessionSectionToolbarMenuId, - group: 'navigation', - order: 1, - when: ContextKeyExpr.equals(SessionSectionTypeContext.key, 'workspace'), - }] + menu: [ + { + id: SessionSectionToolbarMenuId, + group: 'navigation', + order: 1, + when: ContextKeyExpr.and( + ChatContextKeys.enabled, + SessionSectionHasNonCloudRepositoryContext, + ContextKeyExpr.equals(SessionSectionTypeContext.key, 'workspace')) + }, + { + id: SessionSectionToolbarMenuId, + group: 'navigation', + order: 1, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(SessionSectionTypeContext.key, 'workspace'), + ContextKeyExpr.or( + ChatContextKeys.enabled.negate(), + SessionSectionHasNonCloudRepositoryContext.negate()), + ), + }, + ] }); } async run(accessor: ServicesAccessor, context?: ISessionSection): Promise { diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index bd194f1485d9b3..cdb09be38fd004 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -11,6 +11,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; +import { IMenuService } from '../../../../../platform/actions/common/actions.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -101,6 +102,7 @@ suite('Sessions - SessionsList', () => { override readonly extUri = new ExtUri(() => true); }, new class extends mock() { }, + new class extends mock() { }, ); const container = document.createElement('div'); const template = renderer.renderTemplate(container); @@ -155,6 +157,7 @@ suite('Sessions - SessionsList', () => { automationSessions, uriIdentityService, new class extends mock() { }, + new class extends mock() { }, ); const runResource = URI.parse('test-session:/workspace/automation'); const statuses: (SessionStatus | undefined)[] = []; @@ -211,6 +214,7 @@ suite('Sessions - SessionsList', () => { constObservable([runningSession, needsInputSession]), uriIdentityService, new class extends mock() { }, + new class extends mock() { }, ); runs.set([ { From 8610a5cdf83565a501b298a3a593bc92f655fefd Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Tue, 18 Aug 2026 18:43:24 +0100 Subject: [PATCH 04/24] Modern UI: Increase margin-top for activity bar items (#331459) * floatingPanels: increase margin-top for activity bar items to improve spacing * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: mrleemurray Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/vs/workbench/browser/media/floatingPanels.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/browser/media/floatingPanels.css b/src/vs/workbench/browser/media/floatingPanels.css index 13e17008289477..263a02c4af5a56 100644 --- a/src/vs/workbench/browser/media/floatingPanels.css +++ b/src/vs/workbench/browser/media/floatingPanels.css @@ -194,10 +194,10 @@ margin-top: var(--vscode-spacing-size20); } -/* At the default (non-compact) size, separate the activity bar items with a 4px gap +/* At the default (non-compact) size, separate the activity bar items with an 8px gap * so they read as distinct floating targets. Compact keeps the tighter default stack. */ .monaco-workbench.floating-panels .part.activitybar:not(.compact) > .content .monaco-action-bar .action-item + .action-item { - margin-top: var(--vscode-spacing-size40); + margin-top: var(--vscode-spacing-size80); } /* Inset and vertically center status bar items within the full-width bottom rail. */ From dd4574812393057fee4e9a3acd5688ab37902357 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Tue, 18 Aug 2026 19:32:16 +0100 Subject: [PATCH 05/24] Modern UI: Update pane header colors and separator behavior for modern UI (#331485) * style: update pane header colors and separator behavior for modern UI * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: mrleemurray Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../modernUI/browser/media/paneHeaders.css | 33 +++++++++++++----- .../browser/modernUI.contribution.test.ts | 34 ++++++++++++++++--- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/media/paneHeaders.css b/src/vs/workbench/contrib/modernUI/browser/media/paneHeaders.css index 7c81868a5a2ece..e06974a41e77a5 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/paneHeaders.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/paneHeaders.css @@ -32,9 +32,7 @@ /* * Redraw the section separator as a shorter, inset line at the top of each - * header (the header is already `position: relative`). It uses the same section - * header border color the PaneView would, falling back across sidebar / panel - * tokens so it is visible in both locations. + * header (the header is already `position: relative`). */ .modern-ui .monaco-pane-view .pane > .pane-header::before { content: ""; @@ -44,7 +42,14 @@ right: var(--vscode-spacing-size40); height: var(--vscode-strokeThickness); pointer-events: none; - background-color: var(--vscode-sideBarSectionHeader-border, var(--vscode-panelSectionHeader-border, var(--vscode-panel-border))); +} + +.modern-ui :is(.part.sidebar, .part.auxiliarybar) .monaco-pane-view .pane > .pane-header::before { + background-color: var(--vscode-sideBarSectionHeader-border); +} + +.modern-ui .part.panel .monaco-pane-view .pane > .pane-header::before { + background-color: var(--vscode-panelSectionHeader-border, var(--vscode-panel-border)); } /* @@ -57,10 +62,13 @@ display: none; } -.modern-ui .monaco-pane-view .pane, -.modern-ui.floating-panels .part.sidebar, -.modern-ui.floating-panels .part.auxiliarybar { - background-color: var(--vscode-sideBar-background, var(--vscode-panel-background)) !important; +.modern-ui :is(.part.sidebar, .part.auxiliarybar) .monaco-pane-view .pane, +.modern-ui.floating-panels :is(.part.sidebar, .part.auxiliarybar) { + background-color: var(--vscode-sideBar-background) !important; +} + +.modern-ui .part.panel .monaco-pane-view .pane { + background-color: var(--vscode-panel-background) !important; } /* Round the header corners and let the header match its surface at rest so it @@ -69,7 +77,14 @@ * below still wins via its higher specificity. */ .modern-ui .monaco-pane-view .pane > .pane-header { border-radius: var(--vscode-cornerRadius-small); - background-color: var(--vscode-sideBar-background, var(--vscode-panel-background)) !important; +} + +.modern-ui :is(.part.sidebar, .part.auxiliarybar) .monaco-pane-view .pane > .pane-header { + background-color: var(--vscode-sideBar-background) !important; +} + +.modern-ui .part.panel .monaco-pane-view .pane > .pane-header { + background-color: var(--vscode-panel-background) !important; } /* diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index fc6045c170e3cf..2e1c4c74e3f9ed 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -173,18 +173,30 @@ suite('ModernUIContribution', () => { }); }); - test('preserves horizontal panel section borders without drawing separators above column headers', () => { + test('uses part-specific pane colors and only draws panel header separators in vertical layouts', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui'; + root.style.setProperty('--vscode-sideBar-background', '#FF8888'); + root.style.setProperty('--vscode-sideBarSectionHeader-border', '#FF0000'); + root.style.setProperty('--vscode-panel-background', '#8888FF'); + root.style.setProperty('--vscode-panelSectionHeader-border', '#0000FF'); document.body.appendChild(root); store.add(toDisposable(() => root.remove())); - const verticalPaneView = appendElement(root, 'monaco-pane-view'); + const sideBarPaneView = appendElement(appendElement(root, 'part sidebar'), 'monaco-pane-view'); + const firstSideBarPane = store.add(new ModernUITestPane()); + appendElement(sideBarPaneView, 'split-view-view').appendChild(firstSideBarPane.element); + + const followingSideBarPane = store.add(new ModernUITestPane()); + appendElement(sideBarPaneView, 'split-view-view').appendChild(followingSideBarPane.element); + + const panel = appendElement(root, 'part panel'); + const verticalPaneView = appendElement(panel, 'monaco-pane-view'); const firstVerticalPane = store.add(new ModernUITestPane()); firstVerticalPane.style({ dropBackground: undefined, headerForeground: undefined, - headerBackground: undefined, + headerBackground: '#FFFFFF', headerBorder: '#00FF00', leftBorder: undefined, }); @@ -194,13 +206,13 @@ suite('ModernUIContribution', () => { followingVerticalPane.style({ dropBackground: undefined, headerForeground: undefined, - headerBackground: undefined, + headerBackground: '#FFFFFF', headerBorder: '#00FF00', leftBorder: undefined, }); appendElement(verticalPaneView, 'split-view-view').appendChild(followingVerticalPane.element); - const horizontalPaneView = appendElement(root, 'monaco-pane-view'); + const horizontalPaneView = appendElement(panel, 'monaco-pane-view'); const firstHorizontalPane = store.add(new ModernUITestPane()); firstHorizontalPane.orientation = Orientation.HORIZONTAL; firstHorizontalPane.style({ @@ -225,6 +237,12 @@ suite('ModernUIContribution', () => { const targetWindow = getWindow(root); assert.deepStrictEqual({ + sideBarPaneBackground: targetWindow.getComputedStyle(followingSideBarPane.element).backgroundColor, + sideBarHeaderBackground: targetWindow.getComputedStyle(followingSideBarPane.draggableElement!).backgroundColor, + sideBarHeaderSeparatorColor: targetWindow.getComputedStyle(followingSideBarPane.draggableElement!, '::before').backgroundColor, + panelPaneBackground: targetWindow.getComputedStyle(followingVerticalPane.element).backgroundColor, + panelHeaderBackground: targetWindow.getComputedStyle(followingVerticalPane.draggableElement!).backgroundColor, + panelHeaderSeparatorColor: targetWindow.getComputedStyle(followingVerticalPane.draggableElement!, '::before').backgroundColor, firstVerticalHeaderSeparatorVisible: targetWindow.getComputedStyle(firstVerticalPane.draggableElement!, '::before').display !== 'none', followingVerticalHeaderSeparatorVisible: targetWindow.getComputedStyle(followingVerticalPane.draggableElement!, '::before').display !== 'none', followingVerticalHeaderBorderTopWidth: targetWindow.getComputedStyle(followingVerticalPane.draggableElement!).borderTopWidth, @@ -233,6 +251,12 @@ suite('ModernUIContribution', () => { followingHorizontalPaneBorderLeftWidth: targetWindow.getComputedStyle(followingHorizontalPane.element).borderLeftWidth, followingHorizontalPaneBorderLeftColor: targetWindow.getComputedStyle(followingHorizontalPane.element).borderLeftColor, }, { + sideBarPaneBackground: 'rgb(255, 136, 136)', + sideBarHeaderBackground: 'rgb(255, 136, 136)', + sideBarHeaderSeparatorColor: 'rgb(255, 0, 0)', + panelPaneBackground: 'rgb(136, 136, 255)', + panelHeaderBackground: 'rgb(136, 136, 255)', + panelHeaderSeparatorColor: 'rgb(0, 0, 255)', firstVerticalHeaderSeparatorVisible: false, followingVerticalHeaderSeparatorVisible: true, followingVerticalHeaderBorderTopWidth: '0px', From 0d07085cd00247fd08528b00bda676ce4c8767ee Mon Sep 17 00:00:00 2001 From: Aaron Munger <2019016+amunger@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:08:11 -0700 Subject: [PATCH 06/24] agentHost: attribute default turns to bound model (#330971) * agentHost: attribute default turns to bound model Use each provider's concrete chat model to fill turn telemetry before usage arrives, while preserving default/auto/explicit selection semantics and existing privacy normalization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: bind deferred Copilot chats to their creation model Record the creation model on a reserved chat backing so model attribution covers Copilot's deferred-chat path, where the model previously stayed on the provisional session and left turns unattributed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/common/agent.ts | 3 ++ .../agentHost/node/agentHostTurnTracker.ts | 4 +- .../agentHost/node/agentSideEffects.ts | 22 ++++---- .../agentHost/node/claude/claudeAgent.ts | 1 + .../agentHost/node/codex/codexAgent.ts | 4 ++ .../agentHost/node/copilot/copilotAgent.ts | 3 +- .../node/agentHostTurnHangTelemetry.test.ts | 2 +- .../test/node/agentHostTurnTelemetry.test.ts | 51 +++++++++++++++++++ .../agentHost/test/node/copilotAgent.test.ts | 30 +++++++++++ .../platform/agentHost/test/node/mockAgent.ts | 2 + 10 files changed, 108 insertions(+), 14 deletions(-) diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 111a1d3ad8128e..6adfdd796d616a 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -714,6 +714,9 @@ export interface IAgentChats { /** Abort the in-flight turn for `chat`. */ abort(chat: URI, context: AgentChatOperationContext): Promise; + /** Return the model currently bound to `chat`, when the provider knows it. */ + getModel?(chat: URI, context: AgentChatOperationContext): ModelSelection | undefined; + changeModel(chat: URI, model: ModelSelection, context: AgentChatOperationContext): Promise; /** diff --git a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts index e1086f977455bd..bea1d2416b5016 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts @@ -142,7 +142,7 @@ export class AgentHostTurnTracker extends Disposable { })); } - turnStarted(provider: string, session: string, turnId: string, model: string | undefined, modelTelemetryKind: AgentHostModelTelemetryKind | undefined, permissionLevel: string | undefined, interactionMode: SessionMode | undefined, clientContext = createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown)): void { + turnStarted(provider: string, session: string, turnId: string, model: string | undefined, modelTelemetryKind: AgentHostModelTelemetryKind | undefined, modelSelectionKind: 'default' | 'auto' | 'explicit', permissionLevel: string | undefined, interactionMode: SessionMode | undefined, clientContext = createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown)): void { const key = this._key(session, turnId); this._turnTimings.set(key, { stopWatch: StopWatch.create(false), @@ -151,7 +151,7 @@ export class AgentHostTurnTracker extends Disposable { turnId, model, modelTelemetryKind, - modelSelectionKind: model === undefined ? 'default' : model === 'auto' ? 'auto' : 'explicit', + modelSelectionKind, permissionLevel, interactionMode, clientContext, diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 1f2fdfecdcbb21..648cac93a03ad0 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -1263,7 +1263,7 @@ export class AgentSideEffects extends Disposable { }); const agent = this._options.getAgent(parentSessionUri); if (agent) { - this._turnTracker.turnStarted(agent.id, subagentChatUri, turnId, undefined, undefined, undefined, undefined, parentClientContext); + this._turnTracker.turnStarted(agent.id, subagentChatUri, turnId, undefined, undefined, 'default', undefined, undefined, parentClientContext); this._turnTracker.setCurrentStage(subagentChatUri, turnId, 'provider'); } @@ -1337,7 +1337,7 @@ export class AgentSideEffects extends Disposable { }); const agent = this._options.getAgent(subagent.sessionUri); if (agent) { - this._turnTracker.turnStarted(agent.id, subagent.chatUri, turnId, undefined, undefined, undefined, undefined, parentClientContext); + this._turnTracker.turnStarted(agent.id, subagent.chatUri, turnId, undefined, undefined, 'default', undefined, undefined, parentClientContext); this._turnTracker.setCurrentStage(subagent.chatUri, turnId, 'provider'); } this._subagentChats.set({ ...subagent, turnStopWatch: StopWatch.create(false) }, parentChatURI, toolCallId); @@ -1605,8 +1605,8 @@ export class AgentSideEffects extends Disposable { } const attachments = action.message.attachments; this._telemetryReporter.userMessageSent(agent.id, clientId, clientContext, channel, action.turnId, state, 'direct', attachments); - const { model, modelTelemetryKind, permissionLevel, interactionMode } = this._getTurnTelemetryContext(agent, state, action.message.model?.id); - this._turnTracker.turnStarted(agent.id, channel, action.turnId, model, modelTelemetryKind, permissionLevel, interactionMode, clientContext); + const { model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode } = this._getTurnTelemetryContext(agent, channel, this._chatContext(sessionChannel, channel), state, action.message.model?.id); + this._turnTracker.turnStarted(agent.id, channel, action.turnId, model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode, clientContext); void this._sendTurnMessage({ agent, sessionChannel, @@ -2059,8 +2059,8 @@ export class AgentSideEffects extends Disposable { const attachments = msg.message.attachments; const queuedState = this._stateManager.getSessionState(session); this._telemetryReporter.userMessageSent(agent.id, sender.clientId, sender.clientContext, session, turnId, queuedState, 'queued', attachments); - const { model, modelTelemetryKind, permissionLevel, interactionMode } = this._getTurnTelemetryContext(agent, queuedState, msg.message.model?.id); - this._turnTracker.turnStarted(agent.id, session, turnId, model, modelTelemetryKind, permissionLevel, interactionMode, sender.clientContext); + const { model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode } = this._getTurnTelemetryContext(agent, session, this._chatContext(sessionChannel, session), queuedState, msg.message.model?.id); + this._turnTracker.turnStarted(agent.id, session, turnId, model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode, sender.clientContext); // Selection travels on the queued message; it is applied before sending. void this._sendTurnMessage({ agent, @@ -2076,14 +2076,16 @@ export class AgentSideEffects extends Disposable { } - private _getTurnTelemetryContext(agent: IAgent, state: SessionState | undefined, modelId: string | undefined): { model: string | undefined; modelTelemetryKind: AgentHostModelTelemetryKind | undefined; permissionLevel: string | undefined; interactionMode: SessionMode | undefined } { + private _getTurnTelemetryContext(agent: IAgent, chat: ProtocolURI, context: IAgentChatContext, state: SessionState | undefined, modelId: string | undefined): { model: string | undefined; modelTelemetryKind: AgentHostModelTelemetryKind | undefined; modelSelectionKind: 'default' | 'auto' | 'explicit'; permissionLevel: string | undefined; interactionMode: SessionMode | undefined } { const permissionValue = state?.config?.values[SessionConfigKey.AutoApprove]; const permissionLevel = typeof permissionValue === 'string' ? permissionValue : undefined; const interactionMode = getConfiguredSessionMode(state?.config); - const modelContext = modelId === undefined + const modelSelectionKind = modelId === undefined ? 'default' : modelId === 'auto' ? 'auto' : 'explicit'; + const effectiveModelId = modelId ?? agent.chats.getModel?.(URI.parse(chat), context)?.id; + const modelContext = effectiveModelId === undefined || (modelId === undefined && effectiveModelId === 'auto') ? { model: undefined, modelTelemetryKind: undefined } - : this._getModelTelemetryContext(agent, modelId); - return { ...modelContext, permissionLevel, interactionMode }; + : this._getModelTelemetryContext(agent, effectiveModelId); + return { ...modelContext, modelSelectionKind, permissionLevel, interactionMode }; } private _getModelTelemetryContext(agent: IAgent, modelId: string): { model: string; modelTelemetryKind: AgentHostModelTelemetryKind } { diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index d70f694dcc50cc..026930e22311c2 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -1111,6 +1111,7 @@ export class ClaudeAgent extends Disposable implements IAgent { abort: (chatUri, context) => { return this._abortSession(chatUri, context); }, + getModel: chatUri => this._chatBackings.get(chatUri.toString())?.model, changeModel: (chatUri, model, context) => { return this._changeModel(chatUri, model, context); }, diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index d44618a8ce3827..d59cf326ea39c1 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -3372,6 +3372,10 @@ export class CodexAgent extends Disposable implements IAgent { abort: (chat: URI, context: URI | IAgentChatContext): Promise => { return this._abort(chat, context); }, + getModel: (chat: URI, context: URI | IAgentChatContext): ModelSelection | undefined => { + const session = this._resolveConversationSession(chat, context); + return session ? this._sessions.get(AgentSession.id(session))?.model : undefined; + }, changeModel: (chat: URI, model: ModelSelection, context: URI | IAgentChatContext): Promise => { return this._changeModel(chat, model, context); }, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 85aa9ed153c06a..c81dff8328039c 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -2701,6 +2701,7 @@ export class CopilotAgent extends Disposable implements IAgent { abort: (chatUri: URI, context: URI | IAgentChatContext): Promise => { return this._abortSession(chatUri, context); }, + getModel: (chatUri: URI): ModelSelection | undefined => this._chatBackings.get(chatUri.toString())?.model, changeModel: (chatUri: URI, model: ModelSelection, context: URI | IAgentChatContext): Promise => { return this._changeModel(chatUri, model, context); }, @@ -2867,7 +2868,7 @@ export class CopilotAgent extends Disposable implements IAgent { project, workspaceless: isWorkspaceless, }); - this._chatBackings.set(chat.toString(), { sdkSessionId }); + this._chatBackings.set(chat.toString(), { sdkSessionId, ...(options.model ? { model: options.model } : {}) }); } this._logService.info(`[Copilot] Chat created; its backing stays deferred until the first send: ${session.toString()}`); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts index a901f0cb38ee6d..9ad80f1698c7e0 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts @@ -424,7 +424,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { await runWithFakedTimers({}, async () => { for (const item of cases) { - tracker.turnStarted('mock', item.session, 'turn', undefined, undefined, undefined, undefined); + tracker.turnStarted('mock', item.session, 'turn', undefined, undefined, 'default', undefined, undefined); tracker.setCurrentStage(item.session, 'turn', item.stage); } await timeout(TURN_HANG_THRESHOLD_MS); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 992e93ec995154..e5e8699fc0ccd7 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -319,6 +319,9 @@ suite('AgentSideEffects — turn tracker telemetry', () => { fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-byok', duration: 1000 }); startTurn('turn-unknown', 'hello', 'unadvertised/private-model'); fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-unknown', duration: 1000 }); + agent.chatModel = { id: 'openrouter/private-model' }; + startTurn('turn-default'); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-default', duration: 1000 }); assert.deepStrictEqual(completedEvents().map(event => { const data = event.data as Record; @@ -326,6 +329,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { }), [ { model: 'byokModel', modelSelectionKind: 'explicit', isBYOK: true }, { model: 'unknown', modelSelectionKind: 'explicit', isBYOK: false }, + { model: 'byokModel', modelSelectionKind: 'default', isBYOK: true }, ]); }); @@ -350,6 +354,53 @@ suite('AgentSideEffects — turn tracker telemetry', () => { }); }); + test('uses the concrete provider default across turn outcomes while preserving Default selection', () => { + setupSession(); + agent.setModels([{ provider: 'mock', id: 'gpt-5.5', name: 'GPT 5.5', supportsVision: false }]); + agent.chatModel = { id: 'gpt-5.5' }; + + startTurn('turn-success'); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-success', duration: 1000 }); + startTurn('turn-error'); + fire({ type: ActionType.ChatError, turnId: 'turn-error', duration: 1000, error: { errorType: 'oops', message: 'fail' } }); + startTurn('turn-cancelled'); + fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-cancelled', duration: 1000 }); + + assert.deepStrictEqual(completedEvents().map(event => { + const data = event.data as Record; + return { + model: capturedModel(data), + modelSelectionKind: data.modelSelectionKind, + result: data.result, + }; + }), [ + { model: { trusted: true, value: 'gpt-5.5' }, modelSelectionKind: 'default', result: 'success' }, + { model: { trusted: true, value: 'gpt-5.5' }, modelSelectionKind: 'default', result: 'error' }, + { model: { trusted: true, value: 'gpt-5.5' }, modelSelectionKind: 'default', result: 'cancelled' }, + ]); + }); + + test('does not treat an Auto provider default as the effective model', () => { + setupSession(); + agent.setModels([ + { provider: 'mock', id: 'auto', name: 'Auto', supportsVision: false }, + { provider: 'mock', id: 'gpt-5.5', name: 'GPT 5.5', supportsVision: false }, + ]); + agent.chatModel = { id: 'auto' }; + startTurn('turn-default'); + + fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-default', duration: 1000 }); + + const data = completedEvents()[0].data as Record; + assert.deepStrictEqual({ + model: data.model, + modelSelectionKind: data.modelSelectionKind, + }, { + model: undefined, + modelSelectionKind: 'default', + }); + }); + test('timeToFirstProgress is undefined when no visible progress arrives before completion', () => { setupSession(); startTurn('turn-1'); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 26f475cb9784b8..57d442dd69c15b 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -6867,6 +6867,36 @@ suite('CopilotAgent', () => { } }); + test('getModel reports the creation model while the backing is still deferred', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([]); + client.createSession = async () => new MockCopilotSession() as unknown as CopilotSession; + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const result = await provisionSession(agent, { + session: AgentSession.uri('copilotcli', 'prov-default-model'), + model: { id: 'gpt-x' }, + workingDirectories: [URI.file('/workspace')], + }); + const chat = defaultChatUri(result.session); + const context = exactChatContext(result.session, chat, result.session); + + // The first turn's telemetry reads the bound model before the + // send materializes the session, so the reserved backing must + // already carry it. + const beforeSend = agent.chats.getModel?.(chat, context); + await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, context); + + assert.deepStrictEqual({ beforeSend, afterMaterialize: agent.chats.getModel?.(chat, context) }, { + beforeSend: { id: 'gpt-x' }, + afterMaterialize: { id: 'gpt-x' }, + }); + } finally { + await disposeAgent(agent); + } + }); + test('disposeSession on provisional session does not touch SDK or worktree', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const client = new TestCopilotClient([]); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index bc8db48c3e91fd..e02b46afbb64dc 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -112,6 +112,7 @@ export class MockAgent implements IAgent { sessionMessages: IHistoryRecord[] = []; /** Usage stamped onto every reconstructed turn (e.g. an Auto-model stub). */ turnUsageOverride: UsageInfo | undefined = undefined; + chatModel: ModelSelection | undefined; /** Optional overrides applied to session metadata from listSessions. */ sessionMetadataOverrides: Partial> = {}; @@ -362,6 +363,7 @@ export class MockAgent implements IAgent { const { session } = this._resolveChatTarget(chat, context); return this.abortSession(session); }, + getModel: (): ModelSelection | undefined => this.chatModel, changeModel: (chatUri: URI, model: ModelSelection, context: URI | IAgentChatContext): Promise => { this._recordContext('changeModel', chatUri, context); const { session, chat } = this._resolveChatTarget(chatUri, context); From c89822681aa2b6dd95f6ee4e329fed7dcc23d333 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:12:28 -0700 Subject: [PATCH 07/24] agentHost: Reject unavailable session catalogs (#331510) Prevent an unavailable provider catalog from being accepted as an authoritative partial session list. Retry migration before listing and preserve the failed state so the client cache is not reconciled as session deletion. Fixes #331452. (cherry picked from commit dcc387ba7e912c3d25e43cc5b064b9fecc3427c2) Co-authored-by: Sandeep Somavarapu Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/agentHost/node/agentService.ts | 10 ++++++++-- .../agentHost/test/node/agentService.test.ts | 20 +++++++++++-------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index bab9b35df50fb1..11f8d6b7e91d8f 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -1306,12 +1306,18 @@ export class AgentService extends Disposable implements IAgentService { private async _awaitInitialProviderMigration(): Promise { const providers = [...this._providers.values()]; const results = await Promise.allSettled(providers.map(provider => this._initialProviderMigrations.get(provider.id) ?? Promise.resolve())); + const retries: Promise[] = []; for (let index = 0; index < results.length; index++) { const result = results[index]; if (result.status === 'rejected') { - this._logService.warn(`[AgentService] initial provider catalogs: provider ${providers[index].id} failed and will be retried on the next signal`, result.reason); + const provider = providers[index]; + this._logService.warn(`[AgentService] initial provider catalog for ${provider.id} was unavailable; retrying before listing sessions`, result.reason); + const retry = this._ensureLegacyChatsMigrated(provider, true); + this._initialProviderMigrations.set(provider.id, retry); + retries.push(retry); } } + await Promise.all(retries); } /** @@ -1474,7 +1480,7 @@ export class AgentService extends Disposable implements IAgentService { } const sessions = await this._enumerateLegacyProviderSessions(provider); if (sessions === undefined) { - return; + throw new Error(`Provider ${provider.id} cannot enumerate its native session catalog yet`); } const existing = new Map((await this._listRegisteredSessions()).map(session => [session.session.toString(), session.external])); const migrationLimiter = new Limiter(4); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 996217099ca470..c425b3ed923224 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -3965,14 +3965,16 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(agent.listExternalChatsCalls, 1); }); - test('a discovery signal does not bypass completed legacy migration semantics', async () => { + test('listSessions rejects an unavailable migration catalog and retries it on the next call', async () => { class NotYetMigratableAgent extends MockAgent { migrationCalls = 0; enumerable = false; } const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); const agent = disposables.add(new NotYetMigratableAgent('copilot')); const legacy = AgentSession.uri('copilot', 'legacy-migration-not-ready'); + (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(legacy), legacy); (agent as unknown as { listChatsToMigrate: () => Promise }).listChatsToMigrate = async () => { agent.migrationCalls++; return agent.enumerable @@ -3980,18 +3982,20 @@ suite('AgentService (node dispatcher)', () => { : undefined; }; svc.registerProvider(agent); - await svc.listSessions(); + await assert.rejects(svc.listSessions(), /cannot enumerate its native session catalog yet/); + const callsAfterFailure = agent.migrationCalls; agent.enumerable = true; - agent.fireDiscoveredChats([]); await timeout(0); - + const listed = await svc.listSessions(); assert.deepStrictEqual({ - migrationCalls: agent.migrationCalls, - registered: (await svc.getRegisteredSessions()).map(session => session.toString()), + retriedBeforeFailure: callsAfterFailure > 1, + retriedAfterFailure: agent.migrationCalls > callsAfterFailure, + listed: listed.map(session => session.session.toString()), }, { - migrationCalls: 1, - registered: [], + retriedBeforeFailure: true, + retriedAfterFailure: true, + listed: [legacy.toString()], }); }); From 4b9b86722e2a76afe616388f06aced03dcfc8ec9 Mon Sep 17 00:00:00 2001 From: Ben Villalobos Date: Tue, 18 Aug 2026 12:14:44 -0700 Subject: [PATCH 08/24] Use "No workspace" label for automation quick-chat target (#331495) * Use 'No workspace' label for automation quick-chat target Align the automation definition card and the non-compact quick-chat row badge with the folder picker's 'No workspace' option, so workspace-less automations read consistently across the UI. * Update Sessions list spec for 'No workspace' badge Match the SESSIONS_LIST doc to the renderer. Regular quick-chat and history rows now display 'No workspace' instead of 'Chat'. * test: cover automation card target label rendering Assert workspace targets render the folder name and non-workspace targets render 'No workspace'. * signing commit --- src/vs/sessions/SESSIONS_LIST.md | 6 +++--- .../contrib/sessions/browser/views/automationsView.ts | 2 +- .../sessions/contrib/sessions/browser/views/sessionsList.ts | 2 +- .../contrib/sessions/test/browser/sessionsList.test.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/vs/sessions/SESSIONS_LIST.md b/src/vs/sessions/SESSIONS_LIST.md index 46c1e48a82fa2e..72e164303054b3 100644 --- a/src/vs/sessions/SESSIONS_LIST.md +++ b/src/vs/sessions/SESSIONS_LIST.md @@ -34,18 +34,18 @@ Each session row displays: - **Status icon** — animated indicator for InProgress / NeedsInput / Error / Completed / Unread; unread takes precedence over completed-state glyphs such as a pull request, while quick chats never show a PR glyph (they have no GitHub PR association) and no per-row chat icon is shown either (the Chats section header, Pinned section, or custom group already conveys their identity) - **Title** — the session's display title (observable) - **Type icon** — regular workspace sessions show a folder/worktree/cloud icon indicating the workspace kind. Compact quick-chat rows omit this icon; regular quick-chat rows show the Chats icon. -- **Workspace or chat badge** — workspace sessions render their workspace label inline after the type icon. It is hidden only when a workspace section header already carries the same label; date, custom-group, Pinned, and Done rows show it unless live status temporarily hides row details. Regular quick-chat rows show `Chat` in the same position. +- **Workspace or chat badge** — workspace sessions render their workspace label inline after the type icon. It is hidden only when a workspace section header already carries the same label; date, custom-group, Pinned, and Done rows show it unless live status temporarily hides row details. Regular quick-chat rows show `No workspace` in the same position. - **Diff stats** (regular sessions only) — `+insertions −deletions` when the session has pending changes; omitted for quick chats - **Status description or timestamp** — InProgress and NeedsInput show a status message instead of a timestamp; Error shows both, and other terminal states show a relative timestamp. Compact quick-chat rows in the primary Sessions list omit this second row; automation history presents quick-chat-backed runs as regular history rows with timestamps. - **Approval row** (optional) — pending agent approvals with an "Allow" button -Compact quick-chat rows use `.session-item.quick-chat` when `useCompactQuickChatRows` is enabled (the default). Driven by the reactive `ISession.isQuickChat` observable, they are single-line entries: the details row is hidden and its content is never built, with a smaller icon and tighter row height (see `SessionsTreeDelegate.ITEM_HEIGHT_QUICK_CHAT`). When compact rendering is disabled, quick chats use the regular two-line row with a Chats icon, `Chat` badge, and status/timestamp metadata while continuing to omit workspace and diff metadata. +Compact quick-chat rows use `.session-item.quick-chat` when `useCompactQuickChatRows` is enabled (the default). Driven by the reactive `ISession.isQuickChat` observable, they are single-line entries: the details row is hidden and its content is never built, with a smaller icon and tighter row height (see `SessionsTreeDelegate.ITEM_HEIGHT_QUICK_CHAT`). When compact rendering is disabled, quick chats use the regular two-line row with a Chats icon, `No workspace` badge, and status/timestamp metadata while continuing to omit workspace and diff metadata. Continuous row animations preserve their existing appearance while limiting rendering work: the title shimmer follows the same three-second path with at most 30 visual updates per second, then rests for three seconds before repeating. Both it and the shared pixel spinner pause outside the viewport and whenever their document is hidden, while their visibility tracking survives temporary row-template detachment. Status icons cross-fade only for state changes within the same session; when virtualization rebinds a row template to another session, the new icon renders immediately so stale status is never shown. `SessionsFlatList` reuses the same session row renderer for sectionless surfaces, including the approval row and dynamic row height updates. Consumers that size their own container listen for content-height changes and relayout the list. When embedded inside another hover, consumers disable row hovers so moving over the list does not replace the parent hover. -Automation run history uses `SessionsFlatList` for runs backed by a live session. Quick-chat-backed runs use the regular two-line history-row presentation so all run entries have consistent height and status-icon sizing; their details row shows the Chats icon, a `Chat` badge in place of a workspace label, and the timestamp while continuing to omit diff metadata. Pending and running runs without a resolved session use a lightweight `Working...` row; date grouping and run actions remain owned by the Automations view. +Automation run history uses `SessionsFlatList` for runs backed by a live session. Quick-chat-backed runs use the regular two-line history-row presentation so all run entries have consistent height and status-icon sizing; their details row shows the Chats icon, a `No workspace` badge in place of a workspace label, and the timestamp while continuing to omit diff metadata. Pending and running runs without a resolved session use a lightweight `Working...` row; date grouping and run actions remain owned by the Automations view. ### Grouping diff --git a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts index c79664fd2303f1..7005541a92c7ab 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts @@ -1015,7 +1015,7 @@ function formatHourMinute(hour: number, minute: number): string { } function getAutomationTargetLabel(target: AutomationTarget): string { - return target.kind === 'workspace' ? basename(target.folderUri) : localize('quickChat', "Quick Chat"); + return target.kind === 'workspace' ? basename(target.folderUri) : localize('quickChat', "No workspace"); } function groupRunsByDate(runs: readonly IAutomationRun[]): { key: string; label: string; runs: IAutomationRun[] }[] { diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index ae58d94bb8cefd..8ee3001aa5760c 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -659,7 +659,7 @@ class SessionItemRenderer implements ITreeRenderer { isShorterThanStandardRow: false, hasCompactClass: false, hasChatIcon: true, - badge: 'Chat', + badge: 'No workspace', time: 'now', hasDiff: false, ariaLabel: 'Investigate failure, chat, updated now', From d9926423315f2ef68c13da2db5c6fbe67cf65055 Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" <122617954+vs-code-engineering[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:14:50 +0000 Subject: [PATCH 09/24] Update distro commit (main) (#331520) Update distro commit to c842171b Co-authored-by: vs-code-engineering[bot] <122617954+vs-code-engineering[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d19766a08bbd34..3d3716499032fd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.135.0", - "distro": "5475972d5042caa842cdccf21488c4d0728ca0c1", + "distro": "c842171bd42ca4aef20b0a186c94d99edd763842", "author": { "name": "Microsoft Corporation" }, From 580ac1c6eb1c64a11e6293cdfae83ed19760dee4 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 18 Aug 2026 15:56:35 -0400 Subject: [PATCH 10/24] Add active session context to voice requests (#331503) * Add active session context to voice requests Refs #329490 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8432c00e-1eae-4634-9dee-bd83a314708b * Scope voice activity flag to focused session Refs #329490 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8432c00e-1eae-4634-9dee-bd83a314708b --------- Copilot-Session: 8432c00e-1eae-4634-9dee-bd83a314708b --- .../speechToText/chatSpeechToTextService.ts | 2 +- .../browser/voiceClient/voiceClientService.ts | 5 +- .../voiceClient/voiceSessionController.ts | 26 ++++++++- .../common/voiceClient/voiceClientService.ts | 7 ++- .../voiceClient/voiceClientService.test.ts | 16 +++--- .../voiceSessionController.test.ts | 56 ++++++++++++++++++- 6 files changed, 97 insertions(+), 15 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts index b724ac4ddcdab5..0666ca22f3733b 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts @@ -965,7 +965,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo // Session is live; drop the connecting spinner so the mic reads as // recording when start() transitions to the Recording state. this._setPreparingModel(false); - this._voiceClientService.sendPttStart(this._maiTurnId); + this._voiceClientService.sendPttStart(this._maiTurnId, { hasActiveSession: false }); } /** diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts index 8c03f95c366bd8..3b871a02c4c18d 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts @@ -14,6 +14,7 @@ import { IProductService } from '../../../../../platform/product/common/productS import { IVoiceClientService, IVoicePriorTimelineEntry, + IVoicePttStartOptions, IVoiceSessionContext, IVoiceTranscription, IVoiceAudioResponse, @@ -628,9 +629,9 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic } } - sendPttStart(turnId: string, passive: boolean = false): void { + sendPttStart(turnId: string, options: IVoicePttStartOptions): void { if (this._ws?.readyState === WebSocket.OPEN) { - this._ws.send(JSON.stringify({ type: 'ptt_start', turn_id: turnId, ...(passive ? { passive: true } : {}) })); + this._ws.send(JSON.stringify({ type: 'ptt_start', turn_id: turnId, has_active_session: options.hasActiveSession, ...(options.passive ? { passive: true } : {}) })); } } diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index 44877f27f9af53..bd50902e33acbf 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -1197,7 +1197,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // Streaming PTT: send start/chunks/end as they arrive this._voiceEventDisposables.add(this.micCaptureService.onPttStart((passive) => { - this.voiceClientService.sendPttStart(this._pttCurrentTurnId, passive); + this.voiceClientService.sendPttStart(this._pttCurrentTurnId, { + hasActiveSession: this._hasSessionInProgress(), + passive, + }); })); this._voiceEventDisposables.add(this.micCaptureService.onPttAudioChunk(b64 => { this.voiceClientService.sendPttAudioChunk(b64); @@ -7367,6 +7370,27 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC }; } + private _hasSessionInProgress(): boolean { + const activeSessionId = this._getActiveSessionId(); + if (!activeSessionId) { + return false; + } + + const session = this.agentSessionsService.model.sessions.find(session => + !session.isArchived() && session.resource.toString() === activeSessionId + ); + if (session) { + const model = this.chatService.getSession(session.resource); + return session.status === AgentSessionStatus.InProgress || (model !== undefined && this._getAgentStateInfo(model).state === 'thinking'); + } + for (const model of this.chatService.chatModels.get()) { + if (model.sessionResource.toString() === activeSessionId) { + return this._getAgentStateInfo(model).state === 'thinking'; + } + } + return false; + } + private _buildSessionContext(): IVoiceSessionContext { const oneHourAgo = Date.now() - 60 * 60 * 1000; const sessions = this.agentSessionsService.model.sessions.filter(s => { diff --git a/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts b/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts index 8eb8eb8412c8bc..9eebf39766e7d8 100644 --- a/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts +++ b/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts @@ -600,6 +600,11 @@ export interface IVoiceFeedbackTranscriptTurn { readonly timestamp: string; } +export interface IVoicePttStartOptions { + readonly hasActiveSession: boolean; + readonly passive?: boolean; +} + export interface IVoiceClientService { readonly _serviceBrand: undefined; @@ -608,7 +613,7 @@ export interface IVoiceClientService { disconnect(): void; // --- Outbound messages --- - sendPttStart(turnId: string, passive?: boolean): void; + sendPttStart(turnId: string, options: IVoicePttStartOptions): void; sendPttAudioChunk(audio: string): void; sendPttEnd(): void; /** diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceClientService.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceClientService.test.ts index 4d058b46eec460..21164abc3a8bbb 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceClientService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceClientService.test.ts @@ -288,12 +288,12 @@ suite('VoiceClientService', () => { const { service } = createService(); await service.connect(createTestWindow()); - service.sendPttStart('turn-1'); + service.sendPttStart('turn-1', { hasActiveSession: false }); service.sendPttAudioChunk('cGNt'); service.sendPttEnd(); assert.deepStrictEqual(socket().sent, [ - { type: 'ptt_start', turn_id: 'turn-1' }, + { type: 'ptt_start', turn_id: 'turn-1', has_active_session: false }, { type: 'ptt_audio_chunk', audio: 'cGNt' }, { type: 'ptt_end' }, ]); @@ -488,14 +488,14 @@ suite('VoiceClientService', () => { const { service } = createService(); await service.connect(createTestWindow()); - service.sendPttStart('turn-passive', true); - service.sendPttStart('turn-real', false); - service.sendPttStart('turn-default'); + service.sendPttStart('turn-passive', { hasActiveSession: true, passive: true }); + service.sendPttStart('turn-real', { hasActiveSession: true, passive: false }); + service.sendPttStart('turn-default', { hasActiveSession: false }); assert.deepStrictEqual(socket().sent, [ - { type: 'ptt_start', turn_id: 'turn-passive', passive: true }, - { type: 'ptt_start', turn_id: 'turn-real' }, - { type: 'ptt_start', turn_id: 'turn-default' }, + { type: 'ptt_start', turn_id: 'turn-passive', has_active_session: true, passive: true }, + { type: 'ptt_start', turn_id: 'turn-real', has_active_session: true }, + { type: 'ptt_start', turn_id: 'turn-default', has_active_session: false }, ]); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts index 10ca6b497c5985..9fe5640b8108e4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts @@ -39,7 +39,7 @@ import { IVoiceToolDispatchService } from '../../../browser/voiceClient/voiceToo import { CHAT_INPUT_WINDOW_ACCEPT_VOICE_COMMAND_ID } from '../../../common/chatInputWindow.js'; import { ChatSendResult, ElicitationState, IChatConfirmation, IChatModelReference, IChatSendRequestOptions, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; -import { derivePendingId, isPendingIdResolved, IVoiceAudioResponse, IVoiceBargeIn, IVoiceCheckpointNarrationMetadata, IVoiceClientService, IVoiceDispatchResult, IVoiceFatalDisconnect, IVoiceNarrationAck, IVoiceNarrationSignal, IVoiceSessionContext, IVoiceSpeechStarted, IVoiceToolCall, IVoiceTranscription, markPendingIdResolved, peekPendingId, VoiceConfirmationType, VoiceNarrationKind, VOICE_AGENT_PROGRESS_SETTING } from '../../../common/voiceClient/voiceClientService.js'; +import { derivePendingId, isPendingIdResolved, IVoiceAudioResponse, IVoiceBargeIn, IVoiceCheckpointNarrationMetadata, IVoiceClientService, IVoiceDispatchResult, IVoiceFatalDisconnect, IVoiceNarrationAck, IVoiceNarrationSignal, IVoicePttStartOptions, IVoiceSessionContext, IVoiceSpeechStarted, IVoiceToolCall, IVoiceTranscription, markPendingIdResolved, peekPendingId, VoiceConfirmationType, VoiceNarrationKind, VOICE_AGENT_PROGRESS_SETTING } from '../../../common/voiceClient/voiceClientService.js'; import { IChatModel, IChatProgressResponseContent, IChatResponseModel } from '../../../common/model/chatModel.js'; import { ChatElicitationRequestPart } from '../../../common/model/chatProgressTypes/chatElicitationRequestPart.js'; import { ChatPlanReviewData } from '../../../common/model/chatProgressTypes/chatPlanReviewData.js'; @@ -87,6 +87,7 @@ class TestVoiceClientService extends mock() { override disconnect(): void { this.connected = false; } override async connect(): Promise { } readonly wireEvents: ({ type: 'session_context'; context: IVoiceSessionContext } | { type: 'request_narration'; kind: VoiceNarrationKind; text: string; confirmationType?: VoiceConfirmationType })[] = []; + readonly pttStarts: { turnId: string; hasActiveSession: boolean; passive: boolean }[] = []; pttEndCalls = 0; private pendingContext: IVoiceSessionContext | undefined; override sendSessionContext(context: IVoiceSessionContext): void { @@ -129,6 +130,9 @@ class TestVoiceClientService extends mock() { override sendPttEnd(): void { this.pttEndCalls++; } + override sendPttStart(turnId: string, options: IVoicePttStartOptions): void { + this.pttStarts.push({ turnId, hasActiveSession: options.hasActiveSession, passive: options.passive ?? false }); + } fireAudioResponse(event: IVoiceAudioResponse): void { this.audioResponseEmitter.fire(event); @@ -324,7 +328,8 @@ class DeferredFirstTtsPlaybackService extends TestTtsPlaybackService { } class TestMicCaptureService extends mock() { - override readonly onPttStart = Event.None; + private readonly pttStartEmitter = new Emitter(); + override readonly onPttStart = this.pttStartEmitter.event; override readonly onPttAudioChunk = Event.None; override readonly onPttEnd = Event.None; override readonly onPttDiagnostic = Event.None; @@ -341,6 +346,13 @@ class TestMicCaptureService extends mock() { } override pttUp(): void { } override abortPtt(): void { } + dispose(): void { + this.pttStartEmitter.dispose(); + } + + firePttStart(passive: boolean): void { + this.pttStartEmitter.fire(passive); + } } class TestAgentSessionsService extends mock() { @@ -707,6 +719,9 @@ suite('VoiceSessionController', () => { ): VoiceSessionController { store.add({ dispose: () => voiceClientService.dispose() }); store.add(ttsPlaybackService); + if (micCaptureService instanceof TestMicCaptureService) { + store.add(micCaptureService); + } return store.add(new VoiceSessionController( voiceClientService, micCaptureService, @@ -772,6 +787,43 @@ suite('VoiceSessionController', () => { return { changeEmitter, parts, response: state as unknown as IChatResponseModel, state }; } + test('reports whether a coding session is in progress when each voice request starts', async () => { + const voiceClientService = new TestVoiceClientService(); + const micCaptureService = new TestMicCaptureService(); + const focusedSession = agentSessionEntry('vscode-chat://focused', 'Focused session', AgentSessionStatus.Completed); + const backgroundSession = agentSessionEntry('vscode-chat://background', 'Background session', AgentSessionStatus.InProgress); + const loadedModel = new class extends mock() { }; + const chatService = new class extends TestChatService { + override getSession(): IChatModel | undefined { + return focusedSession.status === AgentSessionStatus.InProgress ? loadedModel : undefined; + } + }; + const controller = createController( + voiceClientService, + undefined, + undefined, + undefined, + micCaptureService, + undefined, + chatService, + undefined, + new TestAgentSessionsService([focusedSession, backgroundSession]), + ); + await connectWithOmniOpen(controller, voiceClientService); + controller.setActiveSessionShown(focusedSession.resource); + + controller['_pttCurrentTurnId'] = 'turn-idle'; + micCaptureService.firePttStart(false); + focusedSession.status = AgentSessionStatus.InProgress; + controller['_pttCurrentTurnId'] = 'turn-active'; + micCaptureService.firePttStart(true); + + assert.deepStrictEqual(voiceClientService.pttStarts, [ + { turnId: 'turn-idle', hasActiveSession: false, passive: false }, + { turnId: 'turn-active', hasActiveSession: true, passive: true }, + ]); + }); + test('does not connect without a paid Copilot entitlement', async () => { const voiceClientService = new TestVoiceClientService(); const notificationService = new VoiceTestNotificationService(); From 5484a62f970a74bc32ceb22c3462cb9868a537f5 Mon Sep 17 00:00:00 2001 From: roblourens Date: Tue, 18 Aug 2026 13:07:01 -0700 Subject: [PATCH 11/24] Chat: Hide debug log export without an active session (#331523) * chat: hide debug log export without active session (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: reset debug context keys on dispose Address review feedback by tracking test context-key services and resetting global debug context keys when the service is disposed. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../actions/chatOpenAgentDebugPanelAction.ts | 16 +++++-- .../chat/browser/chatDebug/chatDebugEditor.ts | 8 +--- .../chat/browser/chatDebug/chatDebugTypes.ts | 1 - .../contrib/chat/common/chatDebugService.ts | 4 ++ .../chat/common/chatDebugServiceImpl.ts | 24 +++++++++- .../chatEditing/chatEditingService.test.ts | 4 +- .../browser/promptsDebugContribution.test.ts | 4 +- .../test/common/chatDebugServiceImpl.test.ts | 47 ++++++++++++++++++- .../common/chatService/chatService.test.ts | 5 +- 9 files changed, 94 insertions(+), 19 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts index add1ab29ea3b63..c94eab4f2aed1c 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts @@ -20,12 +20,12 @@ import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { isChatViewTitleActionContext } from '../../common/actions/chatActions.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IChatDebugService } from '../../common/chatDebugService.js'; +import { CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST, CHAT_DEBUG_HAS_ACTIVE_SESSION, IChatDebugService } from '../../common/chatDebugService.js'; import { ChatViewId, IChatWidgetService } from '../chat.js'; import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from './chatActions.js'; import { ChatDebugEditorInput } from '../chatDebug/chatDebugEditorInput.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { IChatDebugEditorOptions, CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST } from '../chatDebug/chatDebugTypes.js'; +import { IChatDebugEditorOptions } from '../chatDebug/chatDebugTypes.js'; import { LocalChatSessionUri } from '../../common/model/chatUri.js'; /** @@ -119,11 +119,19 @@ export function registerChatOpenAgentDebugPanelAction() { icon: Codicon.chatExport, f1: true, category: Categories.Developer, - precondition: ChatContextKeys.enabled, + precondition: ContextKeyExpr.and( + ChatContextKeys.enabled, + CHAT_DEBUG_HAS_ACTIVE_SESSION, + CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST.negate(), + ), menu: [{ id: MenuId.EditorTitle, group: 'navigation', - when: ContextKeyExpr.and(ActiveEditorContext.isEqualTo(ChatDebugEditorInput.ID), CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST.negate()), + when: ContextKeyExpr.and( + ActiveEditorContext.isEqualTo(ChatDebugEditorInput.ID), + CHAT_DEBUG_HAS_ACTIVE_SESSION, + CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST.negate(), + ), order: 10 }], }); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts index 9d86ade840fa9d..27aed8258b486c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts @@ -11,7 +11,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { DisposableMap, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { AgentHostAhpJsonlLoggingSettingId } from '../../../../../platform/agentHost/common/agentService.js'; -import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IStorageService } from '../../../../../platform/storage/common/storage.js'; @@ -27,7 +27,7 @@ import { IChatDebugService } from '../../common/chatDebugService.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { AgentHostAgentDebugLogEnabledSettingId, AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING } from '../../common/promptSyntax/promptTypes.js'; import { IChatWidgetService } from '../chat.js'; -import { ViewState, IChatDebugEditorOptions, CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST } from './chatDebugTypes.js'; +import { ViewState, IChatDebugEditorOptions } from './chatDebugTypes.js'; import { ChatDebugFilterState, registerFilterMenuItems } from './chatDebugFilters.js'; import { isAgentHostSession } from './agentHostLogSources.js'; import { isChatDebugLoggingEnabledForSession, isWireLogLoggingEnabled, renderChatDebugLoggingDisabledMessage, renderWireLogLoggingDisabledMessage } from './chatDebugEnablement.js'; @@ -73,7 +73,6 @@ export class ChatDebugEditor extends EditorPane { private filterState: ChatDebugFilterState | undefined; private _scopedContextKeyService: IContextKeyService | undefined; - private _activeSessionIsAgentHostContextKey: IContextKey | undefined; /** * Shared overlay shown in place of a session sub-view (Logs, Flow Chart, @@ -100,7 +99,6 @@ export class ChatDebugEditor extends EditorPane { this.chatDebugService.endSession(sessionResource); } this.chatDebugService.activeSessionResource = undefined; - this._activeSessionIsAgentHostContextKey?.set(false); } constructor( @@ -126,7 +124,6 @@ export class ChatDebugEditor extends EditorPane { this.filterState = this._register(new ChatDebugFilterState()); const scopedContextKeyService = this._register(this.contextKeyService.createScoped(this.container)); this._scopedContextKeyService = scopedContextKeyService; - this._activeSessionIsAgentHostContextKey = CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST.bindTo(scopedContextKeyService); this._register(registerFilterMenuItems(this.filterState, scopedContextKeyService)); // Create sub-views via DI @@ -375,7 +372,6 @@ export class ChatDebugEditor extends EditorPane { } this.chatDebugService.activeSessionResource = sessionResource; - this._activeSessionIsAgentHostContextKey?.set(isAgentHostSession(sessionResource)); if (!this.chatDebugService.hasInvokedProviders(sessionResource)) { this.chatDebugService.invokeProviders(sessionResource); } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts index 8c534c5a025f09..0bddeae1807ef0 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts @@ -38,7 +38,6 @@ export const enum LogsViewMode { } export const CHAT_DEBUG_FILTER_ACTIVE = new RawContextKey('chatDebugFilterActive', false); -export const CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST = new RawContextKey('chatDebug.activeSessionIsAgentHost', false); export const CHAT_DEBUG_KIND_TOOL_CALL = new RawContextKey('chatDebug.kindToolCall', true); export const CHAT_DEBUG_KIND_MODEL_TURN = new RawContextKey('chatDebug.kindModelTurn', true); export const CHAT_DEBUG_KIND_PROMPT_DISCOVERY = new RawContextKey('chatDebug.kindPromptDiscovery', true); diff --git a/src/vs/workbench/contrib/chat/common/chatDebugService.ts b/src/vs/workbench/contrib/chat/common/chatDebugService.ts index 705c5300c9f767..797a594e46900f 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugService.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugService.ts @@ -6,9 +6,13 @@ import { Event } from '../../../../base/common/event.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; +import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; +export const CHAT_DEBUG_HAS_ACTIVE_SESSION = new RawContextKey('chatDebug.hasActiveSession', false); +export const CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST = new RawContextKey('chatDebug.activeSessionIsAgentHost', false); + /** * The severity level of a chat debug log event. */ diff --git a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts index b99e65f0827df4..dd5309060b4d70 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts @@ -11,10 +11,11 @@ import { Disposable, IDisposable, toDisposable } from '../../../../base/common/l import { ResourceMap } from '../../../../base/common/map.js'; import { extUri } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; -import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugLogProvider, IChatDebugResolvedEventContent, IChatDebugService } from './chatDebugService.js'; +import { CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST, CHAT_DEBUG_HAS_ACTIVE_SESSION, ChatDebugLogLevel, IChatDebugEvent, IChatDebugLogProvider, IChatDebugResolvedEventContent, IChatDebugService } from './chatDebugService.js'; import { isAgentHostTarget, localChatSessionType } from './chatSessionsService.js'; import { getChatSessionType } from './model/chatUri.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { AgentHostAgentDebugLogMaxEventsSettingId } from './promptSyntax/promptTypes.js'; /** @@ -140,12 +141,31 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic /** Human-readable titles for imported sessions. */ private readonly _importedSessionTitles = new ResourceMap(); - activeSessionResource: URI | undefined; + private readonly _hasActiveSessionContextKey: IContextKey; + private readonly _activeSessionIsAgentHostContextKey: IContextKey; + private _activeSessionResource: URI | undefined; + + get activeSessionResource(): URI | undefined { + return this._activeSessionResource; + } + + set activeSessionResource(value: URI | undefined) { + this._activeSessionResource = value; + this._hasActiveSessionContextKey.set(value !== undefined); + this._activeSessionIsAgentHostContextKey.set(value ? isAgentHostTarget(getChatSessionType(value)) : false); + } constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, ) { super(); + this._hasActiveSessionContextKey = CHAT_DEBUG_HAS_ACTIVE_SESSION.bindTo(contextKeyService); + this._activeSessionIsAgentHostContextKey = CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST.bindTo(contextKeyService); + this._register(toDisposable(() => { + this._hasActiveSessionContextKey.reset(); + this._activeSessionIsAgentHostContextKey.reset(); + })); } /** Priority for deduplicating events with the same ID: lower = richer. */ diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts index 698e76d6bdf655..ccf26a7421e416 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts @@ -22,6 +22,7 @@ import { IModelService } from '../../../../../../editor/common/services/model.js import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; import { SyncDescriptor } from '../../../../../../platform/instantiation/common/descriptors.js'; import { ServiceCollection } from '../../../../../../platform/instantiation/common/serviceCollection.js'; +import { MockContextKeyService } from '../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { IWorkbenchAssignmentService } from '../../../../../services/assignment/common/assignmentService.js'; import { NullWorkbenchAssignmentService } from '../../../../../services/assignment/test/common/nullAssignmentService.js'; import { nullExtensionDescription } from '../../../../../services/extensions/common/extensions.js'; @@ -92,7 +93,8 @@ suite('ChatEditingService', function () { collection.set(IMcpService, new TestMcpService()); collection.set(IPromptsService, new MockPromptsService()); collection.set(ILanguageModelsService, new SyncDescriptor(NullLanguageModelsService)); - collection.set(IChatDebugService, new ChatDebugServiceImpl(new TestConfigurationService())); + const contextKeyService = store.add(new MockContextKeyService()); + collection.set(IChatDebugService, store.add(new ChatDebugServiceImpl(new TestConfigurationService(), contextKeyService))); collection.set(IMultiDiffSourceResolverService, new class extends mock() { override registerResolver(_resolver: IMultiDiffSourceResolver): IDisposable { return Disposable.None; diff --git a/src/vs/workbench/contrib/chat/test/browser/promptsDebugContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptsDebugContribution.test.ts index 0cea5ffaf04f83..4fa0cd523b010a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptsDebugContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptsDebugContribution.test.ts @@ -9,6 +9,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugGenericEvent, IChatDebugService } from '../../common/chatDebugService.js'; import { ChatDebugServiceImpl } from '../../common/chatDebugServiceImpl.js'; import { LocalChatSessionUri } from '../../common/model/chatUri.js'; @@ -47,7 +48,8 @@ suite('PromptsDebugContribution', () => { setup(() => { instaService = disposables.add(new TestInstantiationService()); - chatDebugService = disposables.add(new ChatDebugServiceImpl(new TestConfigurationService())); + const contextKeyService = disposables.add(new MockContextKeyService()); + chatDebugService = disposables.add(new ChatDebugServiceImpl(new TestConfigurationService(), contextKeyService)); instaService.stub(IChatDebugService, chatDebugService); willInvokeAgentEmitter = disposables.add(new Emitter()); diff --git a/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts b/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts index 76d2d0e0ad5c7b..a8a3c1960cb9a6 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts @@ -9,7 +9,8 @@ import { errorHandler } from '../../../../../base/common/errors.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugGenericEvent, IChatDebugLogProvider, IChatDebugModelTurnEvent, IChatDebugResolvedEventContent, IChatDebugToolCallEvent } from '../../common/chatDebugService.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST, CHAT_DEBUG_HAS_ACTIVE_SESSION, ChatDebugLogLevel, IChatDebugEvent, IChatDebugGenericEvent, IChatDebugLogProvider, IChatDebugModelTurnEvent, IChatDebugResolvedEventContent, IChatDebugToolCallEvent } from '../../common/chatDebugService.js'; import { ChatDebugServiceImpl } from '../../common/chatDebugServiceImpl.js'; import { LocalChatSessionUri } from '../../common/model/chatUri.js'; @@ -17,6 +18,7 @@ suite('ChatDebugServiceImpl', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); let service: ChatDebugServiceImpl; + let contextKeyService: MockContextKeyService; const session1 = URI.parse('vscode-chat-session://local/session-1'); const session2 = URI.parse('vscode-chat-session://local/session-2'); @@ -25,9 +27,50 @@ suite('ChatDebugServiceImpl', () => { const sessionGeneric = URI.parse('vscode-chat-session://local/session'); const nonLocalSession = URI.parse('some-other-scheme://authority/session-1'); const copilotCliSession = URI.parse('copilotcli:/test-session-id'); + const agentHostSession = URI.parse('agent-host-copilotcli:/test-session-id'); setup(() => { - service = disposables.add(new ChatDebugServiceImpl(new TestConfigurationService())); + contextKeyService = disposables.add(new MockContextKeyService()); + service = disposables.add(new ChatDebugServiceImpl(new TestConfigurationService(), contextKeyService)); + }); + + test('updates the active session context key', () => { + const states = [[ + contextKeyService.getContextKeyValue(CHAT_DEBUG_HAS_ACTIVE_SESSION.key), + contextKeyService.getContextKeyValue(CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST.key), + ]]; + service.activeSessionResource = session1; + states.push([ + contextKeyService.getContextKeyValue(CHAT_DEBUG_HAS_ACTIVE_SESSION.key), + contextKeyService.getContextKeyValue(CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST.key), + ]); + service.activeSessionResource = agentHostSession; + states.push([ + contextKeyService.getContextKeyValue(CHAT_DEBUG_HAS_ACTIVE_SESSION.key), + contextKeyService.getContextKeyValue(CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST.key), + ]); + service.activeSessionResource = undefined; + states.push([ + contextKeyService.getContextKeyValue(CHAT_DEBUG_HAS_ACTIVE_SESSION.key), + contextKeyService.getContextKeyValue(CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST.key), + ]); + + assert.deepStrictEqual(states, [ + [false, false], + [true, false], + [true, true], + [false, false], + ]); + }); + + test('resets the active session context keys on dispose', () => { + service.activeSessionResource = agentHostSession; + service.dispose(); + + assert.deepStrictEqual([ + contextKeyService.getContextKeyValue(CHAT_DEBUG_HAS_ACTIVE_SESSION.key), + contextKeyService.getContextKeyValue(CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST.key), + ], [false, false]); }); suite('addEvent and getEvents', () => { diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 59934e561e3e62..8949146a416b59 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -189,7 +189,8 @@ suite('ChatService', () => { instantiationService.stub(IUserDataProfilesService, { defaultProfile: toUserDataProfile('default', 'Default', URI.file('/test/userdata'), URI.file('/test/cache')) }); instantiationService.stub(ITelemetryService, NullTelemetryService); instantiationService.stub(IExtensionService, new TestExtensionService()); - instantiationService.stub(IContextKeyService, new MockContextKeyService()); + const contextKeyService = testDisposables.add(new MockContextKeyService()); + instantiationService.stub(IContextKeyService, contextKeyService); instantiationService.stub(IViewsService, new TestExtensionService()); instantiationService.stub(IWorkspaceContextService, new TestContextService()); instantiationService.stub(IChatSlashCommandService, testDisposables.add(instantiationService.createInstance(ChatSlashCommandService))); @@ -200,7 +201,7 @@ suite('ChatService', () => { instantiationService.stub(IEnvironmentService, { workspaceStorageHome: URI.file('/test/path/to/workspaceStorage') }); instantiationService.stub(ILifecycleService, { onWillShutdown: Event.None }); instantiationService.stub(IWorkspaceEditingService, { onDidEnterWorkspace: Event.None }); - instantiationService.stub(IChatDebugService, testDisposables.add(new ChatDebugServiceImpl(new TestConfigurationService()))); + instantiationService.stub(IChatDebugService, testDisposables.add(new ChatDebugServiceImpl(new TestConfigurationService(), contextKeyService))); editingSessionEntries = observableValue('editingSessionEntries', []); instantiationService.stub(IChatEditingService, new class extends mock() { override startOrContinueGlobalEditingSession(): IChatEditingSession { From 04e0eae362506812d6abbb9b4b32a7f53c75c264 Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Tue, 18 Aug 2026 16:21:13 -0400 Subject: [PATCH 12/24] Support info message and fix deprecations (#331505) * Support info message and fix deprecations * Cleanup exception code * Announce model picker notices to screen readers Addresses PR review feedback: - Fold the hover's warning and info banners into the model row's ariaDescription, stripped of markdown and prefixed with severity. - Use model_relocated for the neutral infoText examples, since model_pending_deprecation maps to a warning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/languageModelAccess.ts | 19 +++++++- .../vscode-node/languageModelAccess.ts | 21 ++++----- .../test/languageModelWarnings.spec.ts | 41 ++++++++++++++++ .../platform/endpoint/node/chatEndpoint.ts | 24 +++++++++- .../node/test/copilotChatEndpoint.spec.ts | 47 +++++++++++++++++++ .../platform/networking/common/networking.ts | 6 +++ .../api/common/extHostLanguageModels.ts | 1 + .../input/modelPicker/media/modelPicker.css | 3 ++ .../input/modelPicker/modelPickerHover.ts | 40 +++++++++------- .../modelPicker/modelPickerItemPrimitives.ts | 29 +++++++++++- .../contrib/chat/common/languageModels.ts | 6 +++ .../modelPicker/modelPickerHover.test.ts | 21 +++++++++ .../modelPicker/modelPickerItems.test.ts | 23 +++++++++ .../modelPicker/modelProviderIcons.test.ts | 5 +- .../vscode.proposed.chatProvider.d.ts | 7 +++ 15 files changed, 260 insertions(+), 33 deletions(-) create mode 100644 extensions/copilot/src/extension/conversation/vscode-node/test/languageModelWarnings.spec.ts diff --git a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts index 9f1c87c87674d8..819aac28463afb 100644 --- a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ -import { IChatEndpoint, IChatEndpointTokenPricing } from '../../../platform/networking/common/networking'; +import { IChatEndpoint, IChatEndpointTokenPricing, PENDING_DEPRECATION_CODE } from '../../../platform/networking/common/networking'; import * as l10n from '@vscode/l10n'; import type { LanguageModelChatInformation, LanguageModelConfigurationSchema } from 'vscode'; @@ -129,6 +129,23 @@ export function buildAutoModeTierSchemaProperty(tiers: readonly string[], defaul }; } +/** + * Resolves the model picker's warning presentation. All warnings show as hover banners, + * but only a degradation or a pending deprecation flags the row, and `rowWarning` is the + * message explaining it. Callers must skip the synthetic Auto model, which wraps another + * endpoint and must not inherit its warnings. + */ +export function resolveModelWarnings(endpoint: Pick): { texts: Record; rowWarning: string | undefined } | undefined { + const texts: Record = { ...endpoint.warningText }; + if (endpoint.degradationReason) { + texts['degradation'] = endpoint.degradationReason; + } + if (Object.keys(texts).length === 0) { + return undefined; + } + return { texts, rowWarning: endpoint.degradationReason ?? texts[PENDING_DEPRECATION_CODE] }; +} + /** * Returns a description of the model's capabilities and intended use cases. * This is shown in the rich hover when selecting models. diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index cb04669e20b504..8b037c1cbcc448 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -45,7 +45,7 @@ import { IExtensionContribution } from '../../common/contributions'; import { PromptRenderer } from '../../prompts/node/base/promptRenderer'; import { isImageDataPart } from '../common/languageModelChatMessageHelpers'; import { LanguageModelAccessPrompt } from './languageModelAccessPrompt'; -import { formatPricingLabel, formatTokenCount, getAutoModelDescription, getAutoModelDiscountLabel, getModelCapabilitiesDescription, buildReasoningEffortSchemaProperty, buildAutoModeTierSchemaProperty } from '../common/languageModelAccess'; +import { formatPricingLabel, formatTokenCount, getAutoModelDescription, getAutoModelDiscountLabel, getModelCapabilitiesDescription, resolveModelWarnings, buildReasoningEffortSchemaProperty, buildAutoModeTierSchemaProperty } from '../common/languageModelAccess'; /** * Builds a configurationSchema for the model picker based on the endpoint's supported capabilities. @@ -339,9 +339,13 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib const sanitizedModelName = endpoint.name .replace(/\([^)]*\bcontext\)/gi, '') .trim(); + + // Auto wraps another endpoint, so it must not inherit that model's warnings. + const warnings = endpoint instanceof AutoChatEndpoint ? undefined : resolveModelWarnings(endpoint); + let modelTooltip: string | undefined; - if (endpoint.degradationReason) { - modelTooltip = endpoint.degradationReason; + if (warnings?.rowWarning) { + modelTooltip = warnings.rowWarning; } else if (endpoint instanceof AutoChatEndpoint) { modelTooltip = getAutoModelDescription(endpoint.discountRange); } else { @@ -384,7 +388,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib priceCategory: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.priceCategory, category: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.modelPickerCategory, detail: modelDetail, - statusIcon: endpoint.degradationReason ? new vscode.ThemeIcon('warning') : undefined, + statusIcon: warnings?.rowWarning ? new vscode.ThemeIcon('warning') : undefined, version: endpoint.version, maxInputTokens: endpoint.modelMaxPromptTokens - baseCount - BaseTokensPerCompletion, maxOutputTokens: endpoint.maxOutputTokens, @@ -396,13 +400,8 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib [ApiChatLocation.Editor]: endpoint instanceof AutoChatEndpoint, // inline chat gets 'Auto' by default }, isUserSelectable: endpoint.showInModelPicker, - warningText: endpoint instanceof AutoChatEndpoint ? undefined : (() => { - const texts: Record = { ...endpoint.warningText }; - if (endpoint.degradationReason) { - texts['degradation'] = endpoint.degradationReason; - } - return Object.keys(texts).length > 0 ? texts : undefined; - })(), + warningText: warnings?.texts, + infoText: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.infoText, promo: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.promo, capabilities: { imageInput: endpoint instanceof AutoChatEndpoint ? true : endpoint.supportsVision, diff --git a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelWarnings.spec.ts b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelWarnings.spec.ts new file mode 100644 index 00000000000000..9a1f96697d4d66 --- /dev/null +++ b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelWarnings.spec.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { resolveModelWarnings } from '../../common/languageModelAccess'; + +const DEPRECATION = 'Claude Sonnet 4.6 has a planned deprecation date of 2026-09-01.'; +const DEGRADATION = 'This model is currently degraded.'; +const RETENTION = 'Prompts are retained for 30 days.'; + +describe('resolveModelWarnings', () => { + it('flags a pending deprecation even though it arrives without a degradation', () => { + expect(resolveModelWarnings({ warningText: { model_pending_deprecation: DEPRECATION } })).toEqual({ + texts: { model_pending_deprecation: DEPRECATION }, + rowWarning: DEPRECATION, + }); + }); + + it('shows a banner-only warning without flagging the row', () => { + expect(resolveModelWarnings({ warningText: { data_retention: RETENTION } })).toEqual({ + texts: { data_retention: RETENTION }, + rowWarning: undefined, + }); + }); + + it('lets a degradation explain the model even when other warnings are present', () => { + expect(resolveModelWarnings({ + warningText: { data_retention: RETENTION }, + degradationReason: DEGRADATION, + })).toEqual({ + texts: { data_retention: RETENTION, degradation: DEGRADATION }, + rowWarning: DEGRADATION, + }); + }); + + it('has no warning presentation when the model carries no warnings', () => { + expect(resolveModelWarnings({})).toBeUndefined(); + }); +}); diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index b791013fedcee5..75f3bd7f5d28c8 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -19,7 +19,7 @@ import { ILogService } from '../../log/common/logService'; import { isAnthropicContextEditingEnabled, isExtendedCacheTtlEnabled } from '../../networking/common/anthropic'; import { FinishedCallback, getRequestId, ICopilotToolCall, OptionalChatRequestParams } from '../../networking/common/fetch'; import { IFetcherService, Response } from '../../networking/common/fetcherService'; -import { createCapiRequestBody, IChatEndpoint, IChatEndpointTokenPricing, ICreateEndpointBodyOptions, IEndpointBody, IMakeChatRequestOptions, InteractionTypeOverride } from '../../networking/common/networking'; +import { createCapiRequestBody, IChatEndpoint, IChatEndpointTokenPricing, ICreateEndpointBodyOptions, IEndpointBody, IMakeChatRequestOptions, InteractionTypeOverride, PENDING_DEPRECATION_CODE } from '../../networking/common/networking'; import { CAPIChatMessage, ChatCompletion, FinishedCompletionReason, RawMessageConversionCallback } from '../../networking/common/openai'; import { prepareChatCompletionForReturn } from '../../networking/node/chatStream'; import { IChatWebSocketManager } from '../../networking/node/chatWebSocketManager'; @@ -152,6 +152,23 @@ export async function defaultNonStreamChatResponseProcessor(response: Response, return AsyncIterableObject.fromArray(completions); } +/** Splits CAPI `info_messages` into warning and info banners keyed by their code. */ +function splitInfoMessages(infoMessages: { code: string; message: string }[] | undefined): { warningText: Record; infoText: Record } { + const warningText: Record = {}; + const infoText: Record = {}; + for (const { code, message } of infoMessages ?? []) { + if (message) { + const target = code === PENDING_DEPRECATION_CODE ? warningText : infoText; + target[code || 'info'] = message; + } + } + return { warningText, infoText }; +} + +function undefinedIfEmpty(record: Record): Record | undefined { + return Object.keys(record).length > 0 ? record : undefined; +} + export class ChatEndpoint implements IChatEndpoint { private readonly _maxTokens: number; private readonly _maxOutputTokens: number; @@ -182,6 +199,7 @@ export class ChatEndpoint implements IChatEndpoint { public readonly customModel?: CustomModel | undefined; public readonly maxPromptImages?: number | undefined; public readonly warningText?: Record | undefined; + public readonly infoText?: Record | undefined; public readonly promo?: { id: string; discountPercent: number; endsAt?: string; message: string } | undefined; private readonly _supportsStreaming: boolean; @@ -233,7 +251,9 @@ export class ChatEndpoint implements IChatEndpoint { this._supportsStreaming = !!modelMetadata.capabilities.supports.streaming; this.customModel = modelMetadata.custom_model; this.maxPromptImages = modelMetadata.capabilities.limits?.vision?.max_prompt_images; - this.warningText = modelMetadata.warning_text; + const infoMessages = splitInfoMessages(modelMetadata.info_messages); + this.warningText = undefinedIfEmpty({ ...modelMetadata.warning_text, ...infoMessages.warningText }); + this.infoText = undefinedIfEmpty(infoMessages.infoText); this.promo = modelMetadata.billing?.promo ? { id: modelMetadata.billing.promo.id, discountPercent: modelMetadata.billing.promo.discount_percent, diff --git a/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts index b77b936c6dc4cf..44c6f8ce013197 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts @@ -664,3 +664,50 @@ describe('ChatEndpoint - CAPI reasoning effort', () => { expect(body.reasoning_effort).toBeUndefined(); }); }); + +describe('ChatEndpoint - model picker notices', () => { + let mockServices: ReturnType; + + beforeEach(() => { + mockServices = createMockServices(); + }); + + const createEndpoint = (metadata: IChatModelInformation) => + new ChatEndpoint( + metadata, + mockServices.domainService, + mockServices.chatMLFetcher, + mockServices.tokenizerProvider, + mockServices.instantiationService, + mockServices.configurationService, + mockServices.expService, + mockServices.chatWebSocketService, + mockServices.logService + ); + + it('shows a pending deprecation as a warning and other info messages as info', () => { + const endpoint = createEndpoint({ + ...createNonAnthropicModelMetadata('gpt-4.1'), + warning_text: { data_retention: 'Prompts are retained for 30 days.' }, + warning_messages: [{ code: 'model_degraded', message: 'GPT-4.1 is currently degraded.' }], + info_messages: [ + { code: 'model_pending_deprecation', message: 'GPT-4.1 has a planned deprecation date of 2026-06-01.' }, + { code: 'model_relocated', message: 'GPT-4.1 now serves from a new region.' }, + ], + }); + + expect({ warningText: endpoint.warningText, infoText: endpoint.infoText, degradationReason: endpoint.degradationReason }).toEqual({ + warningText: { + data_retention: 'Prompts are retained for 30 days.', + model_pending_deprecation: 'GPT-4.1 has a planned deprecation date of 2026-06-01.', + }, + infoText: { model_relocated: 'GPT-4.1 now serves from a new region.' }, + degradationReason: 'GPT-4.1 is currently degraded.', + }); + }); + + it('has no notices when CAPI sends none', () => { + const endpoint = createEndpoint({ ...createNonAnthropicModelMetadata('gpt-4.1'), info_messages: [] }); + expect({ warningText: endpoint.warningText, infoText: endpoint.infoText }).toEqual({ warningText: undefined, infoText: undefined }); + }); +}); diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts index 9bd8936fe37157..f3384ef4e72fa1 100644 --- a/extensions/copilot/src/platform/networking/common/networking.ts +++ b/extensions/copilot/src/platform/networking/common/networking.ts @@ -321,6 +321,9 @@ export interface IChatEndpointTokenPricing { readonly longContext?: ITokenPriceTier; } +/** CAPI notice code that shows as a warning banner and also flags the model picker row. */ +export const PENDING_DEPRECATION_CODE = 'model_pending_deprecation'; + export interface IChatEndpoint extends IEndpoint { readonly maxOutputTokens: number; /** The model ID- this may change and will be `copilot-utility` for the utility (fallback) model. Use `family` to switch behavior based on model type. */ @@ -341,7 +344,10 @@ export interface IChatEndpoint extends IEndpoint { readonly showInModelPicker: boolean; readonly isPremium?: boolean; readonly degradationReason?: string; + /** Category-keyed warning banners for the model picker. */ readonly warningText?: Record; + /** Category-keyed info banners for the model picker. Unlike {@link warningText} these never signal a problem. */ + readonly infoText?: Record; readonly promo?: { id: string; discountPercent: number; endsAt?: string; message: string }; readonly multiplier?: number; readonly restrictedToSkus?: string[]; diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index db5b0006326ef8..99af8667831d50 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -245,6 +245,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { targetChatSessionType: m.targetChatSessionType, configurationSchema: m.configurationSchema as IJSONSchema | undefined, warningText: m.warningText, + infoText: m.infoText, promo: m.promo, capabilities: m.capabilities ? { vision: m.capabilities.imageInput, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css index 0b1839410f4583..703060386aeee6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css @@ -139,6 +139,7 @@ } .chat-model-hover-warning-text, +.chat-model-hover-info-text, .chat-model-hover-promo-text { display: flex; gap: 6px; @@ -154,12 +155,14 @@ color: var(--vscode-notificationsWarningIcon-foreground); } +.chat-model-hover-info-text > .codicon, .chat-model-hover-promo-text > .codicon { flex-shrink: 0; color: var(--vscode-notificationsInfoIcon-foreground); } .chat-model-hover-warning-text p, +.chat-model-hover-info-text p, .chat-model-hover-promo-text p, .chat-model-hover-description p { margin: 0; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts index 96225a82357e5b..21a1543265f5a2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts @@ -13,6 +13,7 @@ import { Codicon } from '../../../../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; import { formatTokenCount } from '../../../../../../../base/common/numbers.js'; +import { ThemeIcon } from '../../../../../../../base/common/themables.js'; import { localize } from '../../../../../../../nls.js'; import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; import { defaultButtonStyles } from '../../../../../../../platform/theme/browser/defaultStyles.js'; @@ -64,28 +65,20 @@ export function getModelHoverContent( if (!isAuto && model.metadata.warningText) { for (const message of Object.values(model.metadata.warningText)) { - const warningContainer = dom.$('.chat-model-hover-warning-text'); - warningContainer.appendChild(renderIcon(Codicon.warning)); - const warningMd = new MarkdownString(message, { isTrusted: false, supportThemeIcons: true }); - const rendered = disposables.add(renderMarkdown(warningMd, { - actionHandler: link => { void openerService.open(link, { allowCommands: false, fromUserGesture: true }); }, - })); - warningContainer.appendChild(rendered.element); - container.appendChild(warningContainer); + container.appendChild(createMessageBanner(message, 'chat-model-hover-warning-text', Codicon.warning, disposables, openerService)); + } + } + + if (!isAuto && model.metadata.infoText) { + for (const message of Object.values(model.metadata.infoText)) { + container.appendChild(createMessageBanner(message, 'chat-model-hover-info-text', Codicon.info, disposables, openerService)); } } if (promo) { - const promoContainer = dom.$('.chat-model-hover-promo-text'); - promoContainer.appendChild(renderIcon(Codicon.info)); const endsAtLabel = ILanguageModelChatMetadata.getPromoEndsAtLabel(promo.endsAt); const promoMessage = endsAtLabel ? promo.message + ' ' + endsAtLabel : promo.message; - const promoMd = new MarkdownString(promoMessage, { isTrusted: false, supportThemeIcons: true }); - const rendered = disposables.add(renderMarkdown(promoMd, { - actionHandler: link => { void openerService.open(link, { allowCommands: false, fromUserGesture: true }); }, - })); - promoContainer.appendChild(rendered.element); - container.appendChild(promoContainer); + container.appendChild(createMessageBanner(promoMessage, 'chat-model-hover-promo-text', Codicon.info, disposables, openerService)); } let costInfoRendered = false; @@ -203,6 +196,21 @@ export function getModelHoverContent( return container.children.length > 0 ? { element: container, disposable: disposables } : undefined; } +/** + * Builds one bordered message banner (an icon plus a rendered markdown message) + * for the warning, info and promo notices shown at the top of the hover. + */ +function createMessageBanner(message: string, className: string, icon: ThemeIcon, disposables: DisposableStore, openerService: IOpenerService): HTMLElement { + const banner = dom.$(`.${className}`); + banner.appendChild(renderIcon(icon)); + const markdown = new MarkdownString(message, { isTrusted: false, supportThemeIcons: true }); + const rendered = disposables.add(renderMarkdown(markdown, { + actionHandler: link => { void openerService.open(link, { allowCommands: false, fromUserGesture: true }); }, + })); + banner.appendChild(rendered.element); + return banner; +} + function appendCostSection(container: HTMLElement, pricing: string): void { const costSection = dom.$('.chat-model-hover-cost'); costSection.appendChild(dom.$('span', undefined, localize('models.cost', "Cost: {0}", pricing))); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts index 88f704d1ecda47..ab53effbe91368 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts @@ -3,21 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { renderAsPlaintext } from '../../../../../../../base/browser/markdownRenderer.js'; import { IAction, toAction } from '../../../../../../../base/common/actions.js'; import { Codicon } from '../../../../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; +import { stripIcons } from '../../../../../../../base/common/iconLabels.js'; import * as semver from '../../../../../../../base/common/semver/semver.js'; +import Severity from '../../../../../../../base/common/severity.js'; import { ThemeIcon } from '../../../../../../../base/common/themables.js'; import { localize } from '../../../../../../../nls.js'; import { ActionListItemKind, IActionListItem } from '../../../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { withSeverityPrefix } from '../../../../../../../platform/notification/common/notification.js'; import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; import { StateType } from '../../../../../../../platform/update/common/update.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../../../services/chat/common/chatEntitlementService.js'; import { getLanguageModelProviderDisplayName, IModelControlEntry, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; import { languageModelSourcePresentationRegistry } from '../../../../common/languageModelSourcePresentation.js'; import { getModelHoverContent } from './modelPickerHover.js'; -import { getPriceCategoryLabel, isMultiplierPricing } from './modelPickerPresentation.js'; +import { getPriceCategoryLabel, isAutoModel, isMultiplierPricing } from './modelPickerPresentation.js'; export function isVersionAtLeast(current: string, required: string): boolean { const currentSemver = semver.coerce(current); @@ -158,12 +162,33 @@ export function createModelAction( section, run: () => onSelect(model), }; - const ariaDescription = priceCategoryLabel + const baseDescription = priceCategoryLabel ? (textDescription ? textDescription + ' · ' + priceCategoryLabel : priceCategoryLabel) : undefined; + const notices = getNoticeAriaLabels(model); + const ariaDescription = notices.length > 0 + ? [baseDescription ?? textDescription, ...notices].filter((part): part is string => !!part).join(', ') + : baseDescription; return { action, ariaDescription }; } +/** + * Screen reader users never reach the rich hover, so its warning and info banners + * are folded into the row's accessible description, stripped of markdown and + * prefixed with their severity. + */ +function getNoticeAriaLabels(model: ILanguageModelChatMetadataAndIdentifier): string[] { + if (isAutoModel(model)) { + return []; + } + const toLabel = (message: string, severity: Severity): string => + withSeverityPrefix(stripIcons(renderAsPlaintext(new MarkdownString(message))), severity); + return [ + ...Object.values(model.metadata.warningText ?? {}).map(message => toLabel(message, Severity.Warning)), + ...Object.values(model.metadata.infoText ?? {}).map(message => toLabel(message, Severity.Info)), + ]; +} + export function getUnavailableReason( entry: IModelControlEntry, chatEntitlementService: IChatEntitlementService, diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 6e96bb4bf8e398..7ce5c6cce710b5 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -325,6 +325,12 @@ export interface ILanguageModelChatMetadata { * The keys are warning categories (e.g. "data_retention") and the values are markdown strings. */ readonly warningText?: IStringDictionary; + /** + * Optional informational text to display in the model picker hover as an info banner. + * The keys are info categories (e.g. "model_relocated") and the values are markdown strings. + * Unlike {@link warningText}, these are neutral notices and never signal a problem with the model. + */ + readonly infoText?: IStringDictionary; /** * Optional promotional information for this model. A positive `discountPercent` * surfaces the full promotional UI; `0` is a message-only promo that features the diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerHover.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerHover.test.ts index e2c13a9b66cbe9..741fcfe6021485 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerHover.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerHover.test.ts @@ -76,4 +76,25 @@ suite('ModelPickerHover', () => { 'Limited time offer', ]); }); + + test('info text renders as its own banner alongside warnings', () => { + const model = createModel('gpt-4.1', 'GPT-4.1'); + model.metadata = { + ...model.metadata, + warningText: { degradation: 'Currently degraded' }, + infoText: { model_relocated: 'GPT-4.1 now serves from a new region.' }, + } as ILanguageModelChatMetadata; + + const hover = getModelHoverContent(model, false, undefined, NullOpenerService); + assert.ok(hover); + disposables.add(hover.disposable); + + assert.deepStrictEqual({ + warnings: Array.from(hover.element.querySelectorAll('.chat-model-hover-warning-text'), element => element.textContent?.trim()), + infos: Array.from(hover.element.querySelectorAll('.chat-model-hover-info-text'), element => element.textContent?.trim()), + }, { + warnings: ['Currently degraded'], + infos: ['GPT-4.1 now serves from a new region.'], + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerItems.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerItems.test.ts index e5c7e994cbd745..98fc78d40f173f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerItems.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerItems.test.ts @@ -244,6 +244,29 @@ suite('buildModelPickerItems', () => { } as IActionListItem), 'Claude Sonnet 4.6, Medium cost'); }); + test('accessibility provider announces hover notices with their severity', () => { + const model = createModel('gpt-4.1', 'GPT-4.1'); + model.metadata = { + ...model.metadata, + priceCategory: 'medium', + warningText: { data_retention: 'Prompts are **retained** for 30 days.' }, + infoText: { model_relocated: 'Now serves from a [new region](https://aka.ms/region).' }, + } as ILanguageModelChatMetadata; + const provider = getModelPickerAccessibilityProvider(); + const item = getActionItems(callBuild([model])).find(a => a.label === 'GPT-4.1')!; + + assert.strictEqual( + provider.getAriaLabel(item), + 'GPT-4.1, Medium cost, Warning: Prompts are retained for 30 days., Info: Now serves from a new region.'); + }); + + test('accessibility provider leaves models without notices unchanged', () => { + const provider = getModelPickerAccessibilityProvider(); + const item = getActionItems(callBuild([createModel('gpt-4.1', 'GPT-4.1')])).find(a => a.label === 'GPT-4.1')!; + + assert.strictEqual(provider.getAriaLabel(item), 'GPT-4.1'); + }); + test('auto model always appears first', () => { const auto = createAutoModel(); const modelA = createModel('gpt-4o', 'GPT-4o'); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelProviderIcons.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelProviderIcons.test.ts index 6281ce23216701..a27c31150069b8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelProviderIcons.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelProviderIcons.test.ts @@ -66,17 +66,20 @@ suite('ModelProviderIcons', () => { ]); }); - test('status icon wins, warning text keeps provider icon', () => { + test('status icon wins, warning and info text keep the provider icon', () => { const model = createModel('gpt-5.6-terra', 'GPT-5.6 Terra'); const modelWithStatusIcon = { ...model, metadata: { ...model.metadata, statusIcon: Codicon.info } }; const modelWithWarningText = { ...model, metadata: { ...model.metadata, warningText: { degradation: 'Degraded' } } }; + const modelWithInfoText = { ...model, metadata: { ...model.metadata, infoText: { model_relocated: 'Now served from a new region.' } } }; assert.deepStrictEqual([ getModelPickerIcon(modelWithStatusIcon).id, getModelPickerIcon(modelWithWarningText).id, + getModelPickerIcon(modelWithInfoText).id, ], [ Codicon.info.id, getModelProviderIcon(model).id, + getModelProviderIcon(model).id, ]); }); }); diff --git a/src/vscode-dts/vscode.proposed.chatProvider.d.ts b/src/vscode-dts/vscode.proposed.chatProvider.d.ts index ab81f4d4d9c07e..613491eb43b7d3 100644 --- a/src/vscode-dts/vscode.proposed.chatProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatProvider.d.ts @@ -96,6 +96,13 @@ declare module 'vscode' { */ readonly warningText?: Record; + /** + * Optional informational text to display in the model picker hover as an info banner. + * The keys are info categories (e.g. "model_relocated") and the values are markdown strings. + * Unlike {@link warningText}, this renders with an info icon and never signals a problem with the model. + */ + readonly infoText?: Record; + /** * Optional promotional information for this model. When present, indicates the model * is currently experiencing a promotional discount. From 678b21544884299f7b8bc1cb7253751e8aca70a5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:17:08 +0000 Subject: [PATCH 13/24] Add mute-mic button and transcript quick-toggle to Agents Voice Mode (#331370) * Initial plan * Add mute-mic button and transcript quick toggle for Agents Voice Mode Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> * Add mute-mic control to shared chat inputs across all windows Render the Voice Mode mute/unmute control in the segmented voice pill so it appears in every chat input, not just the Agents Voice widget. Extend the pill's active context key to any connected session (previously only manual, non-hands-free). While muted, the waveform and input glow fall back to the calm idle state instead of reacting to the user's voice, and the listening placeholder reads "Unmute to speak...". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Only read voice mute state when connected/listening to fix fixtures The muted-idle fallback read isMuted unconditionally in the pill and voice input decoration autoruns, which crashed component fixtures whose mock IVoiceSessionController does not stub isMuted. Gate the reads on the connected/listening state (matching how the other voice observables are read) so idle/disconnected surfaces no longer depend on isMuted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> Co-authored-by: meganrogge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentsVoice/browser/agentsVoiceWidget.ts | 35 +++++++++ .../browser/agentsVoiceWidgetBinding.ts | 2 + .../browser/agentsVoiceWindowService.ts | 1 + .../browser/components/headerComponent.ts | 28 ++++++- .../browser/actions/chatAccessibilityHelp.ts | 2 +- .../speechToText/micButtonMenuActions.ts | 18 +++++ .../voiceClient/voiceInputDecorations.ts | 13 +++- .../voiceClient/voiceSessionController.ts | 37 +++++++++ .../voiceInputModeActionViewItem.ts | 77 +++++++++++++++---- .../voiceInputModeContextKeys.ts | 7 +- .../widgetHosts/viewPane/chatViewPane.ts | 16 +++- .../test/browser/micButtonMenuActions.test.ts | 15 ++++ .../voiceSessionController.test.ts | 44 +++++++++++ .../chat/test/browser/voiceInputMode.test.ts | 1 - 14 files changed, 265 insertions(+), 31 deletions(-) diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts index 507621beff6607..73b189f0ac5200 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts @@ -30,6 +30,8 @@ export interface VoiceWidgetCallbacks { disconnect(): void; pttDown(): void; pttUp(): void; + /** Toggle whether the microphone is muted while keeping the session connected. */ + toggleMute(): void; closeWindow(): void; stopPlayback(): void; openSession(resource: URI): void; @@ -149,6 +151,7 @@ export class AgentsVoiceWidget extends Disposable { private readonly _isConnected: ISettableObservable = observableValue(this, false); private readonly _isConnecting: ISettableObservable = observableValue(this, false); private readonly _isReconnecting: ISettableObservable = observableValue(this, false); + private readonly _isMuted: ISettableObservable = observableValue(this, false); private readonly _voiceState: ISettableObservable = observableValue(this, 'idle'); private readonly _expanded: ISettableObservable = observableValue(this, false); private readonly _workingCount: ISettableObservable = observableValue(this, 0); @@ -199,6 +202,7 @@ export class AgentsVoiceWidget extends Disposable { private readonly _inputBoxToolbar: HTMLElement | undefined; private readonly _inputBoxMicBtn: HTMLElement | undefined; private readonly _inputBoxConnIndicator: HTMLElement | undefined; + private readonly _inputBoxMuteBtn: HTMLElement | undefined; /** Ambient voice glow on the input box (input-box layout only). */ private readonly _glowController: IVoiceGlowController | undefined; private readonly _inputBoxFeedbackBtn: HTMLElement | undefined; @@ -370,6 +374,13 @@ export class AgentsVoiceWidget extends Disposable { localize('agentsVoice.disconnect', "Disconnect"), localize('agentsVoice.disconnect', "Disconnect")); + // Mute microphone button — color/label managed reactively in update. + this._inputBoxMuteBtn = dom.$('span.codicon.codicon-mic'); + this._inputBoxMuteBtn.role = 'button'; + this._inputBoxMuteBtn.tabIndex = 0; + this._inputBoxMuteBtn.style.cssText = `font-size:${FONT_SIZE.iconSm};color:var(--vscode-descriptionForeground);cursor:pointer;-webkit-app-region:no-drag;padding:2px;`; + addKeyboardActivation(this._inputBoxMuteBtn); + // Feedback button this._inputBoxFeedbackBtn = toolbarBtn('codicon-feedback', localize('agentsVoice.sendFeedback', "Send feedback"), @@ -395,6 +406,7 @@ export class AgentsVoiceWidget extends Disposable { this._inputBoxToolbar.append( this._inputBoxMicBtn, this._inputBoxConnIndicator, + this._inputBoxMuteBtn, toolbarSpacer, this._inputBoxFeedbackBtn, this._inputBoxSessionsBtn, @@ -799,6 +811,23 @@ export class AgentsVoiceWidget extends Disposable { this._inputBoxConnIndicator!.style.display = !voiceControlsSuppressed && showConnected ? '' : 'none'; this._inputBoxConnIndicator!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.disconnect(); }; + // Mute microphone button — visible when connected, keeps the session alive + const muted = this._isMuted.read(reader); + this._inputBoxMuteBtn!.style.display = !voiceControlsSuppressed && showConnected ? '' : 'none'; + this._inputBoxMuteBtn!.classList.toggle('codicon-mic', !muted); + this._inputBoxMuteBtn!.classList.toggle('codicon-mute', muted); + const muteColor = muted ? 'var(--vscode-editorError-foreground)' : 'var(--vscode-descriptionForeground)'; + this._inputBoxMuteBtn!.style.color = muteColor; + const muteLabel = muted + ? localize('agentsVoice.unmuteMic', "Unmute Microphone") + : localize('agentsVoice.muteMic', "Mute Microphone"); + this._inputBoxMuteBtn!.title = muteLabel; + this._inputBoxMuteBtn!.ariaLabel = muteLabel; + this._inputBoxMuteBtn!.setAttribute('aria-pressed', muted ? 'true' : 'false'); + this._inputBoxMuteBtn!.onmouseenter = () => { this._inputBoxMuteBtn!.style.color = 'var(--vscode-foreground)'; }; + this._inputBoxMuteBtn!.onmouseleave = () => { this._inputBoxMuteBtn!.style.color = muteColor; }; + this._inputBoxMuteBtn!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.toggleMute(); }; + // Feedback button — always visible this._inputBoxFeedbackBtn!.style.display = voiceControlsSuppressed ? 'none' : ''; this._inputBoxFeedbackBtn!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this._toggleFeedbackDialog(); }; @@ -862,6 +891,7 @@ export class AgentsVoiceWidget extends Disposable { showPopout: !!this.callbacks.openPopout && this._popoutAvailable.read(reader), hideDisconnect: this.callbacks.hideDisconnect, centerConnectButton: opts.centerConnectButton, + isMuted: this._isMuted.read(reader), onMicDown: (e: MouseEvent) => { e.preventDefault(); this.callbacks.pttDown(); }, onMicUp: () => { this.callbacks.pttUp(); }, onConnectClick: (e: MouseEvent) => { @@ -878,6 +908,7 @@ export class AgentsVoiceWidget extends Disposable { onCloseClick: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.closeWindow(); }, onToggleClick: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this._expanded.set(!this._expanded.get(), undefined); }, onMicContextMenu: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.showVoiceContextMenu(e); }, + onMuteClick: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.toggleMute(); }, onPopoutClick: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.openPopout?.(); }, onFeedbackClick: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this._toggleFeedbackDialog(); }, pttKeyLabel: this._pttKeyLabel.read(reader), @@ -974,6 +1005,10 @@ export class AgentsVoiceWidget extends Disposable { this._isReconnecting.set(reconnecting, undefined); } + setMuted(muted: boolean): void { + this._isMuted.set(muted, undefined); + } + setVoiceState(state: VoiceState): void { this._voiceState.set(state, undefined); } diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidgetBinding.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidgetBinding.ts index 9a938e37c3a3bd..26a7355f67b352 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidgetBinding.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidgetBinding.ts @@ -50,6 +50,7 @@ export function bindWidgetToController(widget: AgentsVoiceWidget, services: IWid const connected = controller.isConnected.read(reader); const connecting = controller.isConnecting.read(reader); const reconnecting = controller.isReconnecting.read(reader); + const muted = controller.isMuted.read(reader); const toolConfirmations = controller.pendingToolConfirmations.read(reader); const speakingSession = voicePlaybackService.speakingSession.read(reader); const statusText = controller.statusText.read(reader); @@ -60,6 +61,7 @@ export function bindWidgetToController(widget: AgentsVoiceWidget, services: IWid widget.setConnected(connected); widget.setConnecting(connecting); widget.setReconnecting(reconnecting); + widget.setMuted(muted); widget.setVoiceControlsSuppressed(omniInputOpen); widget.setVoiceState(omniInputOpen ? 'idle' : state); widget.setPendingToolConfirmations(toolConfirmations); diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts index fbffa2ce15349c..aaad3da376e05e 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts @@ -179,6 +179,7 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice this.voiceSessionController.pttDown(); }, pttUp: () => this.voiceSessionController.pttUp(), + toggleMute: () => this.voiceSessionController.setMuted(!this.voiceSessionController.isMuted.get()), closeWindow: () => this.closeWindow(), stopPlayback: () => this.ttsPlaybackService.stopPlayback(), openSession: (resource) => { diff --git a/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts b/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts index 55c9c745ef7d94..fa6b8311ef9312 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts @@ -21,6 +21,7 @@ export interface HeaderProps { readonly showPopout: boolean; readonly hideDisconnect: boolean; readonly centerConnectButton: boolean; + readonly isMuted: boolean; readonly onMicDown: (e: MouseEvent) => void; readonly onMicUp: () => void; readonly onConnectClick: (e: MouseEvent) => void; @@ -28,6 +29,7 @@ export interface HeaderProps { readonly onCloseClick: (e: MouseEvent) => void; readonly onToggleClick: (e: MouseEvent) => void; readonly onMicContextMenu: (e: MouseEvent) => void; + readonly onMuteClick: (e: MouseEvent) => void; readonly onPopoutClick: (e: MouseEvent) => void; readonly onFeedbackClick: (e: MouseEvent) => void; readonly expanded: boolean; @@ -86,6 +88,14 @@ export function createHeader(): HeaderComponent { connIndicator.append(connDot, connDisc); addKeyboardActivation(connIndicator); + // Mute microphone button — toggles whether captured audio is sent to the + // backend. Shown only while connected. Visual state clearly reflects mute. + const muteBtn = dom.$('span.codicon.codicon-mic'); + muteBtn.role = 'button'; + muteBtn.tabIndex = 0; + muteBtn.style.cssText = `font-size:${FONT_SIZE.iconSm};cursor:pointer;-webkit-app-region:no-drag;flex-shrink:0;border-radius:4px;padding:2px;`; + addKeyboardActivation(muteBtn); + // Placeholder text — clickable, shows PTT keybinding const placeholderText = dom.$('span.voice-placeholder-text'); placeholderText.role = 'button'; @@ -128,7 +138,7 @@ export function createHeader(): HeaderComponent { } `; - container.append(copilotIcon, micBtn, placeholderText, connIndicator, spacer, popoutBtn, closeBtn, connStyle); + container.append(copilotIcon, micBtn, placeholderText, connIndicator, muteBtn, spacer, popoutBtn, closeBtn, connStyle); return { element: container, @@ -175,6 +185,22 @@ export function createHeader(): HeaderComponent { connIndicator.style.display = showConnected && !props.hideDisconnect ? 'inline-flex' : 'none'; connIndicator.onclick = props.onDisconnectClick; + // Mute microphone button — shown only when connected + muteBtn.style.display = showConnected ? '' : 'none'; + muteBtn.classList.toggle('codicon-mic', !props.isMuted); + muteBtn.classList.toggle('codicon-mute', props.isMuted); + const muteColor = props.isMuted ? 'var(--vscode-editorError-foreground)' : 'var(--vscode-descriptionForeground)'; + muteBtn.style.color = muteColor; + const muteLabel = props.isMuted + ? localize('agentsVoice.unmuteMic', "Unmute Microphone") + : localize('agentsVoice.muteMic', "Mute Microphone"); + muteBtn.ariaLabel = muteLabel; + muteBtn.title = muteLabel; + muteBtn.setAttribute('aria-pressed', props.isMuted ? 'true' : 'false'); + muteBtn.onmouseenter = () => { muteBtn.style.color = 'var(--vscode-foreground)'; }; + muteBtn.onmouseleave = () => { muteBtn.style.color = muteColor; }; + muteBtn.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); props.onMuteClick(e); }; + // Spacer / center connect button const showConnBtnCenter = !showConnected && props.centerConnectButton; spacer.style.cssText = 'flex:1;'; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index a6c2226e82171f..6dc3c261688d8f 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -110,7 +110,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('workbench.action.chat.toggleSpeechToText', 'To dictate your request into the input box, invoke the Dictate command{0}. Invoke it again to stop; recording start and stop are indicated by accessibility signals.', '')); content.push(localize('workbench.action.chat.cancelSpeechToText', 'While dictating, invoke the Cancel Dictation command{0} to stop and discard the dictated text.', '')); content.push(localize('chat.speechToText.contextMenu', 'To choose a microphone or turn off dictation or Voice Mode, focus the microphone button in the input toolbar and open its context menu{0} (for example Shift+F10).', '')); - content.push(localize('chat.voiceInputMode.segmented', 'When the segmented voice input control is enabled, the input toolbar offers Dictation, Voice Mode, and, in manual Voice Mode, a Start or Stop Listening button. Stopping listening sends the completed turn. Each button can be focused and activated with Enter or Space.')); + content.push(localize('chat.voiceInputMode.segmented', 'When the segmented voice input control is enabled, the input toolbar offers Dictation and Voice Mode. A connected hands-free session also offers Mute or Unmute Microphone; manual Voice Mode instead offers Start or Stop Listening, where stopping sends the completed turn. Each button can be focused and activated with Enter or Space.')); content.push(localize('chat.voiceInputMode.holdToTalk', 'In manual Voice Mode, the Start or Stop Listening button toggles listening when tapped, or you can press and hold it to talk and release to send. You can also hold the Voice Mode: Hold to Talk keybinding{0} to talk and release to send; this interrupts the assistant to barge in.', '')); content.push(localize('chat.voiceMode.introduction', 'The first time Voice Mode starts, an introduction appears above the input box. Tab to reach it, then use the arrow keys to move between the available voices; Enter or Space plays a voice and keeps it for future conversations. Its description also contains two links: Settings, which opens the Voice Mode settings, and How It Responds, which opens a file for customizing what the agent says back. Voice Mode stays connected but does not listen while the introduction is open. Press Escape, or activate the Close button, to dismiss it and return to the input box.')); if (type === 'agentView') { diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts b/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts index 68bb0801465011..dc189a52c0660f 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts @@ -36,6 +36,8 @@ export const SHOW_VOICE_MODE_ONBOARDING_COMMAND = 'agentsVoice.showOnboarding'; const DICTATION_ENABLED_SETTING = 'dictation.enabled'; /** Setting that enables Voice Mode; toggled off by "Disable". */ const VOICE_ENABLED_SETTING = 'agents.voice.enabled'; +/** Setting that shows the live voice transcript overlay; toggled from the menu. */ +const VOICE_SHOW_TRANSCRIPT_SETTING = 'agents.voice.showTranscript'; /** * "Select Microphone" entry shared by every dictation / Voice Mode mic button @@ -161,6 +163,21 @@ function createConfigureInstructionsAction(commandService: ICommandService, comm }); } +/** + * Checkable "Show Transcript" entry: a quick per-session toggle for the live + * voice transcript overlay. Reflects and flips `agents.voice.showTranscript`, + * which also serves as the user's default preference. + */ +function createToggleTranscriptAction(configurationService: IConfigurationService): IAction { + const shown = configurationService.getValue(VOICE_SHOW_TRANSCRIPT_SETTING) === true; + return toAction({ + id: 'chat.voiceMode.toggleTranscript', + label: localize('voiceMode.showTranscript', "Show Transcript"), + checked: shown, + run: () => configurationService.updateValue(VOICE_SHOW_TRANSCRIPT_SETTING, !shown), + }); +} + /** * Actions for the Voice Mode mic button context menu. Keybinding and feature * disabling are grouped separately from configuration and onboarding. @@ -170,6 +187,7 @@ export function getVoiceModeContextMenuActions(commandService: ICommandService, [ createConfigureKeybindingAction(commandService, keybindingService, keybindingCommandId), createToggleButtonAction(configurationService, AgentsVoiceSettingId.ShowButton, 'chat.voiceMode.toggleButton', localize('voiceMode.button', "Voice Mode Button")), + createToggleTranscriptAction(configurationService), createDisableVoiceModeAction(commandService, configurationService), ], [ diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceInputDecorations.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceInputDecorations.ts index 5ceeba8113a99c..233d09e72680ed 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceInputDecorations.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceInputDecorations.ts @@ -18,7 +18,7 @@ import { IThemeService } from '../../../../../platform/theme/common/themeService import { isDark } from '../../../../../platform/theme/common/theme.js'; import { IMicCaptureService } from './micCaptureService.js'; import { ITtsPlaybackService } from './ttsPlaybackService.js'; -import { readVoiceGlowIntensity, resolveVoiceGlowColors, shouldRenderVoiceInputGlow } from './voiceGlow.js'; +import { readVoiceGlowIntensity, resolveVoiceGlowColors, shouldRenderVoiceInputGlow, VoiceGlowState } from './voiceGlow.js'; import { createVoiceGlowController, IVoiceGlowController } from './voiceGlowController.js'; import { IVoiceSessionController } from './voiceSessionController.js'; @@ -139,7 +139,12 @@ export function setupVoiceInputDecorations(services: IVoiceInputDecorationsServi const voiceState = voiceSessionController.voiceState.read(reader); const active = isActive.read(reader); const ownsVoice = isSurfaceOwner(reader); - if (shouldRenderVoiceInputGlow(connected, active, ownsVoice, voiceState)) { + // A muted mic isn't heard, so the listening rim would misleadingly react to + // the user's voice; treat muted-listening as idle (no glow) until unmuted. + // Only read the mute observable while listening, so idle/disconnected surfaces + // don't depend on it. + const glowState: VoiceGlowState = voiceState === 'listening' && voiceSessionController.isMuted.read(reader) ? 'idle' : voiceState; + if (shouldRenderVoiceInputGlow(connected, active, ownsVoice, glowState)) { startGlowAnimation(); } else { stopGlowAnimation(); @@ -177,7 +182,9 @@ export function setupVoiceInputDecorations(services: IVoiceInputDecorationsServi transcriptOverlayNode.classList.remove('has-transcript'); transcriptOverlay.replaceChildren(); const listening = dom.$('span.listening'); - listening.textContent = localize('voiceMode.listening', "Listening..."); + listening.textContent = voiceSessionController.isMuted.read(reader) + ? localize('voiceMode.mutedUnmuteToSpeak', "Unmute to speak...") + : localize('voiceMode.listening', "Listening..."); transcriptOverlay.append(listening); transcriptScrollable.scanDomNode(); } else if (!showTranscript && voiceState === 'speaking') { diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index bd50902e33acbf..3a1bb6e1eeef2b 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -213,6 +213,8 @@ export interface IVoiceSessionController { readonly isConnected: IObservable; readonly isConnecting: IObservable; readonly isReconnecting: IObservable; + /** Whether the user has muted the microphone while keeping the session connected. */ + readonly isMuted: IObservable; readonly pendingToolConfirmations: IObservable; /** The session resource that transcriptions will be sent to. undefined = active session. */ readonly targetSession: IObservable; @@ -245,6 +247,14 @@ export interface IVoiceSessionController { */ stopListening(source?: 'explicit' | 'internal'): void; + /** + * Mute or unmute the microphone without ending the session. While muted, + * captured audio is not forwarded to the backend so background noise or + * private speech never reaches transcription, but the WebSocket stays + * connected so the user can unmute and resume instantly. + */ + setMuted(muted: boolean): void; + /** * Hold hands-free auto-listen off until released. * @@ -380,6 +390,12 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC private readonly _isReconnecting = observableValue(this, false); readonly isReconnecting: IObservable = this._isReconnecting; + /** User-facing microphone mute. When set, captured audio is not forwarded to + * the backend, but the session stays connected so the user can unmute and + * resume without a new handshake. Reset to `false` on (re)connect. */ + private readonly _isMuted = observableValue(this, false); + readonly isMuted: IObservable = this._isMuted; + /** Set when the connection closed terminally (e.g. another window took over * the session). Suppresses the reconnect display path so the controller * settles to a clean, restartable state instead of a stuck "Reconnecting...". @@ -1203,6 +1219,11 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC }); })); this._voiceEventDisposables.add(this.micCaptureService.onPttAudioChunk(b64 => { + // While the user has muted the microphone, keep the session alive but + // drop captured audio so nothing reaches transcription / the backend. + if (this._isMuted.get()) { + return; + } this.voiceClientService.sendPttAudioChunk(b64); })); this._voiceEventDisposables.add(this.micCaptureService.onPttEnd(() => { @@ -1761,6 +1782,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._voiceAutorunDisposable.value = connectionDisposables; this.micCaptureService.isMuted = false; + this._isMuted.set(false, undefined); this._statusText.set('Hold to speak...', undefined); this._voiceState.set('idle', undefined); @@ -2466,6 +2488,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._resetTranscriptionTurn(); this._bargeInListenActive = false; this._isConnected.set(false, undefined); + this._isMuted.set(false, undefined); this._voiceState.set('idle', undefined); this._statusText.set('Tap to start', undefined); this._transcriptTurns.set([], undefined); @@ -3120,6 +3143,20 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } } + setMuted(muted: boolean): void { + if (this._isMuted.get() === muted) { + return; + } + this._isMuted.set(muted, undefined); + // Stop the source stream too so muted audio is dropped immediately even + // while a push-to-talk press is in flight (the `onPttAudioChunk` gate is + // the durable guard because `micCaptureService.isMuted` is reset on each + // press). Muting does not tear down the session or the auto-listen loop, + // so unmuting resumes instantly. + this.micCaptureService.isMuted = muted; + this.logService.trace(`[voice] setMuted: ${muted}`); + } + stopListening(source: 'explicit' | 'internal' = 'explicit'): void { // Stop the current recording / auto-listen loop WITHOUT tearing down // the WebSocket. Any in-flight press is finished through the normal diff --git a/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts b/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts index 954c4a03c003b3..a00123838b4d25 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts @@ -318,10 +318,9 @@ export interface IVoiceInputModePillOptions { } /** - * A single segmented control in the chat input that hosts both voice input modes: - * a Dictation segment (speech-to-text into the input) and a Voice Mode segment (live - * conversational agent). Only one mode can be active at a time — activating one stops - * the other. Both segments stay visible (when available) so users discover both modes. + * A single segmented control in the chat input that hosts Dictation and Voice Mode, + * including the connected-session listen or mute control. Only one input mode can be + * active at a time — activating one stops the other. */ export class VoiceInputModeActionViewItem extends BaseActionViewItem { @@ -329,8 +328,10 @@ export class VoiceInputModeActionViewItem extends BaseActionViewItem { private _dictationCell: HTMLElement | undefined; private _voiceCell: HTMLElement | undefined; private _listenCell: HTMLElement | undefined; + private _muteCell: HTMLElement | undefined; private _dictationIcon: HTMLElement | undefined; private _listenIcon: HTMLElement | undefined; + private _muteIcon: HTMLElement | undefined; private _voiceBars: HTMLElement | undefined; private _voiceBarEls: HTMLElement[] = []; private _barAnimationFrame: number | undefined; @@ -364,6 +365,9 @@ export class VoiceInputModeActionViewItem extends BaseActionViewItem { this._listenCell?.setAttribute('aria-label', this._listenCell.classList.contains('active') ? this._getLabelWithKeybinding(localize('voiceInputMode.stopListening', "Stop Listening"), ChatVoiceInputModeToggleListenAction.ID) : this._getLabelWithKeybinding(localize('voiceInputMode.startListening', "Start Listening"), ChatVoiceInputModeToggleListenAction.ID)); + this._muteCell?.setAttribute('aria-label', this._muteCell.classList.contains('active') + ? localize('voiceInputMode.unmuteMicrophone', "Unmute Microphone") + : localize('voiceInputMode.muteMicrophone', "Mute Microphone")); } constructor( @@ -401,12 +405,11 @@ export class VoiceInputModeActionViewItem extends BaseActionViewItem { this._updateVoiceStateColors(container); this._register(this.themeService.onDidColorThemeChange(() => this._updateVoiceStateColors(container))); - // A masked 2-slot viewport ("slot machine reel"). The reel holds three cells: - // [ dictation ][ voice ][ listen ] + // A masked 2-slot viewport ("slot machine reel"). The reel holds four cells: + // [ dictation ][ voice ][ listen ][ mute ] // Disconnected → the reel shows slots 0..1 (dictation + voice-connect). - // Connected → the reel slides left one slot to show slots 1..2, so the voice - // cell takes the dictation cell's place (now animated + disconnect) - // and the listen toggle slides in from the right. + // Connected → the voice cell takes the dictation cell's place (now animated + // + disconnect) and either listen or mute occupies the second slot. const pill = dom.append(container, dom.$('.monaco-segmented-icon-toggle.chat-voice-input-mode')); this._reel = dom.append(pill, dom.$('.monaco-segmented-icon-toggle-reel.chat-voice-input-mode-reel')); @@ -509,6 +512,26 @@ export class VoiceInputModeActionViewItem extends BaseActionViewItem { })); this._registerActivationKeys(this._listenCell, () => this._onClickListen()); + // --- Mute cell: microphone transport toggle for hands-free voice mode. --- + this._muteCell = dom.append(this._reel, dom.$('button.monaco-segmented-icon-toggle-cell.chat-voice-input-mode-cell.mute')); + this._muteCell.setAttribute('type', 'button'); + this._muteCell.setAttribute('role', 'button'); + this._muteIcon = dom.append(this._muteCell, dom.$('span.chat-voice-input-mode-icon')); + this._register(addMicButtonContextMenuListener( + this._muteCell, + () => getVoiceModeContextMenuActions(this.commandService, this.configurationService, this.keybindingService, VOICE_START_COMMAND_ID), + this.contextMenuService, + )); + this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), this._muteCell, + () => this.voiceSessionController.isMuted.get() + ? localize('voiceInputMode.unmuteMicrophone', "Unmute Microphone") + : localize('voiceInputMode.muteMicrophone', "Mute Microphone"))); + this._register(dom.addDisposableListener(this._muteCell, dom.EventType.CLICK, e => { + dom.EventHelper.stop(e, true); + this._onClickMute(); + })); + this._registerActivationKeys(this._muteCell, () => this._onClickMute()); + // Dictation activity: scoped to chat so editor and terminal dictation do not // animate this control. const dictationActive = observableFromEvent(this, @@ -560,30 +583,37 @@ export class VoiceInputModeActionViewItem extends BaseActionViewItem { listening = connected && voiceState === 'listening'; speaking = connected && voiceState === 'speaking'; } - const voiceLive = listening || speaking; + // While muted the mic isn't heard, so the audio-reactive listening state + // would misleadingly react to the user's voice; render the calm idle-on + // wave instead until unmuted. Speaking (the assistant) is unaffected. + // Only read the mute observable while connected, mirroring how the state + // observables above are only read when voice is active. + const muted = sim === undefined && connected && this.voiceSessionController.isMuted.read(reader); + const micListening = listening && !muted; + const voiceLive = micListening || speaking; const voiceOn = connected || connecting; this._voiceLive = voiceLive; // First-use model download/load (real state only; simulations never prepare). const dictationBusy = sim === undefined && isDictationActive && dictationPreparing.read(reader); - // The dedicated listen (start/stop speaking) toggle shows in manual - // (non-hands-free) connected voice mode. In hands-free mode the auto-listen - // loop drives listening, so there is no listen cell. It keys off `connected` - // rather than `voiceOn` so a connect/reconnect renders as a single-cell - // spinner instead of a spinner beside an inert listen button. + // Connected Voice Mode always has one session control: manual mode shows + // start/stop listening, while hands-free mode shows mute/unmute. const showListen = connected && !handsFree; + const showMute = connected && handsFree; // Presence of each cell. The housing is a constant size; the absent cell // collapses its width to 0 (mask recenters) so icons slide into place. // - dictation: shown when NOT in voice mode (home menu / dictating) // - voice: shown unless dictation is actively recording // - listen: shown only in manual-connected voice mode + // - mute: shown only in hands-free connected voice mode const dictationPresent = dictationAvailable && !voiceOn; const voicePresent = voiceAvailable && !isDictating && !dictationBusy; const listenPresent = showListen; + const mutePresent = showMute; // Exactly one icon → single-icon view (the lone button fills the whole pill). - const presentCount = (dictationPresent ? 1 : 0) + (voicePresent ? 1 : 0) + (listenPresent ? 1 : 0); + const presentCount = (dictationPresent ? 1 : 0) + (voicePresent ? 1 : 0) + (listenPresent ? 1 : 0) + (mutePresent ? 1 : 0); container.classList.toggle('connected', voiceOn); container.classList.toggle('single', presentCount === 1); @@ -628,7 +658,7 @@ export class VoiceInputModeActionViewItem extends BaseActionViewItem { this._voiceCell!.classList.toggle('on', voiceOn); this._voiceCell!.classList.toggle('connecting', connecting && !connected); this._voiceCell!.classList.toggle('idle-on', voiceOn && !voiceLive); - this._voiceCell!.classList.toggle('listening', listening); + this._voiceCell!.classList.toggle('listening', micListening); this._voiceCell!.classList.toggle('speaking', speaking); this._voiceCell!.setAttribute('aria-pressed', String(voiceOn)); // Simulated hover (walkthrough only) mirrors the real :hover disconnect preview. @@ -640,6 +670,12 @@ export class VoiceInputModeActionViewItem extends BaseActionViewItem { this._listenCell!.classList.toggle('muted', !listening); this._listenCell!.setAttribute('aria-pressed', String(listening)); this._listenIcon!.className = `chat-voice-input-mode-icon ${ThemeIcon.asClassName(listening ? Codicon.personVoiceFilledCompact : Codicon.personVoiceCompact)}`; + + // Mute / unmute toggle: the glyph describes the action available. + this._muteCell!.classList.toggle('collapsed', !mutePresent); + this._muteCell!.classList.toggle('active', muted); + this._muteCell!.setAttribute('aria-pressed', String(muted)); + this._muteIcon!.className = `chat-voice-input-mode-icon ${ThemeIcon.asClassName(muted ? Codicon.mic : Codicon.mute)}`; this._updateAriaLabels(); // Audio-reactive bars only while live (and not hovering the disconnect preview). @@ -821,6 +857,13 @@ export class VoiceInputModeActionViewItem extends BaseActionViewItem { } } + private _onClickMute(): void { + const controller = this.voiceSessionController; + if (controller.isConnected.get()) { + controller.setMuted(!controller.isMuted.get()); + } + } + /** Threshold (ms) separating a quick tap (toggle) from a press-and-hold (talk). */ private static readonly HOLD_THRESHOLD_MS = 180; diff --git a/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeContextKeys.ts b/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeContextKeys.ts index 942c6f9a5d49c1..c287d97fad803c 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeContextKeys.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeContextKeys.ts @@ -11,8 +11,6 @@ const VoiceModeButtonShown = ContextKeyExpr.notEquals('config.agents.voice.showB /** Mirrors `ChatSpeechToTextConfigured` (built-in on-device dictation available). */ const DictationConfigured = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.has(ChatContextKeys.speechToTextConfigured.key))!; const DictationButtonShown = ContextKeyExpr.notEquals('config.dictation.showButton', false); -/** Voice Mode runs manual push-to-talk rather than hands-free auto-listen. */ -const HandsFreeDisabled = ContextKeyExpr.equals('config.agents.voice.handsFree', false); const VisibleVoiceMode = ContextKeyExpr.and(AGENTS_VOICE_ENABLED, VoiceModeButtonShown)!; const VisibleDictation = ContextKeyExpr.and(DictationConfigured, DictationButtonShown)!; @@ -21,8 +19,7 @@ const VisibleDictation = ContextKeyExpr.and(DictationConfigured, DictationButton * place when it would host at least two cells; otherwise the single standalone * control for the lone available mode is clearer: * - both dictation and Voice Mode are enabled (dictation + voice-connect cells), or - * - only Voice Mode is enabled in manual (non-hands-free) mode AND a session is - * active, so the voice-connection + listen cells both render. + * - Voice Mode is connected, so the voice-connection + listen/mute cells render. * In every other single-mode case the standalone controls (gated on the negation * below) take over. */ @@ -32,7 +29,7 @@ export const SegmentedVoiceInputModePillActive: ContextKeyExpression = ContextKe VisibleVoiceMode, ContextKeyExpr.or( VisibleDictation, - ContextKeyExpr.and(HandsFreeDisabled, AGENTS_VOICE_CONNECTED), + AGENTS_VOICE_CONNECTED, ), )!; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 59d57de0c9f08d..fa4084505f4af4 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -585,9 +585,13 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { if (sim === 'off' || sim === 'connecting' || sim === 'dictating') { return { connected: false, voiceState: 'idle', simulating: true }; } + const voiceState = this.voiceSessionController.voiceState.get() as VoiceGlowState; return { connected: this.voiceSessionController.isConnected.get(), - voiceState: this.voiceSessionController.voiceState.get() as VoiceGlowState, + // While muted the mic isn't heard; treat muted-listening as idle (no + // glow). Only check mute in the listening state so other states don't + // depend on it. + voiceState: voiceState === 'listening' && this.voiceSessionController.isMuted.get() ? 'idle' : voiceState, simulating: false, }; }; @@ -645,9 +649,13 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // glow. Idle renders none, so keeping the loop alive then would burn a // requestAnimationFrame callback every frame for nothing. React to // simulated states too, so the walkthrough commands light up the glow. + // A muted mic isn't heard, so the listening rim would misleadingly react + // to the user's voice; treat muted-listening as idle (no glow). The mute + // observable is only read in the listening state. const sim = this.voiceInputModeService.simulatedVoiceState.read(reader); const simGlow = sim === 'listening' || sim === 'speaking'; - if (!omniInputOpen && (simGlow || (connected && isGlowingVoiceState(voiceState)))) { + const liveGlow = connected && isGlowingVoiceState(voiceState) && !(voiceState === 'listening' && this.voiceSessionController.isMuted.read(reader)); + if (!omniInputOpen && (simGlow || liveGlow)) { startGlowAnimation(); } else { stopGlowAnimation(); @@ -782,7 +790,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { transcriptOverlayNode.classList.remove('has-transcript'); transcriptOverlay.replaceChildren(); const listening = $('span.listening'); - listening.textContent = localize('voiceMode.listening', "Listening..."); + listening.textContent = this.voiceSessionController.isMuted.read(reader) + ? localize('voiceMode.mutedUnmuteToSpeak', "Unmute to speak...") + : localize('voiceMode.listening', "Listening..."); transcriptOverlay.append(listening); transcriptScrollable.scanDomNode(); } else if (!showTranscript && voiceState === 'speaking') { diff --git a/src/vs/workbench/contrib/chat/test/browser/micButtonMenuActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/micButtonMenuActions.test.ts index 95a9b44ec12481..e862f86a255e93 100644 --- a/src/vs/workbench/contrib/chat/test/browser/micButtonMenuActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/micButtonMenuActions.test.ts @@ -25,6 +25,7 @@ suite('Mic button menu actions', () => { assert.deepStrictEqual(actions.map(action => action.label), [ 'Configure Keybinding', 'Voice Mode Button', + 'Show Transcript', 'Disable', '', 'Open Settings', @@ -34,6 +35,20 @@ suite('Mic button menu actions', () => { ]); }); + test('Voice Mode "Show Transcript" toggle reflects and flips the transcript setting', async () => { + const updated: [string, unknown][] = []; + const configurationService = upcastPartial({ + getValue: () => false, + updateValue: async (key: string, value: unknown) => { updated.push([key, value]); }, + }); + const actions = getVoiceModeContextMenuActions(commandService, configurationService, keybindingService, 'voice.start'); + const toggle = actions.find(action => action.label === 'Show Transcript')!; + + assert.deepStrictEqual({ checked: toggle.checked, updatedBeforeRun: updated }, { checked: false, updatedBeforeRun: [] }); + await toggle.run(); + assert.deepStrictEqual(updated, [['agents.voice.showTranscript', true]]); + }); + test('Voice Mode button toggle reflects and flips the visibility setting', async () => { const updated: [string, unknown][] = []; const configurationService = upcastPartial({ diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts index 9fe5640b8108e4..95c7ed219dee90 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts @@ -1105,6 +1105,50 @@ suite('VoiceSessionController', () => { }); }); + test('setMuted gates outgoing audio while keeping the session connected', async () => { + const sentChunks: string[] = []; + const voiceClientService = new class extends TestVoiceClientService { + override sendPttAudioChunk(chunk: string): void { sentChunks.push(chunk); } + }(); + const audioChunkEmitter = store.add(new Emitter()); + const micCaptureService = new class extends TestMicCaptureService { + override readonly onPttAudioChunk = audioChunkEmitter.event; + }(); + const controller = createController( + voiceClientService, + undefined, + undefined, + undefined, + micCaptureService, + new TestConfigurationService({ 'agents.voice.handsFree': false }), + ); + await controller.connect(mainWindow); + voiceClientService.fireConnectionState(true); + await voiceClientService.sessionCommandSent.p; + voiceClientService.fireSessionInit(); + + // Unmuted: chunks flow to the backend. + audioChunkEmitter.fire('chunk-1'); + // Muted: chunks are dropped, session stays connected. + controller.setMuted(true); + audioChunkEmitter.fire('chunk-2'); + // Unmuted again: chunks flow once more. + controller.setMuted(false); + audioChunkEmitter.fire('chunk-3'); + + assert.deepStrictEqual({ + sentChunks, + micMuted: micCaptureService.isMuted, + isMuted: controller.isMuted.get(), + connected: controller.isConnected.get(), + }, { + sentChunks: ['chunk-1', 'chunk-3'], + micMuted: false, + isMuted: false, + connected: true, + }); + }); + test('hands-free warm-up failure returns to idle and allows retry', async () => { const voiceClientService = new TestVoiceClientService(); const resetObserved = new DeferredPromise(); diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceInputMode.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceInputMode.test.ts index 881153306ae40f..7bccd205c1eaa6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceInputMode.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceInputMode.test.ts @@ -102,7 +102,6 @@ suite('VoiceInputModeService', () => { assert.strictEqual(matches(SegmentedVoiceInputModePillActive), false); assert.strictEqual(matches(SegmentedVoiceInputModePillInactive), true); - values['config.agents.voice.handsFree'] = false; values[AGENTS_VOICE_CONNECTED.key] = true; assert.strictEqual(matches(SegmentedVoiceInputModePillActive), true); assert.strictEqual(matches(SegmentedVoiceInputModePillInactive), false); From 262a87b1f7a5cc81cf2177b1d9f8325b13fef1a3 Mon Sep 17 00:00:00 2001 From: joshspicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:17:14 -0700 Subject: [PATCH 14/24] Force the Agent Host harness when the sandbox is managed (#331298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Force the Agent Host harness when the sandbox is managed Enterprises in the sandbox pilot enforce `chat.agent.sandbox.enabled` (or `chat.agent.sandbox.enabledWindows`) through managed settings. Treat that as the governance signal for the chat harness: hide the legacy local harness from the new-chat pickers and default new chats to the Agent Host Copilot SDK, without the administrator having to also push `chat.editor.localAgent.enabled`, `chat.defaultToCopilotHarness` and `chat.editor.preferCopilotHarness`. A user- or workspace-level sandbox opt-in does not trigger this, and existing local chat sessions keep running on the local harness. The decision is reported in the Policy Diagnostics developer report. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Report the effective harness decision, not just the signal Address PR review feedback: - `chat.editor.localAgent.enabled` and `chat.defaultToCopilotHarness` descriptions claimed the policy always applies, but virtual workspaces are checked first and keep the local harness. Scope both descriptions to non-virtual workspaces. - Policy Diagnostics reported the harness as unconditionally hidden/forced whenever the policy signal was active. Split the section into the governance signal and the effective decision in this window, deriving the latter from the workspace kind and Agent Host enablement, which `getComputedDefaultSessionType` also depends on. Also export the harness setting ids from the platform module so the diagnostics labels stay in sync with the registration, and cover the case where a governed window has no Agent Host and no contributed harness. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Key enforcement on the only policy-backed sandbox setting A review council independently found that the `chat.agent.sandbox.enabledWindows` branch could never fire. `inspect().policyValue` is only populated for settings that declare a policy (`PolicyConfiguration.update`), and unlike `chat.agent.sandbox.enabled` that setting declares none, so its policy value is permanently undefined. The accompanying test fabricated a policy value the real configuration service cannot produce for that key, so it asserted behavior on a state unreachable in production. Key the governance signal on `chat.agent.sandbox.enabled` alone and drop the dead field, its picker listener, and the fabricated test case. Add a test locking in the real contract: the Windows setting must not act as a signal, so a local user opt-in on Windows cannot silently retire the local harness. Sandboxing is per-platform while enforcement is fleet-wide, so Policy Diagnostics now reports the platform-appropriate sandbox setting and whether the sandbox is actually active on this machine, instead of implying an enforced user is sandboxed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Key harness enforcement on the SDK sandbox managed setting The previous revision keyed on `chat.agent.sandbox.enabled` — the VS Code terminal-engine sandbox that the *local* harness uses — which is the wrong signal. The sandbox the pilot enforces is the Copilot SDK sandbox floor, delivered as the runtime-owned `sandbox.enabled` managed setting (`force-on-wins` in the runtime's managed-settings schema) and applied by the Agent Host over AHP. Read that key directly from the managed-settings channels instead of through a VS Code configuration policy: the control is runtime-owned, so mirroring it as a `policy:` declaration would invert ownership. `sandbox.enabled` is registered as a pipeline-consumed control (like `forceRemoteSettingsRefresh`) so native MDM watches it without any setting declaring it, and resolution follows the standard native MDM > server > file precedence. The enablement service exposes the result as a `managedSandboxEnforced` observable, which the harness decision points take as an explicit argument rather than re-reading configuration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply the sandbox floor on every new-chat path and trim the diff A review council unanimously found that the floor never reached the real New Chat entry points. `managedSandboxEnforced` was an optional parameter defaulting to `false`, so every call site that was not updated silently opted out: the picker hid the local harness while the New Chat, panel and editor actions kept creating local sessions the user could no longer re-select, and a remembered local selection kept overriding the mandated default. Thread the flag through `getDefaultNewChatSessionResource` and remembered-type resolution, and supply it from every production call site. Cover both the resource path and the remembered-local override with a regression test. Also drop the file managed-settings channel: it was accepted as an optional constructor argument that no concrete service ever supplied, so its precedence branch was unreachable while diagnostics still reported the channel. Resolution is now native MDM plus server, matching `shouldForceRemoteSettingsRefresh`; wiring the file channel is follow-up work. Trim the rest to what the change needs: collapse the Policy Diagnostics section to a single table, unexport two module-private helpers, and revert the exported setting ids that only existed to label the removed diagnostics rows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Resolve sandbox floor with managed-settings precedence Use the canonical native MDM, server, and file precedence when deciding whether the managed sandbox floor should retire the local harness. Observe file-managed changes and include that channel in policy diagnostics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Encapsulate managed settings resolution Expose effective managed values from AccountPolicyService through a source-agnostic platform service. Agent Host now observes one resolved sandbox value instead of depending on native, server, and file channel implementations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Agent Host test service mocks Register the effective managed-settings service in web enablement tests and complete the Agent Host enablement mock used by chat component fixtures. Format the earlier test-stub updates so hygiene accepts them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Restore chat harness setting descriptions Remove the managed-sandbox qualification from the existing harness setting descriptions and leave their behavior documentation unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Keep local harness when Agent Host is unavailable Apply the managed sandbox harness override only while Agent Host is enabled, including picker visibility and remembered session usability. Also reduce policy diagnostics to the effective harness decision. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentHostEnablementService.ts | 12 +- .../common/agentHostEnablementService.ts | 11 ++ .../agentHostEnablementService.test.ts | 38 +++++- .../policy/common/copilotManagedSettings.ts | 28 +++++ .../node/nativeManagedSettingsService.test.ts | 8 +- .../copilotChatSessionsProvider.test.ts | 4 +- .../electron-browser/sessions.main.ts | 3 +- .../browser/actions/developerActions.ts | 32 +++++ src/vs/workbench/browser/web.main.ts | 3 +- .../chat/browser/actions/chatActions.ts | 3 +- .../input/sessionTargetPickerActionItem.ts | 15 ++- .../widgetHosts/editor/chatEditorInput.ts | 6 +- .../widgetHosts/viewPane/chatViewPane.ts | 6 +- .../contrib/chat/common/constants.ts | 87 ++++++++++---- ...lowSignedOutWhenUsableContribution.test.ts | 2 +- .../agentHostChatContribution.test.ts | 6 +- ...HostCopilotCliSettingsContribution.test.ts | 2 +- .../agentHostTerminalContribution.test.ts | 4 +- .../editor/chatEditorInput.test.ts | 8 +- .../chat/test/common/constants.test.ts | 111 +++++++++++++++++- .../electron-browser/desktop.main.ts | 3 +- .../browser/webAgentHostEnablementService.ts | 4 +- ...editorRemoteAgentHostServiceClient.test.ts | 2 +- .../webAgentHostEnablementService.test.ts | 2 + .../electron-browser/agentHostService.test.ts | 3 +- .../policies/common/accountPolicyService.ts | 19 ++- .../test/browser/accountPolicyService.test.ts | 27 +++-- .../chat/chatFixtureUtils.ts | 1 + 28 files changed, 381 insertions(+), 69 deletions(-) diff --git a/src/vs/platform/agentHost/browser/agentHostEnablementService.ts b/src/vs/platform/agentHost/browser/agentHostEnablementService.ts index 22fce8f0b3e294..23a6aa0342b05d 100644 --- a/src/vs/platform/agentHost/browser/agentHostEnablementService.ts +++ b/src/vs/platform/agentHost/browser/agentHostEnablementService.ts @@ -4,13 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../base/common/lifecycle.js'; -import { derived, IObservable } from '../../../base/common/observable.js'; +import { derived, IObservable, observableFromEvent } from '../../../base/common/observable.js'; import { isWeb } from '../../../base/common/platform.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { ChatAIDisabledSettingId } from '../../chat/common/chatSettings.js'; import { IContextKeyService } from '../../contextkey/common/contextkey.js'; import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; import { bindContextKey, observableConfigValue } from '../../observable/common/platformObservableUtils.js'; +import { COPILOT_SANDBOX_ENABLED_KEY, IManagedSettingsService } from '../../policy/common/copilotManagedSettings.js'; import { AGENT_HOST_ENABLED_CONTEXT_KEY, IAgentHostEnablementService } from '../common/agentHostEnablementService.js'; export class AgentHostEnablementService extends Disposable implements IAgentHostEnablementService { @@ -18,16 +19,22 @@ export class AgentHostEnablementService extends Disposable implements IAgentHost declare readonly _serviceBrand: undefined; readonly enabled: IObservable; + readonly managedSandboxEnforced: IObservable; constructor( private readonly _isAgentHostRuntimeAvailable: boolean, configurationService: IConfigurationService, contextKeyService: IContextKeyService, + managedSettingsService: IManagedSettingsService, ) { super(); const aiFeaturesDisabled = observableConfigValue(ChatAIDisabledSettingId, false, configurationService); this.enabled = derived(this, reader => this._isAgentHostRuntimeAvailable && !aiFeaturesDisabled.read(reader)); this._register(bindContextKey(AGENT_HOST_ENABLED_CONTEXT_KEY, contextKeyService, reader => this.enabled.read(reader))); + + this.managedSandboxEnforced = observableFromEvent(this, + managedSettingsService.onDidChangeManagedSettings, + () => managedSettingsService.getManagedSettingValue(COPILOT_SANDBOX_ENABLED_KEY) === true); } } @@ -35,8 +42,9 @@ class BrowserAgentHostEnablementService extends AgentHostEnablementService { constructor( @IConfigurationService configurationService: IConfigurationService, @IContextKeyService contextKeyService: IContextKeyService, + @IManagedSettingsService managedSettingsService: IManagedSettingsService, ) { - super(!isWeb, configurationService, contextKeyService); + super(!isWeb, configurationService, contextKeyService, managedSettingsService); } } diff --git a/src/vs/platform/agentHost/common/agentHostEnablementService.ts b/src/vs/platform/agentHost/common/agentHostEnablementService.ts index 587f0fa2fdfdd3..96f8f8c8cb09ab 100644 --- a/src/vs/platform/agentHost/common/agentHostEnablementService.ts +++ b/src/vs/platform/agentHost/common/agentHostEnablementService.ts @@ -22,6 +22,17 @@ export interface IAgentHostEnablementService { * Whether Agent Host features are available and AI features are enabled in this window. */ readonly enabled: IObservable; + /** + * Whether an enterprise has mandated the Copilot SDK sandbox floor through managed settings + * (`sandbox.enabled`). The runtime owns composing and enforcing that floor; VS Code reads it + * only to retire the legacy local harness for governed users, since the sandbox is implemented + * by the Agent Host. + * + * A user- or workspace-level sandbox opt-in is not an enterprise decision and does not set + * this. Existing local chat sessions keep working; only the harness used for *new* chats is + * affected, and virtual workspaces are exempt. + */ + readonly managedSandboxEnforced: IObservable; } const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); diff --git a/src/vs/platform/agentHost/test/browser/agentHostEnablementService.test.ts b/src/vs/platform/agentHost/test/browser/agentHostEnablementService.test.ts index d2fa3796d9d716..2ae3b0ef95c0f9 100644 --- a/src/vs/platform/agentHost/test/browser/agentHostEnablementService.test.ts +++ b/src/vs/platform/agentHost/test/browser/agentHostEnablementService.test.ts @@ -6,11 +6,13 @@ import assert from 'assert'; import { autorun } from '../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { Emitter } from '../../../../base/common/event.js'; import { AgentHostEnablementService } from '../../browser/agentHostEnablementService.js'; import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../common/agentHostEnablementService.js'; import { ConfigurationTarget, IConfigurationChangeEvent, IConfigurationOverrides } from '../../../configuration/common/configuration.js'; import { ChatAIDisabledSettingId } from '../../../chat/common/chatSettings.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; +import { COPILOT_SANDBOX_ENABLED_KEY, IManagedSettingsService, NullManagedSettingsService } from '../../../policy/common/copilotManagedSettings.js'; import { MockContextKeyService } from '../../../keybinding/test/common/mockKeybindingService.js'; class AgentHostTestConfigurationService extends TestConfigurationService { @@ -41,7 +43,7 @@ class AgentHostTestConfigurationService extends TestConfigurationService { suite('AgentHostEnablementService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - function createService(aiDisabled = false, runtimeAvailable = true): { + function createService(aiDisabled = false, runtimeAvailable = true, managedSettingsService: IManagedSettingsService = new NullManagedSettingsService()): { readonly service: AgentHostEnablementService; readonly configurationService: AgentHostTestConfigurationService; readonly contextKeyService: MockContextKeyService; @@ -49,7 +51,12 @@ suite('AgentHostEnablementService', () => { const configurationService = new AgentHostTestConfigurationService(aiDisabled); disposables.add(configurationService.onDidChangeConfigurationEmitter); const contextKeyService = disposables.add(new MockContextKeyService()); - const service = disposables.add(new AgentHostEnablementService(runtimeAvailable, configurationService, contextKeyService)); + const service = disposables.add(new AgentHostEnablementService( + runtimeAvailable, + configurationService, + contextKeyService, + managedSettingsService, + )); return { service, configurationService, contextKeyService }; } @@ -105,4 +112,31 @@ suite('AgentHostEnablementService', () => { }); }); + test('tracks the effective managed sandbox floor', () => { + let sandboxEnabled = false; + const managedSettingsEmitter = disposables.add(new Emitter()); + const managedSettingsService: IManagedSettingsService = { + _serviceBrand: undefined, + onDidChangeManagedSettings: managedSettingsEmitter.event, + getManagedSettingValue: key => key === COPILOT_SANDBOX_ENABLED_KEY ? sandboxEnabled : undefined, + }; + + const { service } = createService(false, true, managedSettingsService); + const changes: boolean[] = []; + disposables.add(autorun(reader => changes.push(service.managedSandboxEnforced.read(reader)))); + + sandboxEnabled = true; + managedSettingsEmitter.fire(); + sandboxEnabled = false; + managedSettingsEmitter.fire(); + + assert.deepStrictEqual({ + enforced: service.managedSandboxEnforced.get(), + changes, + }, { + enforced: false, + changes: [false, true, false], + }); + }); + }); diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index c8c981ae197574..3796ecef8a51b8 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -55,12 +55,22 @@ export const COPILOT_ALLOW_MANAGED_HOOKS_ONLY_KEY = 'allowManagedHooksOnly'; /** Managed-settings transport control that requires a fresh server fetch on startup. */ export const COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY = 'forceRemoteSettingsRefresh'; +/** + * Enterprise-mandated sandbox floor (`sandbox.enabled` in the runtime's managed-settings schema). + * The runtime owns composing and enforcing this floor — it is `force-on-wins`, so a managed `true` + * cannot be loosened by the user. VS Code only *reads* it to decide which chat harness to offer, + * and deliberately declares no configuration policy for it: the control is runtime-owned, and + * mirroring it as a VS Code policy would invert ownership. + */ +export const COPILOT_SANDBOX_ENABLED_KEY = 'sandbox.enabled'; + /** * Managed-settings controls consumed by the delivery pipeline itself rather than by a * configuration policy. Native MDM must watch these even though no setting declares them. */ export const MANAGED_SETTINGS_CONTROL_DEFINITIONS: IManagedSettingsPolicyDefinitions = { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: { type: 'boolean' }, + [COPILOT_SANDBOX_ENABLED_KEY]: { type: 'boolean' }, }; /** Policy-only configuration delivery slot for {@link COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_KEY}. */ @@ -152,6 +162,24 @@ export function shouldForceRemoteSettingsRefresh(nativeMdm: ManagedSettingsData return server?.[COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY] === true; } +export const IManagedSettingsService = createDecorator('managedSettingsService'); + +/** Read-only access to effective managed settings after channel resolution. */ +export interface IManagedSettingsService { + readonly _serviceBrand: undefined; + readonly onDidChangeManagedSettings: Event; + getManagedSettingValue(key: string): ManagedSettingValue | undefined; +} + +export class NullManagedSettingsService implements IManagedSettingsService { + readonly _serviceBrand: undefined; + readonly onDidChangeManagedSettings = Event.None; + + getManagedSettingValue(): ManagedSettingValue | undefined { + return undefined; + } +} + let managedModelValueCallback: ((policyData: IPolicyData) => ManagedSettingValue | undefined) | undefined; /** Trim a managed-settings model value, treating a blank/whitespace-only string as unset. */ diff --git a/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts b/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts index 2e2bd68f7b4d6c..9d98995a30bbeb 100644 --- a/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts +++ b/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts @@ -10,7 +10,7 @@ import { ManagedSettingsData } from '../../../../base/common/policy.js'; import { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY } from '../../common/copilotManagedSettings.js'; +import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_SANDBOX_ENABLED_KEY } from '../../common/copilotManagedSettings.js'; import { NativeManagedSettingsChannelClient } from '../../common/nativeManagedSettingsIpc.js'; import { PolicyValue } from '../../common/policy.js'; import { NativeManagedSettingsService, NativePolicyWatcherFactory } from '../../node/nativeManagedSettingsService.js'; @@ -26,6 +26,7 @@ suite('NativeManagedSettingsService', () => { assert.deepStrictEqual(policies, { [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: { type: 'string' }, [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: { type: 'boolean' }, + [COPILOT_SANDBOX_ENABLED_KEY]: { type: 'boolean' }, }); onDidChange = callback; callback({}); @@ -64,7 +65,10 @@ suite('NativeManagedSettingsService', () => { watchedSettings, managedSettings: service.managedSettings, }, { - watchedSettings: { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: { type: 'boolean' } }, + watchedSettings: { + [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: { type: 'boolean' }, + [COPILOT_SANDBOX_ENABLED_KEY]: { type: 'boolean' }, + }, managedSettings: { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }, }); }); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 1c275327ba9250..12430098468244 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -244,7 +244,7 @@ function createProviderWithConfig( instantiationService.stub(IConfigurationService, configService); instantiationService.stub(IContextKeyService, disposables.add(new MockContextKeyService())); - instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: agentHostEnabled }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: agentHostEnabled, managedSandboxEnforced: constObservable(false) }); instantiationService.stub(IStorageService, disposables.add(new TestStorageService())); instantiationService.stub(IFileDialogService, {}); instantiationService.stub(IDialogService, { @@ -377,7 +377,7 @@ function createProviderForSendTests( getUriLabel: (uri: URI) => uri.path, }); instantiationService.stub(IUriIdentityService, { extUri }); - instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(opts?.agentHostEnabled ?? true) }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(opts?.agentHostEnabled ?? true), managedSandboxEnforced: constObservable(false) }); instantiationService.stub(IContextKeyService, new MockContextKeyService()); instantiationService.stub(IGitHubService, new TestGitHubService()); instantiationService.stub(IPullRequestIconCache, new TestPullRequestIconCache()); diff --git a/src/vs/sessions/electron-browser/sessions.main.ts b/src/vs/sessions/electron-browser/sessions.main.ts index b1f9ec4d6499ae..b72abdc5b4d1ed 100644 --- a/src/vs/sessions/electron-browser/sessions.main.ts +++ b/src/vs/sessions/electron-browser/sessions.main.ts @@ -50,7 +50,7 @@ import { IUserDataProfilesService, reviveProfile } from '../../platform/userData import { UserDataProfilesService } from '../../platform/userDataProfile/common/userDataProfileIpc.js'; import { PolicyChannelClient } from '../../platform/policy/common/policyIpc.js'; import { NativeManagedSettingsChannelClient } from '../../platform/policy/common/nativeManagedSettingsIpc.js'; -import { INativeManagedSettingsService, IFileManagedSettingsService } from '../../platform/policy/common/copilotManagedSettings.js'; +import { INativeManagedSettingsService, IFileManagedSettingsService, IManagedSettingsService } from '../../platform/policy/common/copilotManagedSettings.js'; import { FileManagedSettingsChannelClient } from '../../platform/policy/common/fileManagedSettingsIpc.js'; import { IPolicyService } from '../../platform/policy/common/policy.js'; import { UserDataProfileService } from '../../workbench/services/userDataProfile/common/userDataProfileService.js'; @@ -227,6 +227,7 @@ export class SessionsMain extends Disposable { const fileManagedSettings = this._register(new FileManagedSettingsChannelClient(mainProcessService.getChannel('fileManagedSettings'))); serviceCollection.set(IFileManagedSettingsService, fileManagedSettings); const accountPolicy = this._register(new AccountPolicyService(logService, defaultAccountService, policyChannel, nativeManagedSettings, fileManagedSettings)); + serviceCollection.set(IManagedSettingsService, accountPolicy); if (policyChannel) { policyService = this._register(new MultiplexPolicyService([policyChannel, accountPolicy], logService)); } else { diff --git a/src/vs/workbench/browser/actions/developerActions.ts b/src/vs/workbench/browser/actions/developerActions.ts index c7141aee59ea33..f394728f063cea 100644 --- a/src/vs/workbench/browser/actions/developerActions.ts +++ b/src/vs/workbench/browser/actions/developerActions.ts @@ -50,6 +50,8 @@ import { IDefaultAccountService } from '../../../platform/defaultAccount/common/ import { IAuthenticationService } from '../../services/authentication/common/authentication.js'; import { IAuthenticationAccessService } from '../../services/authentication/browser/authenticationAccessService.js'; import { IPolicyService, PolicyValueSource } from '../../../platform/policy/common/policy.js'; +import { IWorkspaceContextService } from '../../../platform/workspace/common/workspace.js'; +import { isVirtualWorkspace } from '../../../platform/workspace/common/virtualWorkspace.js'; import { COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_STRICT_MARKETPLACES_KEY, INativeManagedSettingsService, IFileManagedSettingsService, ManagedSettingsChannel, ManagedSettingsSource, normalizeManagedSettings, projectManagedSettings, pickManagedSettings } from '../../../platform/policy/common/copilotManagedSettings.js'; import { IManagedSettingPolicyDefinition, ManagedSettingsData } from '../../../base/common/policy.js'; import { APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME, IAccountPolicyGateService } from '../../services/policies/common/accountPolicyService.js'; @@ -735,6 +737,7 @@ interface IPolicyDiagnosticsSummary { effectiveManagedSettings: string; managedSettingsIssues: string; agentRuntime: string; + chatHarnessEnforcement: string; policyControlledSettings: string; } @@ -751,6 +754,7 @@ interface IPolicyDiagnosticsServices { accountPolicyGateService: IAccountPolicyGateService; agentHostService: IAgentHostService; agentHostEnablementService: IAgentHostEnablementService; + workspaceContextService: IWorkspaceContextService; nativeManagedSettingsService: INativeManagedSettingsService | undefined; fileManagedSettingsService: IFileManagedSettingsService | undefined; } @@ -779,6 +783,7 @@ class PolicyDiagnosticsAction extends Action2 { const accountPolicyGateService = accessor.get(IAccountPolicyGateService); const agentHostService = accessor.get(IAgentHostService); const agentHostEnablementService = accessor.get(IAgentHostEnablementService); + const workspaceContextService = accessor.get(IWorkspaceContextService); const progressService = accessor.get(IProgressService); // Native MDM is a desktop-only channel, registered in the renderer service collection on // desktop and Agents windows but absent in web. Resolve it now, synchronously, because the @@ -815,6 +820,7 @@ class PolicyDiagnosticsAction extends Action2 { accountPolicyGateService, agentHostService, agentHostEnablementService, + workspaceContextService, nativeManagedSettingsService, fileManagedSettingsService, })); @@ -834,6 +840,7 @@ class PolicyDiagnosticsAction extends Action2 { accountPolicyGateService, agentHostService, agentHostEnablementService, + workspaceContextService, nativeManagedSettingsService, fileManagedSettingsService, } = services; @@ -845,6 +852,7 @@ class PolicyDiagnosticsAction extends Action2 { effectiveManagedSettings: 'Unavailable', managedSettingsIssues: 'Unavailable', agentRuntime: 'Unavailable', + chatHarnessEnforcement: 'Unavailable', policyControlledSettings: 'Unavailable' }; @@ -1229,6 +1237,29 @@ class PolicyDiagnosticsAction extends Action2 { content += '*No policy-controlled settings found*\n\n'; } + content += '## Chat Harness Enforcement\n\n'; + try { + const sandboxEnforced = agentHostEnablementService.managedSandboxEnforced.get(); + const virtualWorkspace = isVirtualWorkspace(workspaceContextService.getWorkspace()); + const agentHostEnabled = agentHostEnablementService.enabled.get(); + + if (!sandboxEnforced) { + summary.chatHarnessEnforcement = 'Not enforced'; + } else if (virtualWorkspace) { + summary.chatHarnessEnforcement = 'Mandated, not applied (virtual workspace)'; + } else if (!agentHostEnabled) { + summary.chatHarnessEnforcement = 'Mandated, not applied (Agent Host disabled)'; + } else { + summary.chatHarnessEnforcement = 'Local harness hidden, new chats use the Agent Host Copilot SDK'; + } + + content += `**Effective decision:** ${summary.chatHarnessEnforcement}.\n\n`; + } catch (error) { + const message = getErrorMessage(error); + summary.chatHarnessEnforcement = `Unavailable (${message})`; + content += `*Error resolving chat harness enforcement: ${markdownText(message)}*\n\n`; + } + // Authentication diagnostics content += '## Authentication Information\n\n'; try { @@ -1292,6 +1323,7 @@ class PolicyDiagnosticsAction extends Action2 { ['Effective managed settings', summary.effectiveManagedSettings], ['Managed-settings issues', summary.managedSettingsIssues], ['Agent Runtime', summary.agentRuntime], + ['Chat harness enforcement', summary.chatHarnessEnforcement], ['Policy-controlled settings', summary.policyControlledSettings] ] ) + diff --git a/src/vs/workbench/browser/web.main.ts b/src/vs/workbench/browser/web.main.ts index 5e4099094acd9b..c7f154e0459b41 100644 --- a/src/vs/workbench/browser/web.main.ts +++ b/src/vs/workbench/browser/web.main.ts @@ -69,7 +69,7 @@ import { DelayedLogChannel } from '../services/output/common/delayedLogChannel.j import { dirname, joinPath } from '../../base/common/resources.js'; import { IUserDataProfile, IUserDataProfilesService } from '../../platform/userDataProfile/common/userDataProfile.js'; import { IPolicyService } from '../../platform/policy/common/policy.js'; -import { INativeManagedSettingsService, NullNativeManagedSettingsService } from '../../platform/policy/common/copilotManagedSettings.js'; +import { IManagedSettingsService, INativeManagedSettingsService, NullNativeManagedSettingsService } from '../../platform/policy/common/copilotManagedSettings.js'; import { IRemoteExplorerService } from '../services/remote/common/remoteExplorerService.js'; import { DisposableTunnel, TunnelProtocol } from '../../platform/tunnel/common/tunnel.js'; import { ILabelService } from '../../platform/label/common/label.js'; @@ -372,6 +372,7 @@ export class BrowserMain extends Disposable { const policyService = new AccountPolicyService(logService, defaultAccountService); serviceCollection.set(IPolicyService, policyService); serviceCollection.set(IAccountPolicyGateService, policyService); + serviceCollection.set(IManagedSettingsService, policyService); const configurationService = await this.createWorkspaceAndDependentServices(serviceCollection, workspace, environmentService, userDataProfileService, userDataProfilesService, fileService, remoteAgentService, uriIdentityService, policyService, logService, loggerService, remoteAuthorityResolverService, productService); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index ea142d1eff3018..3d144289c6fc1b 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -597,7 +597,8 @@ export function registerChatActions() { * honoring the remembered harness preference and then the configured default. */ function getNewChatEditorSessionUri(accessor: ServicesAccessor): URI { - return getDefaultNewChatSessionResource(accessor.get(IConfigurationService), accessor.get(IChatSessionsService), accessor.get(IStorageService), accessor.get(IWorkspaceContextService).getWorkspace(), accessor.get(IAgentHostEnablementService).enabled.get()); + const agentHostEnablementService = accessor.get(IAgentHostEnablementService); + return getDefaultNewChatSessionResource(accessor.get(IConfigurationService), accessor.get(IChatSessionsService), accessor.get(IStorageService), accessor.get(IWorkspaceContextService).getWorkspace(), agentHostEnablementService.enabled.get(), undefined, agentHostEnablementService.managedSandboxEnforced.get()); } registerAction2(PrimaryOpenChatGlobalAction); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index a9af3ac24bece7..d97be275daa357 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -7,6 +7,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { renderAsPlaintext } from '../../../../../../base/browser/markdownRenderer.js'; import { renderLabelWithIcons } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { IAction } from '../../../../../../base/common/actions.js'; +import { autorun } from '../../../../../../base/common/observable.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; @@ -197,6 +198,16 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { } })); + // The managed sandbox floor is delivered by managed settings, not configuration, so it needs + // its own subscription to keep the visible harness list in sync. + this._register(autorun(reader => { + this.agentHostEnablementService.managedSandboxEnforced.read(reader); + this._updateAgentSessionItems(); + if (this.element) { + this.renderLabel(this.element); + } + })); + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this._updateAgentSessionItems())); this._updateAgentSessionItems(); @@ -302,11 +313,11 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { * {@link AgentSessionProviders.Local}. */ protected _getDefaultSessionType(): AgentSessionTarget { - return getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get()) as AgentSessionTarget; + return getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()) as AgentSessionTarget; } protected _isVisible(type: AgentSessionTarget): boolean { - return isVisibleEditorChatSessionType(type, this.configurationService, this.chatSessionsService, this.workspaceContextService.getWorkspace()); + return isVisibleEditorChatSessionType(type, this.configurationService, this.chatSessionsService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.managedSandboxEnforced.get(), this.agentHostEnablementService.enabled.get()); } protected _isSessionTypeEnabled(type: AgentSessionTarget): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts index b285bf15a04bdd..21f8c4c748b2c7 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts @@ -251,7 +251,7 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler } if (this.shouldReplaceEmptyLocalSession(this._sessionResource)) { - const defaultResource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get()); + const defaultResource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()); if (getChatSessionType(defaultResource) !== localChatSessionType) { let modelRef: IChatModelReference | undefined; try { @@ -276,7 +276,7 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler if (this.options.explicitSessionType === localChatSessionType) { this.modelRef.value = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { canUseTools: !inputType, debugOwner: 'ChatEditorInput#resolveExplicitLocal' }); } else { - const defaultResource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get()); + const defaultResource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()); if (getChatSessionType(defaultResource) === localChatSessionType) { this.modelRef.value = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { canUseTools: !inputType, debugOwner: 'ChatEditorInput#resolveUntitled' }); } else { @@ -321,7 +321,7 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler && this.options.explicitSessionType !== localChatSessionType && !!this.model && !this.model.hasRequests - && getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get()) !== localChatSessionType; + && getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()) !== localChatSessionType; } /** diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index fa4084505f4af4..5a77a716baccd3 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -1307,11 +1307,11 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { */ private async acquireDefaultNewSession(token: CancellationToken): Promise { const workspace = this.workspaceContextService.getWorkspace(); - const defaultType = getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService, workspace, this.agentHostEnablementService.enabled.get()); + const defaultType = getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService, workspace, this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()); if (defaultType === localChatSessionType) { return undefined; } - const resource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService, this.storageService, workspace, this.agentHostEnablementService.enabled.get()); + const resource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService, this.storageService, workspace, this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()); try { return await this.chatService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, token, 'ChatViewPane#acquireDefaultNewSession'); } catch (error) { @@ -1347,7 +1347,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private shouldSkipRestoredLocalSession(sessionResource: URI, model: IChatModel): boolean { const workspace = this.workspaceContextService.getWorkspace(); - const defaultType = getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService, workspace, this.agentHostEnablementService.enabled.get()); + const defaultType = getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService, workspace, this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()); return defaultType !== localChatSessionType && getChatSessionType(sessionResource) === localChatSessionType && !model.hasRequests; diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index e23300dd17b4ab..ada948dabfce68 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -302,25 +302,26 @@ export function isSupportedChatFileScheme(accessor: ServicesAccessor, scheme: st * editor window. * * Virtual workspaces always default to {@link localChatSessionType}. Otherwise, - * when the agent host is enabled and `chat.defaultToCopilotHarness` is opted in, - * Agent Host Copilot CLI is the default. It falls back to the local harness - * when enabled, or to the first visible non-local provider. + * when the agent host is enabled and either `chat.defaultToCopilotHarness` is opted in or the + * agent sandbox is enforced by policy, Agent Host Copilot CLI is the default. It falls back to + * the local harness when enabled, or to the first visible non-local provider. */ export function getComputedDefaultSessionType( configurationService: IConfigurationService, chatSessionsService: Pick, workspace: IWorkspace, - agentHostEnabled: boolean + agentHostEnabled: boolean, + managedSandboxEnforced = false ): string { if (isVirtualWorkspace(workspace)) { return localChatSessionType; } - if (agentHostEnabled && configurationService.getValue(ChatConfiguration.DefaultToCopilotHarness)) { + if (agentHostEnabled && isCopilotHarnessDefault(configurationService, managedSandboxEnforced)) { return SessionType.AgentHostCopilot; } - if (isEditorLocalAgentEnabled(configurationService, workspace)) { + if (isEditorLocalAgentEnabled(configurationService, workspace, agentHostEnabled && managedSandboxEnforced)) { return localChatSessionType; } @@ -343,14 +344,15 @@ export function isNewChatSessionTypeUsable( chatSessionsService: Pick, workspace: IWorkspace, agentHostEnabled = true, + managedSandboxEnforced = false, ): boolean { if (sessionType === localChatSessionType) { - return isEditorLocalAgentEnabled(configurationService, workspace); + return isEditorLocalAgentEnabled(configurationService, workspace, agentHostEnabled && managedSandboxEnforced); } if (isAgentHostTarget(sessionType)) { return agentHostEnabled; } - return isVisibleEditorChatSessionType(sessionType, configurationService, chatSessionsService, workspace); + return isVisibleEditorChatSessionType(sessionType, configurationService, chatSessionsService, workspace, managedSandboxEnforced); } export interface IDefaultNewChatSessionTypeOptions { @@ -369,7 +371,8 @@ export function getDefaultNewChatSessionType( storageService: IStorageService, workspace: IWorkspace, agentHostEnabled: boolean, - options?: IDefaultNewChatSessionTypeOptions + options?: IDefaultNewChatSessionTypeOptions, + managedSandboxEnforced = false ): string { if (options?.explicitOverride) { return options.explicitOverride; @@ -379,16 +382,16 @@ export function getDefaultNewChatSessionType( return localChatSessionType; } - const remembered = getUsableRememberedSessionType(storageService, configurationService, chatSessionsService, workspace, agentHostEnabled); + const remembered = getUsableRememberedSessionType(storageService, configurationService, chatSessionsService, workspace, agentHostEnabled, managedSandboxEnforced); if (remembered) { return remembered; } - if (options?.currentSessionType && isNewChatSessionTypeUsable(options.currentSessionType, configurationService, chatSessionsService, workspace, agentHostEnabled)) { + if (options?.currentSessionType && isNewChatSessionTypeUsable(options.currentSessionType, configurationService, chatSessionsService, workspace, agentHostEnabled, managedSandboxEnforced)) { return options.currentSessionType; } - return getComputedDefaultSessionType(configurationService, chatSessionsService, workspace, agentHostEnabled); + return getComputedDefaultSessionType(configurationService, chatSessionsService, workspace, agentHostEnabled, managedSandboxEnforced); } export function resolveDefaultNewChatSessionType( @@ -399,7 +402,9 @@ export function resolveDefaultNewChatSessionType( const chatSessionsService = accessor.get(IChatSessionsService); const storageService = accessor.get(IStorageService); const workspace = accessor.get(IWorkspaceContextService).getWorkspace(); - const agentHostEnabled = accessor.get(IAgentHostEnablementService).enabled.get(); + const agentHostEnablementService = accessor.get(IAgentHostEnablementService); + const agentHostEnabled = agentHostEnablementService.enabled.get(); + const managedSandboxEnforced = agentHostEnablementService.managedSandboxEnforced.get(); if (options?.explicitOverride) { return { sessionType: options.explicitOverride }; @@ -409,18 +414,18 @@ export function resolveDefaultNewChatSessionType( return { sessionType: localChatSessionType }; } - const remembered = getUsableRememberedSessionType(storageService, configurationService, chatSessionsService, workspace, agentHostEnabled); + const remembered = getUsableRememberedSessionType(storageService, configurationService, chatSessionsService, workspace, agentHostEnabled, managedSandboxEnforced); if (remembered && remembered !== localChatSessionType) { return { sessionType: remembered }; } if (options?.currentSessionType === localChatSessionType && agentHostEnabled - && configurationService.getValue(ChatConfiguration.EditorPreferCopilotHarness)) { + && isCopilotHarnessPreferred(configurationService, managedSandboxEnforced)) { return { sessionType: SessionType.AgentHostCopilot }; } - return { sessionType: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, workspace, agentHostEnabled, options) }; + return { sessionType: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, workspace, agentHostEnabled, options, managedSandboxEnforced) }; } function getUsableRememberedSessionType( @@ -429,9 +434,10 @@ function getUsableRememberedSessionType( chatSessionsService: Pick, workspace: IWorkspace, agentHostEnabled: boolean, + managedSandboxEnforced = false, ): string | undefined { const remembered = getRememberedSessionType(storageService); - return remembered && isNewChatSessionTypeUsable(remembered, configurationService, chatSessionsService, workspace, agentHostEnabled) ? remembered : undefined; + return remembered && isNewChatSessionTypeUsable(remembered, configurationService, chatSessionsService, workspace, agentHostEnabled, managedSandboxEnforced) ? remembered : undefined; } export function getDefaultNewChatSessionResource( @@ -440,9 +446,10 @@ export function getDefaultNewChatSessionResource( storageService: IStorageService, workspace: IWorkspace, agentHostEnabled: boolean, - options?: IDefaultNewChatSessionTypeOptions + options?: IDefaultNewChatSessionTypeOptions, + managedSandboxEnforced = false ): URI { - const defaultType = getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, workspace, agentHostEnabled, options); + const defaultType = getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, workspace, agentHostEnabled, options, managedSandboxEnforced); return getNewChatSessionResource(defaultType); } @@ -461,18 +468,52 @@ export function recordUserSelectedSessionType( } } -export function isEditorLocalAgentEnabled(configurationService: IConfigurationService, workspace: IWorkspace): boolean { - return isVirtualWorkspace(workspace) || (configurationService.getValue(ChatConfiguration.EditorLocalAgentEnabled) ?? true); +/** + * Whether new editor and panel chats should default to the Agent Host Copilot SDK. Enterprises + * whose managed settings mandate the SDK sandbox floor get this behavior without opting into + * `chat.defaultToCopilotHarness`. + */ +function isCopilotHarnessDefault(configurationService: IConfigurationService, managedSandboxEnforced = false): boolean { + return configurationService.getValue(ChatConfiguration.DefaultToCopilotHarness) === true + || managedSandboxEnforced; +} + +/** + * Whether the Agent Host Copilot SDK replaces the local harness whenever the local harness would + * otherwise be picked for a new chat. Implied by an enterprise-mandated sandbox floor. + */ +function isCopilotHarnessPreferred(configurationService: IConfigurationService, managedSandboxEnforced = false): boolean { + return configurationService.getValue(ChatConfiguration.EditorPreferCopilotHarness) === true + || managedSandboxEnforced; +} + +/** + * Whether the legacy local chat harness is offered. Virtual workspaces always keep it. Outside + * virtual workspaces, an enterprise-mandated sandbox floor retires it: the sandbox is implemented + * by the Agent Host, so the enterprise has declared these users governed. + */ +export function isEditorLocalAgentEnabled(configurationService: IConfigurationService, workspace: IWorkspace, managedSandboxEnforced = false): boolean { + if (isVirtualWorkspace(workspace)) { + return true; + } + + if (managedSandboxEnforced) { + return false; + } + + return configurationService.getValue(ChatConfiguration.EditorLocalAgentEnabled) ?? true; } export function isVisibleEditorChatSessionType( sessionType: string, configurationService: IConfigurationService, chatSessionsService: Pick, - workspace: IWorkspace + workspace: IWorkspace, + managedSandboxEnforced = false, + agentHostEnabled = true ): boolean { if (sessionType === localChatSessionType) { - return isEditorLocalAgentEnabled(configurationService, workspace) || getVisibleNonLocalEditorChatSessionTypes(configurationService, chatSessionsService, workspace).length === 0; + return isEditorLocalAgentEnabled(configurationService, workspace, agentHostEnabled && managedSandboxEnforced) || getVisibleNonLocalEditorChatSessionTypes(configurationService, chatSessionsService, workspace).length === 0; } if (sessionType === SessionType.CopilotCLI) { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAllowSignedOutWhenUsableContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAllowSignedOutWhenUsableContribution.test.ts index 04c082529e013d..99d6e74be38e66 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAllowSignedOutWhenUsableContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAllowSignedOutWhenUsableContribution.test.ts @@ -96,7 +96,7 @@ function setup(disposables: DisposableStore, settings: Record) const configurationService = new TestConfigurationService(settings); instantiationService.stub(IAgentHostService, agentHostService); instantiationService.stub(IConfigurationService, configurationService); - instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(true) }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false) }); disposables.add(instantiationService.createInstance(AgentHostAllowSignedOutWhenUsableContribution)); return { agentHostService, configurationService }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index ab5c59c9f53883..8060ae9072a788 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -775,7 +775,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv ...languageModelToolsServiceOverride, }); instantiationService.stub(IOutputService, { getChannel: () => undefined }); - instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(true) }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false) }); instantiationService.stub(IProgressService, { withProgress: (_options: IProgressNotificationOptions, task: (progress: IProgress) => Promise) => task({ report: () => { } }) }); instantiationService.stub(IWorkspaceContextService, { getWorkbenchState: () => workspaceFolders.length > 1 ? WorkbenchState.WORKSPACE : workspaceFolders.length === 1 ? WorkbenchState.FOLDER : WorkbenchState.EMPTY, @@ -8778,7 +8778,7 @@ suite('AgentHostChatContribution', () => { test('setting gate prevents registration', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { instantiationService } = createTestServices(disposables); - instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(false) }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false) }); const contribution = disposables.add(instantiationService.createInstance(AgentHostContribution)); // Contribution should exist but not have registered any agents @@ -8806,7 +8806,7 @@ suite('AgentHostChatContribution', () => { const { instantiationService, agentHostService, chatSessionContributions } = createTestServices( disposables, undefined, undefined, undefined, undefined, false, undefined, { [ChatAIDisabledSettingId]: false }); const enabled = observableValue('agentHostEnabled', true); - instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled, managedSandboxEnforced: constObservable(false) }); let progressStarts = 0; instantiationService.stub(IProgressService, { withProgress: (_options: IProgressNotificationOptions, task: (progress: IProgress) => Promise) => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts index bbcdfa26540998..68d1bd7a8bd4fa 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts @@ -92,7 +92,7 @@ function setup(disposables: DisposableStore, settings: Record) const configurationService = new TestConfigurationService(settings); instantiationService.stub(IAgentHostService, agentHostService); instantiationService.stub(IConfigurationService, configurationService); - instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(true) }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false) }); disposables.add(instantiationService.createInstance(AgentHostCopilotCliSettingsContribution)); return { agentHostService }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts index 1fc6b0fd472ce8..982d98c9d98be2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { OS, OperatingSystem } from '../../../../../../base/common/platform.js'; -import { observableValue } from '../../../../../../base/common/observable.js'; +import { observableValue, constObservable } from '../../../../../../base/common/observable.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -216,7 +216,7 @@ function setup(disposables: DisposableStore, agentHostEnabled: boolean = true, r instantiationService.stub(IAgentHostService, agentHostService); instantiationService.stub(IConfigurationService, configurationService); - instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: observableValue('agentHostEnabled', agentHostEnabled) }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: observableValue('agentHostEnabled', agentHostEnabled), managedSandboxEnforced: constObservable(false) }); instantiationService.stub(IWorkbenchEnvironmentService, new class extends mock() { override readonly remoteAuthority = remoteAuthority; }()); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts index 4426cbd6985deb..8105c4c5f09433 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts @@ -68,7 +68,7 @@ suite('ChatEditorInput', () => { {} as IStorageService, new NullLogService(), new TestContextService(), - { _serviceBrand: undefined, enabled: constObservable(false) }, + { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false) }, ); try { @@ -123,7 +123,7 @@ suite('ChatEditorInput', () => { {} as IStorageService, new NullLogService(), new TestContextService(), - { _serviceBrand: undefined, enabled: constObservable(false) }, + { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false) }, ); try { @@ -156,7 +156,7 @@ suite('ChatEditorInput', () => { }]); const storageService = store.add(new TestStorageService()); const workspaceContextService = new TestContextService(); - const agentHostEnablementService = { _serviceBrand: undefined, enabled: constObservable(true) } satisfies IAgentHostEnablementService; + const agentHostEnablementService = { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false) } satisfies IAgentHostEnablementService; instantiationService.stub(IChatService, {}); instantiationService.stub(IDialogService, {}); @@ -205,7 +205,7 @@ suite('ChatEditorInput', () => { instantiationService.set(IStorageService, store.add(new TestStorageService())); instantiationService.set(ILogService, new NullLogService()); instantiationService.set(IWorkspaceContextService, new TestContextService()); - instantiationService.set(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(agentHostEnabled) }); + instantiationService.set(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(agentHostEnabled), managedSandboxEnforced: constObservable(false) }); return store.add(instantiationService.createInstance(ChatEditorInput, resource, {})); } diff --git a/src/vs/workbench/contrib/chat/test/common/constants.test.ts b/src/vs/workbench/contrib/chat/test/common/constants.test.ts index 6805d7aa471e36..7634e5d3931421 100644 --- a/src/vs/workbench/contrib/chat/test/common/constants.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/constants.test.ts @@ -17,7 +17,7 @@ import { ChatConfiguration, ChatPermissionLevel, getChatPermissionLevelFromDefau import { localChatSessionType, SessionType, IChatSessionsExtensionPoint, IChatSessionsService } from '../../common/chatSessionsService.js'; import { MockChatSessionsService } from './mockChatSessionsService.js'; import { TestContextService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; -import { getRememberedSessionType } from '../../common/chatSessionTypePreference.js'; +import { getRememberedSessionType, storeUserSelectedSessionType } from '../../common/chatSessionTypePreference.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; suite('ChatConfiguration defaults', () => { @@ -59,7 +59,7 @@ suite('ChatConfiguration defaults', () => { accessor.set(IChatSessionsService, chatSessionsService); accessor.set(IStorageService, storageService); accessor.set(IWorkspaceContextService, new TestContextService(workspace)); - accessor.set(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(agentHostEnabled) }); + accessor.set(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(agentHostEnabled), managedSandboxEnforced: constObservable(false) }); return resolveDefaultNewChatSessionType(accessor, options); } @@ -538,6 +538,113 @@ suite('ChatConfiguration defaults', () => { }); }); + test('managed sandbox floor hides the local harness and defaults to the Copilot SDK', () => { + const configurationService = new TestConfigurationService(); + const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot, SessionType.AgentHostClaude); + const storageService = disposables.add(new TestStorageService()); + + // `chat.editor.localAgent.enabled` and `chat.defaultToCopilotHarness` are left at their + // defaults: an enterprise-mandated sandbox floor implies both. + assert.deepStrictEqual({ + localEnabled: isEditorLocalAgentEnabled(configurationService, localWorkspace, true), + localVisible: isVisibleEditorChatSessionType(localChatSessionType, configurationService, chatSessionsService, localWorkspace, true), + localUsable: isNewChatSessionTypeUsable(localChatSessionType, configurationService, chatSessionsService, localWorkspace, true, true), + computed: getComputedDefaultSessionType(configurationService, chatSessionsService, localWorkspace, true, true), + rememberedAware: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, localWorkspace, true, undefined, true), + fromLocal: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, localWorkspace, true, { currentSessionType: localChatSessionType }, true), + }, { + localEnabled: false, + localVisible: false, + localUsable: false, + computed: SessionType.AgentHostCopilot, + rememberedAware: SessionType.AgentHostCopilot, + fromLocal: SessionType.AgentHostCopilot, + }); + }); + + test('managed sandbox floor reaches the New Chat entry points and overrides remembered local', () => { + const configurationService = new TestConfigurationService(); + const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot); + const storageService = disposables.add(new TestStorageService()); + + // A local harness remembered from before the floor was mandated must not keep winning: + // otherwise the picker hides local while New Chat keeps opening local sessions. + storeUserSelectedSessionType(storageService, localChatSessionType); + + assert.deepStrictEqual({ + remembered: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, localWorkspace, true, undefined, true), + resource: getChatSessionType(getDefaultNewChatSessionResource(configurationService, chatSessionsService, storageService, localWorkspace, true, undefined, true)), + }, { + remembered: SessionType.AgentHostCopilot, + resource: SessionType.AgentHostCopilot, + }); + }); + + test('managed sandbox floor does not override remembered Claude and Codex selections', () => { + const configurationService = new TestConfigurationService(); + const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot, SessionType.AgentHostClaude, SessionType.AgentHostCodex); + const storageService = disposables.add(new TestStorageService()); + + const currentCodex = getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, localWorkspace, true, { currentSessionType: SessionType.AgentHostCodex }, true); + recordUserSelectedSessionType(storageService, configurationService, chatSessionsService, localWorkspace, SessionType.AgentHostClaude, true); + + assert.deepStrictEqual({ + currentCodex, + rememberedClaude: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, localWorkspace, true, { currentSessionType: localChatSessionType }, true), + }, { + currentCodex: SessionType.AgentHostCodex, + rememberedClaude: SessionType.AgentHostClaude, + }); + }); + + test('no managed sandbox floor leaves the harness settings in charge', () => { + const configurationService = new TestConfigurationService(); + const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot); + const storageService = disposables.add(new TestStorageService()); + + assert.deepStrictEqual({ + localEnabled: isEditorLocalAgentEnabled(configurationService, localWorkspace, false), + computed: getComputedDefaultSessionType(configurationService, chatSessionsService, localWorkspace, true, false), + resolved: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, localWorkspace, true, { currentSessionType: localChatSessionType }, false), + }, { + localEnabled: true, + computed: localChatSessionType, + resolved: localChatSessionType, + }); + }); + + test('managed sandbox floor keeps local when Agent Host is disabled', () => { + const configurationService = new TestConfigurationService(); + const chatSessionsService = createChatSessionsService(SessionType.AgentHostClaude); + const storageService = disposables.add(new TestStorageService()); + + assert.deepStrictEqual({ + visible: isVisibleEditorChatSessionType(localChatSessionType, configurationService, chatSessionsService, localWorkspace, true, false), + usable: isNewChatSessionTypeUsable(localChatSessionType, configurationService, chatSessionsService, localWorkspace, false, true), + computed: getComputedDefaultSessionType(configurationService, chatSessionsService, localWorkspace, false, true), + resolved: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, localWorkspace, false, { currentSessionType: localChatSessionType }, true), + }, { + visible: true, + usable: true, + computed: localChatSessionType, + resolved: localChatSessionType, + }); + }); + + test('virtual workspace keeps local available when the sandbox floor is managed', () => { + const configurationService = new TestConfigurationService(); + const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot); + const workspace = createWorkspace(URI.parse('vscode-vfs://github/microsoft/vscode')); + + assert.deepStrictEqual({ + localEnabled: isEditorLocalAgentEnabled(configurationService, workspace, true), + computed: getComputedDefaultSessionType(configurationService, chatSessionsService, workspace, true, true), + }, { + localEnabled: true, + computed: localChatSessionType, + }); + }); + test('virtual workspace keeps local available when setting is disabled', () => { const configurationService = new TestConfigurationService({ [ChatConfiguration.EditorLocalAgentEnabled]: false, diff --git a/src/vs/workbench/electron-browser/desktop.main.ts b/src/vs/workbench/electron-browser/desktop.main.ts index afb041e7a7c8e7..a827f145c61f0a 100644 --- a/src/vs/workbench/electron-browser/desktop.main.ts +++ b/src/vs/workbench/electron-browser/desktop.main.ts @@ -53,7 +53,7 @@ import { IUserDataProfilesService, reviveProfile } from '../../platform/userData import { UserDataProfilesService } from '../../platform/userDataProfile/common/userDataProfileIpc.js'; import { PolicyChannelClient } from '../../platform/policy/common/policyIpc.js'; import { NativeManagedSettingsChannelClient } from '../../platform/policy/common/nativeManagedSettingsIpc.js'; -import { INativeManagedSettingsService, IFileManagedSettingsService } from '../../platform/policy/common/copilotManagedSettings.js'; +import { INativeManagedSettingsService, IFileManagedSettingsService, IManagedSettingsService } from '../../platform/policy/common/copilotManagedSettings.js'; import { FileManagedSettingsChannelClient } from '../../platform/policy/common/fileManagedSettingsIpc.js'; import { IPolicyService } from '../../platform/policy/common/policy.js'; import { UserDataProfileService } from '../services/userDataProfile/common/userDataProfileService.js'; @@ -224,6 +224,7 @@ export class DesktopMain extends Disposable { const fileManagedSettings = this._register(new FileManagedSettingsChannelClient(mainProcessService.getChannel('fileManagedSettings'))); serviceCollection.set(IFileManagedSettingsService, fileManagedSettings); const accountPolicy = this._register(new AccountPolicyService(logService, defaultAccountService, policyChannel, nativeManagedSettings, fileManagedSettings)); + serviceCollection.set(IManagedSettingsService, accountPolicy); if (policyChannel) { policyService = this._register(new MultiplexPolicyService([policyChannel, accountPolicy], logService)); } else { diff --git a/src/vs/workbench/services/agentHost/browser/webAgentHostEnablementService.ts b/src/vs/workbench/services/agentHost/browser/webAgentHostEnablementService.ts index cb9f7c3c74b31c..d50f022cd5631f 100644 --- a/src/vs/workbench/services/agentHost/browser/webAgentHostEnablementService.ts +++ b/src/vs/workbench/services/agentHost/browser/webAgentHostEnablementService.ts @@ -9,14 +9,16 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; +import { IManagedSettingsService } from '../../../../platform/policy/common/copilotManagedSettings.js'; export class WebAgentHostEnablementService extends AgentHostEnablementService { constructor( @IConfigurationService configurationService: IConfigurationService, @IContextKeyService contextKeyService: IContextKeyService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, + @IManagedSettingsService managedSettingsService: IManagedSettingsService, ) { - super(!!environmentService.remoteAuthority, configurationService, contextKeyService); + super(!!environmentService.remoteAuthority, configurationService, contextKeyService, managedSettingsService); } } diff --git a/src/vs/workbench/services/agentHost/test/browser/editorRemoteAgentHostServiceClient.test.ts b/src/vs/workbench/services/agentHost/test/browser/editorRemoteAgentHostServiceClient.test.ts index 06234984b29641..5fa447388b66d9 100644 --- a/src/vs/workbench/services/agentHost/test/browser/editorRemoteAgentHostServiceClient.test.ts +++ b/src/vs/workbench/services/agentHost/test/browser/editorRemoteAgentHostServiceClient.test.ts @@ -108,7 +108,7 @@ suite('EditorRemoteAgentHostServiceClient', () => { const agentHostEnabled = observableValue('agentHostEnabled', false); const instantiationService = disposables.add(new TestInstantiationService(new ServiceCollection( [IRemoteAgentService, remoteAgentService], - [IAgentHostEnablementService, { _serviceBrand: undefined, enabled: agentHostEnabled }], + [IAgentHostEnablementService, { _serviceBrand: undefined, enabled: agentHostEnabled, managedSandboxEnforced: constObservable(false) }], [ILogService, new NullLogService()], [IWorkbenchEnvironmentService, { isSessionsWindow: false }], ))); diff --git a/src/vs/workbench/services/agentHost/test/browser/webAgentHostEnablementService.test.ts b/src/vs/workbench/services/agentHost/test/browser/webAgentHostEnablementService.test.ts index 0f5eebe944acfa..5a5b852b6aba2c 100644 --- a/src/vs/workbench/services/agentHost/test/browser/webAgentHostEnablementService.test.ts +++ b/src/vs/workbench/services/agentHost/test/browser/webAgentHostEnablementService.test.ts @@ -12,6 +12,7 @@ import { TestConfigurationService } from '../../../../../platform/configuration/ import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { IManagedSettingsService, NullManagedSettingsService } from '../../../../../platform/policy/common/copilotManagedSettings.js'; import { IWorkbenchEnvironmentService } from '../../../environment/common/environmentService.js'; import { WebAgentHostEnablementService } from '../../browser/webAgentHostEnablementService.js'; @@ -32,6 +33,7 @@ suite('WebAgentHostEnablementService', () => { const contextKeyService = disposables.add(new MockContextKeyService()); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IContextKeyService, contextKeyService); + instantiationService.stub(IManagedSettingsService, new NullManagedSettingsService()); instantiationService.stub(IWorkbenchEnvironmentService, { remoteAuthority: options.remoteAuthority }); const service = disposables.add(instantiationService.createInstance(WebAgentHostEnablementService)); diff --git a/src/vs/workbench/services/agentHost/test/electron-browser/agentHostService.test.ts b/src/vs/workbench/services/agentHost/test/electron-browser/agentHostService.test.ts index ede43d110f77e2..ca11193240f024 100644 --- a/src/vs/workbench/services/agentHost/test/electron-browser/agentHostService.test.ts +++ b/src/vs/workbench/services/agentHost/test/electron-browser/agentHostService.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { observableValue } from '../../../../../base/common/observable.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { NullAgentHostService } from '../../../../../platform/agentHost/browser/nullAgentHostService.js'; import { IAgentHostEnablementService } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; @@ -68,6 +68,7 @@ class TestAgentHostEnablementService extends Disposable implements IAgentHostEna private readonly _enabled; readonly enabled; + readonly managedSandboxEnforced = constObservable(false); constructor(enabled: boolean) { super(); diff --git a/src/vs/workbench/services/policies/common/accountPolicyService.ts b/src/vs/workbench/services/policies/common/accountPolicyService.ts index 556d6ff5a655e9..15ddd3d2b5ed22 100644 --- a/src/vs/workbench/services/policies/common/accountPolicyService.ts +++ b/src/vs/workbench/services/policies/common/accountPolicyService.ts @@ -6,12 +6,13 @@ import { IStringDictionary } from '../../../../base/common/collections.js'; import { IPolicyData } from '../../../../base/common/defaultAccount.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { ManagedSettingsData } from '../../../../base/common/policy.js'; +import { equals } from '../../../../base/common/objects.js'; +import { ManagedSettingValue, ManagedSettingsData } from '../../../../base/common/policy.js'; import { localize } from '../../../../nls.js'; import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { INativeManagedSettingsService, IFileManagedSettingsService, IManagedSettingsPick, ManagedSettingsChannel, collectManagedSettingsDefinitions, hasManagedSettingsDefinitions, projectManagedSettings, pickManagedSettings } from '../../../../platform/policy/common/copilotManagedSettings.js'; +import { INativeManagedSettingsService, IFileManagedSettingsService, IManagedSettingsPick, IManagedSettingsService, ManagedSettingsChannel, collectManagedSettingsDefinitions, hasManagedSettingsDefinitions, projectManagedSettings, pickManagedSettings } from '../../../../platform/policy/common/copilotManagedSettings.js'; import { AbstractPolicyService, getRestrictedPolicyValue, IPolicyService, PolicyDefinition, PolicyValue, PolicyValueSource } from '../../../../platform/policy/common/policy.js'; import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; @@ -65,7 +66,7 @@ interface IResolvedPolicyData { readonly managedSettingResolutions: IManagedSettingsPick['resolutions']; } -export class AccountPolicyService extends AbstractPolicyService implements IPolicyService, IAccountPolicyGateService { +export class AccountPolicyService extends AbstractPolicyService implements IPolicyService, IAccountPolicyGateService, IManagedSettingsService { declare readonly _serviceBrand: undefined; @@ -75,6 +76,14 @@ export class AccountPolicyService extends AbstractPolicyService implements IPoli private readonly _onDidChangeGateInfo = this._register(new Emitter()); readonly onDidChangeGateInfo = this._onDidChangeGateInfo.event; + private _managedSettings: ManagedSettingsData = {}; + private readonly _onDidChangeManagedSettings = this._register(new Emitter()); + readonly onDidChangeManagedSettings = this._onDidChangeManagedSettings.event; + + getManagedSettingValue(key: string): ManagedSettingValue | undefined { + return this._managedSettings[key]; + } + // Read-only — the MultiplexPolicyService owns calling updatePolicyDefinitions. private readonly managedPolicyReader?: IPolicyService; private readonly nativeManagedSettingsService?: INativeManagedSettingsService; @@ -237,6 +246,10 @@ export class AccountPolicyService extends AbstractPolicyService implements IPoli // channel is still filled in by a lower one. A key locked by a higher channel cannot be // overwritten. See `.github/skills/policy-and-managed-settings/github-managed-settings.md` for the rationale. const pick = pickManagedSettings(nativeManagedSettings, accountPolicyData?.managedSettings, fileManagedSettings); + if (!equals(this._managedSettings, pick.values)) { + this._managedSettings = pick.values; + this._onDidChangeManagedSettings.fire(); + } if (!accountPolicyData && pick.activeSources.length === 0) { return undefined; } diff --git a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts index 3339f5e5096917..0e8424a01d120c 100644 --- a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts @@ -12,7 +12,7 @@ import { Extensions, IConfigurationNode, IConfigurationRegistry } from '../../.. import { DefaultConfiguration, PolicyConfiguration } from '../../../../../platform/configuration/common/configurations.js'; import { IDefaultAccountProvider, IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; -import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, INativeManagedSettingsService, IFileManagedSettingsService } from '../../../../../platform/policy/common/copilotManagedSettings.js'; +import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_SANDBOX_ENABLED_KEY, INativeManagedSettingsService, IFileManagedSettingsService } from '../../../../../platform/policy/common/copilotManagedSettings.js'; import { AbstractPolicyService, IPolicyService, PolicyDefinition, PolicyValue, PolicyValueSource } from '../../../../../platform/policy/common/policy.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { TestProductService } from '../../../../test/common/workbenchTestServices.js'; @@ -396,22 +396,35 @@ suite('AccountPolicyService', () => { // All three channels provide the same key with different values. // Server says 'enable', MDM says 'disable', File says 'file-value'. // Native MDM should win. - const fileManagedSettingsService = new FakeFileManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'file-value' }); - const nativeManagedSettingsService = disposables.add(new FakeNativeManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' })); + const fileManagedSettingsService = new FakeFileManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'file-value', [COPILOT_SANDBOX_ENABLED_KEY]: true }); + const nativeManagedSettingsService = disposables.add(new FakeNativeManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable', [COPILOT_SANDBOX_ENABLED_KEY]: false })); policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, nativeManagedSettingsService, fileManagedSettingsService)); const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); await defaultConfiguration.initialize(); policyConfiguration = disposables.add(new PolicyConfiguration(defaultConfiguration, policyService, new NullLogService())); - const policyData: IPolicyData = { managedSettings: { [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'enable' } }; + const policyData: IPolicyData = { managedSettings: { [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'enable', [COPILOT_SANDBOX_ENABLED_KEY]: true } }; defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, policyData)); await defaultAccountService.refresh(); await policyConfiguration.initialize(); - // Native MDM value 'disable' wins — policy is forced to false - assert.strictEqual(policyService.getPolicyValue('PolicySettingF'), false); - assert.strictEqual(policyService.getPolicyValueSource('PolicySettingF'), PolicyValueSource.NativeMdm); + const initialSandbox = policyService.getManagedSettingValue(COPILOT_SANDBOX_ENABLED_KEY); + const managedSettingsChanged = Event.toPromise(policyService.onDidChangeManagedSettings); + nativeManagedSettingsService.setManagedSettings({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' }); + await managedSettingsChanged; + + assert.deepStrictEqual({ + policy: policyService.getPolicyValue('PolicySettingF'), + source: policyService.getPolicyValueSource('PolicySettingF'), + initialSandbox, + updatedSandbox: policyService.getManagedSettingValue(COPILOT_SANDBOX_ENABLED_KEY), + }, { + policy: false, + source: PolicyValueSource.NativeMdm, + initialSandbox: false, + updatedSandbox: true, + }); }); test('managed settings: file-based settings apply when server and MDM are empty', async () => { diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index 330acf28f040b0..a0f5cbd74aeb30 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -364,6 +364,7 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I }()); reg.defineInstance(IAgentHostEnablementService, new class extends mock() { override readonly enabled = constObservable(false); + override readonly managedSandboxEnforced = constObservable(false); }()); const artifactGroups = options.artifactGroups ?? observableValue('artifactGroups', []); From e0b73c2f039a35ee7af0d20041c0be9c36948506 Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Tue, 18 Aug 2026 17:22:29 -0400 Subject: [PATCH 15/24] Fix the quota data missing from the agent window (#331536) * Fix the quiota data missing from the agent window * Create a reset date calculation helper --- .../browser/account.contribution.ts | 100 +++++++++++------- .../browser/media/accountWidget.css | 37 ------- .../browser/chatStatus/chatStatusDashboard.ts | 66 ++++++------ .../browser/chatStatus/chatStatusEntry.ts | 17 +-- .../test/browser/chatStatusDashboard.test.ts | 32 ++++++ .../common/chatEntitlementService.test.ts | 72 ++++++++++++- .../chat/common/chatEntitlementService.ts | 92 ++++++++++++++++ 7 files changed, 290 insertions(+), 126 deletions(-) diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index 5e9a3e23cfd1ab..e37dca5953c298 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -21,7 +21,7 @@ import { appendUpdateMenuItems as registerUpdateMenuItems } from '../../../../wo import { Menus } from '../../../browser/menus.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { fillInActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; -import { $, addDisposableListener, append, disposableWindowInterval, EventType, getDomNodePagePosition } from '../../../../base/browser/dom.js'; +import { $, addDisposableListener, append, clearNode, disposableWindowInterval, EventType, getDomNodePagePosition } from '../../../../base/browser/dom.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { ActionBar, ActionsOrientation } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; @@ -30,7 +30,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { registerUpdateTitleBarMenuPlacement } from '../../../../workbench/contrib/update/browser/updateTitleBarEntry.js'; -import { ChatEntitlement, ChatEntitlementService, getChatPlanName, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { ChatEntitlement, ChatEntitlementService, getChatPlanName, getQuotaReset, getQuotaUsage, IChatEntitlementService, IQuotaSnapshot, QuotaUsageKind } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; import { ChatStatusDashboard, IChatStatusDashboardOptions } from '../../../../workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; @@ -663,24 +663,54 @@ class TitleBarAccountWidget extends BaseActionViewItem { } private appendCopilotUsage(accountSection: HTMLElement, panelStore: DisposableStore): void { - const quota = this.chatEntitlementService.quotas.premiumChat ?? this.chatEntitlementService.quotas.chat; const usage = append(accountSection, $('.sessions-account-titlebar-panel-provider-usage')); + const contentStore = panelStore.add(new DisposableStore()); + + const render = () => { + contentStore.clear(); + clearNode(usage); + this.renderCopilotUsage(usage, contentStore); + }; + render(); + + // The panel is built from the cached snapshot while the embedded dashboard kicks off a + // fresh entitlement request, so rebuild the row once that lands rather than leaving it + // stale until the panel is reopened. + panelStore.add(this.chatEntitlementService.onDidChangeQuotaRemaining(render)); + panelStore.add(this.chatEntitlementService.onDidChangeEntitlement(render)); + } + + private renderCopilotUsage(usage: HTMLElement, store: DisposableStore): void { + const quota = this.chatEntitlementService.quotas.premiumChat ?? this.chatEntitlementService.quotas.chat; const planRow = append(usage, $('.sessions-account-titlebar-panel-provider-metric-row.primary')); append(planRow, $('span.sessions-account-titlebar-panel-provider-plan', undefined, this.getCopilotPlanLabel())); - if (quota && !quota.unlimited) { - const usedPercentage = Math.max(0, Math.floor(100 - quota.percentRemaining)); - const usageValue = append(planRow, $('span.sessions-account-titlebar-panel-provider-usage-value', { tabIndex: 0 })); + + const quotaUsage = getQuotaUsage(quota); + if (!quota || !quotaUsage) { + return; + } + + const formatter = safeIntl.NumberFormat(language, { maximumFractionDigits: 2, minimumFractionDigits: 0 }); + + if (quotaUsage.kind === QuotaUsageKind.CreditsUsed) { + const creditsFormatted = formatter.value.format(quotaUsage.creditsUsed); + append(planRow, $('span.sessions-account-titlebar-panel-provider-usage-value', { + 'aria-label': localize('copilotCreditsUsedTotal', "{0} credits used", creditsFormatted) + }, creditsFormatted)); + } else { + const usedPercentage = Math.floor(quotaUsage.usedPercentage); const percentageLabel = localize('copilotCreditsUsedPercentageValue', "{0}%", usedPercentage); const percentageAriaLabel = localize('copilotCreditsUsedPercentage', "{0}% credits used", usedPercentage); + const { used, total } = quotaUsage; + + // Revealing the ratio is the only interaction, so this is a tab stop only when there is a ratio to reveal. + const usageValue = append(planRow, $('span.sessions-account-titlebar-panel-provider-usage-value', used !== undefined && total !== undefined ? { tabIndex: 0 } : undefined)); usageValue.textContent = percentageLabel; usageValue.setAttribute('aria-label', percentageAriaLabel); - if (quota.entitlement) { - const formatter = safeIntl.NumberFormat(language, { maximumFractionDigits: 2, minimumFractionDigits: 0 }); - const used = quota.creditsUsed ?? (quota.quotaRemaining !== undefined - ? quota.entitlement - quota.quotaRemaining - : quota.entitlement * (100 - quota.percentRemaining) / 100); - const creditsValue = localize('copilotCreditsUsedRatioValue', "{0} / {1}", formatter.value.format(used), formatter.value.format(quota.entitlement)); - const creditsAriaLabel = localize('copilotCreditsUsedRatio', "{0} / {1} credits used", formatter.value.format(used), formatter.value.format(quota.entitlement)); + + if (used !== undefined && total !== undefined) { + const creditsValue = localize('copilotCreditsUsedRatioValue', "{0} / {1}", formatter.value.format(used), formatter.value.format(total)); + const creditsAriaLabel = localize('copilotCreditsUsedRatio', "{0} / {1} credits used", formatter.value.format(used), formatter.value.format(total)); const showCredits = () => { usageValue.textContent = creditsValue; usageValue.setAttribute('aria-label', creditsAriaLabel); @@ -689,20 +719,21 @@ class TitleBarAccountWidget extends BaseActionViewItem { usageValue.textContent = percentageLabel; usageValue.setAttribute('aria-label', percentageAriaLabel); }; - panelStore.add(addDisposableListener(usageValue, EventType.MOUSE_ENTER, showCredits)); - panelStore.add(addDisposableListener(usageValue, EventType.MOUSE_LEAVE, showPercentage)); - panelStore.add(addDisposableListener(usageValue, EventType.FOCUS, showCredits)); - panelStore.add(addDisposableListener(usageValue, EventType.BLUR, showPercentage)); - } - const detailRow = append(usage, $('.sessions-account-titlebar-panel-provider-metric-row.secondary')); - const resetLabel = this.getCopilotResetLabel(quota.resetAt); - if (resetLabel) { - append(detailRow, $('span.sessions-account-titlebar-panel-provider-reset', undefined, resetLabel)); - } else { - detailRow.classList.add('without-reset'); + store.add(addDisposableListener(usageValue, EventType.MOUSE_ENTER, showCredits)); + store.add(addDisposableListener(usageValue, EventType.MOUSE_LEAVE, showPercentage)); + store.add(addDisposableListener(usageValue, EventType.FOCUS, showCredits)); + store.add(addDisposableListener(usageValue, EventType.BLUR, showPercentage)); } - append(detailRow, $('span.sessions-account-titlebar-panel-provider-usage-label', undefined, localize('copilotCreditsUsedLabel', "Credits used"))); } + + const detailRow = append(usage, $('.sessions-account-titlebar-panel-provider-metric-row.secondary')); + const resetLabel = this.getCopilotResetLabel(quota); + if (resetLabel) { + append(detailRow, $('span.sessions-account-titlebar-panel-provider-reset', undefined, resetLabel)); + } else { + detailRow.classList.add('without-reset'); + } + append(detailRow, $('span.sessions-account-titlebar-panel-provider-usage-label', undefined, localize('copilotCreditsUsedLabel', "Credits used"))); } private appendChatGPTUsage(accountSection: HTMLElement): void { @@ -734,20 +765,15 @@ class TitleBarAccountWidget extends BaseActionViewItem { append(detailRow, $('span.sessions-account-titlebar-panel-provider-usage-label', undefined, localize('chatGPTLimitUsedLabel', "Limit used"))); } - private getCopilotResetLabel(resetAt: number | undefined): string | undefined { - if (resetAt) { - const resetDate = new Date(resetAt * 1000); - return localize('copilotCreditsResetAt', "Resets {0} at {1}", accountDateFormatter.value.format(resetDate), accountTimeFormatter.value.format(resetDate)); - } - - const { resetDate, resetDateHasTime } = this.chatEntitlementService.quotas; - if (!resetDate) { + private getCopilotResetLabel(quota: IQuotaSnapshot | undefined): string | undefined { + const reset = getQuotaReset(quota, this.chatEntitlementService.quotas); + if (!reset) { return undefined; } - const date = new Date(resetDate); - return resetDateHasTime - ? localize('copilotCreditsResetAt', "Resets {0} at {1}", accountDateFormatter.value.format(date), accountTimeFormatter.value.format(date)) - : localize('copilotCreditsReset', "Resets {0}", accountDateFormatter.value.format(date)); + + return reset.hasTime + ? localize('copilotCreditsResetAt', "Resets {0} at {1}", accountDateFormatter.value.format(reset.date), accountTimeFormatter.value.format(reset.date)) + : localize('copilotCreditsReset', "Resets {0}", accountDateFormatter.value.format(reset.date)); } private getChatGPTLimitLabel(windowDurationMins: number | undefined): string { diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css index dc8a9b2e1ca1f5..b52d2570a8ecce 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css @@ -120,43 +120,6 @@ min-width: 0; } -/* Chat status dashboard embedded in the agents-app titlebar account panel */ -.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip { - max-width: 360px; - padding: 2px 0 4px 0; -} - -.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip div.header { - padding: 8px 10px 8px 8px; -} - -.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip .quota-indicator .quota-title { - font-size: var(--vscode-agents-fontSize-body1); - margin-bottom: 0; - color: var(--vscode-foreground); -} - -.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip .collapsible-inner { - padding-top: 0; -} - -.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip .contribution .header { - padding: 0 10px 0 8px; - margin-bottom: 0; - line-height: 18px; - color: var(--vscode-descriptionForeground); -} - -.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip div.header .monaco-action-bar { - color: var(--vscode-foreground); -} - -.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip .contribution .body { - padding: 0 10px 0 8px; - line-height: 16px; - color: var(--vscode-descriptionForeground); -} - .monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button { width: auto; max-width: none; diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index 18681e3653dad0..c460ce93d1ba22 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -39,7 +39,7 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { defaultButtonStyles, defaultCheckboxStyles, defaultSelectBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { DomWidget } from '../../../../../platform/domWidget/browser/domWidget.js'; import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/editor.js'; -import { IChatEntitlementService, ChatEntitlementService, ChatEntitlement, IQuotaSnapshot, getChatPlanName } from '../../../../services/chat/common/chatEntitlementService.js'; +import { IChatEntitlementService, ChatEntitlementService, ChatEntitlement, IQuotaSnapshot, getChatPlanName, getQuotaReset, getQuotaUsage, QuotaUsageKind } from '../../../../services/chat/common/chatEntitlementService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; import { isNewUser } from './chatStatus.js'; @@ -245,9 +245,9 @@ export class ChatStatusDashboard extends DomWidget { // Premium chat included indicator (shown when premium chat is unlimited) const hasPremiumUnlimited = !!premiumChat?.unlimited; - const creditsUsed = hasPremiumUnlimited && !isPooledQuotaDepleted ? premiumChat?.creditsUsed : undefined; - if (typeof creditsUsed === 'number') { - this.createCreditsUsedIndicator(this.element, creditsUsed, premiumChat?.resetAt); + const premiumChatUsage = getQuotaUsage(premiumChat); + if (premiumChatUsage?.kind === QuotaUsageKind.CreditsUsed) { + this.createCreditsUsedIndicator(this.element, premiumChatUsage.creditsUsed, this.formatQuotaResetLabel(premiumChat)); } else if (hasPremiumUnlimited) { const includedTitle = this.chatEntitlementService.quotas.usageBasedBilling ? localize('includedTitleTBB', "Credits") @@ -299,7 +299,6 @@ export class ChatStatusDashboard extends DomWidget { const planName = compact ? getChatPlanName(this.chatEntitlementService.entitlement) : undefined; if (chatQuota || premiumChatQuota || completionsQuota) { - const resetLabel = this.formatGlobalResetLabel(); // Global quota callout (shown at the top, before quota indicators) const globalCalloutUpdater = this.createGlobalQuotaCallout(container); @@ -320,7 +319,7 @@ export class ChatStatusDashboard extends DomWidget { const chatLabel = this.chatEntitlementService.quotas.usageBasedBilling && this.chatEntitlementService.entitlement === ChatEntitlement.Free ? localize('creditsLabel', "Credits") : localize('chatsLabel', "Chat messages"); - chatQuotaIndicator = this.createQuotaIndicator(container, chatQuota, chatLabel, resetLabel, compact ? planName : undefined); + chatQuotaIndicator = this.createQuotaIndicator(container, chatQuota, chatLabel, this.formatQuotaResetLabel(chatQuota), compact ? planName : undefined); } let premiumChatQuotaIndicator: ((quota: IQuotaSnapshot | string) => void) | undefined; @@ -329,8 +328,7 @@ export class ChatStatusDashboard extends DomWidget { const premiumChatLabel = isUBB ? localize('creditsLabel', "Credits") : this.chatEntitlementService.quotas.additionalUsageEnabled ? localize('includedPremiumChatsLabel', "Included premium requests") : localize('premiumChatsLabel', "Premium requests"); - const premiumChatResetLabel = isUBB ? this.formatResetAtLabel(premiumChatQuota.resetAt) ?? resetLabel : resetLabel; - premiumChatQuotaIndicator = this.createQuotaIndicator(container, premiumChatQuota, premiumChatLabel, premiumChatResetLabel, compact ? planName : undefined); + premiumChatQuotaIndicator = this.createQuotaIndicator(container, premiumChatQuota, premiumChatLabel, this.formatQuotaResetLabel(premiumChatQuota), compact ? planName : undefined); } // Additional Budget indicator (overage bar, shown when overage_entitlement > 0) @@ -345,9 +343,10 @@ export class ChatStatusDashboard extends DomWidget { unlimited: false, entitlement: initialOverageEntitlement, quotaRemaining: Math.max(0, initialOverageEntitlement - overageCount), + resetAt: premiumChatQuota?.resetAt, }; const additionalBudgetLabel = localize('additionalBudgetLabel', "Additional Budget"); - additionalBudgetIndicator = this.createQuotaIndicator(container, overageSnapshot, additionalBudgetLabel, resetLabel, compact ? additionalBudgetLabel : undefined); + additionalBudgetIndicator = this.createQuotaIndicator(container, overageSnapshot, additionalBudgetLabel, this.formatQuotaResetLabel(overageSnapshot), compact ? additionalBudgetLabel : undefined); additionalBudgetElement = container.lastElementChild as HTMLElement; const isPremiumExhausted = premiumChatQuota && premiumChatQuota.percentRemaining <= 0; if (!isPremiumExhausted) { @@ -359,7 +358,7 @@ export class ChatStatusDashboard extends DomWidget { const showCompletions = !compact && completionsQuota && !completionsQuota.unlimited && completionsQuota.percentRemaining >= 0 && (!this.chatEntitlementService.quotas.usageBasedBilling || this.chatEntitlementService.entitlement === ChatEntitlement.Free); if (showCompletions) { - completionsQuotaIndicator = this.createQuotaIndicator(container, completionsQuota, localize('completionsLabel', "Inline Suggestions"), resetLabel, compact ? planName : undefined); + completionsQuotaIndicator = this.createQuotaIndicator(container, completionsQuota, localize('completionsLabel', "Inline Suggestions"), this.formatQuotaResetLabel(completionsQuota), compact ? planName : undefined); } // Update indicators from current quota state @@ -759,27 +758,19 @@ export class ChatStatusDashboard extends DomWidget { this.hoverService.hideHover(true); } - private formatResetAtLabel(resetAt: number | undefined): string | undefined { - if (!resetAt) { + private formatQuotaResetLabel(quota: IQuotaSnapshot | undefined): string | undefined { + const reset = getQuotaReset(quota, this.chatEntitlementService.quotas); + if (!reset) { return undefined; } - const resetDate = new Date(resetAt * 1000); - return localize('quotaResetsAt', "Resets {0} at {1}", this.dateFormatter.value.format(resetDate), this.timeFormatter.value.format(resetDate)); - } - private formatGlobalResetLabel(): string | undefined { - const { resetDate, resetDateHasTime } = this.chatEntitlementService.quotas; - if (!resetDate) { - return undefined; - } - return resetDateHasTime - ? localize('quotaResetsAt', "Resets {0} at {1}", this.dateFormatter.value.format(new Date(resetDate)), this.timeFormatter.value.format(new Date(resetDate))) - : localize('quotaResets', "Resets {0}", this.dateFormatter.value.format(new Date(resetDate))); + return reset.hasTime + ? localize('quotaResetsAt', "Resets {0} at {1}", this.dateFormatter.value.format(reset.date), this.timeFormatter.value.format(reset.date)) + : localize('quotaResets', "Resets {0}", this.dateFormatter.value.format(reset.date)); } - private createCreditsUsedIndicator(container: HTMLElement, creditsUsed: number, resetAt: number | undefined): void { + private createCreditsUsedIndicator(container: HTMLElement, creditsUsed: number, resetLabel: string | undefined): void { const isCompact = !!this.options?.compactQuotaLayout; - const resetLabel = this.formatResetAtLabel(resetAt) ?? this.formatGlobalResetLabel(); const resetValue = $('span.quota-reset'); if (resetLabel) { @@ -858,18 +849,21 @@ export class ChatStatusDashboard extends DomWidget { }; const showCredits = () => { - if (typeof currentQuota !== 'string' && currentQuota.entitlement) { - const total = currentQuota.entitlement; - const used = currentQuota.quotaRemaining !== undefined - ? total - currentQuota.quotaRemaining - : total * (100 - currentQuota.percentRemaining) / 100; - const usedFormatted = this.quotaCreditsFormatter.value.format(used); - const totalFormatted = this.quotaCreditsFormatter.value.format(total); - quotaValueText.textContent = localize('quotaCreditsDisplay', "{0} / {1}", usedFormatted, totalFormatted); - quotaValueSuffix.textContent = isCompact - ? localize('quotaLabelUsed', "{0} used", label) - : ` ${localize('quotaUsed', "used")}`; + if (typeof currentQuota === 'string') { + return; + } + + const usage = getQuotaUsage(currentQuota); + if (usage?.kind !== QuotaUsageKind.Percentage || usage.used === undefined || usage.total === undefined) { + return; } + + const usedFormatted = this.quotaCreditsFormatter.value.format(usage.used); + const totalFormatted = this.quotaCreditsFormatter.value.format(usage.total); + quotaValueText.textContent = localize('quotaCreditsDisplay', "{0} / {1}", usedFormatted, totalFormatted); + quotaValueSuffix.textContent = isCompact + ? localize('quotaLabelUsed', "{0} used", label) + : ` ${localize('quotaUsed', "used")}`; }; const hoverTarget = isCompact ? quotaValueText : quotaPercentage; diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts index 0599367449235c..e8a7cefacbf938 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts @@ -8,7 +8,7 @@ import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '.. import { localize } from '../../../../../nls.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, ShowTooltipCommand, StatusbarAlignment, StatusbarEntryKind } from '../../../../services/statusbar/browser/statusbar.js'; -import { ChatEntitlement, ChatEntitlementContextKeys, ChatEntitlementService, IChatEntitlementService, isProUser } from '../../../../services/chat/common/chatEntitlementService.js'; +import { ChatEntitlement, ChatEntitlementContextKeys, ChatEntitlementService, getQuotaReset, IChatEntitlementService, isProUser } from '../../../../services/chat/common/chatEntitlementService.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { disposableLongTimeout, disposableTimeout } from '../../../../../base/common/async.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; @@ -237,20 +237,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu private getQuotaResetTime(): number | undefined { const quotas = this.chatEntitlementService.quotas; - - const premiumResetAt = quotas.premiumChat?.resetAt; - if (typeof premiumResetAt === 'number') { - return premiumResetAt * 1000; - } - - if (quotas.resetDate) { - const parsed = Date.parse(quotas.resetDate); - if (!isNaN(parsed)) { - return parsed; - } - } - - return undefined; + return getQuotaReset(quotas.premiumChat, quotas)?.date.getTime(); } private scheduleQuotaResetRefresh(): void { diff --git a/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts index 0a982405397f37..fad846204b5267 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts @@ -41,6 +41,8 @@ function createEntitlementService(opts: { additionalUsageEnabled?: boolean; additionalUsageCount?: number; entitlement?: ChatEntitlement; + resetDate?: string; + resetDateHasTime?: boolean; }): IChatEntitlementService { return { _serviceBrand: undefined, @@ -58,6 +60,8 @@ function createEntitlementService(opts: { usageBasedBilling: opts.usageBasedBilling ?? opts.premiumChat?.usageBasedBilling, additionalUsageEnabled: opts.additionalUsageEnabled, additionalUsageCount: opts.additionalUsageCount, + resetDate: opts.resetDate, + resetDateHasTime: opts.resetDateHasTime, }, update: (_token: CancellationToken) => Promise.resolve(), onDidChangeSentiment: Event.None, @@ -93,6 +97,15 @@ function getQuotaLabels(element: HTMLElement): string[] { return Array.from(indicators).map(el => el.textContent ?? ''); } +function getQuotaResets(element: HTMLElement): [string, string][] { + const indicators = element.querySelectorAll('.quota-indicator:not(.included)'); + return Array.from(indicators).map(el => [ + el.querySelector('.quota-title > span:not(.quota-reset)')?.textContent ?? '', + // The time of day is locale and timezone dependent, so only its presence is asserted. + (el.querySelector('.quota-reset')?.textContent ?? '').replace(/ at .+$/, ' at