retain input in question carousel (#298795)

* retain input state for chat questions

* better approach
This commit is contained in:
Megan Rogge
2026-03-02 17:07:04 -05:00
committed by GitHub
parent 2c494f110b
commit 119cb000f2
5 changed files with 121 additions and 6 deletions

View File

@@ -19,6 +19,7 @@ import { Button } from '../../../../../../base/browser/ui/button/button.js';
import { InputBox } from '../../../../../../base/browser/ui/inputbox/inputBox.js';
import { Checkbox } from '../../../../../../base/browser/ui/toggle/toggle.js';
import { IChatQuestion, IChatQuestionCarousel } from '../../../common/chatService/chatService.js';
import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js';
import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js';
import { IChatRendererContent, isResponseVM } from '../../../common/model/chatViewModel.js';
import { ChatTreeItem } from '../../chat.js';
@@ -32,7 +33,6 @@ import './media/chatQuestionCarousel.css';
const PREVIOUS_QUESTION_ACTION_ID = 'workbench.action.chat.previousQuestion';
const NEXT_QUESTION_ACTION_ID = 'workbench.action.chat.nextQuestion';
export interface IChatQuestionCarouselOptions {
onSubmit: (answers: Map<string, unknown> | undefined) => void;
shouldAutoFocus?: boolean;
@@ -100,7 +100,20 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
this.domNode.setAttribute('aria-roledescription', localize('chat.questionCarousel.roleDescription', 'chat question'));
this._updateAriaLabel();
// Restore answers from carousel data if already submitted (e.g., after re-render due to virtualization)
// Restore draft state from transient runtime fields when available.
if (carousel instanceof ChatQuestionCarouselData) {
if (typeof carousel.draftCurrentIndex === 'number') {
this._currentIndex = Math.max(0, Math.min(carousel.draftCurrentIndex, carousel.questions.length - 1));
}
if (carousel.draftAnswers) {
for (const [key, value] of Object.entries(carousel.draftAnswers)) {
this._answers.set(key, value);
}
}
}
// Restore submitted answers for summary rendering.
if (carousel.data) {
for (const [key, value] of Object.entries(carousel.data)) {
this._answers.set(key, value);
@@ -219,7 +232,20 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
const answer = this.getCurrentAnswer();
if (answer !== undefined) {
this._answers.set(currentQuestion.id, answer);
} else {
this._answers.delete(currentQuestion.id);
}
this.persistDraftState();
}
private persistDraftState(): void {
if (this.carousel.isUsed || !(this.carousel instanceof ChatQuestionCarouselData)) {
return;
}
this.carousel.draftAnswers = Object.fromEntries(this._answers.entries());
this.carousel.draftCurrentIndex = this._currentIndex;
}
/**
@@ -231,6 +257,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
if (newIndex >= 0 && newIndex < this.carousel.questions.length) {
this.saveCurrentAnswer();
this._currentIndex = newIndex;
this.persistDraftState();
this.renderCurrentQuestion(true);
}
}
@@ -245,6 +272,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
if (this._currentIndex < this.carousel.questions.length - 1) {
// Move to next question
this._currentIndex++;
this.persistDraftState();
this.renderCurrentQuestion(true);
} else {
// Submit
@@ -648,6 +676,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
placeholder: localize('chat.questionCarousel.enterText', 'Enter your answer'),
inputBoxStyles: defaultInputBoxStyles,
}));
this._inputBoxes.add(inputBox.onDidChange(() => this.saveCurrentAnswer()));
// Restore previous answer if exists
const previousAnswer = this._answers.get(question.id);
@@ -716,6 +745,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
if (data) {
data.selectedIndex = newIndex;
}
this.saveCurrentAnswer();
};
options.forEach((option, index) => {
@@ -810,6 +841,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
this._inputBoxes.add(dom.addDisposableListener(freeformTextarea, dom.EventType.INPUT, () => {
if (freeformTextarea.value.length > 0) {
updateSelection(-1);
} else {
this.saveCurrentAnswer();
}
}));
@@ -963,6 +996,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
this._inputBoxes.add(checkbox.onChange(() => {
listItem.classList.toggle('checked', checkbox.checked);
listItem.setAttribute('aria-selected', String(checkbox.checked));
this.saveCurrentAnswer();
}));
// Click handler for the entire row (toggle checkbox)
@@ -1007,6 +1041,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
// Setup auto-resize behavior
const autoResize = this.setupTextareaAutoResize(freeformTextarea);
this._inputBoxes.add(dom.addDisposableListener(freeformTextarea, dom.EventType.INPUT, () => this.saveCurrentAnswer()));
freeformContainer.appendChild(freeformTextarea);
container.appendChild(freeformContainer);
@@ -1272,4 +1307,12 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
addDisposable(disposable: { dispose(): void }): void {
this._register(disposable);
}
override dispose(): void {
if (!this._isSkipped && !this.carousel.isUsed) {
this.saveCurrentAnswer();
}
super.dispose();
}
}

View File

@@ -2158,11 +2158,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
const handleSubmit = async (answers: Map<string, unknown> | undefined, part: ChatQuestionCarouselPart) => {
// Mark the carousel as used and store the answers
const answersRecord = answers ? Object.fromEntries(answers) : undefined;
if (answersRecord) {
carousel.data = answersRecord;
}
carousel.data = answersRecord ?? {};
carousel.isUsed = true;
if (carousel instanceof ChatQuestionCarouselData) {
carousel.draftAnswers = undefined;
carousel.draftCurrentIndex = undefined;
carousel.completion.complete({ answers: answersRecord });
}
@@ -2183,10 +2183,12 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
const inputPartHasCarousel = widget?.input.questionCarousel !== undefined;
if (carousel.isUsed || responseIsComplete) {
if (responseIsComplete && !carousel.isUsed && isResponseVM(context.element) && carousel.resolveId && carousel.data === undefined) {
if (responseIsComplete && !carousel.isUsed && isResponseVM(context.element) && carousel.resolveId) {
carousel.data = {};
carousel.isUsed = true;
if (carousel instanceof ChatQuestionCarouselData) {
carousel.draftAnswers = undefined;
carousel.draftCurrentIndex = undefined;
carousel.completion.complete({ answers: undefined });
}
this.chatService.notifyQuestionCarouselAnswer(context.element.requestId, carousel.resolveId, undefined);

View File

@@ -14,6 +14,8 @@ import { IChatQuestion, IChatQuestionCarousel } from '../../chatService/chatServ
export class ChatQuestionCarouselData implements IChatQuestionCarousel {
public readonly kind = 'questionCarousel' as const;
public readonly completion = new DeferredPromise<{ answers: Record<string, unknown> | undefined }>();
public draftAnswers: Record<string, unknown> | undefined;
public draftCurrentIndex: number | undefined;
constructor(
public questions: IChatQuestion[],

View File

@@ -11,6 +11,7 @@ import { workbenchInstantiationService } from '../../../../../../test/browser/wo
import { ChatQuestionCarouselPart, IChatQuestionCarouselOptions } from '../../../../browser/widget/chatContentParts/chatQuestionCarouselPart.js';
import { IChatQuestionCarousel } from '../../../../common/chatService/chatService.js';
import { IChatContentPartRenderContext } from '../../../../browser/widget/chatContentParts/chatContentParts.js';
import { ChatQuestionCarouselData } from '../../../../common/model/chatProgressTypes/chatQuestionCarouselData.js';
function createMockCarousel(questions: IChatQuestionCarousel['questions'], allowSkip: boolean = true): IChatQuestionCarousel {
return {
@@ -578,6 +579,69 @@ suite('ChatQuestionCarouselPart', () => {
});
suite('Used Carousel Summary', () => {
test('retains current question after navigation without editing', () => {
const carousel = new ChatQuestionCarouselData([
{ id: 'q1', type: 'text', title: 'Question 1' },
{ id: 'q2', type: 'text', title: 'Question 2' }
], true);
const firstWidget = createWidget(carousel);
const nextButton = firstWidget.domNode.querySelector('.chat-question-nav-next') as HTMLElement | null;
assert.ok(nextButton, 'next button should exist');
nextButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
firstWidget.dispose();
firstWidget.domNode.remove();
const recreatedWidget = createWidget(carousel);
const stepIndicator = recreatedWidget.domNode.querySelector('.chat-question-step-indicator');
assert.strictEqual(stepIndicator?.textContent, '2/2', 'should restore the current question index after navigation');
const title = recreatedWidget.domNode.querySelector('.chat-question-title');
assert.ok(title?.textContent?.includes('Question 2'), 'should restore to the second question view');
});
test('retains draft answers and current question after widget recreation', () => {
const carousel = new ChatQuestionCarouselData([
{ id: 'q1', type: 'text', title: 'Question 1' },
{ id: 'q2', type: 'text', title: 'Question 2' }
], true);
const firstWidget = createWidget(carousel);
const firstInput = firstWidget.domNode.querySelector('.monaco-inputbox input') as HTMLInputElement | null;
assert.ok(firstInput, 'first question input should exist');
firstInput.value = 'first draft answer';
firstInput.dispatchEvent(new Event('input', { bubbles: true }));
const nextButton = firstWidget.domNode.querySelector('.chat-question-nav-next') as HTMLElement | null;
assert.ok(nextButton, 'next button should exist');
nextButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
const secondInput = firstWidget.domNode.querySelector('.monaco-inputbox input') as HTMLInputElement | null;
assert.ok(secondInput, 'second question input should exist');
secondInput.value = 'second draft answer';
secondInput.dispatchEvent(new Event('input', { bubbles: true }));
firstWidget.dispose();
firstWidget.domNode.remove();
const recreatedWidget = createWidget(carousel);
const stepIndicator = recreatedWidget.domNode.querySelector('.chat-question-step-indicator');
assert.strictEqual(stepIndicator?.textContent, '2/2', 'should restore the current question index');
const recreatedSecondInput = recreatedWidget.domNode.querySelector('.monaco-inputbox input') as HTMLInputElement | null;
assert.ok(recreatedSecondInput, 'recreated second question input should exist');
assert.strictEqual(recreatedSecondInput.value, 'second draft answer', 'should restore draft input for current question');
const prevButton = recreatedWidget.domNode.querySelector('.chat-question-nav-prev') as HTMLElement | null;
assert.ok(prevButton, 'previous button should exist');
prevButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
const recreatedFirstInput = recreatedWidget.domNode.querySelector('.monaco-inputbox input') as HTMLInputElement | null;
assert.ok(recreatedFirstInput, 'recreated first question input should exist');
assert.strictEqual(recreatedFirstInput.value, 'first draft answer', 'should restore draft input for previous question');
});
test('shows summary with answers after skip()', () => {
const carousel = createMockCarousel([
{ id: 'q1', type: 'text', title: 'Question 1', defaultValue: 'default answer' }

View File

@@ -52,6 +52,8 @@ suite('ChatQuestionCarouselData', () => {
test('toJSON strips the completion promise', () => {
const carousel = new ChatQuestionCarouselData(createQuestions(), true, 'test-resolve-id', { q1: 'saved' }, true);
carousel.draftAnswers = { q2: 'draft' };
carousel.draftCurrentIndex = 1;
const json = carousel.toJSON();
@@ -60,6 +62,8 @@ suite('ChatQuestionCarouselData', () => {
assert.deepStrictEqual(json.data, { q1: 'saved' });
assert.strictEqual(json.isUsed, true);
assert.strictEqual((json as { completion?: unknown }).completion, undefined, 'toJSON should not include completion');
assert.strictEqual((json as { draftAnswers?: unknown }).draftAnswers, undefined, 'toJSON should not include draftAnswers');
assert.strictEqual((json as { draftCurrentIndex?: unknown }).draftCurrentIndex, undefined, 'toJSON should not include draftCurrentIndex');
});
test('multiple carousels can have independent completion promises', async () => {