mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
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.
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<MobileNativeChatTurnActivity thinking={line.thinking} activityText={line.activityText} />
|
||||
)
|
||||
}
|
||||
const label = formatNativeChatActiveTurnLabel(line)
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.pressed]}
|
||||
// The finished row reads this key too, so a block opened here lands open once it ends.
|
||||
onPress={() => onToggleReasoning(nativeChatReasoningDisclosureKey(reasoning.id))}
|
||||
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"
|
||||
>
|
||||
<ActivityIndicator size="small" color={colors.textMuted} />
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<View style={open ? styles.caretOpen : undefined}>
|
||||
<ChevronRight size={14} color={colors.textMuted} strokeWidth={2} />
|
||||
</View>
|
||||
</Pressable>
|
||||
{open ? (
|
||||
<View style={styles.body}>
|
||||
<MobileNativeChatReasoningBody
|
||||
markdown={deriveNativeChatRowContent(reasoning.blocks).markdown}
|
||||
fontScale={fontScale}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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<NativeChatMessage, 'state' | 'completedAt' | 'timestamp'>
|
||||
message: Pick<NativeChatMessage, 'id' | 'state' | 'completedAt' | 'timestamp'>
|
||||
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 (
|
||||
<View>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.reasoningToggle, pressed && styles.reasoningPressed]}
|
||||
onPress={() => setExpanded((open) => !open)}
|
||||
onPress={toggle}
|
||||
hitSlop={6}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded }}
|
||||
@@ -50,16 +62,41 @@ export function MobileNativeChatReasoningRow({
|
||||
</View>
|
||||
</Pressable>
|
||||
{expanded ? (
|
||||
<MobileNativeChatLongPressContent onLongPress={onLongPress} style={styles.reasoning}>
|
||||
<MobileMarkdown
|
||||
content={markdown}
|
||||
rangeSelectable
|
||||
textScale={1.25 * fontScale}
|
||||
onOpenFile={onOpenFile}
|
||||
onLongPress={onLongPress}
|
||||
/>
|
||||
</MobileNativeChatLongPressContent>
|
||||
<MobileNativeChatReasoningBody
|
||||
markdown={markdown}
|
||||
fontScale={fontScale}
|
||||
onOpenFile={onOpenFile}
|
||||
onLongPress={onLongPress}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<ScrollView style={styles.reasoningBody} nestedScrollEnabled>
|
||||
<MobileNativeChatLongPressContent onLongPress={onLongPress} style={styles.reasoning}>
|
||||
<MobileMarkdown
|
||||
content={markdown}
|
||||
rangeSelectable
|
||||
textScale={1.25 * fontScale}
|
||||
onOpenFile={onOpenFile}
|
||||
onLongPress={onLongPress}
|
||||
/>
|
||||
</MobileNativeChatLongPressContent>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' }))
|
||||
|
||||
@@ -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 ? (
|
||||
<MobileNativeChatTurnActivity
|
||||
thinking={turns.active.thinking}
|
||||
activityText={turns.activeActivityText}
|
||||
/>
|
||||
) : null
|
||||
const liveStatus = turns.liveLine ? (
|
||||
<MobileNativeChatLiveLine
|
||||
line={turns.liveLine}
|
||||
onToggleReasoning={turns.onToggleReasoning}
|
||||
fontScale={fontScale}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
) : null
|
||||
|
||||
const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error)
|
||||
const showLoading = status === 'loading' && messages.length === 0
|
||||
|
||||
@@ -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<string, unknown> }).props
|
||||
}
|
||||
|
||||
/** What the live footer line says: its label inputs, read off the line the view hands it. */
|
||||
function footerProps(): Record<string, unknown> | null {
|
||||
const list = renderer!.root.find((node) => String(node.type) === 'FlatList')
|
||||
const footer = list.props.ListFooterComponent as
|
||||
| { props: Record<string, unknown> }
|
||||
| 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[] {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string>, (key: string) => void] {
|
||||
const [state, setState] = useState<{ scopeKey: string; keys: ReadonlySet<string> }>(() => ({
|
||||
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<string>
|
||||
}>(() => ({ 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<MobileNativeChatLiveLine | null>(
|
||||
() =>
|
||||
showsLiveLine
|
||||
? {
|
||||
thinking: active?.thinking === true,
|
||||
activityText: activeActivityText,
|
||||
reasoning: liveReasoning,
|
||||
reasoningExpanded:
|
||||
liveReasoning !== null &&
|
||||
expandedReasoning.has(nativeChatReasoningDisclosureKey(liveReasoning.id))
|
||||
}
|
||||
: null,
|
||||
[active, activeActivityText, expandedReasoning, liveReasoning, showsLiveLine]
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+186
@@ -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> = {}
|
||||
): 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.ComponentProps<typeof NativeChatMessageList>> = {}
|
||||
): 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 (
|
||||
<NativeChatMessageList
|
||||
session={session}
|
||||
journalItems={journal(rows)}
|
||||
isWorking
|
||||
expandSignal={false}
|
||||
fontScale={1}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const liveLine = (): HTMLElement =>
|
||||
screen.getByText('Thinking').closest<HTMLElement>('[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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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({
|
||||
<NativeChatTurnActivityLine
|
||||
activity={turnActivity}
|
||||
thinking={turnStatuses.active?.thinking === true}
|
||||
liveReasoning={liveReasoning}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
/>
|
||||
) : tailRow === 'awaiting-input' ? (
|
||||
<NativeChatAwaitingInputRow subject={null} pending />
|
||||
|
||||
@@ -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 (
|
||||
<div className="scrollbar-sleek mt-1 max-h-80 overflow-y-auto pl-5.5 italic">
|
||||
<CommentMarkdown
|
||||
content={markdown}
|
||||
variant="document"
|
||||
className="text-sm"
|
||||
renderCodeBlock={NativeChatCodeBlock}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
linkifyFilePaths={onLinkClick !== undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className="size-3.5 shrink-0 transition-all can-hover:opacity-0 group-hover/reasoning:opacity-100 group-focus-visible/reasoning:opacity-100 group-data-[state=open]/reasoning:rotate-90 group-data-[state=open]/reasoning:opacity-100 motion-reduce:transition-none"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 }) => <div data-testid="markdown">{content}</div>
|
||||
@@ -22,7 +26,7 @@ describe('reasoning disclosure', () => {
|
||||
it('starts collapsed without mounting markdown', () => {
|
||||
render(
|
||||
<NativeChatReasoningRow
|
||||
message={{ role: 'reasoning', timestamp: STARTED, state: 'completed' }}
|
||||
message={{ id: 'r-1', role: 'reasoning', timestamp: STARTED, state: 'completed' }}
|
||||
markdown={'\n\nInspecting the request\nFull reasoning'}
|
||||
/>
|
||||
)
|
||||
@@ -42,7 +46,7 @@ describe('reasoning disclosure', () => {
|
||||
cleanup()
|
||||
render(
|
||||
<NativeChatReasoningRow
|
||||
message={{ role: 'reasoning', timestamp: STARTED, state: 'completed' }}
|
||||
message={{ id: 'r-1', role: 'reasoning', timestamp: STARTED, state: 'completed' }}
|
||||
markdown="Inspecting"
|
||||
/>
|
||||
)
|
||||
@@ -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(
|
||||
<NativeChatReasoningRow
|
||||
message={{ role: 'reasoning', timestamp: STARTED, state: 'completed' }}
|
||||
message={{ id: 'r-1', role: 'reasoning', timestamp: STARTED, state: 'completed' }}
|
||||
markdown="Inspecting"
|
||||
/>
|
||||
)
|
||||
@@ -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(<NativeChatReasoningRow message={message} markdown="Inspecting" />)
|
||||
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 (
|
||||
<NativeChatDisclosureContext.Provider value={disclosures}>
|
||||
{children}
|
||||
</NativeChatDisclosureContext.Provider>
|
||||
)
|
||||
}
|
||||
const row = (
|
||||
<NativeChatReasoningRow
|
||||
message={{ id: 'r-1', role: 'reasoning', timestamp: STARTED, state: 'completed' }}
|
||||
markdown="Inspecting"
|
||||
/>
|
||||
)
|
||||
const { rerender } = render(<Transcript>{row}</Transcript>)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
// Windowed out, then back.
|
||||
rerender(<Transcript>{null}</Transcript>)
|
||||
rerender(<Transcript>{row}</Transcript>)
|
||||
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(
|
||||
<NativeChatReasoningRow
|
||||
message={{ role: 'reasoning', timestamp: STARTED, state: 'running' }}
|
||||
message={{ id: 'r-1', role: 'reasoning', timestamp: STARTED, state: 'running' }}
|
||||
markdown={markdown}
|
||||
/>
|
||||
)
|
||||
@@ -110,7 +143,7 @@ describe('the reasoning headline', () => {
|
||||
) => {
|
||||
render(
|
||||
<NativeChatReasoningRow
|
||||
message={{ role: 'reasoning', ...message }}
|
||||
message={{ id: 'r-1', role: 'reasoning', ...message }}
|
||||
markdown="Reasoned"
|
||||
turnIsWorking={turnIsWorking}
|
||||
/>
|
||||
@@ -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(
|
||||
<MessageRow
|
||||
message={{ ...message, state: 'completed', completedAt: STARTED + 3_000 }}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import CommentMarkdown, {
|
||||
type CommentMarkdownLinkClickHandler
|
||||
} from '@/components/sidebar/CommentMarkdown'
|
||||
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import {
|
||||
isNativeChatReasoningUnderway,
|
||||
nativeChatReasoningDisclosureKey,
|
||||
nativeChatReasoningHeadline,
|
||||
type NativeChatReasoningHeadline
|
||||
} from '../../../../shared/native-chat-reasoning-row'
|
||||
import { NativeChatCodeBlock } from './NativeChatCodeBlock'
|
||||
import {
|
||||
NativeChatReasoningBody,
|
||||
NativeChatReasoningChevron
|
||||
} from './NativeChatReasoningDisclosure'
|
||||
import { NativeChatToolRunIcon } from './NativeChatToolIcon'
|
||||
import { useNativeChatDisclosure } from './native-chat-disclosure-store'
|
||||
|
||||
function translatedHeadline(headline: NativeChatReasoningHeadline): string {
|
||||
if (headline.kind === 'thoughtFor') {
|
||||
@@ -31,22 +32,24 @@ export function NativeChatReasoningRow({
|
||||
onLinkClick,
|
||||
allowFileUriLinks
|
||||
}: {
|
||||
message: Pick<NativeChatMessage, 'role' | 'state' | 'completedAt' | 'timestamp'>
|
||||
message: Pick<NativeChatMessage, 'id' | 'role' | 'state' | 'completedAt' | 'timestamp'>
|
||||
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 (
|
||||
<div className="min-w-0 text-sm text-muted-foreground">
|
||||
<Collapsible>
|
||||
<Collapsible open={disclosure.open} onOpenChange={disclosure.setOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
{/* Laid out like a tool run's header, so its glyph sits in the same column. */}
|
||||
<button
|
||||
@@ -58,24 +61,15 @@ export function NativeChatReasoningRow({
|
||||
<span className="min-w-0 truncate leading-relaxed transition-colors group-hover/reasoning:text-foreground/80">
|
||||
{headline}
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className="size-3.5 shrink-0 transition-all can-hover:opacity-0 group-hover/reasoning:opacity-100 group-focus-visible/reasoning:opacity-100 group-data-[state=open]/reasoning:rotate-90 group-data-[state=open]/reasoning:opacity-100 motion-reduce:transition-none"
|
||||
/>
|
||||
<NativeChatReasoningChevron />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-1 pl-5.5 italic">
|
||||
<CommentMarkdown
|
||||
content={markdown}
|
||||
variant="document"
|
||||
className="text-sm"
|
||||
renderCodeBlock={NativeChatCodeBlock}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
linkifyFilePaths={onLinkClick !== undefined}
|
||||
/>
|
||||
</div>
|
||||
<NativeChatReasoningBody
|
||||
markdown={markdown}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<NativeChatTurnActivityDisclosure
|
||||
label={label}
|
||||
reasoning={liveReasoning}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-6 items-center gap-1.5 text-sm leading-relaxed text-muted-foreground"
|
||||
@@ -33,3 +61,51 @@ export function NativeChatTurnActivityLine({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-w-0 text-sm text-muted-foreground" data-native-chat-turn-activity="true">
|
||||
<Collapsible open={disclosure.open} onOpenChange={disclosure.setOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group/reasoning flex min-h-6 w-full min-w-0 items-center gap-1.5 rounded-md text-left leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
|
||||
>
|
||||
<Loader2
|
||||
aria-hidden
|
||||
className="size-4 shrink-0 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
{/* Only the label is live: the streaming body would be re-announced on every frame. */}
|
||||
<span
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="min-w-0 truncate text-foreground/85"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<NativeChatReasoningChevron />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<NativeChatReasoningBody
|
||||
markdown={deriveNativeChatRowContent(reasoning.blocks).markdown}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }]
|
||||
|
||||
@@ -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<Parameters<typeof build>[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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<NativeChatTranscriptSlotsInput, 'liveReasoningId'> & {
|
||||
/** 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 }
|
||||
}
|
||||
@@ -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> = {}
|
||||
): 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<NativeChatMessage, 'role' | 'state'>,
|
||||
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<NativeChatMessage, 'state' | 'completedAt' | 'timestamp'>
|
||||
message: Pick<NativeChatMessage, 'state' | 'completedAt' | 'timestamp'>,
|
||||
{ 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user