Files
orca/mobile/src/session/MobileNativeChatOverlay.tsx
T
3d341c132f fix(mobile): dismiss the keyboard after sending to an agent (#17059)
* 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>
2026-08-30 16:59:55 -07:00

121 lines
4.8 KiB
TypeScript

import { useMemo } from 'react'
import { StyleSheet, View } from 'react-native'
import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView'
import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data'
import type { MobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments'
import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-streaming-bubble'
type Props = {
controller: MobileNativeChatController
/** Opens a tapped file reference (worktree-relative or absolute, optional
* :line(:col) suffix) through the shared tap-to-open flow. */
onOpenFile: (pathText: string) => void
/** Native-chat image attachments: picking adds a composer chip, and sending
* rides the pending images along with the message text (desktop parity). */
images: MobileNativeChatImageAttachments
onMicPress: () => void
micActive: boolean
dictationMode: 'toggle' | 'hold'
onMicPressIn: () => void
onMicPressOut: () => void
inputLockReason: MobileNativeChatInputLockReason | null
/** Latest send failure, rendered inline above the composer. */
sendErrorMessage: string | null
/** Drops that failure once a later send succeeds. */
onClearSendError: () => void
/** Stable host/worktree/tab identity for accepted-send completion fencing. */
sendSurfaceId: string
/** Reads the retained route's focus generation for accepted-send fencing. */
getSendCompletionGeneration: () => number
keyboardInset: number
}
/** Keeps the terminal mounted underneath chat so its PTY subscription survives
* view toggles while the native surface owns the visible composer. Also owns
* the streaming gate: this component stays mounted across those toggles, while
* the chat list below it does not. */
export function MobileNativeChatOverlay({
controller,
onOpenFile,
images,
onMicPress,
micActive,
dictationMode,
onMicPressIn,
onMicPressOut,
inputLockReason,
sendErrorMessage,
onClearSendError,
sendSurfaceId,
getSendCompletionGeneration,
keyboardInset
}: Props): React.JSX.Element | null {
const session = controller.nativeChatSession
const folded = useMemo(() => foldMobileNativeChatMessages(session.messages), [session.messages])
const streaming = useMobileNativeChatStreamingBubble(
folded,
controller.nativeChatStreamingText,
controller.nativeChatStreamScopeKey,
controller.nativeChatStreamLive
)
if (!controller.showNativeChat) {
return null
}
return (
<View style={styles.overlay}>
<MobileNativeChatView
messages={session.messages}
folded={folded}
status={session.status}
error={session.error}
agent={controller.nativeChatAgent}
agentWorking={controller.nativeChatAgentWorking}
streaming={streaming}
onStop={controller.handleNativeChatStop}
ask={controller.nativeChatAsk}
askKey={controller.nativeChatAskKey}
onDismissAsk={controller.dismissNativeChatAsk}
onAnswerAsk={controller.handleNativeChatAnswerAsk}
onCancelAsk={controller.handleNativeChatCancelAsk}
question={controller.nativeChatQuestion}
onAnswerQuestion={controller.handleNativeChatQuestionAnswer}
permission={controller.nativeChatPermission}
onRespondPermission={controller.handleNativeChatRespondPermission}
onOpenFile={onOpenFile}
hasMore={session.hasMore}
loadingEarlier={session.loadingEarlier}
onLoadEarlier={session.loadEarlier}
onSend={images.sendNativeChat}
sendSurfaceId={sendSurfaceId}
getSendCompletionGeneration={getSendCompletionGeneration}
getComposerEditGeneration={controller.getChatComposerEditGeneration}
pending={controller.chatPending}
imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId}
composerText={controller.chatComposerText}
onComposerTextChange={controller.setChatComposerText}
onAttachImage={() => void images.attachImage('library')}
attachments={images.attachments}
onRemoveAttachment={images.removeAttachment}
isAttaching={images.isAttaching}
onMicPress={onMicPress}
micActive={micActive}
dictationMode={dictationMode}
onMicPressIn={onMicPressIn}
onMicPressOut={onMicPressOut}
inputLockReason={inputLockReason}
sendErrorMessage={sendErrorMessage}
onClearSendError={onClearSendError}
filePaths={controller.nativeChatFilePaths}
onNeedFiles={controller.loadNativeChatFiles}
sessionOptions={controller.nativeChatSessionOptions}
keyboardInset={keyboardInset}
/>
</View>
)
}
const styles = StyleSheet.create({
overlay: StyleSheet.absoluteFillObject
})