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>
This commit is contained in:
Brennan Benson
2026-08-30 16:59:55 -07:00
committed by GitHub
co-authored by Merge Sim Merge Sim
parent b8d5b0486e
commit 3d341c132f
36 changed files with 2278 additions and 489 deletions
+83 -454
View File
@@ -5,8 +5,6 @@ import {
Linking,
type AppStateStatus,
BackHandler,
FlatList,
Image,
View,
Text,
ScrollView,
@@ -16,8 +14,7 @@ import {
Platform,
ActivityIndicator,
type KeyboardEvent,
type LayoutChangeEvent,
type ListRenderItem
type LayoutChangeEvent
} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
@@ -37,12 +34,10 @@ import {
GitBranch,
Globe,
Keyboard as KeyboardIcon,
MessageSquare,
Monitor,
MoreHorizontal,
Plus,
RefreshCw,
Send,
Smartphone,
SquareTerminal,
X
@@ -82,6 +77,8 @@ import {
} from '../../../../src/session/mobile-bulk-close-sheet-actions'
import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context'
import { isFloatingWorkspaceWorktreeId } from '../../../../src/session/floating-workspace'
import { useAgentSendKeyboardDismissal } from '../../../../src/session/use-agent-send-keyboard-dismissal'
import { useMobileSendCompletionGeneration } from '../../../../src/session/use-mobile-send-completion-generation'
import { SessionDockColumn } from '../../../../src/session/SessionDockColumn'
import { MobileSessionHeaderIconButton } from '../../../../src/session/MobileSessionHeaderIconButton'
import { MobileSessionHeaderMoreActionsSheet } from '../../../../src/session/MobileSessionHeaderMoreActionsSheet'
@@ -130,6 +127,7 @@ import { useTerminalLiveInputFocus } from '../../../../src/terminal/use-terminal
import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss'
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
import { useBufferedTerminalDrafts } from '../../../../src/terminal/use-buffered-terminal-drafts'
import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply'
import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version'
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
@@ -160,7 +158,6 @@ import { MobileAgentIcon } from '../../../../src/components/MobileAgentIcon'
import { TextInputModal } from '../../../../src/components/TextInputModal'
import { ConfirmModal } from '../../../../src/components/ConfirmModal'
import { MobileMarkdownReader } from '../../../../src/session/MobileMarkdownReader'
import { MobileSyntaxSegments } from '../../../../src/components/MobileSyntaxSegments'
import {
CustomKeyModal,
loadCustomKeys,
@@ -174,12 +171,6 @@ import {
removeDeliveredMobileDiffComments,
removeMobileDiffComments
} from '../../../../src/session/mobile-diff-comments'
import {
buildPlainMobileDiffSyntaxLines,
highlightMobileCode,
highlightMobileDiffLines,
resolveMobileSyntaxLanguage
} from '../../../../src/session/mobile-file-syntax'
import {
getTerminalRecordsFromSessionTabs,
hasConnectedTerminalAbsentFromSessionTabs,
@@ -223,7 +214,6 @@ import {
buildMarkdownDiskFallbackDoc,
shouldReadMarkdownFromDiskAfterReadTabFailure
} from '../../../../src/session/mobile-markdown-disk-fallback'
import { MobileHtmlPreview } from '../../../../src/components/MobileHtmlPreview'
import { MobileDictationSetupSheet } from '../../../../src/components/MobileDictationSetupSheet'
import {
fetchDictationSetup,
@@ -290,21 +280,18 @@ import {
import { colors } from '../../../../src/theme/mobile-theme'
import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton'
import { styles } from '../../../../src/session/mobile-session-styles'
import { MobileSessionFileReader } from '../../../../src/session/MobileSessionFileReader'
import type { DiffComment } from '../../../../../src/shared/diff-comment-types'
import type { TerminalQuickCommand } from '../../../../../src/shared/terminal-quick-command-types'
import type {
DiffCommentActions,
DiffNotesDelivery,
DiffSyntaxState,
DirtyMarkdownDraft,
FileDocState,
FileSyntaxState,
MarkdownDocState,
MobileDisplayMode,
MobileNewTabAgentLoadState,
MobileSessionTab,
MobileSessionTabType,
RenderableDiffLine,
RuntimeRepoSummary,
SessionTabsResult,
Terminal,
@@ -324,416 +311,6 @@ function getClosedTabTombstoneExpiry(): number {
return Date.now() + CLOSED_TAB_TOMBSTONE_TTL_MS
}
function DiffLineRow({
line,
title,
index,
comments,
activeCommentLine,
commentDraft,
commentsBusy,
onStartComment,
onCancelComment,
onDraftChange,
onSubmitComment,
onDeleteComment
}: {
line: RenderableDiffLine
title: string
index: number
comments: DiffComment[]
activeCommentLine: number | null
commentDraft: string
commentsBusy: boolean
onStartComment: (lineNumber: number) => void
onCancelComment: () => void
onDraftChange: (value: string) => void
onSubmitComment: (lineNumber: number) => void
onDeleteComment: (commentId: string) => void
}) {
const commentLine = line.newLineNumber
const isCommenting = commentLine !== undefined && activeCommentLine === commentLine
const canComment = commentLine !== undefined
// Why: review notes anchor to the modified side, so show that line number in the single mobile gutter.
const gutterLineNumber = line.newLineNumber ?? line.oldLineNumber ?? ''
return (
<View style={styles.diffLineBlock}>
<View
style={[
styles.diffLine,
line.kind === 'add' && styles.diffLineAdded,
line.kind === 'delete' && styles.diffLineDeleted
]}
>
<Text style={styles.diffGutter}>{gutterLineNumber}</Text>
<Text
selectable
style={styles.diffText}
accessibilityLabel={`${title} diff line ${index + 1}`}
>
<Text
style={[
styles.diffPrefix,
line.kind === 'add' && styles.diffPrefixAdded,
line.kind === 'delete' && styles.diffPrefixDeleted
]}
>
{line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '}
</Text>
<MobileSyntaxSegments segments={line.segments} />
</Text>
{canComment ? (
<Pressable
style={({ pressed }) => [
styles.diffCommentAddButton,
pressed && styles.diffCommentAddButtonPressed,
commentsBusy && styles.diffCommentButtonDisabled
]}
disabled={commentsBusy}
onPress={() => {
if (commentLine !== undefined) {
onStartComment(commentLine)
}
}}
accessibilityLabel={`Add note on line ${commentLine}`}
>
<Plus size={12} color={colors.textSecondary} strokeWidth={2.3} />
</Pressable>
) : null}
</View>
{comments.length > 0 ? (
<View style={styles.diffCommentList}>
{comments.map((comment) => (
<View key={comment.id} style={styles.diffCommentCard}>
<View style={styles.diffCommentHeader}>
<MessageSquare size={12} color={colors.textMuted} strokeWidth={2.2} />
<Text style={styles.diffCommentMeta}>Line {comment.lineNumber}</Text>
<Pressable
style={styles.diffCommentDeleteButton}
disabled={commentsBusy}
onPress={() => onDeleteComment(comment.id)}
accessibilityLabel={`Delete note on line ${comment.lineNumber}`}
>
<X size={12} color={colors.textMuted} strokeWidth={2.2} />
</Pressable>
</View>
<Text style={styles.diffCommentBody}>{comment.body}</Text>
</View>
))}
</View>
) : null}
{isCommenting ? (
<View style={styles.diffCommentComposer}>
<TextInput
style={[styles.textInput, styles.diffCommentInput]}
value={commentDraft}
onChangeText={onDraftChange}
placeholder="Add review note"
placeholderTextColor={colors.textMuted}
editable={!commentsBusy}
multiline
textAlignVertical="top"
autoFocus
/>
<View style={styles.diffCommentComposerActions}>
<Pressable
style={styles.diffCommentSecondaryAction}
disabled={commentsBusy}
onPress={onCancelComment}
>
<Text style={styles.diffCommentSecondaryText}>Cancel</Text>
</Pressable>
<Pressable
style={[
styles.diffCommentPrimaryAction,
(!commentDraft.trim() || commentsBusy) && styles.diffCommentButtonDisabled
]}
disabled={!commentDraft.trim() || commentsBusy}
onPress={() => {
if (commentLine !== undefined) {
onSubmitComment(commentLine)
}
}}
>
<Text style={styles.diffCommentPrimaryText}>Save note</Text>
</Pressable>
</View>
</View>
) : null}
</View>
)
}
function FileReader({
doc,
title,
relativePath,
language,
diffCommentActions
}: {
doc: FileDocState | undefined
title: string
relativePath: string
language?: string
diffCommentActions?: DiffCommentActions
}) {
const syntaxLanguage = useMemo(
() => resolveMobileSyntaxLanguage(relativePath || title, language),
[language, relativePath, title]
)
const [fileSyntax, setFileSyntax] = useState<FileSyntaxState | null>(null)
const [diffSyntax, setDiffSyntax] = useState<DiffSyntaxState | null>(null)
const [activeCommentLine, setActiveCommentLine] = useState<number | null>(null)
const [commentDraft, setCommentDraft] = useState('')
const plainDiffLines = useMemo(
() =>
doc?.status === 'ready' && doc.kind === 'diff'
? buildPlainMobileDiffSyntaxLines(doc.lines)
: [],
[doc]
)
const diffCommentsForFile = useMemo(
() =>
diffCommentActions?.comments.filter(
(comment) => comment.filePath === relativePath && comment.source !== 'markdown'
) ?? [],
[diffCommentActions?.comments, relativePath]
)
const diffCommentsByLine = useMemo(() => {
const map = new Map<number, DiffComment[]>()
for (const comment of diffCommentsForFile) {
const list = map.get(comment.lineNumber) ?? []
list.push(comment)
map.set(comment.lineNumber, list)
}
for (const list of map.values()) {
list.sort((a, b) => a.createdAt - b.createdAt)
}
return map
}, [diffCommentsForFile])
const startComment = useCallback((lineNumber: number) => {
setActiveCommentLine(lineNumber)
setCommentDraft('')
}, [])
const cancelComment = useCallback(() => {
setActiveCommentLine(null)
setCommentDraft('')
}, [])
const submitComment = useCallback(
(lineNumber: number) => {
if (!diffCommentActions) {
return
}
void diffCommentActions.onAdd(relativePath, lineNumber, commentDraft).then((added) => {
if (added) {
setActiveCommentLine(null)
setCommentDraft('')
}
})
},
[commentDraft, diffCommentActions, relativePath]
)
const renderDiffLine: ListRenderItem<RenderableDiffLine> = useCallback(
({ item, index }) => (
<DiffLineRow
line={item}
title={title}
index={index}
comments={
item.newLineNumber !== undefined ? (diffCommentsByLine.get(item.newLineNumber) ?? []) : []
}
activeCommentLine={activeCommentLine}
commentDraft={commentDraft}
commentsBusy={diffCommentActions?.busy === true}
onStartComment={startComment}
onCancelComment={cancelComment}
onDraftChange={setCommentDraft}
onSubmitComment={submitComment}
onDeleteComment={(commentId) => {
if (diffCommentActions) {
void diffCommentActions.onDelete(commentId)
}
}}
/>
),
[
activeCommentLine,
cancelComment,
commentDraft,
diffCommentActions,
diffCommentsByLine,
startComment,
submitComment,
title
]
)
useEffect(() => {
if (doc?.status !== 'ready') {
return undefined
}
// Why: defer highlighting one tick so large files show as plain text immediately before colors are applied.
const timer = setTimeout(() => {
// file + html share the syntax-segment source view (html's "Source" toggle).
if (doc.kind === 'file' || doc.kind === 'html') {
setFileSyntax({
doc,
language: syntaxLanguage,
segments: highlightMobileCode(doc.content, syntaxLanguage).segments
})
return
}
if (doc.kind === 'diff') {
setDiffSyntax({
doc,
language: syntaxLanguage,
lines: highlightMobileDiffLines(doc.lines, syntaxLanguage)
})
}
// image: no syntax highlighting.
}, 0)
return () => clearTimeout(timer)
}, [doc, syntaxLanguage])
if (!doc || doc.status === 'loading') {
return (
<View style={styles.markdownState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
)
}
if (doc.status === 'error') {
return (
<View style={styles.markdownState}>
<Text style={styles.markdownError}>{doc.message}</Text>
</View>
)
}
if (doc.kind === 'diff') {
const activeDiffSyntax =
diffSyntax?.doc === doc && diffSyntax.language === syntaxLanguage ? diffSyntax.lines : null
const commentCount = diffCommentActions?.comments.length ?? 0
const unsentCommentCount =
diffCommentActions?.comments.filter((comment) => !comment.sentAt).length ?? 0
const commentsBusy = diffCommentActions?.busy === true
const canCopyNotes = commentCount > 0 && !commentsBusy
const canSendNotes = unsentCommentCount > 0 && !commentsBusy
return (
<View style={styles.markdownEditor}>
{diffCommentActions ? (
<View style={styles.diffNotesToolbar}>
<View style={styles.diffNotesTitleRow}>
<MessageSquare size={14} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.diffNotesTitle}>
{commentCount === 0
? 'No review notes'
: `${commentCount} review ${commentCount === 1 ? 'note' : 'notes'}`}
</Text>
</View>
<View style={styles.diffNotesActions}>
<Pressable
style={[
styles.diffNotesActionButton,
!canCopyNotes && styles.diffCommentButtonDisabled
]}
disabled={!canCopyNotes}
onPress={() => void diffCommentActions.onCopyAll()}
accessibilityLabel="Copy review notes"
>
<Copy size={13} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.diffNotesActionText}>Copy</Text>
</Pressable>
<Pressable
style={[
styles.diffNotesActionButton,
!canSendNotes && styles.diffCommentButtonDisabled
]}
disabled={!canSendNotes}
onPress={diffCommentActions.onSendAll}
accessibilityLabel="Send review notes to AI"
>
<Send size={13} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.diffNotesActionText}>Send</Text>
</Pressable>
</View>
</View>
) : null}
<FlatList
data={activeDiffSyntax ?? plainDiffLines}
style={styles.filePreviewScroll}
contentContainerStyle={styles.filePreviewContent}
keyExtractor={(line, index) =>
`${index}:${line.kind}:${line.oldLineNumber ?? ''}:${line.newLineNumber ?? ''}`
}
renderItem={renderDiffLine}
initialNumToRender={32}
maxToRenderPerBatch={48}
windowSize={7}
removeClippedSubviews={Platform.OS !== 'web'}
keyboardShouldPersistTaps="handled"
/>
</View>
)
}
if (doc.kind === 'image') {
return (
<View style={styles.imagePreviewContainer}>
<ScrollView
style={styles.imagePreviewScroll}
contentContainerStyle={styles.imagePreviewContent}
maximumZoomScale={4}
minimumZoomScale={1}
centerContent
>
<Image
source={{ uri: doc.dataUri }}
style={styles.imagePreview}
resizeMode="contain"
accessibilityLabel={`${title} image`}
/>
</ScrollView>
</View>
)
}
const renderSourceText = (content: string) => (
<View style={styles.markdownEditor}>
<ScrollView
style={styles.filePreviewScroll}
contentContainerStyle={styles.filePreviewContent}
>
<Text selectable style={styles.filePreviewText} accessibilityLabel={`${title} preview`}>
<MobileSyntaxSegments
segments={
fileSyntax?.doc === doc && fileSyntax.language === syntaxLanguage
? fileSyntax.segments
: [{ text: content, kind: 'plain' }]
}
/>
</Text>
</ScrollView>
</View>
)
if (doc.kind === 'html') {
return (
<View style={styles.markdownEditor}>
<MobileHtmlPreview html={doc.content} renderSource={() => renderSourceText(doc.content)} />
</View>
)
}
return renderSourceText(doc.content)
}
export default function SessionScreen() {
const {
hostId,
@@ -815,7 +392,6 @@ export default function SessionScreen() {
// Why: after an optimistic close, suppress the tab (with expiry) until the publisher confirms, so an in-flight snapshot can't flash it back.
const closedTabTombstonesRef = useRef<Map<string, number>>(new Map())
const [terminalsLoaded, setTerminalsLoaded] = useWorktreeSessionTabsLoaded(worktreeId)
const [input, setInput] = useState('')
// Why: baseline terminal zoom reloaded on focus so a Settings → Terminal change applies in place (panes stay mounted).
const [terminalTextScale, setTerminalTextScale] = useState(1)
// Why: terminal command-bar autocomplete opt-in, reloaded on focus so a Settings → Terminal toggle takes effect on return.
@@ -951,6 +527,8 @@ export default function SessionScreen() {
// Why: don't subscribe until the WebView fires web-ready — iOS may defer JS in hidden WebViews and init() messages would queue unrendered.
const webReadyHandlesRef = useRef<Set<string>>(new Set())
const activeHandleRef = useRef<string | null>(null)
const bufferedTerminalDraftState = useBufferedTerminalDrafts({ activeHandle, activeHandleRef })
const reconcileBufferedDraftsRef = useRef(bufferedTerminalDraftState.reconcileTerminalTabs)
const activeSessionTabTypeRef = useRef<MobileSessionTabType | null>(null)
const pendingActiveSessionTabIdRef = useRef<string | null>(null)
// Why: survive transient snapshot gaps so the device's own tab pick can re-bind.
@@ -983,6 +561,7 @@ export default function SessionScreen() {
const {
clearPendingLiveInputCommit,
flushPendingLiveInputBeforeExternalSend,
getLiveInputInteractionGeneration: getLiveInteractionGeneration,
handleLiveInputAccessoryBytes,
handleLiveInputChange,
handleLiveInputKeyPress,
@@ -1015,12 +594,6 @@ export default function SessionScreen() {
liveInputEnabled,
timerRef: liveInputFocusTimerRef
})
useFocusEffect(
useCallback(() => {
// Expo retains this route while pushed screens are visible.
return resetLiveInputFocus
}, [resetLiveInputFocus])
)
const [browserScreencastSupported, setBrowserScreencastSupported] = useState<boolean | null>(null)
// Why: hosts without aiVault.v1 reject listSessions, so hide the header entry instead of a dead-end "update this host" panel.
const [agentSessionHistorySupported, setAgentSessionHistorySupported] = useState<boolean | null>(
@@ -1133,6 +706,11 @@ export default function SessionScreen() {
})
const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController
nativeChatSendError.bannerMountedRef.current = showNativeChat
const routeKey = nativeChatScopeKey ?? `${hostId}\0${worktreeId}`
const getSendCompletionGeneration = useMobileSendCompletionGeneration({
onBlur: resetLiveInputFocus,
surfaceKey: JSON.stringify([routeKey, activeHandle, showNativeChat, liveInputEnabled])
})
const dictation = useMobileDictation({
client,
@@ -1170,7 +748,7 @@ export default function SessionScreen() {
})()
return
}
setInput((current) => appendBufferedDictation(current, route.text))
bufferedTerminalDraftState.setInput((current) => appendBufferedDictation(current, route.text))
showToast('Dictation inserted')
},
onError: (err) => {
@@ -1664,7 +1242,9 @@ export default function SessionScreen() {
// Sweep against the retained set, not the raw list: a chat-covered handle
// keeps its subscription across a graph reload, so erasing its live-input
// preference on the same refresh is the erasure this guard exists to stop.
pruneTerminalHandlesFromLiveInput(resolveRetainedTerminalHandles(pruneContext))
const retainedHandles = resolveRetainedTerminalHandles(pruneContext)
pruneTerminalHandlesFromLiveInput(retainedHandles)
bufferedTerminalDraftState.pruneDrafts(retainedHandles)
defaultTerminalHandlesToLiveInput([...liveHandles])
const shouldPrune = createTerminalPrunePredicate(pruneContext)
for (const handle of Array.from(terminalUnsubsRef.current.keys())) {
@@ -1721,6 +1301,7 @@ export default function SessionScreen() {
clearTerminalLiveInputDefault,
defaultTerminalHandlesToLiveInput,
nativeChatStream,
bufferedTerminalDraftState.pruneDrafts,
pruneTerminalHandlesFromLiveInput,
subscribeToTerminal,
terminalInventoryRequest,
@@ -1761,6 +1342,9 @@ export default function SessionScreen() {
if (orphanedDraftTabs.length > 0) {
nextTabs = [...orphanedDraftTabs, ...nextTabs]
}
reconcileBufferedDraftsRef.current(currentSessionTabs, nextTabs, {
retainMissingSurfaces: result.tabs.length === 0
})
sessionTabsRef.current = nextTabs
initialSessionAutoCreateRef.current.sawSessionTabs ||= nextTabs.length > 0
// Why: subscribe snapshots often repeat identical payloads; skip re-set to avoid a subscription teardown/replay loop.
@@ -2663,6 +2247,7 @@ export default function SessionScreen() {
terminalDiagnosticsRef.current.resetRoute()
appliedSnapshotMarkerRef.current = { epoch: null, version: -1 }
closedTabTombstonesRef.current.clear()
bufferedTerminalDraftState.resetDrafts()
for (const queued of terminalGestureInputQueuesRef.current.values()) {
if (queued.timer) {
clearTimeout(queued.timer)
@@ -2682,14 +2267,17 @@ export default function SessionScreen() {
return () => {
sessionTabActionSheetRequestSeqRef.current += 1
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
bufferedTerminalDraftState.clearPendingRestorations()
clearPendingLiveInputCommit()
clearDelayedActionTimers()
}
}, [
clearDelayedActionTimers,
bufferedTerminalDraftState.clearPendingRestorations,
clearPendingLiveInputCommit,
clearTerminalCache,
hostId,
bufferedTerminalDraftState.resetDrafts,
worktreeId
])
@@ -3020,6 +2608,20 @@ export default function SessionScreen() {
}
}, [activeSessionTab, fileDocs, readFileTab])
const dismissSoftwareKeyboard = useCallback(() => {
dismissTerminalKeyboard({
clearPendingLiveInputFocus: () => clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef),
commandInput: commandInputRef.current,
dismissKeyboard: () => Keyboard.dismiss(),
liveInput: liveInputRef.current
})
}, [])
const dismissKeyboardAfterAgentSend = useAgentSendKeyboardDismissal(
dismissSoftwareKeyboard,
getSendCompletionGeneration
)
async function handleSend() {
// Why: the return key still submits while offline; hold the composed text instead of firing a doomed RPC (#6713).
if (!client || !activeHandle || sendingRef.current || !canSend) {
@@ -3027,12 +2629,23 @@ export default function SessionScreen() {
}
sendingRef.current = true
const text = normalizeTerminalTextInput(input)
setInput('')
const draft = bufferedTerminalDraftState.input
const text = normalizeTerminalTextInput(draft)
const bufferedDraftSend = bufferedTerminalDraftState.beginBufferedTerminalDraftSend(
activeHandle,
draft
)
const sendOrigin = {
handle: activeHandle,
tab: activeSessionTab,
generation: getSendCompletionGeneration()
}
const restoreRejectedDraft = () =>
bufferedTerminalDraftState.restoreRejectedDraft(bufferedDraftSend)
try {
// Why: fail now and restore the text — a send parked across a reconnect would execute long after the tap.
await client.sendRequest(
const response = await client.sendRequest(
'terminal.send',
buildTerminalSendParams({
terminal: activeHandle,
@@ -3042,9 +2655,17 @@ export default function SessionScreen() {
}),
TERMINAL_INPUT_SEND_OPTIONS
)
const accepted = isTerminalSendRpcAccepted(response)
if (!accepted) {
restoreRejectedDraft()
}
const draftUnchanged =
accepted && bufferedTerminalDraftState.settleBufferedTerminalDraftSend(bufferedDraftSend)
dismissKeyboardAfterAgentSend(sendOrigin, accepted && draftUnchanged)
} catch {
setInput(text)
restoreRejectedDraft()
} finally {
bufferedTerminalDraftState.settleBufferedTerminalDraftSend(bufferedDraftSend)
sendingRef.current = false
}
}
@@ -3180,15 +2801,6 @@ export default function SessionScreen() {
]
)
const dismissSoftwareKeyboard = useCallback(() => {
dismissTerminalKeyboard({
clearPendingLiveInputFocus: () => clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef),
commandInput: commandInputRef.current,
dismissKeyboard: () => Keyboard.dismiss(),
liveInput: liveInputRef.current
})
}, [])
// Tap a terminal or chat file path → resolve on host, open as file tab/preview.
const { handleFileTap, handleNativeChatFileTap } = useMobileFileTapHandlers<MobileSessionTab>({
client,
@@ -4123,6 +3735,7 @@ export default function SessionScreen() {
})
if (response.ok) {
const remainingTabs = sessionTabsRef.current.filter((candidate) => candidate.id !== tab.id)
reconcileBufferedDraftsRef.current(sessionTabsRef.current, remainingTabs)
if (tab.type === 'browser' && tab.browserPageId === pendingBrowserFocusPageIdRef.current) {
pendingBrowserFocusPageIdRef.current = null
}
@@ -4649,7 +4262,7 @@ export default function SessionScreen() {
</View>
) : activeFileTab ? (
<View style={styles.markdownFrame}>
<FileReader
<MobileSessionFileReader
doc={fileDocs.get(activeFileTab.id)}
title={activeFileTab.title || 'File'}
relativePath={activeFileTab.relativePath}
@@ -4768,6 +4381,8 @@ export default function SessionScreen() {
inputLockReason={nativeChatInputLockReason}
sendErrorMessage={nativeChatSendError.message}
onClearSendError={nativeChatSendError.clear}
sendSurfaceId={nativeChatScopeKey ?? ''}
getSendCompletionGeneration={getSendCompletionGeneration}
keyboardInset={keyboardLift}
/>
{toastMessage && (
@@ -5023,7 +4638,20 @@ export default function SessionScreen() {
// marked-text report that says whether this text is still preedit.
onChange={handleLiveInputChange}
onKeyPress={handleLiveInputKeyPress}
onSubmitEditing={handleLiveInputSubmit}
onSubmitEditing={() => {
const submit = handleLiveInputSubmit()
const sendOrigin = {
tab: activeSessionTab,
generation: getSendCompletionGeneration(),
interaction: getLiveInteractionGeneration()
}
void submit.then((accepted) =>
dismissKeyboardAfterAgentSend(
sendOrigin,
accepted && sendOrigin.interaction === getLiveInteractionGeneration()
)
)
}}
placeholder=""
showSoftInputOnFocus
autoCapitalize="none"
@@ -5052,9 +4680,9 @@ export default function SessionScreen() {
: 'cmd-input'
}
style={styles.textInput}
value={input}
value={bufferedTerminalDraftState.input}
// Why: iOS kills active dictation/IME if JS writes a value differing from native text; store raw, normalize at send.
onChangeText={setInput}
onChangeText={bufferedTerminalDraftState.setInput}
placeholder="Type a command…"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
@@ -5068,6 +4696,7 @@ export default function SessionScreen() {
autocompleteEnabled
)}
returnKeyType="send"
blurOnSubmit={false}
// Why: composing is local — an outage must not lock the field or discard typed text (#6713).
editable={canCompose}
onSubmitEditing={() => void handleSend()}
@@ -0,0 +1,147 @@
import { Pressable, Text, TextInput, View } from 'react-native'
import { MessageSquare, Plus, X } from 'lucide-react-native'
import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-session-styles'
import type { DiffComment } from '../../../src/shared/diff-comment-types'
import type { RenderableDiffLine } from './mobile-session-route-types'
export function MobileDiffCommentLineRow({
line,
title,
index,
comments,
activeCommentLine,
commentDraft,
commentsBusy,
onStartComment,
onCancelComment,
onDraftChange,
onSubmitComment,
onDeleteComment
}: {
line: RenderableDiffLine
title: string
index: number
comments: DiffComment[]
activeCommentLine: number | null
commentDraft: string
commentsBusy: boolean
onStartComment: (lineNumber: number) => void
onCancelComment: () => void
onDraftChange: (value: string) => void
onSubmitComment: (lineNumber: number) => void
onDeleteComment: (commentId: string) => void
}) {
const commentLine = line.newLineNumber
const isCommenting = commentLine !== undefined && activeCommentLine === commentLine
const canComment = commentLine !== undefined
// Why: review notes anchor to the modified side, so show that line number in the single mobile gutter.
const gutterLineNumber = line.newLineNumber ?? line.oldLineNumber ?? ''
return (
<View style={styles.diffLineBlock}>
<View
style={[
styles.diffLine,
line.kind === 'add' && styles.diffLineAdded,
line.kind === 'delete' && styles.diffLineDeleted
]}
>
<Text style={styles.diffGutter}>{gutterLineNumber}</Text>
<Text
selectable
style={styles.diffText}
accessibilityLabel={`${title} diff line ${index + 1}`}
>
<Text
style={[
styles.diffPrefix,
line.kind === 'add' && styles.diffPrefixAdded,
line.kind === 'delete' && styles.diffPrefixDeleted
]}
>
{line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '}
</Text>
<MobileSyntaxSegments segments={line.segments} />
</Text>
{canComment ? (
<Pressable
style={({ pressed }) => [
styles.diffCommentAddButton,
pressed && styles.diffCommentAddButtonPressed,
commentsBusy && styles.diffCommentButtonDisabled
]}
disabled={commentsBusy}
onPress={() => {
if (commentLine !== undefined) {
onStartComment(commentLine)
}
}}
accessibilityLabel={`Add note on line ${commentLine}`}
>
<Plus size={12} color={colors.textSecondary} strokeWidth={2.3} />
</Pressable>
) : null}
</View>
{comments.length > 0 ? (
<View style={styles.diffCommentList}>
{comments.map((comment) => (
<View key={comment.id} style={styles.diffCommentCard}>
<View style={styles.diffCommentHeader}>
<MessageSquare size={12} color={colors.textMuted} strokeWidth={2.2} />
<Text style={styles.diffCommentMeta}>Line {comment.lineNumber}</Text>
<Pressable
style={styles.diffCommentDeleteButton}
disabled={commentsBusy}
onPress={() => onDeleteComment(comment.id)}
accessibilityLabel={`Delete note on line ${comment.lineNumber}`}
>
<X size={12} color={colors.textMuted} strokeWidth={2.2} />
</Pressable>
</View>
<Text style={styles.diffCommentBody}>{comment.body}</Text>
</View>
))}
</View>
) : null}
{isCommenting ? (
<View style={styles.diffCommentComposer}>
<TextInput
style={[styles.textInput, styles.diffCommentInput]}
value={commentDraft}
onChangeText={onDraftChange}
placeholder="Add review note"
placeholderTextColor={colors.textMuted}
editable={!commentsBusy}
multiline
textAlignVertical="top"
autoFocus
/>
<View style={styles.diffCommentComposerActions}>
<Pressable
style={styles.diffCommentSecondaryAction}
disabled={commentsBusy}
onPress={onCancelComment}
>
<Text style={styles.diffCommentSecondaryText}>Cancel</Text>
</Pressable>
<Pressable
style={[
styles.diffCommentPrimaryAction,
(!commentDraft.trim() || commentsBusy) && styles.diffCommentButtonDisabled
]}
disabled={!commentDraft.trim() || commentsBusy}
onPress={() => {
if (commentLine !== undefined) {
onSubmitComment(commentLine)
}
}}
>
<Text style={styles.diffCommentPrimaryText}>Save note</Text>
</Pressable>
</View>
</View>
) : null}
</View>
)
}
@@ -1,8 +1,20 @@
import { createElement } from 'react'
import { createElement, StrictMode, type ComponentProps } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { Keyboard } from 'react-native'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { radii, spacing } from '../theme/mobile-theme'
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
import { MobileNativeChatComposer as NativeChatComposer } from './MobileNativeChatComposer'
const getNoComposerEditGeneration = () => 0
function MobileNativeChatComposer({
getComposerEditGeneration = getNoComposerEditGeneration,
...props
}: Omit<ComponentProps<typeof NativeChatComposer>, 'getComposerEditGeneration'> & {
getComposerEditGeneration?: () => number
}): React.JSX.Element {
return createElement(NativeChatComposer, { ...props, getComposerEditGeneration })
}
vi.mock('react-native', async () => {
const React = await import('react')
@@ -45,6 +57,7 @@ vi.mock('../components/BottomDrawer', async () => {
describe('MobileNativeChatComposer', () => {
let renderer: ReactTestRenderer | null = null
const getCurrentSendCompletionGeneration = () => 0
afterEach(() => {
act(() => renderer?.unmount())
@@ -53,15 +66,24 @@ describe('MobileNativeChatComposer', () => {
async function render(
onSend: (text: string) => Promise<boolean>,
onChangeText: () => void,
isAttaching = false
onChangeText: (text: string) => void,
isAttaching = false,
sendSurfaceId = 'tab-a',
getSendCompletionGeneration = () => 0
) {
let composerEditGeneration = 0
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: ' hello ',
onChangeText,
onChangeText: (text) => {
composerEditGeneration += 1
onChangeText(text)
},
onSend,
sendSurfaceId,
getSendCompletionGeneration,
getComposerEditGeneration: () => composerEditGeneration,
isAttaching
})
)
@@ -117,7 +139,9 @@ describe('MobileNativeChatComposer', () => {
createElement(MobileNativeChatComposer, {
value: ' /clear is prose ',
onChangeText: vi.fn(),
onSend
onSend,
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration
})
)
})
@@ -164,6 +188,8 @@ describe('MobileNativeChatComposer', () => {
value: 'run the tests',
onChangeText: vi.fn(),
onSend,
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
sessionOptions: { isWorking: false, controller }
})
)
@@ -196,6 +222,8 @@ describe('MobileNativeChatComposer', () => {
value: 'hello',
onChangeText: vi.fn(),
onSend,
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
sessionOptions: {
isWorking: false,
controller: {
@@ -241,6 +269,8 @@ describe('MobileNativeChatComposer', () => {
value: 'half-typed',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
disabled: true
})
)
@@ -262,6 +292,8 @@ describe('MobileNativeChatComposer', () => {
value: '',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
attachments: [
{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' },
{ id: 'img-2', path: '/tmp/b.png', previewUri: 'file:///b.png' }
@@ -290,6 +322,8 @@ describe('MobileNativeChatComposer', () => {
value: '',
onChangeText: vi.fn(),
onSend,
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
attachments: [{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }]
})
)
@@ -307,6 +341,8 @@ describe('MobileNativeChatComposer', () => {
value: '/c',
onChangeText,
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
agent: 'claude'
})
)
@@ -345,6 +381,8 @@ describe('MobileNativeChatComposer', () => {
value: '/',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
agent: 'codex'
})
)
@@ -378,6 +416,8 @@ describe('MobileNativeChatComposer', () => {
value: '',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
onMicPress,
dictationMode: 'hold',
onMicPressIn,
@@ -396,6 +436,8 @@ describe('MobileNativeChatComposer', () => {
value: '',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
onMicPress,
dictationMode: 'toggle',
onMicPressIn,
@@ -408,4 +450,225 @@ describe('MobileNativeChatComposer', () => {
expect(mic().props.onPressIn).toBeUndefined()
expect(mic().props.onPressOut).toBeUndefined()
})
it('dismisses the keyboard once a send is accepted', async () => {
// Why: the reply the user is now waiting on sits behind the keyboard.
vi.mocked(Keyboard.dismiss).mockClear()
await render(vi.fn().mockResolvedValue(true), vi.fn())
await act(async () => sendButton().props.onPress())
expect(Keyboard.dismiss).toHaveBeenCalledTimes(1)
})
it('dismisses an accepted send after Strict Mode replays mount effects', async () => {
vi.mocked(Keyboard.dismiss).mockClear()
await act(async () => {
renderer = create(
createElement(
StrictMode,
null,
createElement(MobileNativeChatComposer, {
value: 'hello',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration
})
)
)
})
await act(async () => sendButton().props.onPress())
expect(Keyboard.dismiss).toHaveBeenCalledTimes(1)
})
it('keeps the keyboard up when the send is rejected', async () => {
// A rejected send hands the draft back for editing, so yanking the keyboard
// would make the user re-open it to fix and retry.
vi.mocked(Keyboard.dismiss).mockClear()
await render(vi.fn().mockResolvedValue(false), vi.fn())
await act(async () => sendButton().props.onPress())
expect(Keyboard.dismiss).not.toHaveBeenCalled()
})
it('does not dismiss a newly focused composer when an old accepted send settles', async () => {
vi.mocked(Keyboard.dismiss).mockClear()
let resolveSend: ((accepted: boolean) => void) | null = null
const onSend = vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveSend = resolve
})
)
await render(onSend, vi.fn())
let pendingSend!: Promise<void>
await act(async () => {
pendingSend = sendButton().props.onPress()
await Promise.resolve()
})
await act(async () => {
renderer!.update(
createElement(MobileNativeChatComposer, {
value: 'new surface draft',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-b',
getSendCompletionGeneration: getCurrentSendCompletionGeneration
})
)
})
await act(async () => {
resolveSend?.(true)
await pendingSend
})
expect(Keyboard.dismiss).not.toHaveBeenCalled()
})
it('does not dismiss after a newer edit on the same surface', async () => {
vi.mocked(Keyboard.dismiss).mockClear()
let resolveSend: ((accepted: boolean) => void) | null = null
const onSend = vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveSend = resolve
})
)
await render(onSend, vi.fn())
let pendingSend!: Promise<void>
await act(async () => {
pendingSend = sendButton().props.onPress()
await Promise.resolve()
})
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
props: { onChangeText: (text: string) => void }
}
await act(async () => input.props.onChangeText('newer draft'))
await act(async () => {
resolveSend?.(true)
await pendingSend
})
expect(Keyboard.dismiss).not.toHaveBeenCalled()
})
it('does not dismiss after autocomplete mutates the same surface', async () => {
vi.mocked(Keyboard.dismiss).mockClear()
let editGeneration = 0
let resolveSend: ((accepted: boolean) => void) | null = null
const onSend = vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveSend = resolve
})
)
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '/c',
onChangeText: () => {
editGeneration += 1
},
onSend,
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
getComposerEditGeneration: () => editGeneration,
agent: 'claude'
})
)
})
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
props: { onSelectionChange: (event: { nativeEvent: { selection: { end: number } } }) => void }
}
await act(async () => input.props.onSelectionChange({ nativeEvent: { selection: { end: 2 } } }))
let pendingSend!: Promise<void>
await act(async () => {
pendingSend = sendButton().props.onPress()
await Promise.resolve()
})
const suggestion = renderer!.root.findAll(
(node) => node.type === 'Pressable' && !node.props.accessibilityLabel
)[0] as { props: { onPress: () => void } }
await act(async () => suggestion.props.onPress())
await act(async () => {
resolveSend?.(true)
await pendingSend
})
expect(Keyboard.dismiss).not.toHaveBeenCalled()
})
it('does not dismiss after dictation mutates the controlled draft', async () => {
vi.mocked(Keyboard.dismiss).mockClear()
let editGeneration = 0
let resolveSend: ((accepted: boolean) => void) | null = null
const onSend = vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveSend = resolve
})
)
const props = {
value: 'hello',
onChangeText: vi.fn(),
onSend,
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
getComposerEditGeneration: () => editGeneration
}
await act(async () => {
renderer = create(createElement(MobileNativeChatComposer, props))
})
let pendingSend!: Promise<void>
await act(async () => {
pendingSend = sendButton().props.onPress()
await Promise.resolve()
})
editGeneration += 1
await act(async () => {
renderer!.update(
createElement(MobileNativeChatComposer, { ...props, value: 'hello dictated text' })
)
})
await act(async () => {
resolveSend?.(true)
await pendingSend
})
expect(Keyboard.dismiss).not.toHaveBeenCalled()
})
it('does not dismiss after its retained route loses focus', async () => {
vi.mocked(Keyboard.dismiss).mockClear()
let generation = 0
let resolveSend: ((accepted: boolean) => void) | null = null
const onSend = vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveSend = resolve
})
)
await render(onSend, vi.fn(), false, 'tab-a', () => generation)
let pendingSend!: Promise<void>
await act(async () => {
pendingSend = sendButton().props.onPress()
await Promise.resolve()
})
generation += 1
await act(async () => {
resolveSend?.(true)
await pendingSend
})
expect(Keyboard.dismiss).not.toHaveBeenCalled()
})
})
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
Image,
Keyboard,
Pressable,
ScrollView,
StyleSheet,
@@ -36,6 +37,12 @@ type Props = {
value: string
onChangeText: (text: string) => void
onSend: (text: string) => Promise<boolean>
/** Changes whenever the route focuses a different chat composer surface. */
sendSurfaceId: string
/** Reads the retained route's focus generation without forcing a screen render. */
getSendCompletionGeneration: () => number
/** Reads user draft mutations owned above this renderable composer. */
getComposerEditGeneration: () => number
/** Active tab's agent — the slash autocomplete serves its command catalog. */
agent?: string | null
/** Model/session-option pickers shown in the composer action row; null when
@@ -63,6 +70,9 @@ export function MobileNativeChatComposer({
value,
onChangeText,
onSend,
sendSurfaceId,
getSendCompletionGeneration,
getComposerEditGeneration,
agent,
sessionOptions,
onAttachImage,
@@ -87,6 +97,15 @@ export function MobileNativeChatComposer({
null
)
const sendingRef = useRef(false)
const mountedRef = useRef(true)
const sendSurfaceIdRef = useRef(sendSurfaceId)
const sendSurfaceGenerationRef = useRef(0)
useLayoutEffect(() => {
if (sendSurfaceIdRef.current !== sendSurfaceId) {
sendSurfaceIdRef.current = sendSurfaceId
sendSurfaceGenerationRef.current += 1
}
}, [sendSurfaceId])
const [sending, setSending] = useState(false)
const trimmed = value.trim()
const sessionOptionDispatching = sessionOptions?.controller.pendingId != null
@@ -126,6 +145,14 @@ export function MobileNativeChatComposer({
}
}, [onNeedFiles, trigger?.kind, trigger?.query])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
sendSurfaceGenerationRef.current += 1
}
}, [])
const handleChange = (next: string): void => {
onChangeText(next)
}
@@ -150,12 +177,24 @@ export function MobileNativeChatComposer({
}
sendingRef.current = true
setSending(true)
const sendSurfaceGeneration = sendSurfaceGenerationRef.current
const sendCompletionGeneration = getSendCompletionGeneration()
const composerEditGeneration = getComposerEditGeneration()
try {
// Raw, not trimmed: the send seam owns the wire trim, and a rejection has
// to hand the user back exactly what they typed (#14819).
const accepted = await onSend(value)
if (accepted) {
if (
accepted &&
mountedRef.current &&
sendSurfaceGeneration === sendSurfaceGenerationRef.current &&
sendCompletionGeneration === getSendCompletionGeneration() &&
composerEditGeneration === getComposerEditGeneration()
) {
setCursor(0)
// Why: the turn is now the agent's — the keyboard would cover the reply.
// A rejected send keeps it up so the handed-back draft stays editable.
Keyboard.dismiss()
}
} finally {
sendingRef.current = false
@@ -51,6 +51,8 @@ function overlayElement(tick: Tick): ReturnType<typeof createElement> {
inputLockReason: null,
sendErrorMessage: null,
onClearSendError: vi.fn(),
sendSurfaceId: tick.identity ?? 'tab-a',
getSendCompletionGeneration: () => 0,
keyboardInset: 0
})
}
@@ -24,6 +24,10 @@ type Props = {
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
}
@@ -43,6 +47,8 @@ export function MobileNativeChatOverlay({
inputLockReason,
sendErrorMessage,
onClearSendError,
sendSurfaceId,
getSendCompletionGeneration,
keyboardInset
}: Props): React.JSX.Element | null {
const session = controller.nativeChatSession
@@ -81,6 +87,9 @@ export function MobileNativeChatOverlay({
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}
@@ -85,6 +85,8 @@ function chatViewElement(overrides: Overrides): ReturnType<typeof createElement>
status: 'ready',
streaming: null,
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: () => 0,
pending: [],
composerText: '',
onComposerTextChange: vi.fn(),
@@ -58,6 +58,12 @@ type Props = {
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
@@ -126,6 +132,9 @@ export function MobileNativeChatView({
loadingEarlier,
onLoadEarlier,
onSend,
sendSurfaceId,
getSendCompletionGeneration,
getComposerEditGeneration,
pending,
imagePreviewsByMessageId,
composerText,
@@ -431,6 +440,8 @@ export function MobileNativeChatView({
value={composerText}
onChangeText={onComposerTextChange}
onSend={handleSend}
sendSurfaceId={sendSurfaceId}
{...{ getSendCompletionGeneration, getComposerEditGeneration }}
agent={agent}
sessionOptions={sessionOptions}
onAttachImage={onAttachImage}
@@ -0,0 +1,302 @@
import { useState, useCallback, useEffect, useMemo } from 'react'
import {
ActivityIndicator,
FlatList,
Image,
Platform,
Pressable,
ScrollView,
Text,
View,
type ListRenderItem
} from 'react-native'
import { Copy, MessageSquare, Send } from 'lucide-react-native'
import { MobileHtmlPreview } from '../components/MobileHtmlPreview'
import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments'
import { colors } from '../theme/mobile-theme'
import {
buildPlainMobileDiffSyntaxLines,
highlightMobileCode,
highlightMobileDiffLines,
resolveMobileSyntaxLanguage
} from './mobile-file-syntax'
import { styles } from './mobile-session-styles'
import { MobileDiffCommentLineRow } from './MobileDiffCommentLineRow'
import type { DiffComment } from '../../../src/shared/diff-comment-types'
import type {
DiffCommentActions,
DiffSyntaxState,
FileDocState,
FileSyntaxState,
RenderableDiffLine
} from './mobile-session-route-types'
export function MobileSessionFileReader({
doc,
title,
relativePath,
language,
diffCommentActions
}: {
doc: FileDocState | undefined
title: string
relativePath: string
language?: string
diffCommentActions?: DiffCommentActions
}) {
const syntaxLanguage = useMemo(
() => resolveMobileSyntaxLanguage(relativePath || title, language),
[language, relativePath, title]
)
const [fileSyntax, setFileSyntax] = useState<FileSyntaxState | null>(null)
const [diffSyntax, setDiffSyntax] = useState<DiffSyntaxState | null>(null)
const [activeCommentLine, setActiveCommentLine] = useState<number | null>(null)
const [commentDraft, setCommentDraft] = useState('')
const plainDiffLines = useMemo(
() =>
doc?.status === 'ready' && doc.kind === 'diff'
? buildPlainMobileDiffSyntaxLines(doc.lines)
: [],
[doc]
)
const diffCommentsForFile = useMemo(
() =>
diffCommentActions?.comments.filter(
(comment) => comment.filePath === relativePath && comment.source !== 'markdown'
) ?? [],
[diffCommentActions?.comments, relativePath]
)
const diffCommentsByLine = useMemo(() => {
const map = new Map<number, DiffComment[]>()
for (const comment of diffCommentsForFile) {
const list = map.get(comment.lineNumber) ?? []
list.push(comment)
map.set(comment.lineNumber, list)
}
for (const list of map.values()) {
list.sort((a, b) => a.createdAt - b.createdAt)
}
return map
}, [diffCommentsForFile])
const startComment = useCallback((lineNumber: number) => {
setActiveCommentLine(lineNumber)
setCommentDraft('')
}, [])
const cancelComment = useCallback(() => {
setActiveCommentLine(null)
setCommentDraft('')
}, [])
const submitComment = useCallback(
(lineNumber: number) => {
if (!diffCommentActions) {
return
}
void diffCommentActions.onAdd(relativePath, lineNumber, commentDraft).then((added) => {
if (added) {
setActiveCommentLine(null)
setCommentDraft('')
}
})
},
[commentDraft, diffCommentActions, relativePath]
)
const renderDiffLine: ListRenderItem<RenderableDiffLine> = useCallback(
({ item, index }) => (
<MobileDiffCommentLineRow
line={item}
title={title}
index={index}
comments={
item.newLineNumber !== undefined ? (diffCommentsByLine.get(item.newLineNumber) ?? []) : []
}
activeCommentLine={activeCommentLine}
commentDraft={commentDraft}
commentsBusy={diffCommentActions?.busy === true}
onStartComment={startComment}
onCancelComment={cancelComment}
onDraftChange={setCommentDraft}
onSubmitComment={submitComment}
onDeleteComment={(commentId) => {
if (diffCommentActions) {
void diffCommentActions.onDelete(commentId)
}
}}
/>
),
[
activeCommentLine,
cancelComment,
commentDraft,
diffCommentActions,
diffCommentsByLine,
startComment,
submitComment,
title
]
)
useEffect(() => {
if (doc?.status !== 'ready') {
return undefined
}
// Why: defer highlighting one tick so large files show as plain text immediately before colors are applied.
const timer = setTimeout(() => {
// file + html share the syntax-segment source view (html's "Source" toggle).
if (doc.kind === 'file' || doc.kind === 'html') {
setFileSyntax({
doc,
language: syntaxLanguage,
segments: highlightMobileCode(doc.content, syntaxLanguage).segments
})
return
}
if (doc.kind === 'diff') {
setDiffSyntax({
doc,
language: syntaxLanguage,
lines: highlightMobileDiffLines(doc.lines, syntaxLanguage)
})
}
// image: no syntax highlighting.
}, 0)
return () => clearTimeout(timer)
}, [doc, syntaxLanguage])
if (!doc || doc.status === 'loading') {
return (
<View style={styles.markdownState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
)
}
if (doc.status === 'error') {
return (
<View style={styles.markdownState}>
<Text style={styles.markdownError}>{doc.message}</Text>
</View>
)
}
if (doc.kind === 'diff') {
const activeDiffSyntax =
diffSyntax?.doc === doc && diffSyntax.language === syntaxLanguage ? diffSyntax.lines : null
const commentCount = diffCommentActions?.comments.length ?? 0
const unsentCommentCount =
diffCommentActions?.comments.filter((comment) => !comment.sentAt).length ?? 0
const commentsBusy = diffCommentActions?.busy === true
const canCopyNotes = commentCount > 0 && !commentsBusy
const canSendNotes = unsentCommentCount > 0 && !commentsBusy
return (
<View style={styles.markdownEditor}>
{diffCommentActions ? (
<View style={styles.diffNotesToolbar}>
<View style={styles.diffNotesTitleRow}>
<MessageSquare size={14} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.diffNotesTitle}>
{commentCount === 0
? 'No review notes'
: `${commentCount} review ${commentCount === 1 ? 'note' : 'notes'}`}
</Text>
</View>
<View style={styles.diffNotesActions}>
<Pressable
style={[
styles.diffNotesActionButton,
!canCopyNotes && styles.diffCommentButtonDisabled
]}
disabled={!canCopyNotes}
onPress={() => void diffCommentActions.onCopyAll()}
accessibilityLabel="Copy review notes"
>
<Copy size={13} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.diffNotesActionText}>Copy</Text>
</Pressable>
<Pressable
style={[
styles.diffNotesActionButton,
!canSendNotes && styles.diffCommentButtonDisabled
]}
disabled={!canSendNotes}
onPress={diffCommentActions.onSendAll}
accessibilityLabel="Send review notes to AI"
>
<Send size={13} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.diffNotesActionText}>Send</Text>
</Pressable>
</View>
</View>
) : null}
<FlatList
data={activeDiffSyntax ?? plainDiffLines}
style={styles.filePreviewScroll}
contentContainerStyle={styles.filePreviewContent}
keyExtractor={(line, index) =>
`${index}:${line.kind}:${line.oldLineNumber ?? ''}:${line.newLineNumber ?? ''}`
}
renderItem={renderDiffLine}
initialNumToRender={32}
maxToRenderPerBatch={48}
windowSize={7}
removeClippedSubviews={Platform.OS !== 'web'}
keyboardShouldPersistTaps="handled"
/>
</View>
)
}
if (doc.kind === 'image') {
return (
<View style={styles.imagePreviewContainer}>
<ScrollView
style={styles.imagePreviewScroll}
contentContainerStyle={styles.imagePreviewContent}
maximumZoomScale={4}
minimumZoomScale={1}
centerContent
>
<Image
source={{ uri: doc.dataUri }}
style={styles.imagePreview}
resizeMode="contain"
accessibilityLabel={`${title} image`}
/>
</ScrollView>
</View>
)
}
const renderSourceText = (content: string) => (
<View style={styles.markdownEditor}>
<ScrollView
style={styles.filePreviewScroll}
contentContainerStyle={styles.filePreviewContent}
>
<Text selectable style={styles.filePreviewText} accessibilityLabel={`${title} preview`}>
<MobileSyntaxSegments
segments={
fileSyntax?.doc === doc && fileSyntax.language === syntaxLanguage
? fileSyntax.segments
: [{ text: content, kind: 'plain' }]
}
/>
</Text>
</ScrollView>
</View>
)
if (doc.kind === 'html') {
return (
<View style={styles.markdownEditor}>
<MobileHtmlPreview html={doc.content} renderSource={() => renderSourceText(doc.content)} />
</View>
)
}
return renderSourceText(doc.content)
}
@@ -0,0 +1,175 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const sessionRouteSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
)
const bufferedDraftHookSource = readFileSync(
new URL('../terminal/use-buffered-terminal-drafts.ts', import.meta.url),
'utf8'
)
const keyboardDismissalHookSource = readFileSync(
new URL('./use-agent-send-keyboard-dismissal.ts', import.meta.url),
'utf8'
)
function routeSlice(anchorStart: string, anchorEnd: string): string {
const start = sessionRouteSource.indexOf(anchorStart)
expect(start).toBeGreaterThanOrEqual(0)
// Why: a duplicated start anchor would silently slice the wrong region.
expect(sessionRouteSource.indexOf(anchorStart, start + 1)).toBe(-1)
const end = sessionRouteSource.indexOf(anchorEnd, start)
expect(end).toBeGreaterThan(start)
return sessionRouteSource.slice(start, end + anchorEnd.length)
}
describe('terminal send keyboard dismissal wiring', () => {
it('gates the dismissal on the agent-session predicate', () => {
const slice = routeSlice(
'const dismissKeyboardAfterAgentSend = useAgentSendKeyboardDismissal(',
'getSendCompletionGeneration\n )'
)
expect(slice).toContain('dismissSoftwareKeyboard')
expect(keyboardDismissalHookSource).toContain(
'shouldDismissKeyboardAfterTerminalSend(origin.tab, accepted)'
)
expect(keyboardDismissalHookSource).toContain(
'origin.generation === getSendCompletionGeneration()'
)
expect(keyboardDismissalHookSource).toContain('dismissSoftwareKeyboard()')
expect(keyboardDismissalHookSource).toContain('return useCallback(')
expect(sessionRouteSource).toContain(
"import { useAgentSendKeyboardDismissal } from '../../../../src/session/use-agent-send-keyboard-dismissal'"
)
})
it('invalidates pending terminal sends when the focused input surface changes', () => {
const slice = routeSlice(
'const getSendCompletionGeneration = useMobileSendCompletionGeneration({',
'})'
)
expect(sessionRouteSource).toContain(
'const routeKey = nativeChatScopeKey ?? `${hostId}\\0${worktreeId}`'
)
expect(slice).toContain(
'surfaceKey: JSON.stringify([routeKey, activeHandle, showNativeChat, liveInputEnabled])'
)
})
it('dismisses after the live input submits, which is the only Enter path', () => {
// terminal-live-input.ts deliberately keeps Enter off the key map, so
// onSubmitEditing is the single send seam for the live field.
const slice = routeSlice('ref={liveInputRef}', 'importantForAutofill="no"')
expect(slice).toContain('generation: getSendCompletionGeneration()')
expect(slice).toContain('const submit = handleLiveInputSubmit()')
expect(slice).toContain('interaction: getLiveInteractionGeneration()')
expect(slice).toContain('sendOrigin.interaction === getLiveInteractionGeneration()')
expect(slice).toContain('dismissKeyboardAfterAgentSend(')
// Explicit dismissal replaces RN's blur, which stays off so a shell send
// does not drop focus.
expect(slice).toContain('blurOnSubmit={false}')
})
it('dismisses the buffered command send only once the write is accepted', () => {
const slice = routeSlice('async function handleSend() {', 'async function handleAccessoryKey(')
const acceptedAt = slice.indexOf('const accepted = isTerminalSendRpcAccepted(response)')
const restoreAt = slice.indexOf('restoreRejectedDraft()', acceptedAt)
const dismissAt = slice.indexOf('dismissKeyboardAfterAgentSend(')
const responseAt = slice.indexOf('const response = await client.sendRequest(')
const catchAt = slice.indexOf('} catch {')
expect(dismissAt).toBeGreaterThan(0)
expect(responseAt).toBeGreaterThan(0)
expect(acceptedAt).toBeGreaterThan(responseAt)
expect(restoreAt).toBeGreaterThan(acceptedAt)
expect(dismissAt).toBeGreaterThan(responseAt)
expect(slice).toContain(
'const draftUnchanged =\n accepted && bufferedTerminalDraftState.settleBufferedTerminalDraftSend(bufferedDraftSend)'
)
expect(slice).toContain('dismissKeyboardAfterAgentSend(sendOrigin, accepted && draftUnchanged)')
expect(catchAt).toBeGreaterThan(0)
// Both resolved rejections and transport failures restore the raw draft.
expect(dismissAt).toBeLessThan(catchAt)
expect(slice.slice(catchAt)).not.toContain('dismissKeyboardAfterAgentSend(')
expect(slice.slice(catchAt)).toContain('restoreRejectedDraft()')
})
it('keeps buffered Return focused until accepted-agent dismissal runs', () => {
const slice = routeSlice('ref={commandInputRef}', 'onSubmitEditing={() => void handleSend()}')
expect(slice).toContain('blurOnSubmit={false}')
})
it('restores a rejected buffered draft by origin without generation fencing', () => {
const sendSlice = routeSlice(
'async function handleSend() {',
'async function handleAccessoryKey('
)
const originAt = sendSlice.indexOf('handle: activeHandle')
const requestAt = sendSlice.indexOf('await client.sendRequest(')
const restoreSlice = routeSlice(
'const bufferedDraftSend = bufferedTerminalDraftState.beginBufferedTerminalDraftSend(',
'bufferedTerminalDraftState.restoreRejectedDraft(bufferedDraftSend)'
)
expect(originAt).toBeGreaterThan(0)
expect(originAt).toBeLessThan(requestAt)
expect(restoreSlice).toContain('activeHandle,\n draft')
expect(bufferedDraftHookSource).not.toContain('getSendCompletionGeneration()')
expect(bufferedDraftHookSource).toContain(
'restoreRejectedBufferedTerminalDraft(current, send.token.handle, send.draft)'
)
expect(sendSlice.match(/restoreRejectedDraft\(\)/g)).toHaveLength(2)
expect(keyboardDismissalHookSource).toContain(
'origin.generation === getSendCompletionGeneration()'
)
})
it('keeps buffered draft callbacks scoped to terminal surfaces and prunes ended tabs', () => {
expect(bufferedDraftHookSource).toContain('const handle = activeHandleRef.current')
expect(bufferedDraftHookSource).toContain('invalidateBufferedTerminalDraftRestoration(')
expect(bufferedDraftHookSource).toContain(
'setDrafts((current) => updateBufferedTerminalDraft(current, handle, value))'
)
expect(bufferedDraftHookSource).toContain('pruneBufferedTerminalDraftRestorations(')
expect(sessionRouteSource).toContain('useRef(bufferedTerminalDraftState.reconcileTerminalTabs)')
expect(sessionRouteSource).toContain(
'reconcileBufferedDraftsRef.current(currentSessionTabs, nextTabs, {'
)
const routeResetSlice = routeSlice(
'// Why: Expo reuses this screen across worktrees;',
'clearDelayedActionTimers()\n }'
)
expect(routeResetSlice).toContain('bufferedTerminalDraftState.resetDrafts()')
expect(routeResetSlice).toContain('bufferedTerminalDraftState.clearPendingRestorations()')
})
it('bounds buffered drafts on the terminal.list sweep, against the retained set', () => {
// The drafts record and the pending-restoration map both live as long as the
// session screen does; this one call is the only thing that bounds either.
const slice = routeSlice(
'const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle))',
'setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune))'
)
expect(slice).toContain('const retainedHandles = resolveRetainedTerminalHandles(pruneContext)')
expect(slice).toContain('bufferedTerminalDraftState.pruneDrafts(retainedHandles)')
// Not the raw list: terminal.list omits a chat-covered handle while the desktop
// graph reloads, so sweeping drafts against it erases text the user still holds.
expect(slice).not.toContain('pruneDrafts(liveHandles)')
expect(slice.match(/pruneDrafts\(/g)).toHaveLength(1)
expect(bufferedDraftHookSource).toContain(
'setDrafts((current) => pruneBufferedTerminalDrafts(current, retainedMappedHandles))'
)
expect(bufferedDraftHookSource).toContain(
'pruneBufferedTerminalDraftRestorations(pendingRestorationsRef.current, retainedMappedHandles)'
)
})
it('leaves the accessory shortcut keys alone, Enter included', () => {
// Why: the accessory bar sits on top of the keyboard — dismissing would
// pull away the very row the user is tapping.
const slice = routeSlice(
'async function handleAccessoryKey(',
'const sendLiveTerminalInput = useCallback('
)
expect(slice).not.toContain('dismissKeyboardAfterAgentSend')
})
})
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import {
shouldDismissKeyboardAfterTerminalSend,
type AgentSendKeyboardDismissalTab
} from './agent-send-keyboard-dismissal'
function terminalTab(overrides: Partial<AgentSendKeyboardDismissalTab> = {}) {
return { type: 'terminal', title: 'Terminal', ...overrides }
}
describe('shouldDismissKeyboardAfterTerminalSend', () => {
it('dismisses for a live agent session', () => {
expect(
shouldDismissKeyboardAfterTerminalSend(
terminalTab({ agentStatus: { agentType: 'claude' } }),
true
)
).toBe(true)
})
it('dismisses off launchAgent before the first agent-status update lands', () => {
expect(
shouldDismissKeyboardAfterTerminalSend(terminalTab({ launchAgent: 'codex' }), true)
).toBe(true)
})
it('keeps the keyboard when an agent send is rejected', () => {
expect(
shouldDismissKeyboardAfterTerminalSend(
terminalTab({ agentStatus: { agentType: 'claude' } }),
false
)
).toBe(false)
})
it('keeps the keyboard for a plain shell so back-to-back commands stay typeable', () => {
expect(shouldDismissKeyboardAfterTerminalSend(terminalTab(), true)).toBe(false)
expect(shouldDismissKeyboardAfterTerminalSend(terminalTab({ agentStatus: null }), true)).toBe(
false
)
})
it('treats a blank agent label as no agent', () => {
// A truthy-empty agentType would otherwise dismiss on every shell Enter.
expect(
shouldDismissKeyboardAfterTerminalSend(terminalTab({ agentStatus: { agentType: '' } }), true)
).toBe(false)
expect(
shouldDismissKeyboardAfterTerminalSend(
terminalTab({ agentStatus: { agentType: ' ' } }),
true
)
).toBe(false)
expect(
shouldDismissKeyboardAfterTerminalSend(
terminalTab({
agentStatus: { agentType: null },
launchAgent: null
}),
true
)
).toBe(false)
})
it('falls through to launchAgent only when live status carries no agent', () => {
expect(
shouldDismissKeyboardAfterTerminalSend(
terminalTab({
agentStatus: { agentType: null },
launchAgent: 'claude'
}),
true
)
).toBe(true)
})
it('never dismisses for non-terminal tabs or a missing tab', () => {
expect(
shouldDismissKeyboardAfterTerminalSend(
{
type: 'markdown',
title: 'README.md',
agentStatus: { agentType: 'claude' }
},
true
)
).toBe(false)
expect(shouldDismissKeyboardAfterTerminalSend(null, true)).toBe(false)
expect(shouldDismissKeyboardAfterTerminalSend(undefined, true)).toBe(false)
})
it('does not authorize dismissal from unknown status or a display-only title', () => {
expect(
shouldDismissKeyboardAfterTerminalSend(
terminalTab({ agentStatus: { agentType: 'unknown' } }),
true
)
).toBe(false)
expect(
shouldDismissKeyboardAfterTerminalSend(terminalTab({ title: '✦ Gemini CLI' }), true)
).toBe(false)
})
it.each(['zsh', 'bash', 'pwsh'])(
'keeps the keyboard when identity-only done status is stale under %s',
(title) => {
expect(
shouldDismissKeyboardAfterTerminalSend(
terminalTab({
title,
agentStatus: { agentType: 'claude', state: 'done' },
launchAgent: 'claude'
}),
true
)
).toBe(false)
}
)
it('still dismisses for a completed agent under a non-shell title', () => {
expect(
shouldDismissKeyboardAfterTerminalSend(
terminalTab({
title: 'Terminal',
agentStatus: { agentType: 'claude', state: 'done' }
}),
true
)
).toBe(true)
})
})
@@ -0,0 +1,39 @@
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import type { TuiAgent } from '../../../src/shared/tui-agent'
import { isClaudeManagementTitle } from '../../../src/shared/agent-title-core'
import { isShellProcess } from '../../../src/shared/shell-process-detection'
import { resolveMobileTerminalTabOwnedAgentId } from './mobile-terminal-tab-agent'
/** Minimal session-tab shape needed to tell an agent session from a plain shell. */
export type AgentSendKeyboardDismissalTab = {
readonly type: string
readonly title: string
readonly agentStatus?: {
readonly agentType?: AgentStatusEntry['agentType'] | null
readonly state?: AgentStatusEntry['state']
} | null
readonly launchAgent?: TuiAgent | null
}
/** Whether a send from this tab should drop the software keyboard.
*
* Why: sending to an agent hands the turn over, and the keyboard hides the
* reply the user is now waiting on. A plain shell keeps it — commands come in
* bursts, and re-opening the keyboard between each one costs more than the
* covered rows. `launchAgent` counts before the first agent-status update
* lands, so the very first accepted prompt of a session already dismisses. */
export function shouldDismissKeyboardAfterTerminalSend(
tab: AgentSendKeyboardDismissalTab | null | undefined,
accepted: boolean
): boolean {
if (!accepted || !tab || tab.type !== 'terminal') {
return false
}
if (
tab.agentStatus?.state === 'done' &&
(isShellProcess(tab.title) || isClaudeManagementTitle(tab.title))
) {
return false
}
return resolveMobileTerminalTabOwnedAgentId(tab) !== null
}
@@ -21,6 +21,7 @@ export type MobileNativeChatController = {
nativeChatAgent: string | null
chatComposerText: string
setChatComposerText: Dispatch<SetStateAction<string>>
getChatComposerEditGeneration: () => number
chatPending: MobileNativeChatPendingMessage[]
chatImagePreviewsByMessageId: Record<string, string[]>
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
@@ -0,0 +1,19 @@
export class MobileNativeChatDraftEditGenerations {
private composerGeneration = 0
private readonly byDraft = new Map<string, number>()
advance(draftKey: string): void {
this.composerGeneration += 1
this.byDraft.set(draftKey, this.readDraft(draftKey) + 1)
}
readonly readComposer = (): number => this.composerGeneration
readDraft(draftKey: string): number {
return this.byDraft.get(draftKey) ?? 0
}
isCurrent(draftKey: string, generation: number): boolean {
return this.readDraft(draftKey) === generation
}
}
@@ -20,6 +20,7 @@ function sendOrigin(
const normalizedText = normalizeReconcileText(text)
return {
draftKey: 'host\0worktree\0tab',
draftEditGeneration: 0,
pendingKey: 'host\0worktree\0tab\0session',
normalizedText,
// Use production's counter so the test cannot mirror its normalization drift.
@@ -16,6 +16,7 @@ export type MobileNativeChatPendingMessage = {
export type MobileNativeChatSendOrigin = {
draftKey: string
draftEditGeneration: number
pendingKey: string | null
normalizedText: string
baselineOccurrences: number
@@ -13,6 +13,7 @@ describe('mobile session last-tab close', () => {
const block = sessionRouteSource.slice(start, end)
expect(block).toContain('} else if (active) {')
expect(block).toContain('retainMissingSurfaces: result.tabs.length === 0')
})
it('clears stale active identity when closing leaves no tabs', () => {
@@ -25,5 +26,8 @@ describe('mobile session last-tab close', () => {
)
expect(block).toContain('activeSessionTabIdRef.current = null')
expect(block).toContain('activeHandleRef.current = null')
expect(block).toContain(
'reconcileBufferedDraftsRef.current(sessionTabsRef.current, remainingTabs)'
)
})
})
@@ -4,7 +4,8 @@ import type { TuiAgent } from '../../../src/shared/tui-agent'
import type { MobileSessionTab } from './mobile-session-route-types'
import {
getMobileSessionTabTitle,
resolveMobileTerminalTabAgentId
resolveMobileTerminalTabAgentId,
resolveMobileTerminalTabOwnedAgentId
} from './mobile-terminal-tab-agent'
function agentStatus(agentType: string | undefined): AgentStatusEntry {
@@ -75,6 +76,15 @@ describe('resolveMobileTerminalTabAgentId', () => {
})
})
describe('resolveMobileTerminalTabOwnedAgentId', () => {
it('excludes display-only terminal titles from behavioral authority', () => {
expect(resolveMobileTerminalTabOwnedAgentId(terminalTab('✦ Gemini CLI'))).toBeNull()
expect(
resolveMobileTerminalTabOwnedAgentId(terminalTab('Terminal', { launchAgent: 'gemini' }))
).toBe('gemini')
})
})
describe('getMobileSessionTabTitle', () => {
it('strips leading agent decorations when an icon is shown', () => {
expect(getMobileSessionTabTitle(terminalTab('✦ Gemini CLI'))).toBe('Gemini CLI')
@@ -15,11 +15,16 @@ import type { MobileSessionTab } from './mobile-session-route-types'
* Returns null when no agent is identified (plain shell / unknown), so the tab
* keeps its text-only label.
*/
export function resolveMobileTerminalTabAgentId(tab: {
type MobileTerminalTabAgentIdentity = {
title: string
agentStatus?: AgentStatusEntry | null
launchAgent?: TuiAgent
}): string | null {
agentStatus?: { agentType?: AgentStatusEntry['agentType'] | null } | null
launchAgent?: TuiAgent | null
}
/** Agent identity Orca owns, excluding the display-only title fallback. */
export function resolveMobileTerminalTabOwnedAgentId(
tab: MobileTerminalTabAgentIdentity
): string | null {
const hookAgentType = tab.agentStatus?.agentType?.trim()
if (hookAgentType && hookAgentType !== 'unknown') {
return hookAgentType
@@ -27,6 +32,16 @@ export function resolveMobileTerminalTabAgentId(tab: {
if (tab.launchAgent) {
return tab.launchAgent
}
return null
}
export function resolveMobileTerminalTabAgentId(
tab: MobileTerminalTabAgentIdentity
): string | null {
const ownedAgent = resolveMobileTerminalTabOwnedAgentId(tab)
if (ownedAgent) {
return ownedAgent
}
return resolveExplicitTerminalTitleAgentType(tab.title)
}
@@ -0,0 +1,27 @@
import { useCallback } from 'react'
import {
shouldDismissKeyboardAfterTerminalSend,
type AgentSendKeyboardDismissalTab
} from './agent-send-keyboard-dismissal'
type AgentSendOrigin = {
readonly tab: AgentSendKeyboardDismissalTab | null
readonly generation: number
}
export function useAgentSendKeyboardDismissal(
dismissSoftwareKeyboard: () => void,
getSendCompletionGeneration: () => number
) {
return useCallback(
(origin: AgentSendOrigin, accepted: boolean): void => {
if (
origin.generation === getSendCompletionGeneration() &&
shouldDismissKeyboardAfterTerminalSend(origin.tab, accepted)
) {
dismissSoftwareKeyboard()
}
},
[dismissSoftwareKeyboard, getSendCompletionGeneration]
)
}
@@ -93,6 +93,7 @@ const sendWithOutcome = vi.mocked(sendMobileNativeChatMessageWithOutcome)
const ORIGIN = {
draftKey: 'h\0w\0tab-1',
draftEditGeneration: 0,
pendingKey: 'h\0w\0tab-1\0session-1',
normalizedText: 'look',
baselineOccurrences: 0,
@@ -90,6 +90,7 @@ export function useMobileNativeChatController(args: {
const {
composerText: chatComposerText,
setComposerText: setChatComposerText,
getComposerEditGeneration: getChatComposerEditGeneration,
pending: chatPending,
imagePreviewsByMessageId: chatImagePreviewsByMessageId,
captureSendOrigin,
@@ -262,6 +263,7 @@ export function useMobileNativeChatController(args: {
nativeChatAgent: activeChatResolution?.agent ?? null,
chatComposerText,
setChatComposerText,
getChatComposerEditGeneration,
chatPending,
chatImagePreviewsByMessageId,
nativeChatSession,
@@ -115,6 +115,20 @@ describe('useMobileNativeChatDrafts', () => {
expect(state?.composerText).toBe('')
})
it('tracks every composer mutation with a stable route-owned generation', async () => {
await mount('a')
const getter = state!.getComposerEditGeneration
const initialGeneration = getter()
act(() => state?.setComposerText('typed'))
expect(getter()).toBe(initialGeneration + 1)
await switchTo('b')
expect(state?.getComposerEditGeneration).toBe(getter)
act(() => state?.setComposerText((current) => `${current} dictated`))
expect(getter()).toBe(initialGeneration + 2)
})
it('restores the text on a definite rejection', async () => {
await mount('a')
act(() => state?.setComposerText('ping'))
@@ -146,6 +160,26 @@ describe('useMobileNativeChatDrafts', () => {
expect(state?.composerText).toBe('newer edit')
})
it('preserves an intentional clear after a newer edit while a rejection is pending', async () => {
await mount('a')
act(() => state?.setComposerText('ping'))
const origin = state?.captureSendOrigin('ping')
act(() => {
if (origin) {
state?.clearDraftForSend(origin, 'ping')
}
})
act(() => state?.setComposerText('newer edit'))
act(() => state?.setComposerText(''))
act(() => {
if (origin) {
state?.restoreRejectedDraft(origin, 'ping')
}
})
expect(state?.composerText).toBe('')
})
it('restores a rejected send onto its originating tab only', async () => {
await mount('a')
act(() => state?.setComposerText('from a'))
@@ -157,12 +191,13 @@ describe('useMobileNativeChatDrafts', () => {
})
await switchTo('b')
act(() => state?.setComposerText('from b'))
act(() => {
if (originA) {
state?.restoreRejectedDraft(originA, 'from a')
}
})
expect(state?.composerText).toBe('')
expect(state?.composerText).toBe('from b')
await switchTo('a')
expect(state?.composerText).toBe('from a')
@@ -443,6 +478,20 @@ describe('useMobileNativeChatDrafts', () => {
expect(state?.composerText).toBe('new edit')
})
it('does not erase a whitespace-only newer edit when an older send clears', async () => {
await mount('a')
act(() => state?.setComposerText('/clear'))
const origin = state?.captureSendOrigin('/clear')
act(() => state?.setComposerText(' /clear'))
act(() => {
if (origin) {
state?.clearDraftForSend(origin, '/clear')
}
})
expect(state?.composerText).toBe(' /clear')
})
it('stays quiet when an unconfirmed send lands in the transcript', async () => {
vi.useFakeTimers()
try {
@@ -22,6 +22,7 @@ import {
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
import { MobileNativeChatDraftEditGenerations } from './mobile-native-chat-draft-edit-generations'
export type { MobileNativeChatPendingMessage, MobileNativeChatSendOrigin }
@@ -54,6 +55,7 @@ export function useMobileNativeChatDrafts(args: {
}): {
composerText: string
setComposerText: Dispatch<SetStateAction<string>>
getComposerEditGeneration: () => number
pending: MobileNativeChatPendingMessage[]
/** Phone-local previews rebound to the transcript message that replaced the
* optimistic echo, keyed by authoritative message id. */
@@ -100,6 +102,7 @@ export function useMobileNativeChatDrafts(args: {
Record<string, Record<string, string[]>>
>({})
const pendingCounterRef = useRef(0)
const draftEditGenerationsRef = useRef(new MobileNativeChatDraftEditGenerations())
const messagesRef = useRef(messages)
messagesRef.current = messages
const activeDraftKeyRef = useRef(draftKey)
@@ -123,6 +126,7 @@ export function useMobileNativeChatDrafts(args: {
if (!draftKey) {
return
}
draftEditGenerationsRef.current.advance(draftKey)
setDrafts((previous) => {
const current = previous[draftKey] ?? ''
const next = typeof value === 'function' ? value(current) : value
@@ -131,7 +135,6 @@ export function useMobileNativeChatDrafts(args: {
},
[draftKey]
)
const captureSendOrigin = useCallback(
(text: string) => {
if (!draftKey) {
@@ -140,6 +143,7 @@ export function useMobileNativeChatDrafts(args: {
const normalizedText = normalizeReconcileText(text)
return {
draftKey,
draftEditGeneration: draftEditGenerationsRef.current.readDraft(draftKey),
pendingKey,
normalizedText,
baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText),
@@ -158,7 +162,8 @@ export function useMobileNativeChatDrafts(args: {
// send". Clear at send time; a definite rejection restores the text below.
const clearDraftForSend = useCallback((origin: MobileNativeChatSendOrigin, text: string) => {
setDrafts((previous) =>
(previous[origin.draftKey] ?? '').trim() === text.trim()
draftEditGenerationsRef.current.isCurrent(origin.draftKey, origin.draftEditGeneration) &&
(previous[origin.draftKey] ?? '') === text
? { ...previous, [origin.draftKey]: '' }
: previous
)
@@ -167,7 +172,10 @@ export function useMobileNativeChatDrafts(args: {
const restoreRejectedDraft = useCallback((origin: MobileNativeChatSendOrigin, text: string) => {
// Why: never clobber text the user typed while the rejection was in flight.
setDrafts((previous) =>
(previous[origin.draftKey] ?? '') === '' ? { ...previous, [origin.draftKey]: text } : previous
draftEditGenerationsRef.current.isCurrent(origin.draftKey, origin.draftEditGeneration) &&
(previous[origin.draftKey] ?? '') === ''
? { ...previous, [origin.draftKey]: text }
: previous
)
}, [])
@@ -269,9 +277,7 @@ export function useMobileNativeChatDrafts(args: {
return
}
const movedIds = new Set(waitingForSession.map((item) => item.id))
setPendingBySession((previous) =>
mergeWaitingSessionPending(previous, pendingKey, waitingForSession)
)
setPendingBySession((state) => mergeWaitingSessionPending(state, pendingKey, waitingForSession))
setPendingWaitingForSession((previous) =>
removeWaitingSessionPending(previous, draftKey, movedIds)
)
@@ -330,6 +336,7 @@ export function useMobileNativeChatDrafts(args: {
return {
composerText: draftKey ? (drafts[draftKey] ?? '') : '',
setComposerText,
getComposerEditGeneration: draftEditGenerationsRef.current.readComposer,
pending,
imagePreviewsByMessageId: pendingKey
? (imagePreviewsBySession[pendingKey] ?? NO_IMAGE_PREVIEWS)
@@ -0,0 +1,78 @@
import { createElement, Suspense } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import { useMobileSendCompletionGeneration } from './use-mobile-send-completion-generation'
let focusCleanup: (() => void) | undefined
vi.mock('expo-router', () => ({
useFocusEffect: (effect: () => void | (() => void)) => {
focusCleanup = effect() ?? undefined
}
}))
describe('mobile send completion generation', () => {
it('invalidates completions on a surface change and retained-route blur', () => {
const onBlur = vi.fn()
let getGeneration: (() => number) | null = null
let renderer: ReactTestRenderer
function Harness({ surfaceKey }: { surfaceKey: string }): null {
getGeneration = useMobileSendCompletionGeneration({ onBlur, surfaceKey })
return null
}
act(() => {
renderer = create(createElement(Harness, { surfaceKey: 'tab-a' }))
})
const initialGeneration = getGeneration!()
act(() => {
renderer.update(createElement(Harness, { surfaceKey: 'tab-b' }))
})
expect(getGeneration!()).toBeGreaterThan(initialGeneration)
const surfaceGeneration = getGeneration!()
act(() => focusCleanup?.())
expect(getGeneration!()).toBeGreaterThan(surfaceGeneration)
expect(onBlur).toHaveBeenCalledTimes(1)
act(() => renderer.unmount())
})
it('does not invalidate the committed surface during a suspended render', () => {
const never = new Promise<never>(() => undefined)
let getGeneration: (() => number) | null = null
let renderer: ReactTestRenderer
function Harness({ surfaceKey, suspend }: { surfaceKey: string; suspend: boolean }): null {
getGeneration = useMobileSendCompletionGeneration({ onBlur: vi.fn(), surfaceKey })
if (suspend) {
throw never
}
return null
}
act(() => {
renderer = create(
createElement(
Suspense,
{ fallback: null },
createElement(Harness, { surfaceKey: 'tab-a', suspend: false })
)
)
})
const committedGeneration = getGeneration!()
act(() => {
renderer.update(
createElement(
Suspense,
{ fallback: null },
createElement(Harness, { surfaceKey: 'tab-b', suspend: true })
)
)
})
expect(getGeneration!()).toBe(committedGeneration)
act(() => renderer.unmount())
})
})
@@ -0,0 +1,28 @@
import { useCallback, useLayoutEffect, useRef } from 'react'
import { useFocusEffect } from 'expo-router'
type Options = {
readonly onBlur: () => void
readonly surfaceKey: string
}
/** Fences async send completions after a surface change or retained-route blur. */
export function useMobileSendCompletionGeneration({ onBlur, surfaceKey }: Options): () => number {
const generationRef = useRef(0)
const surfaceRef = useRef(surfaceKey)
useLayoutEffect(() => {
if (surfaceRef.current !== surfaceKey) {
surfaceRef.current = surfaceKey
generationRef.current += 1
}
}, [surfaceKey])
useFocusEffect(
useCallback(() => {
return () => {
generationRef.current += 1
onBlur()
}
}, [onBlur])
)
return useCallback(() => generationRef.current, [])
}
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import {
beginBufferedTerminalDraftRestoration,
invalidateBufferedTerminalDraftRestoration,
pruneBufferedTerminalDrafts,
pruneBufferedTerminalDraftRestorations,
restoreRejectedBufferedTerminalDraft,
settleBufferedTerminalDraftRestoration,
updateBufferedTerminalDraft
} from './buffered-terminal-draft-restoration'
describe('buffered terminal draft restoration', () => {
it('restores the exact rejected draft when the composer is still empty', () => {
const pendingRestorations = new Map()
const token = beginBufferedTerminalDraftRestoration(pendingRestorations, 'terminal')
expect(settleBufferedTerminalDraftRestoration(pendingRestorations, 'terminal', token)).toBe(
true
)
expect(
restoreRejectedBufferedTerminalDraft({ terminal: '' }, 'terminal', ' echo ab ')
).toEqual({ terminal: ' echo ab ' })
})
it('preserves newer text composed while the rejected send was in flight', () => {
const drafts = { terminal: 'next command' }
const pendingRestorations = new Map()
const token = beginBufferedTerminalDraftRestoration(pendingRestorations, 'terminal')
invalidateBufferedTerminalDraftRestoration(pendingRestorations, 'terminal')
expect(settleBufferedTerminalDraftRestoration(pendingRestorations, 'terminal', token)).toBe(
false
)
expect(restoreRejectedBufferedTerminalDraft(drafts, 'terminal', 'rejected command')).toBe(
drafts
)
})
it('clears restoration metadata when an accepted send settles', () => {
const pendingRestorations = new Map()
const token = beginBufferedTerminalDraftRestoration(pendingRestorations, 'terminal')
expect(settleBufferedTerminalDraftRestoration(pendingRestorations, 'terminal', token)).toBe(
true
)
expect(settleBufferedTerminalDraftRestoration(pendingRestorations, 'terminal', token)).toBe(
false
)
})
it('preserves a later intentional clear while the rejected send was in flight', () => {
const terminal = 'terminal'
const rejectedDraft = 'rejected command'
const pendingRestorations = new Map()
const token = beginBufferedTerminalDraftRestoration(pendingRestorations, terminal)
let drafts = { [terminal]: rejectedDraft }
drafts = updateBufferedTerminalDraft(drafts, terminal, '')
invalidateBufferedTerminalDraftRestoration(pendingRestorations, terminal)
drafts = updateBufferedTerminalDraft(drafts, terminal, 'new command')
drafts = updateBufferedTerminalDraft(drafts, terminal, '')
if (settleBufferedTerminalDraftRestoration(pendingRestorations, terminal, token)) {
drafts = restoreRejectedBufferedTerminalDraft(drafts, terminal, rejectedDraft)
}
expect(drafts).toEqual({ [terminal]: '' })
})
it('restores a rejection to terminal A after switching to terminal B', () => {
const terminalA = 'terminal-a'
const terminalB = 'terminal-b'
const rejectedDraft = ' echo exacttext '
let activeHandle = terminalA
const sendOrigin = activeHandle
const pendingRestorations = new Map()
const token = beginBufferedTerminalDraftRestoration(pendingRestorations, sendOrigin)
let drafts = { [terminalA]: rejectedDraft, [terminalB]: 'new command for B' }
drafts = updateBufferedTerminalDraft(drafts, sendOrigin, '')
activeHandle = terminalB
if (settleBufferedTerminalDraftRestoration(pendingRestorations, sendOrigin, token)) {
drafts = restoreRejectedBufferedTerminalDraft(drafts, sendOrigin, rejectedDraft)
}
expect(activeHandle).toBe(terminalB)
expect(drafts).toEqual({
[terminalA]: rejectedDraft,
[terminalB]: 'new command for B'
})
})
it('prunes drafts when their terminal lifetime ends', () => {
const liveDrafts = { live: 'keep' }
expect(pruneBufferedTerminalDrafts(liveDrafts, new Set(['live']))).toBe(liveDrafts)
expect(
pruneBufferedTerminalDrafts({ live: 'keep', closed: 'drop' }, new Set(['live']))
).toEqual({ live: 'keep' })
const pendingRestorations = new Map()
const liveToken = beginBufferedTerminalDraftRestoration(pendingRestorations, 'live')
const closedToken = beginBufferedTerminalDraftRestoration(pendingRestorations, 'closed')
pruneBufferedTerminalDraftRestorations(pendingRestorations, new Set(['live']))
expect(settleBufferedTerminalDraftRestoration(pendingRestorations, 'live', liveToken)).toBe(
true
)
expect(settleBufferedTerminalDraftRestoration(pendingRestorations, 'closed', closedToken)).toBe(
false
)
})
})
@@ -0,0 +1,115 @@
export type BufferedTerminalDraftValue = string | ((current: string) => string)
export type BufferedTerminalDraftRestorationToken = { handle: string }
export function updateBufferedTerminalDraft(
currentDrafts: Record<string, string>,
handle: string | null,
value: BufferedTerminalDraftValue
): Record<string, string> {
if (!handle) {
return currentDrafts
}
const current = currentDrafts[handle] ?? ''
const next = typeof value === 'function' ? value(current) : value
return next === current ? currentDrafts : { ...currentDrafts, [handle]: next }
}
export function beginBufferedTerminalDraftRestoration(
pendingRestorations: Map<string, BufferedTerminalDraftRestorationToken>,
handle: string
): BufferedTerminalDraftRestorationToken {
const token = { handle }
pendingRestorations.set(handle, token)
return token
}
export function invalidateBufferedTerminalDraftRestoration(
pendingRestorations: Map<string, BufferedTerminalDraftRestorationToken>,
handle: string
): void {
pendingRestorations.delete(handle)
}
export function settleBufferedTerminalDraftRestoration(
pendingRestorations: Map<string, BufferedTerminalDraftRestorationToken>,
handle: string,
token: BufferedTerminalDraftRestorationToken
): boolean {
const currentHandle = pendingRestorations.get(handle) === token ? handle : token.handle
if (pendingRestorations.get(currentHandle) !== token) {
return false
}
pendingRestorations.delete(currentHandle)
return true
}
export function remapBufferedTerminalDraftRestoration(
pendingRestorations: Map<string, BufferedTerminalDraftRestorationToken>,
previousHandle: string,
nextHandle: string
): void {
const token = pendingRestorations.get(previousHandle)
if (!token || pendingRestorations.has(nextHandle)) {
pendingRestorations.delete(previousHandle)
return
}
pendingRestorations.delete(previousHandle)
token.handle = nextHandle
pendingRestorations.set(nextHandle, token)
}
export function remapBufferedTerminalDraft(
currentDrafts: Record<string, string>,
previousHandle: string,
nextHandle: string
): Record<string, string> {
if (previousHandle === nextHandle || !Object.hasOwn(currentDrafts, previousHandle)) {
return currentDrafts
}
const next = { ...currentDrafts }
if (!Object.hasOwn(currentDrafts, nextHandle)) {
next[nextHandle] = currentDrafts[previousHandle] ?? ''
}
delete next[previousHandle]
return next
}
/** Restore a rejected send without overwriting text composed while its RPC was in flight. */
export function restoreRejectedBufferedTerminalDraft(
currentDrafts: Record<string, string>,
originHandle: string,
rejectedDraft: string
): Record<string, string> {
if ((currentDrafts[originHandle] ?? '').length > 0) {
return currentDrafts
}
return updateBufferedTerminalDraft(currentDrafts, originHandle, rejectedDraft)
}
export function pruneBufferedTerminalDrafts(
currentDrafts: Record<string, string>,
retainedHandles: ReadonlySet<string>
): Record<string, string> {
let next = currentDrafts
for (const handle of Object.keys(currentDrafts)) {
if (retainedHandles.has(handle)) {
continue
}
if (next === currentDrafts) {
next = { ...currentDrafts }
}
delete next[handle]
}
return next
}
export function pruneBufferedTerminalDraftRestorations(
pendingRestorations: Map<string, BufferedTerminalDraftRestorationToken>,
retainedHandles: ReadonlySet<string>
): void {
for (const handle of pendingRestorations.keys()) {
if (!retainedHandles.has(handle)) {
pendingRestorations.delete(handle)
}
}
}
@@ -13,13 +13,13 @@ const sessionRouteSource = readFileSync(
// apply dash normalization only on the send/mirror path. See stablyai/orca#7925.
describe('terminal iOS dictation write-back', () => {
it('does not write normalized text back into the buffered command input value', () => {
expect(sessionRouteSource).toContain('onChangeText={setInput}')
expect(sessionRouteSource).toContain('onChangeText={bufferedTerminalDraftState.setInput}')
expect(sessionRouteSource).not.toContain(
'setInput((previousText) => normalizeTerminalTextInput'
)
})
it('still normalizes the buffered command text at send time', () => {
expect(sessionRouteSource).toContain('normalizeTerminalTextInput(input)')
expect(sessionRouteSource).toContain('normalizeTerminalTextInput(draft)')
})
})
@@ -17,6 +17,10 @@ const liveInputFocusSource = readFileSync(
new URL('./use-terminal-live-input-focus.ts', import.meta.url),
'utf8'
)
const sendCompletionGenerationSource = readFileSync(
new URL('../session/use-mobile-send-completion-generation.ts', import.meta.url),
'utf8'
)
function liveInputBarBlock(): string {
const start = sessionRouteSource.indexOf('{liveInputEnabled ? (')
@@ -41,7 +45,10 @@ describe('terminal live input affordance', () => {
expect(block).toContain('showSoftInputOnFocus')
expect(block).toContain('liveInputText={liveInputCapture}')
expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({')
expect(sessionRouteSource).toContain('return resetLiveInputFocus')
expect(sessionRouteSource).toContain('useMobileSendCompletionGeneration({')
expect(sessionRouteSource).toContain('onBlur: resetLiveInputFocus')
expect(sendCompletionGenerationSource).toContain('return () => {')
expect(sendCompletionGenerationSource).toContain('onBlur()')
expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current')
expect(liveInputFocusSource).toContain('lifecycleIdentity,')
expect(liveInputFocusSource).toContain('resetLiveInputFocus')
@@ -0,0 +1,302 @@
import { createElement, useRef } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it } from 'vitest'
import { resolveRetainedTerminalHandles } from '../session/mobile-terminal-prune-decision'
import { useBufferedTerminalDrafts } from './use-buffered-terminal-drafts'
type BufferedDraftHook = ReturnType<typeof useBufferedTerminalDrafts>
let currentHook: BufferedDraftHook | null = null
let renderer: ReactTestRenderer | null = null
let probeRenderCount = 0
function Probe({ activeHandle }: { readonly activeHandle: string | null }) {
probeRenderCount += 1
const activeHandleRef = useRef(activeHandle)
activeHandleRef.current = activeHandle
currentHook = useBufferedTerminalDrafts({ activeHandle, activeHandleRef })
return null
}
function hook(): BufferedDraftHook {
if (!currentHook) {
throw new Error('Hook probe is not mounted')
}
return currentHook
}
afterEach(() => {
act(() => renderer?.unmount())
currentHook = null
probeRenderCount = 0
renderer = null
})
describe('useBufferedTerminalDrafts', () => {
it('does not re-render for an unchanged terminal reconciliation', () => {
act(() => {
renderer = create(createElement(Probe, { activeHandle: 'terminal' }))
})
const initialRenderCount = probeRenderCount
act(() => {
hook().reconcileTerminalTabs(
[{ id: 'tab-1', leafId: 'leaf-1', terminal: 'terminal' }],
[{ id: 'tab-1', leafId: 'leaf-1', terminal: 'terminal' }]
)
})
expect(probeRenderCount).toBe(initialRenderCount)
})
it('does not re-render when terminal-list pruning retains every mapped draft', () => {
act(() => {
renderer = create(createElement(Probe, { activeHandle: 'terminal' }))
})
act(() => hook().setInput('draft'))
act(() => {
hook().reconcileTerminalTabs(
[{ id: 'tab-1', leafId: 'leaf-1', terminal: 'terminal' }],
[{ id: 'tab-1', leafId: 'leaf-1', terminal: 'terminal' }]
)
})
const renderCountBeforePrune = probeRenderCount
act(() => hook().pruneDrafts(new Set()))
expect(probeRenderCount).toBe(renderCountBeforePrune)
})
it('carries an unsent draft through a pending-handle remint', () => {
act(() => {
renderer = create(createElement(Probe, { activeHandle: 'terminal-old' }))
})
act(() => hook().setInput('keep across reload'))
act(() => {
hook().reconcileTerminalTabs(
[{ id: 'tab-old', leafId: 'leaf-1', terminal: 'terminal-old' }],
[{ id: 'tab-old', leafId: 'leaf-1', terminal: null }]
)
renderer?.update(createElement(Probe, { activeHandle: null }))
})
act(() => {
hook().reconcileTerminalTabs(
[{ id: 'tab-old', leafId: 'leaf-1', terminal: null }],
[{ id: 'tab-reminted', leafId: 'leaf-1', terminal: 'terminal-new' }]
)
renderer?.update(createElement(Probe, { activeHandle: 'terminal-new' }))
})
expect(hook().input).toBe('keep across reload')
})
it('carries an unsent draft through a transient empty snapshot and handle remint', () => {
act(() => {
renderer = create(createElement(Probe, { activeHandle: 'terminal-old' }))
})
act(() => hook().setInput('keep through empty snapshot'))
act(() => {
hook().reconcileTerminalTabs(
[{ id: 'tab-old', leafId: 'leaf-1', terminal: 'terminal-old' }],
[],
{ retainMissingSurfaces: true }
)
renderer?.update(createElement(Probe, { activeHandle: null }))
})
act(() => {
hook().reconcileTerminalTabs(
[],
[{ id: 'tab-reminted', leafId: 'leaf-1', terminal: 'terminal-new' }]
)
renderer?.update(createElement(Probe, { activeHandle: 'terminal-new' }))
})
expect(hook().input).toBe('keep through empty snapshot')
})
it('restores a rejected send to its reminted terminal surface', () => {
act(() => {
renderer = create(createElement(Probe, { activeHandle: 'terminal-old' }))
})
act(() => hook().setInput('rejected command'))
let send: ReturnType<BufferedDraftHook['beginBufferedTerminalDraftSend']>
act(() => {
send = hook().beginBufferedTerminalDraftSend('terminal-old', hook().input)
hook().reconcileTerminalTabs(
[{ id: 'tab-old', leafId: 'leaf-1', terminal: 'terminal-old' }],
[{ id: 'tab-reminted', leafId: 'leaf-1', terminal: 'terminal-new' }]
)
renderer?.update(createElement(Probe, { activeHandle: 'terminal-new' }))
})
act(() => hook().restoreRejectedDraft(send))
expect(hook().input).toBe('rejected command')
})
it('restores a rejected send after a transient empty snapshot remints its surface', () => {
act(() => {
renderer = create(createElement(Probe, { activeHandle: 'terminal-old' }))
})
act(() => hook().setInput('rejected command'))
let send: ReturnType<BufferedDraftHook['beginBufferedTerminalDraftSend']>
act(() => {
send = hook().beginBufferedTerminalDraftSend('terminal-old', hook().input)
hook().reconcileTerminalTabs(
[{ id: 'tab-old', leafId: 'leaf-1', terminal: 'terminal-old' }],
[],
{ retainMissingSurfaces: true }
)
hook().reconcileTerminalTabs(
[],
[{ id: 'tab-reminted', leafId: 'leaf-1', terminal: 'terminal-new' }]
)
renderer?.update(createElement(Probe, { activeHandle: 'terminal-new' }))
})
act(() => hook().restoreRejectedDraft(send))
expect(hook().input).toBe('rejected command')
})
it('keeps mapped drafts during terminal-list gaps but prunes them after confirmed close', () => {
act(() => {
renderer = create(createElement(Probe, { activeHandle: 'terminal' }))
})
act(() => hook().setInput('bounded draft'))
act(() => {
hook().reconcileTerminalTabs(
[{ id: 'tab-1', leafId: 'leaf-1', terminal: 'terminal' }],
[{ id: 'tab-1', leafId: 'leaf-1', terminal: null }]
)
hook().pruneDrafts(new Set())
})
expect(hook().input).toBe('bounded draft')
act(() => {
hook().reconcileTerminalTabs([{ id: 'tab-1', leafId: 'leaf-1', terminal: null }], [])
hook().pruneDrafts(new Set())
})
expect(hook().input).toBe('')
})
it('preserves an intentional clear after the optimistic send clear', () => {
act(() => {
renderer = create(createElement(Probe, { activeHandle: 'terminal' }))
})
act(() => hook().setInput('rejected command'))
let send: ReturnType<BufferedDraftHook['beginBufferedTerminalDraftSend']>
act(() => {
send = hook().beginBufferedTerminalDraftSend('terminal', hook().input)
})
act(() => hook().setInput('new command'))
act(() => hook().setInput(''))
expect(hook().settleBufferedTerminalDraftSend(send)).toBe(false)
act(() => hook().restoreRejectedDraft(send))
expect(hook().input).toBe('')
})
it('restores by origin after a tab switch and preserves stable callback identities', () => {
act(() => {
renderer = create(createElement(Probe, { activeHandle: 'terminal-a' }))
})
const callbacks = {
begin: hook().beginBufferedTerminalDraftSend,
prune: hook().pruneDrafts,
reconcile: hook().reconcileTerminalTabs,
reset: hook().resetDrafts,
restore: hook().restoreRejectedDraft,
setInput: hook().setInput,
settle: hook().settleBufferedTerminalDraftSend
}
act(() => hook().setInput(' echo exacttext '))
let send: ReturnType<BufferedDraftHook['beginBufferedTerminalDraftSend']>
act(() => {
send = hook().beginBufferedTerminalDraftSend('terminal-a', hook().input)
renderer?.update(createElement(Probe, { activeHandle: 'terminal-b' }))
})
act(() => hook().setInput('new command for B'))
act(() => hook().restoreRejectedDraft(send))
act(() => renderer?.update(createElement(Probe, { activeHandle: 'terminal-a' })))
expect(hook().input).toBe(' echo exacttext ')
expect(hook().beginBufferedTerminalDraftSend).toBe(callbacks.begin)
expect(hook().pruneDrafts).toBe(callbacks.prune)
expect(hook().reconcileTerminalTabs).toBe(callbacks.reconcile)
expect(hook().resetDrafts).toBe(callbacks.reset)
expect(hook().restoreRejectedDraft).toBe(callbacks.restore)
expect(hook().setInput).toBe(callbacks.setInput)
expect(hook().settleBufferedTerminalDraftSend).toBe(callbacks.settle)
})
// Why the handle set the session sweep passes matters: terminal.list omits a
// chat-covered handle while the desktop graph reloads, so the raw list and the
// retained set disagree exactly there, and only the retained set keeps the draft.
it('keeps a chat-covered draft against the retained set and drops it against the raw list', () => {
const listedHandles = new Set(['other-terminal'])
const retainedHandles = resolveRetainedTerminalHandles({
liveHandles: listedHandles,
showNativeChat: true,
activeHandle: 'covered-terminal'
})
act(() => {
renderer = create(createElement(Probe, { activeHandle: 'covered-terminal' }))
})
act(() => hook().setInput('half-typed command'))
act(() => hook().pruneDrafts(retainedHandles))
expect(hook().input).toBe('half-typed command')
act(() => hook().pruneDrafts(listedHandles))
expect(hook().input).toBe('')
})
it('keeps a chat-covered pending restoration against the retained set only', () => {
const listedHandles = new Set(['other-terminal'])
const retainedHandles = resolveRetainedTerminalHandles({
liveHandles: listedHandles,
showNativeChat: true,
activeHandle: 'covered-terminal'
})
act(() => {
renderer = create(createElement(Probe, { activeHandle: 'covered-terminal' }))
})
act(() => hook().setInput('rejected command'))
let retainedSend: ReturnType<BufferedDraftHook['beginBufferedTerminalDraftSend']>
act(() => {
retainedSend = hook().beginBufferedTerminalDraftSend('covered-terminal', hook().input)
})
act(() => hook().pruneDrafts(retainedHandles))
act(() => hook().restoreRejectedDraft(retainedSend))
expect(hook().input).toBe('rejected command')
let droppedSend: ReturnType<BufferedDraftHook['beginBufferedTerminalDraftSend']>
act(() => {
droppedSend = hook().beginBufferedTerminalDraftSend('covered-terminal', hook().input)
})
act(() => hook().pruneDrafts(listedHandles))
act(() => hook().restoreRejectedDraft(droppedSend))
expect(hook().input).toBe('')
})
it('drops ended-handle and route-reset restoration metadata', () => {
act(() => {
renderer = create(createElement(Probe, { activeHandle: 'terminal' }))
})
act(() => hook().setInput('rejected command'))
let prunedSend: ReturnType<BufferedDraftHook['beginBufferedTerminalDraftSend']>
act(() => {
prunedSend = hook().beginBufferedTerminalDraftSend('terminal', hook().input)
hook().reconcileTerminalTabs([{ id: 'tab-1', leafId: 'leaf-1', terminal: 'terminal' }], [])
hook().restoreRejectedDraft(prunedSend)
})
expect(hook().input).toBe('')
act(() => hook().setInput('route draft'))
let resetSend: ReturnType<BufferedDraftHook['beginBufferedTerminalDraftSend']>
act(() => {
resetSend = hook().beginBufferedTerminalDraftSend('terminal', hook().input)
hook().resetDrafts()
hook().restoreRejectedDraft(resetSend)
})
expect(hook().input).toBe('')
})
})
@@ -0,0 +1,196 @@
import { useCallback, useRef, useState } from 'react'
import type { RefObject } from 'react'
import {
type BufferedTerminalDraftRestorationToken,
type BufferedTerminalDraftValue,
beginBufferedTerminalDraftRestoration,
invalidateBufferedTerminalDraftRestoration,
pruneBufferedTerminalDrafts,
pruneBufferedTerminalDraftRestorations,
remapBufferedTerminalDraft,
remapBufferedTerminalDraftRestoration,
restoreRejectedBufferedTerminalDraft,
settleBufferedTerminalDraftRestoration,
updateBufferedTerminalDraft
} from './buffered-terminal-draft-restoration'
interface BufferedTerminalDraftSend {
readonly draft: string
readonly handle: string
readonly token: BufferedTerminalDraftRestorationToken
}
interface UseBufferedTerminalDraftsOptions {
readonly activeHandle: string | null
readonly activeHandleRef: RefObject<string | null>
}
type BufferedTerminalDraftTab = {
readonly id: string
readonly type?: string
readonly leafId?: string
readonly terminal?: string | null
}
type ReconcileBufferedTerminalDraftTabsOptions = {
readonly retainMissingSurfaces?: boolean
}
function getBufferedTerminalDraftSurfaceKey(tab: BufferedTerminalDraftTab): string {
return tab.leafId ? `leaf:${tab.leafId}` : `tab:${tab.id}`
}
export function useBufferedTerminalDrafts({
activeHandle,
activeHandleRef
}: UseBufferedTerminalDraftsOptions) {
const [drafts, setDrafts] = useState<Record<string, string>>({})
const pendingRestorationsRef = useRef<Map<string, BufferedTerminalDraftRestorationToken>>(
new Map()
)
const handlesBySurfaceRef = useRef<Map<string, string>>(new Map())
const input = activeHandle ? (drafts[activeHandle] ?? '') : ''
const setInput = useCallback(
(value: BufferedTerminalDraftValue) => {
const handle = activeHandleRef.current
if (!handle) {
return
}
invalidateBufferedTerminalDraftRestoration(pendingRestorationsRef.current, handle)
setDrafts((current) => updateBufferedTerminalDraft(current, handle, value))
},
[activeHandleRef]
)
const beginBufferedTerminalDraftSend = useCallback(
(handle: string, draft: string): BufferedTerminalDraftSend => {
const token = beginBufferedTerminalDraftRestoration(pendingRestorationsRef.current, handle)
setDrafts((current) => updateBufferedTerminalDraft(current, handle, ''))
return { draft, handle, token }
},
[]
)
const restoreRejectedDraft = useCallback((send: BufferedTerminalDraftSend): void => {
if (
!settleBufferedTerminalDraftRestoration(
pendingRestorationsRef.current,
send.handle,
send.token
)
) {
return
}
setDrafts((current) =>
restoreRejectedBufferedTerminalDraft(current, send.token.handle, send.draft)
)
}, [])
const settleBufferedTerminalDraftSend = useCallback(
(send: BufferedTerminalDraftSend): boolean =>
settleBufferedTerminalDraftRestoration(
pendingRestorationsRef.current,
send.handle,
send.token
),
[]
)
const pruneDrafts = useCallback((retainedHandles: ReadonlySet<string>): void => {
const retainedMappedHandles = new Set(retainedHandles)
for (const handle of handlesBySurfaceRef.current.values()) {
retainedMappedHandles.add(handle)
}
setDrafts((current) => pruneBufferedTerminalDrafts(current, retainedMappedHandles))
pruneBufferedTerminalDraftRestorations(pendingRestorationsRef.current, retainedMappedHandles)
}, [])
const reconcileTerminalTabs = useCallback(
(
previousTabs: readonly BufferedTerminalDraftTab[],
nextTabs: readonly BufferedTerminalDraftTab[],
{ retainMissingSurfaces = false }: ReconcileBufferedTerminalDraftTabsOptions = {}
): void => {
const handlesBySurface = handlesBySurfaceRef.current
for (const tab of previousTabs) {
if (tab.type && tab.type !== 'terminal') {
continue
}
if (typeof tab.terminal === 'string') {
const surfaceKey = getBufferedTerminalDraftSurfaceKey(tab)
if (!handlesBySurface.has(surfaceKey)) {
handlesBySurface.set(surfaceKey, tab.terminal)
}
}
}
const retainedHandles = new Set<string>(
retainMissingSurfaces ? handlesBySurface.values() : []
)
const retainedSurfaces = new Set<string>(retainMissingSurfaces ? handlesBySurface.keys() : [])
const remaps: Array<{ previousHandle: string; nextHandle: string }> = []
for (const tab of nextTabs) {
if (tab.type && tab.type !== 'terminal') {
continue
}
const surfaceKey = getBufferedTerminalDraftSurfaceKey(tab)
retainedSurfaces.add(surfaceKey)
const previousHandle = handlesBySurface.get(surfaceKey)
if (typeof tab.terminal === 'string') {
retainedHandles.add(tab.terminal)
if (previousHandle && previousHandle !== tab.terminal) {
remaps.push({ previousHandle, nextHandle: tab.terminal })
}
handlesBySurface.set(surfaceKey, tab.terminal)
} else if (previousHandle) {
retainedHandles.add(previousHandle)
}
}
for (const surfaceKey of handlesBySurface.keys()) {
if (!retainedSurfaces.has(surfaceKey)) {
handlesBySurface.delete(surfaceKey)
}
}
for (const { previousHandle, nextHandle } of remaps) {
remapBufferedTerminalDraftRestoration(
pendingRestorationsRef.current,
previousHandle,
nextHandle
)
}
pruneBufferedTerminalDraftRestorations(pendingRestorationsRef.current, retainedHandles)
setDrafts((current) => {
let next = current
for (const { previousHandle, nextHandle } of remaps) {
next = remapBufferedTerminalDraft(next, previousHandle, nextHandle)
}
return pruneBufferedTerminalDrafts(next, retainedHandles)
})
},
[]
)
const resetDrafts = useCallback((): void => {
pendingRestorationsRef.current.clear()
handlesBySurfaceRef.current.clear()
setDrafts((current) => (Object.keys(current).length === 0 ? current : {}))
}, [])
const clearPendingRestorations = useCallback((): void => {
pendingRestorationsRef.current.clear()
}, [])
return {
beginBufferedTerminalDraftSend,
clearPendingRestorations,
input,
pruneDrafts,
reconcileTerminalTabs,
resetDrafts,
restoreRejectedDraft,
setInput,
settleBufferedTerminalDraftSend
}
}
@@ -90,6 +90,7 @@ function createAccessoryInputCommitHarness({
liveInputComposingRef,
liveInputRef,
liveInputTerminalHandles,
onInteraction: vi.fn(),
pendingLiveInputHandleRef,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
@@ -32,6 +32,7 @@ type TerminalLiveAccessoryInputCommitOptions = {
readonly liveInputComposingRef: RefObject<boolean | undefined>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly onInteraction: () => void
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly sentLiveInputTextRef: RefObject<string>
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
@@ -48,6 +49,7 @@ export function useTerminalLiveAccessoryInputCommit({
liveInputComposingRef,
liveInputRef,
liveInputTerminalHandles,
onInteraction,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
@@ -64,6 +66,7 @@ export function useTerminalLiveAccessoryInputCommit({
if (!liveInputTerminalHandles.has(activeHandle)) {
return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush)
}
onInteraction()
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
clearPendingLiveInputCommit()
@@ -116,6 +119,7 @@ export function useTerminalLiveAccessoryInputCommit({
liveInputComposingRef,
liveInputRef,
liveInputTerminalHandles,
onInteraction,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
@@ -19,6 +19,7 @@ function changeLiveInput(
type TerminalLiveInputCommitHarness = {
readonly captures: readonly string[]
readonly getHandlers: () => TerminalLiveInputCommitHandlers
readonly handlers: TerminalLiveInputCommitHandlers
readonly sent: readonly string[]
readonly setActiveSessionTabType: (next: string | undefined) => void
@@ -85,6 +86,12 @@ function createTerminalLiveInputCommitHarness({
return {
captures,
getHandlers: () => {
if (!handlers) {
throw new Error('terminal live input hook is not mounted')
}
return handlers
},
handlers,
sent,
setActiveSessionTabType: (next: string | undefined): void => {
@@ -239,9 +246,10 @@ describe('terminal live input commit hook', () => {
changeLiveInput(handlers, '한')
// When
handlers.handleLiveInputSubmit()
const accepted = await handlers.handleLiveInputSubmit()
// Then
expect(accepted).toBe(true)
await vi.waitFor(() => expect(sent).toEqual(['한', '\r']))
})
@@ -250,26 +258,54 @@ describe('terminal live input commit hook', () => {
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When
handlers.handleLiveInputSubmit()
const accepted = await handlers.handleLiveInputSubmit()
// Then
expect(accepted).toBe(true)
await vi.waitFor(() => expect(sent).toEqual(['\r']))
})
it('increments a stable interaction generation for typing, submit, and accessory Enter', async () => {
const { getHandlers, handlers, setActiveSessionTabType } =
createTerminalLiveInputCommitHarness()
const getter = handlers.getLiveInputInteractionGeneration
const initialGeneration = getter()
changeLiveInput(handlers, 'newer text')
const typedGeneration = getter()
await handlers.handleLiveInputSubmit()
const submitGeneration = getter()
await handlers.handleLiveInputAccessoryBytes({ bytes: '\r' })
setActiveSessionTabType(undefined)
expect(getHandlers().getLiveInputInteractionGeneration).toBe(getter)
expect(typedGeneration).toBe(initialGeneration + 1)
expect(submitGeneration).toBeGreaterThan(typedGeneration)
expect(getter()).toBeGreaterThan(submitGeneration)
})
it('Given a rejected held-text send When submit is requested Then suppresses the carriage return', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
changeLiveInput(handlers, '한')
// When
handlers.handleLiveInputSubmit()
await Promise.resolve()
await Promise.resolve()
const accepted = await handlers.handleLiveInputSubmit()
// Then: the held commit went out but was not accepted, so no \r follows
expect(accepted).toBe(false)
await vi.waitFor(() => expect(sent).toEqual(['한']))
})
it('Given a rejected carriage return When submit is requested Then reports rejection', async () => {
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
const accepted = await handlers.handleLiveInputSubmit()
expect(accepted).toBe(false)
expect(sent).toEqual(['\r'])
})
it('Given ASCII typing When changes arrive Then mirrors immediately', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
@@ -1,4 +1,4 @@
import { useCallback, useEffect, type RefObject } from 'react'
import { useCallback, useEffect, useRef, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import { getTerminalLiveSpecialKeyDecision } from './terminal-live-text-commit'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
@@ -43,12 +43,13 @@ type TerminalLiveInputCommitOptions<TTabType extends string> = {
type TerminalLiveInputCommitHandlers = {
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise<boolean>
readonly getLiveInputInteractionGeneration: () => number
readonly handleLiveInputAccessoryBytes: (
input: TerminalLiveAccessoryInput
) => Promise<TerminalLiveAccessoryInputCommitResult>
readonly handleLiveInputChange: (event: TerminalLiveInputChangeEvent) => void
readonly handleLiveInputKeyPress: (event: TerminalLiveInputKeyPressEvent) => void
readonly handleLiveInputSubmit: () => void
readonly handleLiveInputSubmit: () => Promise<boolean>
}
export function useTerminalLiveInputCommit<TTabType extends string>({
@@ -63,6 +64,10 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
sendLiveTerminalInputRef,
setLiveInputCapture
}: TerminalLiveInputCommitOptions<TTabType>): TerminalLiveInputCommitHandlers {
const liveInputInteractionGenerationRef = useRef(0)
const advanceLiveInputInteractionGeneration = useCallback(() => {
liveInputInteractionGenerationRef.current += 1
}, [])
const {
applyLiveInputMirror,
clearPendingLiveInputCommit,
@@ -108,6 +113,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
const flushPendingLiveInputBeforeExternalSend = useCallback(
async (handle: string): Promise<boolean> => {
advanceLiveInputInteractionGeneration()
const pendingHandle = pendingLiveInputHandleRef.current
if (pendingHandle && pendingHandle !== handle) {
clearPendingLiveInputCommit()
@@ -120,7 +126,12 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
}
return waitForPendingLiveInputFlush()
},
[clearPendingLiveInputCommit, flushPendingLiveInputText, waitForPendingLiveInputFlush]
[
advanceLiveInputInteractionGeneration,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
waitForPendingLiveInputFlush
]
)
const handleLiveInputChange = useCallback(
@@ -132,6 +143,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
// Why: iOS kills an active dictation/IME session when JS writes a value
// that differs from the native field text, so the controlled capture must
// echo the field verbatim; only the PTY mirror sees normalized text.
advanceLiveInputInteractionGeneration()
setLiveInputCapture(nativeEvent.text)
void applyLiveInputMirror(
activeHandle,
@@ -141,6 +153,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
},
[
activeHandle,
advanceLiveInputInteractionGeneration,
applyLiveInputMirror,
clearPendingLiveInputCommit,
liveInputTerminalHandles,
@@ -148,11 +161,17 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
]
)
const getLiveInputInteractionGeneration = useCallback(
() => liveInputInteractionGenerationRef.current,
[]
)
const handleLiveInputKeyPress = useCallback(
(event: TerminalLiveInputKeyPressEvent) => {
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
return
}
advanceLiveInputInteractionGeneration()
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
clearPendingLiveInputCommit()
@@ -183,6 +202,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
},
[
activeHandle,
advanceLiveInputInteractionGeneration,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
liveInputTerminalHandles,
@@ -200,6 +220,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
liveInputComposingRef,
liveInputRef,
liveInputTerminalHandles,
onInteraction: advanceLiveInputInteractionGeneration,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
@@ -207,19 +228,27 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
waitForPendingLiveInputFlush
})
const handleLiveInputSubmit = useCallback(() => {
const handleLiveInputSubmit = useCallback((): Promise<boolean> => {
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
return
return Promise.resolve(false)
}
void sendTerminalLiveControlAfterPendingFlush(
advanceLiveInputInteractionGeneration()
return sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, '\r')
)
}, [activeHandle, flushPendingLiveInputText, liveInputTerminalHandles, sendLiveTerminalInputRef])
}, [
activeHandle,
advanceLiveInputInteractionGeneration,
flushPendingLiveInputText,
liveInputTerminalHandles,
sendLiveTerminalInputRef
])
return {
clearPendingLiveInputCommit,
flushPendingLiveInputBeforeExternalSend,
getLiveInputInteractionGeneration,
handleLiveInputAccessoryBytes,
handleLiveInputChange,
handleLiveInputKeyPress,