diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index e09d1631a8d..f910458c3d4 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -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 { diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 5d013688249..935751e717d 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -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} /> diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 997083cb26e..389beb8eaad 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -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} diff --git a/mobile/src/session/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/MobileNativeChatTurnStatus.test.ts index 78ac01e0d37..6b6a1d49e07 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.test.ts +++ b/mobile/src/session/MobileNativeChatTurnStatus.test.ts @@ -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) => + 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', () => { diff --git a/mobile/src/session/MobileNativeChatTurnStatus.tsx b/mobile/src/session/MobileNativeChatTurnStatus.tsx index 4ce73cdcd38..acf922265f6 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.tsx +++ b/mobile/src/session/MobileNativeChatTurnStatus.tsx @@ -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 ( [...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 ( - {label} + {settled ? null : } + + {label} + ) } @@ -109,7 +103,8 @@ const styles = StyleSheet.create({ }, label: { color: colors.textMuted, - fontSize: typography.bodySize + fontSize: typography.bodySize, + flexShrink: 1 }, caretOpen: { transform: [{ rotate: '90deg' }] diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 63bfb715445..212d87e1e94 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -73,6 +73,7 @@ type Overrides = { onSend?: (text: string) => Promise pending?: Parameters[0]['pending'] structuredActivityUi?: boolean + turnIndicator?: Parameters[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 }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 440e05e6b9d..1bbc4c05f45 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -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 } diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index da1b91acb25..36c07215e8e 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -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 diff --git a/mobile/src/session/use-mobile-bridge-chat-prompt-writes.ts b/mobile/src/session/use-mobile-bridge-chat-prompt-writes.ts new file mode 100644 index 00000000000..2dddad48e16 --- /dev/null +++ b/mobile/src/session/use-mobile-bridge-chat-prompt-writes.ts @@ -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 + deviceTokenRef: MutableRefObject + agentRef: MutableRefObject + /** 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 + respondPermission: (send: string) => Promise + 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 } +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index d3d68b85032..5cd7e40e4b1 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -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 diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts index 5620c1390b2..c3a8a6644e1 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -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: diff --git a/mobile/src/session/use-mobile-native-chat-turn-status.ts b/mobile/src/session/use-mobile-native-chat-turn-status.ts index 5686acde515..f7cbb2dd887 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-status.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-status.ts @@ -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(() => ({ @@ -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 } } diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 5c458bbdd9f..938e881aec7 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -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 ({ 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, diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx new file mode 100644 index 00000000000..43466043406 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -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 | 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' }) + }) +})