make questions collapsible (#302870)

This commit is contained in:
Megan Rogge
2026-03-18 14:28:44 -04:00
committed by GitHub
parent dec2c92c47
commit 2c045d1130
4 changed files with 168 additions and 3 deletions

View File

@@ -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<string, IChatQuestionAnswerValue>();
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

View File

@@ -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;
}
}

View File

@@ -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[],

View File

@@ -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', () => {