From b8ef5fcbc21d5c219a4e40c5be80c1ff3af7053b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:18:13 -0700 Subject: [PATCH] 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. --- .../NativeChatAwaitingInputRow.tsx | 96 +++++++++++-- .../NativeChatResolutionReceipt.test.tsx | 48 ++++++- .../NativeChatResolutionReceipt.tsx | 14 +- .../NativeChatToolRun.ask-row.test.tsx | 126 +++++++++++++++++- .../native-chat/NativeChatToolRun.tsx | 6 +- .../native-chat/NativeChatTranscriptRow.tsx | 2 +- 6 files changed, 276 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx b/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx index 0f9dab5720b..964a1f57a95 100644 --- a/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx @@ -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(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 ( -
+ const header = ( + <> {label} - {text} + {toggles && open ? null : ( + + {text} + + )} + {toggles ? ( + + ) : null} + + ) + + return ( +
+ {toggles ? ( + + ) : ( +
{header}
+ )} + {toggles && open ? ( + // Indented to the label, past the icon slot and its gap. +

+ {question} +

+ ) : null}
) } diff --git a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx index fcf13956b9c..cac8ebdce0d 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx @@ -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 ( + + + + ) + } + try { + const { rerender } = render() + fireEvent.click(screen.getByRole('button', { name: /Awaiting user input:/ })) + + rerender( + + ) + 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', diff --git a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx index d6587fe9f00..f390df76cdb 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx @@ -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' ? ( - + ) : null } const { resolution } = body @@ -43,7 +47,11 @@ export function NativeChatResolutionReceipt({ data-native-chat-receipt={body.kind} > {body.kind === 'question' ? ( - + ) : (
{title}
)} diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx index 4de5a0d214b..870bf39a409 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx @@ -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 ( + + {mounted ? ( + + ) : null} + + ) +} + 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( + + ) + 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( + + ) + 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() + fireEvent.click(screen.getByRole('button', { name: /Asked:/ })) + + rerender() + expect(screen.queryByText(QUESTION)).toBeNull() + rerender() + + 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( + + ) + 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' }, diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index bb8d9f96302..00971927fe6 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -217,7 +217,11 @@ export function NativeChatToolRun({
{standaloneRows} {hasAskCall ? ( - + ) : null} {!showsHeader ? null : ( // One element for the run's whole life. Live and settled are states of diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx index 266f528ebfd..feb2ea7721d 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx @@ -45,7 +45,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ return (
{receipt ? ( - + ) : (