mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-02 08:15:56 +01:00
retain input in question carousel (#298795)
* retain input state for chat questions * better approach
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user