Files
orca/mobile/src/session/MobileNativeChatView.tsx
T
Neilandmonsterlag 80b9dca13e fix(mobile): keep terminal wait feedback visible with a draft (#31658)
Keep terminal-wait feedback visible while a draft is present, preserving editing and existing send gating.

Adapted from https://github.com/stablyai/orca/pull/31528.

Co-authored-by: monsterlag <64719697+monsterlag@users.noreply.github.com>
2026-10-21 15:37:59 -07:00

458 lines
19 KiB
TypeScript

import { useCallback, useMemo } from 'react'
import { nativeChatTaskListPredecessors } from '../../../src/shared/native-chat-task-list-history'
import {
MobileNativeChatDisclosureContext,
useMobileNativeChatDisclosures
} from './mobile-native-chat-disclosure-store'
import {
ActivityIndicator,
type NativeScrollEvent,
type NativeSyntheticEvent,
Text,
View
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
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 type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership'
import type { NativeChatResolvedPrompt } from '../../../src/shared/native-chat-resolution-receipt'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-view-styles'
import { mobileNativeChatListFooter } from './mobile-native-chat-list-footer'
import { mobileNativeChatListHeader } from './mobile-native-chat-list-header'
import {
buildMobileNativeChatTransientData,
mobileNativeChatEmptyState,
type MobileNativeChatPendingItem
} from './mobile-native-chat-render-data'
import { useMobileNativeChatRowKeys } from './mobile-native-chat-row-keys'
import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture'
import { useMobileNativeChatTailFollow } from './use-mobile-native-chat-tail-follow'
import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure'
import {
mobileNativeChatComposerPlaceholder,
useSettledMobileNativeChatInputLock
} from './use-mobile-native-chat-input-lease'
import { MobileNativeChatLiveLine } from './MobileNativeChatLiveLine'
import { MobileNativeChatFloatingControls } from './MobileNativeChatFloatingControls'
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
import {
MobileNativeChatComposer,
type MobileNativeChatStructuredComposer
} from './MobileNativeChatComposer'
import { MobileNativeChatPromptCard } from './MobileNativeChatPromptCard'
import { NO_COMPOSER_TRAY, type ComposerTrayProps } from './use-mobile-native-chat-composer-tray'
import type { MobileChatPermission } from './mobile-native-chat-permission'
import type { MobileChatQuestion } from './mobile-native-chat-question'
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
import { MobileNativeChatScrollHold } from './MobileNativeChatScrollHold'
import { MobileNativeChatRowList } from './MobileNativeChatRowList'
import { useMobileNativeChatRowRenderer } from './use-mobile-native-chat-row-renderer'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
import type { OlderPagePhase } from './use-mobile-older-page-loader'
/** 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 = ComposerTrayProps & {
/** 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
/** The read failed for good (damage, a newer Orca's chat): its error takes the whole pane. */
readFailedFinally?: boolean
/** 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
/** Structured lane: the journal that places each row in its turn. */
turnJournal?: NativeChatTurnJournal | null
receipts?: ReadonlyMap<string, NativeChatResolvedPrompt> | null
actionablePromptIds?: readonly string[]
/** Interrupt a provider turn (Stop takes the composer's Send slot). */
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
olderPages?: OlderPagePhase
/** `settled`: on the page, an older page waits for it so it never lands under a moving reader. */
onLoadEarlier?: (settled?: () => Promise<void>) => 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?: string
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 structured chat's + sheet, files and skills; absent on a terminal-backed chat. */
structuredComposer?: MobileNativeChatStructuredComposer | 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>
askRequiresTerminal?: boolean
onAnswerInTerminal?: () => void
onCancelAsk?: () => Promise<boolean>
/** Cancel a structured approval/question with exact item identity when supported. */
onCancelPrompt?: (prompt?: { itemId: string; expectedRevision: number }) => Promise<boolean>
onCollapseAsk?: () => void
onCollapsePrompt?: () => void
collapsedPrompt?: { title: string; expand: () => void } | null
question?: MobileChatQuestion | null
onAnswerQuestion?: (text: string) => Promise<boolean>
permission?: MobileChatPermission | null
promptKey?: string | 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,
readFailedFinally = false,
agent,
agentWorking,
canStop = agentWorking,
structuredActivityUi = false,
turnIndicator = null,
workingStartedAt,
settledTurns,
turnJournal = null,
receipts = null,
actionablePromptIds,
onStop,
streaming,
hasMore,
olderPages = 'idle',
onLoadEarlier,
onSend,
sendSurfaceId,
getSendCompletionGeneration,
getComposerEditGeneration,
pending,
imagePreviewsByMessageId,
composerText,
onComposerTextChange,
onAttachImage,
attachments,
onRemoveAttachment,
isAttaching,
onMicPress,
micActive,
dictationMode,
onMicPressIn,
onMicPressOut,
inputLockReason,
sendErrorMessage,
onClearSendError,
filePaths,
onNeedFiles,
sessionOptions,
structuredComposer = null,
ask,
askKey,
onDismissAsk,
onAnswerAsk,
askRequiresTerminal = false,
onAnswerInTerminal,
onCancelAsk,
onCancelPrompt,
onCollapseAsk,
onCollapsePrompt,
collapsedPrompt,
question,
onAnswerQuestion,
permission,
promptKey,
onRespondPermission,
composerTray: { content: trayContent, composerInputRef: inputRef } = NO_COMPOSER_TRAY,
onOpenFile,
keyboardInset = 0
}: Props): React.JSX.Element {
const insets = useSafeAreaInsets()
// 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 { fontScale, pinchGesture } = useMobileNativeChatPinchGesture()
// `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]
)
const tail = useMobileNativeChatTailFollow({ hasItems: data.length > 0, scopeKey: sendSurfaceId })
const { jumpToTail, recordScrollMetrics, scrollMayLoadEarlier, pageSettled } = tail
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.
jumpToTail()
return true
},
[onSend, onClearSendError, jumpToTail]
)
const loadEarlier = useCallback(() => onLoadEarlier?.(pageSettled), [onLoadEarlier, pageSettled])
const onScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset } = e.nativeEvent
recordScrollMetrics(e.nativeEvent)
// Near the top — page in older history. Only the reader's drag: a pin reports offsets too.
// After a failure only a tap retries, so a reader still at the top can't loop the spinner.
if (contentOffset.y < 60 && hasMore && olderPages === 'idle' && scrollMayLoadEarlier()) {
loadEarlier()
}
},
[hasMore, olderPages, loadEarlier, scrollMayLoadEarlier, recordScrollMetrics]
)
// 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 gets the live line only.
const turns = useMobileNativeChatTurnDisclosure({
messages: data,
enabled: structuredActivityUi,
isWorking: agentWorking === true,
workingStartedAt,
settledTurns,
turnJournal,
thinking: turnIndicator?.thinking === true,
activityText: turnIndicator?.activityText ?? null,
// The display status, decided once in the session hook.
stopping: turnIndicator?.stopping === true,
lineYields: ask != null || permission != null || question != null,
receipts,
scopeKey: sendSurfaceId
})
// What the reader opened in this chat's runs, kept above the rows the list recycles or windows out.
const rowKeys = useMobileNativeChatRowKeys(folded, messages, sendSurfaceId, turns.presentation)
const disclosures = useMobileNativeChatDisclosures(sendSurfaceId)
const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(data), [data])
const renderItem = useMobileNativeChatRowRenderer({
rowKeyOf: rowKeys.keyOf,
runOwners: rowKeys.runOwners,
taskListPredecessors,
fontScale,
onOpenFile,
structuredActivityUi,
turns,
turnJournal,
agent,
receipts,
actionablePromptIds
})
const liveStatus = turns.liveLine ? (
<MobileNativeChatLiveLine
line={turns.liveLine}
onToggleReasoning={turns.onToggleReasoning}
fontScale={fontScale}
onOpenFile={onOpenFile}
/>
) : null
const header = mobileNativeChatListHeader(hasMore, olderPages, loadEarlier)
// How rows draw: a change re-lays every row, which the place-hold and the window both follow.
const layoutKey = `${fontScale}`
const pageHeader = (measuring: boolean) =>
mobileNativeChatListHeader(hasMore, olderPages, loadEarlier, measuring)
const footer = mobileNativeChatListFooter(liveStatus, turns.waitingRows, renderItem)
const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error)
const lockReason = useSettledMobileNativeChatInputLock(inputLockReason)
const composerPlaceholder = mobileNativeChatComposerPlaceholder(
lockReason,
turnIndicator?.afterStop
)
// Why only Send, terminal-backed only: that send types into the agent's prompt and can answer it,
// while drafting never does; the host queues a structured send behind it.
const expandedPromptOwnsSend =
!structuredActivityUi && !collapsedPrompt && (ask ?? permission ?? question) != null
const emptyStateView = emptyState ? (
<View style={styles.center}>
<Text style={styles.emptyTitle}>{emptyState.title}</Text>
<Text style={styles.emptySubtitle}>{emptyState.subtitle}</Text>
</View>
) : null
// Whatever was already on screen: nothing here can act on a chat that cannot load, and its words
// say why once, as a fresh open's do.
if (readFailedFinally && emptyStateView) {
return <View style={[styles.root, { paddingBottom: bottomPad }]}>{emptyStateView}</View>
}
return (
<View style={[styles.root, { paddingBottom: bottomPad }]}>
{status === 'loading' && messages.length === 0 ? (
<View style={styles.center}>
<ActivityIndicator color={colors.textSecondary} />
</View>
) : (
<GestureHandlerRootView style={styles.listWrap}>
<MobileNativeChatDisclosureContext.Provider value={disclosures}>
<MobileNativeChatScrollHold
{...{ rowKeys, layoutKey, ...tail.placeHold }}
roots={disclosures.roots}
runPlacements={rowKeys.runPlacements}
>
<GestureDetector gesture={pinchGesture}>
<MobileNativeChatRowList
{...{ tail, rowKeys, renderItem, footer, emptyStateView, onScroll, layoutKey }}
rows={turns.listMessages}
roots={disclosures.roots}
rowState={turns.resolveRow}
contentContainerStyle={styles.listContent}
header={pageHeader}
nativeHeader={header}
hasHeader={hasMore === true}
/>
</GestureDetector>
</MobileNativeChatScrollHold>
</MobileNativeChatDisclosureContext.Provider>
<MobileNativeChatFloatingControls
showJumpToTail={tail.showJumpToTail}
onJumpToTail={jumpToTail}
/>
</GestureHandlerRootView>
)}
<MobileNativeChatPromptCard
key={promptKey ?? undefined}
{...{ ask, askKey, onDismissAsk, onAnswerAsk, onCancelAsk, onCancelPrompt, onCollapseAsk }}
requiresTerminal={askRequiresTerminal}
onAnswerInTerminal={onAnswerInTerminal}
{...{ permission, onRespondPermission, question, onAnswerQuestion, onCollapsePrompt }}
collapsedPrompt={collapsedPrompt}
/>
<Text
style={lockReason === 'waiting' ? styles.inputLockStatus : undefined}
accessibilityLiveRegion="polite"
>
{lockReason === 'waiting' ? composerPlaceholder : null}
</Text>
{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}
{/* Directly on the composer, which it tucks into. */}
{trayContent}
<MobileNativeChatComposer
structured={structuredActivityUi ? structuredComposer : null}
value={composerText}
onChangeText={onComposerTextChange}
onSend={handleSend}
sendSurfaceId={sendSurfaceId}
{...{ getSendCompletionGeneration, getComposerEditGeneration, inputRef }}
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}
sendDisabled={expandedPromptOwnsSend}
placeholder={composerPlaceholder}
filePaths={filePaths}
onNeedFiles={onNeedFiles}
stop={
canStop
? {
onStop,
held: agentWorking === true && turnIndicator?.stopRequestInFlight === true
}
: null
}
/>
</View>
)
}