From 2911a2a99554dc291d63144bb3bfc97c75301256 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:54:37 -0700 Subject: [PATCH] refactor(native-chat): draw the live line from one shared value, with one live region Desktop and mobile now render the live activity line from one pure function, `nativeChatLiveLine`: whether it draws, what it says, and the open reasoning block it discloses with the text it has so far. The open block's row is hidden from that same value, so desktop no longer restates the line's render condition beside it, and the lines no longer re-derive the text. The line keeps one element, and so one live region, through every state; only its trigger and body come and go, so a screen reader hears "Thinking" and the label after it. On mobile the live text gets the finished row's Android long press (copy or select through the message actions sheet), the line's touch target is the row's 44 pt, and its label and body sit in the finished row's column so nothing moves when the row takes over. --- .../MobileNativeChatLiveLine.android.test.ts | 77 +++++++++++ .../session/MobileNativeChatLiveLine.test.ts | 127 +++++++++++++----- .../src/session/MobileNativeChatLiveLine.tsx | 79 +++++++---- .../session/MobileNativeChatMessage.test.ts | 11 ++ .../MobileNativeChatTurnStatus.test.ts | 40 +----- .../session/MobileNativeChatTurnStatus.tsx | 27 +--- .../src/session/MobileNativeChatView.test.ts | 4 +- .../MobileNativeChatView.turn-status.test.ts | 4 +- .../MobileNativeChatView.waiting-rows.test.ts | 5 +- ...obile-native-chat-turn-disclosure.test.tsx | 10 +- .../use-mobile-native-chat-turn-disclosure.ts | 52 ++++--- ...iveChatMessageList.live-reasoning.test.tsx | 28 ++++ .../native-chat/NativeChatMessageList.tsx | 15 ++- .../NativeChatReasoningDisclosure.tsx | 6 +- .../NativeChatTurnActivityLine.tsx | 116 ++++++---------- .../use-native-chat-transcript-slots.ts | 38 +++--- src/shared/native-chat-live-line.test.ts | 55 ++++++++ src/shared/native-chat-live-line.ts | 38 ++++++ src/shared/native-chat-reasoning-row.test.ts | 37 +++-- src/shared/native-chat-reasoning-row.ts | 26 ++-- 20 files changed, 499 insertions(+), 296 deletions(-) create mode 100644 mobile/src/session/MobileNativeChatLiveLine.android.test.ts create mode 100644 src/shared/native-chat-live-line.test.ts create mode 100644 src/shared/native-chat-live-line.ts diff --git a/mobile/src/session/MobileNativeChatLiveLine.android.test.ts b/mobile/src/session/MobileNativeChatLiveLine.android.test.ts new file mode 100644 index 00000000000..6100fd92218 --- /dev/null +++ b/mobile/src/session/MobileNativeChatLiveLine.android.test.ts @@ -0,0 +1,77 @@ +import { createElement, type ReactNode } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatLiveReasoning } from '../../../src/shared/native-chat-reasoning-row' + +// Why a separate file: Android has no inline selection, so the live text is copied the way a +// finished row's is, through the message actions sheet. +vi.mock('react-native', async () => { + const React = await import('react') + const host = + (name: string) => + ({ children, ...props }: { children?: ReactNode }): ReactNode => + React.createElement(name, props, children) + return { + ActivityIndicator: host('ActivityIndicator'), + Platform: { OS: 'android' }, + Pressable: host('Pressable'), + Text: host('Text'), + View: host('View'), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 } + } +}) +vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight' })) +vi.mock('./MobileNativeChatReasoningRow', () => ({ + MobileNativeChatReasoningBody: 'ReasoningBody' +})) +vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ + MobileNativeChatMessageActionsSheet: 'MessageActionsSheet' +})) + +import { MobileNativeChatLiveLine } from './MobileNativeChatLiveLine' + +const block: NativeChatLiveReasoning = { + message: { + id: 'r-1', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }], + timestamp: null, + source: 'transcript', + state: 'running' + }, + markdown: 'Weighing two approaches' +} + +describe('MobileNativeChatLiveLine on Android', () => { + let renderer: ReactTestRenderer | null = null + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + const byType = (type: string): ReactTestInstance[] => + renderer!.root.findAll((node) => String(node.type) === type) + + it('opens the actions sheet for the live block from a long press on its text', () => { + act(() => { + renderer = create( + createElement(MobileNativeChatLiveLine, { + line: { thinking: true, activityText: null, reasoning: block, reasoningExpanded: true }, + onToggleReasoning: vi.fn(), + fontScale: 1 + }) + ) + }) + expect(byType('MessageActionsSheet')).toHaveLength(0) + const [body] = byType('ReasoningBody') + expect(typeof body?.props.onLongPress).toBe('function') + + act(() => body!.props.onLongPress()) + const [sheet] = byType('MessageActionsSheet') + // The sheet copies and selects the message's own text: the block as it stands. + expect(sheet?.props.message).toBe(block.message) + + act(() => sheet!.props.onClose()) + expect(byType('MessageActionsSheet')).toHaveLength(0) + }) +}) diff --git a/mobile/src/session/MobileNativeChatLiveLine.test.ts b/mobile/src/session/MobileNativeChatLiveLine.test.ts index 6e4ff4ad8ba..37bcc460ca5 100644 --- a/mobile/src/session/MobileNativeChatLiveLine.test.ts +++ b/mobile/src/session/MobileNativeChatLiveLine.test.ts @@ -1,7 +1,9 @@ import { createElement, type ReactNode } from 'react' import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { NativeChatLiveReasoning } from '../../../src/shared/native-chat-reasoning-row' + +const pressableMounts = vi.hoisted(() => ({ count: 0 })) vi.mock('react-native', async () => { const React = await import('react') @@ -9,9 +11,17 @@ vi.mock('react-native', async () => { (name: string) => ({ children, ...props }: { children?: ReactNode }): ReactNode => React.createElement(name, props, children) + // Counts mounts, so a test can tell the live region was kept rather than replaced. + const Pressable = ({ children, ...props }: { children?: ReactNode }): ReactNode => { + React.useEffect(() => { + pressableMounts.count += 1 + }, []) + return React.createElement('Pressable', props, children) + } return { ActivityIndicator: host('ActivityIndicator'), - Pressable: host('Pressable'), + Platform: { OS: 'ios' }, + Pressable, Text: host('Text'), View: host('View'), StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 } @@ -21,16 +31,22 @@ vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight' })) vi.mock('./MobileNativeChatReasoningRow', () => ({ MobileNativeChatReasoningBody: 'ReasoningBody' })) +vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ + MobileNativeChatMessageActionsSheet: 'MessageActionsSheet' +})) import { MobileNativeChatLiveLine } from './MobileNativeChatLiveLine' -const block: NativeChatMessage = { - id: 'r-1', - role: 'reasoning', - blocks: [{ type: 'text', text: 'Weighing two approaches' }], - timestamp: null, - source: 'transcript', - state: 'running' +const block: NativeChatLiveReasoning = { + message: { + id: 'r-1', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }], + timestamp: null, + source: 'transcript', + state: 'running' + }, + markdown: 'Weighing two approaches' } describe('MobileNativeChatLiveLine', () => { @@ -38,48 +54,83 @@ describe('MobileNativeChatLiveLine', () => { afterEach(() => { act(() => renderer?.unmount()) renderer = null + pressableMounts.count = 0 }) - function render(reasoning: NativeChatMessage | null, reasoningExpanded = false) { - const onToggleReasoning = vi.fn() - act(() => { - renderer = create( - createElement(MobileNativeChatLiveLine, { - line: { thinking: true, activityText: null, reasoning, reasoningExpanded }, - onToggleReasoning, - fontScale: 1 - }) - ) + const onToggleReasoning = vi.fn() + function element( + fields: { + thinking?: boolean + activityText?: string | null + reasoning?: NativeChatLiveReasoning | null + reasoningExpanded?: boolean + } = {} + ) { + return createElement(MobileNativeChatLiveLine, { + line: { + thinking: true, + activityText: null, + reasoning: null, + reasoningExpanded: false, + ...fields + }, + onToggleReasoning, + fontScale: 1 }) - return { root: renderer!.root, onToggleReasoning } + } + function render(fields: Parameters[0] = {}): ReactTestInstance { + act(() => { + renderer = create(element(fields)) + }) + return renderer!.root } const byType = (root: ReactTestInstance, type: string): ReactTestInstance[] => root.findAll((node) => String(node.type) === type) const labels = (root: ReactTestInstance): string[] => byType(root, 'Text').map((text) => String(text.children.join(''))) - const buttons = (root: ReactTestInstance): ReactTestInstance[] => - root.findAll( - (node) => String(node.type) === 'Pressable' && node.props.accessibilityRole === 'button' - ) + const header = (root: ReactTestInstance): ReactTestInstance => + root.find((node) => String(node.type) === 'Pressable') - it('is the plain live line, not a button, while no open block has text', () => { - const { root } = render(null) + it('reads "Thinking" beside one spinner while the turn reasons', () => { + const root = render() expect(labels(root)).toEqual(['Thinking']) - expect(buttons(root)).toHaveLength(0) + expect(byType(root, 'ActivityIndicator')).toHaveLength(1) + }) + + // The bar owns the clock; the tail line never repeats it. + it('reads plain "Working…" when the turn is not reasoning, and holds no timer', () => { + vi.useFakeTimers() + const root = render({ thinking: false }) + expect(labels(root)).toEqual(['Working…']) + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + + it('lets provider activity text beat both fallbacks', () => { + expect(labels(render({ activityText: 'Running pnpm test' }))).toEqual(['Running pnpm test']) + }) + + it('announces what it says to assistive tech, and is no button while it discloses nothing', () => { + const line = header(render()) + expect(line.props.accessibilityLiveRegion).toBe('polite') + expect(line.props.accessibilityLabel).toBe('Thinking') + expect(line.props.accessibilityRole).toBeUndefined() + expect(line.props.onPress).toBeUndefined() }) it('discloses the open block under one "Thinking", collapsed, toggled by its block key', () => { - const { root, onToggleReasoning } = render(block) + const root = render({ reasoning: block }) expect(labels(root)).toEqual(['Thinking']) - const [toggle] = buttons(root) - expect(toggle?.props.accessibilityState).toEqual({ expanded: false }) + const line = header(root) + expect(line.props.accessibilityRole).toBe('button') + expect(line.props.accessibilityState).toEqual({ expanded: false }) expect(byType(root, 'ReasoningBody')).toHaveLength(0) - act(() => toggle?.props.onPress()) + act(() => line.props.onPress()) expect(onToggleReasoning).toHaveBeenCalledWith('reasoning:r-1') }) it('shows the live text outside the live region once opened', () => { - const { root } = render(block, true) + const root = render({ reasoning: block, reasoningExpanded: true }) const [body] = byType(root, 'ReasoningBody') expect(body?.props.markdown).toBe('Weighing two approaches') let ancestor = body?.parent ?? null @@ -87,6 +138,16 @@ describe('MobileNativeChatLiveLine', () => { expect(ancestor.props.accessibilityLiveRegion).toBeUndefined() ancestor = ancestor.parent } - expect(buttons(root)[0]?.props.accessibilityLiveRegion).toBe('polite') + // iOS selects inline, so the body takes no long press. + expect(body?.props.onLongPress).toBeUndefined() + }) + + it('keeps one live region while it turns into the disclosure and back', () => { + render({ thinking: false }) + act(() => renderer!.update(element({ reasoning: block }))) + expect(header(renderer!.root).props.accessibilityLabel).toBe('Thinking') + act(() => renderer!.update(element({ thinking: false }))) + expect(header(renderer!.root).props.accessibilityLabel).toBe('Working…') + expect(pressableMounts.count).toBe(1) }) }) diff --git a/mobile/src/session/MobileNativeChatLiveLine.tsx b/mobile/src/session/MobileNativeChatLiveLine.tsx index bb4af046f5b..439b4d4925e 100644 --- a/mobile/src/session/MobileNativeChatLiveLine.tsx +++ b/mobile/src/session/MobileNativeChatLiveLine.tsx @@ -1,15 +1,18 @@ +import { useCallback, useState } from 'react' import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native' import { ChevronRight } from 'lucide-react-native' import { nativeChatReasoningDisclosureKey } from '../../../src/shared/native-chat-reasoning-row' -import { deriveNativeChatRowContent } from '../../../src/shared/native-chat-row-content' import { formatNativeChatActiveTurnLabel } from '../../../src/shared/native-chat-turn-status' +import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection' import { colors, spacing, typography } from '../theme/mobile-theme' +import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet' import { MobileNativeChatReasoningBody } from './MobileNativeChatReasoningRow' -import { MobileNativeChatTurnActivity } from './MobileNativeChatTurnStatus' import type { MobileNativeChatLiveLine as LiveLine } from './use-mobile-native-chat-turn-disclosure' -/** The live turn's tail line. While the agent's open reasoning block has text it is also that - * block's disclosure, and the block's row draws nothing. Desktop parity: `NativeChatTurnActivityLine`. */ +/** The live turn's tail line: a spinner beside what the provider says it is doing, else + * "Thinking", else "Working…". The clock stays in the turn bar. While the agent's open reasoning + * block has text it is also that block's disclosure, and the block's row draws nothing. + * Desktop parity: `NativeChatTurnActivityLine`. */ export function MobileNativeChatLiveLine({ line, onToggleReasoning, @@ -21,55 +24,77 @@ export function MobileNativeChatLiveLine({ fontScale: number onOpenFile?: (relativePath: string) => void }): React.JSX.Element { - const { reasoning, reasoningExpanded: open } = line - if (!reasoning) { - return ( - - ) - } + const { reasoning, reasoningExpanded } = line + const open = reasoning !== null && reasoningExpanded const label = formatNativeChatActiveTurnLabel(line) + // Android has no inline selection; the finished row's long-press sheet copies the live text too. + const [actionsOpen, setActionsOpen] = useState(false) + const openActions = useCallback(() => setActionsOpen(true), []) return ( + {/* One element for every state of the line, so TalkBack hears each new label; the body + sits outside it. */} [styles.row, pressed && styles.pressed]} - // The finished row reads this key too, so a block opened here lands open once it ends. - onPress={() => onToggleReasoning(nativeChatReasoningDisclosureKey(reasoning.id))} + style={({ pressed }) => [styles.row, reasoning && pressed && styles.pressed]} + onPress={ + reasoning + ? () => onToggleReasoning(nativeChatReasoningDisclosureKey(reasoning.message.id)) + : undefined + } + // With the row's 32 pt height, the 44 pt target the reasoning row has. hitSlop={6} - accessibilityRole="button" - accessibilityState={{ expanded: open }} + accessibilityRole={reasoning ? 'button' : undefined} + accessibilityState={reasoning ? { expanded: open } : undefined} accessibilityLabel={label} - // Only the line is live: the streaming body below would be re-announced on every frame. accessibilityLiveRegion="polite" > - + + + {label} - - - + {reasoning ? ( + + + + ) : null} - {open ? ( + {reasoning && open ? ( ) : null} + {reasoning && actionsOpen ? ( + setActionsOpen(false)} + /> + ) : null} ) } +// The finished reasoning row's geometry (row gutter, 15 pt glyph slot, its gap, its 32 pt height), +// so the label and the open text do not move when that row takes over. const styles = StyleSheet.create({ - // Matches `MobileNativeChatTurnActivity`'s row, so the line does not move when it turns expandable. row: { flexDirection: 'row', alignItems: 'center', - gap: spacing.xs, - minHeight: 28, - paddingHorizontal: spacing.md + gap: spacing.sm, + minHeight: 32, + paddingHorizontal: spacing.lg + }, + // The spinner is wider than the brain; centred in the brain's slot it keeps the text column. + glyph: { + width: 15, + alignItems: 'center', + overflow: 'visible' }, pressed: { opacity: 0.6 @@ -83,6 +108,6 @@ const styles = StyleSheet.create({ transform: [{ rotate: '90deg' }] }, body: { - paddingHorizontal: spacing.md + paddingHorizontal: spacing.lg } }) diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 572b95915f3..d353244c78c 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -380,6 +380,17 @@ describe('MobileNativeChatMessage', () => { expect(render(reasoning({ blocks: [{ type: 'text', text: ' \n ' }] })).toJSON()).toBeNull() }) + // The turn's bar is not the block's: hiding the block must not hide the bar it sits on. + it("still draws its turn's bar while the live line discloses it", () => { + const tree = render(reasoning({ state: 'running' }), { + activeTurnIsWorking: true, + reasoningIsLive: true, + turnStatus: { startedAt: 1_000, thinking: true, workedSeconds: null } + }) + expect(tree.root.findAll((node) => String(node.type) === 'Pressable')).toHaveLength(0) + expect(textIn(tree.root).some((text) => text.startsWith('Working for'))).toBe(true) + }) + // Only the block the line discloses hides: a subagent's or a stale open block draws, unended. it('draws any other open block in its working turn as Reasoning', () => { const child = render(reasoning({ state: 'running', agentId: 'sub-1' }), { diff --git a/mobile/src/session/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/MobileNativeChatTurnStatus.test.ts index a0393996cec..2506ab6e311 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.test.ts +++ b/mobile/src/session/MobileNativeChatTurnStatus.test.ts @@ -19,10 +19,7 @@ vi.mock('react-native', async () => { }) vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight' })) -import { - MobileNativeChatTurnActivity, - MobileNativeChatTurnStatus -} from './MobileNativeChatTurnStatus' +import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus' const labels = (node: ReactTestInstance): string[] => node.findAllByType('Text' as never).map((text) => String(text.children.join(''))) @@ -108,38 +105,3 @@ describe('MobileNativeChatTurnStatus', () => { expect(vi.getTimerCount()).toBe(0) }) }) - -describe('MobileNativeChatTurnActivity', () => { - function render(props: { thinking: boolean; activityText?: string | null }): ReactTestRenderer { - act(() => { - renderer = create(createElement(MobileNativeChatTurnActivity, props)) - }) - return renderer! - } - - it('reads "Thinking" beside one spinner while the turn reasons', () => { - const tree = render({ thinking: true }) - expect(labels(tree.root)).toEqual(['Thinking']) - expect(spinners(tree.root)).toHaveLength(1) - }) - - // The bar owns the clock; the tail line never repeats it. - it('reads plain "Working…" when the turn is not reasoning, and holds no timer', () => { - const tree = render({ thinking: false }) - expect(labels(tree.root)).toEqual(['Working…']) - expect(vi.getTimerCount()).toBe(0) - }) - - it('lets provider activity text beat both fallbacks', () => { - const tree = render({ thinking: true, activityText: 'Running pnpm test' }) - expect(labels(tree.root)).toEqual(['Running pnpm test']) - expect(spinners(tree.root)).toHaveLength(1) - }) - - it('announces the live line to assistive tech', () => { - const tree = render({ thinking: true }) - const row = tree.root.findByType('View' as never) - expect(row.props.accessibilityLiveRegion).toBe('polite') - expect(row.props.accessibilityLabel).toBe('Agent is responding') - }) -}) diff --git a/mobile/src/session/MobileNativeChatTurnStatus.tsx b/mobile/src/session/MobileNativeChatTurnStatus.tsx index a39e404c639..9bb6a5c9e0f 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.tsx +++ b/mobile/src/session/MobileNativeChatTurnStatus.tsx @@ -1,8 +1,7 @@ import { useEffect, useState } from 'react' -import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' import { ChevronRight } from 'lucide-react-native' import { - formatNativeChatActiveTurnLabel, formatNativeChatTurnStatusLabel, NATIVE_CHAT_TURN_STATUS_COPY, nativeChatElapsedSeconds @@ -76,30 +75,6 @@ export function MobileNativeChatTurnStatus({ ) } -/** The live turn's tail line: a spinner beside what the provider says it is doing, - * else "Thinking", else "Working…". The clock stays in the turn bar. Desktop - * parity: `NativeChatTurnActivityLine`. */ -export function MobileNativeChatTurnActivity({ - thinking, - activityText -}: { - thinking: boolean - activityText?: string | null -}): React.JSX.Element { - return ( - - - - {formatNativeChatActiveTurnLabel({ activityText, thinking })} - - - ) -} - const styles = StyleSheet.create({ row: { flexDirection: 'row', diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index e16445521ac..d96c9179b08 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -47,9 +47,7 @@ vi.mock('lucide-react-native', () => ({ })) vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) -vi.mock('./MobileNativeChatReasoningRow', () => ({ - MobileNativeChatReasoningBody: 'ReasoningBody' -})) +vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) diff --git a/mobile/src/session/MobileNativeChatView.turn-status.test.ts b/mobile/src/session/MobileNativeChatView.turn-status.test.ts index 818c1b8e5a6..47137a7deaa 100644 --- a/mobile/src/session/MobileNativeChatView.turn-status.test.ts +++ b/mobile/src/session/MobileNativeChatView.turn-status.test.ts @@ -47,9 +47,7 @@ vi.mock('lucide-react-native', () => ({ })) vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) -vi.mock('./MobileNativeChatReasoningRow', () => ({ - MobileNativeChatReasoningBody: 'ReasoningBody' -})) +vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) diff --git a/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts b/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts index d0dacd67988..f4916c0a324 100644 --- a/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts +++ b/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts @@ -37,10 +37,7 @@ vi.mock('lucide-react-native', () => ({ Square: 'Square' })) vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) -vi.mock('./MobileNativeChatReasoningRow', () => ({ - MobileNativeChatReasoningBody: 'ReasoningBody' -})) -vi.mock('./MobileNativeChatTurnStatus', () => ({ MobileNativeChatTurnActivity: 'LiveStatus' })) +vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) vi.mock('./MobileNativeChatComposer', () => ({ MobileNativeChatComposer: 'Composer' })) // The queue's action sheet pulls in the animation runtime, which this react-native mock can't host. vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx index aa0d7b2b1d6..5a29875b08c 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx @@ -802,7 +802,10 @@ describe('the open reasoning block the live line discloses', () => { it('hides only that block, and lands it open once it ends if the reader opened it live', () => { const prompt = userMessage('u1') show([prompt, block('r-1', 'running')], { thinking: true }) - expect(latest().liveLine).toMatchObject({ reasoning: { id: 'r-1' }, reasoningExpanded: false }) + expect(latest().liveLine).toMatchObject({ + reasoning: { message: { id: 'r-1' } }, + reasoningExpanded: false + }) expect(latest().resolveRow(1, block('r-1', 'running')).reasoningIsLive).toBe(true) act(() => latest().onToggleReasoning('reasoning:r-1')) expect(latest().liveLine.reasoningExpanded).toBe(true) @@ -815,7 +818,10 @@ describe('the open reasoning block the live line discloses', () => { // The next block starts collapsed. show([prompt, block('r-1', 'completed'), block('r-2', 'running')], { thinking: true }) - expect(latest().liveLine).toMatchObject({ reasoning: { id: 'r-2' }, reasoningExpanded: false }) + expect(latest().liveLine).toMatchObject({ + reasoning: { message: { id: 'r-2' } }, + reasoningExpanded: false + }) }) it('discloses nothing, and hides nothing, while a prompt takes the line', () => { diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts index e95b7150d12..4c7262efc70 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -1,9 +1,10 @@ import { useCallback, useMemo, useState } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { nativeChatReasoningDisclosureKey } from '../../../src/shared/native-chat-reasoning-row' import { - nativeChatReasoningDisclosureKey, - selectNativeChatLiveReasoning -} from '../../../src/shared/native-chat-reasoning-row' + nativeChatLiveLine, + type NativeChatLiveLine +} from '../../../src/shared/native-chat-live-line' import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' import { isNativeChatRowInLiveWorkingTurn, @@ -39,13 +40,8 @@ export type MobileNativeChatTurnRow = { onToggleReasoning: (key: string) => void } -/** The live activity line, when it draws. `reasoning`: the open block it discloses. */ -export type MobileNativeChatLiveLine = { - thinking: boolean - activityText: string | null - reasoning: NativeChatMessage | null - reasoningExpanded: boolean -} +/** The live activity line, when it draws, and whether the reader opened the block it discloses. */ +export type MobileNativeChatLiveLine = NativeChatLiveLine & { reasoningExpanded: boolean } /** Keys the reader opened in this chat, bounded; another chat's never leak in. */ function useScopedOpenKeys(scopeKey: string): [ReadonlySet, (key: string) => void] { @@ -166,27 +162,27 @@ export function useMobileNativeChatTurnDisclosure({ [enabled, isWorking, liveTurnKey, turnKeys] ) const activeActivityText = enabled && isWorking ? (activityText ?? null) : null - const showsLiveLine = enabled && isWorking && !lineYields && active !== null - // The line's own render condition, so a row is never hidden while nothing on screen discloses it. - const lineShowsThinking = showsLiveLine && active?.thinking === true - const liveReasoning = useMemo( - () => selectNativeChatLiveReasoning(rows, inLiveWorkingTurn, lineShowsThinking), - [inLiveWorkingTurn, lineShowsThinking, rows] + const line = useMemo( + () => + nativeChatLiveLine({ + draws: enabled && isWorking && !lineYields && active !== null, + thinking: active?.thinking === true, + activityText: activeActivityText, + messages: rows, + inLiveWorkingTurn + }), + [active, activeActivityText, enabled, inLiveWorkingTurn, isWorking, lineYields, rows] ) - const liveReasoningId = liveReasoning?.id + const liveReasoningId = line?.reasoning?.message.id const liveLine = useMemo( () => - showsLiveLine - ? { - thinking: active?.thinking === true, - activityText: activeActivityText, - reasoning: liveReasoning, - reasoningExpanded: - liveReasoning !== null && - expandedReasoning.has(nativeChatReasoningDisclosureKey(liveReasoning.id)) - } - : null, - [active, activeActivityText, expandedReasoning, liveReasoning, showsLiveLine] + line && { + ...line, + reasoningExpanded: + line.reasoning !== null && + expandedReasoning.has(nativeChatReasoningDisclosureKey(line.reasoning.message.id)) + }, + [expandedReasoning, line] ) const resolveRow = useCallback( (listIndex: number, message: NativeChatMessage): MobileNativeChatTurnRow => { diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx index 09b8523a5cf..39fe487478b 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx @@ -263,4 +263,32 @@ describe('live reasoning, read through the one live line', () => { fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) expect(screen.getAllByText('Checking the background build')).toHaveLength(1) }) + + // A live region only announces changes to itself; a replaced one says nothing. + it('keeps one live region while the line turns into the disclosure and back', () => { + const tool: NativeChatMessage = { + id: 'tool-1', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'completed' }], + timestamp: STARTED, + source: 'transcript' + } + const later = { timestamp: STARTED + 50 } + const region = () => document.querySelector('[data-native-chat-turn-activity][aria-live]') + const { rerender } = render(list([tool])) + const before = region() + expect(before).toHaveTextContent('Working…') + rerender(list([tool, reasoning('r-1', 'Weighing two approaches', 'running', later)])) + expect(region()).toBe(before) + expect(before).toHaveTextContent('Thinking') + rerender( + list([ + tool, + reasoning('r-1', 'Weighing two approaches', 'completed', later), + { ...tool, id: 'tool-2', timestamp: STARTED + 60 } + ]) + ) + expect(region()).toBe(before) + expect(before).toHaveTextContent('Working…') + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index bbfd7f59290..98470c15167 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -184,7 +184,7 @@ export function NativeChatMessageList({ ? 'activity' : null const lifecycleWorking = session.transcriptLifecycle?.state === 'working' - const { slots: allSlots, liveReasoning } = useNativeChatTranscriptSlots({ + const { slots: allSlots, liveLine } = useNativeChatTranscriptSlots({ messages: rows, turnKeys, liveTurnKey, @@ -196,8 +196,11 @@ export function NativeChatMessageList({ lifecycleWorking, subagentSections, subagentChoices, - // The line's own render condition, so a row is never hidden while nothing on screen discloses it. - lineShowsThinking: tailRow === 'activity' && turnStatuses.active?.thinking === true + line: { + draws: tailRow === 'activity', + thinking: turnStatuses.active?.thinking === true, + activityText: turnActivity?.text + } }) // A message waiting behind the live turn draws after that turn's live activity, not inside it. const { slots, waitingSlots } = useMemo( @@ -383,11 +386,9 @@ export function NativeChatMessageList({ context={rowContext} window={transcriptWindow} /> - {tailRow === 'activity' ? ( + {liveLine ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatReasoningDisclosure.tsx b/src/renderer/src/components/native-chat/NativeChatReasoningDisclosure.tsx index c04049abefa..adb5e6d9a1f 100644 --- a/src/renderer/src/components/native-chat/NativeChatReasoningDisclosure.tsx +++ b/src/renderer/src/components/native-chat/NativeChatReasoningDisclosure.tsx @@ -30,13 +30,13 @@ export function NativeChatReasoningBody({ ) } -/** The disclosure caret of a `group/reasoning` header: shown on hover, keyboard focus and touch, - * and turned while open. */ +/** The disclosure caret of a `group/reasoning` header: shown on hover, keyboard focus (on the + * header or a trigger inside it) and touch, and turned while open. */ export function NativeChatReasoningChevron(): React.JSX.Element { return ( ) } diff --git a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx index feefa5cfdd6..a56a5065be5 100644 --- a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx @@ -1,12 +1,11 @@ +import { useId } from 'react' import { Loader2 } from 'lucide-react' import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { translate } from '@/i18n/i18n' -import type { NativeChatTurnActivity } from '../../../../shared/native-chat-turn-activity' -import { describeNativeChatActiveTurnLabel } from '../../../../shared/native-chat-turn-status' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { deriveNativeChatRowContent } from '../../../../shared/native-chat-row-content' +import type { NativeChatLiveLine } from '../../../../shared/native-chat-live-line' import { nativeChatReasoningDisclosureKey } from '../../../../shared/native-chat-reasoning-row' +import { describeNativeChatActiveTurnLabel } from '../../../../shared/native-chat-turn-status' import { NativeChatReasoningBody, NativeChatReasoningChevron @@ -18,94 +17,67 @@ import { useNativeChatDisclosure } from './native-chat-disclosure-store' * The clock lives in the turn bar under the user's message, not here. While the * agent's open reasoning block has text, the line is that block's disclosure. */ export function NativeChatTurnActivityLine({ - activity, - thinking, - liveReasoning = null, + line, onLinkClick, allowFileUriLinks }: { - activity?: NativeChatTurnActivity | null - thinking: boolean - /** The open block this line discloses; its row draws nothing meanwhile. */ - liveReasoning?: NativeChatMessage | null + line: NativeChatLiveLine onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean }): React.JSX.Element { - const resolved = describeNativeChatActiveTurnLabel({ activityText: activity?.text, thinking }) + const resolved = describeNativeChatActiveTurnLabel(line) const label = resolved.source === 'activity' ? resolved.text : resolved.key === 'thinking' ? translate('components.native-chat.status.thinking', 'Thinking') : translate('components.native-chat.status.working', 'Working…') - - if (liveReasoning) { - return ( - - ) - } - return ( -
- - {label} -
- ) -} - -function NativeChatTurnActivityDisclosure({ - label, - reasoning, - onLinkClick, - allowFileUriLinks -}: { - label: string - reasoning: NativeChatMessage - onLinkClick?: CommentMarkdownLinkClickHandler - allowFileUriLinks?: boolean -}): React.JSX.Element { + const { reasoning } = line // The finished row reads this key too, so a block opened here lands open once it ends. - const disclosure = useNativeChatDisclosure(nativeChatReasoningDisclosureKey(reasoning.id), false) + const disclosure = useNativeChatDisclosure( + reasoning ? nativeChatReasoningDisclosureKey(reasoning.message.id) : undefined, + false + ) + const open = reasoning !== null && disclosure.open + const labelId = useId() + return ( -
- - - - + +
+ {reasoning ? ( - - + ) : null} + ) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-slots.ts index 44b2401d63f..bd5404f69a5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-slots.ts @@ -1,22 +1,23 @@ import { useMemo } from 'react' -import { selectNativeChatLiveReasoning } from '../../../../shared/native-chat-reasoning-row' +import { + nativeChatLiveLine, + type NativeChatLiveLine +} from '../../../../shared/native-chat-live-line' import { isNativeChatRowInLiveWorkingTurn } from '../../../../shared/native-chat-turn-membership' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { buildNativeChatTranscriptSlots, type NativeChatTranscriptSlot, type NativeChatTranscriptSlotsInput } from './native-chat-transcript-slots' -/** The transcript's slots, and the open reasoning block the live activity line discloses instead - * of a slot. Decided together, so a row is hidden exactly while the line shows it. */ +/** The transcript's slots and its live activity line, decided together: the open reasoning block + * the line discloses takes no slot, so a row is hidden exactly while the line shows it. */ export function useNativeChatTranscriptSlots({ - lineShowsThinking, + line, ...input }: Omit & { - /** The live line's own render condition and that it reads "Thinking". */ - lineShowsThinking: boolean -}): { slots: NativeChatTranscriptSlot[]; liveReasoning: NativeChatMessage | null } { + line: { draws: boolean; thinking: boolean; activityText?: string | null } +}): { slots: NativeChatTranscriptSlot[]; liveLine: NativeChatLiveLine | null } { const { messages, turnKeys, @@ -30,21 +31,24 @@ export function useNativeChatTranscriptSlots({ subagentSections, subagentChoices } = input - const liveReasoning = useMemo( + const { draws, thinking, activityText } = line + const liveLine = useMemo( () => - selectNativeChatLiveReasoning( + nativeChatLiveLine({ + draws, + thinking, + activityText, messages, - (index) => + inLiveWorkingTurn: (index) => isNativeChatRowInLiveWorkingTurn( turnKeys[index], liveTurnKey, isWorking || lifecycleWorking - ), - lineShowsThinking - ), - [isWorking, lifecycleWorking, lineShowsThinking, liveTurnKey, messages, turnKeys] + ) + }), + [activityText, draws, isWorking, lifecycleWorking, liveTurnKey, messages, thinking, turnKeys] ) - const liveReasoningId = liveReasoning?.id ?? null + const liveReasoningId = liveLine?.reasoning?.message.id ?? null const slots = useMemo( () => buildNativeChatTranscriptSlots({ @@ -76,5 +80,5 @@ export function useNativeChatTranscriptSlots({ turnStatuses ] ) - return { slots, liveReasoning } + return { slots, liveLine } } diff --git a/src/shared/native-chat-live-line.test.ts b/src/shared/native-chat-live-line.test.ts new file mode 100644 index 00000000000..981b9db2f23 --- /dev/null +++ b/src/shared/native-chat-live-line.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { nativeChatLiveLine } from './native-chat-live-line' +import type { NativeChatMessage } from './native-chat-types' + +const prompt: NativeChatMessage = { + id: 'user-1', + role: 'user', + blocks: [{ type: 'text', text: 'Start the task' }], + timestamp: 1_000, + source: 'transcript' +} +const open: NativeChatMessage = { + id: 'r-1', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }], + timestamp: 1_000, + source: 'transcript', + state: 'running' +} +const line = (fields: { draws?: boolean; thinking?: boolean; activityText?: string | null }) => + nativeChatLiveLine({ + draws: true, + thinking: true, + messages: [prompt, open], + inLiveWorkingTurn: () => true, + ...fields + }) + +describe('the live line both clients draw', () => { + it('discloses the open block while it reads "Thinking"', () => { + expect(line({})).toEqual({ + thinking: true, + activityText: null, + reasoning: { message: open, markdown: 'Weighing two approaches' } + }) + }) + + it('discloses nothing, so nothing is hidden, unless it draws and the turn is reasoning', () => { + // A prompt the reader owes replaces the line: no line, and the row draws. + expect(line({ draws: false })).toBeNull() + expect(line({ thinking: false })).toEqual({ + thinking: false, + activityText: null, + reasoning: null + }) + }) + + // The label may read the provider's own words; the block it discloses is still the open one. + it('keeps the activity text it was handed', () => { + expect(line({ activityText: 'Summarizing the plan' })).toMatchObject({ + activityText: 'Summarizing the plan', + reasoning: { message: open } + }) + }) +}) diff --git a/src/shared/native-chat-live-line.ts b/src/shared/native-chat-live-line.ts new file mode 100644 index 00000000000..d7c10d19f20 --- /dev/null +++ b/src/shared/native-chat-live-line.ts @@ -0,0 +1,38 @@ +// The live turn's tail line, as desktop and mobile both draw it: whether it draws, what it says, +// and which open reasoning block it discloses. One value, so a block's row is hidden exactly while +// the line that shows it draws. + +import { + selectNativeChatLiveReasoning, + type NativeChatLiveReasoning +} from './native-chat-reasoning-row' +import type { NativeChatMessage } from './native-chat-types' + +export type NativeChatLiveLine = { + /** The turn is reasoning now; the label reads "Thinking" unless activity text outranks it. */ + thinking: boolean + activityText: string | null + /** The open block the line discloses; its row draws nothing meanwhile. */ + reasoning: NativeChatLiveReasoning | null +} + +export function nativeChatLiveLine(input: { + /** The running turn's tail is the activity line: nothing (a prompt the reader owes) replaces it. */ + draws: boolean + thinking: boolean + activityText?: string | null + messages: readonly NativeChatMessage[] + inLiveWorkingTurn: (index: number) => boolean +}): NativeChatLiveLine | null { + if (!input.draws) { + return null + } + return { + thinking: input.thinking, + activityText: input.activityText ?? null, + // Rows never say "Thinking", so the line discloses only while it is the one that does. + reasoning: input.thinking + ? selectNativeChatLiveReasoning(input.messages, input.inLiveWorkingTurn) + : null + } +} diff --git a/src/shared/native-chat-reasoning-row.test.ts b/src/shared/native-chat-reasoning-row.test.ts index ac269475021..76c2f422e15 100644 --- a/src/shared/native-chat-reasoning-row.test.ts +++ b/src/shared/native-chat-reasoning-row.test.ts @@ -28,45 +28,44 @@ const open = row('r-1', 'reasoning', 'Weighing two approaches', { state: 'runnin const inTurn = (): boolean => true describe('the open block the live line discloses', () => { - it('is the newest root reasoning row with text, while the line reads "Thinking"', () => { - expect(selectNativeChatLiveReasoning([prompt, open], inTurn, true)).toBe(open) + const select = (messages: NativeChatMessage[], live: (index: number) => boolean = inTurn) => + selectNativeChatLiveReasoning(messages, live)?.message ?? null + + it('is the newest root reasoning row with text, with the text it has so far', () => { + expect(selectNativeChatLiveReasoning([prompt, open], inTurn)).toEqual({ + message: open, + markdown: 'Weighing two approaches' + }) // A host that keeps no lifecycle gets the same single live slot. const stateless = row('r-1', 'reasoning', 'Weighing two approaches') - expect(selectNativeChatLiveReasoning([prompt, stateless], inTurn, true)).toBe(stateless) + expect(select([prompt, stateless])).toBe(stateless) }) - it('is nothing unless the line shows "Thinking", or when the block is blank or ended', () => { - expect(selectNativeChatLiveReasoning([prompt, open], inTurn, false)).toBeNull() - const blank = row('r-1', 'reasoning', ' \n', { state: 'running' }) - expect(selectNativeChatLiveReasoning([prompt, blank], inTurn, true)).toBeNull() - const ended = { ...open, state: 'completed' as const } - expect(selectNativeChatLiveReasoning([prompt, ended], inTurn, true)).toBeNull() + it('is nothing when the block is blank or ended', () => { + expect(select([prompt, row('r-1', 'reasoning', ' \n', { state: 'running' })])).toBeNull() + expect(select([prompt, { ...open, state: 'completed' }])).toBeNull() }) it('is nothing once a tool or the answer is newer than the block', () => { - const answer = row('a-1', 'assistant', 'Here it is') - expect(selectNativeChatLiveReasoning([prompt, open, answer], inTurn, true)).toBeNull() + expect(select([prompt, open, row('a-1', 'assistant', 'Here it is')])).toBeNull() const tool = row('t-1', 'assistant', '', { blocks: [{ type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' }, state: 'running' }] }) - expect(selectNativeChatLiveReasoning([prompt, open, tool], inTurn, true)).toBeNull() + expect(select([prompt, open, tool])).toBeNull() }) it('looks past notices, empty rows and rows outside the live working turn', () => { const notice = row('s-1', 'system', 'Compacting') const empty = row('a-0', 'assistant', '') const waiting = row('user-2', 'user', 'Also say banana') - const live = (index: number): boolean => index < 4 - expect(selectNativeChatLiveReasoning([prompt, open, notice, empty, waiting], live, true)).toBe( - open - ) + expect(select([prompt, open, notice, empty, waiting], (index) => index < 4)).toBe(open) }) it('stops at the live turn prompt and ignores a subagent reasoning', () => { - expect(selectNativeChatLiveReasoning([open, prompt], inTurn, true)).toBeNull() + expect(select([open, prompt])).toBeNull() const child = row('r-2', 'reasoning', 'Child thinking', { state: 'running', agentId: 'sub-1' }) - expect(selectNativeChatLiveReasoning([prompt, child], inTurn, true)).toBeNull() - expect(selectNativeChatLiveReasoning([prompt, open, child], inTurn, true)).toBe(open) + expect(select([prompt, child])).toBeNull() + expect(select([prompt, open, child])).toBe(open) }) it('keys one block the same for the line and the row, apart from tool runs', () => { diff --git a/src/shared/native-chat-reasoning-row.ts b/src/shared/native-chat-reasoning-row.ts index 307502e9a3f..8bcd14b6ea1 100644 --- a/src/shared/native-chat-reasoning-row.ts +++ b/src/shared/native-chat-reasoning-row.ts @@ -7,19 +7,18 @@ import { deriveNativeChatRowContent, nativeChatRowRendersContent } from './nativ import { formatNativeChatDuration } from './native-chat-turn-status' import type { NativeChatMessage } from './native-chat-types' +/** An open reasoning block and the text it has so far. */ +export type NativeChatLiveReasoning = { message: NativeChatMessage; markdown: string } + /** - * The open reasoning block the live activity line discloses, or null. Its row draws nothing while - * this returns it, so the line is the one live "Thinking" and nothing is ever hidden that the - * screen does not show. `lineShowsThinking` must be the line's own render condition. + * The agent's open reasoning block, with its text, when it is the newest thing its live working + * turn produced; else null. Only `nativeChatLiveLine` asks, so a block is disclosed (and its row + * hidden) only while the line that discloses it draws. */ export function selectNativeChatLiveReasoning( messages: readonly NativeChatMessage[], - inLiveWorkingTurn: (index: number) => boolean, - lineShowsThinking: boolean -): NativeChatMessage | null { - if (!lineShowsThinking) { - return null - } + inLiveWorkingTurn: (index: number) => boolean +): NativeChatLiveReasoning | null { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index] if (!message || !inLiveWorkingTurn(index)) { @@ -39,10 +38,11 @@ export function selectNativeChatLiveReasoning( return null } // Not `=== 'running'`: a host that keeps no lifecycle gets the same single live slot. - return message.state !== 'completed' && - deriveNativeChatRowContent(message.blocks).markdown.trim().length > 0 - ? message - : null + if (message.state === 'completed') { + return null + } + const { markdown } = deriveNativeChatRowContent(message.blocks) + return markdown.trim().length > 0 ? { message, markdown } : null } return null }