diff --git a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts index 2b71f1aef4ec1..8ec5aa9e93ce7 100644 --- a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts +++ b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts @@ -96,8 +96,8 @@ export interface ITabbedActionListShowOptions { readonly tabs: readonly ITabDescriptor[]; /** Initially active tab id. Must match an entry in {@link tabs}. */ readonly initialTab: string; - /** Computes the list items and per-tab options shown when the given tab is active. */ - createActionList(activeTab: string): ITabbedActionListBuildResult; + /** Computes a tab's list, or its resting contents when `forSizing` is true. */ + createActionList(activeTab: string, forSizing?: boolean): ITabbedActionListBuildResult; /** Item delegate (selection, hide, focus). */ readonly delegate: IActionListDelegate; /** Optional accessibility provider passed to the underlying list. */ @@ -158,7 +158,7 @@ export class TabbedActionListWidget extends Disposable { /** Boxes and labels from the last render, so the next one can animate from them. */ private _previousTabBoxes: Map | undefined; private _previousTabTexts: ReadonlyMap | undefined; - /** Initial list height from {@link ITabbedActionListShowOptions.sizingTab}. */ + /** List height from {@link ITabbedActionListShowOptions.sizingTab}, recomputed when items change. */ private _fixedListHeight: number | undefined; private _fixedPopupHeight: number | undefined; private _hasMeasuredSizingTab = false; @@ -304,11 +304,10 @@ export class TabbedActionListWidget extends Disposable { // Built before the active tab's list because a consumer may hold per-tab state // while building, and the active tab has to be the one that keeps it. - const needsSizing = !this._hasMeasuredSizingTab - && options.sizingTab !== undefined - && options.tabs.some(tab => tab.id === options.sizingTab); - const sizingBuild = needsSizing && options.sizingTab !== activeTab - ? options.createActionList(options.sizingTab!) + const sizingTab = options.tabs.find(tab => tab.id === options.sizingTab)?.id; + const needsSizing = !this._hasMeasuredSizingTab && sizingTab !== undefined; + const sizingBuild = needsSizing + ? options.createActionList(sizingTab, true) : undefined; const { items, listOptions } = options.createActionList(activeTab); @@ -329,10 +328,26 @@ export class TabbedActionListWidget extends Disposable { this._refreshActiveList = refreshOptions => { const hadFocus = dom.isAncestorOfActiveElement(widget); applyWidgetClassNames(); - list.updateItems(options.createActionList(activeTab).items, refreshOptions?.focusItemId, { + const sizing = sizingTab !== undefined + ? options.createActionList(sizingTab, true) + : undefined; + const refreshed = options.createActionList(activeTab); + const sizingHeight = sizing + ? list.computeHeightForItems(sizing.items, sizing.listOptions?.collapsedByDefault, sizing.listOptions) || undefined + : undefined; + const sizingChanged = sizingHeight !== this._fixedListHeight; + if (sizingChanged) { + this._fixedListHeight = sizingHeight; + this._fixedPopupHeight = undefined; + } + list.updateItems(refreshed.items, refreshOptions?.focusItemId, { preserveHover: refreshOptions?.preserveHover, animateItemMove: refreshOptions?.animateItemMove && !this._accessibilityService.isMotionReduced(), }); + if (sizingChanged) { + layout(); + this._contextViewService.layout(); + } if (hadFocus && !dom.isAncestorOfActiveElement(widget)) { if (emptyBody) { radio.focusActiveItem(); @@ -574,9 +589,8 @@ export class TabbedActionListWidget extends Disposable { } /** - * Rebuilds the active tab's items and the popup's class names in place, keeping its - * position and whatever currently has focus. Use when an action inside the popup - * changes what it shows but should not dismiss it; `preserveHover` retains its live detail panel. + * Rebuilds the active tab and remeasures its resting sizing contents without dismissing the popup. + * Focus is retained, and `preserveHover` keeps the live detail panel. */ refreshActiveList(options?: ITabbedActionListRefreshOptions): void { this._refreshActiveList?.(options); diff --git a/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts b/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts index 0817500dff78d..d1c4f9042c169 100644 --- a/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts +++ b/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts @@ -648,7 +648,7 @@ suite('TabbedActionListWidget', () => { // Opens on the short tab on purpose: the height has to come from the sizing tab // regardless of which tab the popup happens to open on. - const heightsAcrossTabs = (sizingTab: string | undefined) => { + const heightsAcrossTabs = (sizingTab: string | undefined, sizingItemIds = ['a', 'b', 'c', 'd', 'e', 'f']) => { const { widget } = createWidget(disposables); widget.show({ user: 'test', @@ -658,20 +658,23 @@ suite('TabbedActionListWidget', () => { sizingTab, createActionList: tab => ({ items: tab === 'Copilot' - ? ['a', 'b', 'c', 'd', 'e', 'f'].map(action) + ? sizingItemIds.map(action) : [action('only')], }), delegate: { onSelect: () => { }, onHide: () => { } }, }); const onShortTab = listHeight(); + widget.refreshActiveList(); + const afterRefresh = listHeight(); document.querySelectorAll('.tabbed-action-list-tabstrip .monaco-button')[0].click(); const onSizingTab = listHeight(); widget.hide(); - return { onShortTab, onSizingTab }; + return { onShortTab, afterRefresh, onSizingTab }; }; const unsized = heightsAcrossTabs(undefined); const sized = heightsAcrossTabs('Copilot'); + const empty = heightsAcrossTabs('Copilot', []); // Clamping depends on the room around the anchor, which differs between the two // renders here, so compare how each tab is sized rather than the pixels. @@ -679,8 +682,13 @@ suite('TabbedActionListWidget', () => { { resizesWithoutASizingTab: unsized.onShortTab < unsized.onSizingTab, shortTabTakesTheSizingTabsHeight: sized.onShortTab > unsized.onShortTab, + emptySizingTab: [empty.onShortTab, empty.afterRefresh], + }, + { + resizesWithoutASizingTab: true, + shortTabTakesTheSizingTabsHeight: true, + emptySizingTab: [unsized.onShortTab, unsized.onShortTab], }, - { resizesWithoutASizingTab: true, shortTabTakesTheSizingTabsHeight: true }, ); }); @@ -724,6 +732,80 @@ suite('TabbedActionListWidget', () => { ); }); + test('refreshing pins preserves collapsed sizing across tabs and search', async () => { + const { widget, contextView } = createWidget(disposables); + const anchor = document.createElement('div'); + anchor.style.cssText = 'position: fixed; top: 400px; width: 120px; height: 20px;'; + document.body.appendChild(anchor); + disposables.add({ dispose: () => anchor.remove() }); + const content = document.createElement('button'); + content.textContent = 'Unpin Model'; + let pinned = true; + let searching = false; + + widget.show({ + user: 'test', + anchor, + tabs: [{ id: 'Copilot' }, { id: 'Other' }], + initialTab: 'Copilot', + sizingTab: 'Copilot', + showCheckedItemHover: true, + createActionList: (tab, forSizing) => { + const model = { + ...action('model'), + item: { id: 'model', checked: true }, + section: pinned ? undefined : 'other', + hover: { content, expandable: true }, + }; + return { + items: searching && !forSizing ? ['one', 'two', 'three', 'four', 'five', 'six'].map(action) : tab === 'Copilot' ? [ + ...(pinned ? [{ kind: ActionListItemKind.Separator, label: 'Pinned' }, model] : []), + action('suggested'), + { ...action('other-models'), section: 'other', isSectionToggle: true }, + ...(pinned ? [] : [model]), + ...['one', 'two', 'three'].map(id => ({ ...action(id), section: 'other' })), + ] : [action('provider-model')], + listOptions: { collapsedByDefault: new Set(['other']), anchorPosition: AnchorPosition.ABOVE, persistentHover: true }, + }; + }, + delegate: { onSelect: () => { }, onHide: () => { } }, + }); + + const listHeight = () => contextView.getContextViewElement().querySelector('.actionList')!.offsetHeight; + const initialHeight = listHeight(); + const toggleOther = () => { + const row = Array.from(contextView.getContextViewElement().querySelectorAll('.monaco-list-row')) + .find(row => row.textContent === 'other-models'); + assert.ok(row); + row.click(); + }; + content.focus(); + pinned = false; + widget.refreshActiveList({ focusItemId: 'model', preserveHover: true }); + toggleOther(); + await Promise.resolve(); + const afterUnpin = listHeight(); + const tabHeights = []; + for (const index of [1, 0]) { + contextView.getContextViewElement().querySelectorAll('.tabbed-action-list-tabstrip .monaco-button')[index].click(); + tabHeights.push(listHeight()); + } + searching = true; + widget.refreshActiveList(); + const searchHeight = listHeight(); + pinned = true; + widget.refreshActiveList(); + const afterPin = listHeight(); + widget.hide(); + + assert.deepStrictEqual({ afterUnpin, tabHeights, searchHeight, afterPin }, { + afterUnpin: initialHeight / 2, + tabHeights: [initialHeight / 2, initialHeight / 2], + searchHeight: initialHeight / 2, + afterPin: initialHeight, + }); + }); + for (const initialFooterHeight of [20, 80]) { test(`resizing the footer preserves the fixed popup height when opened with a ${initialFooterHeight}px footer`, async () => { const { widget, contextView } = createWidget(disposables); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.ts index 1c7f76c873a79..48e34945328ae 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.ts @@ -23,7 +23,7 @@ import { withChatInputPickerMotion } from '../chatInputPickerActionItem.js'; import { IModelConfigurationAccess } from './modelPickerModelConfig.js'; import { ModelPickerAutoRow } from './modelPickerAutoRow.js'; import { IModelCardOptions, IPricingDisclosure, ModelCard } from './modelPickerCard.js'; -import { buildSpeedVariants, collapseSpeedVariants, IModelSpeedVariants } from './modelPickerVariants.js'; +import { getPreferredSpeedVariant, IModelSpeedVariants } from './modelPickerVariants.js'; import { getModelBadge } from './modelPickerBadges.js'; import { createModelAction, createUnavailableModelItem, getUnavailableReason } from './modelPickerItemPrimitives.js'; import { getModelPickerAccessibilityProvider } from './modelPickerItems.js'; @@ -98,7 +98,8 @@ export class TabbedModelPicker extends Disposable { private _anchor: HTMLElement | undefined; private _activeDestination: string | undefined; private _searchVisible = false; - private _speedVariants: ReadonlyMap = new Map(); + private readonly _speedVariants = new Map(); + private readonly _preferredSpeedVariants = new Map(); private _selectionVersion = 0; /** The model to fall back to when Auto is switched off. */ private _lastExplicitModelId: string | undefined; @@ -149,7 +150,7 @@ export class TabbedModelPicker extends Disposable { return; } - this._speedVariants = buildSpeedVariants(context.models); + this._speedVariants.clear(); this._cards.clearAndDisposeAll(); const destinations = this._buildDestinations(context); if (!destinations.length) { @@ -179,23 +180,24 @@ export class TabbedModelPicker extends Disposable { tabLabels: 'active', filterInTabBar: true, width: PICKER_WIDTH, - createActionList: activeTab => { + createActionList: (activeTab, forSizing) => { const current = this._context ?? context; const currentDestinations = this._buildDestinations(current); const destination = currentDestinations.find(candidate => candidate.id === activeTab) ?? currentDestinations[0]; const sections = this._buildSections(destination, current); // Search spans every destination at once, so each model names its provider. - const items = this._searchVisible - ? currentDestinations.flatMap(candidate => this._buildSearchItems(candidate, current)) + const searching = this._searchVisible && !forSizing; + const items = searching + ? currentDestinations.flatMap(candidate => this._buildSearchItems(candidate, candidate === destination ? sections : this._buildSections(candidate, current), current)) : this._buildItems(destination, sections, current); return { items, listOptions: withChatInputPickerMotion({ className: 'chat-model-picker-dropdown chat-model-picker-tabbed', persistentHover: true, - showFilter: this._searchVisible, + showFilter: searching, filterPlaceholder: localize('chat.modelPicker.search', "Search models"), - focusFilterOnOpen: this._searchVisible, + focusFilterOnOpen: searching, initialFilterValue, filterAsCombobox: true, onType: text => { @@ -239,11 +241,25 @@ export class TabbedModelPicker extends Disposable { }, accessibilityProvider: getModelPickerAccessibilityProvider(this._searchVisible), }); + if (this._context?.selectedModelId) { + this._rememberSpeedVariant(this._context.selectedModelId); + } } private _buildDestinations(context: ITabbedModelPickerContext): IModelPickerDestination[] { - const models = collapseSpeedVariants(context.models, this._speedVariants, context.selectedModelId); - return buildModelPickerDestinations(models, this._languageModelsService, context.providerPlaceholders); + return buildModelPickerDestinations(context.models, this._languageModelsService, context.providerPlaceholders); + } + + private _rememberSpeedVariant(modelIdentifier: string): void { + const pair = this._speedVariants.get(modelIdentifier); + if (pair) { + this._preferredSpeedVariants.set(pair.standard.identifier, modelIdentifier); + } + } + + private _pinnedVariantIds(modelIdentifier: string, context: ITabbedModelPickerContext): string[] { + const pair = this._speedVariants.get(modelIdentifier); + return context.pinnedModelIds.filter(id => id === modelIdentifier || id === pair?.standard.identifier || id === pair?.fast.identifier); } private _isAutoSelected(context: ITabbedModelPickerContext): boolean { @@ -257,12 +273,13 @@ export class TabbedModelPicker extends Disposable { private _buildSections(destination: IModelPickerDestination, context: ITabbedModelPickerContext): IModelPickerSections { const isBuiltIn = destination.id === MODEL_PICKER_BUILT_IN_DESTINATION; - return buildModelPickerSections({ + const sections = buildModelPickerSections({ models: destination.models, selectedModelId: context.selectedModelId, recentModelIds: context.recentModelIds, pinnedModelIds: context.pinnedModelIds, controlModels: context.controlModels, + preferredSpeedVariants: this._preferredSpeedVariants, // Only the built-in provider curates a shortlist. A provider the user added // gets a tab of its own, which is already the whole of what it offers. showSuggested: isBuiltIn, @@ -270,6 +287,10 @@ export class TabbedModelPicker extends Disposable { showUnavailable: isBuiltIn && context.unavailableContext.show, currentVSCodeVersion: context.unavailableContext.currentVSCodeVersion, }); + for (const [id, pair] of sections.speedVariants) { + this._speedVariants.set(id, pair); + } + return sections; } private _buildTabBarActions(context: ITabbedModelPickerContext): ITabBarAction[] { @@ -370,9 +391,8 @@ export class TabbedModelPicker extends Disposable { * Every model in one destination as flat rows, for searching. Sections would only * get in the way of a result list, but each row still names its provider. */ - private _buildSearchItems(destination: IModelPickerDestination, context: ITabbedModelPickerContext): IActionListItem[] { - return destination.models - .slice() + private _buildSearchItems(destination: IModelPickerDestination, sections: IModelPickerSections, context: ITabbedModelPickerContext): IActionListItem[] { + return [...sections.pinned, ...sections.suggested, ...sections.other] .sort((left, right) => left.metadata.name.localeCompare(right.metadata.name)) .map(model => this._createModelItem(model, context, undefined, getModelProviderLabel(model, this._languageModelsService))); } @@ -383,7 +403,13 @@ export class TabbedModelPicker extends Disposable { section?: string, providerLabel?: string, ): IActionListItem { - const { action, ariaDescription } = createModelAction(model, context.selectedModelId, context.onSelect, section, true); + const { action, ariaDescription } = createModelAction(model, context.selectedModelId, next => { + this._selectionVersion++; + const pair = this._speedVariants.get(next.identifier); + context.onSelect(pair + ? getPreferredSpeedVariant(pair, this._context?.selectedModelId, this._preferredSpeedVariants.get(pair.standard.identifier)) + : next); + }, section, true); const badge = getModelBadge(model, { configurationAccess: context.configurationAccess, providerLabel }); // While Auto is choosing, a model's settings do not apply, so the card that edits // them stays shut. The row is still selectable, which is what turns Auto off. @@ -394,12 +420,13 @@ export class TabbedModelPicker extends Disposable { configurationAccess: context.configurationAccess, isUBB: context.isUBB, openerService: this._openerService, - isPinned: context.pinnedModelIds.includes(model.identifier), + isPinned: this._pinnedVariantIds(model.identifier, context).length > 0, pricingDisclosure: this._pricingDisclosure, speedVariants: this._speedVariants.get(model.identifier), onWillSelect: () => { selectionVersion = ++this._selectionVersion; }, onSelect: next => { if (selectionVersion === this._selectionVersion && this._widget.isVisible && next.identifier !== (this._context ?? context).selectedModelId) { + this._rememberSpeedVariant(next.identifier); context.onSelect(next); this._context = { ...(this._context ?? context), selectedModelId: next.identifier }; this._lastExplicitModelId = next.identifier; @@ -450,12 +477,19 @@ export class TabbedModelPicker extends Disposable { if (!context?.onTogglePin) { return; } - context.onTogglePin(modelIdentifier, pinned); + const pinnedVariantIds = this._pinnedVariantIds(modelIdentifier, context); + if (pinned) { + context.onTogglePin(modelIdentifier, true); + } else { + for (const id of pinnedVariantIds) { + context.onTogglePin(id, false); + } + } this._context = { ...context, pinnedModelIds: pinned ? [...context.pinnedModelIds, modelIdentifier] - : context.pinnedModelIds.filter(id => id !== modelIdentifier), + : context.pinnedModelIds.filter(id => !pinnedVariantIds.includes(id)), }; this._widget.refreshActiveList({ focusItemId: modelIdentifier, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabs.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabs.ts index f333a75ac8667..a7723ed4c30dc 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabs.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabs.ts @@ -13,6 +13,7 @@ import { isDeprecated } from './modelPickerBadges.js'; import { isEarlyAccessModel, latestOfEachLine } from './modelPickerLineage.js'; import { getProviderIconForIdentity } from './modelProviderIcons.js'; import { isAutoModel } from './modelPickerPresentation.js'; +import { buildSpeedVariants, collapseSpeedVariants, IModelSpeedVariants } from './modelPickerVariants.js'; /** The built-in provider's models. */ export const MODEL_PICKER_BUILT_IN_DESTINATION = 'builtIn'; @@ -64,6 +65,8 @@ export interface IModelPickerSections { readonly other: readonly ILanguageModelChatMetadataAndIdentifier[]; /** Curated models the user cannot select yet, shown alongside the recommended ones. */ readonly unavailable: readonly IModelPickerUnavailableEntry[]; + /** Selectable speed pairs, shared with the model cards. */ + readonly speedVariants: ReadonlyMap; } /** @@ -179,11 +182,13 @@ export function buildModelPickerDestinations( } export interface IModelPickerSectionsOptions { + /** The full destination catalogue, before collapsing speed variants. */ readonly models: readonly ILanguageModelChatMetadataAndIdentifier[]; readonly selectedModelId: string | undefined; readonly recentModelIds: readonly string[]; readonly pinnedModelIds: readonly string[]; readonly controlModels: IStringDictionary; + readonly preferredSpeedVariants?: ReadonlyMap; /** Whether the destination has a curated shortlist to lead with. Only the built-in provider curates one. */ readonly showSuggested: boolean; /** Whether to name curated models the user cannot select yet. Off by default. */ @@ -201,12 +206,21 @@ export function buildModelPickerSections(options: IModelPickerSectionsOptions): // surfaced only as the update it needs. const unavailable = buildUnavailableEntries(options); const gated = new Set(unavailable.filter(entry => entry.needsUpdate).map(entry => entry.id)); - const selectable = gated.size === 0 + const available = gated.size === 0 ? options.models : options.models.filter(model => !gated.has(model.metadata.id) && !gated.has(model.identifier)); + const speedVariants = buildSpeedVariants(available); + const selectable = collapseSpeedVariants(available, speedVariants, options.selectedModelId, options.preferredSpeedVariants); - const byIdentifier = new Map(selectable.map(model => [model.identifier, model])); - const byMetadataId = new Map(selectable.map(model => [model.metadata.id, model])); + const byIdentifier = new Map(); + const byMetadataId = new Map(); + for (const model of selectable) { + const pair = speedVariants.get(model.identifier); + for (const variant of pair ? [pair.standard, pair.fast] : [model]) { + byIdentifier.set(variant.identifier, model); + byMetadataId.set(variant.metadata.id, model); + } + } const placed = new Set(); const take = (id: string | undefined): ILanguageModelChatMetadataAndIdentifier | undefined => { const model = id ? byIdentifier.get(id) ?? byMetadataId.get(id) : undefined; @@ -221,7 +235,7 @@ export function buildModelPickerSections(options: IModelPickerSectionsOptions): const suggested: ILanguageModelChatMetadataAndIdentifier[] = []; if (options.showSuggested) { - for (const model of options.models) { + for (const model of selectable) { if (!model.metadata.promo) { continue; } @@ -266,6 +280,7 @@ export function buildModelPickerSections(options: IModelPickerSectionsOptions): suggested: suggested.sort(byPromoThenName), other: rest, unavailable, + speedVariants, }; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerVariants.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerVariants.ts index 1d44de78400f3..cefeedfa72cd0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerVariants.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerVariants.ts @@ -49,15 +49,24 @@ export function buildSpeedVariants( return pairs; } -/** - * Drops the twin that is not in use, so a pair takes one row rather than two. The - * selected twin is the one kept, since a picker that hides the current choice cannot - * be read as showing it. - */ +/** Resolves a pair's selected speed, falling back to its remembered choice and then Standard. */ +export function getPreferredSpeedVariant( + pair: IModelSpeedVariants, + selectedModelId: string | undefined, + preferredModelId: string | undefined, +): ILanguageModelChatMetadataAndIdentifier { + const preferred = selectedModelId === pair.standard.identifier || selectedModelId === pair.fast.identifier + ? selectedModelId + : preferredModelId; + return preferred === pair.fast.identifier ? pair.fast : pair.standard; +} + +/** Keeps one row per pair, preserving the current selection or the pair's remembered speed. */ export function collapseSpeedVariants( models: readonly ILanguageModelChatMetadataAndIdentifier[], variants: ReadonlyMap, selectedModelId: string | undefined, + preferredVariants?: ReadonlyMap, ): ILanguageModelChatMetadataAndIdentifier[] { if (!variants.size) { return [...models]; @@ -67,7 +76,7 @@ export function collapseSpeedVariants( if (!pair) { return true; } - const inUse = selectedModelId === pair.fast.identifier ? pair.fast : pair.standard; + const inUse = getPreferredSpeedVariant(pair, selectedModelId, preferredVariants?.get(pair.standard.identifier)); return model.identifier === inUse.identifier; }); } diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabs.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabs.test.ts index 1e2b343b962ff..5cd15d5575085 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabs.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabs.test.ts @@ -508,6 +508,86 @@ suite('Model picker destinations', () => { ); }); + test('a pair remembers its speed without overriding an explicit selection', () => { + const standard = createModel('example-2.5', 'Example 2.5'); + const fast = createModel('example-2.5-fast', 'Example 2.5 (fast mode)'); + const models = [gpt, standard, fast]; + const variants = buildSpeedVariants(models); + const preferred = new Map([[standard.identifier, fast.identifier]]); + const ids = (selected: string | undefined) => + collapseSpeedVariants(models, variants, selected, preferred).map(model => model.identifier); + + assert.deepStrictEqual({ + unselected: ids(undefined), + otherSelected: ids(gpt.identifier), + standardSelected: ids(standard.identifier), + fastSelected: ids(fast.identifier), + }, { + unselected: [gpt.identifier, fast.identifier], + otherSelected: [gpt.identifier, fast.identifier], + standardSelected: [gpt.identifier, standard.identifier], + fastSelected: [gpt.identifier, fast.identifier], + }); + }); + + test('pins on either speed produce one pinned row for the selected variant', () => { + const standard = createModel('example-2.5', 'Example 2.5'); + const fast = createModel('example-2.5-fast', 'Example 2.5 (fast mode)'); + const sections = buildModelPickerSections({ + models: [gpt, standard, fast], + selectedModelId: fast.identifier, + recentModelIds: [], + pinnedModelIds: [standard.identifier, fast.identifier], + controlModels: {}, + showSuggested: true, + }); + + assert.deepStrictEqual({ + pinned: sections.pinned.map(model => model.identifier), + suggested: sections.suggested.map(model => model.identifier), + other: sections.other.map(model => model.identifier), + }, { + pinned: [fast.identifier], + suggested: [gpt.identifier], + other: [], + }); + }); + + test('collapsing speed variants preserves real availability and update restrictions', () => { + const standard = createModel('example-2.5', 'Example 2.5'); + const fast = createModel('example-2.5-fast', 'Example 2.5 (fast mode)'); + const inspect = (models: ILanguageModelChatMetadataAndIdentifier[], minVSCodeVersion?: string) => { + const sections = buildModelPickerSections({ + models, + selectedModelId: fast.identifier, + recentModelIds: [], + pinnedModelIds: [], + controlModels: { + [standard.metadata.id]: { label: standard.metadata.name, featured: true, exists: false }, + [fast.metadata.id]: { label: fast.metadata.name, featured: true, exists: false, minVSCodeVersion }, + }, + showSuggested: true, + showUnavailable: true, + currentVSCodeVersion: '1.100.0', + }); + return { + selectable: [...sections.pinned, ...sections.suggested, ...sections.other].map(model => model.identifier), + unavailable: sections.unavailable.map(entry => ({ id: entry.id, needsUpdate: entry.needsUpdate })), + pairedVariants: sections.speedVariants.size, + }; + }; + + assert.deepStrictEqual({ + bothAvailable: inspect([standard, fast]), + standardUnavailable: inspect([fast]), + fastNeedsUpdate: inspect([standard, fast], '99.0.0'), + }, { + bothAvailable: { selectable: [fast.identifier], unavailable: [], pairedVariants: 2 }, + standardUnavailable: { selectable: [fast.identifier], unavailable: [{ id: standard.metadata.id, needsUpdate: false }], pairedVariants: 0 }, + fastNeedsUpdate: { selectable: [standard.identifier], unavailable: [{ id: fast.metadata.id, needsUpdate: true }], pairedVariants: 0 }, + }); + }); + test('badges rank a retiring model over an offer over the settings a model was tuned to', () => { const retiring = { ...gpt, metadata: { ...gpt.metadata, warningText: { model_pending_deprecation: 'Retiring soon.' } } }; const promo = { ...claude, metadata: { ...claude.metadata, promo: { id: 'p', discountPercent: 25, message: 'Save now.' } } }; diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTelemetry.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTelemetry.test.ts index e282f5ee5b8cd..64dd42ba2ce76 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTelemetry.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTelemetry.test.ts @@ -65,6 +65,7 @@ function createModel(id: string, metadata: Partial = suite('ModelPickerTelemetry', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); const model = createModel('test-model'); + const fastModel = createModel('test-model-fast'); const otherModel = createModel('other-model'); const thirdPartyModel = createModel('private-model', { vendor: 'third-party' }); const autoModel = createModel('auto', { @@ -95,7 +96,7 @@ suite('ModelPickerTelemetry', () => { }, getModelConfigurationActions: () => [], }; - const models = [autoModel, model, otherModel, thirdPartyModel]; + const models = [autoModel, model, fastModel, otherModel, thirdPartyModel]; const container = dom.append(mainWindow.document.body, dom.$('.monaco-reduce-motion')); store.add(toDisposable(() => container.remove())); const footer = dom.append(container, dom.$('div')); @@ -319,6 +320,54 @@ suite('ModelPickerTelemetry', () => { }); } + test('accepting a row during a speed change does not report a revert to Standard', () => { + const result = createPicker(true); + option(result.showCard(model.metadata.name), 'Fast').click(); + result.selectItem(model.metadata.name); + + assert.deepStrictEqual({ selected: result.picker.selectedModel?.identifier, events: result.events }, { + selected: fastModel.identifier, + events: [modelChange(model, fastModel), modelChange(fastModel, fastModel)], + }); + }); + + for (const initiallyFast of [false, true]) { + test(`selecting a remembered speed through search reports Fast (initially fast: ${initiallyFast})`, async () => { + const result = createPicker(true, initiallyFast ? fastModel : model); + if (!initiallyFast) { + option(result.showCard(model.metadata.name), 'Fast').click(); + await timeout(0); + } + result.selectItem(otherModel.metadata.name); + result.picker.show(result.container); + result.listOptions.onType?.('test'); + result.selectItem(fastModel.metadata.name); + + assert.deepStrictEqual({ searching: result.listOptions.showFilter, events: result.events }, { + searching: true, + events: [ + ...(initiallyFast ? [] : [modelChange(model, fastModel)]), + modelChange(fastModel, otherModel), + modelChange(otherModel, fastModel), + ], + }); + }); + } + + test('pinning a model then changing speed preserves its pin without a configuration event', async () => { + const result = createPicker(true); + const card = result.showCard(model.metadata.name); + card.querySelector('[aria-label="Pin Model"]')!.click(); + option(card, 'Fast').click(); + await timeout(0); + result.showCard(fastModel.metadata.name).querySelector('[aria-label="Unpin Model"]')!.click(); + + assert.deepStrictEqual({ pinned: result.pinnedModelIds, events: result.events }, { + pinned: [], + events: [modelChange(model, fastModel)], + }); + }); + test('tabbed Auto toggles report the current previous model while the popup stays open', () => { const result = createPicker(true); const toggle = result.container.querySelector('[role="switch"]');