mobile: give the live turn row a spinner and the shared indicator label

Mobile's per-turn row is already the only live indicator on the structured
lane, but it pulsed a bare word and never showed what the provider said it was
doing. It now renders a spinner beside the same resolved label desktop uses,
and reads reasoning from the journal instead of inferring it from missing
output. The bridge lane's four prompt/interrupt write seams move to one module
so the controller stays under its line cap.
This commit is contained in:
Merge Sim
2026-09-10 20:11:43 -07:00
parent 86f8710de6
commit 2154c27cf8
14 changed files with 378 additions and 101 deletions
@@ -9,6 +9,7 @@ vi.mock('react-native', async () => {
const Text = ({ children, ...props }: { children?: unknown }): unknown =>
React.createElement('Text', props, children)
return {
ActivityIndicator: 'ActivityIndicator',
Animated: {
Text,
Value: class {
@@ -73,6 +73,7 @@ function MobileNativeChatMessageImpl({
turnStatus,
turnExpanded,
turnKey,
turnActivityText,
onToggleTurn,
activeTurnIsWorking,
structuredActivityUi = false
@@ -88,6 +89,8 @@ function MobileNativeChatMessageImpl({
turnExpanded?: boolean
/** Set only when this row's turn has settled and can disclose its activity. */
turnKey?: string
/** Provider activity copy, set only on the live turn's row. */
turnActivityText?: string | null
/** Stable across renders; the row supplies its own key when tapped. */
onToggleTurn?: (turnKey: string) => void
/** Session-level working state for this message's turn; gates the live tool row. */
@@ -150,6 +153,7 @@ function MobileNativeChatMessageImpl({
startedAt={turnStatus.startedAt}
thinking={turnStatus.thinking}
workedSeconds={turnStatus.workedSeconds}
activityText={turnActivityText}
expanded={turnExpanded ?? false}
onToggleExpanded={turnKey && onToggleTurn ? () => onToggleTurn(turnKey) : undefined}
/>
@@ -73,6 +73,7 @@ export function MobileNativeChatOverlay({
agentWorking={controller.nativeChatAgentWorking}
canStop={controller.nativeChatCanStop}
structuredActivityUi={controller.nativeChatStructured}
turnIndicator={controller.nativeChatTurnIndicator}
workingStartedAt={controller.nativeChatWorkingStartedAt}
settledTurns={controller.nativeChatSettledTurns}
streaming={streaming}
@@ -7,18 +7,8 @@ vi.mock('react-native', async () => {
const Text = ({ children, ...props }: { children?: unknown }): unknown =>
React.createElement('Text', props, children)
return {
Animated: {
Text,
Value: class {
constructor(private value: number) {}
setValue(next: number): void {
this.value = next
}
},
loop: (animation: unknown) => animation,
sequence: () => ({ start: vi.fn(), stop: vi.fn() }),
timing: () => ({ start: vi.fn(), stop: vi.fn() })
},
ActivityIndicator: (props: Record<string, unknown>) =>
React.createElement('ActivityIndicator', props),
Pressable: ({ children, ...props }: { children?: unknown }) =>
React.createElement('Pressable', props, children),
Text,
@@ -49,6 +39,7 @@ describe('MobileNativeChatTurnStatus', () => {
startedAt: number | null
thinking: boolean
workedSeconds?: number | null
activityText?: string | null
expanded?: boolean
onToggleExpanded?: () => void
}): ReactTestRenderer {
@@ -61,12 +52,16 @@ describe('MobileNativeChatTurnStatus', () => {
const labels = (node: ReactTestInstance): string[] =>
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))
it('reads "Thinking" before the turn produces output', () => {
const spinners = (node: ReactTestInstance): ReactTestInstance[] =>
node.findAllByType('ActivityIndicator' as never)
it('reads "Thinking" beside one spinner while the turn reasons', () => {
const tree = render({ startedAt: Date.now(), thinking: true })
expect(labels(tree.root)).toEqual(['Thinking'])
expect(spinners(tree.root)).toHaveLength(1)
})
it('counts up once the turn is producing output', () => {
it('counts up on that same single row when the turn is not reasoning', () => {
const startedAt = Date.now()
const tree = render({ startedAt, thinking: false })
expect(labels(tree.root)).toEqual(['Working for 0s'])
@@ -74,6 +69,19 @@ describe('MobileNativeChatTurnStatus', () => {
vi.advanceTimersByTime(12_000)
})
expect(labels(tree.root)).toEqual(['Working for 12s'])
expect(spinners(tree.root)).toHaveLength(1)
})
it('lets provider activity text beat both fallbacks and hold the clock', () => {
const tree = render({
startedAt: Date.now(),
thinking: true,
activityText: 'Running pnpm test'
})
expect(labels(tree.root)).toEqual(['Running pnpm test'])
expect(spinners(tree.root)).toHaveLength(1)
// No label consumes the duration, so nothing schedules a tick for it.
expect(vi.getTimerCount()).toBe(0)
})
it('settles to a tappable "Worked for" row that toggles the turn', () => {
@@ -98,9 +106,10 @@ describe('MobileNativeChatTurnStatus', () => {
expect(labels(tree.root)).toEqual(['Worked for 5s'])
})
it('holds no interval once the turn has settled', () => {
render({ startedAt: Date.now(), thinking: false, workedSeconds: 5 })
it('holds no interval, and no spinner, once the turn has settled', () => {
const tree = render({ startedAt: Date.now(), thinking: false, workedSeconds: 5 })
expect(vi.getTimerCount()).toBe(0)
expect(spinners(tree.root)).toHaveLength(0)
})
it('announces the live row to assistive tech', () => {
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native'
import { useEffect, useState } from 'react'
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'
import { ChevronRight } from 'lucide-react-native'
import {
formatNativeChatActiveTurnLabel,
formatNativeChatTurnStatusLabel,
NATIVE_CHAT_TURN_STATUS_COPY,
nativeChatElapsedSeconds
@@ -25,48 +26,38 @@ function useElapsedSeconds(startedAt: number | null, counting: boolean): number
return counting ? nativeChatElapsedSeconds(startedAt, mountedAt, now) : 0
}
/** The per-turn status row — "Thinking", then "Working for 12s" while the turn
* runs, settling to a tappable "Worked for 3m 4s" that discloses the turn's
* tool activity. Desktop parity: `NativeChatWorkingStatus`. */
/** The per-turn status row. While the turn runs it is the one live indicator — a
* spinner beside what the provider says it is doing, else "Thinking", else
* "Working for 12s". It settles to a tappable "Worked for 3m 4s" that discloses
* the turn's tool activity. Desktop parity: `NativeChatTurnActivityLine` for the
* live row, `NativeChatWorkingStatus` for the settled one. */
export function MobileNativeChatTurnStatus({
startedAt,
thinking,
workedSeconds,
activityText,
expanded = false,
onToggleExpanded
}: {
startedAt: number | null
thinking: boolean
workedSeconds?: number | null
/** Provider activity copy for a live turn; outranks the other two labels. */
activityText?: string | null
expanded?: boolean
onToggleExpanded?: () => void
}): React.JSX.Element {
const counting = !thinking && workedSeconds == null
const settled = workedSeconds != null
const counting = !settled && !thinking && !activityText?.trim()
const elapsedSeconds = useElapsedSeconds(startedAt, counting)
const label = formatNativeChatTurnStatusLabel({ thinking, workedSeconds, elapsedSeconds })
const label = settled
? formatNativeChatTurnStatusLabel({ thinking, workedSeconds, elapsedSeconds })
: formatNativeChatActiveTurnLabel({ activityText, thinking, elapsedSeconds })
const pulse = useRef(new Animated.Value(1)).current
useEffect(() => {
if (!thinking) {
pulse.setValue(1)
return
}
const animation = Animated.loop(
Animated.sequence([
Animated.timing(pulse, { toValue: 0.45, duration: 700, useNativeDriver: true }),
Animated.timing(pulse, { toValue: 1, duration: 700, useNativeDriver: true })
])
)
animation.start()
return () => animation.stop()
}, [pulse, thinking])
const rowStyle = [styles.row, thinking ? null : styles.rowSettled]
if (workedSeconds != null && onToggleExpanded) {
if (settled && onToggleExpanded) {
return (
<Pressable
style={({ pressed }) => [...rowStyle, pressed && styles.pressed]}
style={({ pressed }) => [styles.row, styles.rowSettled, pressed && styles.pressed]}
onPress={onToggleExpanded}
hitSlop={6}
accessibilityRole="button"
@@ -83,11 +74,14 @@ export function MobileNativeChatTurnStatus({
return (
<View
style={rowStyle}
style={[styles.row, settled ? styles.rowSettled : null]}
accessibilityLiveRegion="polite"
accessibilityLabel={NATIVE_CHAT_TURN_STATUS_COPY.responding}
>
<Animated.Text style={[styles.label, thinking && { opacity: pulse }]}>{label}</Animated.Text>
{settled ? null : <ActivityIndicator size="small" color={colors.textMuted} />}
<Text style={styles.label} numberOfLines={1}>
{label}
</Text>
</View>
)
}
@@ -109,7 +103,8 @@ const styles = StyleSheet.create({
},
label: {
color: colors.textMuted,
fontSize: typography.bodySize
fontSize: typography.bodySize,
flexShrink: 1
},
caretOpen: {
transform: [{ rotate: '90deg' }]
@@ -73,6 +73,7 @@ type Overrides = {
onSend?: (text: string) => Promise<boolean>
pending?: Parameters<typeof MobileNativeChatView>[0]['pending']
structuredActivityUi?: boolean
turnIndicator?: Parameters<typeof MobileNativeChatView>[0]['turnIndicator']
agentWorking?: boolean
canStop?: boolean
sendSurfaceId?: string
@@ -282,11 +283,50 @@ describe('MobileNativeChatView', () => {
await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true })
const props = rowProps('u1')
expect(props.structuredActivityUi).toBe(true)
expect(props.turnStatus).toMatchObject({ thinking: true, workedSeconds: null })
// Nothing reports reasoning, so the one live row counts instead of guessing.
expect(props.turnStatus).toMatchObject({ thinking: false, workedSeconds: null })
expect(props.turnActivityText).toBeNull()
expect(props.activeTurnIsWorking).toBe(true)
expect(workingIndicators()).toHaveLength(0)
})
it('reports the live turn as thinking only when its journal says it is reasoning', async () => {
const folded = [userTurn('u1', 'go')]
await render({
messages: folded,
folded,
structuredActivityUi: true,
agentWorking: true,
turnIndicator: { thinking: true, activityText: null }
})
expect(rowProps('u1').turnStatus).toMatchObject({ thinking: true, workedSeconds: null })
})
it('hands the live row the provider activity copy that outranks its fallbacks', async () => {
const folded = [userTurn('u1', 'go')]
await render({
messages: folded,
folded,
structuredActivityUi: true,
agentWorking: true,
turnIndicator: { thinking: true, activityText: 'Running pnpm test' }
})
expect(rowProps('u1').turnActivityText).toBe('Running pnpm test')
})
it('withholds the activity copy from a settled turn', async () => {
const folded = [userTurn('u1', 'go'), userTurn('u2', 'again')]
await render({
messages: folded,
folded,
structuredActivityUi: true,
agentWorking: true,
turnIndicator: { thinking: false, activityText: 'Running pnpm test' }
})
expect(rowProps('u1').turnActivityText).toBeNull()
expect(rowProps('u2').turnActivityText).toBe('Running pnpm test')
})
it('keeps the bridge lane on the three-dot indicator with no turn status', async () => {
const folded = [userTurn('u1', 'go')]
await render({ messages: folded, folded, agentWorking: true })
+13 -3
View File
@@ -13,7 +13,10 @@ import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-ha
import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native'
import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status'
import type {
NativeChatLiveTurnIndicator,
NativeChatSettledTurns
} from '../../../src/shared/native-chat-turn-status'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-view-styles'
import {
@@ -53,6 +56,8 @@ type Props = {
/** Structured lane: per-turn "Working for N" status plus live tool progress,
* replacing the bridge lane's static three-dot working row (desktop parity). */
structuredActivityUi?: boolean
/** What labels the live turn's one indicator row (structured lane only). */
turnIndicator?: NativeChatLiveTurnIndicator | null
/** Structured lane: host-recorded turn timing feeding the per-turn status rows. */
workingStartedAt?: number | null
settledTurns?: NativeChatSettledTurns | null
@@ -136,6 +141,7 @@ export function MobileNativeChatView({
agentWorking,
canStop = agentWorking,
structuredActivityUi = false,
turnIndicator = null,
workingStartedAt,
settledTurns,
onStop,
@@ -259,14 +265,17 @@ export function MobileNativeChatView({
[hasMore, loadingEarlier, onLoadEarlier]
)
// Per-turn "Thinking / Working for N / Worked for N" rows. The structured lane
// owns them; the bridge lane keeps its three-dot indicator.
// Per-turn status rows: one live indicator while the turn runs, then a settled
// "Worked for N" row. The structured lane owns them; the bridge lane keeps its
// three-dot indicator.
const turns = useMobileNativeChatTurnDisclosure({
messages: data,
enabled: structuredActivityUi,
isWorking: agentWorking === true,
workingStartedAt,
settledTurns,
thinking: turnIndicator?.thinking === true,
activityText: turnIndicator?.activityText ?? null,
scopeKey: sendSurfaceId
})
@@ -336,6 +345,7 @@ export function MobileNativeChatView({
startedAt={turns.active.startedAt}
thinking={turns.active.thinking}
workedSeconds={turns.active.workedSeconds}
activityText={turns.activeActivityText}
/>
) : null
}
@@ -6,7 +6,10 @@ import type {
} from '../../../src/shared/native-chat-ask'
import type { detectAgentPermission } from './mobile-native-chat-permission'
import type { parseAgentQuestion } from './mobile-native-chat-question'
import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status'
import type {
NativeChatLiveTurnIndicator,
NativeChatSettledTurns
} from '../../../src/shared/native-chat-turn-status'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts'
import type { useMobileNativeChatSession } from './use-mobile-native-chat-session'
@@ -29,6 +32,8 @@ export type MobileNativeChatController = {
/** Structured lane: drives the per-turn status row and live tool progress. */
nativeChatStructured: boolean
nativeChatAgentWorking: boolean
/** What labels the live turn's one indicator row; null off the structured lane. */
nativeChatTurnIndicator: NativeChatLiveTurnIndicator | null
/** Structured lane: host-recorded turn timing for the per-turn status rows. */
nativeChatWorkingStartedAt: number | null
nativeChatSettledTurns: NativeChatSettledTurns | null
@@ -0,0 +1,65 @@
import type { MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask'
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
import type { MobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
/** The bridge lane's four prompt/interrupt write seams. They share one enable
* gate and chain through the answer seam's `cancelPending`, so a caller cannot
* wire one of them to a different lane or forget to drop in-flight answer
* writes before an Escape. The structured lane answers over RPC instead. */
export function useMobileBridgeChatPromptWrites(args: {
client: RpcClient | null
enabled: boolean
handleRef: MutableRefObject<string | null>
deviceTokenRef: MutableRefObject<string | null>
agentRef: MutableRefObject<string | null>
/** Changes on chat session swap; cancels pending writes when it does. */
sessionId: string | null
streamIdentity: string
onSendError: (message: string) => void
}): {
answerAsk: MobileNativeChatAnswerSend['answerAsk']
cancelAsk: () => Promise<boolean>
respondPermission: (send: string) => Promise<boolean>
stop: () => void
} {
const { client, enabled, handleRef, deviceTokenRef, streamIdentity, onSendError } = args
const { answerAsk, cancelPending } = useMobileNativeChatAnswerSend({
client,
enabled,
handleRef,
deviceTokenRef,
agentRef: args.agentRef,
sessionId: args.sessionId,
streamIdentity,
onSendError
})
const cancelAsk = useMobileNativeChatCancelAsk({
client,
enabled,
handleRef,
deviceTokenRef,
cancelPending,
onSendError
})
const respondPermission = useMobileNativeChatPermissionSend({
client,
enabled,
handleRef,
deviceTokenRef,
onSendError
})
const stop = useMobileNativeChatStop({
client,
enabled,
handleRef,
deviceTokenRef,
streamIdentity,
cancelPending,
onSendError
})
return { answerAsk, cancelAsk, respondPermission, stop }
}
@@ -2,10 +2,7 @@ import { useLayoutEffect, useRef, type MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import type { MobileNativeChatTab } from './mobile-native-chat-eligibility'
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask'
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
@@ -14,10 +11,10 @@ import { useMobileNativeChatSessionOptionController } from './use-mobile-native-
import { useMobileNativeChatSessionLane } from './use-mobile-native-chat-session-lane'
import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge'
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
import { useThrottledLatestValue } from './use-throttled-latest-value'
import type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
import { useMobileBridgeChatPromptWrites } from './use-mobile-bridge-chat-prompt-writes'
import { useMobileNativeChatActiveResolution } from './use-mobile-native-chat-active-resolution'
export type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
@@ -171,42 +168,19 @@ export function useMobileNativeChatController(args: {
? client != null && activeChatSessionId != null && connState === 'connected'
: nativeChatInputLeaseReady && connState === 'connected'
const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } =
useMobileNativeChatAnswerSend({
client,
enabled: inputSendable && !activeChatStructured,
handleRef: activeHandleRef,
deviceTokenRef,
agentRef: activeChatAgentRef,
sessionId: activeChatSessionId,
streamIdentity,
onSendError
})
const handleNativeChatCancelAsk = useMobileNativeChatCancelAsk({
client,
enabled: inputSendable && !activeChatStructured,
handleRef: activeHandleRef,
deviceTokenRef,
cancelPending: cancelNativeChatAnswer,
onSendError
})
const legacyHandleNativeChatRespondPermission = useMobileNativeChatPermissionSend({
client,
enabled: inputSendable && !activeChatStructured,
handleRef: activeHandleRef,
deviceTokenRef,
onSendError
})
const handleNativeChatStop = useMobileNativeChatStop({
const {
answerAsk: handleNativeChatAnswerAsk,
cancelAsk: handleNativeChatCancelAsk,
respondPermission: legacyHandleNativeChatRespondPermission,
stop: handleNativeChatStop
} = useMobileBridgeChatPromptWrites({
client,
enabled: inputSendable && !activeChatStructured,
handleRef: activeHandleRef,
deviceTokenRef,
agentRef: activeChatAgentRef,
sessionId: activeChatSessionId,
streamIdentity,
cancelPending: cancelNativeChatAnswer,
onSendError
})
@@ -298,6 +272,7 @@ export function useMobileNativeChatController(args: {
/** Structured lane: drives the per-turn status row and live tool progress. */
nativeChatStructured: activeChatStructured,
nativeChatAgentWorking,
nativeChatTurnIndicator: activeChatStructured ? structuredNativeChat.turnIndicator : null,
nativeChatWorkingStartedAt: activeChatStructured ? structuredNativeChat.workingStartedAt : null,
nativeChatSettledTurns: activeChatStructured ? structuredNativeChat.settledTurns : null,
nativeChatCanStop: activeChatStructured
@@ -16,6 +16,8 @@ export type MobileNativeChatTurnRow = {
turnExpanded: boolean
/** Set only on a settled turn — the one row that has activity to disclose. */
turnKey?: string
/** Provider activity copy, on the live turn's row only; it labels that one row. */
turnActivityText?: string | null
activeTurnIsWorking: boolean
}
@@ -28,6 +30,8 @@ export function useMobileNativeChatTurnDisclosure({
isWorking,
workingStartedAt,
settledTurns,
thinking = false,
activityText = null,
scopeKey
}: {
messages: readonly NativeChatMessage[]
@@ -36,10 +40,16 @@ export function useMobileNativeChatTurnDisclosure({
workingStartedAt?: number | null
/** Host-recorded durations; they outrank whatever this client observed. */
settledTurns?: NativeChatSettledTurns | null
/** Whether the turn is reasoning right now, derived from its journal content. */
thinking?: boolean
/** What the provider says the live turn is doing; outranks the other labels. */
activityText?: string | null
/** Host/worktree/tab identity for timing and disclosure isolation. */
scopeKey: string
}): {
active: NativeChatTurnStatus | null
/** The live turn's provider activity copy, for the unanchored footer row. */
activeActivityText: string | null
/** True when the live turn has no user message to hang its status row under. */
activeTurnIsUnanchored: boolean
onToggleTurn: (turnKey: string) => void
@@ -51,6 +61,7 @@ export function useMobileNativeChatTurnDisclosure({
isWorking,
workingStartedAt,
settledTurns,
thinking,
scopeKey
})
const [expandedTurns, setExpandedTurns] = useState<{
@@ -93,13 +104,15 @@ export function useMobileNativeChatTurnDisclosure({
}, [enabled, messages])
const { active, activeTurnKey, completedByTurn } = turnStatuses
const activeActivityText = enabled && isWorking ? (activityText ?? null) : null
const resolveRow = useCallback(
(index: number, message: NativeChatMessage): MobileNativeChatTurnRow => {
const turnKey = turnKeys[index]
const isActiveTurn = turnKey === activeTurnKey
const turnStatus =
!enabled || message.role !== 'user'
? null
: turnKey === activeTurnKey
: isActiveTurn
? active
: turnKey
? (completedByTurn[turnKey] ?? null)
@@ -107,6 +120,7 @@ export function useMobileNativeChatTurnDisclosure({
return {
turnStatus,
turnExpanded: turnKey ? expandedTurnIds.has(turnKey) : false,
turnActivityText: isActiveTurn ? activeActivityText : null,
// Why: the key travels and the row calls one stable handler with it. A
// closure per row would be a new identity every render of a streaming
// transcript, defeating the row's memo; caching one per turn would mean
@@ -120,11 +134,21 @@ export function useMobileNativeChatTurnDisclosure({
(turnKey === undefined && activeTurnKey === MOBILE_UNANCHORED_TURN_KEY))
}
},
[turnKeys, enabled, activeTurnKey, active, completedByTurn, expandedTurnIds, isWorking]
[
turnKeys,
enabled,
activeTurnKey,
active,
activeActivityText,
completedByTurn,
expandedTurnIds,
isWorking
]
)
return {
active,
activeActivityText,
/** Stable for a given chat scope, so it never disturbs a row's memo. */
onToggleTurn: toggleExpandedTurn,
activeTurnIsUnanchored:
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
nativeChatTurnHasResponse,
reduceNativeChatTurnTiming,
selectNativeChatTurnStatuses,
type NativeChatSettledTurns,
@@ -27,6 +26,7 @@ export function useMobileNativeChatTurnStatus({
isWorking,
workingStartedAt,
settledTurns,
thinking = false,
scopeKey
}: {
messages: readonly NativeChatMessage[]
@@ -35,6 +35,8 @@ export function useMobileNativeChatTurnStatus({
workingStartedAt?: number | null
/** Host-recorded durations; they outrank whatever this client observed. */
settledTurns?: NativeChatSettledTurns | null
/** Whether the turn is reasoning right now, derived from its journal content. */
thinking?: boolean
/** Host/worktree/tab identity. Timings never carry across chat surfaces. */
scopeKey: string
}): {
@@ -45,7 +47,6 @@ export function useMobileNativeChatTurnStatus({
const latestUserIndex = enabled
? messages.findLastIndex((message) => message.role === 'user')
: -1
const hasCurrentTurnResponse = enabled && nativeChatTurnHasResponse(messages, latestUserIndex)
const latestUserId = latestUserIndex !== -1 ? (messages[latestUserIndex]?.id ?? null) : null
const activeTurnKey = latestUserId ?? MOBILE_UNANCHORED_TURN_KEY
const [scopedTiming, setScopedTiming] = useState<ScopedTurnTiming>(() => ({
@@ -95,6 +96,7 @@ export function useMobileNativeChatTurnStatus({
// turn re-renders ~20x/s. Without this, every settled turn's row gets fresh
// props each tick and the memoized message rows all re-render.
const turnIsWorking = enabled && isWorking
const turnIsThinking = enabled && thinking
const settledByTurn = enabled ? (settledTurns ?? undefined) : undefined
const statuses = useMemo(
() =>
@@ -102,17 +104,10 @@ export function useMobileNativeChatTurnStatus({
activeTurnKey,
isWorking: turnIsWorking,
workingStartedAt,
hasCurrentTurnResponse,
thinking: turnIsThinking,
settledByTurn
}),
[
timingByTurn,
activeTurnKey,
turnIsWorking,
workingStartedAt,
hasCurrentTurnResponse,
settledByTurn
]
[timingByTurn, activeTurnKey, turnIsWorking, workingStartedAt, turnIsThinking, settledByTurn]
)
return { ...statuses, activeTurnKey }
}
@@ -11,10 +11,12 @@ import {
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection'
import { hasUnansweredStructuredAgentSessionDispatch } from '../../../src/shared/structured-agent-session-projection'
import {
activeStructuredAgentSessionTurnId,
hasUnansweredStructuredAgentSessionDispatch
} from '../../../src/shared/structured-agent-session-projection'
isStructuredAgentSessionThinking
} from '../../../src/shared/structured-agent-session-live-turn'
import { selectStructuredAgentTurnActivity } from '../../../src/shared/native-chat-turn-activity'
import {
pendingStructuredApproval,
pendingStructuredQuestion,
@@ -31,6 +33,7 @@ import type { RpcClient } from '../transport/rpc-client'
import type { MobileChatPermission } from './mobile-native-chat-permission'
import type { MobileChatQuestion } from './mobile-native-chat-question'
import type { MobileNativeChatSession } from './use-mobile-native-chat-session'
import type { NativeChatLiveTurnIndicator } from '../../../src/shared/native-chat-turn-status'
import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state'
import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses'
import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options'
@@ -43,6 +46,8 @@ type StructuredMobileSession = ReturnType<typeof useMobileStructuredAgentOptions
session: MobileNativeChatSession
isWorking: boolean
turnId: string | null
/** What labels the live turn's one indicator row. */
turnIndicator: NativeChatLiveTurnIndicator
sendWithOutcome: (
text: string,
images?: string[],
@@ -270,6 +275,12 @@ export function useMobileStructuredAgentSession(args: {
)
const turnId = activeStructuredAgentSessionTurnId(state.items)
const turnTiming = useMobileStructuredAgentTurnTiming(state, turnId)
const activityText =
selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null
const thinking = isStructuredAgentSessionThinking(state.items)
// Stable while the readings hold, so a streaming turn does not re-render the
// whole chat surface on every journal batch.
const turnIndicator = useMemo(() => ({ thinking, activityText }), [thinking, activityText])
const status = state.status === 'idle' ? 'idle' : state.status
const approvalPrompt = useMemo(
() => state.items.find(pendingStructuredApproval) ?? null,
@@ -296,6 +307,7 @@ export function useMobileStructuredAgentSession(args: {
turnId !== null ||
hasUnansweredStructuredAgentSessionDispatch(state.submissions, state.fence),
turnId,
turnIndicator,
...turnTiming,
sendWithOutcome,
cancel,
@@ -0,0 +1,141 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
AGENT_JOURNAL_THINKING_PRESENTATION,
type AgentJournalRenderItem
} from '../../../src/shared/agent-session-journal-types'
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
import type { RpcClient } from '../transport/rpc-client'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
function journalItem(
sequence: number,
body: AgentJournalRenderItem['body']
): AgentJournalRenderItem {
return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body }
}
function snapshot(items: AgentJournalRenderItem[], fence: number): AgentSessionSubscribeEvent {
const newest = items.length
return {
type: 'snapshot',
sessionId: 'session-1',
fence,
page: {
sessionId: 'session-1',
epoch: 'epoch-1',
fence,
direction: 'tail',
items,
removedItemIds: [],
submissions: [],
window: {
oldest: { epoch: 'epoch-1', sequence: 1 },
newest: { epoch: 'epoch-1', sequence: newest },
nextCursor: { epoch: 'epoch-1', sequence: newest + 1 }
},
liveCursor: { epoch: 'epoch-1', sequence: newest },
hasOlder: false,
hasNewer: false
}
} as AgentSessionSubscribeEvent
}
/** What the one live indicator row reads, resolved off the session journal. */
describe('useMobileStructuredAgentSession turn indicator', () => {
let renderer: ReactTestRenderer | null = null
let hook: ReturnType<typeof useMobileStructuredAgentSession> | null = null
let listener: ((value: unknown) => void) | null = null
const sendRequest = vi.fn(async (method: string) => ({
ok: true,
result:
method === 'agentSession.options'
? {
models: [{ id: 'gpt-fast', label: 'GPT Fast', isDefault: true, efforts: [] }],
current: { model: 'gpt-fast' }
}
: {},
_meta: { runtimeId: 'r1' }
}))
const subscribe = vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => {
listener = onData
return vi.fn()
})
const client = { sendRequest, subscribe } as unknown as RpcClient
// Stable across renders: a fresh callback would re-run the hold/subscribe effect
// and release the session out from under the test.
const onSendError = vi.fn()
function Harness(): null {
hook = useMobileStructuredAgentSession({
client,
sessionId: 'session-1',
sourceIdentity: 'host-a\0workspace-a',
enabled: true,
connected: true,
agent: 'codex',
onSendError
} as never)
return null
}
beforeEach(() => {
vi.clearAllMocks()
listener = null
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
hook = null
})
const runningTurn = journalItem(1, { kind: 'turn', turnId: 'turn-1', state: 'running' })
const reasoning = journalItem(2, {
kind: 'status',
text: 'Weighing two approaches',
presentation: AGENT_JOURNAL_THINKING_PRESENTATION
})
it('reads the live turn as reasoning while reasoning is its newest content', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).not.toBeNull())
act(() => {
listener?.(snapshot([runningTurn, reasoning], 3))
})
expect(hook?.turnIndicator).toEqual({ thinking: true, activityText: null })
})
it('hands the row the provider copy once real content ends the reasoning', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).not.toBeNull())
act(() => {
listener?.(
snapshot(
[
runningTurn,
reasoning,
journalItem(3, {
kind: 'tool-call',
name: 'shell',
input: { command: 'pnpm lint' },
state: 'running'
}),
journalItem(4, { kind: 'status', text: 'Updating the plan' })
],
3
)
)
})
expect(hook?.turnIndicator).toEqual({ thinking: false, activityText: 'Updating the plan' })
})
})