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 }