Add session provider picker to automations dialog (#325223)

* Stop defaulting to copilot

* Render the SessionTypePicker

* Automations: Wire in the sessiontypepicker

* Remove session type binder in favor of session type picker

* Permission label updates when selecting a new session type

* Use MobileSessionTypePicker for phone-layout bottom sheet support

The automation dialog was using the desktop-only SessionTypePicker
directly. The rest of the codebase uses MobileSessionTypePicker which
renders a bottom sheet on phone layouts and falls back to the desktop
action-widget popup otherwise. Swap to the mobile-aware subclass so
the automation picker behaves consistently on all viewports.

* Allow clearing provider and session type

* Prevent automations session type pick to change new session store

* Keep picker in sync when late providers advertise session types

* signing commit
This commit is contained in:
Ben Villalobos
2026-07-10 19:51:00 +00:00
committed by GitHub
parent ce5e0c6583
commit e8a3eada34
6 changed files with 182 additions and 129 deletions
@@ -15,7 +15,7 @@ import { Codicon } from '../../../../base/common/codicons.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { KeyCode } from '../../../../base/common/keyCodes.js';
import { DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
import { autorun } from '../../../../base/common/observable.js';
import { autorun, constObservable, observableValue } from '../../../../base/common/observable.js';
import { URI } from '../../../../base/common/uri.js';
import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';
import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js';
@@ -35,16 +35,16 @@ import { IProductService } from '../../../../platform/product/common/productServ
import { defaultCheckboxStyles, defaultInputBoxStyles, defaultSelectBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js';
import { hasNativeContextMenu } from '../../../../platform/window/common/window.js';
import { WorkspacePicker } from '../../chat/browser/sessionWorkspacePicker.js';
import { ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL } from '../../../services/sessions/common/session.js';
import { MobileSessionTypePicker } from '../../chat/browser/mobile/mobileSessionTypePicker.js';
import { ISession, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL } from '../../../services/sessions/common/session.js';
import { IGitService } from '../../../../workbench/contrib/git/common/gitService.js';
import { AutomationInterval } from '../../../../workbench/contrib/chat/common/automations/automation.js';
import { IShowAutomationDialogOptions } from '../../../../workbench/contrib/chat/common/automations/automationDialogService.js';
import { IAutomationSessionTypeChoice, IAutomationSessionTypeProvider } from '../../../../workbench/contrib/chat/common/automations/automationSessionTypes.js';
import { DAYS_OF_WEEK } from '../../../../workbench/contrib/chat/common/automations/schedule.js';
import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js';
import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js';
import { ChatAgentLocation, isChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js';
import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js';
import { AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js';
import { IChatWidget, ISessionTypePickerDelegate } from '../../../../workbench/contrib/chat/browser/chat.js';
import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPart.js';
import { isModeConsideredBuiltIn } from '../../../../workbench/contrib/chat/browser/widget/input/modePickerActionItem.js';
@@ -92,28 +92,10 @@ interface IRenderFormHandle {
readonly getModelId: () => string | undefined;
}
const AUTOMATIONS_HARNESS_CHIP_ACTION_ID = 'workbench.action.chat.renderAutomationsHarnessChip';
const AUTOMATIONS_ISOLATION_GROUP_ACTION_ID = 'workbench.action.chat.renderAutomationsIsolationGroup';
function createAutomationHarnessChip(): HTMLElement {
const harnessChip = $('span.automation-form-harness-chip');
DOM.append(harnessChip, renderIcon(Codicon.copilot));
DOM.append(harnessChip, $('span.automation-form-harness-label', undefined, localize('automation.form.harness', "Copilot CLI")));
return harnessChip;
}
class AutomationHarnessChipActionViewItem extends BaseActionViewItem {
constructor(action: IAction, options?: IBaseActionViewItemOptions) {
super(undefined, action, options);
}
override render(container: HTMLElement): void {
super.render(container);
DOM.clearNode(container);
DOM.append(container, createAutomationHarnessChip());
}
}
class AutomationIsolationGroupActionViewItem extends BaseActionViewItem {
private readonly renderDisposables = this._register(new DisposableStore());
private readonly branchRepoDisposable = this._register(new MutableDisposable<IDisposable>());
@@ -280,6 +262,28 @@ class AutomationIsolationGroupActionViewItem extends BaseActionViewItem {
}
}
/**
* Hosts the shared {@link SessionTypePicker} inside the chat input's secondary
* toolbar, in the slot previously occupied by the hardcoded harness chip. The
* picker instance is owned by the dialog (registered on its disposables); this
* view item only renders it into the toolbar container.
*/
class AutomationSessionTypePickerActionViewItem extends BaseActionViewItem {
constructor(
action: IAction,
private readonly picker: MobileSessionTypePicker,
options?: IBaseActionViewItemOptions,
) {
super(undefined, action, options);
}
override render(container: HTMLElement): void {
super.render(container);
DOM.clearNode(container);
this.picker.render(container);
}
}
registerAction2(class OpenAutomationsHarnessChipAction extends Action2 {
constructor() {
super({
@@ -318,83 +322,6 @@ registerAction2(class OpenAutomationsIsolationGroupAction extends Action2 {
override async run(): Promise<void> { /* handled by action view item */ }
});
/**
* Two-way binding between the chat input's session-target chip and the form's
* providerId + sessionTypeId fields.
*/
function createSessionTypeBinder(
state: IFormState,
sessionTypeProvider: IAutomationSessionTypeProvider,
disposables: DisposableStore,
): ISessionTypePickerDelegate & { setFolder(folder: URI | undefined): void } {
const onDidChange = disposables.add(new Emitter<AgentSessionTarget>());
const pickDefault = (available: readonly IAutomationSessionTypeChoice[]): IAutomationSessionTypeChoice | undefined => {
if (available.length === 0) {
return undefined;
}
return available.find(c => c.sessionTypeId === AgentSessionProviders.Background)
?? available[0];
};
const validateOrDefault = (folder: URI | undefined): void => {
if (!folder) {
state.providerId = undefined;
state.sessionTypeId = undefined;
return;
}
const available = sessionTypeProvider.getSessionTypesForFolder(folder);
if (state.providerId && state.sessionTypeId) {
const match = available.find(c => c.providerId === state.providerId && c.sessionTypeId === state.sessionTypeId);
if (match) {
return;
}
}
const def = pickDefault(available);
state.providerId = def?.providerId;
state.sessionTypeId = def?.sessionTypeId;
};
validateOrDefault(state.folderUri);
return {
getActiveSessionProvider: () => state.sessionTypeId as AgentSessionTarget | undefined,
setActiveSessionProvider: (target: AgentSessionTarget) => {
// Safe against folder-change races: we read the current folderUri and
// validate target against it. If the folder changed while the picker was
// open, the old target won't match the new folder's available types.
if (!state.folderUri) {
return;
}
const available = sessionTypeProvider.getSessionTypesForFolder(state.folderUri);
const match = available.find(c => c.sessionTypeId === target);
if (!match) {
return;
}
state.providerId = match.providerId;
state.sessionTypeId = match.sessionTypeId;
onDidChange.fire(match.sessionTypeId as AgentSessionTarget);
},
onDidChangeActiveSessionProvider: onDidChange.event,
setFolder: (folder: URI | undefined) => {
validateOrDefault(folder);
if (state.sessionTypeId) {
onDidChange.fire(state.sessionTypeId as AgentSessionTarget);
}
},
isSessionTypeVisible: (type: AgentSessionTarget) => {
if (type !== AgentSessionProviders.Background) {
return false;
}
if (!state.folderUri) {
return true;
}
const available = sessionTypeProvider.getSessionTypesForFolder(state.folderUri);
return available.some(c => c.sessionTypeId === type);
},
};
}
export function renderForm(
form: HTMLElement,
state: IFormState,
@@ -409,7 +336,6 @@ export function renderForm(
layoutService: ILayoutService,
logService: ILogService,
productService: IProductService,
sessionTypeProvider: IAutomationSessionTypeProvider,
initialPrompt: string,
initialMode: string | undefined,
initialPermissionLevel: string | undefined,
@@ -495,7 +421,37 @@ export function renderForm(
applyIntervalVisibility();
}));
const sessionTypeBinder = createSessionTypeBinder(state, sessionTypeProvider, disposables);
// The picker is authoritative for the session type
const folderObs = observableValue<URI | undefined>('automationFolder', state.folderUri);
const sessionTypePicker = disposables.add(instantiationService.createInstance(MobileSessionTypePicker, constObservable<ISession | undefined>(undefined), { persistSelection: false, telemetrySource: 'AutomationSessionTypePicker' }));
sessionTypePicker.setFolderSource(folderObs, {
initialPick: state.providerId && state.sessionTypeId
? { providerId: state.providerId, sessionTypeId: state.sessionTypeId }
: undefined,
});
// The dialog has no session, so the input part reads the active session type from the picker via this delegate.
const onDidChangeSessionType = disposables.add(new Emitter<AgentSessionTarget>());
const sessionTypeDelegate: ISessionTypePickerDelegate = {
getActiveSessionProvider: () => sessionTypePicker.selectedPick?.sessionTypeId as AgentSessionTarget | undefined,
onDidChangeActiveSessionProvider: onDidChangeSessionType.event,
};
const syncStateFromPicker = () => {
const pick = sessionTypePicker.selectedPick;
state.providerId = pick?.providerId;
state.sessionTypeId = pick?.sessionTypeId;
if (pick?.sessionTypeId) {
onDidChangeSessionType.fire(pick.sessionTypeId as AgentSessionTarget);
}
};
// Seed state from the picker's initial default (edit: saved type; create: folder default).
syncStateFromPicker();
// Covers both explicit user picks and recomputes (e.g. an agent host
// advertising its session types after the dialog opened), so the saved
// automation always matches the chip the picker displays.
disposables.add(sessionTypePicker.onDidChangeSelectedPick(() => {
syncStateFromPicker();
revalidate();
}));
const workspacePicker = disposables.add(instantiationService.createInstance(AutomationsWorkspacePicker));
@@ -505,13 +461,16 @@ export function renderForm(
disposables.add(workspacePicker.onDidSelectWorkspace(uri => {
state.folderUri = uri;
sessionTypeBinder.setFolder(uri);
// Setting the folder recomputes the picker's default; onDidChangeSelectedPick
// mirrors any resulting pick change into state. revalidate() still runs here
// because folder validity can change even when the pick does not.
folderObs.set(uri, undefined);
revalidate();
}));
if (!state.folderUri && workspacePicker.selectedFolderUri) {
state.folderUri = workspacePicker.selectedFolderUri;
sessionTypeBinder.setFolder(state.folderUri);
folderObs.set(state.folderUri, undefined);
}
const promptRow = DOM.append(form, $('.automation-form-row'));
@@ -545,11 +504,11 @@ export function renderForm(
// reserve the default 24px margin and lay the editor out too narrow,
// leaving its scrollbar floating ~24px in from the right wall.
inputPartHorizontalPadding: 0,
sessionTypePickerDelegate: sessionTypeBinder,
sessionTypePickerDelegate: sessionTypeDelegate,
workspacePickerInput: workspacePicker,
secondaryToolbarActionViewItemProvider: (action, itemOptions) => {
if (action.id === AUTOMATIONS_HARNESS_CHIP_ACTION_ID) {
return new AutomationHarnessChipActionViewItem(action, itemOptions);
return new AutomationSessionTypePickerActionViewItem(action, sessionTypePicker, itemOptions);
}
if (action.id === AUTOMATIONS_ISOLATION_GROUP_ACTION_ID) {
const actionWidgetService = instantiationService.invokeFunction(accessor => accessor.get(IActionWidgetService));
@@ -21,13 +21,10 @@ import { createWorkbenchDialogOptions } from '../../../../workbench/browser/part
import { IAutomationSchedule } from '../../../../workbench/contrib/chat/common/automations/automation.js';
import { IAutomationDialogResult, IAutomationDialogService, IShowAutomationDialogOptions } from '../../../../workbench/contrib/chat/common/automations/automationDialogService.js';
import { ICreateAutomationOptions, IUpdateAutomationOptions } from '../../../../workbench/contrib/chat/common/automations/automationService.js';
import { IAutomationSessionTypeProvider } from '../../../../workbench/contrib/chat/common/automations/automationSessionTypes.js';
import { SessionType } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
import { IHostService } from '../../../../workbench/services/host/browser/host.js';
import { IFormState, IValidationState, isAutomationDialogPopupTarget, renderForm, updateSaveButtonState } from './automationDialog.js';
const $ = DOM.$;
const COPILOT_PROVIDER_ID = 'default-copilot';
/**
* Owns the Automations create/edit dialog in the sessions layer, where the
@@ -48,7 +45,6 @@ export class AutomationDialogService implements IAutomationDialogService {
@ILogService private readonly logService: ILogService,
@IProductService private readonly productService: IProductService,
@IHostService private readonly hostService: IHostService,
@IAutomationSessionTypeProvider private readonly sessionTypeProvider: IAutomationSessionTypeProvider,
) { }
async showAutomationDialog(options: IShowAutomationDialogOptions): Promise<IAutomationDialogResult | undefined> {
@@ -122,7 +118,7 @@ export class AutomationDialogService implements IAutomationDialogService {
const formPane = DOM.append(container, $('.automation-form-pane'));
const form = DOM.append(formPane, $('.automation-form'));
const handle = renderForm(form, state, options, disposables, validation, () => revalidate(), this.instantiationService, this.contextKeyService, this.contextViewService, this.configurationService, this.layoutService, this.logService, this.productService, this.sessionTypeProvider, initial?.prompt ?? '', initial?.mode, initial?.permissionLevel, initial?.modelId);
const handle = renderForm(form, state, options, disposables, validation, () => revalidate(), this.instantiationService, this.contextKeyService, this.contextViewService, this.configurationService, this.layoutService, this.logService, this.productService, initial?.prompt ?? '', initial?.mode, initial?.permissionLevel, initial?.modelId);
getPrompt = handle.getPrompt;
getMode = handle.getMode;
getPermissionLevel = handle.getPermissionLevel;
@@ -165,8 +161,8 @@ export class AutomationDialogService implements IAutomationDialogService {
prompt,
schedule,
folderUri: state.folderUri,
providerId: state.providerId ?? COPILOT_PROVIDER_ID,
sessionTypeId: state.sessionTypeId ?? SessionType.CopilotCLI,
providerId: state.providerId ?? null,
sessionTypeId: state.sessionTypeId ?? null,
modelId: modelId ?? null,
mode: mode ?? null,
permissionLevel: permissionLevel ?? null,
@@ -182,8 +178,8 @@ export class AutomationDialogService implements IAutomationDialogService {
prompt,
schedule,
folderUri: state.folderUri,
providerId: state.providerId ?? COPILOT_PROVIDER_ID,
sessionTypeId: state.sessionTypeId ?? SessionType.CopilotCLI,
providerId: state.providerId,
sessionTypeId: state.sessionTypeId,
modelId,
mode,
permissionLevel,
@@ -17,7 +17,7 @@ import { ISessionsProvidersService } from '../../../../services/sessions/browser
import { ISession } from '../../../../services/sessions/common/session.js';
import { IObservable } from '../../../../../base/common/observable.js';
import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
import { SessionTypePicker } from '../sessionTypePicker.js';
import { SessionTypePicker, ISessionTypePickerOptions } from '../sessionTypePicker.js';
import { isPhoneLayout } from '../../../../browser/parts/mobile/mobileLayout.js';
import { IMobilePickerSheetItem, showMobilePickerSheet } from '../../../../browser/parts/mobile/mobilePickerSheet.js';
@@ -36,6 +36,7 @@ export class MobileSessionTypePicker extends SessionTypePicker {
constructor(
session: IObservable<ISession | undefined>,
options: ISessionTypePickerOptions | undefined,
@IActionWidgetService actionWidgetService: IActionWidgetService,
@ISessionsManagementService sessionsManagementService: ISessionsManagementService,
@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,
@@ -47,7 +48,7 @@ export class MobileSessionTypePicker extends SessionTypePicker {
@IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService,
@IContextKeyService contextKeyService: IContextKeyService,
) {
super(session, actionWidgetService, sessionsManagementService, _sessionsProvidersService, storageService, telemetryService, chatSessionsService, chatEntitlementService, languageModelsService, contextKeyService);
super(session, options, actionWidgetService, sessionsManagementService, _sessionsProvidersService, storageService, telemetryService, chatSessionsService, chatEntitlementService, languageModelsService, contextKeyService);
}
override render(container: HTMLElement, options?: { className?: string }): void {
@@ -337,7 +337,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation
// the same class regardless of construction-time viewport
// avoids a class-mismatch when the user resizes across the
// phone breakpoint after the chat input mounted.
this.sessionTypePicker = this._register(this.instantiationService.createInstance(MobileSessionTypePicker, this.options.session));
this.sessionTypePicker = this._register(this.instantiationService.createInstance(MobileSessionTypePicker, this.options.session, undefined));
this._register(this._contextAttachments.onDidChangeContext(() => {
this._updateDraftState();
this._updateSendButtonState();
@@ -53,11 +53,36 @@ export interface IPreferredSessionType {
readonly sessionTypeId: string;
}
function pickEquals(a: IPreferredSessionType | undefined, b: IPreferredSessionType | undefined): boolean {
return a?.providerId === b?.providerId && a?.sessionTypeId === b?.sessionTypeId;
}
interface IStoredSessionTypePick {
readonly providerId?: string;
readonly sessionTypeId: string;
}
/** Default telemetry source used when the picker serves the New Session composer. */
const DEFAULT_TELEMETRY_SOURCE = 'NewChatSessionTypePicker';
/**
* Configures how the picker behaves when reused outside the New Session
* composer (e.g. the automations dialog), where profile-wide persistence and
* new-chat telemetry would be incorrect side effects.
*/
export interface ISessionTypePickerOptions {
/**
* When `false` (used e.g. by the automations dialog), an explicit pick is
* never written to or cleared from the profile-wide
* {@link STORAGE_KEY_LAST_SESSION_TYPE} preference, so picking a type here
* cannot change the New Session default. The stored preference is still read
* to seed a sensible initial default. Defaults to `true`.
*/
readonly persistSelection?: boolean;
/** Telemetry id/name reported on selection. Defaults to {@link DEFAULT_TELEMETRY_SOURCE}. */
readonly telemetrySource?: string;
}
/**
* Row item rendered inside the session type picker — carries both the
* provider id and the session type so we can dispatch creation through
@@ -89,6 +114,16 @@ export class SessionTypePicker extends Disposable {
protected readonly _onDidSelectSessionType = this._register(new Emitter<IPickedSessionType | undefined>());
readonly onDidSelectSessionType = this._onDidSelectSessionType.event;
/**
* Fires whenever the effective {@link selectedPick} changes for any reason:
* an explicit user pick OR a recompute (e.g. a provider advertising its
* session types late). Unlike {@link onDidSelectSessionType}, which only
* covers explicit picks, this lets consumers that cache the pick stay in
* sync when the displayed default shifts on its own.
*/
protected readonly _onDidChangeSelectedPick = this._register(new Emitter<IPreferredSessionType | undefined>());
readonly onDidChangeSelectedPick = this._onDidChangeSelectedPick.event;
/** Session types the active session's folder can be served by, across all providers. */
protected _folderSessionTypes: IProviderSessionType[] = [];
@@ -109,6 +144,7 @@ export class SessionTypePicker extends Disposable {
constructor(
private readonly _session: IObservable<ISession | undefined>,
private readonly _options: ISessionTypePickerOptions | undefined,
@IActionWidgetService private readonly actionWidgetService: IActionWidgetService,
@ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService,
@ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService,
@@ -143,8 +179,12 @@ export class SessionTypePicker extends Disposable {
*/
protected _recompute(): void {
this._folderSessionTypes = this._resolveFolderSessionTypes();
const previous = this._picked;
this._picked = this._computeCurrentPick();
this._updateTriggerLabel();
if (!pickEquals(previous, this._picked)) {
this._onDidChangeSelectedPick.fire(this._picked);
}
}
/**
@@ -405,9 +445,10 @@ export class SessionTypePicker extends Disposable {
const beforeLabel = this._folderSessionTypes.find(t => t.sessionType.id === beforeId)?.sessionType.label;
const afterLabel = this._folderSessionTypes.find(t => t.providerId === pick.providerId && t.sessionType.id === pick.sessionTypeId)?.sessionType.label;
const telemetrySource = this._options?.telemetrySource ?? DEFAULT_TELEMETRY_SOURCE;
reportNewChatPickerClosed(this.telemetryService, {
id: 'NewChatSessionTypePicker',
name: 'NewChatSessionTypePicker',
id: telemetrySource,
name: telemetrySource,
optionIdBefore: beforeId,
optionIdAfter: pick.sessionTypeId,
optionLabelBefore: beforeLabel,
@@ -424,10 +465,15 @@ export class SessionTypePicker extends Disposable {
const preferred = this._folderSessionTypes[0];
const isDefault = !!preferred && preferred.providerId === pick.providerId && preferred.sessionType.id === pick.sessionTypeId;
const visiblePickChanged = pick.providerId !== this._picked?.providerId || pick.sessionTypeId !== this._picked?.sessionTypeId;
if (isDefault) {
this._clearStoredPick(pick);
} else {
this._writeStoredPick(pick);
// profile-wide preference is gated so non-persisting callers (e.g. the
// automations dialog) can pick a type without changing the New Session default
this._picked = pick;
if (this._options?.persistSelection !== false) {
if (isDefault) {
this._clearStoredPick();
} else {
this._writeStoredPick(pick);
}
}
// Folder-driven callers have no session change to re-run the refresh autorun, so refresh the label here.
this._updateTriggerLabel();
@@ -435,6 +481,7 @@ export class SessionTypePicker extends Disposable {
// actually changed, to avoid unnecessary work.
if (visiblePickChanged) {
this._onDidSelectSessionType.fire(pick);
this._onDidChangeSelectedPick.fire(this._picked);
}
}
@@ -461,7 +508,6 @@ export class SessionTypePicker extends Disposable {
}
private _writeStoredPick(pick: IPickedSessionType): void {
this._picked = pick;
const stored: IStoredSessionTypePick = { providerId: pick.providerId, sessionTypeId: pick.sessionTypeId };
this.storageService.store(STORAGE_KEY_LAST_SESSION_TYPE, JSON.stringify(stored), StorageScope.PROFILE, StorageTarget.MACHINE);
}
@@ -471,8 +517,7 @@ export class SessionTypePicker extends Disposable {
* type). The display still reflects the in-memory pick, but consumers
* reading {@link getUserPickedSessionType} fall back to the preferred type.
*/
private _clearStoredPick(pick: IPickedSessionType): void {
this._picked = pick;
private _clearStoredPick(): void {
this.storageService.remove(STORAGE_KEY_LAST_SESSION_TYPE, StorageScope.PROFILE);
}
@@ -24,7 +24,7 @@ import { TestStorageService } from '../../../../../workbench/test/common/workben
import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';
import { IProviderSessionType, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';
import { ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js';
import { IPickedSessionType, IPreferredSessionType, SessionTypePicker } from '../../browser/sessionTypePicker.js';
import { IPickedSessionType, IPreferredSessionType, ISessionTypePickerOptions, SessionTypePicker } from '../../browser/sessionTypePicker.js';
// ---- Mocks ------------------------------------------------------------------
@@ -109,6 +109,7 @@ function createPicker(
session: ISettableObservable<ISession | undefined>,
managementService: MockSessionsManagementService,
storage: IStorageService,
options?: ISessionTypePickerOptions,
): TestSessionTypePicker {
const instantiationService = disposables.add(new TestInstantiationService());
instantiationService.stub(IActionWidgetService, { isVisible: false, hide: () => { }, show: () => { } });
@@ -127,7 +128,7 @@ function createPicker(
lookupLanguageModel: () => undefined,
});
instantiationService.stub(IContextKeyService, new MockContextKeyService());
return disposables.add(instantiationService.createInstance(TestSessionTypePicker, session));
return disposables.add(instantiationService.createInstance(TestSessionTypePicker, session, options));
}
// ---- Tests ------------------------------------------------------------------
@@ -252,6 +253,57 @@ suite('SessionTypePicker', () => {
assert.strictEqual(picker.getUserPickedSessionType(), undefined);
});
test('persistSelection false never mutates the shared New Session preference', () => {
management.setSessionTypes([
sessionType('local-1', 'local', 'Local'),
sessionType('copilot', 'copilot-cli', 'Copilot CLI'),
sessionType('anthropic', 'claude', 'Claude'),
]);
// The New Session composer stored an explicit, non-default preference.
const shared = createPicker(disposables, session, management, storage);
shared.pick({ providerId: 'copilot', sessionTypeId: 'copilot-cli' });
assert.deepStrictEqual(shared.getUserPickedSessionType(), { providerId: 'copilot', sessionTypeId: 'copilot-cli' });
// The automations dialog picker still reads that stored preference to seed
// a sensible default, but must never write or clear it.
const scopedSession = observableValue<ISession | undefined>('scoped', undefined);
const scoped = createPicker(disposables, scopedSession, management, storage, { persistSelection: false });
assert.deepStrictEqual(scoped.getUserPickedSessionType(), { providerId: 'copilot', sessionTypeId: 'copilot-cli' });
// Give the scoped picker a folder so 'local' is its default type.
scopedSession.set(createFakeSession('local-1', 'local', folder), undefined);
// A different non-default pick would normally be written — it must not be.
scoped.pick({ providerId: 'anthropic', sessionTypeId: 'claude' });
assert.deepStrictEqual(shared.getUserPickedSessionType(), { providerId: 'copilot', sessionTypeId: 'copilot-cli' });
// Picking the default type would normally clear the stored pick — it must not.
scoped.pick({ providerId: 'local-1', sessionTypeId: 'local' });
assert.deepStrictEqual(shared.getUserPickedSessionType(), { providerId: 'copilot', sessionTypeId: 'copilot-cli' });
});
test('onDidChangeSelectedPick fires when session types are advertised after the picker is created', () => {
// No types advertised yet (e.g. the agent host has not connected).
management.setSessionTypes([]);
const picker = createPicker(disposables, session, management, storage);
const folderObs = observableValue<URI | undefined>('folder', folder);
picker.setFolderSource(folderObs);
assert.strictEqual(picker.selectedPick, undefined);
const fired: (IPreferredSessionType | undefined)[] = [];
disposables.add(picker.onDidChangeSelectedPick(pick => fired.push(pick)));
// A provider advertises its types late; the displayed default shifts on its
// own (no explicit user pick), and consumers that cache the pick are notified.
management.setSessionTypes([
sessionType('local-1', 'local', 'Local'),
sessionType('copilot', 'copilot-cli', 'Copilot CLI'),
]);
assert.deepStrictEqual(picker.selectedPick, { providerId: 'local-1', sessionTypeId: 'local' });
assert.deepStrictEqual(fired, [{ providerId: 'local-1', sessionTypeId: 'local' }]);
});
test('a quick chat sources its types from the quick-chat list, not the folder list', () => {
// Folder list is empty (workspace-less); quick-chat list drives defaults.
management.setSessionTypes([]);