From 024010ecfc91b47e98cd89c75681fa4401d99558 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:33:02 -0700 Subject: [PATCH] feat(native-chat): read an open reasoning block through the one live "Thinking" line While the agent's open reasoning block has text, the turn's live activity line is its disclosure: collapsed by default, expandable to the live text (capped and scrollable), and the block's row draws nothing meanwhile. When the block ends, its row appears in place, open if the reader opened it live, because the line and the row read one disclosure key. Which block the line discloses is derived from the line's own render condition, so a row is never hidden while nothing on screen shows it; any other open block (a subagent's, or one a prompt pushed off the line) draws as "Reasoning". Desktop and mobile alike; no host or wire change. --- .../session/MobileNativeChatLiveLine.test.ts | 92 +++++++++ .../src/session/MobileNativeChatLiveLine.tsx | 88 +++++++++ .../MobileNativeChatMessage.android.test.ts | 2 + .../session/MobileNativeChatMessage.test.ts | 19 +- .../src/session/MobileNativeChatMessage.tsx | 24 ++- .../session/MobileNativeChatReasoningRow.tsx | 65 ++++-- .../src/session/MobileNativeChatView.test.ts | 3 + mobile/src/session/MobileNativeChatView.tsx | 20 +- .../MobileNativeChatView.turn-status.test.ts | 11 +- .../MobileNativeChatView.waiting-rows.test.ts | 3 + .../mobile-native-chat-message-styles.ts | 4 + ...obile-native-chat-turn-disclosure.test.tsx | 62 ++++++ .../use-mobile-native-chat-turn-disclosure.ts | 126 +++++++++--- ...iveChatMessageList.live-reasoning.test.tsx | 186 ++++++++++++++++++ .../native-chat/NativeChatMessageList.tsx | 49 ++--- .../NativeChatReasoningDisclosure.tsx | 42 ++++ .../NativeChatReasoningRow.test.tsx | 54 ++++- .../native-chat/NativeChatReasoningRow.tsx | 46 ++--- .../NativeChatTurnActivityLine.tsx | 80 +++++++- .../native-chat-subagent-section-slots.ts | 7 +- .../native-chat-subagent-sections.test.ts | 11 ++ .../native-chat-transcript-slots.test.ts | 27 ++- .../native-chat-transcript-slots.ts | 25 +-- .../use-native-chat-transcript-slots.ts | 80 ++++++++ src/shared/native-chat-reasoning-row.test.ts | 99 ++++++++-- src/shared/native-chat-reasoning-row.ts | 67 +++++-- src/shared/native-chat-turn-membership.ts | 10 + 27 files changed, 1104 insertions(+), 198 deletions(-) create mode 100644 mobile/src/session/MobileNativeChatLiveLine.test.ts create mode 100644 mobile/src/session/MobileNativeChatLiveLine.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatReasoningDisclosure.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-transcript-slots.ts 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