Files
orca/mobile/src/session/MobileNativeChatView.tsx
T
Brennan BensonandMerge Sim fab78c7669 fix(native-chat): show one live-turn indicator, and make Thinking mean reasoning (#19977)
* native-chat: render one indicator row for the live desktop turn

The turn-timing row and the spinner+activity line were two rows saying
"Working" at once. A settled turn keeps its own row; the live turn now has
only the spinner row, labelled provider activity -> Thinking -> Working for N
through the shared resolver. Reasoning is the turn's content, so it no longer
becomes the activity label, and "Thinking" now means the turn is reasoning
right now rather than that it has produced no output yet.

* 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.

* codex: mark streamed reasoning as reasoning too, and pin the provider markers

The settled reasoning item carried the marker but the streaming one did not,
so a live Codex turn - the only time the indicator is on screen - never read
as reasoning. Both paths now stamp it; a plan document keeps its own
presentation and must never read as reasoning.

* fix(native-chat): tighten live turn reasoning state

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-11 00:19:57 -07:00

457 lines
18 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
FlatList,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable,
Text,
View
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
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 {
NativeChatLiveTurnIndicator,
NativeChatSettledTurns
} from '../../../src/shared/native-chat-turn-status'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-view-styles'
import {
buildMobileNativeChatTransientData,
mobileNativeChatEmptyState,
type MobileNativeChatPendingItem
} from './mobile-native-chat-render-data'
import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture'
import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure'
import { useSettledMobileNativeChatInputLock } from './use-mobile-native-chat-input-lease'
import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus'
import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator'
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
import { MobileNativeChatPromptCard } from './MobileNativeChatPromptCard'
import type { MobileChatPermission } from './mobile-native-chat-permission'
import type { MobileChatQuestion } from './mobile-native-chat-question'
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
/** Why the composer input is locked: the transport is disconnected, or the
* terminal subscription has not acknowledged its input lease yet. */
export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting'
type Props = {
/** Raw transcript, only for telling "still loading" from "loaded and empty". */
messages: NativeChatMessage[]
/** `messages` with noise stripped and tool turns folded in, from the overlay. */
folded: NativeChatMessage[]
status: MobileNativeChatStatus
error?: string
/** Resolved agent for this chat; names the empty-state copy (desktop parity). */
agent?: string | null
agentWorking?: boolean
canStop?: boolean
/** 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
/** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */
/** Interrupt a provider turn. */
onStop?: () => void
/** Live partial assistant text to show as an in-progress bubble, already gated
* by the overlay against the transcript catching up. */
streaming: string | null
hasMore?: boolean
loadingEarlier?: boolean
onLoadEarlier?: () => void
onSend: (text: string) => Promise<boolean>
/** Route identity used to fence accepted sends that settle after a tab/view switch. */
sendSurfaceId: string
/** Reads the retained route's focus generation for accepted-send fencing. */
getSendCompletionGeneration: () => number
/** Reads user draft mutations from the route-owned controller. */
getComposerEditGeneration: () => number
/** Accepted user echoes awaiting transcript replacement, including image previews. */
pending: MobileNativeChatPendingItem[]
/** Local photo URIs retained when the authoritative transcript replaces an
* optimistic image bubble. */
imagePreviewsByMessageId?: Record<string, string[]>
/** Controlled composer text (owned by the route so dictation can write to it). */
composerText: string
onComposerTextChange: (text: string) => void
onAttachImage?: () => void
/** Pending image attachments shown as composer thumbnails until the next send. */
attachments?: PendingNativeChatImage[]
onRemoveAttachment?: (id: string) => void
isAttaching?: boolean
onMicPress?: () => void
micActive?: boolean
dictationMode?: 'toggle' | 'hold'
onMicPressIn?: () => void
onMicPressOut?: () => void
inputLockReason?: MobileNativeChatInputLockReason | null
/** Route-reported send failure (answer cards, permission replies, stop). Shares the
* inline banner with a rejected composer send, so one failure paints once. The
* route routes these here only while this view is mounted, and falls back to its
* toast otherwise — a deferred failure must not land on an unmounted banner. */
sendErrorMessage?: string | null
/** Clears `sendErrorMessage` once a later send is accepted. */
onClearSendError?: () => void
filePaths?: string[]
onNeedFiles?: (query: string) => void
/** Model/session-option pickers for the composer action row (desktop parity). */
sessionOptions?: MobileNativeChatSessionOptionPickersProps | null
/** A pending agent question/permission detected from live status, shown as a
* native card above the composer; answering sends text to the agent. */
/** Structured AskUserQuestion prompt parsed from the transcript (preferred over
* the heuristic question card). */
ask?: AskPrompt | null
/** Stable key for the ask card. Dismissal state lives in the controller (it
* must survive this subtree unmounting on a chat↔terminal toggle). */
askKey?: string | null
/** Hide the answered/dismissed ask until a different question arrives. */
onDismissAsk?: () => void
/** Deliver the ask answer as per-question selections; the send hook turns them
* into selector keystrokes (Claude) or pasted label text (other agents). */
onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise<boolean>
onCancelAsk?: () => Promise<boolean>
question?: MobileChatQuestion | null
onAnswerQuestion?: (text: string) => Promise<boolean>
permission?: MobileChatPermission | null
onRespondPermission?: (send: string) => Promise<boolean>
/** Open a worktree file tapped in agent markdown. */
onOpenFile?: (relativePath: string) => void
/** Pixels to lift the composer by when the soft keyboard is open. The route
* owns keyboard tracking (the app uses manual lift, not KeyboardAvoidingView). */
keyboardInset?: number
}
export function MobileNativeChatView({
messages,
folded,
status,
error,
agent,
agentWorking,
canStop = agentWorking,
structuredActivityUi = false,
turnIndicator = null,
workingStartedAt,
settledTurns,
onStop,
streaming,
hasMore,
loadingEarlier,
onLoadEarlier,
onSend,
sendSurfaceId,
getSendCompletionGeneration,
getComposerEditGeneration,
pending,
imagePreviewsByMessageId,
composerText,
onComposerTextChange,
onAttachImage,
attachments,
onRemoveAttachment,
isAttaching,
onMicPress,
micActive,
dictationMode,
onMicPressIn,
onMicPressOut,
inputLockReason,
sendErrorMessage,
onClearSendError,
filePaths,
onNeedFiles,
sessionOptions,
ask,
askKey,
onDismissAsk,
onAnswerAsk,
onCancelAsk,
question,
onAnswerQuestion,
permission,
onRespondPermission,
onOpenFile,
keyboardInset = 0
}: Props): React.JSX.Element {
const insets = useSafeAreaInsets()
const listRef = useRef<FlatList<NativeChatMessage>>(null)
const [toolsExpanded, setToolsExpanded] = useState(false)
// Lift the composer clear of the keyboard, plus the bottom safe-area so it
// never sits under the home indicator / nav bar (mirrors the terminal dock).
const bottomPad = keyboardInset > 0 ? keyboardInset + insets.bottom : insets.bottom
const [atBottom, setAtBottom] = useState(true)
const sendScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture()
useEffect(
() => () => {
if (sendScrollTimerRef.current) {
clearTimeout(sendScrollTimerRef.current)
}
},
[]
)
// `data` is the list source: folded transcript + synthetic streaming bubble +
// route-owned accepted echoes. Memoize on the same deps so the
// downstream autoscroll effects/`renderItem` keep referential stability.
const { data } = useMemo(
() =>
buildMobileNativeChatTransientData({
messages,
folded,
streaming,
pending,
imagePreviewsByMessageId
}),
[messages, folded, streaming, pending, imagePreviewsByMessageId]
)
// Follow the tail as the conversation grows and keep the newest message above
// the keyboard when it opens — but only when already pinned to the bottom, so
// we don't yank the user away while they read history. (Also fires on keyboard
// close, which is harmless while atBottom.)
useEffect(() => {
if (data.length === 0 || !atBottom) {
return
}
const t = setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 60)
return () => clearTimeout(t)
}, [data.length, atBottom, keyboardInset])
const handleSend = useCallback(
async (text: string): Promise<boolean> => {
const accepted = await onSend(text)
if (!accepted) {
return false
}
// The route-owned banner outlives this send; a success must retire it too,
// or a stale "Message not sent" sits above the delivered message.
onClearSendError?.()
// Always jump to the newest message when the user sends.
setAtBottom(true)
if (sendScrollTimerRef.current) {
clearTimeout(sendScrollTimerRef.current)
}
sendScrollTimerRef.current = setTimeout(() => {
sendScrollTimerRef.current = null
listRef.current?.scrollToEnd({ animated: true })
}, 60)
return true
},
[onSend, onClearSendError]
)
const onScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent
const distanceFromBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height)
setAtBottom(distanceFromBottom < 80)
// Near the top — page in older history.
if (contentOffset.y < 60 && hasMore && !loadingEarlier) {
onLoadEarlier?.()
}
},
[hasMore, loadingEarlier, onLoadEarlier]
)
// 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
})
const renderItem = useCallback(
({ item, index }: { item: NativeChatMessage; index: number }) => (
<MobileNativeChatMessage
message={item}
toolsExpanded={toolsExpanded}
fontScale={fontScale}
onOpenFile={onOpenFile}
structuredActivityUi={structuredActivityUi}
onToggleTurn={turns.onToggleTurn}
{...turns.resolveRow(index, item)}
/>
),
[toolsExpanded, fontScale, onOpenFile, structuredActivityUi, turns]
)
const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error)
const showLoading = status === 'loading' && messages.length === 0
const lockReason = useSettledMobileNativeChatInputLock(inputLockReason)
return (
<View style={[styles.root, { paddingBottom: bottomPad }]}>
{showLoading ? (
<View style={styles.center}>
<ActivityIndicator color={colors.textSecondary} />
</View>
) : (
<GestureHandlerRootView style={styles.listWrap}>
<GestureDetector gesture={pinchGesture}>
<FlatList
ref={listRef}
data={data}
keyExtractor={(item) => item.id}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
// Let link/file taps land while the composer keyboard is up
// instead of being swallowed by the dismiss gesture.
keyboardShouldPersistTaps="handled"
onScroll={onScroll}
scrollEventThrottle={32}
onContentSizeChange={() => {
if (data.length > 0 && atBottom) {
listRef.current?.scrollToEnd({ animated: false })
}
}}
ListHeaderComponent={
hasMore ? (
<Pressable
style={styles.loadEarlier}
onPress={onLoadEarlier}
disabled={loadingEarlier}
>
{loadingEarlier ? (
<ActivityIndicator size="small" color={colors.textMuted} />
) : (
<Text style={styles.loadEarlierText}>Load earlier messages</Text>
)}
</Pressable>
) : null
}
ListFooterComponent={
structuredActivityUi && agentWorking && turns.active ? (
<MobileNativeChatTurnStatus
startedAt={turns.active.startedAt}
thinking={turns.active.thinking}
workedSeconds={turns.active.workedSeconds}
activityText={turns.activeActivityText}
/>
) : null
}
ListEmptyComponent={
emptyState ? (
<View style={styles.center}>
<Text style={styles.emptyTitle}>{emptyState.title}</Text>
<Text style={styles.emptySubtitle}>{emptyState.subtitle}</Text>
</View>
) : null
}
/>
</GestureDetector>
{/* Jump-to-latest control. */}
{!atBottom ? (
<Pressable
accessibilityLabel="Scroll to latest"
style={[styles.fab, styles.fabBottom]}
onPress={() => listRef.current?.scrollToEnd({ animated: true })}
>
<ArrowDown size={18} color={colors.textPrimary} strokeWidth={2.2} />
</Pressable>
) : null}
</GestureHandlerRootView>
)}
<MobileNativeChatPromptCard
ask={ask}
askKey={askKey}
onDismissAsk={onDismissAsk}
onAnswerAsk={onAnswerAsk}
onCancelAsk={onCancelAsk}
permission={permission}
onRespondPermission={onRespondPermission}
question={question}
onAnswerQuestion={onAnswerQuestion}
/>
<View style={styles.chromeRow}>
<View style={styles.chromeLeft}>
{agentWorking && !structuredActivityUi ? <MobileAgentWorkingIndicator /> : null}
<Pressable
style={({ pressed }) => [styles.chromeToggle, pressed && styles.pressed]}
onPress={() => setToolsExpanded((v) => !v)}
hitSlop={8}
>
{toolsExpanded ? (
<ChevronsDownUp size={14} color={colors.textMuted} strokeWidth={2} />
) : (
<ChevronsUpDown size={14} color={colors.textMuted} strokeWidth={2} />
)}
<Text style={styles.chromeToggleLabel}>{toolsExpanded ? 'Collapse' : 'Tools'}</Text>
</Pressable>
</View>
{canStop ? (
<Pressable
style={({ pressed }) => [styles.stopButton, pressed && styles.pressed]}
onPress={onStop}
hitSlop={8}
accessibilityLabel="Stop the agent"
>
<Square size={13} color={colors.statusRed} strokeWidth={2.4} fill={colors.statusRed} />
<Text style={styles.stopLabel}>Stop</Text>
</Pressable>
) : null}
</View>
{sendErrorMessage ? (
// This banner is the only channel for a send failure — announce it.
<View
style={styles.sendError}
accessibilityRole="alert"
accessibilityLiveRegion="assertive"
>
<Text style={styles.sendErrorText}>{sendErrorMessage}</Text>
</View>
) : null}
<MobileNativeChatComposer
structuredCommands={
structuredActivityUi ? (sessionOptions?.controller.conversationCommands ?? []) : undefined
}
value={composerText}
onChangeText={onComposerTextChange}
onSend={handleSend}
sendSurfaceId={sendSurfaceId}
{...{ getSendCompletionGeneration, getComposerEditGeneration }}
agent={agent}
sessionOptions={sessionOptions}
onAttachImage={onAttachImage}
attachments={attachments}
onRemoveAttachment={onRemoveAttachment}
isAttaching={isAttaching}
onMicPress={onMicPress}
micActive={micActive}
dictationMode={dictationMode}
onMicPressIn={onMicPressIn}
onMicPressOut={onMicPressOut}
disabled={lockReason !== null}
placeholder={
lockReason === 'disconnected'
? 'Reconnecting…'
: lockReason === 'waiting'
? 'Waiting for terminal…'
: 'Message, @files, /commands'
}
filePaths={filePaths}
onNeedFiles={onNeedFiles}
/>
</View>
)
}