Skip to content

Commit

Permalink
Change agent/model pickers to proper dropdowns (#240014)
Browse files Browse the repository at this point in the history
* Use DropdownMenuActionViewItem for toggle agent action

* Fix model picker

* Reorder

* Fix

* Add aria attributes
  • Loading branch information
roblourens authored Feb 8, 2025
1 parent f44ea9d commit 1816f95
Show file tree
Hide file tree
Showing 5 changed files with 140 additions and 144 deletions.
76 changes: 41 additions & 35 deletions src/vs/base/browser/ui/dropdown/dropdownActionViewItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,23 @@
*--------------------------------------------------------------------------------------------*/

import * as nls from '../../../../nls.js';
import { Action, IAction, IActionRunner } from '../../../common/actions.js';
import { Codicon } from '../../../common/codicons.js';
import { Emitter } from '../../../common/event.js';
import { ResolvedKeybinding } from '../../../common/keybindings.js';
import { KeyCode } from '../../../common/keyCodes.js';
import { IDisposable } from '../../../common/lifecycle.js';
import { ThemeIcon } from '../../../common/themables.js';
import { IContextMenuProvider } from '../../contextmenu.js';
import { $, addDisposableListener, append, EventType, h } from '../../dom.js';
import { StandardKeyboardEvent } from '../../keyboardEvent.js';
import { IActionViewItemProvider } from '../actionbar/actionbar.js';
import { ActionViewItem, BaseActionViewItem, IActionViewItemOptions, IBaseActionViewItemOptions } from '../actionbar/actionViewItems.js';
import { AnchorAlignment } from '../contextview/contextview.js';
import { DropdownMenu, IActionProvider, IDropdownMenuOptions, ILabelRenderer } from './dropdown.js';
import { Action, IAction, IActionRunner } from '../../../common/actions.js';
import { Codicon } from '../../../common/codicons.js';
import { ThemeIcon } from '../../../common/themables.js';
import { Emitter } from '../../../common/event.js';
import { KeyCode } from '../../../common/keyCodes.js';
import { ResolvedKeybinding } from '../../../common/keybindings.js';
import { IDisposable } from '../../../common/lifecycle.js';
import './dropdown.css';
import { getDefaultHoverDelegate } from '../hover/hoverDelegateFactory.js';
import { getBaseLayerHoverDelegate } from '../hover/hoverDelegate2.js';
import { getDefaultHoverDelegate } from '../hover/hoverDelegateFactory.js';
import './dropdown.css';
import { DropdownMenu, IActionProvider, IDropdownMenuOptions, ILabelRenderer } from './dropdown.js';

export interface IKeybindingProvider {
(action: IAction): ResolvedKeybinding | undefined;
Expand Down Expand Up @@ -73,31 +73,7 @@ export class DropdownMenuActionViewItem extends BaseActionViewItem {

const labelRenderer: ILabelRenderer = (el: HTMLElement): IDisposable | null => {
this.element = append(el, $('a.action-label'));

let classNames: string[] = [];

if (typeof this.options.classNames === 'string') {
classNames = this.options.classNames.split(/\s+/g).filter(s => !!s);
} else if (this.options.classNames) {
classNames = this.options.classNames;
}

// todo@aeschli: remove codicon, should come through `this.options.classNames`
if (!classNames.find(c => c === 'icon')) {
classNames.push('codicon');
}

this.element.classList.add(...classNames);

this.element.setAttribute('role', 'button');
this.element.setAttribute('aria-haspopup', 'true');
this.element.setAttribute('aria-expanded', 'false');
if (this._action.label) {
this._register(getBaseLayerHoverDelegate().setupManagedHover(this.options.hoverDelegate ?? getDefaultHoverDelegate('mouse'), this.element, this._action.label));
}
this.element.ariaLabel = this._action.label || '';

return null;
return this.renderLabel(this.element);
};

const isActionsArray = Array.isArray(this.menuActionsOrProvider);
Expand Down Expand Up @@ -138,6 +114,36 @@ export class DropdownMenuActionViewItem extends BaseActionViewItem {
this.updateEnabled();
}

protected renderLabel(element: HTMLElement): IDisposable | null {
let classNames: string[] = [];

if (typeof this.options.classNames === 'string') {
classNames = this.options.classNames.split(/\s+/g).filter(s => !!s);
} else if (this.options.classNames) {
classNames = this.options.classNames;
}

// todo@aeschli: remove codicon, should come through `this.options.classNames`
if (!classNames.find(c => c === 'icon')) {
classNames.push('codicon');
}

element.classList.add(...classNames);

if (this._action.label) {
this._register(getBaseLayerHoverDelegate().setupManagedHover(this.options.hoverDelegate ?? getDefaultHoverDelegate('mouse'), element, this._action.label));
}

return null;
}

protected setAriaLabelAttributes(element: HTMLElement): void {
element.setAttribute('role', 'button');
element.setAttribute('aria-haspopup', 'true');
element.setAttribute('aria-expanded', 'false');
element.ariaLabel = this._action.label || '';
}

protected override getTooltip(): string | undefined {
let title: string | null = null;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { IContextMenuProvider } from '../../../base/browser/contextmenu.js';
import { IActionProvider } from '../../../base/browser/ui/dropdown/dropdown.js';
import { DropdownMenuActionViewItem, IDropdownMenuActionViewItemOptions } from '../../../base/browser/ui/dropdown/dropdownActionViewItem.js';
import { IAction } from '../../../base/common/actions.js';
import * as nls from '../../../nls.js';
import { IContextKeyService } from '../../contextkey/common/contextkey.js';
import { IKeybindingService } from '../../keybinding/common/keybinding.js';

export class DropdownMenuActionViewItemWithKeybinding extends DropdownMenuActionViewItem {
constructor(
action: IAction,
menuActionsOrProvider: readonly IAction[] | IActionProvider,
contextMenuProvider: IContextMenuProvider,
options: IDropdownMenuActionViewItemOptions = Object.create(null),
@IKeybindingService private readonly keybindingService: IKeybindingService,
@IContextKeyService private readonly contextKeyService: IContextKeyService,
) {
super(action, menuActionsOrProvider, contextMenuProvider, options);
}

protected override getTooltip() {
const keybinding = this.keybindingService.lookupKeybinding(this.action.id, this.contextKeyService);
const keybindingLabel = keybinding && keybinding.getLabel();

const tooltip = this.action.tooltip ?? this.action.label;
return keybindingLabel
? nls.localize('titleAndKb', "{0} ({1})", tooltip, keybindingLabel)
: tooltip;
}
}
158 changes: 55 additions & 103 deletions src/vs/workbench/contrib/chat/browser/chatInputPart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ import { IHistoryNavigationWidget } from '../../../../base/browser/history.js';
import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
import * as aria from '../../../../base/browser/ui/aria/aria.js';
import { Button } from '../../../../base/browser/ui/button/button.js';
import { IActionProvider } from '../../../../base/browser/ui/dropdown/dropdown.js';
import { IManagedHoverTooltipMarkdownString } from '../../../../base/browser/ui/hover/hover.js';
import { IHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate.js';
import { getBaseLayerHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate2.js';
import { createInstantHoverDelegate, getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';
import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js';
import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
import { ProgressBar } from '../../../../base/browser/ui/progressbar/progressbar.js';
import { IAction, Separator, toAction } from '../../../../base/common/actions.js';
import { IAction } from '../../../../base/common/actions.js';
import { coalesce } from '../../../../base/common/arrays.js';
import { Promises } from '../../../../base/common/async.js';
import { Codicon } from '../../../../base/common/codicons.js';
Expand Down Expand Up @@ -51,8 +52,9 @@ import { SuggestController } from '../../../../editor/contrib/suggest/browser/su
import { localize } from '../../../../nls.js';
import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js';
import { MenuWorkbenchButtonBar } from '../../../../platform/actions/browser/buttonbar.js';
import { DropdownMenuActionViewItemWithKeybinding } from '../../../../platform/actions/browser/dropdownActionViewItemWithKeybinding.js';
import { DropdownWithPrimaryActionViewItem, IDropdownWithPrimaryActionViewItemOptions } from '../../../../platform/actions/browser/dropdownWithPrimaryActionViewItem.js';
import { getFlatActionBarActions, IMenuEntryActionViewItemOptions, MenuEntryActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js';
import { getFlatActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js';
import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';
import { IMenuService, MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
Expand Down Expand Up @@ -837,10 +839,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
},
getModels: () => this.getModels()
};
return this.instantiationService.createInstance(ModelPickerActionViewItem, action, this._currentLanguageModel, itemDelegate, { hoverDelegate: options.hoverDelegate, keybinding: options.keybinding ?? undefined });
return this.instantiationService.createInstance(ModelPickerActionViewItem, action, this._currentLanguageModel, itemDelegate);
}
} else if (action.id === ToggleAgentModeActionId && action instanceof MenuItemAction) {
return this.instantiationService.createInstance(ToggleAgentActionViewItem, action, options as IMenuEntryActionViewItemOptions);
return this.instantiationService.createInstance(ToggleAgentActionViewItem, action);
}

return undefined;
Expand Down Expand Up @@ -1637,155 +1639,105 @@ interface ModelPickerDelegate {
getModels(): ILanguageModelChatMetadataAndIdentifier[];
}

class ModelPickerActionViewItem extends MenuEntryActionViewItem {
class ModelPickerActionViewItem extends DropdownMenuActionViewItemWithKeybinding {
constructor(
action: MenuItemAction,
private currentLanguageModel: ILanguageModelChatMetadataAndIdentifier,
private readonly delegate: ModelPickerDelegate,
options: IMenuEntryActionViewItemOptions,
@IKeybindingService keybindingService: IKeybindingService,
@INotificationService notificationService: INotificationService,
@IContextKeyService contextKeyService: IContextKeyService,
@IThemeService themeService: IThemeService,
@IContextMenuService contextMenuService: IContextMenuService,
@IAccessibilityService _accessibilityService: IAccessibilityService,
@ICommandService private readonly _commandService: ICommandService,
@IKeybindingService keybindingService: IKeybindingService,
@IContextKeyService contextKeyService: IContextKeyService,
) {
super(action, options, keybindingService, notificationService, contextKeyService, themeService, contextMenuService, _accessibilityService);
const modelActionsProvider: IActionProvider = {
getActions: () => {
const setLanguageModelAction = (entry: ILanguageModelChatMetadataAndIdentifier): IAction => {
return {
id: entry.identifier,
label: entry.metadata.name,
tooltip: '',
class: undefined,
enabled: true,
checked: entry.identifier === this.currentLanguageModel.identifier,
run: () => {
this.currentLanguageModel = entry;
this.renderLabel(this.element!);
this.delegate.setModel(entry);
}
};
};

const models: ILanguageModelChatMetadataAndIdentifier[] = this.delegate.getModels();
return models.map(entry => setLanguageModelAction(entry));
}
};

super(action, modelActionsProvider, contextMenuService, undefined, keybindingService, contextKeyService);
this._register(delegate.onDidChangeModel(modelId => {
this.currentLanguageModel = modelId;
this.updateLabel();
this.renderLabel(this.element!);
}));
}

override async onClick(event: MouseEvent): Promise<void> {
this._openContextMenu();
protected override renderLabel(element: HTMLElement): IDisposable | null {
this.setAriaLabelAttributes(element);
dom.reset(element, dom.$('span.chat-model-label', undefined, this.currentLanguageModel.metadata.name), ...renderLabelWithIcons(`$(chevron-down)`));
return null;
}

override render(container: HTMLElement): void {
super.render(container);
container.classList.add('chat-modelPicker-item');

// TODO@roblourens this should be a DropdownMenuActionViewItem, but we can't customize how it's rendered yet.
this._register(dom.addDisposableListener(container, dom.EventType.KEY_UP, e => {
const event = new StandardKeyboardEvent(e);
if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) {
this._openContextMenu();
}
}));
}

protected override updateLabel(): void {
if (this.label && this.currentLanguageModel) {
dom.reset(this.label, dom.$('span.chat-model-label', undefined, this.currentLanguageModel.metadata.name), ...renderLabelWithIcons(`$(chevron-down)`));
}
}

private _openContextMenu() {
const setLanguageModelAction = (entry: ILanguageModelChatMetadataAndIdentifier): IAction => {
return {
id: entry.identifier,
label: entry.metadata.name,
tooltip: '',
class: undefined,
enabled: true,
checked: entry.identifier === this.currentLanguageModel.identifier,
run: () => {
this.currentLanguageModel = entry;
this.updateLabel();
this.delegate.setModel(entry);
}
};
};

const models: ILanguageModelChatMetadataAndIdentifier[] = this.delegate.getModels();
this._contextMenuService.showContextMenu({
getAnchor: () => this.element!,
getActions: () => {
const actions = models.map(entry => setLanguageModelAction(entry));

if (this._contextKeyService.getContextKeyValue<boolean>(ChatContextKeys.Setup.limited.key) === true) {
actions.push(new Separator());
actions.push(toAction({ id: 'moreModels', label: localize('chat.moreModels', "Add More Models..."), run: () => this._commandService.executeCommand('workbench.action.chat.upgradePlan') }));
}

return actions;
},
});
}
}

const chatInputEditorContainerSelector = '.interactive-input-editor';
setupSimpleEditorSelectionStyling(chatInputEditorContainerSelector);

class ToggleAgentActionViewItem extends MenuEntryActionViewItem {
class ToggleAgentActionViewItem extends DropdownMenuActionViewItemWithKeybinding {
private readonly agentStateActions: IAction[];

constructor(
action: MenuItemAction,
options: IMenuEntryActionViewItemOptions,
@IContextMenuService contextMenuService: IContextMenuService,
@IKeybindingService keybindingService: IKeybindingService,
@INotificationService notificationService: INotificationService,
@IContextKeyService contextKeyService: IContextKeyService,
@IThemeService themeService: IThemeService,
@IContextMenuService contextMenuService: IContextMenuService,
@IAccessibilityService _accessibilityService: IAccessibilityService
) {
options.keybindingNotRenderedWithLabel = true;
super(action, options, keybindingService, notificationService, contextKeyService, themeService, contextMenuService, _accessibilityService);

this.agentStateActions = [
const agentStateActions = [
{
...this.action,
...action,
id: 'agentMode',
label: localize('chat.agentMode', "Agent"),
class: undefined,
enabled: true,
run: () => this.action.run({ agentMode: true } satisfies IToggleAgentModeArgs)
run: () => action.run({ agentMode: true } satisfies IToggleAgentModeArgs)
},
{
...this.action,
...action,
id: 'normalMode',
label: localize('chat.normalMode', "Edit"),
class: undefined,
enabled: true,
checked: !this.action.checked,
run: () => this.action.run({ agentMode: false } satisfies IToggleAgentModeArgs)
checked: !action.checked,
run: () => action.run({ agentMode: false } satisfies IToggleAgentModeArgs)
},
];

super(action, agentStateActions, contextMenuService, undefined, keybindingService, contextKeyService);
this.agentStateActions = agentStateActions;
}

override async onClick(event: MouseEvent): Promise<void> {
this._openContextMenu();
protected override renderLabel(element: HTMLElement): IDisposable | null {
// Can't call super.renderLabel because it has a hack of forcing the 'codicon' class
this.setAriaLabelAttributes(element);

const state = this.agentStateActions.find(action => action.checked)?.label ?? '';
dom.reset(element, dom.$('span.chat-model-label', undefined, state), ...renderLabelWithIcons(`$(chevron-down)`));
return null;
}

override render(container: HTMLElement): void {
super.render(container);
container.classList.add('chat-modelPicker-item');

// TODO@roblourens this should be a DropdownMenuActionViewItem, but we can't customize how it's rendered yet.
this._register(dom.addDisposableListener(container, dom.EventType.KEY_UP, e => {
const event = new StandardKeyboardEvent(e);
if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) {
this._openContextMenu();
}
}));
}

protected override updateLabel(): void {
if (this.label) {
const state = this.agentStateActions.find(action => action.checked)?.label ?? '';
dom.reset(this.label, dom.$('span.chat-model-label', undefined, state), ...renderLabelWithIcons(`$(chevron-down)`));
}
}

private _openContextMenu() {
if (this.action.enabled) {
this._contextMenuService.showContextMenu({
getAnchor: () => this.element!,
getActions: () => this.agentStateActions
});
}
}
}
Loading

0 comments on commit 1816f95

Please sign in to comment.