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 fbdfccb2402..5ce3306ae08 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -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 | 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(); + } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index c3ae8477d42..f0af7f89d3d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -2158,11 +2158,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer | 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 | undefined }>(); + public draftAnswers: Record | undefined; + public draftCurrentIndex: number | 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 66d10c9b487..10045c7dce3 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 @@ -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' } diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts index 1c022960b23..5707ebbe4ab 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts @@ -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 () => {