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 ? ( - + ) : (