mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-15 10:15:04 +01:00
automations: UI: fix hidden Automations list layout (#327254)
Defer dynamic-height list updates while the Automations section is hidden and remeasure rows when it becomes visible. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b4bb084c-7e34-4dcc-99de-0ad3b864fa59
This commit is contained in:
@@ -81,6 +81,8 @@ Manual automation runs announce that they started once session dispatch commits,
|
||||
|
||||
Automations use a discriminated target that is either workspace-backed or a workspace-less quick chat. The workspace dropdown owns both choices: selecting **No workspace** switches to the existing quick-chat provider/session-type catalog, while selecting a folder restores repository configuration. Workspace-less targets display and announce as `without a workspace` in the list and cannot carry folder, isolation, or branch configuration; workspace-backed targets require a folder, with Worktree isolation requiring its base branch. Ledger schema v3 persists this target union and migrates schema-v1/v2 flat records while preserving valid workspace-backed targets.
|
||||
|
||||
Automation rows use dynamic heights. The management editor propagates both editor-pane and section visibility to the Automations widget; while hidden, the widget updates its view model but defers list splices and layout. Revealing the section commits the latest entries and forces a fresh row measurement so `display:none` cannot cache zero-height rows.
|
||||
|
||||
### IAICustomizationWorkspaceService
|
||||
|
||||
The `IAICustomizationWorkspaceService` interface controls per-window behavior:
|
||||
|
||||
+23
-3
@@ -1713,9 +1713,7 @@ export class AICustomizationManagementEditor extends EditorPane {
|
||||
if (this.pluginContentContainer) {
|
||||
this.pluginContentContainer.style.display = !isEditorMode && !isMigrationMode && !isDetailMode && isPluginsSection ? '' : 'none';
|
||||
}
|
||||
if (this.automationsContentContainer) {
|
||||
this.automationsContentContainer.style.display = !isEditorMode && !isMigrationMode && !isDetailMode && isAutomationsSection ? '' : 'none';
|
||||
}
|
||||
this.updateAutomationsContentVisibility(!isEditorMode && !isMigrationMode && !isDetailMode && isAutomationsSection);
|
||||
if (this.pluginDetailContainer) {
|
||||
this.pluginDetailContainer.style.display = isPluginDetailMode ? '' : 'none';
|
||||
}
|
||||
@@ -1738,6 +1736,20 @@ export class AICustomizationManagementEditor extends EditorPane {
|
||||
}
|
||||
}
|
||||
|
||||
private updateAutomationsContentVisibility(sectionVisible: boolean): void {
|
||||
if (!this.automationsContentContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sectionVisible) {
|
||||
this.automationsContentContainer.style.display = '';
|
||||
this.automationsListWidget?.setVisible(this.isVisible());
|
||||
} else {
|
||||
this.automationsListWidget?.setVisible(false);
|
||||
this.automationsContentContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new customization using the AI-guided flow.
|
||||
*/
|
||||
@@ -1910,6 +1922,14 @@ export class AICustomizationManagementEditor extends EditorPane {
|
||||
super.clearInput();
|
||||
}
|
||||
|
||||
protected override setEditorVisible(visible: boolean): void {
|
||||
super.setEditorVisible(visible);
|
||||
this.updateAutomationsContentVisibility(this.viewMode === 'list' && this.selectedSection === AICustomizationManagementSection.Automations);
|
||||
if (visible && this.dimension) {
|
||||
this.layout(this.dimension);
|
||||
}
|
||||
}
|
||||
|
||||
override layout(dimension: DOM.Dimension): void {
|
||||
this.dimension = dimension;
|
||||
|
||||
|
||||
@@ -374,8 +374,9 @@ export class AutomationsListWidget extends Disposable {
|
||||
|
||||
private lastHeight = 0;
|
||||
private lastWidth = 0;
|
||||
private _layoutDeferred = false;
|
||||
private readonly _layoutRAF = this._register(new MutableDisposable());
|
||||
private visible = false;
|
||||
private listDirty = false;
|
||||
private remeasureOnNextLayout = false;
|
||||
|
||||
constructor(
|
||||
@IAutomationService private readonly automationService: IAutomationService,
|
||||
@@ -465,11 +466,12 @@ export class AutomationsListWidget extends Disposable {
|
||||
private updateList(items: readonly IAutomation[]): void {
|
||||
if (items.length === 0) {
|
||||
this.element.classList.add('automations-empty');
|
||||
this.displayEntries = [];
|
||||
this.listDirty = true;
|
||||
this.commitList();
|
||||
this.emptyContainer.style.display = '';
|
||||
this.listContainer.style.display = 'none';
|
||||
this.renderEmptyState();
|
||||
this.displayEntries = [];
|
||||
this.list.splice(0, this.list.length, []);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -486,7 +488,20 @@ export class AutomationsListWidget extends Disposable {
|
||||
inFlight: this.runInFlight.has(automation.id),
|
||||
}));
|
||||
|
||||
this.listDirty = true;
|
||||
this.commitList();
|
||||
if (this.visible && this.lastHeight > 0 && this.lastWidth > 0) {
|
||||
this.layout(this.lastHeight, this.lastWidth);
|
||||
}
|
||||
}
|
||||
|
||||
private commitList(): void {
|
||||
if (!this.visible || !this.listDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.list.splice(0, this.list.length, this.displayEntries);
|
||||
this.listDirty = false;
|
||||
}
|
||||
|
||||
private renderEmptyState(): void {
|
||||
@@ -677,24 +692,36 @@ export class AutomationsListWidget extends Disposable {
|
||||
|
||||
this.element.style.height = `${height}px`;
|
||||
|
||||
// Measure the header to calculate the list height.
|
||||
// When offsetHeight returns 0 the container may have just become visible
|
||||
// after display:none and the browser hasn't reflowed yet. Defer layout
|
||||
// once so measurements are accurate. Only retry once to avoid an endless
|
||||
// loop when the widget is created while permanently hidden.
|
||||
if (!this.visible || this.displayEntries.length === 0 || height <= 0 || width <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headerHeight = this.headerEl.offsetHeight;
|
||||
if (headerHeight === 0 && !this._layoutDeferred) {
|
||||
this._layoutDeferred = true;
|
||||
this._layoutRAF.value = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.element), () => {
|
||||
this._layoutDeferred = false;
|
||||
this.layout(this.lastHeight, this.lastWidth);
|
||||
});
|
||||
if (headerHeight === 0) {
|
||||
return;
|
||||
}
|
||||
const listHeight = Math.max(0, height - headerHeight);
|
||||
|
||||
this.listContainer.style.height = `${listHeight}px`;
|
||||
this.list.layout(listHeight, width);
|
||||
if (this.remeasureOnNextLayout) {
|
||||
this.remeasureOnNextLayout = false;
|
||||
this.list.rerender();
|
||||
}
|
||||
}
|
||||
|
||||
setVisible(visible: boolean): void {
|
||||
if (this.visible === visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.visible = visible;
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.commitList();
|
||||
this.remeasureOnNextLayout = this.list.length > 0;
|
||||
}
|
||||
|
||||
fireItemCount(): void {
|
||||
|
||||
+34
-1
@@ -15,7 +15,7 @@ import { ChatConfiguration } from '../../../common/constants.js';
|
||||
import { IPromptPath, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js';
|
||||
import { IHeaderAttribute } from '../../../common/promptSyntax/promptFileParser.js';
|
||||
import { PromptsType, Target } from '../../../common/promptSyntax/promptTypes.js';
|
||||
import { AICustomizationSources } from '../../../common/aiCustomizationWorkspaceService.js';
|
||||
import { AICustomizationManagementSection, AICustomizationSources } from '../../../common/aiCustomizationWorkspaceService.js';
|
||||
|
||||
suite('aiCustomizationManagementEditor', () => {
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
@@ -34,11 +34,16 @@ suite('aiCustomizationManagementEditor', () => {
|
||||
hoverService: IHoverService;
|
||||
configurationService: IConfigurationService;
|
||||
welcomePage: { setPromptMigrationInfo(info: unknown): void } | undefined;
|
||||
selectedSection: AICustomizationManagementSection | undefined;
|
||||
automationsContentContainer: HTMLElement | undefined;
|
||||
automationsListWidget: { setVisible(visible: boolean): void } | undefined;
|
||||
getEditorModeButtonLabel(): string;
|
||||
getEditorModeButtonTooltip(): string;
|
||||
renderPreviewAttribute(attribute: IHeaderAttribute, promptType: PromptsType, target: Target): void;
|
||||
onStructuredPreviewSettingChanged(): void;
|
||||
refreshPromptMigrationUi(): void;
|
||||
updateContentVisibility(): void;
|
||||
setVisible(visible: boolean): void;
|
||||
};
|
||||
|
||||
function createConfigurationServiceStub(values: Record<string, unknown> = {}): IConfigurationService {
|
||||
@@ -84,6 +89,10 @@ suite('aiCustomizationManagementEditor', () => {
|
||||
};
|
||||
editor.viewMode = 'list';
|
||||
editor.dimension = undefined;
|
||||
editor.selectedSection = undefined;
|
||||
editor.automationsContentContainer = undefined;
|
||||
editor.automationsListWidget = undefined;
|
||||
editor.setVisible(false);
|
||||
return editor;
|
||||
}
|
||||
|
||||
@@ -211,4 +220,28 @@ suite('aiCustomizationManagementEditor', () => {
|
||||
assert.deepStrictEqual(welcomePageCalls, [undefined]);
|
||||
editor.editorPreviewDisposables.dispose();
|
||||
});
|
||||
|
||||
test('propagates editor and section visibility to the Automations widget', () => {
|
||||
const visibility: boolean[] = [];
|
||||
const editor = createTestEditor();
|
||||
editor.selectedSection = AICustomizationManagementSection.Automations;
|
||||
editor.automationsContentContainer = document.createElement('div');
|
||||
editor.automationsListWidget = {
|
||||
setVisible: visible => visibility.push(visible),
|
||||
};
|
||||
|
||||
editor.updateContentVisibility();
|
||||
editor.setVisible(true);
|
||||
editor.selectedSection = AICustomizationManagementSection.Agents;
|
||||
editor.updateContentVisibility();
|
||||
|
||||
assert.deepStrictEqual({
|
||||
visibility,
|
||||
display: editor.automationsContentContainer.style.display,
|
||||
}, {
|
||||
visibility: [false, true, false],
|
||||
display: 'none',
|
||||
});
|
||||
editor.editorPreviewDisposables.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -265,6 +265,7 @@ suite('AutomationsListWidget', () => {
|
||||
instantiation.stub(IConfigurationService, configService);
|
||||
|
||||
const widget = teardown.add(instantiation.createInstance(AutomationsListWidget));
|
||||
widget.setVisible(true);
|
||||
return { widget, service, runner, dialog, workspace, configService, automationDialogService };
|
||||
}
|
||||
|
||||
@@ -295,6 +296,27 @@ suite('AutomationsListWidget', () => {
|
||||
assert.deepStrictEqual(names, ['First', 'Second']);
|
||||
});
|
||||
|
||||
test('defers list updates while hidden and commits the latest entries when shown', async () => {
|
||||
const { widget, service } = setup();
|
||||
await service.createAutomation({ name: 'First', prompt: 'p1', schedule: hourly(), target: workspaceTarget() });
|
||||
|
||||
widget.setVisible(false);
|
||||
await service.createAutomation({ name: 'Second', prompt: 'p2', schedule: hourly(), target: workspaceTarget() });
|
||||
const committedItemCountWhileHidden = widget.itemCount;
|
||||
|
||||
widget.setVisible(true);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
committedItemCountWhileHidden,
|
||||
visibleItemCount: widget.itemCount,
|
||||
names: widget.getDisplayEntriesForTest().map(entry => entry.automation.name),
|
||||
}, {
|
||||
committedItemCountWhileHidden: 1,
|
||||
visibleItemCount: 2,
|
||||
names: ['Second', 'First'],
|
||||
});
|
||||
});
|
||||
|
||||
test('disabled automations surface in the view-model as not enabled', async () => {
|
||||
const { widget, service } = setup();
|
||||
await service.createAutomation({ name: 'D', prompt: 'p', schedule: hourly(), target: workspaceTarget(), enabled: false });
|
||||
|
||||
Reference in New Issue
Block a user