mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
* fix(mobile): dismiss the keyboard after sending to an agent Sending a message left the software keyboard up, covering the reply the user was waiting on. Drop it once the send is accepted, on all three send paths: the terminal live input, the buffered command input, and the chat composer. Gated on the tab being an agent session. A plain shell keeps the keyboard so back-to-back commands stay typeable, a rejected send keeps it so the handed-back draft stays editable, and the accessory shortcut row is untouched because dismissing would pull away the row being tapped. * fix(mobile): gate keyboard dismissal on accepted sends * fix(mobile): fence keyboard dismissal completions * fix(mobile): fence stale send completions * test(mobile): update terminal guard expectations * fix(mobile): restore rejected buffered drafts by origin * fix(mobile): preserve intentional buffered draft clears * fix(mobile): harden send dismissal authority * test(mobile): preserve Strict Mode send dismissal * fix(mobile): preserve drafts across terminal remints * fix(mobile): preserve draft ownership through terminal races * fix(mobile): harden draft recovery and send freshness * fix(mobile): fence route reuse and native draft clears * fix(mobile): preserve native draft edits before clear * test(mobile): pin the terminal-list sweep that bounds buffered drafts `bufferedTerminalDraftState.pruneDrafts(retainedHandles)` is the only bound on two structures that live as long as the session screen — the buffered-draft record and the pending-restoration map — and nothing failed when it was deleted or when it was pointed at the raw `terminal.list` handles instead of the retained set. Both mutations reddened 0 of 3,949 mobile tests. Adds the wiring pin (both mutations now redden it) plus two behavioural tests showing why the argument matters: `terminal.list` omits a chat-covered handle while the desktop graph reloads, so the raw list drops a draft the user is still holding while the retained set keeps it. --------- Co-authored-by: Merge Sim <merge@sim.local> Co-authored-by: Merge Sim <sim@local>
470 lines
18 KiB
TypeScript
470 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 { 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 { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator'
|
|
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
|
|
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
|
|
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
|
|
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
|
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
|
|
import { MobileNativeChatPermission } from './MobileNativeChatPermission'
|
|
import type { MobileChatPermission } from './mobile-native-chat-permission'
|
|
import { MobileNativeChatQuestion } from './MobileNativeChatQuestion'
|
|
import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question'
|
|
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
|
|
|
|
const INPUT_LOCK_SETTLE_MS = 600
|
|
|
|
/** 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
|
|
/** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */
|
|
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,
|
|
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]
|
|
)
|
|
|
|
// Align a single message's top to the top of the viewport.
|
|
const onScrollToMessage = useCallback((index: number) => {
|
|
listRef.current?.scrollToIndex({ index, viewPosition: 0, animated: true })
|
|
}, [])
|
|
|
|
const renderItem = useCallback(
|
|
({ item, index }: { item: NativeChatMessage; index: number }) => (
|
|
<MobileNativeChatMessage
|
|
message={item}
|
|
toolsExpanded={toolsExpanded}
|
|
fontScale={fontScale}
|
|
messageIndex={index}
|
|
onScrollToMessage={onScrollToMessage}
|
|
onOpenFile={onOpenFile}
|
|
/>
|
|
),
|
|
[toolsExpanded, fontScale, onScrollToMessage, onOpenFile]
|
|
)
|
|
|
|
const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error)
|
|
const showLoading = status === 'loading' && messages.length === 0
|
|
|
|
// A dead PTY emits subscribed→end; settle both edges so its false lease cannot flash the composer enabled.
|
|
const rawLockReason = inputLockReason ?? null
|
|
const rawLockHeld = rawLockReason !== null
|
|
const [lockHeld, setLockHeld] = useState(false)
|
|
useEffect(() => {
|
|
if (rawLockHeld === lockHeld) {
|
|
return
|
|
}
|
|
const timer = setTimeout(() => setLockHeld(rawLockHeld), INPUT_LOCK_SETTLE_MS)
|
|
return () => clearTimeout(timer)
|
|
}, [lockHeld, rawLockHeld])
|
|
const lockReason = lockHeld ? (rawLockReason ?? 'waiting') : null
|
|
|
|
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 })
|
|
}
|
|
}}
|
|
// scrollToIndex can fail before an off-screen row is measured —
|
|
// fall back to an estimated offset, then retry once it's laid out.
|
|
onScrollToIndexFailed={(info) => {
|
|
listRef.current?.scrollToOffset({
|
|
offset: info.averageItemLength * info.index,
|
|
animated: true
|
|
})
|
|
setTimeout(() => {
|
|
listRef.current?.scrollToIndex({
|
|
index: info.index,
|
|
viewPosition: 0,
|
|
animated: true
|
|
})
|
|
}, 120)
|
|
}}
|
|
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
|
|
}
|
|
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. The scroll-to-top affordance now lives
|
|
per-message (the up-arrow in each agent message's controls). */}
|
|
{!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>
|
|
)}
|
|
{/* Pending agent prompt: a structured AskUserQuestion wins, then a
|
|
heuristic permission, then a heuristic question. The controller owns
|
|
dismissal (it must survive this subtree unmounting on a view toggle);
|
|
`ask` arrives already nulled while dismissed. */}
|
|
{ask ? (
|
|
<MobileNativeChatAsk
|
|
key={askKey ?? 'ask'}
|
|
prompt={ask}
|
|
onAnswer={async (selections) => {
|
|
const accepted = (await onAnswerAsk?.(ask, selections)) ?? false
|
|
if (accepted) {
|
|
onDismissAsk?.()
|
|
}
|
|
return accepted
|
|
}}
|
|
onCancel={async () => {
|
|
const accepted = (await onCancelAsk?.()) ?? false
|
|
if (accepted) {
|
|
onDismissAsk?.()
|
|
}
|
|
return accepted
|
|
}}
|
|
/>
|
|
) : permission ? (
|
|
<MobileNativeChatPermission
|
|
key={JSON.stringify(permission)}
|
|
permission={permission}
|
|
onRespond={async (send) => (await onRespondPermission?.(send)) ?? false}
|
|
/>
|
|
) : question ? (
|
|
<MobileNativeChatQuestion
|
|
key={mobileChatQuestionKey(question)}
|
|
question={question}
|
|
onAnswer={async (text) => (await onAnswerQuestion?.(text)) ?? false}
|
|
/>
|
|
) : null}
|
|
{/* Chrome row above the composer: the working indicator and the global
|
|
tool-calls expand/collapse toggle on the left, Stop in the far corner. */}
|
|
<View style={styles.chromeRow}>
|
|
<View style={styles.chromeLeft}>
|
|
{agentWorking ? <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>
|
|
{agentWorking ? (
|
|
<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
|
|
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>
|
|
)
|
|
}
|