polishing model picker and enable it in chat (#296356)

This commit is contained in:
Sandeep Somavarapu
2026-02-19 21:35:41 +01:00
committed by GitHub
parent 8679e41a8f
commit e4e882fb4a
11 changed files with 139 additions and 56 deletions
@@ -3,8 +3,10 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as dom from '../../../base/browser/dom.js';
import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js';
import { renderMarkdown } from '../../../base/browser/markdownRenderer.js';
import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js';
import { getAnchorRect, IAnchor } from '../../../base/browser/ui/contextview/contextview.js';
import { KeybindingLabel } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js';
import { IListEvent, IListMouseEvent, IListRenderer, IListVirtualDelegate } from '../../../base/browser/ui/list/list.js';
import { IListAccessibilityProvider, List } from '../../../base/browser/ui/list/listWidget.js';
@@ -13,6 +15,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../base/common
import { Codicon } from '../../../base/common/codicons.js';
import { IMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js';
import { ResolvedKeybinding } from '../../../base/common/keybindings.js';
import { AnchorPosition } from '../../../base/common/layout.js';
import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js';
import { OS } from '../../../base/common/platform.js';
import { ThemeIcon } from '../../../base/common/themables.js';
@@ -333,11 +336,6 @@ export interface IActionListOptions {
*/
readonly showFilter?: boolean;
/**
* Placement of the filter input. Defaults to 'top'.
*/
readonly filterPlacement?: 'top' | 'bottom';
/**
* Section IDs that should be collapsed by default.
*/
@@ -373,6 +371,18 @@ export class ActionList<T> extends Disposable {
private _lastMinWidth = 0;
private _cachedMaxWidth: number | undefined;
private _hasLaidOut = false;
private _showAbove: boolean | undefined;
/**
* Returns the resolved anchor position after the first layout.
* Used by the context view delegate to lock the dropdown direction.
*/
get anchorPosition(): AnchorPosition | undefined {
if (this._showAbove === undefined) {
return undefined;
}
return this._showAbove ? AnchorPosition.ABOVE : AnchorPosition.BELOW;
}
constructor(
user: string,
@@ -381,6 +391,7 @@ export class ActionList<T> extends Disposable {
private readonly _delegate: IActionListDelegate<T>,
accessibilityProvider: Partial<IListAccessibilityProvider<IActionListItem<T>>> | undefined,
private readonly _options: IActionListOptions | undefined,
private readonly _anchor: HTMLElement | StandardMouseEvent | IAnchor,
@IContextViewService private readonly _contextViewService: IContextViewService,
@IKeybindingService private readonly _keybindingService: IKeybindingService,
@ILayoutService private readonly _layoutService: ILayoutService,
@@ -650,8 +661,13 @@ export class ActionList<T> extends Disposable {
return this._filterContainer;
}
/**
* Returns the resolved filter placement based on the dropdown direction.
* When shown above the anchor, filter is at the bottom (closest to anchor);
* when shown below, filter is at the top.
*/
get filterPlacement(): 'top' | 'bottom' {
return this._options?.filterPlacement ?? 'top';
return this._showAbove ? 'bottom' : 'top';
}
get filterInput(): HTMLInputElement | undefined {
@@ -692,6 +708,13 @@ export class ActionList<T> extends Disposable {
this._contextViewService.hideContextView();
}
private hasDynamicHeight(): boolean {
if (this._options?.showFilter) {
return true;
}
return this._allMenuItems.some(item => item.isSectionToggle);
}
private computeHeight(): number {
// Compute height based on currently visible items in the list
const visibleCount = this._list.length;
@@ -714,9 +737,36 @@ export class ActionList<T> extends Disposable {
const filterHeight = this._filterContainer ? 36 : 0;
const padding = 10;
const targetWindow = dom.getWindow(this.domNode);
const windowHeight = this._layoutService.getContainer(targetWindow).clientHeight;
const widgetTop = this.domNode.getBoundingClientRect().top;
const availableHeight = widgetTop > 0 ? windowHeight - widgetTop - padding : windowHeight * 0.7;
let availableHeight;
if (this.hasDynamicHeight()) {
const viewportHeight = targetWindow.innerHeight;
const anchorRect = getAnchorRect(this._anchor);
const anchorTopInViewport = anchorRect.top - targetWindow.pageYOffset;
const spaceBelow = viewportHeight - anchorTopInViewport - anchorRect.height - padding;
const spaceAbove = anchorTopInViewport - padding;
// Lock the direction on first layout based on whether the full
// unconstrained list fits below. Once decided, the dropdown stays
// in the same position even when the visible item count changes.
if (this._showAbove === undefined) {
let fullHeight = filterHeight;
for (const item of this._allMenuItems) {
switch (item.kind) {
case ActionListItemKind.Header: fullHeight += this._headerLineHeight; break;
case ActionListItemKind.Separator: fullHeight += this._separatorLineHeight; break;
default: fullHeight += this._actionLineHeight; break;
}
}
this._showAbove = fullHeight > spaceBelow && spaceAbove > spaceBelow;
}
availableHeight = this._showAbove ? spaceAbove : spaceBelow;
} else {
const windowHeight = this._layoutService.getContainer(targetWindow).clientHeight;
const widgetTop = this.domNode.getBoundingClientRect().top;
availableHeight = widgetTop > 0 ? windowHeight - widgetTop - padding : windowHeight * 0.7;
}
const maxHeight = Math.max(availableHeight, this._actionLineHeight * 3 + filterHeight);
const height = Math.min(listHeight + filterHeight, maxHeight);
return height - filterHeight;
@@ -798,6 +848,21 @@ export class ActionList<T> extends Disposable {
this._cachedMaxWidth = this.computeMaxWidth(minWidth);
this._list.layout(listHeight, this._cachedMaxWidth);
this.domNode.style.height = `${listHeight}px`;
// Place filter container on the correct side based on dropdown direction.
// When shown above, filter goes below the list (closest to anchor).
// When shown below, filter goes above the list (closest to anchor).
if (this._filterContainer && this._filterContainer.parentElement) {
const parent = this._filterContainer.parentElement;
if (this._showAbove) {
// Move filter after the list
parent.appendChild(this._filterContainer);
} else {
// Move filter before the list
parent.insertBefore(this._filterContainer, this.domNode);
}
}
return this._cachedMaxWidth;
}
@@ -64,7 +64,7 @@ class ActionWidgetService extends Disposable implements IActionWidgetService {
show<T>(user: string, supportsPreview: boolean, items: readonly IActionListItem<T>[], delegate: IActionListDelegate<T>, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[], accessibilityProvider?: Partial<IListAccessibilityProvider<IActionListItem<T>>>, listOptions?: IActionListOptions): void {
const visibleContext = ActionWidgetContextKeys.Visible.bindTo(this._contextKeyService);
const list = this._instantiationService.createInstance(ActionList, user, supportsPreview, items, delegate, accessibilityProvider, listOptions);
const list = this._instantiationService.createInstance(ActionList, user, supportsPreview, items, delegate, accessibilityProvider, listOptions, anchor);
this._contextViewService.showContextView({
getAnchor: () => anchor,
render: (container: HTMLElement) => {
@@ -75,6 +75,7 @@ class ActionWidgetService extends Disposable implements IActionWidgetService {
visibleContext.reset();
this._onWidgetClosed(didCancel);
},
get anchorPosition() { return list.anchorPosition; },
}, container, false);
}
@@ -118,15 +119,10 @@ class ActionWidgetService extends Disposable implements IActionWidgetService {
this._list.value = list;
if (this._list.value) {
// Filter input at the top
if (this._list.value.filterContainer && this._list.value.filterPlacement === 'top') {
if (this._list.value.filterContainer) {
widget.appendChild(this._list.value.filterContainer);
}
widget.appendChild(this._list.value.domNode);
// Filter input at the bottom
if (this._list.value.filterContainer && this._list.value.filterPlacement === 'bottom') {
widget.appendChild(this._list.value.filterContainer);
}
} else {
throw new Error('List has no value');
}
@@ -8,6 +8,7 @@ import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js';
import { AnchorAlignment, AnchorAxisAlignment, IAnchor, IContextViewProvider } from '../../../base/browser/ui/contextview/contextview.js';
import { IAction } from '../../../base/common/actions.js';
import { Event } from '../../../base/common/event.js';
import { AnchorPosition } from '../../../base/common/layout.js';
import { IDisposable } from '../../../base/common/lifecycle.js';
import { IMenuActionOptions, MenuId } from '../../actions/common/actions.js';
import { IContextKeyService } from '../../contextkey/common/contextkey.js';
@@ -43,6 +44,7 @@ export interface IContextViewDelegate {
focus?(): void;
anchorAlignment?: AnchorAlignment;
anchorAxisAlignment?: AnchorAxisAlignment;
anchorPosition?: AnchorPosition;
// context views with higher layers are rendered over contet views with lower layers
layer?: number; // Default: 0
@@ -467,7 +467,6 @@ class NewChatWidget extends Disposable {
currentModel: this._currentLanguageModel,
setModel: (model: ILanguageModelChatMetadataAndIdentifier) => {
this._currentLanguageModel.set(model, undefined);
this.languageModelsService.addToRecentlyUsedList(model);
},
getModels: () => this._getAvailableModels(),
canManageModels: () => true,
@@ -12,6 +12,7 @@ import { Action2, registerAction2 } from '../../../../../platform/actions/common
import { IEditorService } from '../../../../services/editor/common/editorService.js';
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
import { IChatService } from '../../common/chatService/chatService.js';
import { ILanguageModelsService } from '../../common/languageModels.js';
import { IChatWidgetService } from '../chat.js';
function uriReplacer(_key: string, value: unknown): unknown {
@@ -31,6 +32,7 @@ export function registerChatDeveloperActions() {
registerAction2(LogChatInputHistoryAction);
registerAction2(LogChatIndexAction);
registerAction2(InspectChatModelAction);
registerAction2(ClearRecentlyUsedLanguageModelsAction);
}
class LogChatInputHistoryAction extends Action2 {
@@ -126,3 +128,21 @@ class InspectChatModelAction extends Action2 {
});
}
}
class ClearRecentlyUsedLanguageModelsAction extends Action2 {
static readonly ID = 'workbench.action.chat.clearRecentlyUsedLanguageModels';
constructor() {
super({
id: ClearRecentlyUsedLanguageModelsAction.ID,
title: localize2('workbench.action.chat.clearRecentlyUsedLanguageModels.label', "Clear Recently Used Language Models"),
category: Categories.Developer,
f1: true,
precondition: ChatContextKeys.enabled
});
}
override run(accessor: ServicesAccessor): void {
accessor.get(ILanguageModelsService).clearRecentlyUsedList();
}
}
@@ -120,12 +120,13 @@ import { ChatInputPartWidgetController } from './chatInputPartWidgets.js';
import { IChatInputPickerOptions } from './chatInputPickerActionItem.js';
import { ChatSelectedTools } from './chatSelectedTools.js';
import { DelegationSessionPickerActionItem } from './delegationSessionPickerActionItem.js';
import { IModelPickerDelegate, ModelPickerActionItem } from './modelPickerActionItem.js';
import { IModelPickerDelegate } from './modelPickerActionItem.js';
import { IModePickerDelegate, ModePickerActionItem } from './modePickerActionItem.js';
import { SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js';
import { WorkspacePickerActionItem } from './workspacePickerActionItem.js';
import { ChatContextUsageWidget } from '../../widgetHosts/viewPane/chatContextUsageWidget.js';
import { Target } from '../../../common/promptSyntax/service/promptsService.js';
import { EnhancedModelPickerActionItem } from './modelPickerActionItem2.js';
const $ = dom.$;
@@ -357,7 +358,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
private agentSessionTypeKey: IContextKey<string>;
private chatSessionHasCustomAgentTarget: IContextKey<boolean>;
private chatSessionHasTargetedModels: IContextKey<boolean>;
private modelWidget: ModelPickerActionItem | undefined;
private modelWidget: EnhancedModelPickerActionItem | undefined;
private modeWidget: ModePickerActionItem | undefined;
private sessionTargetWidget: SessionTypePickerActionItem | undefined;
private delegationWidget: DelegationSessionPickerActionItem | undefined;
@@ -998,8 +999,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
public setCurrentLanguageModel(model: ILanguageModelChatMetadataAndIdentifier) {
this._currentLanguageModel.set(model, undefined);
this.languageModelsService.addToRecentlyUsedList(model);
if (this.cachedWidth) {
// For quick chat and editor chat, relayout because the input may need to shrink to accomodate the model name
this.layout(this.cachedWidth);
@@ -2185,7 +2184,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
getModels: () => this.getModels(),
canManageModels: () => !this.getCurrentSessionType()
};
return this.modelWidget = this.instantiationService.createInstance(ModelPickerActionItem, action, undefined, itemDelegate, pickerOptions);
return this.modelWidget = this.instantiationService.createInstance(EnhancedModelPickerActionItem, action, itemDelegate, pickerOptions);
} else if (action.id === OpenModePickerAction.ID && action instanceof MenuItemAction) {
const delegate: IModePickerDelegate = {
currentMode: this._currentModeObservable,
@@ -232,18 +232,21 @@ export function buildModelPickerItems(
}
}
// Render promoted section: available sorted alphabetically, then unavailable
// Render promoted section: sorted alphabetically by name
if (promotedItems.length > 0) {
const available = promotedItems.filter((i): i is PromotedItem & { kind: 'available' } => i.kind === 'available');
const unavailable = promotedItems.filter((i): i is PromotedItem & { kind: 'unavailable' } => i.kind === 'unavailable');
available.sort((a, b) => a.model.metadata.name.localeCompare(b.model.metadata.name));
promotedItems.sort((a, b) => {
const aName = a.kind === 'available' ? a.model.metadata.name : a.entry.label;
const bName = b.kind === 'available' ? b.model.metadata.name : b.entry.label;
return aName.localeCompare(bName);
});
items.push({ kind: ActionListItemKind.Separator });
for (const { model } of available) {
items.push(createModelItem(createModelAction(model, selectedModelId, onSelect), model));
}
for (const { entry, reason } of unavailable) {
items.push(createUnavailableModelItem(entry, reason, upgradePlanUrl, updateStateType));
for (const item of promotedItems) {
if (item.kind === 'available') {
items.push(createModelItem(createModelAction(item.model, selectedModelId, onSelect), item.model));
} else {
items.push(createUnavailableModelItem(item.entry, item.reason, upgradePlanUrl, updateStateType));
}
}
}
@@ -324,7 +327,7 @@ function createUnavailableModelItem(
if (reason === 'upgrade') {
description = upgradePlanUrl
? new MarkdownString(localize('chat.modelPicker.upgradeLink', "[Upgrade]({0})", upgradePlanUrl), { isTrusted: true })
? new MarkdownString(localize('chat.modelPicker.upgradeLink', "[Upgrade your plan]({0})", upgradePlanUrl), { isTrusted: true })
: localize('chat.modelPicker.upgrade', "Upgrade");
} else {
icon = Codicon.warning;
@@ -619,23 +622,6 @@ function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier): M
markdown.appendText(`\n`);
}
if (model.metadata.capabilities) {
markdown.appendMarkdown(`${localize('models.capabilities', 'Capabilities')}: `);
if (model.metadata.capabilities?.toolCalling) {
markdown.appendMarkdown(`&nbsp;<span style="background-color:#8080802B;">&nbsp;_${localize('models.toolCalling', 'Tools')}_&nbsp;</span>`);
}
if (model.metadata.capabilities?.vision) {
markdown.appendMarkdown(`&nbsp;<span style="background-color:#8080802B;">&nbsp;_${localize('models.vision', 'Vision')}_&nbsp;</span>`);
}
if (model.metadata.capabilities?.agentMode) {
markdown.appendMarkdown(`&nbsp;<span style="background-color:#8080802B;">&nbsp;_${localize('models.agentMode', 'Agent Mode')}_&nbsp;</span>`);
}
for (const editTool of model.metadata.capabilities.editTools ?? []) {
markdown.appendMarkdown(`&nbsp;<span style="background-color:#8080802B;">&nbsp;_${editTool}_&nbsp;</span>`);
}
markdown.appendText(`\n`);
}
return markdown;
}
@@ -48,7 +48,7 @@ import { IChatTransferService } from '../model/chatTransferService.js';
import { LocalChatSessionUri } from '../model/chatUri.js';
import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js';
import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../constants.js';
import { ChatMessageRole, IChatMessage } from '../languageModels.js';
import { ChatMessageRole, IChatMessage, ILanguageModelsService } from '../languageModels.js';
import { ILanguageModelToolsService } from '../tools/languageModelToolsService.js';
import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js';
import { IPromptsService } from '../promptSyntax/service/promptsService.js';
@@ -159,6 +159,7 @@ export class ChatService extends Disposable implements IChatService {
@IMcpService private readonly mcpService: IMcpService,
@IPromptsService private readonly promptsService: IPromptsService,
@IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService,
@ILanguageModelsService private readonly languageModelsService: ILanguageModelsService,
) {
super();
@@ -1194,6 +1195,9 @@ export class ChatService extends Disposable implements IChatService {
this.processNextPendingRequest(model);
}
});
if (options?.userSelectedModelId) {
this.languageModelsService.addToRecentlyUsedList(options.userSelectedModelId);
}
this._onDidSubmitRequest.fire({ chatSessionResource: model.sessionResource });
return {
responseCreatedPromise: responseCreated.p,
@@ -376,7 +376,12 @@ export interface ILanguageModelsService {
/**
* Records that a model was used, updating the recently used list.
*/
addToRecentlyUsedList(model: ILanguageModelChatMetadataAndIdentifier): void;
addToRecentlyUsedList(modelIdentifier: string): void;
/**
* Clears the recently used model list.
*/
clearRecentlyUsedList(): void;
/**
* Returns the models from the control manifest,
@@ -1378,22 +1383,22 @@ export class LanguageModelsService implements ILanguageModelsService {
getRecentlyUsedModelIds(): string[] {
// Filter to only include models that still exist in the cache
return this._recentlyUsedModelIds
.filter(id => this._modelCache.has(id) && id !== 'auto')
.filter(id => this._modelCache.has(id) && id !== 'copilot/auto')
.slice(0, 5);
}
addToRecentlyUsedList(model: ILanguageModelChatMetadataAndIdentifier): void {
if (model.metadata.id === 'auto' && this._vendors.get(model.metadata.vendor)?.isDefault) {
addToRecentlyUsedList(modelIdentifier: string): void {
if (modelIdentifier === 'copilot/auto') {
return;
}
// Remove if already present (to move to front)
const index = this._recentlyUsedModelIds.indexOf(model.identifier);
const index = this._recentlyUsedModelIds.indexOf(modelIdentifier);
if (index !== -1) {
this._recentlyUsedModelIds.splice(index, 1);
}
// Add to front
this._recentlyUsedModelIds.unshift(model.identifier);
this._recentlyUsedModelIds.unshift(modelIdentifier);
// Cap at a reasonable max to avoid unbounded growth
if (this._recentlyUsedModelIds.length > 20) {
this._recentlyUsedModelIds.length = 20;
@@ -1401,6 +1406,11 @@ export class LanguageModelsService implements ILanguageModelsService {
this._saveRecentlyUsedModels();
}
clearRecentlyUsedList(): void {
this._recentlyUsedModelIds = [];
this._saveRecentlyUsedModels();
}
//#endregion
//#region Models control manifest
@@ -140,6 +140,7 @@ class MockLanguageModelsService implements ILanguageModelsService {
getRecentlyUsedModelIds(): string[] { return []; }
addToRecentlyUsedList(): void { }
clearRecentlyUsedList(): void { }
getModelsControlManifest(): IModelsControlManifest { return { free: {}, paid: {} }; }
restrictedChatParticipants = observableValue('restrictedChatParticipants', Object.create(null));
}
@@ -93,6 +93,7 @@ export class NullLanguageModelsService implements ILanguageModelsService {
}
addToRecentlyUsedList(): void { }
clearRecentlyUsedList(): void { }
getModelsControlManifest(): IModelsControlManifest {
return { free: {}, paid: {} };