From 2dbf97f311e1b183e6efe411050737c4ed997303 Mon Sep 17 00:00:00 2001 From: Ulugbek Abdullaev Date: Fri, 24 Jul 2026 15:00:29 +0500 Subject: [PATCH] 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 --- src/vs/sessions/AI_CUSTOMIZATIONS.md | 2 + .../aiCustomizationManagementEditor.ts | 26 ++++++++- .../aiCustomization/automationsListWidget.ts | 57 ++++++++++++++----- .../aiCustomizationManagementEditor.test.ts | 35 +++++++++++- .../automations/automationsListWidget.test.ts | 22 +++++++ 5 files changed, 123 insertions(+), 19 deletions(-) diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index b6708f9575c..6fbdd080e1a 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -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: diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 1aa335c87ae..0545b2d6942 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -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; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/automationsListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/automationsListWidget.ts index 16f551feaf8..2bea92c2515 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/automationsListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/automationsListWidget.ts @@ -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 { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts index 66a0fa77841..4e25acc0c0c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts @@ -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 = {}): 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(); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/automations/automationsListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/automations/automationsListWidget.test.ts index 00f9581d6aa..e09488e7171 100644 --- a/src/vs/workbench/contrib/chat/test/browser/automations/automationsListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/automations/automationsListWidget.test.ts @@ -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 });