Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 32 additions & 17 deletions src/vs/platform/actionWidget/browser/actionList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ export interface IActionListItemHover {
readonly panelClassName?: string;
/** Align the panel's adjoining edge with the outer action widget rather than its inset list. */
readonly alignToParent?: boolean;
/** Align the panel's bottom with the parent popup, keeping content above its trigger. */
readonly alignToParentBottom?: boolean;
/** Keep the initial top edge as content or rows change, scrolling at the viewport edge. */
readonly preserveVerticalPosition?: boolean;
/** Align the panel's top edge with the row that opened it. */
Expand Down Expand Up @@ -165,7 +167,7 @@ export interface IActionListItem<T> {
* the chevron opens an inline submenu with these actions.
*/
readonly submenuActions?: IAction[];
/** When true, clicking the row opens its submenu instead of selecting the item. */
/** Open a submenu or expandable hover on row activation instead of selecting the item. */
readonly openSubmenuOnClick?: boolean;
/** Options for the action list rendered in the nested submenu panel. */
readonly submenuOptions?: IActionListOptions;
Expand Down Expand Up @@ -2303,11 +2305,15 @@ export class ActionListWidget<T> extends Disposable {
return;
}
}
if (element.openSubmenuOnClick && element.submenuActions?.length && (isPointerActivation || e.browserEvent instanceof AcceptSelectedEvent)) {
if (element.openSubmenuOnClick && (element.submenuActions?.length || element.hover?.expandable) && (isPointerActivation || e.browserEvent instanceof AcceptSelectedEvent)) {
this._list.setSelection([]);
this._showSubmenuForItem(element);
if (!isPointerActivation) {
this._currentSubmenuWidget?.focus();
if (this._currentSubmenuWidget) {
this._currentSubmenuWidget.focus();
} else {
this._submenuContainer.focus();
}
}
return;
}
Expand Down Expand Up @@ -2570,7 +2576,7 @@ export class ActionListWidget<T> extends Disposable {

const preserveVerticalPosition = element.hover?.preserveVerticalPosition;
const hasSubmenuActions = !!element.submenuActions?.length;
const scrollableContent = preserveVerticalPosition || (!hasSubmenuActions && !this._options?.persistentHover && !element.hover?.alignToParent);
const scrollableContent = preserveVerticalPosition || element.hover?.alignToParentBottom || (!hasSubmenuActions && !this._options?.persistentHover && !element.hover?.alignToParent);
const content = scrollableContent ? dom.$('.action-list-submenu-content') : this._submenuContainer;
const viewport = scrollableContent ? dom.$('.action-list-submenu-viewport', undefined, content) : undefined;
const scrollbar = viewport && !hasSubmenuActions ? this._submenuDisposables.add(new DomScrollableElement(viewport, {
Expand Down Expand Up @@ -2777,7 +2783,7 @@ export class ActionListWidget<T> extends Disposable {
}
row?.setAttribute('aria-expanded', 'true');
const parentRect = this.domNode.getBoundingClientRect();
const alignToParent = persistent || currentElement.hover?.alignToParent;
const alignToParent = persistent || currentElement.hover?.alignToParent || currentElement.hover?.alignToParentBottom;
const edgeRect = alignToParent
? this.domNode.parentElement?.closest('.action-widget')?.getBoundingClientRect() ?? parentRect
: parentRect;
Expand Down Expand Up @@ -2845,13 +2851,16 @@ export class ActionListWidget<T> extends Disposable {
openingPanelHeight ??= panelHeight / zoom;
}
const anchorHeight = openingPanelHeight !== undefined ? openingPanelHeight * zoom : panelHeight;
let top = openingPanelTop !== undefined
? openingPanelTop * zoom
: row && currentElement.hover?.alignToAnchorTop
? anchorRect.top - parentRect.top
: row
? anchorRect.top - parentRect.top + (anchorRect.height - anchorHeight) / 2
: panelRect.top - parentRect.top;
const bottomBoundary = currentElement.hover?.alignToParentBottom ? Math.min(edgeRect.bottom, targetWindow.innerHeight) : targetWindow.innerHeight;
let top = currentElement.hover?.alignToParentBottom
? bottomBoundary - parentRect.top - anchorHeight
: openingPanelTop !== undefined
? openingPanelTop * zoom
: row && currentElement.hover?.alignToAnchorTop
? anchorRect.top - parentRect.top
: row
? anchorRect.top - parentRect.top + (anchorRect.height - anchorHeight) / 2
: panelRect.top - parentRect.top;
if (preserveVerticalPosition && currentElement.hover?.alignToAnchorTop && viewport && submenuWidget) {
const outerChromeHeight = panelRect.height - viewport.getBoundingClientRect().height;
const submenuChromeHeight = (submenuWidget.headerContainer?.offsetHeight ?? 0)
Expand All @@ -2861,8 +2870,8 @@ export class ActionListWidget<T> extends Disposable {
top = Math.min(top, targetWindow.innerHeight - parentRect.top - desiredPanelHeight - 8);
}
const panelBottom = parentRect.top + top + anchorHeight;
if (panelBottom > targetWindow.innerHeight && !(preserveVerticalPosition && currentElement.hover?.alignToAnchorTop)) {
top -= panelBottom - targetWindow.innerHeight + 8;
if (panelBottom > bottomBoundary && !(preserveVerticalPosition && currentElement.hover?.alignToAnchorTop)) {
top -= panelBottom - bottomBoundary + 8;
}
if (parentRect.top + top < 0) {
top = -parentRect.top;
Expand All @@ -2871,29 +2880,35 @@ export class ActionListWidget<T> extends Disposable {
if (preserveVerticalPosition) {
openingPanelTop ??= top / zoom;
}
const availablePanelHeight = currentElement.hover?.alignToParentBottom
? bottomBoundary - 8
: targetWindow.innerHeight - parentRect.top - top - 8;
if (viewport && scrollbar) {
const chromeHeight = (panelRect.height - scrollbar.getDomNode().getBoundingClientRect().height) / zoom;
const availableHeight = Math.max(0, (targetWindow.innerHeight - parentRect.top - top - 8) / zoom - chromeHeight);
const availableHeight = Math.max(0, availablePanelHeight / zoom - chromeHeight);
viewport.style.height = `${Math.min(content.getBoundingClientRect().height / zoom, availableHeight)}px`;
scrollbar.scanDomNode();
} else if (viewport && submenuWidget) {
const chromeHeight = (panelRect.height - viewport.getBoundingClientRect().height) / zoom;
const availableHeight = Math.max(0, (targetWindow.innerHeight - parentRect.top - top - 8) / zoom - chromeHeight);
const availableHeight = Math.max(0, availablePanelHeight / zoom - chromeHeight);
const submenuChromeHeight = (submenuWidget.headerContainer?.offsetHeight ?? 0)
+ (submenuWidget.filterContainer?.offsetHeight ?? 0)
+ (submenuWidget.footerContainer?.offsetHeight ?? 0);
const submenuHeight = totalHeight === 0 ? 0 : Math.max(this._actionLineHeight, Math.min(totalHeight, availableHeight - submenuChromeHeight));
submenuWidget.layout(submenuHeight, maxWidth);
viewport.style.height = `${submenuChromeHeight + submenuHeight}px`;
}
if (currentElement.hover?.alignToParentBottom) {
top = Math.max(0, bottomBoundary - this._submenuContainer.getBoundingClientRect().height) - parentRect.top;
}
this._submenuContainer.style.top = `${top / zoom}px`;
};
this._layoutSubmenu = layout;
layout();
// tabThroughPanel content (e.g. a GitHub reference hover) can grow when
// focus reveals bounded text, in which case the panel must reposition
// itself, not just the row that measured it before the content changed.
if ((this._options?.persistentHover || element.hover?.alignToParent || element.hover?.tabThroughPanel || preserveVerticalPosition) && this._currentSubmenuElement === element) {
if ((this._options?.persistentHover || element.hover?.alignToParent || element.hover?.tabThroughPanel || preserveVerticalPosition || element.hover?.alignToParentBottom) && this._currentSubmenuElement === element) {
if (!submenuWidget) {
const scheduledLayout = this._submenuDisposables.add(new MutableDisposable());
const observer = this._submenuDisposables.add(new dom.DisposableResizeObserver('ActionListWidget.hoverPanel', () => {
Expand Down
84 changes: 84 additions & 0 deletions src/vs/platform/actionWidget/test/browser/actionList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,90 @@ suite('ActionListWidget', () => {
});
}

for (const activation of ['click', 'tap', 'keyboard'] as const) {
test(`${activation} opens opted-in live details and preserves them through refresh`, () => {
const selected: string[] = [];
const content = document.createElement('div');
content.textContent = 'Running, Attached, 1s';
const item = (): IActionListItem<ITestActionItem> => ({
...action('shell'),
hover: { content, expandable: true },
openSubmenuOnClick: true,
});
const widget = createActionListWidget(disposables, {
items: [item()],
onSelect: entry => selected.push(entry.id),
listOptions: { showFilter: false },
});
widget.focus();
const row = widget.domNode.querySelector<HTMLElement>('.monaco-list-row')!;
if (activation === 'keyboard') {
widget.acceptSelected();
} else if (activation === 'tap') {
row.dispatchEvent(Object.assign(new CustomEvent(TouchEventType.Tap, { bubbles: true }), { initialTarget: row }));
} else {
row.click();
}
const panel = widget.domNode.querySelector<HTMLElement>('.action-list-submenu-panel')!;
const opened = panel.style.display !== 'none' && panel.contains(content);
const keyboardFocused = activation !== 'keyboard' || document.activeElement === panel;
content.textContent = 'Running, Attached, 2s';
widget.updateItems([item()], undefined, { preserveHover: true });
const retained = panel.style.display !== 'none' && panel.contains(content);
const elapsed = panel.textContent?.includes('Running, Attached, 2s');
widget.updateItems([], undefined, { preserveHover: true });

assert.deepStrictEqual({ selected, opened, keyboardFocused, retained, elapsed, closedOnCompletion: panel.style.display === 'none' }, {
selected: [], opened: true, keyboardFocused: true, retained: true, elapsed: true, closedOnCompletion: true,
});
});
}

for (const zoom of [1, 1.25]) {
for (const contentHeight of [80, 800]) {
test(`bottom-aligned details remain above the input boundary at ${zoom} zoom with ${contentHeight}px content`, async () => {
const content = document.createElement('div');
content.style.cssText = `width: 200px; height: ${contentHeight}px;`;
content.textContent = 'Background shell details';
const item = (): IActionListItem<ITestActionItem> => ({
...action('shell'),
hover: { content, expandable: true, alignToParentBottom: true },
openSubmenuOnClick: true,
});
const widget = createActionListWidget(disposables, {
items: [item()],
listOptions: { showFilter: false },
});
const popup = document.createElement('div');
popup.className = 'action-widget';
popup.style.cssText = `position: fixed; top: 160px; left: 40px; zoom: ${zoom};`;
document.body.appendChild(popup);
disposables.add({ dispose: () => popup.remove() });
popup.appendChild(widget.domNode);
widget.layout(24, 240);
widget.focus();
widget.acceptSelected();
await settleLayout();
const panel = widget.domNode.querySelector<HTMLElement>('.action-list-submenu-panel')!;
const initial = panel.getBoundingClientRect();
for (let i = 0; i < 3; i++) {
widget.updateItems([item()], undefined, { preserveHover: true });
await settleLayout();
}
const updated = panel.getBoundingClientRect();
const bottom = popup.getBoundingClientRect().bottom;

assert.deepStrictEqual({
initialAbove: initial.bottom <= bottom + 1,
updatedAbove: updated.bottom <= bottom + 1,
withinViewport: updated.top >= -1,
stableHeight: Math.abs(updated.height - initial.height) < 1,
visible: updated.height > 0,
}, { initialAbove: true, updatedAbove: true, withinViewport: true, stableHeight: true, visible: true });
});
}
}

test('keyboard activation on an opted-in submenu row focuses its filter without selecting it', () => {
const selected: string[] = [];
const widget = createActionListWidget(disposables, {
Expand Down
2 changes: 2 additions & 0 deletions src/vs/platform/agentHost/common/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1345,6 +1345,8 @@ export interface IAgent {
readonly onDidChangeChatHistory?: Event<IAgentChatHistoryChange>;
/** Observe another client's persisted transcript while a host client subscribes to this chat. */
watchChatHistory?(chat: URI): IDisposable;
/** Refresh background shell inventory while a client observes an already-hydrated chat. */
watchChatBackgroundShells?(chat: URI): IDisposable;

/** Starts provider-owned native chat discovery; repeated calls are idempotent. */
startChatDiscovery?(): Promise<void>;
Expand Down
Loading
Loading