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