mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 02:17:31 +00:00
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:
co-authored by
Claude Opus 5.5
Michael Schaecher
parent
bbe34a04f1
commit
2eb5903acc
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user