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>
This commit is contained in:
Justin Chen
2026-08-26 05:28:40 +00:00
committed by GitHub
co-authored by copilot-swe-agent[bot]
parent d2eb83323f
commit d77fa1711f
33 changed files with 1531 additions and 246 deletions
+66 -27
View File
@@ -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);
@@ -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,
});
});
});
@@ -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;
@@ -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<AutomationIsolationGroupActionViewItem>());
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,
@@ -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,
[],
{
@@ -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);
}
@@ -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;
}
@@ -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) */
@@ -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,
@@ -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<HTMLElement, HTMLElement | undefined>();
const actionItemLabelCounts = new Map<HTMLElement, number>();
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<boolean>('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<boolean>;
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 {
@@ -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<ISessionTypePickerItem>(
@@ -506,7 +514,7 @@ export class SessionTypePicker extends Disposable {
false,
groupedItems,
delegate,
this._triggerElement,
anchor,
undefined,
[],
{
@@ -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), {
@@ -107,10 +107,6 @@ class TestSessionTypePicker extends SessionTypePicker {
pick(p: IPickedSessionType): void {
this._handleSelectedSessionType(p);
}
showPicker(): void {
this._showPicker();
}
}
function createPicker(
@@ -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<boolean>;
constructor(
action: MenuItemAction,
pickerOptions: IChatInputPickerOptions,
pickerOptions: IChatInputPickerOptions & { readonly compact: ISettableObservable<boolean> },
session: IObservable<IActiveSession | undefined>,
@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
@@ -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<boolean, void>(action, false),
listOptions: { minWidth: 255 },
};
} satisfies IChatInputPickerOptions;
return instantiationService.createInstance(
AgentHostPermissionPickerActionItem,
action,
@@ -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;
@@ -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)));
}
@@ -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;
}
@@ -37,6 +37,7 @@ export class AgentHostGenericConfigChips extends Disposable {
private _container: HTMLElement | undefined;
private readonly _chips = this._register(new DisposableMap<string>());
private readonly _chipElements = new Map<string, HTMLElement>();
/**
* 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);
},
});
}
@@ -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;
}
@@ -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;
}
@@ -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<string, ISettableObservable<boolean>>): IChatInputPickerResponsiveLayoutItem[] {
const items: IChatInputPickerResponsiveLayoutItem[] = [];
const visibleActionIds = new Set<string>();
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<ChatTodoListWidget>());
private readonly _chatArtifactsWidget = this._register(new MutableDisposable<ChatArtifactsWidget>());
private readonly _chatQuestionCarouselWidgets = this._register(new DisposableMap<string, ChatQuestionCarouselPart>());
@@ -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<IDisposable>());
private readonly overflowPickerWidget = this._register(new MutableDisposable<IDisposable>());
private sessionTargetWidget: SessionTypePickerActionItem | undefined;
private delegationWidget: DelegationSessionPickerActionItem | undefined;
private readonly chatSessionPickerWidgets = this._register(new DisposableMap<string, ChatSessionPickerActionItem>());
@@ -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<string, ISettableObservable<boolean>>();
const secondaryPickerCompactStates = new Map<string, ISettableObservable<boolean>>();
const inputOverflowPickerHandlers = new Map<string, (anchor: HTMLElement) => void>();
const secondaryOverflowPickerHandlers = new Map<string, (anchor: HTMLElement) => void>();
const getCompactState = (states: Map<string, ISettableObservable<boolean>>, actionId: string): ISettableObservable<boolean> => {
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<string, (anchor: HTMLElement) => 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<string>([
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<string, number>([
// 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<string, number>([
[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();
@@ -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');
@@ -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<IChatInputPickerResponsiveLayoutItem, number> } {
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<IChatInputPickerResponsiveLayoutItem, number>();
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,
});
}
}
@@ -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;
}
}
@@ -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 {
@@ -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 {
@@ -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));
@@ -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;
}
@@ -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;
}
@@ -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
@@ -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<void>(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,
});
});
});
@@ -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)