Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Comment thread
meganrogge marked this conversation as resolved.

/**
Expand All @@ -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);
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -716,6 +745,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
if (data) {
data.selectedIndex = newIndex;
}

this.saveCurrentAnswer();
};

options.forEach((option, index) => {
Expand Down Expand Up @@ -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();
}
}));

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
}
10 changes: 6 additions & 4 deletions src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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' }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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 () => {
Expand Down
Loading