mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-30 18:46:26 +01:00
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:
+18
-18
@@ -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);
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
-352
@@ -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;
|
||||
}
|
||||
|
||||
+212
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user