From 2eb5903acc2ec89ef6cd7fbf94085ef8c9d117fa Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 5 Nov 2026 03:08:06 +0000 Subject: [PATCH] fix(native-chat): wrap long answers in a question card's answer field (#38325) * fix(native-chat): wrap long answers in a question card's answer field The free-form answer field was a one-line input, so a long answer scrolled sideways out of view. It is now a one-row textarea that grows with its text (CSS field sizing, like the Linear title and agent-args fields) up to six of its own lines, then scrolls. Enter still submits, and a pasted line break becomes a space, so the answer stays one line. The answer ref is typed for the textarea along the card chain. Co-Authored-By: Claude Opus 5.5 * fix(native-chat): keep Undo after a multi-line paste into the answer field Rewriting the field value to strip pasted line breaks cleared the textarea's undo history. A paste that holds line breaks now inserts the one-line text with insertText right after the paste event, so it is one undoable edit and the caret lands after it. The in-place cleanup in onChange stays for dictation and drops. Co-Authored-By: Claude Opus 5.5 * fix(native-chat): keep caret-aware arrows on the wrapping answer field * fix(native-chat): drop the answer field's paste-event path Desktop Cmd+V and menu Paste reach the field through the paste bridge, not a paste event, so the field's onChange is the one place line breaks become spaces. --------- Co-authored-by: Michael Schaecher <276865230+big-sheq@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../native-chat/NativeChatInteractiveCard.tsx | 2 +- .../NativeChatQuestionCard.test.tsx | 70 +++++++++++-------- .../native-chat/NativeChatQuestionCard.tsx | 22 ++++-- .../native-chat/NativeChatResolvedView.tsx | 2 +- .../NativeChatStructuredQuestionCard.tsx | 2 +- .../NativeChatStructuredSession.tsx | 2 +- .../native-chat-root-key-routing.ts | 2 +- .../use-native-chat-paste-bridge.ts | 7 +- .../use-native-chat-question-keys.ts | 5 +- .../use-native-chat-root-input-routing.ts | 2 +- ...e-structured-native-chat-pane-commands.tsx | 2 +- 11 files changed, 72 insertions(+), 46 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx index 893cd7893f8..7fb0dbe1853 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx @@ -30,7 +30,7 @@ export function NativeChatInteractiveCard({ /** Take focus when the card takes the input region from the composer. */ shouldFocus?: boolean /** Paste target while the prompt card replaces the composer. */ - answerInputRef?: React.RefObject + answerInputRef?: React.RefObject } & Pick): React.JSX.Element { const { sendAnswer, sendRawVerified, cancelPending, cancelAsk } = send // Keep paced multi-step answers visible until delivery settles; block duplicate submissions meanwhile. diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx index febdeb3ca19..aaaf364d2a7 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx @@ -87,9 +87,9 @@ function clickAction(text: string): void { } function typeAnswer(value: string): void { - const input = container.querySelector('input')! + const input = container.querySelector('textarea')! act(() => { - const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set! setter.call(input, value) input.dispatchEvent(new Event('input', { bubbles: true })) }) @@ -185,21 +185,38 @@ describe('NativeChatQuestionCard', () => { const onAnswer = vi.fn() render(tabsOrSpaces, onAnswer) - const input = container.querySelector('input')! - act(() => { - const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! - setter.call(input, 'four spaces') - input.dispatchEvent(new Event('input', { bubbles: true })) - }) + typeAnswer('four spaces') clickAction('Submit') expect(onAnswer).toHaveBeenCalledWith([{ indices: [], other: 'four spaces' }]) }) + it('keeps a multi-line answer on one line and still submits on Enter', () => { + const onAnswer = vi.fn() + render(tabsOrSpaces, onAnswer) + + typeAnswer('two\nspaces\u2028please') + const field = container.querySelector('textarea')! + expect(field.value).toBe('two spaces please') + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set! + setter.call(field, 'two\nspaces please') + field.setSelectionRange(4, 4) + field.dispatchEvent(new Event('input', { bubbles: true })) + }) + expect(field.value).toBe('two spaces please') + expect(field.selectionStart).toBe(4) + act(() => { + field.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + }) + + expect(onAnswer).toHaveBeenCalledExactlyOnceWith([{ indices: [], other: 'two spaces please' }]) + }) + it('hides free text when the provider requires a listed option', () => { render(tabsOrSpaces, vi.fn(), false) - expect(container.querySelector('input')).toBeNull() + expect(container.querySelector('textarea')).toBeNull() expect(container.textContent).not.toContain('Type your answer') }) @@ -225,9 +242,9 @@ describe('NativeChatQuestionCard', () => { [false, true] ) - expect(container.querySelector('input')).toBeNull() + expect(container.querySelector('textarea')).toBeNull() clickAction('Skip') - expect(container.querySelector('input')).not.toBeNull() + expect(container.querySelector('textarea')).not.toBeNull() }) it('submits grouped multi-select and free-text answers together', () => { @@ -256,12 +273,7 @@ describe('NativeChatQuestionCard', () => { clickOption('Web') clickOption('Mobile') clickAction('Next') - const input = container.querySelector('input')! - act(() => { - const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! - setter.call(input, 'SSH host') - input.dispatchEvent(new Event('input', { bubbles: true })) - }) + typeAnswer('SSH host') clickAction('Submit') expect(onAnswer).toHaveBeenCalledWith([ @@ -288,7 +300,7 @@ describe('NativeChatQuestionCard', () => { typeAnswer('two spaces') clickOption('Tabs') - expect(container.querySelector('input')!.value).toBe('two spaces') + expect(container.querySelector('textarea')!.value).toBe('two spaces') clickAction('Submit') expect(onAnswer).toHaveBeenCalledWith([{ indices: [0], other: '' }]) @@ -301,7 +313,7 @@ describe('NativeChatQuestionCard', () => { typeAnswer('two spaces') clickOption('Tabs') act(() => { - container.querySelector('input')!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + container.querySelector('textarea')!.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) expect(optionPressed('Tabs')).toBe('false') clickAction('Submit') @@ -326,8 +338,8 @@ describe('NativeChatQuestionCard', () => { typeAnswer('two spaces') clickOption('Tabs') renderCard(true) - // Chromium still delivers pointer events to a disabled input. - const input = container.querySelector('input')! + // Chromium still delivers pointer events to a disabled field. + const input = container.querySelector('textarea')! act(() => { input.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })) input.dispatchEvent(new MouseEvent('click', { bubbles: true })) @@ -343,7 +355,9 @@ describe('NativeChatQuestionCard', () => { typeAnswer('two spaces') clickOption('Tabs') act(() => { - container.querySelector('input')!.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + container + .querySelector('textarea')! + .dispatchEvent(new FocusEvent('focusin', { bubbles: true })) }) clickAction('Submit') @@ -449,7 +463,7 @@ describe('NativeChatQuestionCard', () => { passAutoAdvanceBeat() expect(document.activeElement?.textContent).toContain('Tabs') pressKey({ key: 'ArrowUp' }, document.activeElement!) - expect(document.activeElement).toBe(container.querySelector('input')) + expect(document.activeElement).toBe(container.querySelector('textarea')) pressKey({ key: 'ArrowUp' }, document.activeElement!) expect(document.activeElement?.textContent).toContain('Spaces') pressKey({ key: 'Enter' }, document.activeElement!) @@ -477,7 +491,7 @@ describe('NativeChatQuestionCard', () => { pressKey({ key: 'Enter', ctrlKey: true }, document.activeElement!) expect(optionPressed('Spaces')).toBe('false') pressKey({ key: 'Enter' }, document.activeElement!) - const input = container.querySelector('input')! + const input = container.querySelector('textarea')! act(() => input.focus()) typeAnswer('Other') expect(document.activeElement).toBe(input) @@ -494,7 +508,7 @@ describe('NativeChatQuestionCard', () => { render(tabsOrSpaces, onAnswer, true, true) typeAnswer('two spaces') clickOption('Tabs') - const input = container.querySelector('input')! + const input = container.querySelector('textarea')! input.setSelectionRange(0, 0) const rows = container.querySelectorAll('button[aria-pressed]') act(() => rows[1]!.focus()) @@ -615,7 +629,7 @@ describe('NativeChatQuestionCard', () => { true ) - expect(document.activeElement).toBe(container.querySelector('input')) + expect(document.activeElement).toBe(container.querySelector('textarea')) }) it('moves the keyboard into the answer field when a number key advances to a typed-only question', () => { @@ -637,7 +651,7 @@ describe('NativeChatQuestionCard', () => { passAutoAdvanceBeat() expect(cardTitle()).toBe('Anything else?') - expect(document.activeElement).toBe(container.querySelector('input')) + expect(document.activeElement).toBe(container.querySelector('textarea')) }) it('picks from a number key pressed anywhere inside its own chat pane', () => { @@ -675,7 +689,7 @@ describe('NativeChatQuestionCard', () => { const event = pressKey( init, from === 'answer-field' - ? container.querySelector('input')! + ? container.querySelector('textarea')! : from === 'outside-pane' ? outside : document.body diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx index 1dc22a2de5a..99a3b0f8d49 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx @@ -1,4 +1,4 @@ -import { ImeInput } from '@/lib/ime-text-field' +import { ImeTextarea } from '@/lib/ime-text-field' import { Textarea } from '@/components/ui/textarea' import { useRef, useState, type RefObject } from 'react' import { Check, Pencil, X } from 'lucide-react' @@ -39,12 +39,13 @@ export type NativeChatQuestionCardProps = { /** Take focus when the card takes the input region; number keys pick while it holds. */ shouldFocus?: boolean /** Paste target while the card replaces the composer. */ - answerInputRef?: RefObject + answerInputRef?: RefObject onLinkClick?: CommentMarkdownLinkClickHandler } // Reserve a non-option index so single-select holds either an option or typed text. const TYPED_ANSWER = -1 +const LINE_BREAKS = /[\r\n\u2028\u2029]/g const CONFIRM_LABEL = { submit: () => translate('components.native-chat.question.send', 'Submit'), @@ -319,13 +320,24 @@ export function NativeChatQuestionCard({ placeholder={answerPlaceholder} /> ) : ( - setOther(index, e.target.value)} + // Enter submits, so a line break becomes a space; edit in place so a mid-draft paste keeps the caret. + onChange={(e) => { + const field = e.target + const oneLine = field.value.replace(LINE_BREAKS, ' ') + if (oneLine !== field.value) { + const { selectionStart, selectionEnd } = field + field.value = oneLine + field.setSelectionRange(selectionStart, selectionEnd) + } + setOther(index, oneLine) + }} // Select typed text on click; tabbing toward Submit must not replace a picked option. onClick={() => { autoAdvance.cancel() @@ -341,7 +353,7 @@ export function NativeChatQuestionCard({ }} placeholder={answerPlaceholder} className={cn( - 'min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:cursor-default disabled:opacity-50', + 'scrollbar-sleek max-h-[6lh] min-w-0 flex-1 resize-none overflow-y-auto bg-transparent text-sm outline-none [field-sizing:content] placeholder:text-muted-foreground disabled:cursor-default disabled:opacity-50', currentTypedAnswerChosen || !otherText[index] ? 'text-foreground' : 'text-muted-foreground' diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index e7e0b77b5bd..ff39e45dcb1 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -118,7 +118,7 @@ export function NativeChatResolvedView({ const rootRef = useRef(null) const composerRef = useRef(null) // Keep Paste targeted while the question card replaces the composer. - const questionAnswerInputRef = useRef(null) + const questionAnswerInputRef = useRef(null) const fileLinkContext = useNativeChatFileLinkContext(terminalTabId) const onPaste = useNativeChatPasteBridge({ rootRef, composerRef, questionAnswerInputRef }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredQuestionCard.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredQuestionCard.tsx index 43e5fc153b2..4b95c81772a 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredQuestionCard.tsx @@ -21,7 +21,7 @@ export function NativeChatStructuredQuestionCard({ onCancel: () => void isSubmitting?: boolean shouldFocus?: boolean - answerInputRef?: RefObject + answerInputRef?: RefObject } & Pick): React.JSX.Element { const card = structuredQuestionCard(questions) return ( diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 8669e15ece7..82151fa6f22 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -83,7 +83,7 @@ export function NativeChatStructuredSession(props: Omit nativeChatQueueFollowUps(store.settings)) const composerRef = useRef(null) // Paste target while a question card replaces the composer. - const questionAnswerInputRef = useRef(null) + const questionAnswerInputRef = useRef(null) const { rewindHost, focusComposer } = useNativeChatRewindHost(props, composerRef) const controller = useStructuredAgentSession({ ...props, diff --git a/src/renderer/src/components/native-chat/native-chat-root-key-routing.ts b/src/renderer/src/components/native-chat/native-chat-root-key-routing.ts index 6757fe5725d..7522ea6609c 100644 --- a/src/renderer/src/components/native-chat/native-chat-root-key-routing.ts +++ b/src/renderer/src/components/native-chat/native-chat-root-key-routing.ts @@ -16,7 +16,7 @@ type NativeChatRootKeyEvent = KeyboardRedirectEvent & { export function routeNativeChatRootKeyToInput( event: NativeChatRootKeyEvent, composer: NativeChatComposerHandle | null, - questionAnswerInput: HTMLInputElement | null + questionAnswerInput: HTMLInputElement | HTMLTextAreaElement | null ): void { // A focused transcript or code box owns Space paging; typing elsewhere still reaches the composer. if ( diff --git a/src/renderer/src/components/native-chat/use-native-chat-paste-bridge.ts b/src/renderer/src/components/native-chat/use-native-chat-paste-bridge.ts index 2a342092969..98b6185bba1 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-paste-bridge.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-paste-bridge.ts @@ -19,11 +19,14 @@ type NativeChatPasteBridgeRefs = { rootRef: RefObject composerRef: RefObject /** Paste here while the question card owns input and the composer is unmounted. */ - questionAnswerInputRef?: RefObject + questionAnswerInputRef?: RefObject } /** Text-only on purpose: an answer input takes no image attachments. */ -function pasteClipboardTextInto(input: HTMLInputElement, stillTarget: () => boolean): void { +function pasteClipboardTextInto( + input: HTMLInputElement | HTMLTextAreaElement, + stillTarget: () => boolean +): void { void (async () => { const text = await window.api.ui .readClipboardText({ maxBytes: TEXT_CONTROL_PASTE_MAX_BYTES }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-question-keys.ts b/src/renderer/src/components/native-chat/use-native-chat-question-keys.ts index 81081d925a4..8983cd718bc 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-question-keys.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-question-keys.ts @@ -14,9 +14,7 @@ export function useNativeChatQuestionKeys( const card = cardRef.current const isNumberKey = /^[1-9]$/.test(event.key) const isArrow = event.key === 'ArrowDown' || event.key === 'ArrowUp' - const field = card?.querySelector( - '[data-native-chat-answer-field]' - ) + const field = card?.querySelector('[data-native-chat-answer-field]') const inField = field != null && target === field if ( event.defaultPrevented || @@ -55,7 +53,6 @@ export function useNativeChatQuestionKeys( if ( rows.length === 0 || (inField && - field instanceof HTMLTextAreaElement && (field.selectionStart !== field.selectionEnd || field.selectionStart !== (up ? 0 : field.value.length))) ) { diff --git a/src/renderer/src/components/native-chat/use-native-chat-root-input-routing.ts b/src/renderer/src/components/native-chat/use-native-chat-root-input-routing.ts index 63bc9f06033..88738e58d20 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-root-input-routing.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-root-input-routing.ts @@ -21,7 +21,7 @@ const revealedChatRoots = new Set() /** Route revealed-pane typing/dictation to its input when focus is unowned or within that chat. */ export function useNativeChatRootInputRouting( args: Omit & { - questionAnswerInputRef: RefObject + questionAnswerInputRef: RefObject } ): { onPointerDownCapture: (event: ReactPointerEvent) => void diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.tsx b/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.tsx index 7a64a7d96d4..7a978162420 100644 --- a/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.tsx +++ b/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.tsx @@ -33,7 +33,7 @@ export function useStructuredNativeChatPaneCommands({ ownerWorktreeId: string | null rootRef: RefObject composerRef: RefObject - questionAnswerInputRef: RefObject + questionAnswerInputRef: RefObject } & NativeChatContextMenuTargetArgs) { const { tabId, groupId, isVisible, sessionId, target } = pane const terminalPaneActions = pane.contextMenuActions