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 @@ -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;
Comment thread
karthiknadig marked this conversation as resolved.
}

case 'multiSelect': {
Expand All @@ -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:
Expand Down Expand Up @@ -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;
Comment on lines +445 to +456
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setupTextareaAutoResize method registers the INPUT event listener with this._inputBoxes.add(), but the returned autoResize function is called manually in lines 632 and 775. These manual calls use dom.runAtThisOrScheduleAtNextAnimationFrame() which also gets registered to this._inputBoxes.

This is correct and follows the disposable pattern, but the method documentation could be clearer about the lifecycle: the INPUT listener is for automatic resizing during user input, while the returned function is for manual resizing (e.g., when restoring content). Consider adding this clarification to the JSDoc comment.

Copilot uses AI. Check for mistakes.
}

private renderTextInput(container: HTMLElement, question: IChatQuestion): void {
const inputBox = this._inputBoxes.add(new InputBox(container, undefined, {
placeholder: localize('chat.questionCarousel.enterText', 'Enter your answer'),
Expand Down Expand Up @@ -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');
Comment thread
karthiknadig marked this conversation as resolved.

const freeformTextarea = dom.$<HTMLTextAreaElement>('textarea.chat-question-freeform-textarea');
freeformTextarea.placeholder = localize('chat.questionCarousel.enterCustomAnswer', 'Enter custom answer');
freeformTextarea.rows = 1;
const freeformTextarea = dom.$<HTMLTextAreaElement>('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()));
}
}

Expand Down Expand Up @@ -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.$<HTMLTextAreaElement>('textarea.chat-question-freeform-textarea');
freeformTextarea.placeholder = localize('chat.questionCarousel.enterCustomAnswer', 'Enter custom answer');
freeformTextarea.rows = 1;
const freeformTextarea = dom.$<HTMLTextAreaElement>('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()));
}
}

Expand Down Expand Up @@ -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;
Comment thread
karthiknadig marked this conversation as resolved.
}

case 'multiSelect': {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]+$/, '');
Comment thread
karthiknadig marked this conversation as resolved.
questionLabel.textContent = labelText;
summaryItem.appendChild(questionLabel);

Expand Down Expand Up @@ -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 ?? '');
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand Down Expand Up @@ -413,9 +447,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', () => {
Expand All @@ -436,12 +471,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', () => {
Expand All @@ -462,9 +498,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', () => {
Expand Down
Loading