Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 232 additions & 0 deletions src/vs/sessions/contrib/chat/browser/modePicker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

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 { localize } from '../../../../nls.js';
import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js';
import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js';
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
import { ChatMode, IChatMode, IChatModeService } from '../../../../workbench/contrib/chat/common/chatModes.js';
import { IGitRepository } from '../../../../workbench/contrib/git/common/gitService.js';
import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js';
import { Target } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';

const CONFIGURE_AGENTS_ACTION_ID = 'workbench.action.chat.picker.customagents';

interface IModePickerItem {
readonly kind: 'mode';
readonly mode: IChatMode;
}

interface IConfigurePickerItem {
readonly kind: 'configure';
}

type ModePickerItem = IModePickerItem | IConfigurePickerItem;

/**
* A self-contained widget for selecting a chat mode (Agent, custom agents)
* for local/Background sessions. Shows only modes whose target matches
* the Background session type's customAgentTarget.
*/
export class ModePicker extends Disposable {

private readonly _onDidChange = this._register(new Emitter<IChatMode>());
readonly onDidChange: Event<IChatMode> = this._onDidChange.event;

private _triggerElement: HTMLElement | undefined;
private _slotElement: HTMLElement | undefined;
private readonly _renderDisposables = this._register(new DisposableStore());

private _selectedMode: IChatMode = ChatMode.Agent;

get selectedMode(): IChatMode {
return this._selectedMode;
}

constructor(
@IActionWidgetService private readonly actionWidgetService: IActionWidgetService,
@IChatModeService private readonly chatModeService: IChatModeService,
@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,
@ICommandService private readonly commandService: ICommandService,
) {
super();
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ModePicker does not listen to changes in available modes. If custom agents are added, removed, or modified after the picker is created, the picker will not update its list of available modes until the user reopens the picker. Consider adding a listener to chatModeService.onDidChangeChatModes to refresh the available modes dynamically.

Suggested change
super();
super();
this._register(this.chatModeService.onDidChangeChatModes(() => {
// Refresh the trigger label when available chat modes change
if (this._triggerElement) {
this._updateTriggerLabel();
}
}));

Copilot uses AI. Check for mistakes.
}

/**
* Sets the git repository. When the repository changes, resets the selected mode
* back to the default Agent mode.
*/
setRepository(repository: IGitRepository | undefined): void {
this._selectedMode = ChatMode.Agent;
this._updateTriggerLabel();
}

/**
* Renders the mode picker trigger button into the given container.
*/
render(container: HTMLElement): HTMLElement {
this._renderDisposables.clear();

const slot = dom.append(container, dom.$('.sessions-chat-picker-slot'));
this._slotElement = slot;
this._renderDisposables.add({ dispose: () => slot.remove() });

const trigger = dom.append(slot, dom.$('a.action-label'));
trigger.tabIndex = 0;
trigger.role = 'button';
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trigger element has tabIndex and role set for keyboard accessibility (lines 73-74), but there's no aria-label or aria-describedby attribute to provide context for screen reader users. Consider adding an aria-label such as "Select chat mode" to improve accessibility, similar to how other pickers in the codebase provide accessible labels.

Suggested change
trigger.role = 'button';
trigger.role = 'button';
trigger.setAttribute('aria-label', localize('sessions.modePicker.ariaLabel', "Select chat mode"));

Copilot uses AI. Check for mistakes.
this._triggerElement = trigger;

this._updateTriggerLabel();

this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => {
dom.EventHelper.stop(e, true);
this._showPicker();
}));

this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => {
if (e.key === 'Enter' || e.key === ' ') {
dom.EventHelper.stop(e, true);
this._showPicker();
}
}));

return slot;
}

/**
* Shows or hides the picker.
*/
setVisible(visible: boolean): void {
if (this._slotElement) {
this._slotElement.style.display = visible ? '' : 'none';
}
}

private _getAvailableModes(): IChatMode[] {
const customAgentTarget = this.chatSessionsService.getCustomAgentTargetForSessionType(AgentSessionProviders.Background);
const effectiveTarget = customAgentTarget && customAgentTarget !== Target.Undefined ? customAgentTarget : Target.GitHubCopilot;
const modes = this.chatModeService.getModes();

// Always include the default Agent mode
const result: IChatMode[] = [ChatMode.Agent];

// Add custom modes matching the target
for (const mode of modes.custom) {
const target = mode.target.get();
if (target === effectiveTarget || target === Target.Undefined) {
result.push(mode);
}
}

return result;
}
Comment on lines +113 to +130
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mode filtering logic does not check the visibility.userInvocable property of custom modes. According to the codebase conventions (see chatModes.ts line 116 and line 162), modes with userInvocable set to false should not be shown in user-facing UI. The picker should filter out modes where visibility.userInvocable is false to prevent users from selecting modes that are only intended for agent invocation.

Copilot uses AI. Check for mistakes.

private _showPicker(): void {
if (!this._triggerElement || this.actionWidgetService.isVisible) {
return;
}

const modes = this._getAvailableModes();
if (modes.length === 0) {
return;
}
Comment on lines +138 to +140
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The early return when modes.length is 0 prevents the picker from being shown, but this condition should never be true since _getAvailableModes always includes at least ChatMode.Agent (line 109). This check is defensive but may hide bugs where the default Agent mode is unexpectedly missing. Consider removing this check or adding logging to detect if this condition ever occurs.

Suggested change
if (modes.length === 0) {
return;
}

Copilot uses AI. Check for mistakes.

const items = this._buildItems(modes);

const triggerElement = this._triggerElement;
const delegate: IActionListDelegate<ModePickerItem> = {
onSelect: (item) => {
this.actionWidgetService.hide();
if (item.kind === 'mode') {
this._selectMode(item.mode);
} else {
this.commandService.executeCommand(CONFIGURE_AGENTS_ACTION_ID);
}
},
onHide: () => { triggerElement.focus(); },
};

this.actionWidgetService.show<ModePickerItem>(
'localModePicker',
false,
items,
delegate,
this._triggerElement,
undefined,
[],
{
getAriaLabel: (item) => item.label ?? '',
getWidgetAriaLabel: () => localize('modePicker.ariaLabel', "Mode Picker"),
},
);
}

private _buildItems(modes: IChatMode[]): IActionListItem<ModePickerItem>[] {
const items: IActionListItem<ModePickerItem>[] = [];

// Default Agent mode
const agentMode = modes[0];
items.push({
kind: ActionListItemKind.Action,
label: agentMode.label.get(),
group: { title: '', icon: this._selectedMode.id === agentMode.id ? Codicon.check : Codicon.blank },
item: { kind: 'mode', mode: agentMode },
});

// Custom modes (with separator if any exist)
const customModes = modes.slice(1);
if (customModes.length > 0) {
items.push({ kind: ActionListItemKind.Separator, label: '' });
for (const mode of customModes) {
items.push({
kind: ActionListItemKind.Action,
label: mode.label.get(),
group: { title: '', icon: this._selectedMode.id === mode.id ? Codicon.check : Codicon.blank },
item: { kind: 'mode', mode },
});
}
}

// Configure Custom Agents action
items.push({ kind: ActionListItemKind.Separator, label: '' });
items.push({
kind: ActionListItemKind.Action,
label: localize('configureCustomAgents', "Configure Custom Agents..."),
group: { title: '', icon: Codicon.blank },
item: { kind: 'configure' },
});

return items;
}

private _selectMode(mode: IChatMode): void {
this._selectedMode = mode;
this._updateTriggerLabel();
this._onDidChange.fire(mode);
}

private _updateTriggerLabel(): void {
if (!this._triggerElement) {
return;
}

dom.clearNode(this._triggerElement);

const icon = this._selectedMode.icon.get();
if (icon) {
dom.append(this._triggerElement, renderIcon(icon));
}

const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label'));
labelSpan.textContent = this._selectedMode.label.get();
dom.append(this._triggerElement, renderIcon(Codicon.chevronDown));
}
Comment on lines +216 to +231
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ModePicker does not visually indicate when no custom modes are available, unlike the CloudModelPicker which adds a 'disabled' class (see modelPicker.ts line 202). While the picker will show only the default Agent mode, it would be better UX to add visual feedback when the picker is effectively showing only one option. Consider adding disabled styling when modes.length is 1.

Copilot uses AI. Check for mistakes.
}
25 changes: 23 additions & 2 deletions src/vs/sessions/contrib/chat/browser/newChatViewPane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { SyncIndicator } from './syncIndicator.js';
import { INewSession, ISessionOptionGroup, RemoteNewSession } from './newSession.js';
import { RepoPicker } from './repoPicker.js';
import { CloudModelPicker } from './modelPicker.js';
import { ModePicker } from './modePicker.js';
import { getErrorMessage } from '../../../../base/common/errors.js';
import { SlashCommandHandler } from './slashCommands.js';
import { IChatModelInputState } from '../../../../workbench/contrib/chat/common/model/chatModel.js';
Expand Down Expand Up @@ -141,6 +142,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
private readonly _repoPicker: RepoPicker;
private _repoPickerContainer: HTMLElement | undefined;
private readonly _cloudModelPicker: CloudModelPicker;
private readonly _modePicker: ModePicker;
private readonly _toolbarPickerWidgets = new Map<string, ChatSessionPickerActionItem | SearchableOptionPickerActionItem>();
private readonly _toolbarPickerDisposables = this._register(new DisposableStore());
private readonly _optionEmitters = new Map<string, Emitter<IChatSessionProviderOptionItem>>();
Expand Down Expand Up @@ -177,6 +179,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
this._folderPicker = this._register(this.instantiationService.createInstance(FolderPicker));
this._repoPicker = this._register(this.instantiationService.createInstance(RepoPicker));
this._cloudModelPicker = this._register(this.instantiationService.createInstance(CloudModelPicker));
this._modePicker = this._register(this.instantiationService.createInstance(ModePicker));
this._targetPicker = this._register(new SessionTargetPicker(options.allowedTargets, this._resolveDefaultTarget(options)));
this._isolationModePicker = this._register(this.instantiationService.createInstance(IsolationModePicker));
this._branchPicker = this._register(this.instantiationService.createInstance(BranchPicker));
Expand Down Expand Up @@ -211,6 +214,12 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
this._focusEditor();
}));

// When mode changes, update the session
this._register(this._modePicker.onDidChange((mode) => {
this._newSession.value?.setMode(mode);
this._focusEditor();
}));

// When language models change (e.g., extension activates), reinitialize if no model selected
this._register(this.languageModelsService.onDidChangeLanguageModels(() => {
this._initDefaultModel();
Expand Down Expand Up @@ -326,6 +335,9 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
session.setModelId(currentModel.identifier);
}

// Set the current mode on the session (for local sessions)
session.setMode(this._modePicker.selectedMode);

// Open repository for the session's repoUri
if (session.repoUri) {
this._openRepository(session.repoUri);
Expand Down Expand Up @@ -367,6 +379,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
this._branchPicker.setRepository(undefined);
this._isolationModePicker.setRepository(undefined);
this._syncIndicator.setRepository(undefined);
this._modePicker.setRepository(undefined);

this.gitService.openRepository(folderUri).then(repository => {
if (cts.token.isCancellationRequested) {
Expand All @@ -377,6 +390,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
this._isolationModePicker.setRepository(repository);
this._branchPicker.setRepository(repository);
this._syncIndicator.setRepository(repository);
this._modePicker.setRepository(repository);
}).catch(e => {
if (cts.token.isCancellationRequested) {
return;
Expand All @@ -387,6 +401,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
this._isolationModePicker.setRepository(undefined);
this._branchPicker.setRepository(undefined);
this._syncIndicator.setRepository(undefined);
this._modePicker.setRepository(undefined);
});
}

Expand Down Expand Up @@ -570,6 +585,10 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
this._localModelPickerContainer = dom.append(toolbar, dom.$('.sessions-chat-model-picker'));
this._createLocalModelPicker(this._localModelPickerContainer);

// Local mode picker
this._modePicker.render(toolbar);
this._modePicker.setVisible(false);

// Remote model picker (action list dropdown)
this._cloudModelPicker.render(toolbar);
this._cloudModelPicker.setVisible(false);
Expand Down Expand Up @@ -691,10 +710,11 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
if (this._extensionPickersLeftContainer) {
this._extensionPickersLeftContainer.style.display = 'block';
}
// Show local model picker, hide remote
// Show local model and mode pickers, hide remote
if (this._localModelPickerContainer) {
this._localModelPickerContainer.style.display = '';
}
this._modePicker.setVisible(true);
this._cloudModelPicker.setVisible(false);
}

Expand All @@ -710,10 +730,11 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
this._folderPickerContainer.style.display = 'none';
}

// Show remote model picker, hide local
// Show remote model picker, hide local pickers
if (this._localModelPickerContainer) {
this._localModelPickerContainer.style.display = 'none';
}
this._modePicker.setVisible(false);
this._cloudModelPicker.setSession(session);
this._cloudModelPicker.setVisible(true);

Expand Down
Loading
Loading