From 01f7f4441f25a344220fbab87c04e6111b207469 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Fri, 30 Jan 2026 16:51:07 -0800 Subject: [PATCH 1/2] fix: restore freeform support and summary --- .../chatQuestionCarouselPart.ts | 141 ++++++++++-------- .../media/chatQuestionCarousel.css | 19 +-- .../chatQuestionCarouselPart.test.ts | 27 ++-- 3 files changed, 104 insertions(+), 83 deletions(-) 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 2ccbba1d5ac5e..58b7061e61cb9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -315,10 +315,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent : undefined; const selectedValue = defaultOption?.value; - if (question.allowFreeformInput) { - return selectedValue !== undefined ? { selectedValue, freeformValue: undefined } : undefined; - } - return selectedValue; + // Always return structured format for single-select (freeform is always shown) + return selectedValue !== undefined ? { selectedValue, freeformValue: undefined } : undefined; } case 'multiSelect': { @@ -330,10 +328,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent .map(opt => opt.value) .filter(v => v !== undefined) ?? []; - if (question.allowFreeformInput) { - return selectedValues.length > 0 ? { selectedValues, freeformValue: undefined } : undefined; - } - return selectedValues; + // Always return structured format for multi-select (freeform is always shown) + return selectedValues.length > 0 ? { selectedValues, freeformValue: undefined } : undefined; } default: @@ -446,6 +442,20 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } } + /** + * Sets up auto-resize behavior for a textarea element. + * @returns A function that triggers the resize manually (useful for initial sizing). + */ + private setupTextareaAutoResize(textarea: HTMLTextAreaElement): () => void { + const autoResize = () => { + textarea.style.height = 'auto'; + textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; + this._onDidChangeHeight.fire(); + }; + this._inputBoxes.add(dom.addDisposableListener(textarea, dom.EventType.INPUT, autoResize)); + return autoResize; + } + private renderTextInput(container: HTMLElement, question: IChatQuestion): void { const inputBox = this._inputBoxes.add(new InputBox(container, undefined, { placeholder: localize('chat.questionCarousel.enterText', 'Enter your answer'), @@ -599,21 +609,27 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } })); - // Add freeform input if allowed - if (question.allowFreeformInput) { - const freeformContainer = dom.$('.chat-question-freeform'); + // Always show freeform input for single-select questions + const freeformContainer = dom.$('.chat-question-freeform'); - const freeformTextarea = dom.$('textarea.chat-question-freeform-textarea'); - freeformTextarea.placeholder = localize('chat.questionCarousel.enterCustomAnswer', 'Enter custom answer'); - freeformTextarea.rows = 1; + const freeformTextarea = dom.$('textarea.chat-question-freeform-textarea'); + freeformTextarea.placeholder = localize('chat.questionCarousel.enterCustomAnswer', 'Enter custom answer'); + freeformTextarea.rows = 1; - if (previousFreeform !== undefined) { - freeformTextarea.value = previousFreeform; - } + if (previousFreeform !== undefined) { + freeformTextarea.value = previousFreeform; + } + + // Setup auto-resize behavior + const autoResize = this.setupTextareaAutoResize(freeformTextarea); + + freeformContainer.appendChild(freeformTextarea); + container.appendChild(freeformContainer); + this._freeformTextareas.set(question.id, freeformTextarea); - freeformContainer.appendChild(freeformTextarea); - container.appendChild(freeformContainer); - this._freeformTextareas.set(question.id, freeformTextarea); + // Resize textarea if it has restored content + if (previousFreeform !== undefined) { + this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => autoResize())); } } @@ -736,21 +752,27 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } })); - // Add freeform input if allowed - if (question.allowFreeformInput) { - const freeformContainer = dom.$('.chat-question-freeform'); + // Always show freeform input for multi-select questions + const freeformContainer = dom.$('.chat-question-freeform'); - const freeformTextarea = dom.$('textarea.chat-question-freeform-textarea'); - freeformTextarea.placeholder = localize('chat.questionCarousel.enterCustomAnswer', 'Enter custom answer'); - freeformTextarea.rows = 1; + const freeformTextarea = dom.$('textarea.chat-question-freeform-textarea'); + freeformTextarea.placeholder = localize('chat.questionCarousel.enterCustomAnswer', 'Enter custom answer'); + freeformTextarea.rows = 1; - if (previousFreeform !== undefined) { - freeformTextarea.value = previousFreeform; - } + if (previousFreeform !== undefined) { + freeformTextarea.value = previousFreeform; + } + + // Setup auto-resize behavior + const autoResize = this.setupTextareaAutoResize(freeformTextarea); - freeformContainer.appendChild(freeformTextarea); - container.appendChild(freeformContainer); - this._freeformTextareas.set(question.id, freeformTextarea); + freeformContainer.appendChild(freeformTextarea); + container.appendChild(freeformContainer); + this._freeformTextareas.set(question.id, freeformTextarea); + + // Resize textarea if it has restored content + if (previousFreeform !== undefined) { + this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => autoResize())); } } @@ -778,17 +800,17 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent selectedValue = defaultOption?.value; } - // Include freeform value if allowed - if (question.allowFreeformInput) { - const freeformTextarea = this._freeformTextareas.get(question.id); - const freeformValue = freeformTextarea?.value !== '' ? freeformTextarea?.value : undefined; - if (freeformValue || selectedValue !== undefined) { - return { selectedValue, freeformValue }; - } - return undefined; + // For single-select: if freeform is provided, use ONLY freeform (ignore selection) + const freeformTextarea = this._freeformTextareas.get(question.id); + const freeformValue = freeformTextarea?.value !== '' ? freeformTextarea?.value : undefined; + if (freeformValue) { + // Freeform takes priority - ignore selectedValue + return { selectedValue: undefined, freeformValue }; } - - return selectedValue; + if (selectedValue !== undefined) { + return { selectedValue, freeformValue: undefined }; + } + return undefined; } case 'multiSelect': { @@ -816,17 +838,13 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent finalSelectedValues = defaultValues?.filter(v => v !== undefined) || []; } - // Include freeform value if allowed - if (question.allowFreeformInput) { - const freeformTextarea = this._freeformTextareas.get(question.id); - const freeformValue = freeformTextarea?.value !== '' ? freeformTextarea?.value : undefined; - if (freeformValue || finalSelectedValues.length > 0) { - return { selectedValues: finalSelectedValues, freeformValue }; - } - return undefined; + // Always include freeform value for multi-select questions + const freeformTextarea = this._freeformTextareas.get(question.id); + const freeformValue = freeformTextarea?.value !== '' ? freeformTextarea?.value : undefined; + if (freeformValue || finalSelectedValues.length > 0) { + return { selectedValues: finalSelectedValues, freeformValue }; } - - return finalSelectedValues; + return undefined; } default: @@ -868,7 +886,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Category label (use same text as shown in question UI: message ?? title) const questionLabel = dom.$('span.chat-question-summary-label'); const questionText = question.message ?? question.title; - const labelText = typeof questionText === 'string' ? questionText : questionText.value; + 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); @@ -911,10 +931,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (typeof answer === 'object' && answer !== null && hasKey(answer, { selectedValue: true })) { const { selectedValue, freeformValue } = answer as { selectedValue?: unknown; freeformValue?: string }; const selectedLabel = question.options?.find(opt => opt.value === selectedValue)?.label; + // For singleSelect, freeform takes priority over selection if (freeformValue) { - return selectedLabel - ? localize('chat.questionCarousel.answerWithFreeform', '{0} ({1})', selectedLabel, freeformValue) - : freeformValue; + return freeformValue; } return selectedLabel ?? String(selectedValue ?? ''); } @@ -926,14 +945,12 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (typeof answer === 'object' && answer !== null && hasKey(answer, { selectedValues: true })) { const { selectedValues, freeformValue } = answer as { selectedValues?: unknown[]; freeformValue?: string }; const labels = (selectedValues ?? []) - .map(v => question.options?.find(opt => opt.value === v)?.label ?? String(v)) - .join(localize('chat.questionCarousel.listSeparator', ', ')); + .map(v => question.options?.find(opt => opt.value === v)?.label ?? String(v)); + // For multiSelect, combine selections and freeform with comma separator if (freeformValue) { - return labels - ? localize('chat.questionCarousel.answerWithFreeform', '{0} ({1})', labels, freeformValue) - : freeformValue; + labels.push(freeformValue); } - return labels; + return labels.join(localize('chat.questionCarousel.listSeparator', ', ')); } if (Array.isArray(answer)) { return answer diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 498923e938253..33224486f58b4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -23,17 +23,16 @@ .chat-question-summary-item { display: flex; flex-direction: row; + flex-wrap: wrap; align-items: baseline; gap: 0; font-size: var(--vscode-chat-font-size-body-s); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } .chat-question-summary-label { color: var(--vscode-descriptionForeground); - flex-shrink: 0; + word-wrap: break-word; + overflow-wrap: break-word; } .chat-question-summary-label::after { @@ -44,14 +43,14 @@ .chat-question-summary-answer-title { color: var(--vscode-foreground); font-weight: 600; - flex-shrink: 0; + word-wrap: break-word; + overflow-wrap: break-word; } .chat-question-summary-answer-desc { color: var(--vscode-foreground); - overflow: hidden; - text-overflow: ellipsis; - white-space: pre; + word-wrap: break-word; + overflow-wrap: break-word; } .chat-question-summary-skipped { @@ -421,16 +420,18 @@ .chat-question-freeform-textarea { width: 100%; + min-height: 32px; max-height: 200px; padding: 6px 8px; border: 1px solid var(--vscode-input-border, var(--vscode-chat-requestBorder)); background-color: var(--vscode-input-background); color: var(--vscode-input-foreground); border-radius: 4px; - resize: vertical; + resize: none; font-family: var(--vscode-chat-font-family, inherit); font-size: var(--vscode-chat-font-size-body-s); box-sizing: border-box; + overflow-y: hidden; } .chat-question-freeform-textarea:focus { 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 fa1721d9ccdfd..25f48c8a5bb49 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 @@ -413,9 +413,10 @@ suite('ChatQuestionCarouselPart', () => { widget.skip(); assert.ok(submittedAnswers instanceof Map); - // When allowFreeformInput is not set, the answer is just the value (not wrapped) - const answer = submittedAnswers?.get('q1'); - assert.strictEqual(answer, 'value_b'); + // singleSelect always returns structured format with freeformValue + const answer = submittedAnswers?.get('q1') as { selectedValue: unknown; freeformValue: unknown }; + assert.strictEqual(answer.selectedValue, 'value_b'); + assert.strictEqual(answer.freeformValue, undefined); }); test('skip returns default values for multiSelect questions', () => { @@ -436,12 +437,13 @@ suite('ChatQuestionCarouselPart', () => { widget.skip(); assert.ok(submittedAnswers instanceof Map); - // When allowFreeformInput is not set, the answer is just the array of values (not wrapped) - const answer = submittedAnswers?.get('q1') as unknown[]; - assert.ok(Array.isArray(answer)); - assert.strictEqual(answer.length, 2); - assert.ok(answer.includes('value_a')); - assert.ok(answer.includes('value_c')); + // multiSelect always returns structured format with freeformValue + const answer = submittedAnswers?.get('q1') as { selectedValues: unknown[]; freeformValue: unknown }; + assert.ok(Array.isArray(answer.selectedValues)); + assert.strictEqual(answer.selectedValues.length, 2); + assert.ok(answer.selectedValues.includes('value_a')); + assert.ok(answer.selectedValues.includes('value_c')); + assert.strictEqual(answer.freeformValue, undefined); }); test('skip returns defaults for multiple questions', () => { @@ -462,9 +464,10 @@ suite('ChatQuestionCarouselPart', () => { widget.skip(); assert.ok(submittedAnswers instanceof Map); assert.strictEqual(submittedAnswers?.get('q1'), 'text default'); - // When allowFreeformInput is not set, the answer is just the value (not wrapped) - const answer = submittedAnswers?.get('q2'); - assert.strictEqual(answer, 'first_value'); + // singleSelect always returns structured format with freeformValue + const answer = submittedAnswers?.get('q2') as { selectedValue: unknown; freeformValue: unknown }; + assert.strictEqual(answer.selectedValue, 'first_value'); + assert.strictEqual(answer.freeformValue, undefined); }); test('skip returns empty map when no defaults are provided', () => { From 6da97b852db38fe088c72aa7e065339b7c6ea5fc Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Fri, 30 Jan 2026 16:59:54 -0800 Subject: [PATCH 2/2] add more tests --- .../chatQuestionCarouselPart.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) 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 25f48c8a5bb49..c83ae7241ff63 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 @@ -153,6 +153,40 @@ suite('ChatQuestionCarouselPart', () => { assert.strictEqual(checkboxes.length, 3, 'Should have 3 checkboxes'); }); + test('freeform textarea is always rendered for singleSelect', () => { + const carousel = createMockCarousel([ + { + id: 'q1', + type: 'singleSelect', + title: 'Choose one', + options: [ + { id: 'a', label: 'Option A', value: 'a' } + ] + } + ]); + createWidget(carousel); + + const freeformTextarea = widget.domNode.querySelector('.chat-question-freeform-textarea'); + assert.ok(freeformTextarea, 'Freeform textarea should always be rendered for singleSelect'); + }); + + test('freeform textarea is always rendered for multiSelect', () => { + const carousel = createMockCarousel([ + { + id: 'q1', + type: 'multiSelect', + title: 'Choose multiple', + options: [ + { id: 'a', label: 'Option A', value: 'a' } + ] + } + ]); + createWidget(carousel); + + const freeformTextarea = widget.domNode.querySelector('.chat-question-freeform-textarea'); + assert.ok(freeformTextarea, 'Freeform textarea should always be rendered for multiSelect'); + }); + test('default options are pre-selected for singleSelect', () => { const carousel = createMockCarousel([ {