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:
Ulugbek Abdullaev
2026-07-24 15:00:29 +05:00
committed by GitHub
parent a9a72955ef
commit 2dbf97f311
5 changed files with 123 additions and 19 deletions
+2
View File
@@ -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:
@@ -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 {
@@ -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 });