Question carousel UI polish (#299272)

* Polish question carousel: simplify title bar, footer nav, and layout

* Question carousel UI polish

- Border radius matches chat input (cornerRadius-large)
- Background uses panel background
- Remove colon prefix from option descriptions
- Option list items use cornerRadius-medium
- Footer padding: 8px left, 16px right
- 12px gap between number and labels
- Freeform row aligned with preset options
- Close button vertically centered in titlebar
- Checkboxes center-aligned in list items
- has-description class for title+description items
- Number elements use consistent width
- Focus outline consistent across all list items
- Tighter gap between presets and custom answer
- Summary Q/A always on separate rows
- Hide submit icon when carousel is open (show stop only)
- Show submit when user types to steer

* Add close button to single-question carousel title row

* Add submit footer for single-question multi-select carousels

* Align single-question submit footer to the right with hint

* Fix failing carousel unit tests

Update test selectors and structure to match current DOM:
- Remove .chat-question-carousel-nav assertion (element no longer exists)
- Update markdown/message tests to use .chat-question-title
- Fix nav button tests to use multi-question carousels with .chat-question-nav-arrow
- Fix submit button test to use multi-question carousel

* Fix chat question carousel navigation and summary test regressions
This commit is contained in:
David Dossett
2026-03-04 13:52:19 -08:00
committed by GitHub
parent ffe529eced
commit a1bbcb5581
4 changed files with 348 additions and 327 deletions

View File

@@ -192,7 +192,11 @@ const requestInProgressWithoutInput = ContextKeyExpr.and(
);
const pendingToolCall = ContextKeyExpr.or(
ChatContextKeys.Editing.hasToolConfirmation,
ChatContextKeys.Editing.hasQuestionCarousel,
ContextKeyExpr.and(ChatContextKeys.Editing.hasQuestionCarousel, ChatContextKeys.inputHasText.negate()),
);
const noQuestionCarouselOrHasInput = ContextKeyExpr.or(
ChatContextKeys.Editing.hasQuestionCarousel.negate(),
ChatContextKeys.inputHasText,
);
const whenNotInProgress = ChatContextKeys.requestInProgress.negate();
@@ -235,6 +239,7 @@ export class ChatSubmitAction extends SubmitAction {
whenNotInProgress,
menuCondition,
ChatContextKeys.withinEditSessionDiff.negate(),
noQuestionCarouselOrHasInput,
),
group: 'navigation',
alt: {
@@ -762,7 +767,8 @@ export class ChatEditingSessionSubmitAction extends SubmitAction {
order: 4,
when: ContextKeyExpr.and(
notInProgressOrEditing,
menuCondition),
menuCondition,
noQuestionCarouselOrHasInput),
group: 'navigation',
alt: {
id: 'workbench.action.chat.sendToNewChat',

View File

@@ -10,6 +10,7 @@ import { Emitter, Event } from '../../../../../../base/common/event.js';
import { IMarkdownString, MarkdownString, isMarkdownString } from '../../../../../../base/common/htmlContent.js';
import { KeyCode } from '../../../../../../base/common/keyCodes.js';
import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js';
import { isMacintosh } from '../../../../../../base/common/platform.js';
import { hasKey } from '../../../../../../base/common/types.js';
import { localize } from '../../../../../../nls.js';
import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js';
@@ -53,12 +54,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
private _closeButtonContainer: HTMLElement | undefined;
private _footerRow: HTMLElement | undefined;
private _stepIndicator: HTMLElement | undefined;
private _navigationButtons: HTMLElement | undefined;
private _submitHint: HTMLElement | undefined;
private _submitButton: Button | undefined;
private _prevButton: Button | undefined;
private _nextButton: Button | undefined;
private readonly _nextButtonHover: MutableDisposable<{ dispose(): void }> = this._register(new MutableDisposable());
private _submitButton: Button | undefined;
private readonly _submitButtonHover: MutableDisposable<{ dispose(): void }> = this._register(new MutableDisposable());
private _skipAllButton: Button | undefined;
private _isSkipped = false;
@@ -147,69 +146,36 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
const skipAllTitle = localize('chat.questionCarousel.skipAllTitle', 'Skip all questions');
const skipAllButton = interactiveStore.add(new Button(this._closeButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true }));
skipAllButton.label = `$(${Codicon.close.id})`;
skipAllButton.element.classList.add('chat-question-nav-arrow', 'chat-question-close');
skipAllButton.element.classList.add('chat-question-close');
skipAllButton.element.setAttribute('aria-label', skipAllTitle);
interactiveStore.add(this._hoverService.setupDelayedHover(skipAllButton.element, { content: skipAllTitle }));
this._skipAllButton = skipAllButton;
}
// Footer row with step indicator and navigation buttons
this._footerRow = dom.$('.chat-question-footer-row');
// Step indicator (e.g., "2/4") on the left
this._stepIndicator = dom.$('.chat-question-step-indicator');
this._footerRow.appendChild(this._stepIndicator);
// Navigation controls (< >) - placed in footer row
this._navigationButtons = dom.$('.chat-question-carousel-nav');
this._navigationButtons.setAttribute('role', 'navigation');
this._navigationButtons.setAttribute('aria-label', localize('chat.questionCarousel.navigation', 'Question navigation'));
// Group prev/next buttons together
const arrowsContainer = dom.$('.chat-question-nav-arrows');
const previousLabel = localize('previous', 'Previous');
const previousLabelWithKeybinding = this.getLabelWithKeybinding(previousLabel, PREVIOUS_QUESTION_ACTION_ID);
const prevButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true }));
prevButton.element.classList.add('chat-question-nav-arrow', 'chat-question-nav-prev');
prevButton.label = `$(${Codicon.chevronLeft.id})`;
prevButton.element.setAttribute('aria-label', previousLabelWithKeybinding);
interactiveStore.add(this._hoverService.setupDelayedHover(prevButton.element, { content: previousLabelWithKeybinding }));
this._prevButton = prevButton;
const nextButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true }));
nextButton.element.classList.add('chat-question-nav-arrow', 'chat-question-nav-next');
nextButton.label = `$(${Codicon.chevronRight.id})`;
this._nextButton = nextButton;
const submitButton = interactiveStore.add(new Button(this._navigationButtons, { ...defaultButtonStyles }));
submitButton.element.classList.add('chat-question-submit-button');
submitButton.label = localize('submit', 'Submit');
this._submitButton = submitButton;
this._navigationButtons.appendChild(arrowsContainer);
this._footerRow.appendChild(this._navigationButtons);
this.domNode.append(this._footerRow);
const isSingleQuestion = this.carousel.questions.length === 1;
if (!isSingleQuestion && this._closeButtonContainer) {
this.domNode.insertBefore(this._closeButtonContainer, this._questionContainer!);
}
// Register event listeners
interactiveStore.add(prevButton.onDidClick(() => this.navigate(-1)));
interactiveStore.add(nextButton.onDidClick(() => this.navigate(1)));
interactiveStore.add(submitButton.onDidClick(() => this.submit()));
if (this._skipAllButton) {
interactiveStore.add(this._skipAllButton.onDidClick(() => this.ignore()));
}
// Register keyboard navigation - handle Enter on text inputs and freeform textareas
// Register keyboard navigation
interactiveStore.add(dom.addDisposableListener(this.domNode, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => {
const event = new StandardKeyboardEvent(e);
if (event.keyCode === KeyCode.Escape && this.carousel.allowSkip) {
e.preventDefault();
e.stopPropagation();
this.ignore();
} else if (event.keyCode === KeyCode.Enter && (event.metaKey || event.ctrlKey)) {
// Cmd/Ctrl+Enter submits immediately from anywhere
e.preventDefault();
e.stopPropagation();
this.submit();
} else if (event.keyCode === KeyCode.Enter && !event.shiftKey) {
// Handle Enter key for text inputs and freeform textareas, not radio/checkbox or buttons
// Buttons have their own Enter/Space handling via Button class
const target = e.target as HTMLElement;
const isTextInput = target.tagName === 'INPUT' && (target as HTMLInputElement).type === 'text';
const isFreeformTextarea = target.tagName === 'TEXTAREA' && target.classList.contains('chat-question-freeform-textarea');
@@ -262,6 +228,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
this._currentIndex = newIndex;
this.persistDraftState();
this.renderCurrentQuestion(true);
this.domNode.focus();
}
}
@@ -339,8 +306,6 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
this._singleSelectItems.clear();
this._multiSelectCheckboxes.clear();
this._freeformTextareas.clear();
this._nextButtonHover.value = undefined;
this._submitButtonHover.value = undefined;
// Clear references to disposed elements
this._prevButton = undefined;
@@ -348,10 +313,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
this._submitButton = undefined;
this._skipAllButton = undefined;
this._questionContainer = undefined;
this._navigationButtons = undefined;
this._closeButtonContainer = undefined;
this._footerRow = undefined;
this._stepIndicator = undefined;
this._submitHint = undefined;
this._inputScrollable = undefined;
}
@@ -381,12 +346,15 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
const availableScrollableHeight = Math.floor(maxContainerHeight - contentVerticalPadding - nonScrollableContentHeight);
const constrainedScrollableHeight = Math.max(0, availableScrollableHeight);
const constrainedScrollableHeightPx = `${constrainedScrollableHeight}px`;
// Constrain the content element (DomScrollableElement._element) so that
// scanDomNode sees clientHeight < scrollHeight and enables scrolling.
// The wrapper inherits the same constraint via CSS flex.
scrollableContent.style.height = `${constrainedScrollableHeight}px`;
scrollableContent.style.maxHeight = `${constrainedScrollableHeight}px`;
if (scrollableContent.style.height !== constrainedScrollableHeightPx || scrollableContent.style.maxHeight !== constrainedScrollableHeightPx) {
scrollableContent.style.height = constrainedScrollableHeightPx;
scrollableContent.style.maxHeight = constrainedScrollableHeightPx;
}
inputScrollable.scanDomNode();
}
@@ -551,7 +519,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
}
private renderCurrentQuestion(focusContainerForScreenReader: boolean = false): void {
if (!this._questionContainer || !this._prevButton || !this._nextButton || !this._submitButton) {
if (!this._questionContainer) {
return;
}
@@ -574,70 +542,32 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
return;
}
// Render question header row with title and close button
// Render unified question title (message ?? title)
const headerRow = dom.$('.chat-question-header-row');
const titleRow = dom.$('.chat-question-title-row');
// Render question title (short header) in the header bar as plain text
if (question.title) {
const questionText = question.message ?? question.title;
if (questionText) {
const title = dom.$('.chat-question-title');
const questionText = question.title;
const messageContent = this.getQuestionText(questionText);
title.setAttribute('aria-label', messageContent);
if (question.message !== undefined) {
const messageMd = isMarkdownString(questionText) ? MarkdownString.lift(questionText) : new MarkdownString(questionText);
const renderedTitle = questionRenderStore.add(this._markdownRendererService.render(messageMd));
title.appendChild(renderedTitle.element);
} else {
// Check for subtitle in parentheses at the end
const parenMatch = messageContent.match(/^(.+?)\s*(\([^)]+\))\s*$/);
if (parenMatch) {
// Main title (bold)
const mainTitle = dom.$('span.chat-question-title-main');
mainTitle.textContent = parenMatch[1];
title.appendChild(mainTitle);
// Subtitle in parentheses (normal weight)
const subtitle = dom.$('span.chat-question-title-subtitle');
subtitle.textContent = ' ' + parenMatch[2];
title.appendChild(subtitle);
} else {
title.textContent = messageContent;
}
}
const messageMd = isMarkdownString(questionText) ? MarkdownString.lift(questionText) : new MarkdownString(questionText);
const renderedTitle = questionRenderStore.add(this._markdownRendererService.render(messageMd));
title.appendChild(renderedTitle.element);
titleRow.appendChild(title);
}
// Add close button to header row (if allowSkip is enabled)
if (this._closeButtonContainer) {
titleRow.appendChild(this._closeButtonContainer);
}
headerRow.appendChild(titleRow);
this._questionContainer.appendChild(headerRow);
// Render full question text below the header row (supports multi-line and markdown)
if (question.message) {
const messageEl = dom.$('.chat-question-message');
if (isMarkdownString(question.message)) {
const renderedMessage = questionRenderStore.add(this._markdownRendererService.render(MarkdownString.lift(question.message)));
messageEl.appendChild(renderedMessage.element);
} else {
messageEl.textContent = this.getQuestionText(question.message);
}
this._questionContainer.appendChild(messageEl);
}
// For single-question carousels, add close button inside the title row
const isSingleQuestion = this.carousel.questions.length === 1;
// Update step indicator in footer
if (this._stepIndicator) {
this._stepIndicator.textContent = `${this._currentIndex + 1}/${this.carousel.questions.length}`;
this._stepIndicator.style.display = isSingleQuestion ? 'none' : '';
if (isSingleQuestion && this._closeButtonContainer) {
titleRow.appendChild(this._closeButtonContainer);
}
this._questionContainer.appendChild(headerRow);
// Render input based on question type
const inputContainer = dom.$('.chat-question-input-container');
this.renderInput(inputContainer, question);
@@ -652,10 +582,24 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
inputScrollableNode.classList.add('chat-question-input-scrollable');
this._questionContainer.appendChild(inputScrollableNode);
const inputResizeObserver = questionRenderStore.add(new dom.DisposableResizeObserver(() => this.layoutInputScrollable(inputScrollable)));
let relayoutScheduled = false;
const relayoutScheduler = questionRenderStore.add(new MutableDisposable());
const scheduleLayoutInputScrollable = () => {
if (relayoutScheduled) {
return;
}
relayoutScheduled = true;
relayoutScheduler.value = dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(this.domNode), () => {
relayoutScheduled = false;
this.layoutInputScrollable(inputScrollable);
});
};
const inputResizeObserver = questionRenderStore.add(new dom.DisposableResizeObserver(() => scheduleLayoutInputScrollable()));
questionRenderStore.add(inputResizeObserver.observe(inputScrollableNode));
questionRenderStore.add(inputResizeObserver.observe(inputContainer));
questionRenderStore.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(this.domNode), () => this.layoutInputScrollable(inputScrollable)));
scheduleLayoutInputScrollable();
this.layoutInputScrollable(inputScrollable);
questionRenderStore.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(this.domNode), () => {
inputContainer.scrollTop = 0;
@@ -664,26 +608,12 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
inputScrollable.scanDomNode();
}));
// Update navigation button states (prevButton and nextButton are guaranteed non-null from guard above)
this._prevButton!.enabled = this._currentIndex > 0;
this._prevButton!.element.style.display = isSingleQuestion ? 'none' : '';
// Keep navigation arrows stable and disable next on the last question
const isLastQuestion = this._currentIndex === this.carousel.questions.length - 1;
const submitLabel = localize('submit', 'Submit');
const nextLabel = localize('next', 'Next');
const nextLabelWithKeybinding = this.getLabelWithKeybinding(nextLabel, NEXT_QUESTION_ACTION_ID);
this._nextButton!.label = `$(${Codicon.chevronRight.id})`;
this._nextButton!.enabled = !isLastQuestion;
this._nextButton!.element.setAttribute('aria-label', nextLabelWithKeybinding);
this._nextButtonHover.value = this._hoverService.setupDelayedHover(this._nextButton!.element, { content: nextLabelWithKeybinding });
this._submitButton!.enabled = isLastQuestion;
this._submitButton!.element.style.display = isLastQuestion ? '' : 'none';
this._submitButton!.element.setAttribute('aria-label', submitLabel);
this._submitButtonHover.value = isLastQuestion
? this._hoverService.setupDelayedHover(this._submitButton!.element, { content: submitLabel })
: undefined;
// Render footer for multi-question carousels or single-question carousels.
if (!isSingleQuestion) {
this.renderFooter();
} else {
this.renderSingleQuestionFooter();
}
// Update aria-label to reflect the current question
this._updateAriaLabel();
@@ -697,6 +627,138 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
this._onDidChangeHeight.fire();
}
/**
* Renders or updates the persistent footer with nav arrows, step indicator, and submit button.
*/
private renderFooter(): void {
if (!this._footerRow) {
const interactiveStore = this._interactiveUIStore.value;
if (!interactiveStore) {
return;
}
this._footerRow = dom.$('.chat-question-footer-row');
// Left side: nav arrows + step indicator
const leftControls = dom.$('.chat-question-footer-left.chat-question-carousel-nav');
leftControls.setAttribute('role', 'navigation');
leftControls.setAttribute('aria-label', localize('chat.questionCarousel.navigation', 'Question navigation'));
const arrowsContainer = dom.$('.chat-question-nav-arrows');
const previousLabel = this.getLabelWithKeybinding(localize('previous', 'Previous'), PREVIOUS_QUESTION_ACTION_ID);
const prevButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true }));
prevButton.element.classList.add('chat-question-nav-arrow', 'chat-question-nav-prev');
prevButton.label = `$(${Codicon.chevronLeft.id})`;
prevButton.element.setAttribute('aria-label', previousLabel);
interactiveStore.add(this._hoverService.setupDelayedHover(prevButton.element, { content: previousLabel }));
interactiveStore.add(prevButton.onDidClick(() => this.navigate(-1)));
this._prevButton = prevButton;
const nextLabel = this.getLabelWithKeybinding(localize('next', 'Next'), NEXT_QUESTION_ACTION_ID);
const nextButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true }));
nextButton.element.classList.add('chat-question-nav-arrow', 'chat-question-nav-next');
nextButton.label = `$(${Codicon.chevronRight.id})`;
nextButton.element.setAttribute('aria-label', nextLabel);
interactiveStore.add(this._hoverService.setupDelayedHover(nextButton.element, { content: nextLabel }));
interactiveStore.add(nextButton.onDidClick(() => this.navigate(1)));
this._nextButton = nextButton;
leftControls.appendChild(arrowsContainer);
this._stepIndicator = dom.$('.chat-question-step-indicator');
leftControls.appendChild(this._stepIndicator);
this._footerRow.appendChild(leftControls);
// Right side: hint + submit
const rightControls = dom.$('.chat-question-footer-right');
const hint = dom.$('span.chat-question-submit-hint');
hint.textContent = isMacintosh
? localize('chat.questionCarousel.submitHintMac', '\u2318\u23CE to submit')
: localize('chat.questionCarousel.submitHintOther', 'Ctrl+Enter to submit');
rightControls.appendChild(hint);
this._submitHint = hint;
const submitButton = interactiveStore.add(new Button(rightControls, { ...defaultButtonStyles }));
submitButton.element.classList.add('chat-question-submit-button');
submitButton.label = localize('submit', 'Submit');
interactiveStore.add(submitButton.onDidClick(() => this.submit()));
this._submitButton = submitButton;
this._footerRow.appendChild(rightControls);
this.domNode.append(this._footerRow);
}
this.updateFooterState();
}
/**
* Updates the footer nav button enabled state and step indicator text.
*/
private updateFooterState(): void {
if (this._prevButton) {
this._prevButton.enabled = this._currentIndex > 0;
}
if (this._nextButton) {
this._nextButton.enabled = this._currentIndex < this.carousel.questions.length - 1;
}
if (this._stepIndicator) {
this._stepIndicator.textContent = localize(
'chat.questionCarousel.stepIndicator',
'{0}/{1}',
this._currentIndex + 1,
this.carousel.questions.length
);
}
if (this._submitButton) {
const isLastQuestion = this._currentIndex === this.carousel.questions.length - 1;
this._submitButton.element.style.display = isLastQuestion ? '' : 'none';
if (this._submitHint) {
this._submitHint.style.display = isLastQuestion ? '' : 'none';
}
}
}
/**
* Renders a simplified footer with just a submit button for single-question multi-select carousels.
*/
private renderSingleQuestionFooter(): void {
if (!this._footerRow) {
const interactiveStore = this._interactiveUIStore.value;
if (!interactiveStore) {
return;
}
this._footerRow = dom.$('.chat-question-footer-row');
// Spacer to push controls to the right
const leftControls = dom.$('.chat-question-footer-left.chat-question-carousel-nav');
leftControls.setAttribute('role', 'navigation');
leftControls.setAttribute('aria-label', localize('chat.questionCarousel.navigation', 'Question navigation'));
this._footerRow.appendChild(leftControls);
const rightControls = dom.$('.chat-question-footer-right');
const hint = dom.$('span.chat-question-submit-hint');
hint.textContent = isMacintosh
? localize('chat.questionCarousel.submitHintMac', '\u2318\u23CE to submit')
: localize('chat.questionCarousel.submitHintOther', 'Ctrl+Enter to submit');
rightControls.appendChild(hint);
this._submitHint = hint;
const submitButton = interactiveStore.add(new Button(rightControls, { ...defaultButtonStyles }));
submitButton.element.classList.add('chat-question-submit-button');
submitButton.label = localize('submit', 'Submit');
interactiveStore.add(submitButton.onDidClick(() => this.submit()));
this._submitButton = submitButton;
this._footerRow.appendChild(rightControls);
this.domNode.append(this._footerRow);
}
}
private getLabelWithKeybinding(label: string, actionId: string): string {
const keybindingLabel = this._keybindingService.lookupKeybinding(actionId, this._contextKeyService)?.getLabel();
return keybindingLabel
@@ -837,12 +899,13 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
const label = dom.$('.chat-question-list-label');
const separatorIndex = option.label.indexOf(' - ');
if (separatorIndex !== -1) {
listItem.classList.add('has-description');
const titleSpan = dom.$('span.chat-question-list-label-title');
titleSpan.textContent = option.label.substring(0, separatorIndex);
label.appendChild(titleSpan);
const descSpan = dom.$('span.chat-question-list-label-desc');
descSpan.textContent = ': ' + option.label.substring(separatorIndex + 3);
descSpan.textContent = option.label.substring(separatorIndex + 3);
label.appendChild(descSpan);
} else {
label.textContent = option.label;
@@ -929,7 +992,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
} else if (event.keyCode === KeyCode.UpArrow) {
e.preventDefault();
newIndex = Math.max(data.selectedIndex - 1, 0);
} else if (event.keyCode === KeyCode.Enter || event.keyCode === KeyCode.Space) {
} else if ((event.keyCode === KeyCode.Enter || event.keyCode === KeyCode.Space) && !event.metaKey && !event.ctrlKey) {
// Enter confirms current selection and advances to next question
e.preventDefault();
e.stopPropagation();
@@ -1037,12 +1100,13 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
const label = dom.$('.chat-question-list-label');
const separatorIndex = option.label.indexOf(' - ');
if (separatorIndex !== -1) {
listItem.classList.add('has-description');
const titleSpan = dom.$('span.chat-question-list-label-title');
titleSpan.textContent = option.label.substring(0, separatorIndex);
label.appendChild(titleSpan);
const descSpan = dom.$('span.chat-question-list-label-desc');
descSpan.textContent = ': ' + option.label.substring(separatorIndex + 3);
descSpan.textContent = option.label.substring(separatorIndex + 3);
label.appendChild(descSpan);
} else {
label.textContent = option.label;
@@ -1128,7 +1192,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
e.preventDefault();
focusedIndex = Math.max(focusedIndex - 1, 0);
listItems[focusedIndex].focus();
} else if (event.keyCode === KeyCode.Enter) {
} else if (event.keyCode === KeyCode.Enter && !event.metaKey && !event.ctrlKey) {
e.preventDefault();
e.stopPropagation();
this.handleNextOrSubmit();
@@ -1267,40 +1331,25 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
for (const question of this.carousel.questions) {
const answer = this._answers.get(question.id);
if (answer === undefined) {
continue;
}
const summaryItem = dom.$('.chat-question-summary-item');
// Category label (use same text as shown in question UI: message ?? title)
const questionLabel = dom.$('span.chat-question-summary-label');
const questionRow = dom.$('div.chat-question-summary-label');
const questionText = question.message ?? question.title;
let labelText = typeof questionText === 'string' ? questionText : questionText.value;
// Remove trailing colons and whitespace to avoid double colons (CSS adds ': ')
labelText = labelText.replace(/[:\s]+$/, '');
questionLabel.textContent = labelText;
summaryItem.appendChild(questionLabel);
questionRow.textContent = localize('chat.questionCarousel.summaryQuestion', 'Q: {0}', labelText);
summaryItem.appendChild(questionRow);
// Format answer with title and description parts
const formattedAnswer = this.formatAnswerForSummary(question, answer);
const separatorIndex = formattedAnswer.indexOf(' - ');
if (separatorIndex !== -1) {
// Answer title (bold)
const answerTitle = dom.$('span.chat-question-summary-answer-title');
answerTitle.textContent = formattedAnswer.substring(0, separatorIndex);
summaryItem.appendChild(answerTitle);
// Answer description (normal)
const answerDesc = dom.$('span.chat-question-summary-answer-desc');
answerDesc.textContent = ' - ' + formattedAnswer.substring(separatorIndex + 3);
summaryItem.appendChild(answerDesc);
if (answer !== undefined) {
const formattedAnswer = this.formatAnswerForSummary(question, answer);
const answerRow = dom.$('div.chat-question-summary-answer-title');
answerRow.textContent = localize('chat.questionCarousel.summaryAnswer', 'A: {0}', formattedAnswer);
summaryItem.appendChild(answerRow);
} else {
// Just the answer value (bold)
const answerValue = dom.$('span.chat-question-summary-answer-title');
answerValue.textContent = formattedAnswer;
summaryItem.appendChild(answerValue);
const unanswered = dom.$('div.chat-question-summary-unanswered');
unanswered.textContent = localize('chat.questionCarousel.notAnsweredYet', 'Not answered yet');
summaryItem.appendChild(unanswered);
}
summaryContainer.appendChild(summaryItem);

View File

@@ -14,20 +14,21 @@
.interactive-session .interactive-input-part .interactive-input-and-edit-session > .chat-question-carousel-widget-container .chat-question-carousel-container {
margin: 0;
border: 1px solid var(--vscode-input-border, transparent);
background-color: var(--vscode-editor-background);
border-radius: 4px;
background-color: var(--vscode-panel-background);
border-radius: var(--vscode-cornerRadius-large);
}
/* general questions styling */
.interactive-session .chat-question-carousel-container {
margin: 8px 0;
border: 1px solid var(--vscode-chat-requestBorder);
border-radius: 4px;
border-radius: var(--vscode-cornerRadius-large);
display: flex;
flex-direction: column;
overflow: hidden;
container-type: inline-size;
max-height: min(420px, 45vh);
position: relative;
}
/* input part wrapper */
@@ -47,7 +48,6 @@
.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-carousel-content,
.interactive-session .interactive-input-part .interactive-input-and-edit-session > .chat-question-carousel-widget-container .chat-question-carousel-content {
flex: 1;
min-height: 0;
}
@@ -55,18 +55,13 @@
.interactive-session .chat-question-carousel-container .chat-question-carousel-content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
background: var(--vscode-chat-requestBackground);
padding: 8px 16px 10px 16px;
overflow: hidden;
.chat-question-header-row {
display: flex;
flex-direction: column;
flex-shrink: 0;
background: var(--vscode-chat-requestBackground);
padding: 0 16px 10px 16px;
overflow: hidden;
.chat-question-title-row {
@@ -75,6 +70,8 @@
align-items: center;
gap: 8px;
min-width: 0;
padding: 8px 8px 8px 16px;
border-bottom: 1px solid var(--vscode-chat-requestBorder);
}
.chat-question-title {
@@ -85,13 +82,6 @@
font-weight: 500;
font-size: var(--vscode-chat-font-size-body-s);
margin: 0;
padding-top: 4px;
padding-bottom: 4px;
margin-left: -16px;
margin-right: -16px;
padding-left: 16px;
padding-right: 16px;
border-bottom: 1px solid var(--vscode-chat-requestBorder);
.rendered-markdown {
a {
@@ -107,15 +97,6 @@
margin: 0;
}
}
.chat-question-title-main {
font-weight: 500;
}
.chat-question-title-subtitle {
font-weight: normal;
color: var(--vscode-descriptionForeground);
}
}
.chat-question-close-container {
@@ -126,49 +107,29 @@
width: 22px;
height: 22px;
padding: 0;
border: none;
border: none !important;
box-shadow: none !important;
background: transparent !important;
color: var(--vscode-foreground) !important;
color: var(--vscode-icon-foreground) !important;
}
.monaco-button.chat-question-close:hover:not(.disabled) {
background: var(--vscode-toolbar-hoverBackground) !important;
}
}
.chat-question-message {
flex-shrink: 0;
padding-top: 8px;
font-size: var(--vscode-chat-font-size-body-s);
word-wrap: break-word;
overflow-wrap: break-word;
line-height: 1.4;
.rendered-markdown {
a {
color: var(--vscode-textLink-foreground);
}
a:hover,
a:active {
color: var(--vscode-textLink-activeForeground);
}
p {
margin: 0;
}
}
}
}
}
/* Extra right padding when close button is absolutely positioned (multi-question) */
.interactive-session .chat-question-carousel-container:has(> .chat-question-close-container) .chat-question-title-row {
padding-right: 36px;
}
/* questions list and freeform area */
.interactive-session .chat-question-carousel-container .chat-question-input-container {
display: flex;
flex-direction: column;
margin-top: 4px;
padding-right: 14px;
padding-bottom: 12px;
padding: 8px;
min-width: 0;
&::after {
@@ -179,37 +140,24 @@
}
/* some hackiness to get the focus looking right */
.chat-question-list-item:focus:not(.selected),
.chat-question-list-item:focus,
.chat-question-list:focus {
outline: none;
}
.chat-question-list:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.chat-question-list:focus-within .chat-question-list-item.selected {
outline-width: 1px;
outline-style: solid;
outline-offset: -1px;
outline-color: var(--vscode-focusBorder);
}
.chat-question-list {
display: flex;
flex-direction: column;
gap: 3px;
outline: none;
padding: 4px 0;
padding: 0;
.chat-question-list-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 3px 8px;
align-items: center;
gap: 12px;
padding: 6px 8px;
cursor: pointer;
border-radius: 3px;
border-radius: var(--vscode-cornerRadius-medium);
user-select: none;
.chat-question-list-indicator {
@@ -220,6 +168,8 @@
justify-content: center;
flex-shrink: 0;
margin-left: auto;
align-self: flex-start;
margin-top: 2px;
}
.chat-question-list-indicator.codicon-check {
@@ -232,11 +182,13 @@
flex: 1;
word-wrap: break-word;
overflow-wrap: break-word;
padding-top: 2px;
display: flex;
flex-direction: column;
}
.chat-question-list-label-title {
font-weight: 600;
font-weight: 500;
line-height: 1.4;
}
.chat-question-list-label-desc {
@@ -245,13 +197,28 @@
}
}
.chat-question-list-item.has-description {
align-items: flex-start;
.chat-question-list-number {
line-height: 1.4;
font-size: var(--vscode-chat-font-size-body-s);
font-weight: 500;
}
.chat-question-list-checkbox {
/* Title line-height is ~17px (1.4 * body-s), checkbox is 16px: 1px offset */
margin-top: 1px;
}
}
.chat-question-list-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
/* Single-select: highlight entire row when selected */
.chat-question-list-item.selected {
background-color: var(--vscode-list-activeSelectionBackground);
background-color: var(--vscode-list-hoverBackground);
color: var(--vscode-list-activeSelectionForeground);
.chat-question-label {
@@ -268,16 +235,12 @@
}
.chat-question-list-number {
background-color: transparent;
color: var(--vscode-list-activeSelectionForeground);
border-color: var(--vscode-list-activeSelectionForeground);
border-bottom-color: var(--vscode-list-activeSelectionForeground);
box-shadow: none;
}
}
.chat-question-list-item.selected:hover {
background-color: var(--vscode-list-activeSelectionBackground);
background-color: var(--vscode-list-hoverBackground);
}
/* Checkbox for multi-select */
@@ -291,11 +254,12 @@
}
.chat-question-freeform {
margin-left: 8px;
margin: 0;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
padding: 4px 8px;
gap: 12px;
.chat-question-freeform-number {
height: fit-content;
@@ -338,22 +302,11 @@
/* todo: change to use keybinding service so we don't have to recreate this */
.chat-question-list-number,
.chat-question-freeform-number {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 14px;
padding: 0px 4px;
border-style: solid;
border-width: 1px;
border-radius: 3px;
font-size: 11px;
font-weight: normal;
background-color: var(--vscode-keybindingLabel-background);
color: var(--vscode-keybindingLabel-foreground);
border-color: var(--vscode-keybindingLabel-border);
border-bottom-color: var(--vscode-keybindingLabel-bottomBorder);
box-shadow: inset 0 -1px 0 var(--vscode-widget-shadow);
font-size: var(--vscode-chat-font-size-body-s);
color: var(--vscode-descriptionForeground);
flex-shrink: 0;
min-width: 1ch;
text-align: right;
}
}
@@ -362,31 +315,53 @@
}
.interactive-session .chat-question-carousel-container .chat-question-input-scrollable {
flex: 1;
flex: 0 1 auto;
min-height: 0;
overscroll-behavior: contain;
}
/* footer with step indicator and nav buttons */
/* close button for multi-question carousels (positioned top-right) */
.interactive-session .chat-question-carousel-container > .chat-question-close-container {
position: absolute;
top: 6px;
right: 8px;
z-index: 1;
.monaco-button.chat-question-close {
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-close:hover:not(.disabled) {
background: var(--vscode-toolbar-hoverBackground) !important;
}
}
/* footer with nav arrows, step indicator, and submit */
.interactive-session .chat-question-carousel-container .chat-question-footer-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 16px;
padding: 4px 8px;
border-top: 1px solid var(--vscode-chat-requestBorder);
background: var(--vscode-chat-requestBackground);
flex-shrink: 0;
.chat-question-step-indicator {
font-size: var(--vscode-chat-font-size-body-s);
color: var(--vscode-descriptionForeground);
}
.chat-question-carousel-nav {
.chat-question-footer-left {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
margin-left: auto;
gap: 8px;
}
.chat-question-footer-right {
display: flex;
align-items: center;
gap: 8px;
}
.chat-question-nav-arrows {
@@ -395,49 +370,48 @@
gap: 4px;
}
.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow {
.monaco-button.chat-question-nav-arrow {
min-width: 22px;
width: 22px;
height: 22px;
padding: 0;
border: none;
}
/* Secondary buttons (prev, next) use gray secondary background */
.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-prev,
.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-next {
background: var(--vscode-button-secondaryBackground) !important;
color: var(--vscode-button-secondaryForeground) !important;
}
.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-prev:hover:not(.disabled),
.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-next:hover:not(.disabled) {
background: var(--vscode-button-secondaryHoverBackground) !important;
}
/* Dedicated submit button uses primary background */
.chat-question-carousel-nav .monaco-button.chat-question-submit-button {
background: var(--vscode-button-background) !important;
color: var(--vscode-button-foreground) !important;
height: 22px;
min-width: auto;
padding: 0 8px;
}
.chat-question-carousel-nav .monaco-button.chat-question-submit-button:hover:not(.disabled) {
background: var(--vscode-button-hoverBackground) !important;
}
/* Close button uses transparent background */
.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-close {
border: none !important;
box-shadow: none !important;
background: transparent !important;
color: var(--vscode-foreground) !important;
}
.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-close:hover:not(.disabled) {
.monaco-button.chat-question-nav-arrow:hover:not(.disabled) {
background: var(--vscode-toolbar-hoverBackground) !important;
}
.monaco-button.chat-question-nav-arrow.disabled {
opacity: 0.4;
}
.chat-question-step-indicator {
font-size: var(--vscode-chat-font-size-body-s);
color: var(--vscode-descriptionForeground);
}
.chat-question-submit-hint {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.monaco-button.chat-question-submit-button {
background: var(--vscode-button-background) !important;
color: var(--vscode-button-foreground) !important;
height: 22px;
width: auto;
flex: 0 0 auto;
min-width: auto;
padding: 0 8px;
}
.monaco-button.chat-question-submit-button:hover:not(.disabled) {
background: var(--vscode-button-hoverBackground) !important;
}
}
/* summary (after finished) */
@@ -449,9 +423,7 @@
.chat-question-summary-item {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
flex-direction: column;
gap: 0;
font-size: var(--vscode-chat-font-size-body-s);
}
@@ -462,11 +434,6 @@
overflow-wrap: break-word;
}
.chat-question-summary-label::after {
content: ': ';
white-space: pre;
}
.chat-question-summary-answer-title {
color: var(--vscode-foreground);
font-weight: 600;

View File

@@ -60,7 +60,6 @@ suite('ChatQuestionCarouselPart', () => {
assert.ok(widget.domNode.classList.contains('chat-question-carousel-container'));
assert.ok(widget.domNode.querySelector('.chat-question-header-row'));
assert.ok(widget.domNode.querySelector('.chat-question-carousel-content'));
assert.ok(widget.domNode.querySelector('.chat-question-carousel-nav'));
});
test('renders question title', () => {
@@ -100,12 +99,7 @@ suite('ChatQuestionCarouselPart', () => {
const title = widget.domNode.querySelector('.chat-question-title');
assert.ok(title, 'title element should exist');
const messageEl = widget.domNode.querySelector('.chat-question-message');
assert.ok(messageEl, 'message element should exist');
assert.ok(messageEl?.querySelector('.rendered-markdown'), 'markdown content should be rendered');
assert.strictEqual(messageEl?.textContent?.includes('**details**'), false, 'markdown syntax should not be shown as raw text');
const link = messageEl?.querySelector('a') as HTMLAnchorElement | null;
assert.ok(link, 'markdown link should render as anchor');
assert.ok(title?.querySelector('.rendered-markdown'), 'markdown content should be rendered');
});
test('renders plain string question message as text', () => {
@@ -119,10 +113,9 @@ suite('ChatQuestionCarouselPart', () => {
]);
createWidget(carousel);
const messageEl = widget.domNode.querySelector('.chat-question-message');
assert.ok(messageEl, 'message element should exist');
assert.ok(messageEl?.textContent?.includes('details'), 'plain text content should be rendered');
assert.strictEqual(messageEl?.querySelector('.rendered-markdown'), null, 'plain string message should not use markdown renderer');
const title = widget.domNode.querySelector('.chat-question-title');
assert.ok(title, 'title element should exist');
assert.ok(title?.textContent?.includes('details'), 'content should be rendered');
});
test('renders progress indicator correctly', () => {
@@ -278,34 +271,40 @@ suite('ChatQuestionCarouselPart', () => {
]);
createWidget(carousel);
// Use dedicated class selectors for stability
const prevButton = widget.domNode.querySelector('.chat-question-nav-prev') as HTMLButtonElement;
const navArrows = widget.domNode.querySelectorAll('.chat-question-nav-arrow') as NodeListOf<HTMLButtonElement>;
const prevButton = navArrows[0];
assert.ok(prevButton, 'Previous button should exist');
assert.ok(prevButton.classList.contains('disabled') || prevButton.disabled, 'Previous button should be disabled on first question');
});
test('next button stays as arrow and is disabled on last question', () => {
const carousel = createMockCarousel([
{ id: 'q1', type: 'text', title: 'Only Question' }
{ id: 'q1', type: 'text', title: 'Only Question' },
{ id: 'q2', type: 'text', title: 'Question 2' }
]);
createWidget(carousel);
// Use dedicated class selector for stability
const nextButton = widget.domNode.querySelector('.chat-question-nav-next') as HTMLButtonElement;
// Navigate to last question
widget.navigateToNextQuestion();
const navArrows = widget.domNode.querySelectorAll('.chat-question-nav-arrow') as NodeListOf<HTMLButtonElement>;
const nextButton = navArrows[1];
assert.ok(nextButton, 'Next button should exist');
assert.strictEqual(nextButton.getAttribute('aria-label'), 'Next', 'Next button should preserve Next aria-label on last question');
assert.ok(nextButton.classList.contains('disabled') || nextButton.disabled, 'Next button should be disabled on last question');
});
test('submit button is shown on last question', () => {
const carousel = createMockCarousel([
{ id: 'q1', type: 'text', title: 'Only Question' }
{ id: 'q1', type: 'text', title: 'Question 1' },
{ id: 'q2', type: 'text', title: 'Question 2' }
]);
createWidget(carousel);
// Navigate to last question
widget.navigateToNextQuestion();
const submitButton = widget.domNode.querySelector('.chat-question-submit-button') as HTMLButtonElement;
assert.ok(submitButton, 'Submit button should exist');
assert.strictEqual(submitButton.getAttribute('aria-label'), 'Submit');
assert.notStrictEqual(submitButton.style.display, 'none', 'Submit button should be visible on last question');
});
});