Reuse PermissionPickerActionItem for agent host auto-approve picker (#311263)

* Reuse PermissionPickerActionItem for agent host auto-approve picker

Previously the agent host had its own custom auto-approve picker that
duplicated most of the workbench PermissionPickerActionItem widget. This
refactor reuses the workbench widget when the active session's
autoApprove session-config property uses the well-known schema (string
type, enum subset of {default, autoApprove, autopilot} containing
'default'). Sessions that match get the workbench widget; non-conforming
agents fall back to the existing generic per-property picker.

- Adds AgentHostPermissionPickerDelegate and a thin
  AgentHostPermissionPickerActionItem subclass that toggles its
  visibility reactively as the active session changes.
- Generalizes the warning/info color rules in chat.css so they live
  with the widget rather than being scoped to .chat-secondary-toolbar.
- Groups all agent-host-only files under
  src/vs/sessions/contrib/chat/browser/agentHost/.
- Fixes a regression where the picker would stay hidden after
  navigating back to the new-chat view; the IActionViewItemService
  factory only runs once per render, so visibility must be reactive
  rather than gated at construction.

(Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Refactor: agent host reuses PermissionPicker (sessions) instead of PermissionPickerActionItem (workbench)

Both pickers now render in the same context (new-chat-page action bar), so
the previously-needed CSS specificity overrides (icon/font sizing,
padding, color rule un-scoping in chat.css) all go away.

Changes:
- Add IPermissionPickerDelegate to permissionPicker.ts: optional
  currentLevel observable, optional isApplicable observable, setLevel.
- Refactor PermissionPicker to take a delegate. When currentLevel is
  provided, the picker label tracks it reactively. When isApplicable is
  provided, the picker hides itself when false.
- Add CopilotPermissionPickerDelegate (writes through to the active
  CopilotChatSessionsProvider session). Preserves today's behavior.
- Move AgentHostPermissionPickerDelegate to its own file under
  agentHost/, retargeted at the new interface (currentLevel,
  isApplicable, setLevel).
- agentHostSessionConfigPicker.ts now constructs PermissionPicker with
  the agent host delegate and wraps in PickerActionViewItem.
- Delete agentHostPermissionPickerActionItem.ts (the workbench widget
  subclass) and its CSS overrides.
- Revert chat.css warning/info color un-scoping (no longer needed since
  agent host doesn't use the workbench widget anymore).
- Rename the test file to match the new product file. Tests cover the
  delegate's permission-level mapping, isApplicable reactivity, and
  isWellKnownAutoApproveSchema.

(Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Use PermissionPickerActionItem for ChatInputSecondary, PermissionPicker for NewSessionControl

Restore the contextual split: agent host now uses each widget where it's
expected to render. The new-chat page (NewSessionControl) keeps the
sessions PermissionPicker (matches surrounding sessions pickers); the
running chat widget (ChatInputSecondary) uses the workbench
PermissionPickerActionItem (matches the rest of the chat-input
secondary toolbar that the extension-host CLI already uses).

Both share AgentHostPermissionPickerDelegate. To make the same delegate
satisfy both consumers, rename its members to
currentPermissionLevel/setPermissionLevel (matching the workbench's
IPermissionPickerDelegate).

(Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Consolidate duplicate AUTO_APPROVE_PROPERTY constant

Both `agentHostPermissionPickerDelegate.ts` and
`agentHostSessionConfigPicker.ts` had identical `'autoApprove'` string
constants (one called `AUTO_APPROVE_SESSION_CONFIG_PROPERTY`, the other
`AUTO_APPROVE_PROPERTY`). Standardize on the shorter name and import it
in the picker. Also drops a now-unused `KNOWN_AUTO_APPROVE_VALUES` set
that was left behind in the picker file. (Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address Copilot review comments

- Dispose `onDidChangeProviders` Emitter in delegate test setup so the
  suite-level leak detector is happy. Refactor `setup()` to take the
  leak-tracking store and register all created disposables itself, so
  individual tests don't repeat the boilerplate.
- Fix corrupted JSDoc on `CopilotPermissionPickerDelegate` (sentence
  was mangled across an inline-code span). (Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Drop redundant `_slot` field from PermissionPicker

The `slot` local in `render()` is in scope for the `autorun` closures, so
storing it on `this` is unnecessary. Removing the field also drops the
needless null-check inside the visibility autorun. (Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Rob Lourens
2026-04-20 16:25:00 -07:00
committed by GitHub
co-authored by Copilot
parent 25032cf76e
commit e0c3dafcdb
10 changed files with 638 additions and 421 deletions
@@ -3,24 +3,24 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js';
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
import { autorun, observableValue } from '../../../../base/common/observable.js';
import * as nls from '../../../../nls.js';
import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js';
import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
import { type ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js';
import { type IChatInputPickerOptions } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js';
import { ModelPickerActionItem, type IModelPickerDelegate } from '../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem.js';
import { ActiveSessionProviderIdContext } from '../../../common/contextkeys.js';
import { type ISession } from '../../../services/sessions/common/session.js';
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
import { Menus } from '../../../browser/menus.js';
import { BaseActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js';
import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';
import { autorun, observableValue } from '../../../../../base/common/observable.js';
import * as nls from '../../../../../nls.js';
import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js';
import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js';
import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js';
import { type ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js';
import { type IChatInputPickerOptions } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js';
import { ModelPickerActionItem, type IModelPickerDelegate } from '../../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem.js';
import { ActiveSessionProviderIdContext } from '../../../../common/contextkeys.js';
import { type ISession } from '../../../../services/sessions/common/session.js';
import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';
import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';
import { Menus } from '../../../../browser/menus.js';
const IsActiveSessionAgentHost = ContextKeyExpr.or(
ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, 'local-agent-host'),
@@ -0,0 +1,82 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { autorun } from '../../../../../base/common/observable.js';
import { MenuItemAction } from '../../../../../platform/actions/common/actions.js';
import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js';
import { IOpenerService } from '../../../../../platform/opener/common/opener.js';
import { IStorageService } from '../../../../../platform/storage/common/storage.js';
import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';
import { IChatInputPickerOptions } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js';
import { PermissionPickerActionItem } from '../../../../../workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.js';
import { AgentHostPermissionPickerDelegate } from './agentHostPermissionPickerDelegate.js';
/**
* Agent host wrapper around the workbench {@link PermissionPickerActionItem}
* for use in the running chat widget's secondary toolbar
* (`MenuId.ChatInputSecondary`). Owns its
* {@link AgentHostPermissionPickerDelegate} and reactively hides itself when
* the active session's `autoApprove` schema doesn't match the well-known
* shape.
*/
export class AgentHostPermissionPickerActionItem extends PermissionPickerActionItem {
private readonly _delegate: AgentHostPermissionPickerDelegate;
constructor(
action: MenuItemAction,
pickerOptions: IChatInputPickerOptions,
@IInstantiationService instantiationService: IInstantiationService,
@IActionWidgetService actionWidgetService: IActionWidgetService,
@IKeybindingService keybindingService: IKeybindingService,
@IContextKeyService contextKeyService: IContextKeyService,
@ITelemetryService telemetryService: ITelemetryService,
@IConfigurationService configurationService: IConfigurationService,
@IDialogService dialogService: IDialogService,
@IOpenerService openerService: IOpenerService,
@IStorageService storageService: IStorageService,
) {
const delegate = instantiationService.createInstance(AgentHostPermissionPickerDelegate);
super(
action,
delegate,
pickerOptions,
actionWidgetService,
keybindingService,
contextKeyService,
telemetryService,
configurationService,
dialogService,
openerService,
storageService,
);
this._delegate = this._register(delegate);
// The base widget's label is rendered on demand via `refresh()`. Keep it
// in sync with the delegate's level observable.
this._register(autorun(reader => {
delegate.currentPermissionLevel.read(reader);
this.refresh();
}));
}
override render(container: HTMLElement): void {
super.render(container);
// The active session can change while this view item is alive (the
// `IActionViewItemService` factory only runs once per render), so gate
// visibility reactively rather than at construction time.
this._register(autorun(reader => {
const visible = this._delegate.isApplicable.read(reader);
if (this.element) {
this.element.style.display = visible ? '' : 'none';
}
}));
}
}
@@ -0,0 +1,157 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js';
import { derived, IObservable, IReader, observableSignal } from '../../../../../base/common/observable.js';
import { ISessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js';
import { ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js';
import { IPermissionPickerDelegate } from '../../../../contrib/copilotChatSessions/browser/permissionPicker.js';
import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../../common/agentHostSessionsProvider.js';
import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js';
import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';
import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';
/**
* The well-known session-config property name for tool auto-approval. The
* Agent Host Protocol's session-config schema is intentionally generic — only
* this property *name* (and the enum values below) is a convention shared
* across implementations that want to opt into VS Code's unified
* permission-picker UI. Agents that don't advertise this exact shape fall
* back to the generic per-property picker.
*/
export const AUTO_APPROVE_PROPERTY = 'autoApprove';
/**
* The set of enum values the unified permission picker understands for the
* `autoApprove` property. Mirrors `ChatPermissionLevel` in
* `vs/workbench/contrib/chat/common/constants.ts`.
*
* `autopilot` is optional (an agent may choose not to advertise it).
* `default` is required as the baseline level.
*/
const KNOWN_AUTO_APPROVE_VALUES: ReadonlySet<string> = new Set(['default', 'autoApprove', 'autopilot']);
const REQUIRED_AUTO_APPROVE_VALUE = 'default';
/**
* Returns `true` when an `autoApprove` session-config property uses the
* shape the unified permission picker expects: a string enum that is a
* subset of `default | autoApprove | autopilot` and contains at least
* `default`.
*
* Callers use this to decide whether to render the unified
* {@link PermissionPicker} (with its built-in warning dialogs, autopilot
* gating, and policy enforcement) or fall back to the generic per-property
* picker.
*/
export function isWellKnownAutoApproveSchema(schema: ISessionConfigPropertySchema): boolean {
if (schema.type !== 'string' || !Array.isArray(schema.enum) || schema.enum.length === 0) {
return false;
}
if (!schema.enum.includes(REQUIRED_AUTO_APPROVE_VALUE)) {
return false;
}
return schema.enum.every(value => KNOWN_AUTO_APPROVE_VALUES.has(value));
}
/**
* {@link IPermissionPickerDelegate} backed by the active session's AHP
* `autoApprove` config property.
*
* - `currentPermissionLevel` derives from the active session's
* `provider.getSessionConfig(...).values.autoApprove`, recomputed when the
* active session changes or when any agent-host provider fires
* `onDidChangeSessionConfig`.
* - `setPermissionLevel(level)` calls `provider.setSessionConfigValue(sessionId,
* 'autoApprove', level)` for the active session's provider.
* - `isApplicable` is `true` only when the active session's `autoApprove`
* schema matches the well-known shape, so the picker hides itself for
* non-conforming agents (which fall back to the generic per-property
* picker) and when no agent-host session is active.
*/
export class AgentHostPermissionPickerDelegate extends Disposable implements IPermissionPickerDelegate {
/** Fires every time any agent-host provider's session config changes. */
private readonly _configChangedSignal = observableSignal('agentHostPermissionPicker.configChanged');
private readonly _providerSubscriptions = this._register(new DisposableMap<string>());
readonly currentPermissionLevel: IObservable<ChatPermissionLevel>;
readonly isApplicable: IObservable<boolean>;
constructor(
@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,
@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,
) {
super();
this._watchProviders(this._sessionsProvidersService.getProviders());
this._register(this._sessionsProvidersService.onDidChangeProviders(e => {
for (const provider of e.removed) {
this._providerSubscriptions.deleteAndDispose(provider.id);
}
this._watchProviders(e.added);
this._configChangedSignal.trigger(undefined);
}));
this.currentPermissionLevel = derived(this, reader => this._readLevel(reader));
this.isApplicable = derived(this, reader => this._readIsWellKnown(reader));
}
setPermissionLevel(level: ChatPermissionLevel): void {
const session = this._sessionsManagementService.activeSession.get();
if (!session) {
return;
}
const provider = this._getProvider(session.providerId);
if (!provider) {
return;
}
provider.setSessionConfigValue(session.sessionId, AUTO_APPROVE_PROPERTY, level)
.catch(() => { /* best-effort */ });
}
private _readLevel(reader: IReader): ChatPermissionLevel {
this._configChangedSignal.read(reader);
const session = this._sessionsManagementService.activeSession.read(reader);
if (!session) {
return ChatPermissionLevel.Default;
}
const provider = this._getProvider(session.providerId);
if (!provider) {
return ChatPermissionLevel.Default;
}
const value = provider.getSessionConfig(session.sessionId)?.values[AUTO_APPROVE_PROPERTY];
return isChatPermissionLevel(value) ? value : ChatPermissionLevel.Default;
}
private _readIsWellKnown(reader: IReader): boolean {
this._configChangedSignal.read(reader);
const session = this._sessionsManagementService.activeSession.read(reader);
if (!session) {
return false;
}
const provider = this._getProvider(session.providerId);
if (!provider) {
return false;
}
const schema = provider.getSessionConfig(session.sessionId)?.schema.properties[AUTO_APPROVE_PROPERTY];
return !!schema && isWellKnownAutoApproveSchema(schema);
}
private _getProvider(providerId: string): IAgentHostSessionsProvider | undefined {
const provider = this._sessionsProvidersService.getProvider(providerId);
return provider && isAgentHostProvider(provider) ? provider : undefined;
}
private _watchProviders(providers: readonly ISessionsProvider[]): void {
for (const provider of providers) {
if (!isAgentHostProvider(provider) || this._providerSubscriptions.has(provider.id)) {
continue;
}
this._providerSubscriptions.set(provider.id, provider.onDidChangeSessionConfig(() => {
this._configChangedSignal.trigger(undefined);
}));
}
}
}
@@ -3,37 +3,41 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import './media/agentHostSessionConfigPicker.css';
import * as dom from '../../../../base/browser/dom.js';
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js';
import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js';
import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js';
import { Delayer } from '../../../../base/common/async.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { MarkdownString } from '../../../../base/common/htmlContent.js';
import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';
import { autorun } from '../../../../base/common/observable.js';
import Severity from '../../../../base/common/severity.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { localize, localize2 } from '../../../../nls.js';
import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js';
import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { AgentHostSessionConfigBranchNameHintKey } from '../../../../platform/agentHost/common/agentService.js';
import type { ISessionConfigPropertySchema, ISessionConfigValueItem } from '../../../../platform/agentHost/common/state/protocol/commands.js';
import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js';
import { ChatContextKeyExprs } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js';
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
import { Menus } from '../../../browser/menus.js';
import { ActiveSessionProviderIdContext } from '../../../common/contextkeys.js';
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
import type { ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js';
import { type IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js';
import '../media/agentHostSessionConfigPicker.css';
import * as dom from '../../../../../base/browser/dom.js';
import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js';
import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js';
import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js';
import { BaseActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js';
import { Delayer } from '../../../../../base/common/async.js';
import { Codicon } from '../../../../../base/common/codicons.js';
import { MarkdownString } from '../../../../../base/common/htmlContent.js';
import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js';
import { autorun, observableValue } from '../../../../../base/common/observable.js';
import Severity from '../../../../../base/common/severity.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { localize, localize2 } from '../../../../../nls.js';
import { IActionViewItemService, type IActionViewItemFactory } from '../../../../../platform/actions/browser/actionViewItemService.js';
import { Action2, MenuId, MenuItemAction, registerAction2 } from '../../../../../platform/actions/common/actions.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';
import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
import { AgentHostSessionConfigBranchNameHintKey } from '../../../../../platform/agentHost/common/agentService.js';
import type { ISessionConfigPropertySchema, ISessionConfigValueItem } from '../../../../../platform/agentHost/common/state/protocol/commands.js';
import { ChatConfiguration } from '../../../../../workbench/contrib/chat/common/constants.js';
import { ChatContextKeyExprs } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js';
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js';
import { type IChatInputPickerOptions } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js';
import { Menus } from '../../../../browser/menus.js';
import { ActiveSessionProviderIdContext } from '../../../../common/contextkeys.js';
import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';
import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';
import type { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js';
import { type IAgentHostSessionsProvider, isAgentHostProvider } from '../../../../common/agentHostSessionsProvider.js';
import { PermissionPicker } from '../../../copilotChatSessions/browser/permissionPicker.js';
import { AgentHostPermissionPickerActionItem } from './agentHostPermissionPickerActionItem.js';
import { AgentHostPermissionPickerDelegate, AUTO_APPROVE_PROPERTY, isWellKnownAutoApproveSchema } from './agentHostPermissionPickerDelegate.js';
const IsActiveSessionRemoteAgentHost = ContextKeyExpr.regex(ActiveSessionProviderIdContext.key, /^agenthost-/);
const IsActiveSessionLocalAgentHost = ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, 'local-agent-host');
@@ -121,12 +125,6 @@ function renderPickerTrigger(slot: HTMLElement, disabled: boolean, disposables:
return trigger;
}
/**
* Special-cased property name for auto-approve session config.
* Used to apply confirmation dialogs and policy enforcement.
*/
const AUTO_APPROVE_PROPERTY = 'autoApprove';
// Track whether auto-approve warnings have been shown this VS Code session
const shownAutoApproveWarnings = new Set<string /* enum value */>();
@@ -285,7 +283,15 @@ class AgentHostSessionConfigPicker extends Disposable {
}
for (const [property, schema] of Object.entries(resolvedConfig.schema.properties)) {
if (property === AgentHostSessionConfigBranchNameHintKey || property === AUTO_APPROVE_PROPERTY) {
if (property === AgentHostSessionConfigBranchNameHintKey) {
continue;
}
// When the autoApprove property uses the well-known schema, the
// workbench `PermissionPickerActionItem` (registered separately for
// `Menus.NewSessionControl`) handles it — skip it here to avoid
// double-rendering. Non-conforming schemas still fall through to
// the generic per-property picker below.
if (property === AUTO_APPROVE_PROPERTY && isWellKnownAutoApproveSchema(schema)) {
continue;
}
const value = resolvedConfig.values[property] ?? schema.default;
@@ -404,8 +410,11 @@ interface IConfigPickerWidget extends IDisposable {
}
class PickerActionViewItem extends BaseActionViewItem {
constructor(private readonly _picker: IConfigPickerWidget) {
constructor(private readonly _picker: IConfigPickerWidget, disposable?: IDisposable) {
super(undefined, { id: '', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } });
if (disposable) {
this._register(disposable);
}
}
override render(container: HTMLElement): void {
@@ -423,25 +432,58 @@ class AgentHostSessionConfigPickerContribution extends Disposable implements IWo
constructor(
@IActionViewItemService actionViewItemService: IActionViewItemService,
@IInstantiationService instantiationService: IInstantiationService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
) {
super();
this._register(actionViewItemService.register(
Menus.NewSessionRepositoryConfig,
'sessions.agentHost.sessionConfigPicker',
() => new PickerActionViewItem(instantiationService.createInstance(AgentHostSessionConfigPicker)),
() => new PickerActionViewItem(this._instantiationService.createInstance(AgentHostSessionConfigPicker)),
));
this._register(actionViewItemService.register(
Menus.NewSessionControl,
NEW_SESSION_APPROVE_PICKER_ID,
() => new PickerActionViewItem(instantiationService.createInstance(AgentHostNewSessionApprovePicker)),
() => this._createNewSessionPermissionPicker(),
));
this._register(actionViewItemService.register(
MenuId.ChatInputSecondary,
RUNNING_SESSION_CONFIG_PICKER_ID,
() => new PickerActionViewItem(instantiationService.createInstance(AgentHostRunningSessionConfigPicker)),
this._createRunningSessionPermissionPickerFactory(),
));
}
/**
* On the new-chat page (left of the toolbar), use the sessions
* {@link PermissionPicker} so the styling matches the surrounding sessions
* pickers (font size, padding, icon size).
*/
private _createNewSessionPermissionPicker(): PickerActionViewItem {
const delegate = this._instantiationService.createInstance(AgentHostPermissionPickerDelegate);
const picker = this._instantiationService.createInstance(PermissionPicker, delegate);
return new PickerActionViewItem(picker, delegate);
}
/**
* Inside a running chat widget (`ChatInputSecondary`), use the workbench
* {@link PermissionPickerActionItem} so it matches the rest of the
* chat-input secondary toolbar (which is what the extension-host CLI
* already uses).
*/
private _createRunningSessionPermissionPickerFactory(): IActionViewItemFactory {
return (action, _options, instantiationService) => {
if (!(action instanceof MenuItemAction)) {
return undefined;
}
const pickerOptions: IChatInputPickerOptions = {
hideChevrons: observableValue('hideChevrons', false),
};
return instantiationService.createInstance(
AgentHostPermissionPickerActionItem,
action,
pickerOptions,
);
};
}
}
// ---- New session auto-approve picker (left side, NewSessionControl) ----
@@ -466,160 +508,6 @@ registerAction2(class extends Action2 {
override async run(): Promise<void> { }
});
/**
* Renders the auto-approve picker in the new session welcome view (left side).
* Only renders the autoApprove property from the session config.
*/
class AgentHostNewSessionApprovePicker extends Disposable {
private readonly _renderDisposables = this._register(new DisposableStore());
private readonly _providerListeners = this._register(new DisposableMap<string>());
private _container: HTMLElement | undefined;
constructor(
@IActionWidgetService private readonly _actionWidgetService: IActionWidgetService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IDialogService private readonly _dialogService: IDialogService,
@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,
@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,
) {
super();
this._register(autorun(reader => {
const session = this._sessionsManagementService.activeSession.read(reader);
if (session) {
session.loading.read(reader);
}
this._render();
}));
this._register(this._sessionsProvidersService.onDidChangeProviders(e => {
for (const provider of e.removed) {
this._providerListeners.deleteAndDispose(provider.id);
}
this._watchProviders(e.added);
this._render();
}));
this._watchProviders(this._sessionsProvidersService.getProviders());
}
private _watchProviders(providers: readonly ISessionsProvider[]): void {
for (const provider of providers) {
if (!isAgentHostProvider(provider) || this._providerListeners.has(provider.id)) {
continue;
}
this._providerListeners.set(provider.id, provider.onDidChangeSessionConfig(() => this._render()));
}
}
render(container: HTMLElement): void {
this._container = dom.append(container, dom.$('.sessions-chat-agent-host-config'));
this._render();
}
private _render(): void {
if (!this._container) {
return;
}
this._renderDisposables.clear();
dom.clearNode(this._container);
const session = this._sessionsManagementService.activeSession.get();
const rawProvider = session ? this._sessionsProvidersService.getProvider(session.providerId) : undefined;
const provider = rawProvider && isAgentHostProvider(rawProvider) ? rawProvider : undefined;
const config = session && provider?.getSessionConfig(session.sessionId);
// `getSessionConfig` may return undefined for sessions whose config
// hasn't been seeded yet (e.g. opened from list, no in-window create).
// The provider lazily acquires a session-state subscription and will fire
// `onDidChangeSessionConfig` once the snapshot arrives, re-rendering us.
if (!session || !provider || !config) {
return;
}
const schema = config.schema.properties[AUTO_APPROVE_PROPERTY];
if (!schema) {
return;
}
const value = config.values[AUTO_APPROVE_PROPERTY] ?? schema.default;
const slot = dom.append(this._container, dom.$('.sessions-chat-picker-slot'));
const trigger = renderPickerTrigger(slot, false, this._renderDisposables, () => this._showPicker(provider, session.sessionId, schema, trigger));
this._renderTrigger(trigger, schema, value);
}
private _renderTrigger(trigger: HTMLElement, schema: ISessionConfigPropertySchema, value: string | undefined): void {
dom.clearNode(trigger);
const icon = getConfigIcon(AUTO_APPROVE_PROPERTY, value);
if (icon) {
dom.append(trigger, renderIcon(icon));
}
const labelSpan = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label'));
const label = this._getLabel(schema, value);
labelSpan.textContent = label;
trigger.setAttribute('aria-label', localize('agentHostNewSessionApprove.triggerAria', "{0}: {1}", schema.title, label));
dom.append(trigger, renderIcon(Codicon.chevronDown));
applyAutoApproveTriggerStyles(trigger, AUTO_APPROVE_PROPERTY, value);
}
private async _showPicker(provider: IAgentHostSessionsProvider, sessionId: string, schema: ISessionConfigPropertySchema, trigger: HTMLElement): Promise<void> {
if (this._actionWidgetService.isVisible) {
return;
}
const rawItems = (schema.enum ?? []).map((value, index) => ({
value,
label: schema.enumLabels?.[index] ?? value,
description: schema.enumDescriptions?.[index],
}));
const { items, policyRestricted } = applyAutoApproveFiltering(rawItems, AUTO_APPROVE_PROPERTY, this._configurationService);
if (items.length === 0) {
return;
}
const currentValue = provider.getSessionConfig(sessionId)?.values[AUTO_APPROVE_PROPERTY];
const actionItems = toActionItems(AUTO_APPROVE_PROPERTY, items, currentValue, policyRestricted);
const delegate: IActionListDelegate<IConfigPickerItem> = {
onSelect: async item => {
this._actionWidgetService.hide();
if (item.value === 'autoApprove' || item.value === 'autopilot') {
const confirmed = await confirmAutoApproveLevel(item.value, this._dialogService);
if (!confirmed) {
return;
}
}
provider.setSessionConfigValue(sessionId, AUTO_APPROVE_PROPERTY, item.value).catch(() => { /* best-effort */ });
},
onHide: () => trigger.focus(),
};
this._actionWidgetService.show<IConfigPickerItem>(
`agentHostNewSessionConfig.${AUTO_APPROVE_PROPERTY}`,
false,
actionItems,
delegate,
trigger,
undefined,
[],
{
getAriaLabel: item => item.label ?? '',
getWidgetAriaLabel: () => localize('agentHostNewSessionApprove.ariaLabel', "{0} Picker", schema.title),
},
);
}
private _getLabel(schema: ISessionConfigPropertySchema, value: string | undefined): string {
if (typeof value === 'string') {
const index = schema.enum?.indexOf(value) ?? -1;
return index >= 0 ? schema.enumLabels?.[index] ?? value : value;
}
return schema.title;
}
}
// ---- Running session config picker (ChatInputSecondary) ----
@@ -643,160 +531,5 @@ registerAction2(class extends Action2 {
override async run(): Promise<void> { }
});
/**
* Renders a single picker trigger in the titlebar for the auto-approve session
* config property during a running agent host session.
*/
class AgentHostRunningSessionConfigPicker extends Disposable {
private readonly _renderDisposables = this._register(new DisposableStore());
private readonly _providerListeners = this._register(new DisposableMap<string>());
private _container: HTMLElement | undefined;
constructor(
@IActionWidgetService private readonly _actionWidgetService: IActionWidgetService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IDialogService private readonly _dialogService: IDialogService,
@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,
@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,
) {
super();
this._register(autorun(reader => {
const session = this._sessionsManagementService.activeSession.read(reader);
if (session) {
session.loading.read(reader);
}
this._render();
}));
this._register(this._sessionsProvidersService.onDidChangeProviders(e => {
for (const provider of e.removed) {
this._providerListeners.deleteAndDispose(provider.id);
}
this._watchProviders(e.added);
this._render();
}));
this._watchProviders(this._sessionsProvidersService.getProviders());
}
private _watchProviders(providers: readonly ISessionsProvider[]): void {
for (const provider of providers) {
if (!isAgentHostProvider(provider) || this._providerListeners.has(provider.id)) {
continue;
}
this._providerListeners.set(provider.id, provider.onDidChangeSessionConfig(() => this._render()));
}
}
render(container: HTMLElement): void {
this._container = dom.append(container, dom.$('.sessions-chat-agent-host-config'));
this._render();
}
private _render(): void {
if (!this._container) {
return;
}
this._renderDisposables.clear();
dom.clearNode(this._container);
const session = this._sessionsManagementService.activeSession.get();
const rawProvider = session ? this._sessionsProvidersService.getProvider(session.providerId) : undefined;
const provider = rawProvider && isAgentHostProvider(rawProvider) ? rawProvider : undefined;
const config = session && provider?.getSessionConfig(session.sessionId);
// See note in `AgentHostNewSessionApprovePicker._render`: `config` may be
// undefined until the lazy session-state subscription hydrates and the
// provider fires `onDidChangeSessionConfig`.
if (!session || !provider || !config) {
return;
}
// Only render session-mutable properties (i.e., autoApprove)
for (const [property, schema] of Object.entries(config.schema.properties)) {
if (!schema.sessionMutable) {
continue;
}
const value = config.values[property] ?? schema.default;
const slot = dom.append(this._container, dom.$('.sessions-chat-picker-slot'));
const trigger = renderPickerTrigger(slot, false, this._renderDisposables, () => this._showPicker(provider, session.sessionId, property, schema, trigger));
this._renderTrigger(trigger, schema, value, property);
}
}
private _renderTrigger(trigger: HTMLElement, schema: ISessionConfigPropertySchema, value: string | undefined, property: string): void {
dom.clearNode(trigger);
const icon = getConfigIcon(property, value);
if (icon) {
dom.append(trigger, renderIcon(icon));
}
const labelSpan = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label'));
const label = this._getLabel(schema, value);
labelSpan.textContent = label;
trigger.setAttribute('aria-label', localize('agentHostRunningSessionConfig.triggerAria', "{0}: {1}", schema.title, label));
dom.append(trigger, renderIcon(Codicon.chevronDown));
applyAutoApproveTriggerStyles(trigger, property, value);
}
private async _showPicker(provider: IAgentHostSessionsProvider, sessionId: string, property: string, schema: ISessionConfigPropertySchema, trigger: HTMLElement): Promise<void> {
if (this._actionWidgetService.isVisible) {
return;
}
const rawItems = (schema.enum ?? []).map((value, index) => ({
value,
label: schema.enumLabels?.[index] ?? value,
description: schema.enumDescriptions?.[index],
}));
const { items, policyRestricted } = applyAutoApproveFiltering(rawItems, property, this._configurationService);
const isAutoApproveProperty = property === AUTO_APPROVE_PROPERTY;
if (items.length === 0) {
return;
}
const currentValue = provider.getSessionConfig(sessionId)?.values[property];
const actionItems = toActionItems(property, items, currentValue, policyRestricted);
const delegate: IActionListDelegate<IConfigPickerItem> = {
onSelect: async item => {
this._actionWidgetService.hide();
if (isAutoApproveProperty && (item.value === 'autoApprove' || item.value === 'autopilot')) {
const confirmed = await confirmAutoApproveLevel(item.value, this._dialogService);
if (!confirmed) {
return;
}
}
provider.setSessionConfigValue(sessionId, property, item.value).catch(() => { /* best-effort */ });
},
onHide: () => trigger.focus(),
};
this._actionWidgetService.show<IConfigPickerItem>(
`agentHostRunningSessionConfig.${property}`,
false,
actionItems,
delegate,
trigger,
undefined,
[],
{
getAriaLabel: item => item.label ?? '',
getWidgetAriaLabel: () => localize('agentHostRunningSessionConfig.ariaLabel', "{0} Picker", schema.title),
},
);
}
private _getLabel(schema: ISessionConfigPropertySchema, value: string | undefined): string {
if (typeof value === 'string') {
const index = schema.enum?.indexOf(value) ?? -1;
return index >= 0 ? schema.enumLabels?.[index] ?? value : value;
}
return schema.title;
}
}
registerWorkbenchContribution2(AgentHostSessionConfigPickerContribution.ID, AgentHostSessionConfigPickerContribution, WorkbenchPhase.AfterRestored);
@@ -12,19 +12,3 @@
.sessions-chat-agent-host-config:empty {
display: none;
}
.sessions-chat-agent-host-config .action-label.warning {
color: var(--vscode-problemsWarningIcon-foreground);
}
.sessions-chat-agent-host-config .action-label.warning .codicon {
color: var(--vscode-problemsWarningIcon-foreground) !important;
}
.sessions-chat-agent-host-config .action-label.info {
color: var(--vscode-problemsInfoIcon-foreground);
}
.sessions-chat-agent-host-config .action-label.info .codicon {
color: var(--vscode-problemsInfoIcon-foreground) !important;
}
@@ -0,0 +1,212 @@
/*---------------------------------------------------------------------------------------------
* 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 { Emitter, Event } from '../../../../../../base/common/event.js';
import { DisposableStore } from '../../../../../../base/common/lifecycle.js';
import { observableValue } from '../../../../../../base/common/observable.js';
import { mock } from '../../../../../../base/test/common/mock.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
import { IResolveSessionConfigResult, ISessionConfigPropertySchema } from '../../../../../../platform/agentHost/common/state/protocol/commands.js';
import { ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js';
import { AgentHostPermissionPickerDelegate, isWellKnownAutoApproveSchema } from '../../../browser/agentHost/agentHostPermissionPickerDelegate.js';
import { IAgentHostSessionsProvider } from '../../../../../common/agentHostSessionsProvider.js';
import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js';
import { ISessionsProvider } from '../../../../../services/sessions/common/sessionsProvider.js';
import { IActiveSession, ISessionsManagementService } from '../../../../../services/sessions/common/sessionsManagement.js';
const PROVIDER_ID = 'local-agent-host';
const SESSION_ID = 'local-agent-host:s1';
function makeWellKnownConfig(value: string | undefined): IResolveSessionConfigResult {
return {
schema: {
type: 'object',
properties: {
autoApprove: {
title: 'Auto Approve',
description: '',
type: 'string',
enum: ['default', 'autoApprove', 'autopilot'],
sessionMutable: true,
},
},
},
values: value === undefined ? {} : { autoApprove: value },
} as IResolveSessionConfigResult;
}
class FakeProvider implements Pick<IAgentHostSessionsProvider, 'id' | 'onDidChangeSessionConfig' | 'getSessionConfig' | 'setSessionConfigValue'> {
readonly id: string = PROVIDER_ID;
private readonly _onDidChange = new Emitter<string>();
readonly onDidChangeSessionConfig: Event<string> = this._onDidChange.event;
config: IResolveSessionConfigResult | undefined;
readonly setCalls: Array<[string, string, string]> = [];
getSessionConfig(_sessionId: string): IResolveSessionConfigResult | undefined {
return this.config;
}
async setSessionConfigValue(sessionId: string, property: string, value: string): Promise<void> {
this.setCalls.push([sessionId, property, value]);
}
fireChange(sessionId: string = SESSION_ID): void {
this._onDidChange.fire(sessionId);
}
dispose(): void {
this._onDidChange.dispose();
}
}
interface ITestRig {
readonly delegate: AgentHostPermissionPickerDelegate;
readonly provider: FakeProvider;
readonly activeSessionObs: ReturnType<typeof observableValue<IActiveSession | undefined>>;
}
function setup(store: Pick<DisposableStore, 'add'>, activeSession: IActiveSession | undefined, configValue?: string): ITestRig {
const provider = new FakeProvider();
store.add({ dispose: () => provider.dispose() });
if (configValue !== undefined) {
provider.config = makeWellKnownConfig(configValue);
}
const onDidChangeProviders = store.add(new Emitter<ISessionsProvidersChangeEvent>());
const sessionsProvidersService = new (class extends mock<ISessionsProvidersService>() {
override readonly onDidChangeProviders = onDidChangeProviders.event;
override getProviders(): ISessionsProvider[] { return [provider as unknown as ISessionsProvider]; }
override getProvider<T extends ISessionsProvider>(id: string): T | undefined {
return id === provider.id ? (provider as unknown as T) : undefined;
}
})();
const activeSessionObs = observableValue<IActiveSession | undefined>('activeSession', activeSession);
const sessionsManagementService = new (class extends mock<ISessionsManagementService>() {
override readonly activeSession = activeSessionObs;
})();
const insta = store.add(new TestInstantiationService());
insta.set(ISessionsManagementService, sessionsManagementService);
insta.set(ISessionsProvidersService, sessionsProvidersService);
const delegate = store.add(insta.createInstance(AgentHostPermissionPickerDelegate));
return { delegate, provider, activeSessionObs };
}
function makeActiveSession(): IActiveSession {
return { providerId: PROVIDER_ID, sessionId: SESSION_ID } as IActiveSession;
}
suite('AgentHostPermissionPickerDelegate', () => {
const store = ensureNoDisposablesAreLeakedInTestSuite();
test('returns Default when there is no active session', () => {
const { delegate } = setup(store, undefined);
assert.strictEqual(delegate.currentPermissionLevel.get(), ChatPermissionLevel.Default);
});
test('returns Default when the active session has no config seeded yet', () => {
const { delegate } = setup(store, makeActiveSession());
assert.strictEqual(delegate.currentPermissionLevel.get(), ChatPermissionLevel.Default);
});
test('reflects the active session\'s autoApprove value and updates on provider change', () => {
const { delegate, provider } = setup(store, makeActiveSession(), 'autoApprove');
assert.strictEqual(delegate.currentPermissionLevel.get(), ChatPermissionLevel.AutoApprove);
provider.config = makeWellKnownConfig('autopilot');
provider.fireChange();
assert.strictEqual(delegate.currentPermissionLevel.get(), ChatPermissionLevel.Autopilot);
provider.config = makeWellKnownConfig('default');
provider.fireChange();
assert.strictEqual(delegate.currentPermissionLevel.get(), ChatPermissionLevel.Default);
});
test('falls back to Default when the stored value is unrecognized', () => {
const { delegate } = setup(store, makeActiveSession(), 'something-else');
assert.strictEqual(delegate.currentPermissionLevel.get(), ChatPermissionLevel.Default);
});
test('setPermissionLevel writes through to the active session\'s provider', () => {
const { delegate, provider } = setup(store, makeActiveSession(), 'default');
delegate.setPermissionLevel(ChatPermissionLevel.AutoApprove);
delegate.setPermissionLevel(ChatPermissionLevel.Autopilot);
assert.deepStrictEqual(provider.setCalls, [
[SESSION_ID, 'autoApprove', 'autoApprove'],
[SESSION_ID, 'autoApprove', 'autopilot'],
]);
});
test('setPermissionLevel is a no-op when there is no active session', () => {
const { delegate, provider } = setup(store, undefined);
delegate.setPermissionLevel(ChatPermissionLevel.AutoApprove);
assert.deepStrictEqual(provider.setCalls, []);
});
test('isApplicable reacts to active session and config changes', () => {
const { delegate, provider, activeSessionObs } = setup(store, undefined);
// No active session → false
assert.strictEqual(delegate.isApplicable.get(), false);
// Active session, no config seeded → false
activeSessionObs.set(makeActiveSession(), undefined);
assert.strictEqual(delegate.isApplicable.get(), false);
// Active session with well-known schema → true
provider.config = makeWellKnownConfig('default');
provider.fireChange();
assert.strictEqual(delegate.isApplicable.get(), true);
// Active session cleared → false (covers the 'back to new chat view' regression)
activeSessionObs.set(undefined, undefined);
assert.strictEqual(delegate.isApplicable.get(), false);
});
});
suite('isWellKnownAutoApproveSchema', () => {
ensureNoDisposablesAreLeakedInTestSuite();
function schema(overrides: Partial<ISessionConfigPropertySchema> = {}): ISessionConfigPropertySchema {
return {
title: 'Auto Approve',
description: 'desc',
type: 'string',
enum: ['default', 'autoApprove', 'autopilot'],
...overrides,
} as ISessionConfigPropertySchema;
}
test('matches the canonical three-value enum', () => {
assert.strictEqual(isWellKnownAutoApproveSchema(schema()), true);
});
test('matches a subset that still contains "default"', () => {
assert.strictEqual(isWellKnownAutoApproveSchema(schema({ enum: ['default', 'autoApprove'] })), true);
assert.strictEqual(isWellKnownAutoApproveSchema(schema({ enum: ['default'] })), true);
});
test('rejects schemas missing the required "default" value', () => {
assert.strictEqual(isWellKnownAutoApproveSchema(schema({ enum: ['autoApprove', 'autopilot'] })), false);
});
test('rejects schemas with unknown enum values', () => {
assert.strictEqual(isWellKnownAutoApproveSchema(schema({ enum: ['default', 'custom'] })), false);
});
test('rejects non-string types and missing/empty enums', () => {
assert.strictEqual(isWellKnownAutoApproveSchema(schema({ type: 'number' as 'string' })), false);
assert.strictEqual(isWellKnownAutoApproveSchema(schema({ enum: undefined })), false);
assert.strictEqual(isWellKnownAutoApproveSchema(schema({ enum: [] })), false);
});
});
@@ -31,7 +31,7 @@ import { IsolationPicker } from './isolationPicker.js';
import { BranchPicker } from './branchPicker.js';
import { ModePicker } from './modePicker.js';
import { CloudModelPicker } from './modelPicker.js';
import { PermissionPicker } from './permissionPicker.js';
import { CopilotPermissionPickerDelegate, PermissionPicker } from './permissionPicker.js';
const IsActiveSessionCopilotCLI = ContextKeyExpr.equals(ActiveSessionTypeContext.key, COPILOT_CLI_SESSION_TYPE);
const IsActiveSessionCopilotCloud = ContextKeyExpr.equals(ActiveSessionTypeContext.key, COPILOT_CLOUD_SESSION_TYPE);
@@ -274,8 +274,9 @@ class CopilotPickerActionViewItemContribution extends Disposable implements IWor
this._register(actionViewItemService.register(
Menus.NewSessionControl, 'sessions.defaultCopilot.permissionPicker',
() => {
const picker = instantiationService.createInstance(PermissionPicker);
return new PickerActionViewItem(picker);
const delegate = instantiationService.createInstance(CopilotPermissionPickerDelegate);
const picker = instantiationService.createInstance(PermissionPicker, delegate);
return new PickerActionViewItem(picker, delegate);
},
));
}
@@ -5,8 +5,8 @@
import * as dom from '../../../../base/browser/dom.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
import { autorun, IObservable } from '../../../../base/common/observable.js';
import { localize } from '../../../../nls.js';
import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js';
import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListOptions } from '../../../../platform/actionWidget/browser/actionList.js';
@@ -23,8 +23,35 @@ import { IOpenerService } from '../../../../platform/opener/common/opener.js';
import { URI } from '../../../../base/common/uri.js';
import { CopilotChatSessionsProvider } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js';
// Track whether warnings have been shown this VS Code session
const shownWarnings = new Set<ChatPermissionLevel>();
/**
* Strategy for the per-provider parts of {@link PermissionPicker}: how to read
* back the current level (if at all), whether the picker should be visible
* given the active session, and where to write the user's selection.
*
* Implementations live with the provider they back (e.g.
* {@link CopilotPermissionPickerDelegate} below for the default Copilot
* provider, or `AgentHostPermissionPickerDelegate` in the agent-host folder).
*/
export interface IPermissionPickerDelegate {
/**
* If provided, the picker's trigger label reactively tracks this. If
* omitted, the picker manages its own internal state and starts at
* {@link ChatPermissionLevel.Default}.
*/
readonly currentPermissionLevel?: IObservable<ChatPermissionLevel>;
/**
* If provided, the picker hides itself when this is `false`. Used by
* delegates whose applicability depends on the active session.
*/
readonly isApplicable?: IObservable<boolean>;
/**
* Called after the user selects a level (and any required confirmation
* dialog has been accepted).
*/
setPermissionLevel(level: ChatPermissionLevel): void;
}
interface IPermissionItem {
readonly level?: ChatPermissionLevel;
@@ -33,45 +60,23 @@ interface IPermissionItem {
readonly checked: boolean;
}
export class PermissionPicker extends Disposable {
// Track whether warnings have been shown this VS Code session
const shownWarnings = new Set<ChatPermissionLevel>();
private readonly _onDidChangeLevel = this._register(new Emitter<ChatPermissionLevel>());
readonly onDidChangeLevel: Event<ChatPermissionLevel> = this._onDidChangeLevel.event;
export class PermissionPicker extends Disposable {
private _currentLevel: ChatPermissionLevel = ChatPermissionLevel.Default;
private _triggerElement: HTMLElement | undefined;
private readonly _renderDisposables = this._register(new DisposableStore());
get permissionLevel(): ChatPermissionLevel {
return this._currentLevel;
}
set permissionLevel(level: ChatPermissionLevel) {
this._currentLevel = level;
this._updateTriggerLabel(this._triggerElement);
}
constructor(
private readonly _delegate: IPermissionPickerDelegate,
@IActionWidgetService private readonly actionWidgetService: IActionWidgetService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IDialogService private readonly dialogService: IDialogService,
@IOpenerService private readonly openerService: IOpenerService,
@ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService,
@ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService,
) {
super();
// Write permission level to the active session data when it changes
this._register(this.onDidChangeLevel(level => {
const session = this.sessionsManagementService.activeSession.get();
if (!session) {
return;
}
const provider = this.sessionsProvidersService.getProvider(session.providerId);
if (provider instanceof CopilotChatSessionsProvider) {
provider.getSession(session.sessionId)?.setPermissionLevel(level);
}
}));
}
render(container: HTMLElement): HTMLElement {
@@ -108,6 +113,21 @@ export class PermissionPicker extends Disposable {
}
}));
const currentPermissionLevel = this._delegate.currentPermissionLevel;
if (currentPermissionLevel) {
this._renderDisposables.add(autorun(reader => {
this._currentLevel = currentPermissionLevel.read(reader);
this._updateTriggerLabel(trigger);
}));
}
const isApplicable = this._delegate.isApplicable;
if (isApplicable) {
this._renderDisposables.add(autorun(reader => {
slot.style.display = isApplicable.read(reader) ? '' : 'none';
}));
}
return slot;
}
@@ -274,7 +294,7 @@ export class PermissionPicker extends Disposable {
this._currentLevel = level;
this._updateTriggerLabel(this._triggerElement);
this._onDidChangeLevel.fire(level);
this._delegate.setPermissionLevel(level);
}
private _updateTriggerLabel(trigger: HTMLElement | undefined): void {
@@ -311,3 +331,31 @@ export class PermissionPicker extends Disposable {
trigger.classList.toggle('info', this._currentLevel === ChatPermissionLevel.AutoApprove);
}
}
/**
* Default-Copilot {@link IPermissionPickerDelegate}: writes the user's chosen
* level back to the active {@link CopilotChatSessionsProvider} session.
*
* Does not provide `currentPermissionLevel` or `isApplicable`, so the picker
* manages its own state and is always visible (visibility is gated at the menu
* contribution level via `when` clauses).
*/
export class CopilotPermissionPickerDelegate extends Disposable implements IPermissionPickerDelegate {
constructor(
@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,
@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,
) {
super();
}
setPermissionLevel(level: ChatPermissionLevel): void {
const session = this._sessionsManagementService.activeSession.get();
if (!session) {
return;
}
const provider = this._sessionsProvidersService.getProvider(session.providerId);
if (provider instanceof CopilotChatSessionsProvider) {
provider.getSession(session.sessionId)?.setPermissionLevel(level);
}
}
}
@@ -642,4 +642,4 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis
// Side-effect registrations for the remote agent host feature
import './remoteAgentHostActions.js';
import '../../chat/browser/agentHostModelPicker.js';
import '../../chat/browser/agentHost/agentHostModelPicker.js';
+1 -1
View File
@@ -460,7 +460,7 @@ import './browser/layoutActions.js';
import './contrib/accountMenu/browser/account.contribution.js';
import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.js';
import './contrib/chat/browser/chat.contribution.js';
import './contrib/chat/browser/agentHostSessionConfigPicker.js';
import './contrib/chat/browser/agentHost/agentHostSessionConfigPicker.js';
import './contrib/chat/browser/customizationsDebugLog.contribution.js';
import './contrib/copilotChatSessions/browser/copilotChatSessions.contribution.js';
import './contrib/sessions/browser/sessions.contribution.js';