From b3f114a5067917dc11415edbe2536035d718b435 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Thu, 29 Jan 2026 11:49:39 -0800 Subject: [PATCH] feat: implement auto-resize for freeform textarea in ask questions (#291680) * feat: implement auto-resize for freeform textarea in chat question carousel * fix: use auto exapnding textarea for all freeform input * review comments * fix: tests --- .../chatQuestionCarouselPart.ts | 76 +++++++++++++++---- .../media/chatQuestionCarousel.css | 13 +++- .../chatQuestionCarouselPart.test.ts | 4 +- 3 files changed, 72 insertions(+), 21 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 2986268d84f..90ab82a4b99 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -13,9 +13,8 @@ import { hasKey } from '../../../../../../base/common/types.js'; import { localize } from '../../../../../../nls.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRendererService } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; -import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../../platform/theme/browser/defaultStyles.js'; +import { defaultButtonStyles } from '../../../../../../platform/theme/browser/defaultStyles.js'; import { Button } from '../../../../../../base/browser/ui/button/button.js'; -import { InputBox } from '../../../../../../base/browser/ui/inputbox/inputBox.js'; import { IChatQuestion, IChatQuestionCarousel } from '../../../common/chatService/chatService.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; import { ChatQueryTitlePart } from './chatConfirmationWidget.js'; @@ -47,7 +46,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private _isSkipped = false; - private readonly _textInputBoxes: Map = new Map(); + private readonly _textInputTextareas: Map = new Map(); private readonly _radioInputs: Map = new Map(); private readonly _checkboxInputs: Map = new Map(); private readonly _freeformTextareas: Map = new Map(); @@ -233,7 +232,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Dispose interactive UI disposables (header, nav buttons, etc.) this._interactiveUIStore.clear(); this._inputBoxes.clear(); - this._textInputBoxes.clear(); + this._textInputTextareas.clear(); this._radioInputs.clear(); this._checkboxInputs.clear(); this._freeformTextareas.clear(); @@ -352,7 +351,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Clear previous input boxes and stale references this._inputBoxes.clear(); - this._textInputBoxes.clear(); + this._textInputTextareas.clear(); this._radioInputs.clear(); this._checkboxInputs.clear(); this._freeformTextareas.clear(); @@ -423,24 +422,55 @@ 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'), - inputBoxStyles: defaultInputBoxStyles, - })); + const textarea = dom.$('textarea.chat-question-text-textarea'); + textarea.placeholder = localize('chat.questionCarousel.enterText', 'Enter your answer'); + textarea.rows = 1; + textarea.setAttribute('aria-label', question.title); // Restore previous answer if exists const previousAnswer = this._answers.get(question.id); if (previousAnswer !== undefined) { - inputBox.value = String(previousAnswer); + textarea.value = String(previousAnswer); } else if (question.defaultValue !== undefined) { - inputBox.value = String(question.defaultValue); + textarea.value = String(question.defaultValue); } - this._textInputBoxes.set(question.id, inputBox); + // Setup auto-resize behavior + const autoResize = this.setupTextareaAutoResize(textarea); + + // Handle Enter to submit (Shift+Enter for newline) + this._inputBoxes.add(dom.addDisposableListener(textarea, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.Enter && !event.shiftKey && textarea.value.trim()) { + e.preventDefault(); + e.stopPropagation(); + this.handleNext(); + } + })); + + container.appendChild(textarea); + this._textInputTextareas.set(question.id, textarea); // Focus on input when rendered using proper DOM scheduling - this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(inputBox.element), () => inputBox.focus())); + this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(textarea), () => { + textarea.focus(); + autoResize(); + })); } private renderSingleSelect(container: HTMLElement, question: IChatQuestion): void { @@ -520,6 +550,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } })); + // Setup auto-resize behavior + const autoResize = this.setupTextareaAutoResize(freeformTextarea); + // uncheck radio when there is text this._inputBoxes.add(dom.addDisposableListener(freeformTextarea, dom.EventType.INPUT, () => { if (freeformTextarea.value.trim()) { @@ -532,6 +565,11 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent 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())); + } } } @@ -613,11 +651,19 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } })); + // Setup auto-resize behavior + const autoResize = this.setupTextareaAutoResize(freeformTextarea); + // For multiSelect, both checkboxes and freeform input are combined, so don't uncheck on input 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())); + } } } @@ -629,8 +675,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent switch (question.type) { case 'text': { - const inputBox = this._textInputBoxes.get(question.id); - return inputBox?.value ?? question.defaultValue; + const textarea = this._textInputTextareas.get(question.id); + return textarea?.value ?? question.defaultValue; } case 'singleSelect': { 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 772b09d77c7..9d2697de0f7 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 @@ -222,25 +222,30 @@ color: var(--vscode-descriptionForeground); } -.chat-question-freeform-textarea { +.chat-question-freeform-textarea, +.chat-question-text-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 { +.chat-question-freeform-textarea:focus, +.chat-question-text-textarea:focus { outline: 1px solid var(--vscode-focusBorder); border-color: var(--vscode-focusBorder); } -.chat-question-freeform-textarea::placeholder { +.chat-question-freeform-textarea::placeholder, +.chat-question-text-textarea::placeholder { color: var(--vscode-input-placeholderForeground); } 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 552a3b3daf2..304216ea7f6 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 @@ -96,8 +96,8 @@ suite('ChatQuestionCarouselPart', () => { const inputContainer = widget.domNode.querySelector('.chat-question-input-container'); assert.ok(inputContainer); - const inputBox = inputContainer?.querySelector('.monaco-inputbox'); - assert.ok(inputBox, 'Should have an input box for text questions'); + const textarea = inputContainer?.querySelector('textarea.chat-question-text-textarea'); + assert.ok(textarea, 'Should have a textarea for text questions'); }); test('renders radio buttons for singleSelect type questions', () => {