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.
This commit is contained in:
Brennan Benson
2026-10-05 20:54:37 -07:00
parent 1a40ca50fe
commit 2911a2a995
20 changed files with 499 additions and 296 deletions
@@ -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)
})
})
@@ -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<typeof element>[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)
})
})
+52 -27
View File
@@ -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 (
<MobileNativeChatTurnActivity thinking={line.thinking} activityText={line.activityText} />
)
}
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 (
<View>
{/* One element for every state of the line, so TalkBack hears each new label; the body
sits outside it. */}
<Pressable
style={({ pressed }) => [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"
>
<ActivityIndicator size="small" color={colors.textMuted} />
<View style={styles.glyph}>
<ActivityIndicator size="small" color={colors.textMuted} />
</View>
<Text style={styles.label} numberOfLines={1}>
{label}
</Text>
<View style={open ? styles.caretOpen : undefined}>
<ChevronRight size={14} color={colors.textMuted} strokeWidth={2} />
</View>
{reasoning ? (
<View style={open ? styles.caretOpen : undefined}>
<ChevronRight size={14} color={colors.textMuted} strokeWidth={2} />
</View>
) : null}
</Pressable>
{open ? (
{reasoning && open ? (
<View style={styles.body}>
<MobileNativeChatReasoningBody
markdown={deriveNativeChatRowContent(reasoning.blocks).markdown}
markdown={reasoning.markdown}
fontScale={fontScale}
onOpenFile={onOpenFile}
onLongPress={INLINE_TEXT_SELECTION ? undefined : openActions}
/>
</View>
) : null}
{reasoning && actionsOpen ? (
<MobileNativeChatMessageActionsSheet
message={reasoning.message}
onClose={() => setActionsOpen(false)}
/>
) : null}
</View>
)
}
// 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
}
})
@@ -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' }), {
@@ -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')
})
})
@@ -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 (
<View
style={styles.row}
accessibilityLiveRegion="polite"
accessibilityLabel={NATIVE_CHAT_TURN_STATUS_COPY.responding}
>
<ActivityIndicator size="small" color={colors.textMuted} />
<Text style={styles.label} numberOfLines={1}>
{formatNativeChatActiveTurnLabel({ activityText, thinking })}
</Text>
</View>
)
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
@@ -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' }))
@@ -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' }))
@@ -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' }))
@@ -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', () => {
@@ -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<string>, (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<MobileNativeChatLiveLine | null>(
() =>
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 => {
@@ -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…')
})
})
@@ -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 ? (
<NativeChatTurnActivityLine
activity={turnActivity}
thinking={turnStatuses.active?.thinking === true}
liveReasoning={liveReasoning}
line={liveLine}
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
/>
@@ -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 (
<ChevronRight
aria-hidden
className="size-3.5 shrink-0 transition-all can-hover:opacity-0 group-hover/reasoning:opacity-100 group-focus-visible/reasoning:opacity-100 group-data-[state=open]/reasoning:rotate-90 group-data-[state=open]/reasoning:opacity-100 motion-reduce:transition-none"
className="size-3.5 shrink-0 transition-all can-hover:opacity-0 group-hover/reasoning:opacity-100 group-focus-visible/reasoning:opacity-100 group-has-[:focus-visible]/reasoning:opacity-100 group-data-[state=open]/reasoning:rotate-90 group-data-[state=open]/reasoning:opacity-100 motion-reduce:transition-none"
/>
)
}
@@ -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 (
<NativeChatTurnActivityDisclosure
label={label}
reasoning={liveReasoning}
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
/>
)
}
return (
<div
className="flex min-h-6 items-center gap-1.5 text-sm leading-relaxed text-muted-foreground"
data-native-chat-turn-activity="true"
aria-live="polite"
aria-atomic="true"
>
<Loader2 aria-hidden className="size-4 shrink-0 animate-spin motion-reduce:animate-none" />
<span className="min-w-0 flex-1 truncate text-foreground/85">{label}</span>
</div>
)
}
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 (
<div className="min-w-0 text-sm text-muted-foreground" data-native-chat-turn-activity="true">
<Collapsible open={disclosure.open} onOpenChange={disclosure.setOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="group/reasoning flex min-h-6 w-full min-w-0 items-center gap-1.5 rounded-md text-left leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
>
<Loader2
aria-hidden
className="size-4 shrink-0 animate-spin motion-reduce:animate-none"
/>
{/* Only the label is live: the streaming body would be re-announced on every frame. */}
<span
aria-live="polite"
aria-atomic="true"
className="min-w-0 truncate text-foreground/85"
>
{label}
</span>
<Collapsible open={open} onOpenChange={disclosure.setOpen}>
{/* One element for every state of the line, so a screen reader hears each new label. The
trigger overlays it, rather than wrapping it, and the body sits outside it. */}
<div
className="group/reasoning relative flex min-h-6 items-center gap-1.5 text-sm leading-relaxed text-muted-foreground"
data-native-chat-turn-activity="true"
data-state={open ? 'open' : 'closed'}
aria-live="polite"
aria-atomic="true"
>
<Loader2 aria-hidden className="size-4 shrink-0 animate-spin motion-reduce:animate-none" />
<span id={labelId} className="min-w-0 truncate text-foreground/85">
{label}
</span>
{reasoning ? (
<>
<NativeChatReasoningChevron />
</button>
</CollapsibleTrigger>
<CollapsibleTrigger asChild>
<button
type="button"
aria-labelledby={labelId}
className="absolute inset-0 rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
/>
</CollapsibleTrigger>
</>
) : null}
</div>
{reasoning ? (
<CollapsibleContent>
<NativeChatReasoningBody
markdown={deriveNativeChatRowContent(reasoning.blocks).markdown}
markdown={reasoning.markdown}
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
/>
</CollapsibleContent>
</Collapsible>
</div>
) : null}
</Collapsible>
)
}
@@ -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<NativeChatTranscriptSlotsInput, 'liveReasoningId'> & {
/** 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 }
}
+55
View File
@@ -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 }
})
})
})
+38
View File
@@ -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
}
}
+18 -19
View File
@@ -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', () => {
+13 -13
View File
@@ -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
}