diff --git a/src/vs/sessions/contrib/chat/browser/agentHostModelPicker.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostModelPicker.ts similarity index 81% rename from src/vs/sessions/contrib/chat/browser/agentHostModelPicker.ts rename to src/vs/sessions/contrib/chat/browser/agentHost/agentHostModelPicker.ts index 57785b2cb9c..38c6a51d478 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHostModelPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostModelPicker.ts @@ -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'), diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerActionItem.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerActionItem.ts new file mode 100644 index 00000000000..930693cd99d --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerActionItem.ts @@ -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'; + } + })); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts new file mode 100644 index 00000000000..75d660a448e --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts @@ -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 = 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()); + + readonly currentPermissionLevel: IObservable; + readonly isApplicable: IObservable; + + 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); + })); + } + } +} diff --git a/src/vs/sessions/contrib/chat/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts similarity index 53% rename from src/vs/sessions/contrib/chat/browser/agentHostSessionConfigPicker.ts rename to src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts index f9528deea19..d1de554901d 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts @@ -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(); @@ -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 { } }); -/** - * 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()); - 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 { - 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 = { - 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( - `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 { } }); -/** - * 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()); - 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 { - 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 = { - 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( - `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); diff --git a/src/vs/sessions/contrib/chat/browser/media/agentHostSessionConfigPicker.css b/src/vs/sessions/contrib/chat/browser/media/agentHostSessionConfigPicker.css index e3a2b3b1528..359c012db4c 100644 --- a/src/vs/sessions/contrib/chat/browser/media/agentHostSessionConfigPicker.css +++ b/src/vs/sessions/contrib/chat/browser/media/agentHostSessionConfigPicker.css @@ -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; -} diff --git a/src/vs/sessions/contrib/chat/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts b/src/vs/sessions/contrib/chat/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts new file mode 100644 index 00000000000..a014ef88e11 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts @@ -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 { + readonly id: string = PROVIDER_ID; + private readonly _onDidChange = new Emitter(); + readonly onDidChangeSessionConfig: Event = 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 { + 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>; +} + +function setup(store: Pick, 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()); + const sessionsProvidersService = new (class extends mock() { + override readonly onDidChangeProviders = onDidChangeProviders.event; + override getProviders(): ISessionsProvider[] { return [provider as unknown as ISessionsProvider]; } + override getProvider(id: string): T | undefined { + return id === provider.id ? (provider as unknown as T) : undefined; + } + })(); + const activeSessionObs = observableValue('activeSession', activeSession); + const sessionsManagementService = new (class extends mock() { + 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 { + 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); + }); +}); diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts index a4e7655a33f..440a03d8e48 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -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); }, )); } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts index 9507116fa34..ad080429eb7 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts @@ -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(); +/** + * 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; + + /** + * If provided, the picker hides itself when this is `false`. Used by + * delegates whose applicability depends on the active session. + */ + readonly isApplicable?: IObservable; + + /** + * 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(); - private readonly _onDidChangeLevel = this._register(new Emitter()); - readonly onDidChangeLevel: Event = 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); + } + } +} diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 0f12071dbdc..518c56e1df4 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -642,4 +642,4 @@ Registry.as(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'; diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 901dcb40f6f..fa93ea472e0 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -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';