From d77fa1711fcb0501c1049795fd8fbaddfb3a57ea Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:28:40 -0700 Subject: [PATCH] chat input: refactor and responsiveness (#332669) * chat input: refactor and responsiveness * address comments * address comp --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- src/vs/base/browser/ui/toolbar/toolbar.ts | 93 +++-- .../test/browser/ui/toolbar/toolbar.test.ts | 151 +++++++- .../browser/parts/mobile/mobileChatShell.css | 3 +- .../automations/browser/automationDialog.ts | 35 ++ .../contrib/chat/browser/branchPicker.ts | 12 +- .../contrib/chat/browser/media/chatInput.css | 47 ++- .../contrib/chat/browser/media/chatView.css | 4 + .../contrib/chat/browser/media/chatWidget.css | 102 +++--- .../browser/mobile/mobileSessionTypePicker.ts | 9 +- .../contrib/chat/browser/newChatInput.ts | 79 ++++- .../contrib/chat/browser/sessionTypePicker.ts | 16 +- .../chat/test/browser/chatView.test.ts | 67 ++++ .../test/browser/sessionTypePicker.test.ts | 4 - .../agentHostPermissionPickerActionItem.ts | 17 +- .../browser/agentHostSessionConfigPicker.ts | 32 +- .../agentHostSessionConfigPicker.test.ts | 34 +- .../agentHost/agentHostChatInputPicker.ts | 6 + .../agentHostFolderPickerActionItem.ts | 5 +- .../agentHost/agentHostGenericConfigChips.ts | 8 + .../media/agentHostChatInputPicker.css | 27 +- .../chatSessionPickerActionItem.ts | 9 +- .../browser/widget/input/chatInputPart.ts | 335 ++++++++++++++---- .../widget/input/chatInputPickerActionItem.ts | 9 + .../input/chatInputPickerResponsiveLayout.ts | 229 ++++++++++++ .../widget/input/modePickerActionItem.ts | 5 +- .../input/modelPicker/media/modelPicker.css | 13 +- .../modelPicker/modelPickerActionItem.ts | 4 +- .../input/permissionPickerActionItem.ts | 6 +- .../input/sessionTargetPickerActionItem.ts | 9 +- .../widget/input/workspacePickerActionItem.ts | 17 +- .../chat/browser/widget/media/chat.css | 59 ++- .../chatInputPickerResponsiveLayout.test.ts | 327 +++++++++++++++++ .../blocks-ci-screenshots.md | 4 +- 33 files changed, 1531 insertions(+), 246 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts diff --git a/src/vs/base/browser/ui/toolbar/toolbar.ts b/src/vs/base/browser/ui/toolbar/toolbar.ts index 63c37073b00..8d5e2ff6ad6 100644 --- a/src/vs/base/browser/ui/toolbar/toolbar.ts +++ b/src/vs/base/browser/ui/toolbar/toolbar.ts @@ -5,7 +5,7 @@ import { IContextMenuProvider } from '../../contextmenu.js'; import * as DOM from '../../dom.js'; -import { ActionBar, ActionsOrientation, IActionViewItemProvider } from '../actionbar/actionbar.js'; +import { ActionBar, ActionsOrientation, IActionViewItem, IActionViewItemProvider } from '../actionbar/actionbar.js'; import { AnchorAlignment, IContextViewCloseAnimation } from '../contextview/contextview.js'; import { DropdownMenuActionViewItem } from '../dropdown/dropdownActionViewItem.js'; import { Action, IAction, IActionRunner, Separator, SubmenuAction } from '../../../common/actions.js'; @@ -30,6 +30,8 @@ export interface IToolBarResponsiveBehaviorOptions { readonly minItems?: number; readonly actionMinWidth?: number; readonly getActionMinWidth?: (action: IAction) => number | undefined; + readonly allowOverflow?: boolean | (() => boolean); + readonly getOverflowAction?: (action: IAction, getAnchor: () => HTMLElement | undefined) => IAction; readonly observedElement?: HTMLElement; readonly getAvailableWidth?: () => number; } @@ -73,6 +75,8 @@ export interface IToolBarOptions { * - `minItems`: The minimum number of items that should always be visible. * - `actionMinWidth`: The minimum width of each action item. Defaults to `ACTION_MIN_WIDTH` (24px). * - `getActionMinWidth`: Optional per-action minimum width override in pixels. + * - `allowOverflow`: Whether actions may move into the overflow menu, or a callback that decides from current presentation state. + * - `getOverflowAction`: Replaces an action only while it is rendered in the overflow menu. */ responsiveBehavior?: IToolBarResponsiveBehaviorOptions; } @@ -234,6 +238,15 @@ export class ToolBar extends Disposable { return this.actionBar.getWidth(index); } + getItemElement(index: number): HTMLElement | undefined { + const element = this.actionBar.getContainer().firstElementChild?.children.item(index); + return DOM.isHTMLElement(element) ? element : undefined; + } + + getItemViewItem(index: number): IActionViewItem | undefined { + return this.actionBar.viewItems[index]; + } + private getUnshrunkItemWidth(index: number): number { const actionItem = this.actionBar.getContainer().firstElementChild?.children.item(index); if (!DOM.isHTMLElement(actionItem)) { @@ -258,6 +271,10 @@ export class ToolBar extends Disposable { return this.actionBar.length(); } + hasOverflow(): boolean { + return this.actionBar.hasAction(this.toggleMenuAction); + } + setAriaLabel(label: string): void { this.actionBar.setAriaLabel(label); } @@ -416,22 +433,33 @@ export class ToolBar extends Disposable { // Each action is assumed to have a minimum width so that actions with a label // can shrink to the action's minimum width. We do this so that action visibility // takes precedence over the action label. + const isActionItemVisible = (index: number): boolean => { + const element = this.getItemElement(index); + return !element || DOM.getWindow(element).getComputedStyle(element).display !== 'none'; + }; + const getVisiblePrimaryActionIndexes = (): number[] => { + const indexes: number[] = []; + for (let index = 0; index < this.actionBar.length(); index++) { + if (this.actionBar.getAction(index) !== this.toggleMenuAction && isActionItemVisible(index)) { + indexes.push(index); + } + } + return indexes; + }; const actionBarMinimumWidth = () => { if (this.options.responsiveBehavior?.kind === 'last') { const hasToggleMenuAction = this.actionBar.hasAction(this.toggleMenuAction); - const primaryActionsCount = hasToggleMenuAction - ? this.actionBar.length() - 1 - : this.actionBar.length(); - if (primaryActionsCount === 0) { + const primaryActionIndexes = getVisiblePrimaryActionIndexes(); + if (primaryActionIndexes.length === 0) { return hasToggleMenuAction ? ACTION_MIN_WIDTH + ACTION_PADDING : 0; } let itemsWidth = 0; - for (let i = 0; i < primaryActionsCount - 1; i++) { - itemsWidth += this.actionBar.getWidth(i) + ACTION_PADDING; + for (const index of primaryActionIndexes.slice(0, -1)) { + itemsWidth += this.actionBar.getWidth(index) + ACTION_PADDING; } - const action = this.actionBar.getAction(primaryActionsCount - 1); + const action = this.actionBar.getAction(primaryActionIndexes.at(-1)!); itemsWidth += this.getActionMinWidth(action); // item to shrink itemsWidth += hasToggleMenuAction ? ACTION_MIN_WIDTH + ACTION_PADDING : 0; // toggle menu action @@ -439,7 +467,9 @@ export class ToolBar extends Disposable { } else { let itemsWidth = 0; for (let i = 0; i < this.actionBar.length(); i++) { - itemsWidth += this.getActionMinWidth(this.actionBar.getAction(i)); + if (isActionItemVisible(i)) { + itemsWidth += this.getActionMinWidth(this.actionBar.getAction(i)); + } } return itemsWidth; } @@ -448,20 +478,17 @@ export class ToolBar extends Disposable { const projectedActionBarMinimumWidth = (actionToAdd: IAction, keepToggleMenuAction: boolean) => { let itemsWidth = this.getActionMinWidth(actionToAdd); if (this.options.responsiveBehavior?.kind === 'last') { - const hasToggleMenuAction = this.actionBar.hasAction(this.toggleMenuAction); - const primaryActionsCount = hasToggleMenuAction - ? this.actionBar.length() - 1 - : this.actionBar.length(); - for (let i = 0; i < primaryActionsCount; i++) { - const itemWidth = i === primaryActionsCount - 1 - ? this.getUnshrunkItemWidth(i) - : this.actionBar.getWidth(i); + const primaryActionIndexes = getVisiblePrimaryActionIndexes(); + for (const [position, index] of primaryActionIndexes.entries()) { + const itemWidth = position === primaryActionIndexes.length - 1 + ? this.getUnshrunkItemWidth(index) + : this.actionBar.getWidth(index); itemsWidth += itemWidth + ACTION_PADDING; } } else { for (let i = 0; i < this.actionBar.length(); i++) { const action = this.actionBar.getAction(i); - if (action && action !== this.toggleMenuAction) { + if (action && action !== this.toggleMenuAction && isActionItemVisible(i)) { itemsWidth += this.getActionMinWidth(action); } } @@ -480,11 +507,14 @@ export class ToolBar extends Disposable { } if (minimumWidth > containerWidth) { + const allowOverflow = this.options.responsiveBehavior?.allowOverflow; + if (allowOverflow === false || (typeof allowOverflow === 'function' && !allowOverflow())) { + return; + } + // Check for max items limit if (this.options.responsiveBehavior?.minItems !== undefined) { - const primaryActionsCount = this.actionBar.hasAction(this.toggleMenuAction) - ? this.actionBar.length() - 1 - : this.actionBar.length(); + const primaryActionsCount = getVisiblePrimaryActionIndexes().length; if (primaryActionsCount <= this.options.responsiveBehavior.minItems) { return; @@ -493,12 +523,16 @@ export class ToolBar extends Disposable { // Hide actions from the right while (minimumWidth > containerWidth && this.actionBar.length() > 0) { - const index = this.originalPrimaryActions.length - this.hiddenActions.length - 1; - if (index < 0) { + const index = getVisiblePrimaryActionIndexes().at(-1); + if (index === undefined) { break; } - const action = this.originalPrimaryActions[index]; - this.hiddenActions.unshift(action); + const action = this.actionBar.getAction(index); + if (!action) { + break; + } + this.hiddenActions.push(action); + this.hiddenActions.sort((a, b) => this.originalPrimaryActions.indexOf(a) - this.originalPrimaryActions.indexOf(b)); // Remove the action this.actionBar.pull(index); @@ -534,7 +568,9 @@ export class ToolBar extends Disposable { icon: this.options.icon ?? true, label: this.options.label ?? false, keybinding: this.getKeybindingLabel(action), - index: this.originalPrimaryActions.length - this.hiddenActions.length - 1 + index: this.originalPrimaryActions + .slice(0, this.originalPrimaryActions.indexOf(action)) + .reduce((index, precedingAction) => index + (this.actionBar.hasAction(precedingAction) ? 1 : 0), 0) }); // There are no secondary actions, and there is only one hidden item left so we @@ -550,7 +586,10 @@ export class ToolBar extends Disposable { } // Update overflow menu - const hiddenActions = this.hiddenActions.slice(0); + const hiddenActions = this.hiddenActions.map(action => this.options.responsiveBehavior?.getOverflowAction?.( + action, + () => this.toggleMenuActionViewItem?.element, + ) ?? action); if (this.originalSecondaryActions.length > 0 || hiddenActions.length > 0) { const secondaryActions = this.originalSecondaryActions.slice(0); this.toggleMenuAction.menuActions = Separator.join(hiddenActions, secondaryActions); diff --git a/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts b/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts index 4ba02bb3916..95bbafbbe10 100644 --- a/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts +++ b/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts @@ -13,12 +13,13 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.j class FixedWidthActionViewItem extends BaseActionViewItem { - constructor(action: IAction, private readonly width: number) { + constructor(action: IAction, private readonly width: number, private readonly visible = true) { super(undefined, action); } override render(container: HTMLElement): void { super.render(container); + container.style.display = this.visible ? '' : 'none'; container.style.width = `${this.width}px`; container.style.boxSizing = 'border-box'; container.style.overflow = 'hidden'; @@ -110,7 +111,7 @@ suite('ToolBar', () => { assert.strictEqual(toolbar.getItemAction(1)?.id, 'workbench.action.chat.openModePicker'); assert.strictEqual(toolbar.getItemAction(2)?.id, 'workbench.action.chat.openModelPicker'); assert.strictEqual(toolbar.getItemAction(3)?.id, ToggleMenuAction.ID); - assert.strictEqual(toolbar.getElement().querySelector('.monaco-action-bar')?.classList.contains('has-overflow'), true); + assert.strictEqual(toolbar.hasOverflow(), true); }); test('applies per-action responsive min widths', () => { @@ -404,13 +405,155 @@ suite('ToolBar', () => { // availableWidth = 200 is plenty for all 3 actions; the element's 0 width is ignored assert.strictEqual(toolbar.getItemsLength(), 3); - assert.strictEqual(toolbar.getElement().querySelector('.monaco-action-bar')?.classList.contains('has-overflow'), false); + assert.strictEqual(toolbar.hasOverflow(), false); availableWidth = 60; toolbar.relayout(); // availableWidth shrank — actions overflow into the toggle menu assert.strictEqual(toolbar.getItemAction(toolbar.getItemsLength() - 1)?.id, ToggleMenuAction.ID); - assert.strictEqual(toolbar.getElement().querySelector('.monaco-action-bar')?.classList.contains('has-overflow'), true); + assert.strictEqual(toolbar.hasOverflow(), true); + + availableWidth = 200; + toolbar.relayout(); + + assert.strictEqual(toolbar.getItemsLength(), 3); + assert.strictEqual(toolbar.hasOverflow(), false); + }); + + test('ignores non-rendered actions when deciding to overflow', () => { + const hiddenActionIds = new Set(['hidden.a', 'hidden.b', 'hidden.c']); + const toolbar = store.add(new TestToolBar(container, contextMenuProvider, { + responsiveBehavior: { + enabled: true, + kind: 'all', + minItems: 1, + actionMinWidth: 48, + getActionMinWidth: () => 22, + getAvailableWidth: () => 60, + }, + actionViewItemProvider: action => new FixedWidthActionViewItem(action, 22, !hiddenActionIds.has(action.id)), + })); + toolbar.setActions([ + store.add(new Action('hidden.a', 'Hidden A')), + store.add(new Action('hidden.b', 'Hidden B')), + store.add(new Action('visible.a', 'Visible A')), + store.add(new Action('hidden.c', 'Hidden C')), + store.add(new Action('visible.b', 'Visible B')), + ]); + + assert.deepStrictEqual({ + visibleActions: Array.from({ length: toolbar.getItemsLength() }, (_, index) => ({ + id: toolbar.getItemAction(index)?.id, + display: toolbar.getItemElement(index)?.style.display, + })).filter(item => item.display !== 'none').map(item => item.id), + overflow: toolbar.hasOverflow(), + }, { + visibleActions: ['visible.a', 'visible.b'], + overflow: false, + }); + }); + + test('can keep compact actions visible instead of overflowing', () => { + const toolbar = store.add(new TestToolBar(container, contextMenuProvider, { + responsiveBehavior: { + enabled: true, + kind: 'all', + minItems: 1, + actionMinWidth: 22, + getAvailableWidth: () => 50, + allowOverflow: false, + }, + actionViewItemProvider: action => new FixedWidthActionViewItem(action, 22), + })); + + toolbar.setActions([ + store.add(new Action('a', 'A')), + store.add(new Action('b', 'B')), + store.add(new Action('c', 'C')), + ]); + + assert.deepStrictEqual({ + items: Array.from({ length: toolbar.getItemsLength() }, (_, index) => toolbar.getItemAction(index)?.id), + overflow: toolbar.hasOverflow(), + }, { + items: ['a', 'b', 'c'], + overflow: false, + }); + }); + + test('allows overflow only after compact actions still exceed the width', () => { + let availableWidth = 100; + let allCompact = false; + const toolbar = store.add(new TestToolBar(container, contextMenuProvider, { + responsiveBehavior: { + enabled: true, + kind: 'all', + minItems: 1, + actionMinWidth: 22, + getAvailableWidth: () => availableWidth, + allowOverflow: () => allCompact, + }, + actionViewItemProvider: action => new FixedWidthActionViewItem(action, 22), + })); + toolbar.setActions([ + store.add(new Action('a', 'A')), + store.add(new Action('b', 'B')), + store.add(new Action('c', 'C')), + ]); + + availableWidth = 50; + toolbar.relayout(); + const beforeCompact = toolbar.hasOverflow(); + + allCompact = true; + toolbar.relayout(); + const afterCompact = toolbar.hasOverflow(); + + assert.deepStrictEqual({ beforeCompact, afterCompact }, { + beforeCompact: false, + afterCompact: true, + }); + }); + + test('uses overflow-specific proxy actions', async () => { + const runs: string[] = []; + let overflowAnchor: HTMLElement | undefined; + const toolbar = store.add(new TestToolBar(container, contextMenuProvider, { + responsiveBehavior: { + enabled: true, + kind: 'all', + minItems: 1, + actionMinWidth: 22, + getAvailableWidth: () => 50, + getOverflowAction: (action, getAnchor) => ({ + ...action, + run: () => { + overflowAnchor = getAnchor(); + runs.push(`overflow:${action.id}`); + }, + }), + }, + actionViewItemProvider: action => new FixedWidthActionViewItem(action, 22), + })); + toolbar.setActions([ + store.add(new Action('a', 'A', undefined, true, () => runs.push('original:a'))), + store.add(new Action('b', 'B', undefined, true, () => runs.push('original:b'))), + store.add(new Action('c', 'C', undefined, true, () => runs.push('original:c'))), + ]); + + const overflowAction = toolbar.getItemAction(toolbar.getItemsLength() - 1); + assert.strictEqual(overflowAction?.id, ToggleMenuAction.ID); + await (overflowAction as ToggleMenuAction).menuActions[0].run(); + const overflowViewItem = toolbar.getItemViewItem(toolbar.getItemsLength() - 1); + const overflowButton = overflowViewItem instanceof BaseActionViewItem ? overflowViewItem.element : undefined; + + assert.deepStrictEqual({ + runs, + usesOverflowButton: overflowAnchor === overflowButton, + }, { + runs: ['overflow:b'], + usesOverflowButton: true, + }); }); }); diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css index 043ab0c986d..137cb2c18fb 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css +++ b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css @@ -632,8 +632,7 @@ /* The chip row scrolls horizontally, so we never want to collapse labels * to icon-only — keep them visible regardless of viewport width. This - * overrides the desktop `@container (max-width: 330px)` query that - * hides `.sessions-chat-dropdown-label` to make icon-only chips. */ + * overrides the desktop collision-driven compact state. */ .agent-sessions-workbench.phone-layout .new-chat-widget-container .new-chat-bottom-container .action-label .sessions-chat-dropdown-label { display: inline; margin-left: 4px; diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts index a2d569e5edb..80e2f1ebda2 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialog.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -444,6 +444,10 @@ export class AutomationIsolationGroupActionViewItem extends BaseActionViewItem { }); } + showPicker(anchor: HTMLElement): void { + this.branchPicker.showPicker(anchor); + } + private refreshTargetCapability(): void { const folderUri = this.isolationModel.folderUri; const sessionTypeId = this.state.sessionTypeId; @@ -1000,6 +1004,8 @@ export function renderForm( listForeground: 'var(--vscode-foreground)', listBackground: 'var(--vscode-input-background)', }; + let automationIsolationAction: IAction | undefined; + const overflowIsolationItem = disposables.add(new MutableDisposable()); const chatInputOptions: IChatInputPartOptions = { renderFollowups: false, @@ -1025,6 +1031,34 @@ export function renderForm( // leaving its scrollbar floating ~24px in from the right wall. inputPartHorizontalPadding: 0, sessionTypePickerDelegate: sessionTypeDelegate, + secondaryToolbarOverflowActionHandler: (actionId, anchor) => { + if (actionId === AUTOMATIONS_HARNESS_CHIP_ACTION_ID) { + sessionTypePicker.showPicker(anchor); + return true; + } + if (actionId === AUTOMATIONS_WORKSPACE_PICKER_ACTION_ID) { + workspacePicker.showPicker(false, anchor); + return true; + } + if (actionId === AUTOMATIONS_ISOLATION_GROUP_ACTION_ID && automationIsolationAction) { + const item = instantiationService.createInstance( + AutomationIsolationGroupActionViewItem, + automationIsolationAction, + state, + isolationModel, + isolationModel.folderUriObs, + onDidChangeSessionTarget.event, + revalidate, + undefined, + workspaceControlsVisible, + ); + overflowIsolationItem.value = item; + item.render(DOM.$('.automation-overflow-isolation-picker')); + item.showPicker(anchor); + return true; + } + return false; + }, secondaryToolbarActionViewItemProvider: (action, itemOptions) => { if (action.id === AUTOMATIONS_HARNESS_CHIP_ACTION_ID) { return new AutomationPickerActionViewItem(action, container => sessionTypePicker.render(container), undefined, itemOptions); @@ -1036,6 +1070,7 @@ export function renderForm( }, undefined, itemOptions); } if (action.id === AUTOMATIONS_ISOLATION_GROUP_ACTION_ID) { + automationIsolationAction = action; const item = instantiationService.createInstance( AutomationIsolationGroupActionViewItem, action, diff --git a/src/vs/sessions/contrib/chat/browser/branchPicker.ts b/src/vs/sessions/contrib/chat/browser/branchPicker.ts index 8d81aa26490..9368cad17cd 100644 --- a/src/vs/sessions/contrib/chat/browser/branchPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/branchPicker.ts @@ -201,8 +201,8 @@ export class BranchPicker extends Disposable { } } - showPicker(): void { - if (!this._triggerElement || this._actionWidgetService.isVisible || !this._state.canOpen) { + showPicker(anchor = this._triggerElement): void { + if (!anchor || this._actionWidgetService.isVisible || !this._state.canOpen) { return; } @@ -218,15 +218,15 @@ export class BranchPicker extends Disposable { }, onHide: () => { this._isOpen = false; - trigger.setAttribute('aria-expanded', 'false'); - if (trigger.isConnected) { + trigger?.setAttribute('aria-expanded', 'false'); + if (trigger?.isConnected) { trigger.focus(); } }, }; this._isOpen = true; - trigger.setAttribute('aria-expanded', 'true'); + trigger?.setAttribute('aria-expanded', 'true'); const items = this._getItems(); const branchCount = items.filter(item => item.item?.kind === 'branch' && !item.item.unavailable).length; this._actionWidgetService.show( @@ -234,7 +234,7 @@ export class BranchPicker extends Disposable { false, items, delegate, - trigger, + anchor, undefined, [], { diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index acd5300f2a0..2e47a616ae6 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -175,10 +175,6 @@ color: var(--vscode-icon-foreground); } -.sessions-chat-toolbar-spacer { - flex: 1; -} - /* Voice mode controls (mic / stop / settings / disconnect) */ .sessions-chat-voice-toolbar { display: flex; @@ -228,6 +224,7 @@ .sessions-chat-config-toolbar { display: flex; align-items: center; + flex: 1 1 0; min-width: 0; overflow: hidden; } @@ -248,12 +245,35 @@ display: flex; align-items: center; min-width: 30px; - overflow: hidden; + overflow: visible; } -/* Prevent the mode picker from shrinking so the model picker label - * ellipsizes first rather than the mode picker collapsing to icon-only. */ -.sessions-chat-config-toolbar .monaco-action-bar .action-item:has(.sessions-chat-dropdown-label) { +.sessions-chat-config-toolbar .monaco-action-bar .action-item.compact-picker { + box-sizing: border-box; + width: 22px; + min-width: 22px; + padding: 0; +} + +.sessions-chat-config-toolbar .monaco-action-bar .action-item.compact-picker .action-label { + box-sizing: border-box; + width: 22px; + min-width: 22px; + padding: 2px 2px 2px 8px; + justify-content: flex-start; +} + +.sessions-chat-config-toolbar .monaco-action-bar .action-item.compact-picker .action-label.model-picker-split { + padding: 0; +} + +.sessions-chat-config-toolbar .monaco-action-bar .action-item.compact-picker .chat-input-picker-label { + display: none; +} + +/* Expanded pickers remain intrinsic; the responsive controller switches them + * to compact form instead of allowing their labels to truncate. */ +.sessions-chat-config-toolbar .monaco-action-bar .action-item:not(.compact-picker) { flex-shrink: 0; } @@ -269,7 +289,7 @@ color: var(--vscode-icon-foreground); white-space: nowrap; min-width: 30px; - overflow: hidden; + overflow: visible; } .sessions-chat-config-toolbar .action-label:hover { @@ -282,13 +302,13 @@ font-size: var(--vscode-agents-fontSize-label2, 11px); } -/* Allow long labels (e.g. the model picker name) to ellipsize when space is tight */ +/* Expanded labels are never truncated; compact mode removes the label. */ .sessions-chat-config-toolbar .action-label .chat-input-picker-label { margin-left: 4px; - overflow: hidden; - text-overflow: ellipsis; + flex-shrink: 0; + overflow: visible; + text-overflow: clip; white-space: nowrap; - min-width: 0; } /* When the picker has no leading icon (e.g. model picker), drop the icon-to-label gap. */ @@ -655,4 +675,3 @@ .sessions-chat-attachment-remove:hover { background-color: var(--vscode-toolbar-hoverBackground); } - diff --git a/src/vs/sessions/contrib/chat/browser/media/chatView.css b/src/vs/sessions/contrib/chat/browser/media/chatView.css index d34de574077..4fbd4ed3c6c 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatView.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatView.css @@ -242,3 +242,7 @@ .agent-sessions-workbench .interactive-session .chat-input-toolbars .chat-sessionPicker-container { display: none; } + +.agent-sessions-workbench .interactive-session .compact-picker .sessions-chat-dropdown-label { + display: none; +} diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 80a3b1fa517..792d63c35a5 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -23,9 +23,6 @@ box-sizing: border-box; overflow: hidden; padding: 16px 16px 20px 16px; - /* Establishes a size container so the @container (max-width: 330px) query below - * can collapse picker labels to icon-only when the new-chat area is narrow. */ - container-type: size; position: relative; } @@ -113,6 +110,10 @@ display: flex; } +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container { + overflow: hidden; +} + .new-chat-widget-container .new-chat-bottom-container .new-chat-controls-container { display: flex; gap: 2px; @@ -133,64 +134,63 @@ overflow: hidden; } -/* Allow nested toolbar items to shrink so labels can ellipsize when space is tight. - * Mirrors the regular chat-input-toolbar pattern: each flex layer between the - * bounded container and the ellipsizing label gets `min-width: 0; overflow: hidden`. */ +/* Toolbar hosts can shrink, while individual expanded pickers remain intrinsic + * and switch to compact form before their labels would truncate. */ .new-chat-widget-container .new-chat-bottom-container .new-chat-controls-container > *, -.new-chat-widget-container .new-chat-bottom-container .new-chat-repo-config-container > *, -.new-chat-widget-container .new-chat-bottom-container .sessions-chat-picker-slot .action-label { +.new-chat-widget-container .new-chat-bottom-container .new-chat-repo-config-container > * { min-width: 0; overflow: hidden; } -/* Floor each picker so the icon + chevron (+ padding) stay visible even when - * the label is fully ellipsized. Approx: 7px padding-left + 12px icon + 2px - * label margin + 16px chevron box + 1px padding-right ~= 38px. The floor must - * be applied to the outermost flex item (.action-item), not just the label, - * because the parent's `min-width: 0` would otherwise let it clip the chevron. */ -.new-chat-widget-container .new-chat-bottom-container .monaco-action-bar .action-item, -.new-chat-widget-container .new-chat-bottom-container .sessions-chat-picker-slot .action-label, -.new-chat-widget-container .new-chat-bottom-container .monaco-action-bar .action-item .action-label { +/* Expanded picker controls never shrink or ellipsize. */ +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .monaco-action-bar .action-item:not(.compact-picker), +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .sessions-chat-picker-slot:not(.compact-picker), +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .sessions-chat-picker-slot:not(.compact-picker) .action-label { + flex-shrink: 0; min-width: 30px; - overflow: hidden; + overflow: visible; } -/* Below this width the bottom-row pickers can't fit their labels comfortably, - * so collapse to icon + chevron only. The .new-chat-widget-container declares - * `container-type: size` which makes this a size container query. The - * permission picker (`.sessions-chat-permission-picker`) gets a more lenient - * threshold below because its label ("Autopilot (Preview)" etc.) carries - * important state that is worth preserving as long as there is room. */ -@container (max-width: 330px) { - /* Bottom-row pickers (Copilot CLI, Default Permissions, Worktree, branch): icon-only */ - .new-chat-widget-container .new-chat-bottom-container .sessions-chat-dropdown-label { - display: none; - } - - .new-chat-widget-container .new-chat-bottom-container .sessions-chat-permission-picker .sessions-chat-dropdown-label { - display: revert; - } - - /* Chat input config toolbar: hide mode picker label (uses sessions-chat-dropdown-label), - * but keep the model picker label (uses chat-input-picker-label) visible. */ - .new-chat-widget-container .sessions-chat-config-toolbar .sessions-chat-dropdown-label { - display: none; - } - - /* With both chevron and label hidden the only content is the icon. Center - * it instead of leaving the 30px min-width as left-aligned padding. - * Permission picker keeps its label so its action-item is excluded. */ - .new-chat-widget-container .new-chat-bottom-container .monaco-action-bar .action-item:not(.sessions-chat-permission-picker) .action-label, - .new-chat-widget-container .new-chat-bottom-container .sessions-chat-picker-slot:not(.sessions-chat-permission-picker) .action-label { - justify-content: center; - padding: 3px; - } +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .sessions-chat-picker-slot:not(.compact-picker) .sessions-chat-dropdown-label, +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .sessions-chat-picker-slot:not(.compact-picker) .chat-session-option-label { + flex-shrink: 0; + overflow: visible; + text-overflow: clip; + white-space: nowrap; } -@container (max-width: 240px) { - .new-chat-widget-container .new-chat-bottom-container .sessions-chat-permission-picker .sessions-chat-dropdown-label { - display: none; - } +/* Individual picker controls collapse from right to left as their row runs out of room. */ +.new-chat-widget-container .compact-picker .sessions-chat-dropdown-label { + display: none; +} + +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .compact-picker.action-item, +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .compact-picker.sessions-chat-picker-slot { + box-sizing: border-box; + width: 22px; + min-width: 22px; + padding: 0; +} + +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .compact-picker.action-item .action-label, +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .compact-picker.sessions-chat-picker-slot .action-label { + box-sizing: border-box; + width: 22px; + min-width: 22px; + justify-content: flex-start; + padding: 2px 2px 2px 8px; +} + +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .compact-picker.sessions-chat-picker-slot .action-label > .codicon { + width: var(--vscode-codiconFontSize-compact); + height: var(--vscode-codiconFontSize-compact); + line-height: var(--vscode-codiconFontSize-compact); +} + +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .compact-picker.sessions-chat-checkbox-chip .monaco-checkbox { + width: 12px; + height: 12px; + margin-right: 0; } /* Spacing between action items inside the bottom-row toolbars (e.g. Worktree, branch) */ diff --git a/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts index c0815437da1..32a38a0dd94 100644 --- a/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts @@ -66,12 +66,12 @@ export class MobileSessionTypePicker extends SessionTypePicker { super.render(container, options); } - protected override _showPicker(): void { - if (!this._triggerElement) { + protected override _showPicker(anchor = this._triggerElement): void { + if (!anchor) { return; } if (!isPhoneLayout(this.layoutService)) { - super._showPicker(); + super._showPicker(anchor); return; } if (this._folderSessionTypes.length <= 1 && this._pickServedByFolder(this._picked)) { @@ -114,6 +114,9 @@ export class MobileSessionTypePicker extends SessionTypePicker { } const trigger = this._triggerElement; + if (!trigger) { + return; + } trigger.setAttribute('aria-expanded', 'true'); showMobilePickerSheet( this.layoutService.mainContainer, diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 6c140554537..a10591cc039 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -89,6 +89,7 @@ import { ChatInputNotificationWidget } from '../../../../workbench/contrib/chat/ import { ChatInputNoticeHost, ChatInputNoticeLane } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeHost.js'; import { registerChatInputOnboardingHosts } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputOnboardingHosts.js'; import { IChatInputNoticeHubService } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeHub.js'; +import { ChatInputPickerResponsiveLayout, IChatInputPickerResponsiveLayoutItem } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.js'; import { chatInputStackClass, chatInputStackSlotClass, ChatInputStackSlot, refreshChatInputStack, setChatInputStackSlot } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; import { IChatSubmitRequestHandlerService } from '../../../../workbench/contrib/chat/browser/chatSubmitRequestHandlerService.js'; import { INewChatModelPickerService, NewChatModelPickerService } from './newChatModelPicker.js'; @@ -131,6 +132,41 @@ const MIN_EDITOR_HEIGHT = 50; const MAX_EDITOR_HEIGHT = 200; const NEW_CHAT_INPUT_FONT_FAMILY = 'system-ui, -apple-system, sans-serif'; +function getLabeledPickerResponsiveItems(container: HTMLElement): IChatInputPickerResponsiveLayoutItem[] { + const elements = new Map(); + const actionItemLabelCounts = new Map(); + const visit = (element: HTMLElement, pickerSlot: HTMLElement | undefined, actionItem: HTMLElement | undefined): void => { + const currentPickerSlot = element.classList.contains('sessions-chat-picker-slot') ? element : pickerSlot; + const currentActionItem = element.classList.contains('action-item') ? element : actionItem; + if (element.classList.contains('sessions-chat-dropdown-label')) { + const pickerElement = currentPickerSlot ?? currentActionItem; + if (pickerElement) { + elements.set(pickerElement, currentActionItem); + if (currentActionItem) { + actionItemLabelCounts.set(currentActionItem, (actionItemLabelCounts.get(currentActionItem) ?? 0) + 1); + } + } + } + for (const child of element.children) { + if (dom.isHTMLElement(child)) { + visit(child, currentPickerSlot, currentActionItem); + } + } + }; + visit(container, undefined, undefined); + + return Array.from(elements, ([element, actionItem]) => ({ + element, + isCompact: () => element.classList.contains('compact-picker'), + setCompact: compact => { + element.classList.toggle('compact-picker', compact); + if (actionItem && actionItem !== element && actionItemLabelCounts.get(actionItem) === 1) { + actionItem.classList.toggle('compact-picker', compact); + } + }, + })); +} + /** True while focus is in an Agents window composer that supports dictation. */ const SessionsChatInputHasDictationFocus = new RawContextKey('sessionsChatInputHasDictationFocus', false, localize('sessionsChatInputHasDictationFocus', "True when focus is in an Agents window chat composer that supports dictation.")); @@ -312,7 +348,6 @@ function getRandomChatInputPlaceholder(): string { // #region --- New Chat Widget --- export class NewChatInputWidget extends Disposable implements IHistoryNavigationWidget, INewSessionComposer { - private static readonly compactModelPickerWidth = 280; readonly sessionTypePicker: SessionTypePicker; @@ -384,6 +419,8 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation private readonly _modelSelection: SessionModelSelection; private readonly _canSendRequest: IObservable; private readonly _compactModelPicker = observableValue(this, false); + private _primaryPickerResponsiveLayout: ChatInputPickerResponsiveLayout | undefined; + private _secondaryPickerResponsiveLayout: ChatInputPickerResponsiveLayout | undefined; // Input state private _draftState: IDraftState | undefined = { @@ -650,6 +687,11 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation }, })); + this._secondaryPickerResponsiveLayout = this._register(new ChatInputPickerResponsiveLayout('NewChatInput.secondaryPicker', newChatBottomContainer, { + getItems: () => getLabeledPickerResponsiveItems(newChatBottomContainer), + })); + this._secondaryPickerResponsiveLayout.layout(); + // Restore draft input state from storage this._restoreState(); @@ -967,7 +1009,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation // Session config pickers (such as model) — rendered via MenuWorkbenchToolBar // Visibility controlled by context keys (isActiveSessionBackgroundProvider, isNewChatSession) const configContainer = dom.append(toolbar, dom.$('.sessions-chat-config-toolbar')); - this._register(this._scopedInstantiationService.createInstance(MenuWorkbenchToolBar, configContainer, Menus.NewSessionConfig, { + const configToolbar = this._register(this._scopedInstantiationService.createInstance(MenuWorkbenchToolBar, configContainer, Menus.NewSessionConfig, { hiddenItemStrategy: HiddenItemStrategy.NoHide, actionViewItemProvider: (action) => { if (action.id === 'sessions.modelPicker') { @@ -978,8 +1020,6 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation }, })); - dom.append(toolbar, dom.$('.sessions-chat-toolbar-spacer')); - // Dictation mic button. Shares the STT service, mic // device, and gating (backend support + `dictation.enabled`) // with the main chat input; inserts the transcript into this composer's @@ -1043,6 +1083,32 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation this._register(sendButton.onDidClick(e => this._send(!!this.options.supportsBackground && !!(e as MouseEvent | KeyboardEvent | undefined)?.altKey))); } updateVoiceInputActionBorder(); + + this._primaryPickerResponsiveLayout = this._register(new ChatInputPickerResponsiveLayout('NewChatInput.primaryPicker', configContainer, { + getItems: () => { + const items: IChatInputPickerResponsiveLayoutItem[] = []; + for (let index = 0; index < configToolbar.getItemsLength(); index++) { + const element = configToolbar.getItemElement(index); + if (!element) { + continue; + } + items.push({ + element, + isCompact: () => element.classList.contains('compact-picker'), + setCompact: (compact: boolean) => { + element.classList.toggle('compact-picker', compact); + if (configToolbar.getItemAction(index)?.id === 'sessions.modelPicker') { + this._compactModelPicker.set(compact, undefined); + } + }, + }); + } + return items; + }, + hasOverflow: () => configToolbar.hasOverflow(), + relayout: () => configToolbar.relayout(), + })); + this._primaryPickerResponsiveLayout.layout(); } private _createVoiceInputModePill(toolbar: HTMLElement, inputContainer: HTMLElement): void { @@ -1435,9 +1501,10 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation } } - layout(_height: number, width: number): void { - this._compactModelPicker.set(width < NewChatInputWidget.compactModelPickerWidth, undefined); + layout(_height: number, _width: number): void { this._editor?.layout(); + this._primaryPickerResponsiveLayout?.layout(); + this._secondaryPickerResponsiveLayout?.layout(); } focus(): void { diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index 76f8116e367..48fb40478df 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -401,8 +401,12 @@ export class SessionTypePicker extends Disposable { * the override can decide where to anchor (or that it doesn't need * anchoring at all, e.g. for a bottom sheet). */ - protected _showPicker(): void { - if (!this._triggerElement || this.actionWidgetService.isVisible) { + showPicker(anchor?: HTMLElement): void { + this._showPicker(anchor); + } + + protected _showPicker(anchor = this._triggerElement): void { + if (!anchor || this.actionWidgetService.isVisible) { return; } @@ -498,7 +502,11 @@ export class SessionTypePicker extends Disposable { this.actionWidgetService.hide(); this._handleSelectedSessionType(item); }, - onHide: () => { triggerElement.focus(); }, + onHide: () => { + if (triggerElement?.isConnected) { + triggerElement.focus(); + } + }, }; this.actionWidgetService.show( @@ -506,7 +514,7 @@ export class SessionTypePicker extends Disposable { false, groupedItems, delegate, - this._triggerElement, + anchor, undefined, [], { diff --git a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts index 002d5052530..ed387797ce5 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -43,6 +43,73 @@ suite('Sessions - Chat View', () => { assert.deepStrictEqual({ forwarded, petHostVisible: isVisible.get() }, { forwarded: [false, true], petHostVisible: true }); }); + test('hides the phone combined picker label when compact', () => { + const toolbar = dom.append(document.body, dom.$('.sessions-chat-config-toolbar')); + disposables.add(toDisposable(() => toolbar.remove())); + const actionBar = dom.append(toolbar, dom.$('.monaco-action-bar')); + const item = dom.append(actionBar, dom.$('.action-item.compact-picker')); + const label = dom.append(item, dom.$('.chat-input-picker-label')); + + assert.strictEqual(dom.getWindow(label).getComputedStyle(label).display, 'none'); + }); + + test('keeps compact empty-state picker icons inside their action item', () => { + const toolbar = dom.append(document.body, dom.$('.sessions-chat-config-toolbar')); + disposables.add(toDisposable(() => toolbar.remove())); + const actionBar = dom.append(toolbar, dom.$('.monaco-action-bar')); + const item = dom.append(actionBar, dom.$('.action-item.compact-picker')); + const label = dom.append(item, dom.$('a.action-label')); + const icon = dom.append(label, dom.$('span.codicon')); + icon.style.width = '12px'; + icon.style.height = '12px'; + + const itemBounds = item.getBoundingClientRect(); + const labelBounds = label.getBoundingClientRect(); + const iconBounds = icon.getBoundingClientRect(); + assert.deepStrictEqual({ + labelOffset: labelBounds.left - itemBounds.left, + iconOffset: iconBounds.left - itemBounds.left, + iconEscapes: iconBounds.left < itemBounds.left || iconBounds.right > itemBounds.right, + }, { + labelOffset: 0, + iconOffset: 8, + iconEscapes: false, + }); + }); + + test('keeps compact bottom-row picker glyphs inside their action item', () => { + const workbench = dom.append(document.body, dom.$('.agent-sessions-workbench')); + disposables.add(toDisposable(() => workbench.remove())); + workbench.style.setProperty('--vscode-codiconFontSize-compact', '12px'); + const widget = dom.append(workbench, dom.$('.new-chat-widget-container.revealed')); + const row = dom.append(widget, dom.$('.new-chat-bottom-container')); + const actionBar = dom.append(row, dom.$('.monaco-action-bar')); + const item = dom.append(actionBar, dom.$('.action-item.compact-picker')); + const label = dom.append(item, dom.$('a.action-label')); + const icon = dom.append(label, dom.$('span.codicon')); + icon.style.width = '12px'; + icon.style.height = '12px'; + + const itemBounds = item.getBoundingClientRect(); + const labelBounds = label.getBoundingClientRect(); + const iconBounds = icon.getBoundingClientRect(); + assert.deepStrictEqual({ + itemWidth: itemBounds.width, + labelWidth: labelBounds.width, + labelOffset: labelBounds.left - itemBounds.left, + iconWidth: iconBounds.width, + iconOffset: iconBounds.left - itemBounds.left, + iconEscapes: iconBounds.left < itemBounds.left || iconBounds.right > itemBounds.right, + }, { + itemWidth: 22, + labelWidth: 22, + labelOffset: 0, + iconWidth: 12, + iconOffset: 8, + iconEscapes: false, + }); + }); + test('does not forward aquarium visibility to the peer chat composer', () => { const isVisible = observableValue(disposables, true); const view: NewChatView = Object.assign(Object.create(NewChatView.prototype), { diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts index 1355c07f504..24934ecbc7a 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts @@ -107,10 +107,6 @@ class TestSessionTypePicker extends SessionTypePicker { pick(p: IPickedSessionType): void { this._handleSelectedSessionType(p); } - - showPicker(): void { - this._showPicker(); - } } function createPicker( diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerActionItem.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerActionItem.ts index 06779871f11..1453a8aa8b7 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerActionItem.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerActionItem.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { autorun, IObservable } from '../../../../../base/common/observable.js'; +import { autorun, IObservable, ISettableObservable } from '../../../../../base/common/observable.js'; import { MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -16,6 +16,7 @@ import { IOpenerService } from '../../../../../platform/opener/common/opener.js' import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IChatInputPickerOptions } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; +import { IChatInputPickerResponsiveState } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.js'; import { PermissionPickerActionItem } from '../../../../../workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { AgentHostPermissionPickerDelegate } from './agentHostPermissionPickerDelegate.js'; @@ -28,13 +29,14 @@ import { AgentHostPermissionPickerDelegate } from './agentHostPermissionPickerDe * the active session's `autoApprove` schema doesn't match the well-known * shape. */ -export class AgentHostPermissionPickerActionItem extends PermissionPickerActionItem { +export class AgentHostPermissionPickerActionItem extends PermissionPickerActionItem implements IChatInputPickerResponsiveState { private readonly _delegate: AgentHostPermissionPickerDelegate; + private readonly _compact: ISettableObservable; constructor( action: MenuItemAction, - pickerOptions: IChatInputPickerOptions, + pickerOptions: IChatInputPickerOptions & { readonly compact: ISettableObservable }, session: IObservable, @IInstantiationService instantiationService: IInstantiationService, @IActionWidgetService actionWidgetService: IActionWidgetService, @@ -63,6 +65,7 @@ export class AgentHostPermissionPickerActionItem extends PermissionPickerActionI hoverService, ); this._delegate = this._register(delegate); + this._compact = pickerOptions.compact; // The base widget's label is rendered on demand via `refresh()`. Keep it // in sync with the delegate's level observable. @@ -72,6 +75,14 @@ export class AgentHostPermissionPickerActionItem extends PermissionPickerActionI })); } + isCompact(): boolean { + return this._compact.get(); + } + + setCompact(compact: boolean): void { + this._compact.set(compact, undefined); + } + override render(container: HTMLElement): void { super.render(container); // The active session can change while this view item is alive (the diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts index c51574db35d..eb0b7d99eea 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -14,7 +14,7 @@ import { Checkbox } from '../../../../../base/browser/ui/toggle/toggle.js'; import { Delayer } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; -import { autorun, constObservable, IObservable } from '../../../../../base/common/observable.js'; +import { autorun, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { localize, localize2 } from '../../../../../nls.js'; import { IActionViewItemService, type IActionViewItemFactory } from '../../../../../platform/actions/browser/actionViewItemService.js'; @@ -34,6 +34,7 @@ import { ChatContextKeyExprs, ChatContextKeys } from '../../../../../workbench/c import { markOnboardingTarget } from '../../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; import { type IChatInputPickerOptions } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; +import { IChatInputPickerResponsiveState } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.js'; import { Menus } from '../../../../browser/menus.js'; import { SessionProviderIdContext, IsPhoneLayoutContext, IsQuickChatSessionContext } from '../../../../common/contextkeys.js'; import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js'; @@ -989,9 +990,12 @@ class MobileAgentHostSessionConfigPicker extends AgentHostSessionConfigPicker { interface IConfigPickerWidget extends IDisposable { render(container: HTMLElement): void; + showPicker?(anchor: HTMLElement, onHide?: () => void): boolean | void; } -export class PickerActionViewItem extends BaseActionViewItem { +export class PickerActionViewItem extends BaseActionViewItem implements IChatInputPickerResponsiveState { + private _compact = false; + constructor(private readonly _picker: IConfigPickerWidget, disposable?: IDisposable) { super(undefined, { id: '', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } }); if (disposable) { @@ -1000,7 +1004,25 @@ export class PickerActionViewItem extends BaseActionViewItem { } override render(container: HTMLElement): void { + this.element = container; this._picker.render(container); + container.classList.toggle('compact-picker', this._compact); + } + + isCompact(): boolean { + return this._compact; + } + + setCompact(compact: boolean): void { + this._compact = compact; + this.element?.classList.toggle('compact-picker', compact); + } + + show(anchor?: HTMLElement): void { + const target = anchor ?? this.element; + if (target) { + this._picker.showPicker?.(target); + } } override dispose(): void { @@ -1126,10 +1148,10 @@ class AgentHostSessionConfigPickerContribution extends Disposable implements IWo return undefined; } const { session } = instantiationService.invokeFunction(accessor => accessor.get(ISessionContext)); - const pickerOptions: IChatInputPickerOptions = { - compact: constObservable(true), + const pickerOptions = { + compact: observableValue(action, false), listOptions: { minWidth: 255 }, - }; + } satisfies IChatInputPickerOptions; return instantiationService.createInstance( AgentHostPermissionPickerActionItem, action, diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts index 678d46f3025..918cc40e166 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts @@ -27,7 +27,7 @@ import { IAgentHostSessionsProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../ import { ISessionsProvidersService } from '../../../../../../services/sessions/browser/sessionsProvidersService.js'; import { IActiveSession } from '../../../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvider } from '../../../../../../services/sessions/common/sessionsProvider.js'; -import { AgentHostSessionConfigPicker, IConfigPickerItem } from '../../../browser/agentHostSessionConfigPicker.js'; +import { AgentHostSessionConfigPicker, IConfigPickerItem, PickerActionViewItem } from '../../../browser/agentHostSessionConfigPicker.js'; const SESSION_ID = 'local-agent-host:s1'; @@ -231,6 +231,38 @@ suite('Agent Host Session Config Picker', () => { }); }); + test('picker action view items expose responsive compact state', () => { + let pickerAnchor: HTMLElement | undefined; + const item = store.add(new PickerActionViewItem({ + render: () => { }, + showPicker: anchor => { + pickerAnchor = anchor; + return true; + }, + dispose: () => { }, + })); + const container = document.createElement('div'); + const overflowAnchor = document.createElement('button'); + item.render(container); + const expanded = { + compact: item.isCompact(), + className: container.classList.contains('compact-picker'), + }; + + item.setCompact(true); + item.show(overflowAnchor); + const compact = { + compact: item.isCompact(), + className: container.classList.contains('compact-picker'), + usesOverflowAnchor: pickerAnchor === overflowAnchor, + }; + + assert.deepStrictEqual({ expanded, compact }, { + expanded: { compact: false, className: false }, + compact: { compact: true, className: true, usesOverflowAnchor: true }, + }); + }); + test('a picker recreated on a session switch still renders the provider-seeded chips (disabled) while resolving', () => { const services = setupServices(store); const { provider } = services; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index 7e178fa86a7..fbf3b88f73a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -416,6 +416,10 @@ export class AgentHostChatInputPicker extends Disposable { this._renderChip(); } + show(anchor: HTMLElement): void { + void this._showPicker(anchor); + } + private _reattach(): void { const sessionResource = this._widget.viewModel?.sessionResource; const provisionalBackend = sessionResource ? this._provisional.get(sessionResource) : undefined; @@ -504,6 +508,7 @@ export class AgentHostChatInputPicker extends Disposable { this._trigger = undefined; this._renderDisposables.clear(); dom.clearNode(this._container); + this._container.classList.remove('agent-host-chat-input-picker-has-icon'); const ctx = this._readContext(); // For sessions that have already started (i.e. no longer untitled — @@ -548,6 +553,7 @@ export class AgentHostChatInputPicker extends Disposable { dom.clearNode(trigger); const icon = getConfigIcon(this._property, value); + this._container?.classList.toggle('agent-host-chat-input-picker-has-icon', !!icon); if (icon) { dom.append(trigger, renderIcon(getCompactCodicon(icon))); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts index 20aa5937d84..995eb823660 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts @@ -150,15 +150,18 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt const selected = this._selectedFolder(); const folder = selected && this._workspaceContextService.getWorkspace().folders.find(f => f.uri.toString() === selected.toString()); const label = folder ? folder.name : (selected ? basename(selected) : localize('agentHost.selectFolder', "Folder")); + const compact = this.pickerOptions.compact.get(); + element.classList.toggle('icon-only', compact); dom.reset( element, ...renderLabelWithIcons(`$(folder-compact)`), - dom.$('span.chat-input-picker-label', undefined, label), + ...(!compact ? [dom.$('span.chat-input-picker-label', undefined, label)] : []), ); // Set the aria label after the visible text is in place: the base class // derives it from `element.textContent`, so labeling first would lag one // selection behind. this.setAriaLabelAttributes(element); + element.ariaLabel = label; return null; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts index 7130daf9d29..89c890813d6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts @@ -37,6 +37,7 @@ export class AgentHostGenericConfigChips extends Disposable { private _container: HTMLElement | undefined; private readonly _chips = this._register(new DisposableMap()); + private readonly _chipElements = new Map(); /** * Subscription to the active session's backend state. Maintained for the @@ -76,6 +77,10 @@ export class AgentHostGenericConfigChips extends Disposable { this._sync(); } + getCompactableElements(): readonly HTMLElement[] { + return Array.from(this._chipElements.values()).filter(element => element.classList.contains('agent-host-chat-input-picker-has-icon')); + } + private _reattach(): void { const sessionResource = this._widget.viewModel?.sessionResource; const provisionalBackend = sessionResource ? this._provisional.get(sessionResource) : undefined; @@ -186,6 +191,7 @@ export class AgentHostGenericConfigChips extends Disposable { for (const property of [...this._chips.keys()]) { if (!desired.has(property)) { this._chips.deleteAndDispose(property); + this._chipElements.delete(property); } } @@ -201,10 +207,12 @@ export class AgentHostGenericConfigChips extends Disposable { // in `chat.css` (height, padding, chevron) applies here too. const slot = dom.append(this._container, dom.$('.agent-host-generic-chip-slot.chat-input-picker-item')); chip.render(slot); + this._chipElements.set(property, slot); this._chips.set(property, { dispose: () => { chip.dispose(); slot.remove(); + this._chipElements.delete(property); }, }); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css index 509d6325322..58e754bf226 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css @@ -90,15 +90,22 @@ } -/* Collapse agent host picker labels to icon-only when the secondary toolbar gets narrow. */ -.interactive-session .chat-secondary-toolbar { - container-type: inline-size; +/* Individual secondary pickers collapse from right to left as the lane narrows. */ +.interactive-session .compact-picker .agent-host-chat-input-picker-label { + display: none; } -@container (max-width: 350px) { - .agent-host-chat-input-picker-label { - display: none; - } +.interactive-session .compact-picker .agent-host-chat-input-picker-slot .action-label { + box-sizing: border-box; + width: 22px; + min-width: 22px; + padding: 2px 2px 2px 8px; + justify-content: flex-start; +} + +.interactive-session .compact-picker .agent-host-chat-input-picker-slot .action-label .codicon { + width: auto; + height: auto; } /* @@ -132,9 +139,9 @@ } .agent-host-chat-input-picker-label { - max-width: 16em; - overflow: hidden; - text-overflow: ellipsis; + flex-shrink: 0; + overflow: visible; + text-overflow: clip; white-space: nowrap; } diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts index dae44597c96..5f67c484c53 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts @@ -238,19 +238,24 @@ export class ChatSessionPickerActionItem extends ActionWidgetDropdownActionViewI const domChildren = []; element.classList.add('chat-session-option-picker'); const group = this.delegate.getOptionGroup(); + const compact = this._pickerOptions?.compact.get() ?? false; + element.classList.toggle('compact', compact); + const label = this.currentOption?.name ?? group?.description ?? localize('chat.sessionPicker.label', "Pick Option"); // If the current option is the default and has an icon, collapse the text and show only the icon const isDefaultWithIcon = this.currentOption?.default && this.currentOption?.icon; + element.classList.toggle('icon-only', compact && !!this.currentOption?.icon); if (this.currentOption?.icon) { domChildren.push(renderIcon(getCompactCodicon(this.currentOption.icon))); } - if (!isDefaultWithIcon) { - domChildren.push(dom.$('span.chat-session-option-label', undefined, this.currentOption?.name ?? group?.description ?? localize('chat.sessionPicker.label', "Pick Option"))); + if (!isDefaultWithIcon && (!compact || !this.currentOption?.icon)) { + domChildren.push(dom.$('span.chat-session-option-label', undefined, label)); } dom.reset(element, ...domChildren); this.setAriaLabelAttributes(element); + element.ariaLabel = label; return null; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 5e9c71cc7e3..dcf156f2097 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -30,7 +30,7 @@ import { ResourceSet } from '../../../../../../base/common/map.js'; import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { mixin } from '../../../../../../base/common/objects.js'; -import { autorun, constObservable, derived, derivedOpts, IObservable, ISettableObservable, ITransaction, observableFromEvent, observableValue, transaction } from '../../../../../../base/common/observable.js'; +import { autorun, derived, derivedOpts, IObservable, ISettableObservable, ITransaction, observableFromEvent, observableValue, transaction } from '../../../../../../base/common/observable.js'; import { isMacintosh } from '../../../../../../base/common/platform.js'; import { isEqual } from '../../../../../../base/common/resources.js'; import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; @@ -57,6 +57,7 @@ import { SuggestController } from '../../../../../../editor/contrib/suggest/brow import { localize } from '../../../../../../nls.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; import { MenuWorkbenchButtonBar } from '../../../../../../platform/actions/browser/buttonbar.js'; +import { IActionViewItemService, type IActionViewItemFactory } from '../../../../../../platform/actions/browser/actionViewItemService.js'; import { MenuEntryActionViewItem } from '../../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; import { MenuId, MenuItemAction } from '../../../../../../platform/actions/common/actions.js'; @@ -160,6 +161,7 @@ import { ChatInputNoticeHost, ChatInputNoticeLane } from './chatInputNoticeHost. import { registerChatInputOnboardingHosts } from './chatInputOnboardingHosts.js'; import { IChatInputNoticeHubService } from './chatInputNoticeHub.js'; import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; +import { ChatInputPickerResponsiveLayout, IChatInputPickerResponsiveLayoutItem, isChatInputPickerResponsiveState } from './chatInputPickerResponsiveLayout.js'; import { chatInputStackClass, chatInputStackSlotClass, ChatInputStackSlot, setChatInputStackInputFocused, setChatInputStackSlot } from './chatInputStack.js'; import { ChatSelectedTools } from './chatSelectedTools.js'; import { ChatPetAchievementIds, didExplicitlySwitchChatPetModel } from '../../chatPetAchievements.js'; @@ -182,9 +184,64 @@ const INPUT_EDITOR_MAX_HEIGHT = 250; const INPUT_EDITOR_LINE_HEIGHT = 20; const INPUT_EDITOR_PADDING = { compact: { top: 2, bottom: 2 }, default: { top: 12, bottom: 12 } }; const CachedLanguageModelsKey = 'chat.cachedLanguageModels.v2'; -const CHAT_INPUT_PICKER_COLLAPSE_WIDTH = 280; const PERMISSION_LEVEL_OPTION_ID = 'permissionLevel'; +function getToolbarPickerResponsiveItems(toolbar: MenuWorkbenchToolBar, compactStates: ReadonlyMap>): IChatInputPickerResponsiveLayoutItem[] { + const items: IChatInputPickerResponsiveLayoutItem[] = []; + const visibleActionIds = new Set(); + + for (let index = 0; index < toolbar.getItemsLength(); index++) { + const action = toolbar.getItemAction(index); + const state = action && compactStates.get(action.id); + const viewItem = toolbar.getItemViewItem(index); + const viewItemState = isChatInputPickerResponsiveState(viewItem) ? viewItem : undefined; + if (!action || (!state && !viewItemState)) { + continue; + } + visibleActionIds.add(action.id); + const element = toolbar.getItemElement(index); + items.push({ + element, + isCompact: () => viewItemState?.isCompact() ?? state!.get(), + setCompact: compact => { + state?.set(compact, undefined); + viewItemState?.setCompact(compact); + element?.classList.toggle('compact-picker', compact); + }, + }); + } + + for (const [actionId, state] of compactStates) { + if (!visibleActionIds.has(actionId)) { + items.push({ + element: undefined, + isCompact: () => state.get(), + setCompact: compact => state.set(compact, undefined), + }); + } + } + + return items; +} + +type ShowableActionViewItem = IActionViewItem & { show(anchor?: HTMLElement): void }; + +function isShowableActionViewItem(item: IActionViewItem | undefined): item is ShowableActionViewItem { + return !!item && 'show' in item && typeof item.show === 'function'; +} + +function createOverflowAction(action: IAction, run: () => void): IAction { + return { + id: action.id, + label: action.label, + tooltip: action.tooltip, + class: action.class, + enabled: action.enabled, + checked: action.checked, + run, + }; +} + export interface IChatInputStyles { overlayBackground: string; listForeground: string; @@ -236,6 +293,11 @@ export interface IChatInputPartOptions { * chat input part while still using menu-driven rendering. */ secondaryToolbarActionViewItemProvider?: (action: IAction, options?: IActionViewItemOptions) => IActionViewItem | undefined; + /** + * Opens a host-owned secondary picker when its toolbar action moves into overflow. + * Returns true when the action was handled. + */ + secondaryToolbarOverflowActionHandler?: (actionId: string, anchor: HTMLElement) => boolean; /** * When true, the mode picker hides custom agents and only offers the * built-in modes (Agent / Ask / Edit / Plan, gated by their normal @@ -341,7 +403,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private static _counter = 0; private _workingSetCollapsed = observableValue('chatInputPart.workingSetCollapsed', true); - private _stableInputPartWidth = observableValue('chatInputPart.stableInputPartWidth', 0); private readonly _chatInputTodoListWidget = this._register(new MutableDisposable()); private readonly _chatArtifactsWidget = this._register(new MutableDisposable()); private readonly _chatQuestionCarouselWidgets = this._register(new DisposableMap()); @@ -609,6 +670,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private executeToolbar!: MenuWorkbenchToolBar; private inputActionsToolbar!: MenuWorkbenchToolBar; + private _inputPickerResponsiveLayout: ChatInputPickerResponsiveLayout | undefined; + private _secondaryPickerResponsiveLayout: ChatInputPickerResponsiveLayout | undefined; @@ -648,6 +711,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private modeWidget: ModePickerActionItem | undefined; private permissionWidget: PermissionPickerActionItem | undefined; private readonly permissionWidgetDisposeListener = this._register(new MutableDisposable()); + private readonly overflowPickerWidget = this._register(new MutableDisposable()); private sessionTargetWidget: SessionTypePickerActionItem | undefined; private delegationWidget: DelegationSessionPickerActionItem | undefined; private readonly chatSessionPickerWidgets = this._register(new DisposableMap()); @@ -866,6 +930,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @IChatService private readonly chatService: IChatService, @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, @IChatPetService private readonly chatPetService: IChatPetService, + @IActionViewItemService private readonly actionViewItemService: IActionViewItemService, ) { super(); this._modelSelectionDiagnostics = new ChatModelSelectionDiagnostics(this.logService, this.storageService, () => ({ @@ -3076,6 +3141,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge ]), ]), dom.h('.chat-secondary-toolbar@secondaryToolbar', [ + dom.h('.chat-responsive-picker-container@responsivePickerContainer'), dom.h('.chat-context-usage-container@contextUsageWidgetContainer'), dom.h('.chat-input-status-container@statusToolbarContainer'), ]), @@ -3112,6 +3178,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge ]), ]), dom.h('.chat-secondary-toolbar@secondaryToolbar', [ + dom.h('.chat-responsive-picker-container@responsivePickerContainer'), dom.h('.chat-context-usage-container@contextUsageWidgetContainer'), dom.h('.chat-input-status-container@statusToolbarContainer'), ]), @@ -3138,6 +3205,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.attachedContextContainer = elements.attachedContextContainer; const toolbarsContainer = elements.inputToolbars; this.secondaryToolbarContainer = elements.secondaryToolbar; + const responsivePickerContainer = elements.responsivePickerContainer; if (this.options.renderStyle === 'compact') { this.secondaryToolbarContainer.style.display = 'none'; } @@ -3353,27 +3421,92 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return !hasDraftTarget && (!target || (!!resource && isEqual(target, resource))); }); - const pickerOptions: IChatInputPickerOptions = { + const inputPickerCompactStates = new Map>(); + const secondaryPickerCompactStates = new Map>(); + const inputOverflowPickerHandlers = new Map void>(); + const secondaryOverflowPickerHandlers = new Map void>(); + const getCompactState = (states: Map>, actionId: string): ISettableObservable => { + let state = states.get(actionId); + if (!state) { + state = observableValue(this, false); + states.set(actionId, state); + } + return state; + }; + const getInputPickerOptions = (actionId: string): IChatInputPickerOptions => ({ getOverflowAnchor: () => this.inputActionsToolbar.getElement(), actionContext: { widget }, - compact: derived(reader => this._stableInputPartWidth.read(reader) < CHAT_INPUT_PICKER_COLLAPSE_WIDTH), - }; - const primarySessionPickerOptions: IChatInputPickerOptions = { - ...pickerOptions, - compact: constObservable(true), - }; - const secondaryPickerOptions: IChatInputPickerOptions = { - ...pickerOptions, + compact: getCompactState(inputPickerCompactStates, actionId), + }); + const getSecondaryPickerOptions = (actionId: string): IChatInputPickerOptions => ({ getOverflowAnchor: () => this.secondaryToolbar.getElement(), - compact: constObservable(true), + actionContext: { widget }, + compact: getCompactState(secondaryPickerCompactStates, actionId), + }); + const showOverflowPicker = (factory: () => ShowableActionViewItem | undefined, anchor: HTMLElement): void => { + const item = factory(); + if (!item) { + return; + } + this.overflowPickerWidget.value = item; + item.render(dom.$('.chat-overflow-picker-item')); + item.show(anchor); + }; + const showRegisteredOverflowPicker = (factory: IActionViewItemFactory, action: IAction, anchor: HTMLElement): boolean => { + const item = factory(action, { hoverDelegate }, this.instantiationService, dom.getWindow(anchor).vscodeWindowId); + if (!isShowableActionViewItem(item)) { + item?.dispose(); + return false; + } + this.overflowPickerWidget.value = item; + item.render(dom.$('.chat-overflow-picker-item')); + item.show(anchor); + return true; + }; + const getOverflowAction = ( + action: IAction, + menuId: MenuId, + handlers: ReadonlyMap void>, + getAnchor: () => HTMLElement | undefined, + fallbackAnchor: HTMLElement, + hostHandler?: (actionId: string, anchor: HTMLElement) => boolean, + ): IAction => { + const handler = handlers.get(action.id); + const registeredFactory = this.actionViewItemService.lookUp(menuId, action.id); + if (!handler && !hostHandler && !registeredFactory) { + return action; + } + return createOverflowAction(action, () => { + const overflowAnchor = getAnchor(); + const anchor = overflowAnchor ?? fallbackAnchor; + dom.getWindow(anchor).setTimeout(() => { + overflowAnchor?.focus(); + if (handler) { + handler(anchor); + } else if (hostHandler?.(action.id, anchor)) { + return; + } else if (registeredFactory && showRegisteredOverflowPicker(registeredFactory, action, anchor)) { + return; + } else { + void action.run({ widget } satisfies IChatExecuteActionContext); + } + }, 0); + }); }; - this._register(dom.addStandardDisposableListener(toolbarsContainer, dom.EventType.CLICK, e => this.inputEditor.focus())); - this._register(dom.addStandardDisposableListener(this.attachmentsContainer, dom.EventType.CLICK, e => this.inputEditor.focus())); const shorterChatInputActionIds = new Set([ OpenModePickerAction.ID, ConfigureToolsAction.ID, ]); + const getInputActionMinWidth = (action: IAction): number | undefined => { + if (shorterChatInputActionIds.has(action.id)) { + return 22; + } + return inputPickerCompactStates.get(action.id)?.get() ? 22 : undefined; + }; + + this._register(dom.addStandardDisposableListener(toolbarsContainer, dom.EventType.CLICK, e => this.inputEditor.focus())); + this._register(dom.addStandardDisposableListener(this.attachmentsContainer, dom.EventType.CLICK, e => this.inputEditor.focus())); this.inputActionsToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, this.options.renderInputToolbarBelowInput ? this.attachmentsContainer : toolbarsContainer, MenuId.ChatInput, { telemetrySource: this.options.menus.telemetrySource, menuOptions: { shouldForwardArgs: true }, @@ -3384,7 +3517,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge kind: 'last', minItems: 1, actionMinWidth: 48, - getActionMinWidth: action => shorterChatInputActionIds.has(action.id) ? 22 : undefined, + getActionMinWidth: getInputActionMinWidth, + allowOverflow: () => this._inputPickerResponsiveLayout?.areAllItemsCompact() === true, + getOverflowAction: (action, getAnchor) => getOverflowAction(action, MenuId.ChatInput, inputOverflowPickerHandlers, getAnchor, toolbarsContainer), }, actionViewItemProvider: (action, options) => { // Phone-layout branch: when an agents-window phone presenter @@ -3414,10 +3549,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } const itemDelegate: IModelPickerDelegate = this._createModelPickerDelegate(); - return this.modelWidget = this.instantiationService.createInstance(ModelPickerActionItem, action, itemDelegate, pickerOptions); + const createPicker = () => this.instantiationService.createInstance(ModelPickerActionItem, action, itemDelegate, getInputPickerOptions(action.id)); + inputOverflowPickerHandlers.set(action.id, anchor => showOverflowPicker(createPicker, anchor)); + return this.modelWidget = createPicker(); } else if (action.id === OpenModePickerAction.ID && action instanceof MenuItemAction) { const delegate: IModePickerDelegate = this._createModePickerDelegate(); - return this.modeWidget = this.instantiationService.createInstance(ModePickerActionItem, action, delegate, pickerOptions); + const createPicker = () => this.instantiationService.createInstance(ModePickerActionItem, action, delegate, getInputPickerOptions(action.id)); + inputOverflowPickerHandlers.set(action.id, anchor => showOverflowPicker(createPicker, anchor)); + return this.modeWidget = createPicker(); } else if ((action.id === OpenSessionTargetPickerAction.ID || action.id === OpenDelegationPickerAction.ID) && action instanceof MenuItemAction) { // Use provided delegate if available, otherwise create default delegate const delegate: ISessionTypePickerDelegate = this.options.sessionTypePickerDelegate ?? { @@ -3434,14 +3573,23 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }; const isWelcomeViewMode = !!this.options.sessionTypePickerDelegate?.setActiveSessionProvider; const Picker = (action.id === OpenSessionTargetPickerAction.ID || isWelcomeViewMode) ? SessionTypePickerActionItem : DelegationSessionPickerActionItem; - return this.sessionTargetWidget = this.instantiationService.createInstance(Picker, action, location === ChatWidgetLocation.Editor ? 'editor' : 'sidebar', delegate, pickerOptions); - } else if (action.id === ChatSessionPrimaryPickerAction.ID && action instanceof MenuItemAction) { - // Cloud sessions render their option-group pickers (e.g. branch) on the primary toolbar - const widgets = this.createChatSessionPickerWidgets(action, primarySessionPickerOptions); - if (widgets.length === 0) { - return new HiddenActionViewItem(action); + const createPicker = () => this.instantiationService.createInstance(Picker, action, location === ChatWidgetLocation.Editor ? 'editor' : 'sidebar', delegate, getInputPickerOptions(action.id)); + inputOverflowPickerHandlers.set(action.id, anchor => showOverflowPicker(createPicker, anchor)); + const picker = createPicker(); + if (picker instanceof DelegationSessionPickerActionItem) { + this.delegationWidget = picker; + } else { + this.sessionTargetWidget = picker; } - return this.instantiationService.createInstance(ChatSessionPickersContainerActionItem, action, widgets); + return picker; + } else if (action.id === ChatSessionPrimaryPickerAction.ID && action instanceof MenuItemAction) { + const createPicker = () => { + // Cloud sessions render their option-group pickers (e.g. branch) on the primary toolbar + const widgets = this.createChatSessionPickerWidgets(action, getInputPickerOptions(action.id)); + return widgets.length === 0 ? undefined : this.instantiationService.createInstance(ChatSessionPickersContainerActionItem, action, widgets); + }; + inputOverflowPickerHandlers.set(action.id, anchor => showOverflowPicker(createPicker, anchor)); + return createPicker() ?? new HiddenActionViewItem(action); } return undefined; } @@ -3460,17 +3608,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._toolbarRelayoutScheduler.schedule(); } })); - // When compact changes, picker items change their rendered size - // but the toolbar's ResizeObserver won't fire (the toolbar element size - // didn't change, only its children did). Force a relayout so the - // responsive overflow logic re-evaluates with the correct item widths. - // The relayout is deferred by a microtask so the picker action view - // items' own autoruns have a chance to re-render their labels first. - this._register(autorun(reader => { - pickerOptions.compact.read(reader); - queueMicrotask(() => this.inputActionsToolbar.relayout()); - })); - // When the phone-input presenter flips between enabled/disabled (e.g. // device rotation crossing the phone breakpoint), the action view item // provider above will return different items. Force the toolbar to @@ -3567,13 +3704,20 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } // Secondary toolbar (permissions) — below the input box. - // Per-action minimum widths (in pixels) for pickers that collapse to an - // icon-only label via a CSS container query in `AgentHostChatInputPicker`. - // Most pickers reserve ~22px for the icon; the tunnel-sharing toggle has - // no chevron, so it can collapse further to 16px. - const agentHostShortPickerMinWidths = new Map([ + // Compact-capable pickers use their 22px control width as the responsive + // floor so icon-only items do not retain empty space from the labeled form. + // The tunnel-sharing toggle has no chevron and can collapse further. + const secondaryPickerMinWidths = new Map([ + [OpenSessionTargetPickerAction.ID, 22], + [OpenDelegationPickerAction.ID, 22], + [OpenWorkspacePickerAction.ID, 22], + [OpenPermissionPickerAction.ID, 22], + [ChatSessionPrimaryPickerAction.ID, 22], [OpenAgentHostModePickerAction.ID, 22], ['sessions.agentHost.runningSessionModePicker', 22], + ['sessions.agentHost.runningSessionConfigPicker', 22], + ['sessions.agentHost.runningSessionPermissionModePicker', 22], + ['sessions.agentHost.runningSessionCodexApprovalsPicker', 22], [OpenAgentHostAutoApprovePickerAction.ID, 22], [OpenAgentHostPermissionModePickerAction.ID, 22], [OpenAgentHostCodexApprovalsPickerAction.ID, 22], @@ -3583,16 +3727,22 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Direct-rendered chip lane for agent-host config properties that // are advertised by the agent's schema but not handled by a // dedicated `MenuId.ChatInputSecondary` action. Sits as a sibling - // of the secondary toolbar so the toolbar can take the available - // space (`flex: 1 1 0`) while the chips pin to the right next to - // the context-usage widget. + // of the content-sized secondary toolbar. const genericChipsContainer = dom.$('.chat-secondary-generic-chips'); const genericChipsLane = this._register(this.instantiationService.createInstance( AgentHostGenericConfigChips, widget, )); genericChipsLane.render(genericChipsContainer); - this.secondaryToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, this.secondaryToolbarContainer, MenuId.ChatInputSecondary, { + const getSecondaryToolbarAvailableWidth = (): number => { + const laneWidth = responsivePickerContainer.getBoundingClientRect().width; + if (genericChipsContainer.parentElement !== responsivePickerContainer || genericChipsContainer.getClientRects().length === 0) { + return laneWidth; + } + const gap = Number.parseFloat(dom.getWindow(responsivePickerContainer).getComputedStyle(responsivePickerContainer).columnGap) || 0; + return Math.max(0, laneWidth - genericChipsContainer.getBoundingClientRect().width - gap); + }; + this.secondaryToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, responsivePickerContainer, MenuId.ChatInputSecondary, { telemetrySource: this.options.menus.telemetrySource, menuOptions: { shouldForwardArgs: true }, hiddenItemStrategy: HiddenItemStrategy.NoHide, @@ -3602,16 +3752,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge kind: 'all', minItems: 1, actionMinWidth: 48, - // Agent-host pickers collapse to an icon-only label via a CSS - // container query in `AgentHostChatInputPicker` when narrow. - // Report a smaller min-width for them so the responsive layout - // keeps them visible instead of overflowing into the menu. - getActionMinWidth: action => agentHostShortPickerMinWidths.get(action.id), + getActionMinWidth: action => secondaryPickerMinWidths.get(action.id) ?? (secondaryPickerCompactStates.get(action.id)?.get() ? 22 : undefined), + observedElement: responsivePickerContainer, + getAvailableWidth: getSecondaryToolbarAvailableWidth, + allowOverflow: () => this._secondaryPickerResponsiveLayout?.areAllItemsCompact() === true, + getOverflowAction: (action, getAnchor) => getOverflowAction(action, MenuId.ChatInputSecondary, secondaryOverflowPickerHandlers, getAnchor, responsivePickerContainer, this.options.secondaryToolbarOverflowActionHandler), }, actionViewItemProvider: (action, options) => { const agentHostPickerProperty = getAgentHostPickerProperty(action.id); const customSecondaryItem = this.options.secondaryToolbarActionViewItemProvider?.(action, options); if (customSecondaryItem) { + getCompactState(secondaryPickerCompactStates, action.id); return customSecondaryItem; } if ((action.id === OpenSessionTargetPickerAction.ID || action.id === OpenDelegationPickerAction.ID) && action instanceof MenuItemAction) { @@ -3629,10 +3780,21 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }; const isWelcomeViewMode = !!this.options.sessionTypePickerDelegate?.setActiveSessionProvider; const Picker = (action.id === OpenSessionTargetPickerAction.ID || isWelcomeViewMode) ? SessionTypePickerActionItem : DelegationSessionPickerActionItem; - return this.sessionTargetWidget = this.instantiationService.createInstance(Picker, action, location === ChatWidgetLocation.Editor ? 'editor' : 'sidebar', delegate, secondaryPickerOptions); + const createPicker = () => this.instantiationService.createInstance(Picker, action, location === ChatWidgetLocation.Editor ? 'editor' : 'sidebar', delegate, getSecondaryPickerOptions(action.id)); + secondaryOverflowPickerHandlers.set(action.id, anchor => showOverflowPicker(createPicker, anchor)); + const picker = createPicker(); + if (picker instanceof DelegationSessionPickerActionItem) { + this.delegationWidget = picker; + } else { + this.sessionTargetWidget = picker; + } + return picker; } else if (action.id === OpenWorkspacePickerAction.ID && action instanceof MenuItemAction) { - if (this.workspaceContextService.getWorkbenchState() === WorkbenchState.EMPTY && this.options.workspacePickerDelegate) { - return this.instantiationService.createInstance(WorkspacePickerActionItem, action, this.options.workspacePickerDelegate, secondaryPickerOptions); + const workspacePickerDelegate = this.options.workspacePickerDelegate; + if (this.workspaceContextService.getWorkbenchState() === WorkbenchState.EMPTY && workspacePickerDelegate) { + const createPicker = () => this.instantiationService.createInstance(WorkspacePickerActionItem, action, workspacePickerDelegate, getSecondaryPickerOptions(action.id)); + secondaryOverflowPickerHandlers.set(action.id, anchor => showOverflowPicker(createPicker, anchor)); + return createPicker(); } else { return new HiddenActionViewItem(action); } @@ -3672,7 +3834,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }, isSandboxToggleApplicable: () => this.getEffectiveSessionType(this.getCurrentSessionResource()) === SessionType.Local, }; - const widget = this.instantiationService.createInstance(PermissionPickerActionItem, action, delegate, secondaryPickerOptions); + const createPicker = () => this.instantiationService.createInstance(PermissionPickerActionItem, action, delegate, getSecondaryPickerOptions(action.id)); + secondaryOverflowPickerHandlers.set(action.id, anchor => showOverflowPicker(createPicker, anchor)); + const widget = createPicker(); this.permissionWidget = widget; this.permissionWidgetDisposeListener.value = widget.onDidDispose(() => { if (this.permissionWidget === widget) { @@ -3685,28 +3849,35 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (this.options.isSessionsWindow) { return new HiddenActionViewItem(action); } - const picker = this.instantiationService.createInstance(AgentHostChatInputPicker, widget, agentHostPickerProperty); - return new AgentHostChatInputPickerActionViewItem(action, picker); + getCompactState(secondaryPickerCompactStates, action.id); + const createPicker = () => this.instantiationService.createInstance(AgentHostChatInputPicker, widget, agentHostPickerProperty); + secondaryOverflowPickerHandlers.set(action.id, anchor => { + const picker = createPicker(); + this.overflowPickerWidget.value = picker; + picker.show(anchor); + }); + return new AgentHostChatInputPickerActionViewItem(action, createPicker()); } else if (action.id === OpenAgentHostFolderPickerAction.ID && action instanceof MenuItemAction) { if (this.options.isSessionsWindow) { return new HiddenActionViewItem(action); } - return this.instantiationService.createInstance(AgentHostFolderPickerActionItem, action, widget, secondaryPickerOptions); + const createPicker = () => this.instantiationService.createInstance(AgentHostFolderPickerActionItem, action, widget, getSecondaryPickerOptions(action.id)); + secondaryOverflowPickerHandlers.set(action.id, anchor => showOverflowPicker(createPicker, anchor)); + return createPicker(); } else if (action.id === ChatSessionPrimaryPickerAction.ID && action instanceof MenuItemAction) { - // Create all pickers and return a container action view item - const widgets = this.createChatSessionPickerWidgets(action, secondaryPickerOptions); - if (widgets.length === 0) { - return new HiddenActionViewItem(action); - } - // Create a container to hold all picker widgets - return this.instantiationService.createInstance(ChatSessionPickersContainerActionItem, action, widgets); + const createPicker = () => { + const widgets = this.createChatSessionPickerWidgets(action, getSecondaryPickerOptions(action.id)); + return widgets.length === 0 ? undefined : this.instantiationService.createInstance(ChatSessionPickersContainerActionItem, action, widgets); + }; + secondaryOverflowPickerHandlers.set(action.id, anchor => showOverflowPicker(createPicker, anchor)); + return createPicker() ?? new HiddenActionViewItem(action); } return undefined; } })); this.secondaryToolbar.getElement().classList.add('chat-secondary-input-toolbar'); this.secondaryToolbar.context = { widget } satisfies IChatExecuteActionContext; - dom.append(this.secondaryToolbarContainer, genericChipsContainer); + dom.append(responsivePickerContainer, genericChipsContainer); this._register(this.secondaryToolbar.onDidChangeMenuItems(() => { // Update container reference for the pickers when the secondary toolbar hosts one. // Only assign when found so we don't overwrite a valid primary container reference @@ -3729,6 +3900,30 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.statusToolbar.getElement().classList.add('chat-input-status-toolbar'); this.statusToolbar.context = { widget } satisfies IChatExecuteActionContext; + const inputToolbarElement = this.inputActionsToolbar.getElement(); + this._inputPickerResponsiveLayout = this._register(new ChatInputPickerResponsiveLayout('ChatInputPart.primaryPicker', inputToolbarElement, { + getItems: () => getToolbarPickerResponsiveItems(this.inputActionsToolbar, inputPickerCompactStates), + hasOverflow: () => this.inputActionsToolbar.hasOverflow(), + relayout: () => this.inputActionsToolbar.relayout(), + })); + + this._secondaryPickerResponsiveLayout = this._register(new ChatInputPickerResponsiveLayout('ChatInputPart.secondaryPicker', responsivePickerContainer, { + getItems: () => [ + ...getToolbarPickerResponsiveItems(this.secondaryToolbar, secondaryPickerCompactStates), + ...genericChipsLane.getCompactableElements() + .map(element => ({ + element, + isCompact: () => element.classList.contains('compact-picker'), + setCompact: (compact: boolean) => element.classList.toggle('compact-picker', compact), + })), + ], + hasOverflow: () => this.secondaryToolbar.hasOverflow(), + relayout: () => this.secondaryToolbar.relayout(), + })); + + this._inputPickerResponsiveLayout.layout(); + this._secondaryPickerResponsiveLayout.layout(); + let inputModel = this.modelService.getModel(this.inputUri); let createdInputModel: ITextModel | undefined; if (!inputModel) { @@ -4783,10 +4978,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge */ layout(width: number) { this.cachedWidth = width; - this._stableInputPartWidth.set(width, undefined); this._updateWorkingProgressAnimationDuration(width); - return this._layout(width); + const result = this._layout(width); + this._inputPickerResponsiveLayout?.layout(); + this._secondaryPickerResponsiveLayout?.layout(); + return result; } private layoutForToolbarChange(): void { @@ -5038,6 +5235,10 @@ class ChatSessionPickersContainerActionItem extends ActionViewItem { } } + show(): void { + this.widgets[0]?.show(); + } + override dispose(): void { for (const widget of this.widgets) { widget.dispose(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts index ddc4987f232..d1c3aea1d57 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts @@ -42,6 +42,7 @@ export function withChatInputPickerMotion(listOptions: IActionListOptions | unde * Provides common anchor resolution logic for dropdown positioning. */ export abstract class ChatInputPickerActionViewItem extends ActionWidgetDropdownActionViewItem { + private _externalAnchor: HTMLElement | undefined; constructor( action: IAction, @@ -80,12 +81,20 @@ export abstract class ChatInputPickerActionViewItem extends ActionWidgetDropdown * Falls back to the overflow anchor if this element is not in the DOM. */ protected getAnchorElement(): HTMLElement { + if (this._externalAnchor?.isConnected) { + return this._externalAnchor; + } if (this.element && getActiveWindow().document.contains(this.element)) { return this.element; } return this.pickerOptions.getOverflowAnchor?.() ?? this.element!; } + override show(anchor?: HTMLElement): void { + this._externalAnchor = anchor; + super.show(); + } + override render(container: HTMLElement): void { super.render(container); container.classList.add('chat-input-picker-item'); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.ts new file mode 100644 index 00000000000..67f8044dd2e --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.ts @@ -0,0 +1,229 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Disposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; + +const WIDTH_TOLERANCE = 1; + +export interface IChatInputPickerResponsiveLayoutDelegate { + getItems(): readonly IChatInputPickerResponsiveLayoutItem[]; + hasOverflow?(): boolean; + relayout?(): void; +} + +export interface IChatInputPickerResponsiveState { + isCompact(): boolean; + setCompact(compact: boolean): void; +} + +export interface IChatInputPickerResponsiveLayoutItem extends IChatInputPickerResponsiveState { + readonly element: HTMLElement | undefined; +} + +export function isChatInputPickerResponsiveState(candidate: object | undefined): candidate is IChatInputPickerResponsiveState { + return !!candidate + && 'isCompact' in candidate + && typeof candidate.isCompact === 'function' + && 'setCompact' in candidate + && typeof candidate.setCompact === 'function'; +} + +/** + * Compacts a picker lane only when its expanded contents no longer fit the width assigned by its surrounding layout. + */ +export class ChatInputPickerResponsiveLayout extends Disposable { + + private readonly _mutationObserver: MutationObserver; + private _isLayouting = false; + + constructor( + name: string, + private readonly _element: HTMLElement, + private readonly _delegate: IChatInputPickerResponsiveLayoutDelegate, + ) { + super(); + + const targetWindow = dom.getWindow(_element); + const resizeObserver = this._register(new dom.DisposableResizeObserver(name, () => this.layout(), targetWindow)); + this._register(resizeObserver.observe(_element)); + + this._mutationObserver = new targetWindow.MutationObserver(() => this.layout()); + this._observeMutations(); + this._register(toDisposable(() => this._mutationObserver.disconnect())); + } + + layout(): void { + if (this._isLayouting || !this._element.isConnected) { + return; + } + + const availableWidth = this._element.getBoundingClientRect().width; + if (availableWidth <= 0) { + return; + } + + this._isLayouting = true; + this._mutationObserver.disconnect(); + try { + // Restore as many hidden actions as possible in their shortest form + // before measuring. Otherwise an overflow menu can hide the very items + // whose expanded width should keep the lane compact. + this._setAllCompact(true); + this._delegate.relayout?.(); + this._setAllCompact(true); + this._delegate.relayout?.(); + if (this._delegate.hasOverflow?.()) { + return; + } + + const items = this._getOrderedVisibleItems(); + for (const item of items) { + item.setCompact(false); + } + + for (const item of items) { + if (this._fitsAvailableWidth(availableWidth)) { + break; + } + item.setCompact(true); + } + this._delegate.relayout?.(); + } finally { + this._observeMutations(); + this._isLayouting = false; + } + } + + areAllItemsCompact(): boolean { + return this._delegate.getItems().every(item => item.isCompact()); + } + + private _setAllCompact(compact: boolean): void { + for (const item of this._delegate.getItems()) { + item.setCompact(compact); + } + } + + private _getOrderedVisibleItems(): IChatInputPickerResponsiveLayoutItem[] { + return this._delegate.getItems() + .filter(item => item.element?.isConnected && item.element.getClientRects().length > 0) + .sort((a, b) => b.element!.getBoundingClientRect().left - a.element!.getBoundingClientRect().left); + } + + private _fitsAvailableWidth(availableWidth: number): boolean { + const items = this._getOrderedVisibleItems(); + const preferredLayout = this._measurePreferredLayout(items); + if (preferredLayout.width > availableWidth + WIDTH_TOLERANCE) { + return false; + } + + const laneBounds = this._element.getBoundingClientRect(); + const itemBounds = items + .map(item => ({ item, bounds: item.element!.getBoundingClientRect() })) + .sort((a, b) => a.bounds.left - b.bounds.left); + for (let index = 0; index < itemBounds.length; index++) { + const { item, bounds } = itemBounds[index]; + if (bounds.left < laneBounds.left - WIDTH_TOLERANCE || bounds.right > laneBounds.right + WIDTH_TOLERANCE) { + return false; + } + if (index > 0 && bounds.left < itemBounds[index - 1].bounds.right - WIDTH_TOLERANCE) { + return false; + } + const preferredWidth = preferredLayout.itemWidths.get(item); + if (preferredWidth !== undefined && bounds.width < preferredWidth - WIDTH_TOLERANCE) { + return false; + } + } + return true; + } + + private _measurePreferredLayout(items: readonly IChatInputPickerResponsiveLayoutItem[]): { width: number; itemWidths: ReadonlyMap } { + const parent = this._element.parentElement; + if (!parent) { + return { width: 0, itemWidths: new Map() }; + } + + const measurementHost = dom.$('.chat-input-picker-measurement-host'); + measurementHost.style.position = 'fixed'; + measurementHost.style.inset = '0 auto auto 0'; + measurementHost.style.width = '0'; + measurementHost.style.height = '0'; + measurementHost.style.overflow = 'hidden'; + measurementHost.style.contain = 'strict'; + measurementHost.style.visibility = 'hidden'; + measurementHost.style.pointerEvents = 'none'; + + const measurement = this._element.cloneNode(true) as HTMLElement; + measurement.setAttribute('aria-hidden', 'true'); + measurement.setAttribute('inert', ''); + measurement.style.position = 'absolute'; + measurement.style.left = '0'; + measurement.style.top = '0'; + measurement.style.width = 'max-content'; + measurement.style.minWidth = 'max-content'; + measurement.style.maxWidth = 'none'; + measurement.style.flex = 'none'; + measurementHost.appendChild(measurement); + parent.appendChild(measurementHost); + try { + const itemWidths = new Map(); + for (const item of items) { + const path = item.element ? this._getElementPath(item.element) : undefined; + const measuredItem = path ? this._getElementAtPath(measurement, path) : undefined; + if (measuredItem) { + measuredItem.style.flex = 'none'; + measuredItem.style.width = 'max-content'; + measuredItem.style.minWidth = 'max-content'; + measuredItem.style.maxWidth = 'none'; + itemWidths.set(item, measuredItem.getBoundingClientRect().width); + } + } + return { width: measurement.getBoundingClientRect().width, itemWidths }; + } finally { + measurementHost.remove(); + } + } + + private _getElementPath(element: HTMLElement): readonly number[] | undefined { + const path: number[] = []; + let current: HTMLElement | null = element; + while (current && current !== this._element) { + const parent: HTMLElement | null = current.parentElement; + if (!parent) { + return undefined; + } + const index = Array.from(parent.children).indexOf(current); + if (index < 0) { + return undefined; + } + path.unshift(index); + current = parent; + } + return current === this._element ? path : undefined; + } + + private _getElementAtPath(root: HTMLElement, path: readonly number[]): HTMLElement | undefined { + let current: Element = root; + for (const index of path) { + const child = current.children.item(index); + if (!child) { + return undefined; + } + current = child; + } + return dom.isHTMLElement(current) ? current : undefined; + } + + private _observeMutations(): void { + this._mutationObserver.observe(this._element, { + attributes: true, + attributeFilter: ['class', 'hidden', 'style'], + characterData: true, + childList: true, + subtree: true, + }); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index 7b800cc5ba5..5ec50cb2725 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -294,8 +294,6 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { } protected override renderLabel(element: HTMLElement): IDisposable | null { - this.setAriaLabelAttributes(element); - const currentMode = this.delegate.currentMode.get(); const state = currentMode.label.get(); let icon = currentMode.icon.get(); @@ -307,6 +305,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { const labelElements = []; const collapsed = this.pickerOptions.compact.get(); + element.classList.toggle('icon-only', collapsed && !!icon); if (icon) { labelElements.push(...renderLabelWithIcons(`$(${getCompactCodicon(icon).id})`)); } @@ -315,6 +314,8 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { } dom.reset(element, ...labelElements); + this.setAriaLabelAttributes(element); + element.ariaLabel = state; return null; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css index 0a7f2fbcb81..601a7065c5f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css @@ -51,14 +51,17 @@ } .chat-input-picker-item .action-label.model-picker-split .model-picker-name { - min-width: 0; - flex-shrink: 1; - overflow: hidden; + flex-shrink: 0; + overflow: visible; +} + +.interactive-session .chat-input-toolbar .chat-input-picker-item.compact-picker .action-label.model-picker-split.compact { + justify-content: flex-start; } .chat-input-picker-item .action-label.model-picker-split .model-picker-name .chat-input-picker-label { - overflow: hidden; - text-overflow: ellipsis; + overflow: visible; + text-overflow: clip; } .chat-input-picker-item .action-label.model-picker-split .model-picker-config { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts index 973051a341d..75a3c1bad1a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts @@ -128,8 +128,8 @@ export class ModelPickerActionItem extends BaseActionViewItem { this._showPicker(); } - public show(): void { - this._showPicker(); + public show(anchor?: HTMLElement): void { + this._pickerWidget.show(anchor ?? this._getAnchorElement()); } public setEnabled(enabled: boolean): void { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts index 2492ec91ee8..a2d0778e6a6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts @@ -387,7 +387,11 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { const labelElements = []; labelElements.push(...renderLabelWithIcons(`$(${getCompactCodicon(icon).id})`)); - labelElements.push(dom.$('span.chat-input-picker-label', undefined, label)); + const compact = this.pickerOptions.compact.get(); + element.classList.toggle('icon-only', compact); + if (!compact) { + labelElements.push(dom.$('span.chat-input-picker-label', undefined, label)); + } dom.reset(element, ...labelElements); element.classList.toggle('warning', !ext && (level === ChatPermissionLevel.Autopilot || level === ChatPermissionLevel.Assisted)); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index 2062b99c727..43d8839dff9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -365,7 +365,6 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { } protected override renderLabel(element: HTMLElement): IDisposable | null { - this.setAriaLabelAttributes(element); const currentType = this._getSelectedSessionType() ?? this._getDefaultSessionType(); // TODO: Remove hardcoded providers from core @@ -377,9 +376,15 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { const labelElements = []; labelElements.push(...renderLabelWithIcons(`$(${getCompactCodicon(icon).id})`)); - labelElements.push(dom.$('span.chat-input-picker-label', undefined, label)); + const compact = this.pickerOptions.compact.get(); + element.classList.toggle('icon-only', compact); + if (!compact) { + labelElements.push(dom.$('span.chat-input-picker-label', undefined, label)); + } dom.reset(element, ...labelElements); + this.setAriaLabelAttributes(element); + element.ariaLabel = label; return null; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/workspacePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/workspacePickerActionItem.ts index 812b0bd282c..710cb57d85d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/workspacePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/workspacePickerActionItem.ts @@ -103,22 +103,23 @@ export class WorkspacePickerActionItem extends ChatInputPickerActionViewItem { } protected override renderLabel(element: HTMLElement): IDisposable | null { - this.setAriaLabelAttributes(element); const currentWorkspace = this.delegate.getSelectedWorkspace(); const labelElements: (string | HTMLElement)[] = []; + const label = currentWorkspace + ? currentWorkspace.label || basename(currentWorkspace.uri) + : localize('selectWorkspace', "Workspace"); + const compact = this.pickerOptions.compact.get(); + element.classList.toggle('icon-only', compact); - if (currentWorkspace) { - // Show the workspace label or folder name - const label = currentWorkspace.label || basename(currentWorkspace.uri); - labelElements.push(...renderLabelWithIcons(`$(folder-compact)`)); + labelElements.push(...renderLabelWithIcons(`$(folder-compact)`)); + if (!compact) { labelElements.push(dom.$('span.chat-input-picker-label', undefined, label)); - } else { - labelElements.push(...renderLabelWithIcons(`$(folder-compact)`)); - labelElements.push(dom.$('span.chat-input-picker-label', undefined, localize('selectWorkspace', "Workspace"))); } dom.reset(element, ...labelElements); + this.setAriaLabelAttributes(element); + element.ariaLabel = label; return null; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 8bb557470f9..60f33e5621f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1843,6 +1843,14 @@ have to be updated for changes to the rules above, or to support more deeply nes display: none; } +.interactive-session .chat-secondary-toolbar .chat-responsive-picker-container { + display: flex; + align-items: center; + flex: 1 1 0; + min-width: 0; + gap: 2px; +} + .interactive-session .chat-secondary-toolbar .chat-secondary-generic-chips { display: flex; align-items: center; @@ -1850,10 +1858,14 @@ have to be updated for changes to the rules above, or to support more deeply nes gap: 2px; } +.interactive-session .chat-secondary-toolbar .chat-secondary-generic-chips:empty { + display: none; +} + .interactive-session .chat-secondary-toolbar .chat-secondary-input-toolbar { overflow: hidden; min-width: 0px; - flex: 1 1 0; + flex: 0 1 auto; color: var(--vscode-icon-foreground); .monaco-action-bar .action-item .codicon { @@ -1865,16 +1877,19 @@ have to be updated for changes to the rules above, or to support more deeply nes .chat-input-picker-item { min-width: 0px; - overflow: hidden; + overflow: visible; + flex-shrink: 0; .action-label { min-width: 0px; - overflow: hidden; + overflow: visible; position: relative; .chat-input-picker-label { - overflow: hidden; - text-overflow: ellipsis; + flex-shrink: 0; + overflow: visible; + text-overflow: clip; + white-space: nowrap; } .codicon + .chat-input-picker-label { @@ -1945,16 +1960,19 @@ have to be updated for changes to the rules above, or to support more deeply nes .chat-input-picker-item { min-width: 0px; - overflow: hidden; + overflow: visible; + flex-shrink: 0; .action-label { min-width: 0px; - overflow: hidden; + overflow: visible; position: relative; .chat-input-picker-label { - overflow: hidden; - text-overflow: ellipsis; + flex-shrink: 0; + overflow: visible; + text-overflow: clip; + white-space: nowrap; } .model-picker-badge { @@ -2020,11 +2038,12 @@ have to be updated for changes to the rules above, or to support more deeply nes background-color: var(--vscode-toolbar-hoverBackground); } -/* When chevrons are hidden and only showing an icon (no label), size to 22x22 with centered icon */ -.interactive-session .chat-input-toolbar .chat-input-picker-item .action-label.compact:not(:has(.chat-input-picker-label)), -.interactive-session .chat-input-toolbar .chat-input-picker-item.compact .action-label:not(:has(.chat-input-picker-label)), -.interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label.compact:not(:has(.chat-input-picker-label)), -.interactive-session .chat-secondary-input-toolbar .chat-sessionPicker-item .action-label.compact:not(:has(.chat-input-picker-label)) { +/* When only the icon remains, keep the expanded control's leading inset so + * the glyph does not move as the label disappears. */ +.interactive-session .chat-input-toolbar .chat-input-picker-item .action-label.icon-only, +.interactive-session .chat-secondary-input-toolbar .chat-input-picker-item .action-label.icon-only, +.interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label.icon-only, +.interactive-session .chat-secondary-input-toolbar .chat-sessionPicker-item .action-label.icon-only { width: 22px; min-width: 22px; height: 22px; @@ -2039,6 +2058,18 @@ have to be updated for changes to the rules above, or to support more deeply nes } } +.interactive-session .chat-input-toolbar .chat-input-picker-item .action-label.icon-only:not(.model-picker-split), +.interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label.icon-only { + padding-left: var(--vscode-spacing-size60); + justify-content: flex-start; +} + +.interactive-session .chat-secondary-input-toolbar .chat-input-picker-item .action-label.icon-only, +.interactive-session .chat-secondary-input-toolbar .chat-sessionPicker-item .action-label.icon-only { + padding-left: var(--vscode-spacing-size80); + justify-content: flex-start; +} + /* Icon-only chips in the primary input toolbar (add context, configure tools, MCP servers) all sit on the compact tier, so the row reads as one dense strip diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts new file mode 100644 index 00000000000..1950c44e45a --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts @@ -0,0 +1,327 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import * as dom from '../../../../../../../base/browser/dom.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { ChatInputPickerResponsiveLayout } from '../../../../browser/widget/input/chatInputPickerResponsiveLayout.js'; +import '../../../../browser/widget/input/modelPicker/media/modelPicker.css'; +import '../../../../browser/widget/media/chat.css'; + +suite('ChatInputPickerResponsiveLayout', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + let host: HTMLElement; + + setup(() => { + host = dom.append(document.body, dom.$('.chat-input-picker-responsive-layout-test')); + }); + + teardown(() => { + host.remove(); + }); + + test('uses the rendered picker width instead of a viewport threshold', () => { + const lane = dom.append(host, dom.$('.picker-lane')); + lane.style.display = 'flex'; + lane.style.width = '120px'; + lane.style.overflow = 'hidden'; + + const picker = dom.append(lane, dom.$('.picker')); + picker.style.flex = '0 0 auto'; + picker.style.width = '240px'; + + let compact = false; + let expandedWidth = 240; + const layout = store.add(new ChatInputPickerResponsiveLayout('test.pickerLane', lane, { + getItems: () => [{ + element: picker, + isCompact: () => compact, + setCompact: value => { + compact = value; + picker.style.width = value ? '20px' : `${expandedWidth}px`; + }, + }], + })); + + layout.layout(); + const narrow = compact; + + lane.style.width = '300px'; + layout.layout(); + const expandedAfterLaneGrows = compact; + + lane.style.width = '120px'; + expandedWidth = 80; + layout.layout(); + const wideEnoughForCurrentItems = compact; + + assert.deepStrictEqual({ narrow, expandedAfterLaneGrows, wideEnoughForCurrentItems }, { + narrow: true, + expandedAfterLaneGrows: false, + wideEnoughForCurrentItems: false, + }); + }); + + test('compacts picker items from right to left until the lane fits', () => { + const lane = dom.append(host, dom.$('.picker-lane')); + lane.style.display = 'flex'; + lane.style.width = '180px'; + lane.style.overflow = 'hidden'; + + const compact = [false, false, false]; + const pickers = compact.map((_, index) => { + const picker = dom.append(lane, dom.$(`.picker-${index}`)); + picker.style.flex = '0 0 auto'; + picker.style.width = '80px'; + return picker; + }); + const layout = store.add(new ChatInputPickerResponsiveLayout('test.progressivePickerLane', lane, { + getItems: () => pickers.map((picker, index) => ({ + element: picker, + isCompact: () => compact[index], + setCompact: value => { + compact[index] = value; + picker.style.width = value ? '20px' : '80px'; + }, + })), + })); + + layout.layout(); + const firstCollision = [...compact]; + + lane.style.width = '130px'; + layout.layout(); + const secondCollision = [...compact]; + + lane.style.width = '240px'; + layout.layout(); + const expanded = [...compact]; + + assert.deepStrictEqual({ firstCollision, secondCollision, expanded }, { + firstCollision: [false, false, true], + secondCollision: [false, true, true], + expanded: [false, false, false], + }); + }); + + test('treats an empty picker set as fully compact', () => { + const lane = dom.append(host, dom.$('.picker-lane')); + const layout = store.add(new ChatInputPickerResponsiveLayout('test.emptyPickerLane', lane, { + getItems: () => [], + })); + + assert.strictEqual(layout.areAllItemsCompact(), true); + }); + + test('ignores mutations outside the responsive picker container', async () => { + const row = dom.append(host, dom.$('.secondary-row')); + const lane = dom.append(row, dom.$('.responsive-picker-container')); + const picker = dom.append(lane, dom.$('.picker')); + const unrelated = dom.append(row, dom.$('.context-usage')); + lane.style.width = '100px'; + lane.style.height = '20px'; + let compact = false; + const layout = store.add(new ChatInputPickerResponsiveLayout('test.isolatedPickerLane', lane, { + getItems: () => [{ + element: picker, + isCompact: () => compact, + setCompact: value => compact = value, + }], + })); + + let layoutCalls = 0; + layout.layout = () => layoutCalls++; + const targetWindow = dom.getWindow(lane); + await new Promise(resolve => targetWindow.requestAnimationFrame(() => targetWindow.requestAnimationFrame(() => resolve()))); + layoutCalls = 0; + unrelated.textContent = 'streamed cost update'; + await new Promise(resolve => setTimeout(resolve, 0)); + const afterUnrelatedMutation = layoutCalls; + + picker.textContent = 'picker changed'; + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.strictEqual(afterUnrelatedMutation, 0); + assert.ok(layoutCalls > 0); + }); + + test('restores overflowed actions in compact form before considering expanded labels', () => { + const lane = dom.append(host, dom.$('.picker-lane')); + lane.style.display = 'flex'; + lane.style.width = '50px'; + lane.style.overflow = 'hidden'; + + const actionBar = dom.append(lane, dom.$('.monaco-action-bar.has-overflow')); + const picker = dom.append(actionBar, dom.$('.picker')); + let compact = false; + let overflow = true; + const layout = store.add(new ChatInputPickerResponsiveLayout('test.overflowedPickerLane', lane, { + getItems: () => [{ + element: picker, + isCompact: () => compact, + setCompact: value => { + compact = value; + picker.style.width = value ? '60px' : '150px'; + }, + }], + hasOverflow: () => overflow, + relayout: () => { + overflow = picker.getBoundingClientRect().width > lane.getBoundingClientRect().width; + }, + })); + + layout.layout(); + const tooNarrowForCompact = { compact, overflow }; + + lane.style.width = '70px'; + layout.layout(); + const compactItemsRestored = { compact, overflow }; + + lane.style.width = '160px'; + layout.layout(); + const expanded = { compact, overflow }; + + assert.deepStrictEqual({ tooNarrowForCompact, compactItemsRestored, expanded }, { + tooNarrowForCompact: { compact: true, overflow: true }, + compactItemsRestored: { compact: true, overflow: false }, + expanded: { compact: false, overflow: false }, + }); + }); + + test('compacts a picker whose rendered bounds escape the lane', () => { + const lane = dom.append(host, dom.$('.picker-lane')); + lane.style.display = 'flex'; + lane.style.width = '100px'; + lane.style.overflow = 'visible'; + + const picker = dom.append(lane, dom.$('.picker')); + picker.style.flex = '0 0 auto'; + picker.style.width = '80px'; + picker.style.transform = 'translateX(50px)'; + let compact = false; + const layout = store.add(new ChatInputPickerResponsiveLayout('test.visuallyOverflowedPickerLane', lane, { + getItems: () => [{ + element: picker, + isCompact: () => compact, + setCompact: value => { + compact = value; + picker.style.width = value ? '20px' : '80px'; + }, + }], + })); + + layout.layout(); + + assert.deepStrictEqual({ + compact, + measurementHosts: host.querySelectorAll('.chat-input-picker-measurement-host').length, + }, { + compact: true, + measurementHosts: 0, + }); + }); + + test('compacts an expanded picker before its label truncates', () => { + const lane = dom.append(host, dom.$('.picker-lane')); + lane.style.display = 'flex'; + lane.style.width = '200px'; + + const picker = dom.append(lane, dom.$('.picker')); + picker.style.flex = '0 1 80px'; + picker.style.width = '80px'; + picker.style.overflow = 'hidden'; + const label = dom.append(picker, dom.$('.picker-label')); + label.style.display = 'block'; + label.style.width = '140px'; + label.textContent = 'A picker label that would otherwise ellipsize'; + + let compact = false; + const layout = store.add(new ChatInputPickerResponsiveLayout('test.truncatedPickerLane', lane, { + getItems: () => [{ + element: picker, + isCompact: () => compact, + setCompact: value => { + compact = value; + picker.style.width = value ? '20px' : '80px'; + label.style.display = value ? 'none' : ''; + }, + }], + })); + + layout.layout(); + + assert.strictEqual(compact, true); + }); + + test('keeps the toolbar row height stable when the model picker overflows', () => { + host.style.setProperty('--vscode-spacing-size40', '4px'); + host.style.setProperty('--vscode-spacing-size60', '6px'); + host.classList.add('interactive-session'); + + const row = dom.append(host, dom.$('.picker-row.chat-input-toolbar')); + row.style.display = 'flex'; + row.style.alignItems = 'center'; + + const modelItem = dom.append(row, dom.$('.chat-input-picker-item')); + const modelLabel = dom.append(modelItem, dom.$('a.action-label.model-picker-split')); + const modelName = dom.append(modelLabel, dom.$('.model-picker-section.model-picker-name')); + const pickerLabel = dom.append(modelName, dom.$('.chat-input-picker-label')); + + const overflowItem = dom.append(row, dom.$('.overflow-item')); + overflowItem.style.width = '22px'; + overflowItem.style.height = '22px'; + overflowItem.style.display = 'none'; + + const withModelPicker = row.getBoundingClientRect().height; + const expandedIconOffset = modelName.getBoundingClientRect().left - modelLabel.getBoundingClientRect().left; + modelLabel.style.width = '22px'; + modelItem.classList.add('compact-picker'); + modelLabel.classList.add('compact'); + const compactIconOffset = modelName.getBoundingClientRect().left - modelLabel.getBoundingClientRect().left; + modelItem.style.display = 'none'; + overflowItem.style.display = ''; + const withOverflow = row.getBoundingClientRect().height; + + assert.deepStrictEqual({ + withModelPicker, + withOverflow, + modelNameFlexShrink: dom.getWindow(modelName).getComputedStyle(modelName).flexShrink, + labelTextOverflow: dom.getWindow(pickerLabel).getComputedStyle(pickerLabel).textOverflow, + expandedIconOffset, + compactIconOffset, + }, { + withModelPicker: 22, + withOverflow: 22, + modelNameFlexShrink: '0', + labelTextOverflow: 'clip', + expandedIconOffset: 0, + compactIconOffset: 0, + }); + }); + + test('keeps the primary picker icon anchored when its label disappears', () => { + host.style.setProperty('--vscode-spacing-size60', '6px'); + host.classList.add('interactive-session'); + const toolbar = dom.append(host, dom.$('.chat-input-toolbar')); + const item = dom.append(toolbar, dom.$('.chat-input-picker-item')); + const actionLabel = dom.append(item, dom.$('a.action-label')); + const icon = dom.append(actionLabel, dom.$('span.codicon')); + icon.style.width = '16px'; + icon.style.height = '16px'; + const pickerLabel = dom.append(actionLabel, dom.$('span.chat-input-picker-label')); + pickerLabel.textContent = 'Picker'; + + const expandedOffset = icon.getBoundingClientRect().left - actionLabel.getBoundingClientRect().left; + item.classList.add('compact'); + actionLabel.classList.add('icon-only'); + pickerLabel.remove(); + const compactOffset = icon.getBoundingClientRect().left - actionLabel.getBoundingClientRect().left; + + assert.deepStrictEqual({ expandedOffset, compactOffset }, { + expandedOffset: 6, + compactOffset: 6, + }); + }); +}); diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index bf64b86f8cf..c4c1c6a1a2d 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -79,10 +79,10 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/7f70224f7733a2461eba63fa98234aab38b8804a73460deffa11f49cd6f7172c) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/f9e5bfb616a989cd170f3aafd172838918c8f40fff0cbbcd5c595cc2405de2dc) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/9a23d68d520d31525d56d8cfb444365a79f81b0ee3391de28e94bf32004ef778) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/385d01a93004536ce28abfc8d99812dc43e08db035983a72d3d762a6754f29ce) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/0907edfda4ec6bb22618a9aa1d3ef106233edde64145f0c4e5ec0c3b14fcbf24) #### editor/inlineChatZoneWidget/InlineChatZoneWidgetTerminated/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/0752cf02ae3a4e21fce84b62859df32a5f41c13622bdec0083a3fd46832c2e0a)