From 2c045d1130cce43fadbaec4541962b75deea151d Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 18 Mar 2026 14:28:44 -0400 Subject: [PATCH] make questions collapsible (#302870) --- .../chatQuestionCarouselPart.ts | 58 +++++++++++++- .../media/chatQuestionCarousel.css | 36 +++++++++ .../chatQuestionCarouselData.ts | 1 + .../chatQuestionCarouselPart.test.ts | 76 +++++++++++++++++++ 4 files changed, 168 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index 704052557be..2105d647822 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -11,6 +11,7 @@ import { IMarkdownString, MarkdownString, isMarkdownString } from '../../../../. import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { isMacintosh } from '../../../../../../base/common/platform.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; import { hasKey } from '../../../../../../base/common/types.js'; import { localize } from '../../../../../../nls.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; @@ -49,13 +50,16 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private _currentIndex = 0; private readonly _answers = new Map(); + private _isCollapsed = false; private _questionContainer: HTMLElement | undefined; + private _headerActionsContainer: HTMLElement | undefined; private _closeButtonContainer: HTMLElement | undefined; private _footerRow: HTMLElement | undefined; private _stepIndicator: HTMLElement | undefined; private _submitHint: HTMLElement | undefined; private _submitButton: Button | undefined; + private _collapseButton: Button | undefined; private _prevButton: Button | undefined; private _nextButton: Button | undefined; private _skipAllButton: Button | undefined; @@ -92,6 +96,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent super(); this.domNode = dom.$('.chat-question-carousel-container'); + this.domNode.id = generateUuid(); this._inChatQuestionCarouselContextKey = ChatContextKeys.inChatQuestionCarousel.bindTo(this._contextKeyService); const focusTracker = this._register(dom.trackFocus(this.domNode)); this._register(focusTracker.onDidFocus(() => this._inChatQuestionCarouselContextKey.set(true))); @@ -110,6 +115,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._currentIndex = Math.max(0, Math.min(carousel.draftCurrentIndex, carousel.questions.length - 1)); } + if (typeof carousel.draftCollapsed === 'boolean') { + this._isCollapsed = carousel.draftCollapsed; + } + if (carousel.draftAnswers) { for (const [key, value] of Object.entries(carousel.draftAnswers)) { this._answers.set(key, value); @@ -141,6 +150,13 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Question container this._questionContainer = dom.$('.chat-question-carousel-content'); this.domNode.append(this._questionContainer); + this._headerActionsContainer = dom.$('.chat-question-header-actions'); + + const collapseToggleTitle = localize('chat.questionCarousel.collapseTitle', 'Collapse Questions'); + const collapseButton = interactiveStore.add(new Button(this._headerActionsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + collapseButton.element.classList.add('chat-question-collapse-toggle'); + collapseButton.element.setAttribute('aria-label', collapseToggleTitle); + this._collapseButton = collapseButton; // Close/skip button (X) - placed in header row, only shown when allowSkip is true if (carousel.allowSkip) { @@ -155,6 +171,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } // Register event listeners + interactiveStore.add(collapseButton.onDidClick(() => this.toggleCollapsed())); + if (this._skipAllButton) { interactiveStore.add(this._skipAllButton.onDidClick(() => this.ignore())); } @@ -224,6 +242,31 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this.carousel.draftAnswers = Object.fromEntries(this._answers.entries()); this.carousel.draftCurrentIndex = this._currentIndex; + this.carousel.draftCollapsed = this._isCollapsed; + } + + private toggleCollapsed(): void { + this._isCollapsed = !this._isCollapsed; + this.persistDraftState(); + this.updateCollapsedPresentation(); + this._onDidChangeHeight.fire(); + } + + private updateCollapsedPresentation(): void { + this.domNode.classList.toggle('chat-question-carousel-collapsed', this._isCollapsed); + + if (this._collapseButton) { + const collapsed = this._isCollapsed; + const buttonTitle = collapsed + ? localize('chat.questionCarousel.expandTitle', 'Expand Questions') + : localize('chat.questionCarousel.collapseTitle', 'Collapse Questions'); + const contentId = this.domNode.id; + this._collapseButton.label = collapsed ? `$(${Codicon.chevronUp.id})` : `$(${Codicon.chevronDown.id})`; + this._collapseButton.element.setAttribute('aria-label', buttonTitle); + this._collapseButton.element.setAttribute('aria-expanded', String(!collapsed)); + this._collapseButton.element.setAttribute('aria-controls', contentId); + this._collapseButton.setTitle(buttonTitle); + } } /** @@ -335,7 +378,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._submitButton = undefined; this._skipAllButton = undefined; this._questionContainer = undefined; + this._headerActionsContainer = undefined; this._closeButtonContainer = undefined; + this._collapseButton = undefined; this._footerRow = undefined; this._stepIndicator = undefined; this._submitHint = undefined; @@ -609,9 +654,15 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent headerRow.appendChild(titleRow); - // Always keep the close button in the title row so it does not overlap content. - if (this._closeButtonContainer) { - titleRow.appendChild(this._closeButtonContainer); + if (this._headerActionsContainer) { + dom.clearNode(this._headerActionsContainer); + if (this._closeButtonContainer) { + this._headerActionsContainer.appendChild(this._closeButtonContainer); + } + if (this._collapseButton) { + this._headerActionsContainer.appendChild(this._collapseButton.element); + } + titleRow.appendChild(this._headerActionsContainer); } this._questionContainer.appendChild(headerRow); @@ -680,6 +731,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Update aria-label to reflect the current question this._updateAriaLabel(); + this.updateCollapsedPresentation(); // In screen reader mode, focus the container and announce the question // This must happen after all render calls to avoid focus being stolen diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 45e5b303c2e..3ae3f7dccea 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -99,6 +99,13 @@ } } + .chat-question-header-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + } + .chat-question-close-container { flex-shrink: 0; @@ -117,6 +124,35 @@ background: var(--vscode-toolbar-hoverBackground) !important; } } + + .monaco-button.chat-question-collapse-toggle { + min-width: 22px; + width: 22px; + height: 22px; + padding: 0; + border: none !important; + box-shadow: none !important; + background: transparent !important; + color: var(--vscode-icon-foreground) !important; + } + + .monaco-button.chat-question-collapse-toggle:hover:not(.disabled) { + background: var(--vscode-toolbar-hoverBackground) !important; + } + } +} + +.interactive-session .chat-question-carousel-container.chat-question-carousel-collapsed { + .chat-question-carousel-content { + .chat-question-description, + .chat-question-input-scrollable, + .chat-question-validation-message { + display: none; + } + } + + .chat-question-footer-row { + display: none; } } diff --git a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts index 5feb507b755..7cc5a5425ff 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts @@ -18,6 +18,7 @@ export class ChatQuestionCarouselData implements IChatQuestionCarousel { public readonly completion = new DeferredPromise<{ answers: IChatQuestionAnswers | undefined }>(); public draftAnswers: IChatQuestionAnswers | undefined; public draftCurrentIndex: number | undefined; + public draftCollapsed: boolean | undefined; constructor( public questions: IChatQuestion[], diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts index 7bc6a6c0412..6362d8764f2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts @@ -149,6 +149,82 @@ suite('ChatQuestionCarouselPart', () => { const directChildCloseContainer = widget.domNode.querySelector(':scope > .chat-question-close-container'); assert.strictEqual(directChildCloseContainer, null, 'close button container should not be positioned as a direct child of the carousel container'); }); + + test('renders collapse button in title row even when skip is disabled', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Question 1' } + ], false); + createWidget(carousel); + + const titleRow = widget.domNode.querySelector('.chat-question-title-row'); + assert.ok(titleRow, 'title row should exist'); + + const collapseButton = titleRow?.querySelector('.chat-question-collapse-toggle'); + assert.ok(collapseButton, 'collapse button should be rendered even when skip is disabled'); + }); + + test('renders collapse button to the right of close button', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Question 1' }, + { id: 'q2', type: 'text', title: 'Question 2' } + ], true); + createWidget(carousel); + + const actionsContainer = widget.domNode.querySelector('.chat-question-header-actions'); + assert.ok(actionsContainer, 'actions container should exist'); + if (!actionsContainer) { + return; + } + + const actionButtons = Array.from(actionsContainer.querySelectorAll('.monaco-button')); + const closeIndex = actionButtons.findIndex(button => button.classList.contains('chat-question-close')); + const collapseIndex = actionButtons.findIndex(button => button.classList.contains('chat-question-collapse-toggle')); + + assert.ok(closeIndex >= 0, 'close button should exist'); + assert.ok(collapseIndex >= 0, 'collapse button should exist'); + assert.ok(collapseIndex > closeIndex, 'collapse button should be positioned to the right of close button'); + }); + + test('toggles collapsed state and updates aria-expanded', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Question 1' }, + { id: 'q2', type: 'text', title: 'Question 2' } + ], true); + createWidget(carousel); + + const collapseButton = widget.domNode.querySelector('.chat-question-collapse-toggle') as HTMLElement; + assert.ok(collapseButton, 'collapse button should exist'); + assert.strictEqual(collapseButton.getAttribute('aria-expanded'), 'true'); + + collapseButton.click(); + assert.ok(widget.domNode.classList.contains('chat-question-carousel-collapsed'), 'widget should enter collapsed state'); + assert.strictEqual(collapseButton.getAttribute('aria-expanded'), 'false'); + const collapsedSummary = widget.domNode.querySelector('.chat-question-collapsed-summary'); + assert.strictEqual(collapsedSummary, null, 'collapsed mode should not render an additional summary section'); + + const titleRow = widget.domNode.querySelector('.chat-question-title-row'); + assert.ok(titleRow, 'header should remain visible when collapsed'); + + const inputScrollable = widget.domNode.querySelector('.chat-question-input-scrollable'); + assert.ok(inputScrollable, 'input section exists in DOM but is hidden while collapsed'); + + collapseButton.click(); + assert.ok(!widget.domNode.classList.contains('chat-question-carousel-collapsed'), 'widget should exit collapsed state'); + assert.strictEqual(collapseButton.getAttribute('aria-expanded'), 'true'); + }); + + test('restores draft collapsed state from carousel data', () => { + const carousel = new ChatQuestionCarouselData([ + { id: 'q1', type: 'text', title: 'Question 1' }, + { id: 'q2', type: 'text', title: 'Question 2' } + ], true); + carousel.draftCollapsed = true; + createWidget(carousel); + + assert.ok(widget.domNode.classList.contains('chat-question-carousel-collapsed'), 'widget should restore collapsed draft state'); + const collapseButton = widget.domNode.querySelector('.chat-question-collapse-toggle'); + assert.strictEqual(collapseButton?.getAttribute('aria-expanded'), 'false'); + }); }); suite('Question Types', () => {