mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 16:02:03 +00:00
fix(native-chat): let the "Asked" row unfold a long question (#22941)
* fix(native-chat): let the "Asked" row unfold a long question The question row clipped long questions to one line with no way to read the rest. The row is now a toggle that wraps the full question in place. * fix(native-chat): open a clipped question below its toggle, selectable The first cut put the whole question inside the toggle button, where Chromium will not select text: dragging or double-clicking in it selected nothing and toggled the row instead. The open state also lived in the row, so scrolling the row out of the windowed transcript, or answering the question, folded it again. - The full question now opens in a paragraph below the button, outside it, so it selects and copies like any other prose. - The toggle is offered only when the question is actually clipped; a question that fits stays plain, selectable text as before. - Open state is kept in the transcript's disclosure store, keyed by the message, so it survives windowing and the pending-to-answered remount. - The chevron also shows on keyboard focus and on touch screens.
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
NATIVE_CHAT_ASK_ROW_COPY,
|
||||
type NativeChatAskRowSubject
|
||||
} from '../../../../shared/native-chat-ask-row'
|
||||
import { useNativeChatDisclosure } from './native-chat-disclosure-store'
|
||||
import { NativeChatToolRunIcon } from './NativeChatToolIcon'
|
||||
|
||||
const ROW_CLASS_NAME =
|
||||
'flex min-h-6 w-full items-center gap-1.5 py-0.5 text-left text-sm leading-relaxed text-muted-foreground'
|
||||
|
||||
/**
|
||||
* The row a question tool call draws in place of its raw input. The agent is
|
||||
* blocked on the reader, so the row says that in plain words and names what was
|
||||
@@ -13,16 +19,46 @@ import { NativeChatToolRunIcon } from './NativeChatToolIcon'
|
||||
*
|
||||
* Only the label breathes: the question is the part worth reading, and animating
|
||||
* it would make the one line the reader has to act on the hardest one to read.
|
||||
*
|
||||
* A question too long for the line becomes a disclosure. Once answered, this row
|
||||
* is the only place the question is still shown, so the full text opens below the
|
||||
* toggle, outside it, where it can be selected and copied like any other prose.
|
||||
*/
|
||||
export function NativeChatAwaitingInputRow({
|
||||
subject,
|
||||
pending
|
||||
pending,
|
||||
disclosureKey
|
||||
}: {
|
||||
/** Null when the payload named no question; the label carries the row alone. */
|
||||
subject: NativeChatAskRowSubject | null
|
||||
/** Still waiting on an answer; a settled prompt reports what was asked. */
|
||||
pending: boolean
|
||||
/** Identity the opened question is remembered under while the row is unmounted. */
|
||||
disclosureKey?: string
|
||||
}): React.JSX.Element {
|
||||
const { open, setOpen } = useNativeChatDisclosure(disclosureKey, false)
|
||||
// Seeded from `open`: a row remounted open was clipped when the reader opened
|
||||
// it, and dropping the toggle as it folds would drop keyboard focus with it.
|
||||
const [clipped, setClipped] = useState(open)
|
||||
const observerRef = useRef<ResizeObserver | null>(null)
|
||||
// Attached to the question only while it sits on the line; an open row keeps its verdict.
|
||||
const measureLine = useCallback((line: HTMLSpanElement | null) => {
|
||||
observerRef.current?.disconnect()
|
||||
observerRef.current = null
|
||||
if (!line) {
|
||||
return
|
||||
}
|
||||
const measure = (): void => setClipped(line.scrollWidth > line.clientWidth)
|
||||
measure()
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
observerRef.current = new ResizeObserver(measure)
|
||||
observerRef.current.observe(line)
|
||||
}, [])
|
||||
|
||||
const question = subject?.kind === 'question' ? subject.text : null
|
||||
const toggles = question !== null && (open || clipped)
|
||||
const label = pending
|
||||
? translate('components.native-chat.ask.awaiting', NATIVE_CHAT_ASK_ROW_COPY.awaiting)
|
||||
: translate('components.native-chat.ask.asked', NATIVE_CHAT_ASK_ROW_COPY.asked)
|
||||
@@ -36,18 +72,60 @@ export function NativeChatAwaitingInputRow({
|
||||
NATIVE_CHAT_ASK_ROW_COPY.questionCount,
|
||||
{ value0: subject.count }
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-6 w-full items-center gap-1.5 py-0.5 text-sm leading-relaxed text-muted-foreground"
|
||||
data-native-chat-ask-row={pending ? 'awaiting' : 'asked'}
|
||||
aria-live={pending ? 'polite' : undefined}
|
||||
>
|
||||
const header = (
|
||||
<>
|
||||
<NativeChatToolRunIcon iconName="message-square-more" className="text-muted-foreground" />
|
||||
<span className={cn('shrink-0', pending && 'animate-pulse motion-reduce:animate-none')}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-foreground/85">{text}</span>
|
||||
{toggles && open ? null : (
|
||||
<span
|
||||
ref={question === null ? undefined : measureLine}
|
||||
className="min-w-0 truncate text-foreground/85"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
{toggles ? (
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 text-muted-foreground transition-all',
|
||||
open
|
||||
? 'rotate-90'
|
||||
: 'can-hover:opacity-0 group-hover/ask-row:opacity-100 group-focus-visible/ask-row:opacity-100'
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-native-chat-ask-row={pending ? 'awaiting' : 'asked'}
|
||||
aria-live={pending ? 'polite' : undefined}
|
||||
>
|
||||
{toggles ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
ROW_CLASS_NAME,
|
||||
'group/ask-row rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70'
|
||||
)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{header}
|
||||
</button>
|
||||
) : (
|
||||
<div className={ROW_CLASS_NAME}>{header}</div>
|
||||
)}
|
||||
{toggles && open ? (
|
||||
// Indented to the label, past the icon slot and its gap.
|
||||
<p className="whitespace-pre-wrap break-words pl-5.5 text-sm leading-relaxed text-foreground/85">
|
||||
{question}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { i18n } from '@/i18n/i18n'
|
||||
import type { AgentJournalQuestionItem } from '../../../../shared/agent-session-journal-types'
|
||||
import { encodeAgentSessionQuestionAnswers } from '../../../../shared/agent-session-question-answer'
|
||||
import { NativeChatResolutionReceipt } from './NativeChatResolutionReceipt'
|
||||
import {
|
||||
NativeChatDisclosureContext,
|
||||
useNativeChatDisclosures
|
||||
} from './native-chat-disclosure-store'
|
||||
import {
|
||||
nativeChatReceiptAnswers,
|
||||
type NativeChatResolvedPrompt
|
||||
@@ -210,6 +214,48 @@ describe('resolution receipts', () => {
|
||||
expect(screen.queryByText('1 grouped question from Claude')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an opened question open once it is answered', () => {
|
||||
const scrollWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollWidth')
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
|
||||
configurable: true,
|
||||
get: () => 400
|
||||
})
|
||||
const pendingBody: AgentJournalQuestionItem = {
|
||||
kind: 'question',
|
||||
question: 'Which of the three migration strategies should I use?',
|
||||
options: [{ id: 'a', label: 'Strategy A' }],
|
||||
resolution: { ...approval.resolution, state: 'pending', selectedOptionId: null }
|
||||
}
|
||||
function Harness({ body }: { body: AgentJournalQuestionItem }): React.JSX.Element {
|
||||
const disclosures = useNativeChatDisclosures()
|
||||
return (
|
||||
<NativeChatDisclosureContext.Provider value={disclosures}>
|
||||
<NativeChatResolutionReceipt body={body} disclosureId="message-1" />
|
||||
</NativeChatDisclosureContext.Provider>
|
||||
)
|
||||
}
|
||||
try {
|
||||
const { rerender } = render(<Harness body={pendingBody} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Awaiting user input:/ }))
|
||||
|
||||
rerender(
|
||||
<Harness
|
||||
body={{ ...pendingBody, resolution: { ...approval.resolution, selectedOptionId: 'a' } }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: /Asked:/ })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
)
|
||||
} finally {
|
||||
if (scrollWidth) {
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', scrollWidth)
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, 'scrollWidth')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('decodes single free-text answers only for the declared question', () => {
|
||||
const body: AgentJournalQuestionItem = {
|
||||
kind: 'question',
|
||||
|
||||
@@ -8,10 +8,14 @@ import {
|
||||
} from './native-chat-resolution-receipt'
|
||||
|
||||
export function NativeChatResolutionReceipt({
|
||||
body
|
||||
body,
|
||||
disclosureId
|
||||
}: {
|
||||
body: NativeChatResolvedPrompt
|
||||
/** Message this receipt stands in for; keys the question row's disclosure. */
|
||||
disclosureId?: string
|
||||
}): React.JSX.Element | null {
|
||||
const askDisclosureKey = disclosureId === undefined ? undefined : `ask:${disclosureId}`
|
||||
const subject: NativeChatAskRowSubject | null =
|
||||
body.kind !== 'question'
|
||||
? null
|
||||
@@ -31,7 +35,7 @@ export function NativeChatResolutionReceipt({
|
||||
}
|
||||
if (body.resolution.state === 'pending') {
|
||||
return body.kind === 'question' ? (
|
||||
<NativeChatAwaitingInputRow subject={subject} pending />
|
||||
<NativeChatAwaitingInputRow subject={subject} pending disclosureKey={askDisclosureKey} />
|
||||
) : null
|
||||
}
|
||||
const { resolution } = body
|
||||
@@ -43,7 +47,11 @@ export function NativeChatResolutionReceipt({
|
||||
data-native-chat-receipt={body.kind}
|
||||
>
|
||||
{body.kind === 'question' ? (
|
||||
<NativeChatAwaitingInputRow pending={false} subject={subject} />
|
||||
<NativeChatAwaitingInputRow
|
||||
pending={false}
|
||||
subject={subject}
|
||||
disclosureKey={askDisclosureKey}
|
||||
/>
|
||||
) : (
|
||||
<div className="font-medium">{title}</div>
|
||||
)}
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { NativeChatBlock } from '../../../../shared/native-chat-types'
|
||||
import { NativeChatToolRun } from './NativeChatToolRun'
|
||||
import {
|
||||
NativeChatDisclosureContext,
|
||||
useNativeChatDisclosures
|
||||
} from './native-chat-disclosure-store'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -16,6 +20,46 @@ function askBlocks(state: 'running' | 'completed'): NativeChatBlock[] {
|
||||
return [{ type: 'tool-call', name: 'AskUserQuestion', input: ASK_INPUT, state }]
|
||||
}
|
||||
|
||||
/** Lays every element out wider than its box, as a long question is on its line. */
|
||||
function clipEveryLine(): () => void {
|
||||
const originals = ['scrollWidth', 'clientWidth'].map(
|
||||
(name) => [name, Object.getOwnPropertyDescriptor(HTMLElement.prototype, name)] as const
|
||||
)
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
|
||||
configurable: true,
|
||||
get: () => 400
|
||||
})
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||
configurable: true,
|
||||
get: () => 100
|
||||
})
|
||||
return () => {
|
||||
for (const [name, original] of originals) {
|
||||
if (original) {
|
||||
Object.defineProperty(HTMLElement.prototype, name, original)
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function DisclosureHarness({ mounted }: { mounted: boolean }): React.JSX.Element {
|
||||
const disclosures = useNativeChatDisclosures()
|
||||
return (
|
||||
<NativeChatDisclosureContext.Provider value={disclosures}>
|
||||
{mounted ? (
|
||||
<NativeChatToolRun
|
||||
blocks={askBlocks('completed')}
|
||||
expandSignal
|
||||
activeTurnIsWorking={false}
|
||||
disclosureId="message-1"
|
||||
/>
|
||||
) : null}
|
||||
</NativeChatDisclosureContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('NativeChatToolRun awaiting-input row', () => {
|
||||
it('does not revive stale tool state after a turn stops', () => {
|
||||
render(
|
||||
@@ -80,9 +124,89 @@ describe('NativeChatToolRun awaiting-input row', () => {
|
||||
expect(screen.getByText('Asked:')).not.toHaveClass('animate-pulse')
|
||||
expect(screen.getByText(QUESTION)).toBeInTheDocument()
|
||||
// A run that is only the ask has no work left to head, so it draws no header.
|
||||
expect(container.querySelector('[data-native-chat-tool-run-state]')).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a question that fits on its line as plain text', () => {
|
||||
const { container } = render(
|
||||
<NativeChatToolRun blocks={askBlocks('completed')} expandSignal activeTurnIsWorking={false} />
|
||||
)
|
||||
expect(screen.getByText(QUESTION)).toHaveClass('truncate')
|
||||
expect(container.querySelector('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens a clipped question below the toggle and folds it back', () => {
|
||||
const restore = clipEveryLine()
|
||||
try {
|
||||
render(
|
||||
<NativeChatToolRun
|
||||
blocks={askBlocks('completed')}
|
||||
expandSignal
|
||||
activeTurnIsWorking={false}
|
||||
/>
|
||||
)
|
||||
const toggle = screen.getByRole('button', { name: /Asked:/ })
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
const full = screen.getByText(QUESTION)
|
||||
expect(full).not.toHaveClass('truncate')
|
||||
// Outside the button, so it selects like prose and a click in it keeps it open.
|
||||
expect(full.closest('button')).toBeNull()
|
||||
fireEvent.click(full)
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.getByText(QUESTION)).toHaveClass('truncate')
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps an opened question open when the windowed row remounts', () => {
|
||||
const restore = clipEveryLine()
|
||||
try {
|
||||
const { rerender } = render(<DisclosureHarness mounted />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Asked:/ }))
|
||||
|
||||
rerender(<DisclosureHarness mounted={false} />)
|
||||
expect(screen.queryByText(QUESTION)).toBeNull()
|
||||
rerender(<DisclosureHarness mounted />)
|
||||
|
||||
const toggle = screen.getByRole('button', { name: /Asked:/ })
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText(QUESTION).closest('button')).toBeNull()
|
||||
|
||||
// Folding it keeps the same control, so keyboard focus is not dropped.
|
||||
toggle.focus()
|
||||
fireEvent.click(toggle)
|
||||
expect(document.activeElement).toBe(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('offers no expansion when the row names only a question count', () => {
|
||||
const restore = clipEveryLine()
|
||||
try {
|
||||
const input = { questions: [{ question: 'First?' }, { question: 'Second?' }] }
|
||||
render(
|
||||
<NativeChatToolRun
|
||||
blocks={[{ type: 'tool-call', name: 'AskUserQuestion', input, state: 'completed' }]}
|
||||
expandSignal
|
||||
activeTurnIsWorking={false}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('2 questions')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Asked:/ })).toBeNull()
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('counts only the work that ran in the header beside the ask', () => {
|
||||
const blocks: NativeChatBlock[] = [
|
||||
{ type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' }, state: 'completed' },
|
||||
|
||||
@@ -217,7 +217,11 @@ export function NativeChatToolRun({
|
||||
<div className="mt-3">
|
||||
{standaloneRows}
|
||||
{hasAskCall ? (
|
||||
<NativeChatAwaitingInputRow subject={askSubject} pending={askIsActive} />
|
||||
<NativeChatAwaitingInputRow
|
||||
subject={askSubject}
|
||||
pending={askIsActive}
|
||||
disclosureKey={disclosureId === undefined ? undefined : `ask:${disclosureId}`}
|
||||
/>
|
||||
) : null}
|
||||
{!showsHeader ? null : (
|
||||
// One element for the run's whole life. Live and settled are states of
|
||||
|
||||
@@ -45,7 +45,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{receipt ? (
|
||||
<NativeChatResolutionReceipt body={receipt} />
|
||||
<NativeChatResolutionReceipt body={receipt} disclosureId={message.id} />
|
||||
) : (
|
||||
<MessageRow
|
||||
message={message}
|
||||
|
||||
Reference in New Issue
Block a user