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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
This commit is contained in:
Kelvin Amoaba
2026-11-05 03:08:06 +00:00
committed by GitHub
co-authored by Claude Opus 5.5 Michael Schaecher
parent bbe34a04f1
commit 2eb5903acc
11 changed files with 72 additions and 46 deletions
@@ -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<HTMLInputElement | null>
answerInputRef?: React.RefObject<HTMLTextAreaElement | null>
} & Pick<NativeChatQuestionCardProps, 'onLinkClick'>): React.JSX.Element {
const { sendAnswer, sendRawVerified, cancelPending, cancelAsk } = send
// Keep paced multi-step answers visible until delivery settles; block duplicate submissions meanwhile.
@@ -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<HTMLButtonElement>('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
@@ -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<HTMLInputElement | null>
answerInputRef?: RefObject<HTMLTextAreaElement | null>
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}
/>
) : (
<ImeInput
<ImeTextarea
ref={answerInputRef}
rows={1}
data-native-chat-answer-field
data-native-chat-prompt-card-focus-target={fieldTakesFocus}
disabled={isSubmitting}
value={otherText[index]}
onChange={(e) => 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'
@@ -118,7 +118,7 @@ export function NativeChatResolvedView({
const rootRef = useRef<HTMLDivElement>(null)
const composerRef = useRef<NativeChatComposerHandle>(null)
// Keep Paste targeted while the question card replaces the composer.
const questionAnswerInputRef = useRef<HTMLInputElement>(null)
const questionAnswerInputRef = useRef<HTMLTextAreaElement>(null)
const fileLinkContext = useNativeChatFileLinkContext(terminalTabId)
const onPaste = useNativeChatPasteBridge({ rootRef, composerRef, questionAnswerInputRef })
@@ -21,7 +21,7 @@ export function NativeChatStructuredQuestionCard({
onCancel: () => void
isSubmitting?: boolean
shouldFocus?: boolean
answerInputRef?: RefObject<HTMLInputElement | null>
answerInputRef?: RefObject<HTMLTextAreaElement | null>
} & Pick<NativeChatQuestionCardProps, 'onLinkClick'>): React.JSX.Element {
const card = structuredQuestionCard(questions)
return (
@@ -83,7 +83,7 @@ export function NativeChatStructuredSession(props: Omit<NativeChatStructuredView
const queueFollowUps = useAppStore((store) => nativeChatQueueFollowUps(store.settings))
const composerRef = useRef<NativeChatComposerHandle>(null)
// Paste target while a question card replaces the composer.
const questionAnswerInputRef = useRef<HTMLInputElement>(null)
const questionAnswerInputRef = useRef<HTMLTextAreaElement>(null)
const { rewindHost, focusComposer } = useNativeChatRewindHost(props, composerRef)
const controller = useStructuredAgentSession({
...props,
@@ -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 (
@@ -19,11 +19,14 @@ type NativeChatPasteBridgeRefs = {
rootRef: RefObject<HTMLDivElement | null>
composerRef: RefObject<NativeChatComposerHandle | null>
/** Paste here while the question card owns input and the composer is unmounted. */
questionAnswerInputRef?: RefObject<HTMLInputElement | null>
questionAnswerInputRef?: RefObject<HTMLInputElement | HTMLTextAreaElement | null>
}
/** 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 })
@@ -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<HTMLInputElement | HTMLTextAreaElement>(
'[data-native-chat-answer-field]'
)
const field = card?.querySelector<HTMLTextAreaElement>('[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)))
) {
@@ -21,7 +21,7 @@ const revealedChatRoots = new Set<HTMLElement>()
/** Route revealed-pane typing/dictation to its input when focus is unowned or within that chat. */
export function useNativeChatRootInputRouting(
args: Omit<NativeChatComposerRevealFocusArgs, 'scheduleFrame'> & {
questionAnswerInputRef: RefObject<HTMLInputElement | null>
questionAnswerInputRef: RefObject<HTMLInputElement | HTMLTextAreaElement | null>
}
): {
onPointerDownCapture: (event: ReactPointerEvent) => void
@@ -33,7 +33,7 @@ export function useStructuredNativeChatPaneCommands({
ownerWorktreeId: string | null
rootRef: RefObject<HTMLDivElement | null>
composerRef: RefObject<NativeChatComposerHandle | null>
questionAnswerInputRef: RefObject<HTMLInputElement | null>
questionAnswerInputRef: RefObject<HTMLInputElement | HTMLTextAreaElement | null>
} & NativeChatContextMenuTargetArgs) {
const { tabId, groupId, isVisible, sessionId, target } = pane
const terminalPaneActions = pane.contextMenuActions