diff --git a/mobile/src/session/MobileNativeChatLiveLine.test.ts b/mobile/src/session/MobileNativeChatLiveLine.test.ts new file mode 100644 index 00000000000..6e4ff4ad8ba --- /dev/null +++ b/mobile/src/session/MobileNativeChatLiveLine.test.ts @@ -0,0 +1,92 @@ +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' + +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'), + 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' +})) + +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' +} + +describe('MobileNativeChatLiveLine', () => { + let renderer: ReactTestRenderer | null = null + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + 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 + }) + ) + }) + return { root: renderer!.root, onToggleReasoning } + } + 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' + ) + + it('is the plain live line, not a button, while no open block has text', () => { + const { root } = render(null) + expect(labels(root)).toEqual(['Thinking']) + expect(buttons(root)).toHaveLength(0) + }) + + it('discloses the open block under one "Thinking", collapsed, toggled by its block key', () => { + const { root, onToggleReasoning } = render(block) + expect(labels(root)).toEqual(['Thinking']) + const [toggle] = buttons(root) + expect(toggle?.props.accessibilityState).toEqual({ expanded: false }) + expect(byType(root, 'ReasoningBody')).toHaveLength(0) + act(() => toggle?.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 [body] = byType(root, 'ReasoningBody') + expect(body?.props.markdown).toBe('Weighing two approaches') + let ancestor = body?.parent ?? null + while (ancestor) { + expect(ancestor.props.accessibilityLiveRegion).toBeUndefined() + ancestor = ancestor.parent + } + expect(buttons(root)[0]?.props.accessibilityLiveRegion).toBe('polite') + }) +}) diff --git a/mobile/src/session/MobileNativeChatLiveLine.tsx b/mobile/src/session/MobileNativeChatLiveLine.tsx new file mode 100644 index 00000000000..bb4af046f5b --- /dev/null +++ b/mobile/src/session/MobileNativeChatLiveLine.tsx @@ -0,0 +1,88 @@ +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 { colors, spacing, typography } from '../theme/mobile-theme' +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`. */ +export function MobileNativeChatLiveLine({ + line, + onToggleReasoning, + fontScale, + onOpenFile +}: { + line: LiveLine + onToggleReasoning: (key: string) => void + fontScale: number + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element { + const { reasoning, reasoningExpanded: open } = line + if (!reasoning) { + return ( + + ) + } + const label = formatNativeChatActiveTurnLabel(line) + return ( + + [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))} + hitSlop={6} + accessibilityRole="button" + accessibilityState={{ expanded: open }} + accessibilityLabel={label} + // Only the line is live: the streaming body below would be re-announced on every frame. + accessibilityLiveRegion="polite" + > + + + {label} + + + + + + {open ? ( + + + + ) : null} + + ) +} + +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 + }, + pressed: { + opacity: 0.6 + }, + label: { + color: colors.textMuted, + fontSize: typography.bodySize, + flexShrink: 1 + }, + caretOpen: { + transform: [{ rotate: '90deg' }] + }, + body: { + paddingHorizontal: spacing.md + } +}) diff --git a/mobile/src/session/MobileNativeChatMessage.android.test.ts b/mobile/src/session/MobileNativeChatMessage.android.test.ts index 4184a28a869..104aa35d2a0 100644 --- a/mobile/src/session/MobileNativeChatMessage.android.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.android.test.ts @@ -23,6 +23,8 @@ vi.mock('react-native', async () => { Image: 'Image', Platform: { OS: 'android' }, Pressable: 'Pressable', + ScrollView: ({ children, ...props }: { children?: ReactNode }) => + React.createElement('ScrollView', props, children), Text, View: ({ children, ...props }: { children?: ReactNode }) => React.createElement('View', props, children), diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 03744565f22..572b95915f3 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -24,6 +24,8 @@ vi.mock('react-native', async () => { Image: 'Image', Platform: { OS: 'ios' }, Pressable: 'Pressable', + ScrollView: ({ children, ...props }: { children?: unknown }) => + React.createElement('ScrollView', props, children), Text, View: ({ children, ...props }: { children?: unknown }) => React.createElement('View', props, children), @@ -78,6 +80,7 @@ describe('MobileNativeChatMessage', () => { workedSeconds: number | null } | null onToggleTurn?: () => void + reasoningIsLive?: boolean } = {} ): ReactTestRenderer { act(() => { @@ -367,13 +370,25 @@ describe('MobileNativeChatMessage', () => { ]) }) - it('draws nothing while still being written in the live turn, or when blank', () => { + it('draws nothing while the live line discloses it, or when blank', () => { expect( - render(reasoning({ state: 'running' }), { activeTurnIsWorking: true }).toJSON() + render(reasoning({ state: 'running' }), { + activeTurnIsWorking: true, + reasoningIsLive: true + }).toJSON() ).toBeNull() expect(render(reasoning({ blocks: [{ type: 'text', text: ' \n ' }] })).toJSON()).toBeNull() }) + // 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' }), { + activeTurnIsWorking: true + }) + expect(textIn(child.root)).toContain('Reasoning') + expect(toggleOf(child).props.accessibilityLabel).toBe('Reasoning') + }) + it('says only what the host saw', () => { expect(textIn(render(reasoning({ state: 'running' })).root)).toContain('Thought') const unknown = render(reasoning({ state: undefined, completedAt: undefined })) diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 803d914b9a3..ec0a6d48b31 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -13,7 +13,7 @@ import { } from '../../../src/shared/agent-session-host-status-rows' import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileMarkdown } from '../components/MobileMarkdown' -import { isNativeChatReasoningUnderway } from '../../../src/shared/native-chat-reasoning-row' +import { deriveNativeChatRowContent } from '../../../src/shared/native-chat-row-content' import { MobileNativeChatReasoningRow } from './MobileNativeChatReasoningRow' import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus' import { ToolRun } from './MobileNativeChatToolRun' @@ -102,7 +102,10 @@ function MobileNativeChatMessageImpl({ turnKey, onToggleTurn, activeTurnIsWorking, - structuredActivityUi = false + structuredActivityUi = false, + reasoningIsLive = false, + reasoningExpanded, + onToggleReasoning }: { message: NativeChatMessage toolsExpanded?: boolean @@ -123,6 +126,11 @@ function MobileNativeChatMessageImpl({ activeTurnIsWorking?: boolean /** Structured lane only: live tool progress plus the turn-status disclosure. */ structuredActivityUi?: boolean + /** This open reasoning block is disclosed by the live activity line, so its row draws nothing. */ + reasoningIsLive?: boolean + /** The transcript-held disclosure of a reasoning row; one stable handler takes its key. */ + reasoningExpanded?: boolean + onToggleReasoning?: (key: string) => void }): React.JSX.Element { const isUser = message.role === 'user' const isReasoning = message.role === 'reasoning' @@ -160,11 +168,10 @@ function MobileNativeChatMessageImpl({ /> ) : null if (isReasoning) { - const markdown = prose.flatMap((block) => (isTextBlock(block) ? [block.text] : [])).join('\n\n') - // Blank, or still being written in the live turn: nothing draws, not even an empty row. - const draws = - markdown.trim().length > 0 && - !isNativeChatReasoningUnderway(message, activeTurnIsWorking === true) + // The same text the live line's selector reads, so the two agree on whether there is any. + const markdown = deriveNativeChatRowContent(message.blocks).markdown + // Blank, or disclosed by the live activity line: nothing draws, not even an empty row. + const draws = markdown.trim().length > 0 && !reasoningIsLive return ( <> {turnStatusAbove ? statusRow : null} @@ -174,6 +181,9 @@ function MobileNativeChatMessageImpl({ message={message} markdown={markdown} fontScale={fontScale} + live={activeTurnIsWorking === true} + expanded={reasoningExpanded} + onToggle={onToggleReasoning} onOpenFile={onOpenFile} onLongPress={onLongPress} /> diff --git a/mobile/src/session/MobileNativeChatReasoningRow.tsx b/mobile/src/session/MobileNativeChatReasoningRow.tsx index a173a37c92d..bbb0ec81207 100644 --- a/mobile/src/session/MobileNativeChatReasoningRow.tsx +++ b/mobile/src/session/MobileNativeChatReasoningRow.tsx @@ -1,7 +1,8 @@ import { useState } from 'react' -import { Pressable, Text, View } from 'react-native' +import { Pressable, ScrollView, Text, View } from 'react-native' import { Brain, ChevronRight } from 'lucide-react-native' import { + nativeChatReasoningDisclosureKey, nativeChatReasoningHeadline, nativeChatReasoningHeadlineText } from '../../../src/shared/native-chat-reasoning-row' @@ -17,24 +18,35 @@ export function MobileNativeChatReasoningRow({ message, markdown, fontScale, + live = false, + expanded: transcriptExpanded = false, + onToggle, onOpenFile, onLongPress }: { - message: Pick + message: Pick markdown: string fontScale: number + /** Drawn inside its working turn: an open row there has not ended. */ + live?: boolean + /** The transcript's disclosure, keyed like the live line's; absent, the row keeps its own. */ + expanded?: boolean + onToggle?: (key: string) => void onOpenFile?: (relativePath: string) => void /** Android only: opens the message's actions sheet, as a long press on any other message does. */ onLongPress?: () => void }): React.JSX.Element { - const [expanded, setExpanded] = useState(false) - const headline = nativeChatReasoningHeadlineText(nativeChatReasoningHeadline(message)) + const [localExpanded, setLocalExpanded] = useState(false) + const expanded = onToggle ? transcriptExpanded : localExpanded + const toggle = () => + onToggle ? onToggle(nativeChatReasoningDisclosureKey(message.id)) : setLocalExpanded(!expanded) + const headline = nativeChatReasoningHeadlineText(nativeChatReasoningHeadline(message, { live })) const label = nativeChatReasoningHeadlineText({ kind: 'reasoning' }) return ( [styles.reasoningToggle, pressed && styles.reasoningPressed]} - onPress={() => setExpanded((open) => !open)} + onPress={toggle} hitSlop={6} accessibilityRole="button" accessibilityState={{ expanded }} @@ -50,16 +62,41 @@ export function MobileNativeChatReasoningRow({ {expanded ? ( - - - + ) : null} ) } + +/** A reasoning block's text, under its row or the live activity line. Capped and scrollable, so an + * open block streaming at the tail cannot grow without bound. */ +export function MobileNativeChatReasoningBody({ + markdown, + fontScale, + onOpenFile, + onLongPress +}: { + markdown: string + fontScale: number + onOpenFile?: (relativePath: string) => void + onLongPress?: () => void +}): React.JSX.Element { + return ( + + + + + + ) +} diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index ff2637f8a45..e16445521ac 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -47,6 +47,9 @@ vi.mock('lucide-react-native', () => ({ })) vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatReasoningRow', () => ({ + MobileNativeChatReasoningBody: 'ReasoningBody' +})) vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 8e729e5e46b..a559bd02ef2 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -30,7 +30,7 @@ import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch- import { useMobileNativeChatTailFollow } from './use-mobile-native-chat-tail-follow' import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' import { useSettledMobileNativeChatInputLock } from './use-mobile-native-chat-input-lease' -import { MobileNativeChatTurnActivity } from './MobileNativeChatTurnStatus' +import { MobileNativeChatLiveLine } from './MobileNativeChatLiveLine' import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { MobileNativeChatComposer } from './MobileNativeChatComposer' @@ -278,10 +278,9 @@ export function MobileNativeChatView({ turnJournal, thinking: turnIndicator?.thinking === true, activityText: turnIndicator?.activityText ?? null, + lineYields: structuredActivityUi && (ask != null || permission != null || question != null), scopeKey: sendSurfaceId }) - const hasPendingStructuredInteraction = - structuredActivityUi && (ask != null || permission != null || question != null) const renderItem = useCallback( ({ item, index }: { item: NativeChatMessage; index: number }) => ( @@ -298,13 +297,14 @@ export function MobileNativeChatView({ [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, turns] ) - const liveStatus = - structuredActivityUi && agentWorking && !hasPendingStructuredInteraction && turns.active ? ( - - ) : null + const liveStatus = turns.liveLine ? ( + + ) : null const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) const showLoading = status === 'loading' && messages.length === 0 diff --git a/mobile/src/session/MobileNativeChatView.turn-status.test.ts b/mobile/src/session/MobileNativeChatView.turn-status.test.ts index 1f8191df80b..818c1b8e5a6 100644 --- a/mobile/src/session/MobileNativeChatView.turn-status.test.ts +++ b/mobile/src/session/MobileNativeChatView.turn-status.test.ts @@ -47,6 +47,9 @@ vi.mock('lucide-react-native', () => ({ })) vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatReasoningRow', () => ({ + MobileNativeChatReasoningBody: 'ReasoningBody' +})) vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) @@ -177,13 +180,11 @@ describe('MobileNativeChatView', () => { return (renderedRow(id) as { props: Record }).props } + /** What the live footer line says: its label inputs, read off the line the view hands it. */ function footerProps(): Record | null { const list = renderer!.root.find((node) => String(node.type) === 'FlatList') - const footer = list.props.ListFooterComponent as - | { props: Record } - | null - | undefined - return footer?.props ?? null + const line = list.props.ListFooterComponent?.props.line + return line ? { thinking: line.thinking, activityText: line.activityText } : null } function workingIndicators(): ReactTestInstance[] { diff --git a/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts b/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts index 518d85f2465..d0dacd67988 100644 --- a/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts +++ b/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts @@ -37,6 +37,9 @@ vi.mock('lucide-react-native', () => ({ Square: 'Square' })) vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatReasoningRow', () => ({ + MobileNativeChatReasoningBody: 'ReasoningBody' +})) vi.mock('./MobileNativeChatTurnStatus', () => ({ MobileNativeChatTurnActivity: 'LiveStatus' })) vi.mock('./MobileNativeChatComposer', () => ({ MobileNativeChatComposer: 'Composer' })) // The queue's action sheet pulls in the animation runtime, which this react-native mock can't host. diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index 69aed3f04b4..2acad402176 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -57,6 +57,10 @@ export const styles = StyleSheet.create({ reasoningCaretOpen: { transform: [{ rotate: '90deg' }] }, + reasoningBody: { + // The common cap for an open reasoning block (about ten lines). + maxHeight: 240 + }, toolRun: { marginTop: spacing.xs }, 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 19f0dfbc0db..aa0d7b2b1d6 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 @@ -29,6 +29,8 @@ function Harness({ settledTurns, turnJournal, workingStartedAt, + thinking, + lineYields, scopeKey = 'host\0worktree\0tab-a' }: { messages: readonly NativeChatMessage[] @@ -37,6 +39,8 @@ function Harness({ settledTurns?: NativeChatSettledTurns turnJournal?: NativeChatTurnJournal workingStartedAt?: number | null + thinking?: boolean + lineYields?: boolean scopeKey?: string }): React.JSX.Element { const disclosure = useMobileNativeChatTurnDisclosure({ @@ -46,6 +50,8 @@ function Harness({ settledTurns, turnJournal, workingStartedAt, + thinking, + lineYields, scopeKey }) return createElement('result', { disclosure }) @@ -762,3 +768,59 @@ describe('useMobileNativeChatTurnDisclosure', () => { expect(rows[0].turnStatus?.workedSeconds).toBe(4) }) }) + +describe('the open reasoning block the live line discloses', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + const block = (id: string, state: 'running' | 'completed'): NativeChatMessage => ({ + id, + role: 'reasoning', + blocks: [{ type: 'text', text: `${id} weighs two approaches` }], + timestamp: null, + source: 'transcript', + state + }) + const show = ( + messages: NativeChatMessage[], + props: { thinking?: boolean; lineYields?: boolean } + ) => + act(() => { + const element = createElement(Harness, { messages, enabled: true, ...props }) + if (renderer) { + renderer.update(element) + } else { + renderer = create(element) + } + }) + const latest = () => renderer!.root.findByType('result').props.disclosure + + 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().resolveRow(1, block('r-1', 'running')).reasoningIsLive).toBe(true) + act(() => latest().onToggleReasoning('reasoning:r-1')) + expect(latest().liveLine.reasoningExpanded).toBe(true) + + // Closed while the turn works on: the line discloses nothing, and the row draws open. + show([prompt, block('r-1', 'completed')], { thinking: false }) + expect(latest().liveLine).toMatchObject({ reasoning: null }) + const row = latest().resolveRow(1, block('r-1', 'completed')) + expect(row).toMatchObject({ reasoningIsLive: false, reasoningExpanded: true }) + + // 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 }) + }) + + it('discloses nothing, and hides nothing, while a prompt takes the line', () => { + show([userMessage('u1'), block('r-1', 'running')], { thinking: true, lineYields: true }) + expect(latest().liveLine).toBeNull() + expect(latest().resolveRow(1, block('r-1', 'running')).reasoningIsLive).toBe(false) + }) +}) 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 6e1d4df395e..e95b7150d12 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -1,7 +1,12 @@ import { useCallback, useMemo, useState } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + nativeChatReasoningDisclosureKey, + selectNativeChatLiveReasoning +} from '../../../src/shared/native-chat-reasoning-row' import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' import { + isNativeChatRowInLiveWorkingTurn, nativeChatMessagesWaitingBehindLiveTurn, nativeChatTurnMembership, type NativeChatTurnJournal @@ -27,6 +32,46 @@ export type MobileNativeChatTurnRow = { /** Set only on a settled turn — the one row that has activity to disclose. */ turnKey?: string activeTurnIsWorking: boolean + /** The live activity line discloses this open reasoning block, so its row draws nothing. */ + reasoningIsLive: boolean + /** A reasoning row's disclosure, keyed like the live line's so an opened block stays open. */ + reasoningExpanded: boolean + 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 +} + +/** Keys the reader opened in this chat, bounded; another chat's never leak in. */ +function useScopedOpenKeys(scopeKey: string): [ReadonlySet, (key: string) => void] { + const [state, setState] = useState<{ scopeKey: string; keys: ReadonlySet }>(() => ({ + scopeKey, + keys: new Set() + })) + const toggle = useCallback( + (key: string) => { + setState((current) => { + const next = new Set(current.scopeKey === scopeKey ? current.keys : []) + if (!next.delete(key)) { + if (next.size >= MAX_EXPANDED_TURNS) { + const oldest = next.values().next().value + if (oldest) { + next.delete(oldest) + } + } + next.add(key) + } + return { scopeKey, keys: next } + }) + }, + [scopeKey] + ) + return [state.scopeKey === scopeKey ? state.keys : EMPTY_TURN_IDS, toggle] } /** Owns the transcript's per-turn status rows and their disclosure state, and @@ -41,6 +86,7 @@ export function useMobileNativeChatTurnDisclosure({ turnJournal = null, thinking = false, activityText = null, + lineYields = false, scopeKey }: { messages: readonly NativeChatMessage[] @@ -55,6 +101,8 @@ export function useMobileNativeChatTurnDisclosure({ thinking?: boolean /** What the provider says the live turn is doing; outranks the other labels. */ activityText?: string | null + /** A prompt the reader must answer replaces the live activity line. */ + lineYields?: boolean /** Host/worktree/tab identity for timing and disclosure isolation. */ scopeKey: string }): { @@ -67,6 +115,10 @@ export function useMobileNativeChatTurnDisclosure({ listMessages: readonly NativeChatMessage[] /** Rows waiting behind the live turn, drawn after its live status. */ waitingRows: readonly { item: NativeChatMessage; index: number }[] + /** The live activity line, or null while the turn is idle or a prompt replaces it. */ + liveLine: MobileNativeChatLiveLine | null + /** Stable for a given chat scope, so it never disturbs a row's memo. */ + onToggleReasoning: (key: string) => void } { // Resolve each row's turn, which turn is live, and the order the rows draw in, once: from the // turn record when the host states scopes, else by journal order. @@ -103,34 +155,39 @@ export function useMobileNativeChatTurnDisclosure({ thinking, scopeKey }) - const [expandedTurns, setExpandedTurns] = useState<{ - scopeKey: string - turnIds: ReadonlySet - }>(() => ({ scopeKey, turnIds: new Set() })) - const expandedTurnIds = - expandedTurns.scopeKey === scopeKey ? expandedTurns.turnIds : EMPTY_TURN_IDS - const toggleExpandedTurn = useCallback( - (turnKey: string) => { - setExpandedTurns((current) => { - const next = new Set(current.scopeKey === scopeKey ? current.turnIds : []) - if (!next.delete(turnKey)) { - if (next.size >= MAX_EXPANDED_TURNS) { - const oldest = next.values().next().value - if (oldest) { - next.delete(oldest) - } - } - next.add(turnKey) - } - return { scopeKey, turnIds: next } - }) - }, - [scopeKey] - ) + const [expandedTurnIds, toggleExpandedTurn] = useScopedOpenKeys(scopeKey) + const [expandedReasoning, toggleReasoning] = useScopedOpenKeys(scopeKey) const bars = useMemo(() => nativeChatTurnBarRows(rows, turnKeys), [rows, turnKeys]) const { active, activeTurnKey, completedByTurn } = turnStatuses + const inLiveWorkingTurn = useCallback( + (index: number) => + isNativeChatRowInLiveWorkingTurn(turnKeys[index], liveTurnKey, enabled && isWorking), + [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 liveReasoningId = liveReasoning?.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] + ) const resolveRow = useCallback( (listIndex: number, message: NativeChatMessage): MobileNativeChatTurnRow => { const index = waiting.indexById?.get(message.id) ?? listIndex @@ -153,23 +210,28 @@ export function useMobileNativeChatTurnDisclosure({ // transcript, defeating the row's memo; caching one per turn would mean // writing a ref during render, which react-freeze can discard. turnKey: turnKey && turnStatus?.workedSeconds != null ? turnKey : undefined, - // Liveness is the live turn's rows, not the newest prompt's: a running turn's rows stay live - // while a newer message waits behind it. With no user boundary at all, the session's - // working state stays authoritative. - activeTurnIsWorking: enabled && isWorking && turnKey === liveTurnKey + // With no user boundary at all, the session's working state stays authoritative. + activeTurnIsWorking: inLiveWorkingTurn(index), + reasoningIsLive: message.id === liveReasoningId, + reasoningExpanded: + message.role === 'reasoning' && + expandedReasoning.has(nativeChatReasoningDisclosureKey(message.id)), + onToggleReasoning: toggleReasoning } }, [ turnKeys, waiting, bars, - liveTurnKey, enabled, activeTurnKey, active, completedByTurn, expandedTurnIds, - isWorking + inLiveWorkingTurn, + liveReasoningId, + expandedReasoning, + toggleReasoning ] ) @@ -180,6 +242,8 @@ export function useMobileNativeChatTurnDisclosure({ onToggleTurn: toggleExpandedTurn, resolveRow, listMessages: waiting.listMessages, - waitingRows: waiting.waitingRows + waitingRows: waiting.waitingRows, + liveLine, + onToggleReasoning: toggleReasoning } } 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 new file mode 100644 index 00000000000..de65e4297ca --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx @@ -0,0 +1,186 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { NativeChatLiveSession } from './use-native-chat-live-session' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const STARTED = 1_000 + +const prompt: NativeChatMessage = { + id: 'user-1', + role: 'user', + blocks: [{ type: 'text', text: 'Start the task' }], + timestamp: STARTED - 500, + source: 'transcript' +} + +function reasoning( + id: string, + text: string, + state: 'running' | 'completed', + fields: Partial = {} +): NativeChatMessage { + return { + id, + role: 'reasoning', + blocks: [{ type: 'text', text }], + timestamp: STARTED, + source: 'transcript', + state, + ...(state === 'completed' ? { completedAt: STARTED + 12_000 } : {}), + ...fields + } +} + +/** The journal that says the turn runs and what its newest content is. */ +function journal(rows: readonly NativeChatMessage[]): AgentJournalRenderItem[] { + return [ + { + itemId: prompt.id, + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: prompt.blocks } + }, + { + itemId: 'turn-1', + revision: 1, + sequence: 2, + observedAt: 2, + body: { kind: 'turn', turnId: 'turn-1', state: 'running', userItemId: prompt.id } + }, + ...rows.map((row, index) => ({ + itemId: row.id, + revision: 1, + sequence: index + 3, + observedAt: index + 3, + ...(row.agentId ? { agentId: row.agentId } : {}), + body: { + kind: 'message' as const, + role: row.role, + blocks: row.blocks, + ...(row.state ? { state: row.state } : {}) + } + })) + ] +} + +function list( + rows: readonly NativeChatMessage[], + props: Partial> = {} +): React.JSX.Element { + const session: NativeChatLiveSession = { + messages: [prompt, ...rows], + status: 'working', + sessionId: 'session-1', + agent: 'claude', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' + } + return ( + + ) +} + +const liveLine = (): HTMLElement => + screen.getByText('Thinking').closest('[data-native-chat-turn-activity]')! + +describe('live reasoning, read through the one live line', () => { + it('shows one "Thinking", collapsed, and no row for the open block', () => { + render(list([reasoning('r-1', 'Weighing two approaches', 'running')])) + expect(screen.getAllByText('Thinking')).toHaveLength(1) + const toggle = screen.getByRole('button', { name: 'Thinking' }) + expect(toggle).toHaveAttribute('aria-expanded', 'false') + expect(liveLine()).toContainElement(toggle) + expect(screen.queryByRole('button', { name: /Reasoning|Thought/ })).toBeNull() + expect(screen.queryByText('Weighing two approaches')).toBeNull() + }) + + it('opens to the live text, which follows the block as it grows', () => { + const { rerender } = render(list([reasoning('r-1', 'Weighing two approaches', 'running')])) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + expect(screen.getByRole('button', { name: 'Thinking' })).toHaveAttribute( + 'aria-expanded', + 'true' + ) + expect(screen.getByText('Weighing two approaches')).toBeInTheDocument() + rerender(list([reasoning('r-1', 'Weighing two approaches, then the cheaper one', 'running')])) + expect(screen.getByText('Weighing two approaches, then the cheaper one')).toBeInTheDocument() + }) + + it('keeps the body out of the live region, which announces the label only', () => { + render(list([reasoning('r-1', 'Weighing two approaches', 'running')])) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + const body = screen.getByText('Weighing two approaches') + expect(body.closest('[aria-live]')).toBeNull() + expect(screen.getByText('Thinking').closest('[aria-live]')).not.toBeNull() + }) + + it('lands the finished row open when the reader opened it live, while the turn works on', () => { + const { rerender } = render(list([reasoning('r-1', 'Weighing two approaches', 'running')])) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + rerender(list([reasoning('r-1', 'Weighing two approaches', 'completed')])) + // The line no longer discloses anything; the row does, still open. + expect(screen.queryByRole('button', { name: /Thinking|Working/ })).toBeNull() + expect(screen.getByRole('button', { name: 'Reasoning: Thought for 12s' })).toHaveAttribute( + 'aria-expanded', + 'true' + ) + expect(screen.getByText('Weighing two approaches')).toBeInTheDocument() + }) + + it('starts the next block collapsed', () => { + const { rerender } = render(list([reasoning('r-1', 'First thought', 'running')])) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + rerender( + list([reasoning('r-1', 'First thought', 'completed'), reasoning('r-2', 'Second', 'running')]) + ) + expect(screen.getByRole('button', { name: 'Thinking' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) + expect(screen.queryByText('Second')).toBeNull() + }) + + it('is not expandable while the open block has no text yet', () => { + render(list([reasoning('r-1', '', 'running')])) + expect(screen.getAllByText('Thinking')).toHaveLength(1) + expect(screen.queryByRole('button', { name: 'Thinking' })).toBeNull() + }) + + it('draws the open row when a waiting prompt replaces the line', () => { + render( + list([reasoning('r-1', 'Weighing two approaches', 'running')], { + awaitingInput: 'unshown' + }) + ) + expect(screen.queryByText('Thinking')).toBeNull() + expect(screen.getByRole('button', { name: 'Reasoning' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index efe1c54dd01..bbfd7f59290 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -24,10 +24,10 @@ import { import type { NativeChatTranscriptRowContext } from './NativeChatTranscriptRow' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' import { - buildNativeChatTranscriptSlots, splitNativeChatSlotsWaitingBehindLiveTurn, nativeChatSlotIndexOf } from './native-chat-transcript-slots' +import { useNativeChatTranscriptSlots } from './use-native-chat-transcript-slots' import { useNativeChatTranscriptWindow } from './use-native-chat-transcript-window' import { nativeChatRowsInTranscriptOrder } from './native-chat-subagent-sections' import { useNativeChatSubagentSections } from './use-native-chat-subagent-sections' @@ -184,35 +184,21 @@ export function NativeChatMessageList({ ? 'activity' : null const lifecycleWorking = session.transcriptLifecycle?.state === 'working' - const allSlots = useMemo( - () => - buildNativeChatTranscriptSlots({ - messages: rows, - turnKeys, - liveTurnKey, - receipts, - turnStatuses, - turnDiffs, - expandedTurnKeys: expandedTurnIds, - isWorking, - lifecycleWorking, - subagentSections, - subagentChoices - }), - [ - liveTurnKey, - expandedTurnIds, - isWorking, - lifecycleWorking, - receipts, - rows, - subagentChoices, - subagentSections, - turnDiffs, - turnKeys, - turnStatuses - ] - ) + const { slots: allSlots, liveReasoning } = useNativeChatTranscriptSlots({ + messages: rows, + turnKeys, + liveTurnKey, + receipts, + turnStatuses, + turnDiffs, + expandedTurnKeys: expandedTurnIds, + isWorking, + 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 + }) // A message waiting behind the live turn draws after that turn's live activity, not inside it. const { slots, waitingSlots } = useMemo( () => splitNativeChatSlotsWaitingBehindLiveTurn(allSlots, journalItems), @@ -401,6 +387,9 @@ export function NativeChatMessageList({ ) : tailRow === 'awaiting-input' ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatReasoningDisclosure.tsx b/src/renderer/src/components/native-chat/NativeChatReasoningDisclosure.tsx new file mode 100644 index 00000000000..c04049abefa --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatReasoningDisclosure.tsx @@ -0,0 +1,42 @@ +import { ChevronRight } from 'lucide-react' +import CommentMarkdown, { + type CommentMarkdownLinkClickHandler +} from '@/components/sidebar/CommentMarkdown' +import { NativeChatCodeBlock } from './NativeChatCodeBlock' + +/** A reasoning block's text, under the live activity line or its finished row. Capped and + * scrollable, so an expanded block streaming at the tail cannot grow without bound. */ +export function NativeChatReasoningBody({ + markdown, + onLinkClick, + allowFileUriLinks +}: { + markdown: string + onLinkClick?: CommentMarkdownLinkClickHandler + allowFileUriLinks?: boolean +}): React.JSX.Element { + return ( +
+ +
+ ) +} + +/** The disclosure caret of a `group/reasoning` header: shown on hover, keyboard focus and touch, + * and turned while open. */ +export function NativeChatReasoningChevron(): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatReasoningRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatReasoningRow.test.tsx index 7e6f3520d19..fc6d917a31b 100644 --- a/src/renderer/src/components/native-chat/NativeChatReasoningRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatReasoningRow.test.tsx @@ -6,6 +6,10 @@ import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { NativeChatReasoningRow } from './NativeChatReasoningRow' import { MessageRow } from './NativeChatMessageRow' import { NativeChatToolRunIcon } from './NativeChatToolIcon' +import { + NativeChatDisclosureContext, + useNativeChatDisclosures +} from './native-chat-disclosure-store' vi.mock('@/components/sidebar/CommentMarkdown', () => ({ default: ({ content }: { content: string }) =>
{content}
@@ -22,7 +26,7 @@ describe('reasoning disclosure', () => { it('starts collapsed without mounting markdown', () => { render( ) @@ -42,7 +46,7 @@ describe('reasoning disclosure', () => { cleanup() render( ) @@ -54,7 +58,7 @@ describe('reasoning disclosure', () => { it('hides its chevron only where hover can reveal it, and shows it on keyboard focus and once open', () => { render( ) @@ -74,7 +78,12 @@ describe('reasoning disclosure', () => { }) it('expands through a native button and keeps disclosure state through revisions', () => { - const message = { role: 'reasoning' as const, timestamp: STARTED, state: 'completed' as const } + const message = { + id: 'r-1', + role: 'reasoning' as const, + timestamp: STARTED, + state: 'completed' as const + } const { rerender } = render() const trigger = screen.getByRole('button') expect(trigger.tagName).toBe('BUTTON') @@ -92,10 +101,34 @@ describe('reasoning disclosure', () => { expect(screen.queryByTestId('markdown')).not.toBeInTheDocument() }) + it('keeps its disclosure under the block key, so a remount (or the live line) finds it open', () => { + const Transcript = ({ children }: { children: React.ReactNode }) => { + const disclosures = useNativeChatDisclosures() + return ( + + {children} + + ) + } + const row = ( + + ) + const { rerender } = render({row}) + fireEvent.click(screen.getByRole('button')) + // Windowed out, then back. + rerender({null}) + rerender({row}) + expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByTestId('markdown')).toHaveTextContent('Inspecting') + }) + it.each(['', ' \n\t'])('draws nothing for blank reasoning %j', (markdown) => { const { container } = render( ) @@ -110,7 +143,7 @@ describe('the reasoning headline', () => { ) => { render( @@ -118,8 +151,9 @@ describe('the reasoning headline', () => { return screen.queryByRole('button')?.textContent ?? null } - it('draws nothing while the row is open and its turn is running: the activity line says Thinking', () => { - expect(headline({ timestamp: STARTED, state: 'running' }, true)).toBeNull() + // The live line hides the one block it discloses; any other open row draws, claiming no end. + it('reads Reasoning while the row is open and its turn or subagent is running', () => { + expect(headline({ timestamp: STARTED, state: 'running' }, true)).toBe('Reasoning') }) it('reads Thought for N s once it closes, while the turn goes on working', () => { @@ -148,7 +182,7 @@ describe('the reasoning headline', () => { expect(headline({ timestamp: STARTED }, true)).toBe('Reasoning') }) - it('appears through the message row only once it closes', () => { + it('draws through the message row while open, and reads its span once it closes', () => { const message: NativeChatMessage = { id: 'reasoning-1', role: 'reasoning', @@ -165,7 +199,7 @@ describe('the reasoning headline', () => { onScrollMessageToTop={vi.fn()} /> ) - expect(screen.queryByRole('button')).toBeNull() + expect(screen.getByRole('button')).toHaveTextContent('Reasoning') rerender( + message: Pick markdown: string - /** The row's own turn is still running; a row is live only inside one. */ + /** The row's own turn (or subagent) is still running; an open row there has not ended. */ turnIsWorking?: boolean onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean }): React.JSX.Element | null { - if (!markdown.trim() || isNativeChatReasoningUnderway(message, turnIsWorking)) { + // Keyed like the live line, so a block opened while it streamed lands open, and windowing keeps it. + const disclosure = useNativeChatDisclosure(nativeChatReasoningDisclosureKey(message.id), false) + if (!markdown.trim()) { return null } const label = translate('components.native-chat.reasoning', 'Reasoning') - const headline = translatedHeadline(nativeChatReasoningHeadline(message)) + const headline = translatedHeadline(nativeChatReasoningHeadline(message, { live: turnIsWorking })) return (
- + {/* Laid out like a tool run's header, so its glyph sits in the same column. */} -
- -
+
diff --git a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx index 3813ff8d203..feefa5cfdd6 100644 --- a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx @@ -1,17 +1,35 @@ 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 { nativeChatReasoningDisclosureKey } from '../../../../shared/native-chat-reasoning-row' +import { + NativeChatReasoningBody, + NativeChatReasoningChevron +} from './NativeChatReasoningDisclosure' +import { useNativeChatDisclosure } from './native-chat-disclosure-store' /** The live turn's tail line: a spinner plus what the turn is doing right now — * the provider's activity text, else that it is reasoning, else plain "Working…". - * The clock lives in the turn bar under the user's message, not here. */ + * 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 + thinking, + liveReasoning = null, + onLinkClick, + allowFileUriLinks }: { activity?: NativeChatTurnActivity | null thinking: boolean + /** The open block this line discloses; its row draws nothing meanwhile. */ + liveReasoning?: NativeChatMessage | null + onLinkClick?: CommentMarkdownLinkClickHandler + allowFileUriLinks?: boolean }): React.JSX.Element { const resolved = describeNativeChatActiveTurnLabel({ activityText: activity?.text, thinking }) const label = @@ -21,6 +39,16 @@ export function NativeChatTurnActivityLine({ ? translate('components.native-chat.status.thinking', 'Thinking') : translate('components.native-chat.status.working', 'Working…') + if (liveReasoning) { + return ( + + ) + } return (
) } + +function NativeChatTurnActivityDisclosure({ + label, + reasoning, + onLinkClick, + allowFileUriLinks +}: { + label: string + reasoning: NativeChatMessage + onLinkClick?: CommentMarkdownLinkClickHandler + allowFileUriLinks?: boolean +}): React.JSX.Element { + // The finished row reads this key too, so a block opened here lands open once it ends. + const disclosure = useNativeChatDisclosure(nativeChatReasoningDisclosureKey(reasoning.id), false) + return ( +
+ + + + + + + + +
+ ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-subagent-section-slots.ts b/src/renderer/src/components/native-chat/native-chat-subagent-section-slots.ts index eed42185266..77636045399 100644 --- a/src/renderer/src/components/native-chat/native-chat-subagent-section-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-subagent-section-slots.ts @@ -9,7 +9,6 @@ import { subagentGroupBlocks } from '../../../../shared/native-chat-subagent-summary' import { nativeChatRowRendersContent } from '../../../../shared/native-chat-row-content' -import { isNativeChatReasoningUnderway } from '../../../../shared/native-chat-reasoning-row' import { estimateNativeChatRowHeight, nativeChatRowContentMetrics, @@ -96,11 +95,7 @@ export function nativeChatSubagentSectionSlots({ for (const [index, { message }] of rows.entries()) { openBefore(pending, message, depth, { turnKey }) const receipt = receipts.get(message.id) - if ( - receipt === undefined && - (!nativeChatRowRendersContent(message.blocks) || - isNativeChatReasoningUnderway(message, working)) - ) { + if (receipt === undefined && !nativeChatRowRendersContent(message.blocks)) { continue } slots.push({ diff --git a/src/renderer/src/components/native-chat/native-chat-subagent-sections.test.ts b/src/renderer/src/components/native-chat/native-chat-subagent-sections.test.ts index f483507c97e..323bd0d820f 100644 --- a/src/renderer/src/components/native-chat/native-chat-subagent-sections.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-subagent-sections.test.ts @@ -240,6 +240,17 @@ describe("a subagent's rows live in its own section", () => { expect(new Set(slots.map(nativeChatSlotKey)).size).toBe(slots.length) }) + // The parent's live line speaks for the session's own agent only, so a child's open block draws. + it("draws a working agent's open reasoning in its section", () => { + const thinking = row('child-think', say('Comparing the two diffs'), { + ...by('task-1'), + role: 'reasoning', + state: 'running' + }) + const rows = [...transcriptWith('working').slice(0, 3), thinking] + expect(outline(slotsOf(rows, { 'task-1': true }, true))).toContain('>child-think') + }) + // The fold reads only the conversation, so a subagent's failure after the answer is its own. it("folds a settled turn to the session's own answer, not a subagent's later failure", () => { const failed = [{ type: 'text' as const, text: 'The subagent failed.', tone: 'error' as const }] diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index 196b7671c85..1d7338f21ac 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -543,23 +543,18 @@ describe('turn-owned grouping', () => { ]) }) - it('reserves nothing for reasoning still underway in the live turn, and draws it once it ends', () => { - const reasoning = (state: 'running' | 'completed'): NativeChatMessage => ({ - ...text('r', 'Weighing two approaches', 'reasoning'), + it('skips only the open reasoning the live line discloses, and only while it does', () => { + const reasoning = (id: string, state: 'running' | 'completed'): NativeChatMessage => ({ + ...text(id, 'Weighing two approaches', 'reasoning'), state }) - const live = { turnKeys: ['A', 'A'], liveTurnKey: 'A', isWorking: true } - expect( - build([text('A', 'go', 'user'), reasoning('running')], live).map((slot) => slot.message.id) - ).toEqual(['A']) - expect( - build([text('A', 'go', 'user'), reasoning('completed')], live).map((slot) => slot.message.id) - ).toEqual(['A', 'r']) - // A row still open in a turn that is no longer running ended unseen; it draws as before. - expect( - build([text('A', 'go', 'user'), reasoning('running')], { turnKeys: ['A', 'A'] }).map( - (slot) => slot.message.id - ) - ).toEqual(['A', 'r']) + const live = { turnKeys: ['A', 'A', 'A'], liveTurnKey: 'A', isWorking: true } + const rows = [text('A', 'go', 'user'), reasoning('r-1', 'running'), reasoning('r-2', 'running')] + const ids = (overrides: Partial[1]>) => + build(rows, { ...live, ...overrides }).map((slot) => slot.message.id) + expect(ids({ liveReasoningId: 'r-2' })).toEqual(['A', 'r-1']) + // Nothing discloses it (a prompt took the line, or it says something else): it draws. + expect(ids({ liveReasoningId: null })).toEqual(['A', 'r-1', 'r-2']) + expect(ids({})).toEqual(['A', 'r-1', 'r-2']) }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index 43cfd86ce56..4ba28526c39 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -14,14 +14,16 @@ import { type NativeChatMessage } from '../../../../shared/native-chat-types' import type { NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status' -import { nativeChatMessagesWaitingBehindLiveTurn } from '../../../../shared/native-chat-turn-membership' +import { + isNativeChatRowInLiveWorkingTurn, + nativeChatMessagesWaitingBehindLiveTurn +} from '../../../../shared/native-chat-turn-membership' import { nativeChatTurnBarRows } from '../../../../shared/native-chat-turn-grouping' import { nativeChatTurnFold, type NativeChatTurnFoldRow } from '../../../../shared/native-chat-turn-fold' import { nativeChatRowRendersContent } from '../../../../shared/native-chat-row-content' -import { isNativeChatReasoningUnderway } from '../../../../shared/native-chat-reasoning-row' import { estimateNativeChatRowHeight, nativeChatRowContentMetrics @@ -99,6 +101,8 @@ export type NativeChatTranscriptSlotsInput = { lifecycleWorking: boolean subagentSections?: NativeChatSubagentSections subagentChoices?: NativeChatSubagentChoices + /** The open reasoning block the live activity line discloses: its row takes no slot meanwhile. */ + liveReasoningId?: string | null } export function buildNativeChatTranscriptSlots( @@ -115,7 +119,8 @@ export function buildNativeChatTranscriptSlots( isWorking, lifecycleWorking, subagentSections: sections = NO_NATIVE_CHAT_SUBAGENT_SECTIONS, - subagentChoices: choices = NO_NATIVE_CHAT_SUBAGENT_CHOICES + subagentChoices: choices = NO_NATIVE_CHAT_SUBAGENT_CHOICES, + liveReasoningId = null } = input // One pass to decide what each row draws, then the fold over those readings — // so "is this the answer" and "does this row render prose" cannot disagree. @@ -182,16 +187,14 @@ export function buildNativeChatTranscriptSlots( // Skipping a folded row entirely is what keeps windowing honest: a counted // index the row declines to draw reserves estimated height for nothing and // opens a gap in the transcript. - // Liveness is the owning turn's, not the newest prompt's: a running turn's - // rows stay live while a newer message waits behind it. - const activeTurnIsWorking = - (liveTurnKey ? turnKey === liveTurnKey : turnKey === undefined) && - (isWorking || lifecycleWorking) + const activeTurnIsWorking = isNativeChatRowInLiveWorkingTurn( + turnKey, + liveTurnKey, + isWorking || lifecycleWorking + ) const drawsRow = receipt !== undefined || - (!folded && - nativeChatRowRendersContent(message.blocks) && - !isNativeChatReasoningUnderway(message, activeTurnIsWorking)) + (!folded && nativeChatRowRendersContent(message.blocks) && message.id !== liveReasoningId) const roster = sectionSlots.rosterAt(message.id) if (drawsRow || status !== undefined || turnDiff !== undefined) { slots.push({ 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 new file mode 100644 index 00000000000..44b2401d63f --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-slots.ts @@ -0,0 +1,80 @@ +import { useMemo } from 'react' +import { selectNativeChatLiveReasoning } from '../../../../shared/native-chat-reasoning-row' +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. */ +export function useNativeChatTranscriptSlots({ + lineShowsThinking, + ...input +}: Omit & { + /** The live line's own render condition and that it reads "Thinking". */ + lineShowsThinking: boolean +}): { slots: NativeChatTranscriptSlot[]; liveReasoning: NativeChatMessage | null } { + const { + messages, + turnKeys, + liveTurnKey, + receipts, + turnStatuses, + turnDiffs, + expandedTurnKeys, + isWorking, + lifecycleWorking, + subagentSections, + subagentChoices + } = input + const liveReasoning = useMemo( + () => + selectNativeChatLiveReasoning( + messages, + (index) => + isNativeChatRowInLiveWorkingTurn( + turnKeys[index], + liveTurnKey, + isWorking || lifecycleWorking + ), + lineShowsThinking + ), + [isWorking, lifecycleWorking, lineShowsThinking, liveTurnKey, messages, turnKeys] + ) + const liveReasoningId = liveReasoning?.id ?? null + const slots = useMemo( + () => + buildNativeChatTranscriptSlots({ + messages, + turnKeys, + liveTurnKey, + receipts, + turnStatuses, + turnDiffs, + expandedTurnKeys, + isWorking, + lifecycleWorking, + subagentSections, + subagentChoices, + liveReasoningId + }), + [ + expandedTurnKeys, + isWorking, + lifecycleWorking, + liveReasoningId, + liveTurnKey, + messages, + receipts, + subagentChoices, + subagentSections, + turnDiffs, + turnKeys, + turnStatuses + ] + ) + return { slots, liveReasoning } +} diff --git a/src/shared/native-chat-reasoning-row.test.ts b/src/shared/native-chat-reasoning-row.test.ts index 86fed235f6f..ac269475021 100644 --- a/src/shared/native-chat-reasoning-row.test.ts +++ b/src/shared/native-chat-reasoning-row.test.ts @@ -1,30 +1,99 @@ import { describe, expect, it } from 'vitest' import { - isNativeChatReasoningUnderway, + nativeChatReasoningDisclosureKey, nativeChatReasoningHeadline, - nativeChatReasoningHeadlineText + nativeChatReasoningHeadlineText, + selectNativeChatLiveReasoning } from './native-chat-reasoning-row' +import type { NativeChatMessage } from './native-chat-types' -describe('the reasoning row every client draws', () => { - it('is hidden only while its block is still being written in a running turn', () => { - expect(isNativeChatReasoningUnderway({ role: 'reasoning', state: 'running' }, true)).toBe(true) - expect(isNativeChatReasoningUnderway({ role: 'reasoning', state: 'running' }, false)).toBe( - false - ) - expect(isNativeChatReasoningUnderway({ role: 'reasoning', state: 'completed' }, true)).toBe( - false - ) - expect(isNativeChatReasoningUnderway({ role: 'reasoning' }, true)).toBe(false) - expect(isNativeChatReasoningUnderway({ role: 'assistant', state: 'running' }, true)).toBe(false) +function row( + id: string, + role: NativeChatMessage['role'], + text: string, + fields: Partial = {} +): NativeChatMessage { + return { + id, + role, + blocks: [{ type: 'text', text }], + timestamp: 1_000, + source: 'transcript', + ...fields + } +} + +const prompt = row('user-1', 'user', 'Start the task') +const open = row('r-1', 'reasoning', 'Weighing two approaches', { state: 'running' }) +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) + // 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) }) + 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 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() + 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() + }) + + 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 + ) + }) + + it('stops at the live turn prompt and ignores a subagent reasoning', () => { + expect(selectNativeChatLiveReasoning([open, prompt], inTurn, true)).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) + }) + + it('keys one block the same for the line and the row, apart from tool runs', () => { + expect(nativeChatReasoningDisclosureKey('r-1')).toBe('reasoning:r-1') + }) +}) + +describe('the reasoning headline', () => { + const text = ( + fields: { state?: 'running' | 'completed'; completedAt?: number }, + live = false + ): string => + nativeChatReasoningHeadlineText( + nativeChatReasoningHeadline({ timestamp: 1_000, ...fields }, { live }) + ) + it('reads the span the host saw, at least a second, and claims none it did not see', () => { - const text = (fields: { state?: 'running' | 'completed'; completedAt?: number }) => - nativeChatReasoningHeadlineText(nativeChatReasoningHeadline({ timestamp: 1_000, ...fields })) expect(text({ state: 'completed', completedAt: 66_000 })).toBe('Thought for 1m 5s') expect(text({ state: 'completed', completedAt: 1_300 })).toBe('Thought for 1s') expect(text({ state: 'completed' })).toBe('Thought') expect(text({ state: 'running' })).toBe('Thought') expect(text({})).toBe('Reasoning') }) + + it('claims no past tense for a block not yet ended inside its working turn', () => { + expect(text({ state: 'running' }, true)).toBe('Reasoning') + expect(text({}, true)).toBe('Reasoning') + expect(text({ state: 'completed', completedAt: 13_000 }, true)).toBe('Thought for 12s') + }) }) diff --git a/src/shared/native-chat-reasoning-row.ts b/src/shared/native-chat-reasoning-row.ts index e3939417965..307502e9a3f 100644 --- a/src/shared/native-chat-reasoning-row.ts +++ b/src/shared/native-chat-reasoning-row.ts @@ -1,30 +1,71 @@ -// The reasoning row, as desktop and mobile both draw it: whether it draws at all, and what its -// collapsed headline says. Read from host facts only — the row's start (`timestamp`) and the end -// the host saw — so every client tells the same story about one row. +// The reasoning row, as desktop and mobile both draw it: which open block the live activity line +// discloses, and what a row's collapsed headline says. Read from host facts only — the row's start +// (`timestamp`) and the end the host saw — so every client tells the same story about one row. +import { isRootAgentJournalItem } from './agent-session-journal-producer' +import { deriveNativeChatRowContent, nativeChatRowRendersContent } from './native-chat-row-content' import { formatNativeChatDuration } from './native-chat-turn-status' import type { NativeChatMessage } from './native-chat-types' -/** A reasoning row still being written in a turn that is running. It draws nothing until it ends: - * the turn's activity line is what says the agent is thinking, and one live indicator is enough. */ -export function isNativeChatReasoningUnderway( - message: Pick, - turnIsWorking: boolean -): boolean { - return message.role === 'reasoning' && message.state === 'running' && turnIsWorking +/** + * 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. + */ +export function selectNativeChatLiveReasoning( + messages: readonly NativeChatMessage[], + inLiveWorkingTurn: (index: number) => boolean, + lineShowsThinking: boolean +): NativeChatMessage | null { + if (!lineShowsThinking) { + return null + } + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (!message || !inLiveWorkingTurn(index)) { + continue + } + if (message.role === 'user') { + return null + } + // A notice is not newer content, and a subagent's reasoning draws in its own section. + if (message.role === 'system' || !isRootAgentJournalItem(message)) { + continue + } + if (message.role !== 'reasoning') { + if (!nativeChatRowRendersContent(message.blocks)) { + continue + } + 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 + } + return null +} + +/** One key per reasoning block, read by the live line and the finished row alike. */ +export function nativeChatReasoningDisclosureKey(messageId: string): string { + return `reasoning:${messageId}` } export type NativeChatReasoningHeadline = - /** From a host that kept no lifecycle: nothing is claimed. */ + /** From a host that kept no lifecycle, or not yet ended: nothing is claimed. */ | { kind: 'reasoning' } /** Ended, with no span the host saw. */ | { kind: 'thought' } | { kind: 'thoughtFor'; duration: string } +/** `live`: the row is drawn inside its working turn or working subagent. */ export function nativeChatReasoningHeadline( - message: Pick + message: Pick, + { live }: { live: boolean } ): NativeChatReasoningHeadline { - if (message.state === undefined) { + // Not ended yet, so it claims no past tense. + if (message.state === undefined || (live && message.state !== 'completed')) { return { kind: 'reasoning' } } // An open row in a turn that is no longer live ended unseen, so it claims no duration. diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index f00923996c4..b40290ae600 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -237,6 +237,16 @@ export function nativeChatMessagesWaitingBehindLiveTurn( ) } +/** Whether a row belongs to the turn running now. Liveness is the owning turn's, not the newest + * prompt's: a running turn's rows stay live while a newer message waits behind it. */ +export function isNativeChatRowInLiveWorkingTurn( + turnKey: string | undefined, + liveTurnKey: string | undefined, + working: boolean +): boolean { + return working && (liveTurnKey ? turnKey === liveTurnKey : turnKey === undefined) +} + function commandTurnRunning(items: readonly AgentJournalRenderItem[]): boolean { const running = liveStructuredAgentSessionTurnScope(items) const bodyOf = (itemId: string) => items.find((item) => item.itemId === itemId)?.body