mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* 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>
457 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|