diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index a34c8643ab0..10b413d0528 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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 ( - - - {gutterLineNumber} - - - {line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '} - - - - {canComment ? ( - [ - styles.diffCommentAddButton, - pressed && styles.diffCommentAddButtonPressed, - commentsBusy && styles.diffCommentButtonDisabled - ]} - disabled={commentsBusy} - onPress={() => { - if (commentLine !== undefined) { - onStartComment(commentLine) - } - }} - accessibilityLabel={`Add note on line ${commentLine}`} - > - - - ) : null} - - {comments.length > 0 ? ( - - {comments.map((comment) => ( - - - - Line {comment.lineNumber} - onDeleteComment(comment.id)} - accessibilityLabel={`Delete note on line ${comment.lineNumber}`} - > - - - - {comment.body} - - ))} - - ) : null} - {isCommenting ? ( - - - - - Cancel - - { - if (commentLine !== undefined) { - onSubmitComment(commentLine) - } - }} - > - Save note - - - - ) : null} - - ) -} - -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(null) - const [diffSyntax, setDiffSyntax] = useState(null) - const [activeCommentLine, setActiveCommentLine] = useState(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() - 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 = useCallback( - ({ item, index }) => ( - { - 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 ( - - - - ) - } - if (doc.status === 'error') { - return ( - - {doc.message} - - ) - } - - 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 ( - - {diffCommentActions ? ( - - - - - {commentCount === 0 - ? 'No review notes' - : `${commentCount} review ${commentCount === 1 ? 'note' : 'notes'}`} - - - - void diffCommentActions.onCopyAll()} - accessibilityLabel="Copy review notes" - > - - Copy - - - - Send - - - - ) : null} - - `${index}:${line.kind}:${line.oldLineNumber ?? ''}:${line.newLineNumber ?? ''}` - } - renderItem={renderDiffLine} - initialNumToRender={32} - maxToRenderPerBatch={48} - windowSize={7} - removeClippedSubviews={Platform.OS !== 'web'} - keyboardShouldPersistTaps="handled" - /> - - ) - } - - if (doc.kind === 'image') { - return ( - - - - - - ) - } - - const renderSourceText = (content: string) => ( - - - - - - - - ) - - if (doc.kind === 'html') { - return ( - - renderSourceText(doc.content)} /> - - ) - } - - 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>(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>(new Set()) const activeHandleRef = useRef(null) + const bufferedTerminalDraftState = useBufferedTerminalDrafts({ activeHandle, activeHandleRef }) + const reconcileBufferedDraftsRef = useRef(bufferedTerminalDraftState.reconcileTerminalTabs) const activeSessionTabTypeRef = useRef(null) const pendingActiveSessionTabIdRef = useRef(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(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( @@ -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({ 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() { ) : activeFileTab ? ( - {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()} diff --git a/mobile/src/session/MobileDiffCommentLineRow.tsx b/mobile/src/session/MobileDiffCommentLineRow.tsx new file mode 100644 index 00000000000..5a0df303e90 --- /dev/null +++ b/mobile/src/session/MobileDiffCommentLineRow.tsx @@ -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 ( + + + {gutterLineNumber} + + + {line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '} + + + + {canComment ? ( + [ + styles.diffCommentAddButton, + pressed && styles.diffCommentAddButtonPressed, + commentsBusy && styles.diffCommentButtonDisabled + ]} + disabled={commentsBusy} + onPress={() => { + if (commentLine !== undefined) { + onStartComment(commentLine) + } + }} + accessibilityLabel={`Add note on line ${commentLine}`} + > + + + ) : null} + + {comments.length > 0 ? ( + + {comments.map((comment) => ( + + + + Line {comment.lineNumber} + onDeleteComment(comment.id)} + accessibilityLabel={`Delete note on line ${comment.lineNumber}`} + > + + + + {comment.body} + + ))} + + ) : null} + {isCommenting ? ( + + + + + Cancel + + { + if (commentLine !== undefined) { + onSubmitComment(commentLine) + } + }} + > + Save note + + + + ) : null} + + ) +} diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts index 3c8afe2cefd..145ea48600c 100644 --- a/mobile/src/session/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -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, '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, - 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((resolve) => { + resolveSend = resolve + }) + ) + await render(onSend, vi.fn()) + + let pendingSend!: Promise + 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((resolve) => { + resolveSend = resolve + }) + ) + await render(onSend, vi.fn()) + + let pendingSend!: Promise + 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((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 + 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((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 + 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((resolve) => { + resolveSend = resolve + }) + ) + await render(onSend, vi.fn(), false, 'tab-a', () => generation) + + let pendingSend!: Promise + await act(async () => { + pendingSend = sendButton().props.onPress() + await Promise.resolve() + }) + generation += 1 + await act(async () => { + resolveSend?.(true) + await pendingSend + }) + + expect(Keyboard.dismiss).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index ee937369ea7..c75d28d9663 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -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 + /** 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 diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index b43a7c79f3b..471e07e76c1 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -51,6 +51,8 @@ function overlayElement(tick: Tick): ReturnType { inputLockReason: null, sendErrorMessage: null, onClearSendError: vi.fn(), + sendSurfaceId: tick.identity ?? 'tab-a', + getSendCompletionGeneration: () => 0, keyboardInset: 0 }) } diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 699f8a1614b..23724300ddf 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -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} diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 1021671b837..9d171e3ac8a 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -85,6 +85,8 @@ function chatViewElement(overrides: Overrides): ReturnType status: 'ready', streaming: null, onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: () => 0, pending: [], composerText: '', onComposerTextChange: vi.fn(), diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 2bd0e92b22c..4db4e437b43 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -58,6 +58,12 @@ type Props = { loadingEarlier?: boolean onLoadEarlier?: () => void onSend: (text: string) => Promise + /** 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} diff --git a/mobile/src/session/MobileSessionFileReader.tsx b/mobile/src/session/MobileSessionFileReader.tsx new file mode 100644 index 00000000000..8b8907f72b5 --- /dev/null +++ b/mobile/src/session/MobileSessionFileReader.tsx @@ -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(null) + const [diffSyntax, setDiffSyntax] = useState(null) + const [activeCommentLine, setActiveCommentLine] = useState(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() + 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 = useCallback( + ({ item, index }) => ( + { + 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 ( + + + + ) + } + if (doc.status === 'error') { + return ( + + {doc.message} + + ) + } + + 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 ( + + {diffCommentActions ? ( + + + + + {commentCount === 0 + ? 'No review notes' + : `${commentCount} review ${commentCount === 1 ? 'note' : 'notes'}`} + + + + void diffCommentActions.onCopyAll()} + accessibilityLabel="Copy review notes" + > + + Copy + + + + Send + + + + ) : null} + + `${index}:${line.kind}:${line.oldLineNumber ?? ''}:${line.newLineNumber ?? ''}` + } + renderItem={renderDiffLine} + initialNumToRender={32} + maxToRenderPerBatch={48} + windowSize={7} + removeClippedSubviews={Platform.OS !== 'web'} + keyboardShouldPersistTaps="handled" + /> + + ) + } + + if (doc.kind === 'image') { + return ( + + + + + + ) + } + + const renderSourceText = (content: string) => ( + + + + + + + + ) + + if (doc.kind === 'html') { + return ( + + renderSourceText(doc.content)} /> + + ) + } + + return renderSourceText(doc.content) +} diff --git a/mobile/src/session/agent-send-keyboard-dismissal-wiring.test.ts b/mobile/src/session/agent-send-keyboard-dismissal-wiring.test.ts new file mode 100644 index 00000000000..3ceb71857b1 --- /dev/null +++ b/mobile/src/session/agent-send-keyboard-dismissal-wiring.test.ts @@ -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') + }) +}) diff --git a/mobile/src/session/agent-send-keyboard-dismissal.test.ts b/mobile/src/session/agent-send-keyboard-dismissal.test.ts new file mode 100644 index 00000000000..2078a3a7210 --- /dev/null +++ b/mobile/src/session/agent-send-keyboard-dismissal.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest' +import { + shouldDismissKeyboardAfterTerminalSend, + type AgentSendKeyboardDismissalTab +} from './agent-send-keyboard-dismissal' + +function terminalTab(overrides: Partial = {}) { + 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) + }) +}) diff --git a/mobile/src/session/agent-send-keyboard-dismissal.ts b/mobile/src/session/agent-send-keyboard-dismissal.ts new file mode 100644 index 00000000000..b07cf17fcf0 --- /dev/null +++ b/mobile/src/session/agent-send-keyboard-dismissal.ts @@ -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 +} diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 1a086783dbb..2283256da05 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -21,6 +21,7 @@ export type MobileNativeChatController = { nativeChatAgent: string | null chatComposerText: string setChatComposerText: Dispatch> + getChatComposerEditGeneration: () => number chatPending: MobileNativeChatPendingMessage[] chatImagePreviewsByMessageId: Record nativeChatSession: ReturnType diff --git a/mobile/src/session/mobile-native-chat-draft-edit-generations.ts b/mobile/src/session/mobile-native-chat-draft-edit-generations.ts new file mode 100644 index 00000000000..d5364aeae2c --- /dev/null +++ b/mobile/src/session/mobile-native-chat-draft-edit-generations.ts @@ -0,0 +1,19 @@ +export class MobileNativeChatDraftEditGenerations { + private composerGeneration = 0 + private readonly byDraft = new Map() + + 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 + } +} diff --git a/mobile/src/session/mobile-native-chat-pending-echo.test.ts b/mobile/src/session/mobile-native-chat-pending-echo.test.ts index 9ff7281be95..f1f70df1a92 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.test.ts @@ -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. diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index 5ded9c73015..91bf778ba7c 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -16,6 +16,7 @@ export type MobileNativeChatPendingMessage = { export type MobileNativeChatSendOrigin = { draftKey: string + draftEditGeneration: number pendingKey: string | null normalizedText: string baselineOccurrences: number diff --git a/mobile/src/session/mobile-session-last-tab-close.test.ts b/mobile/src/session/mobile-session-last-tab-close.test.ts index 7bd6ad179fc..a125bbe3af0 100644 --- a/mobile/src/session/mobile-session-last-tab-close.test.ts +++ b/mobile/src/session/mobile-session-last-tab-close.test.ts @@ -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)' + ) }) }) diff --git a/mobile/src/session/mobile-terminal-tab-agent.test.ts b/mobile/src/session/mobile-terminal-tab-agent.test.ts index db6d95c228e..5177981f0e6 100644 --- a/mobile/src/session/mobile-terminal-tab-agent.test.ts +++ b/mobile/src/session/mobile-terminal-tab-agent.test.ts @@ -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') diff --git a/mobile/src/session/mobile-terminal-tab-agent.ts b/mobile/src/session/mobile-terminal-tab-agent.ts index f28d9b3fd98..20326d41c98 100644 --- a/mobile/src/session/mobile-terminal-tab-agent.ts +++ b/mobile/src/session/mobile-terminal-tab-agent.ts @@ -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) } diff --git a/mobile/src/session/use-agent-send-keyboard-dismissal.ts b/mobile/src/session/use-agent-send-keyboard-dismissal.ts new file mode 100644 index 00000000000..38996a06a60 --- /dev/null +++ b/mobile/src/session/use-agent-send-keyboard-dismissal.ts @@ -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] + ) +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index d22b4fe26ce..40937adc0e0 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -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, diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index ab1319c7847..4d405ac5d65 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -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, diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts index 2a5ff2d54c6..586b384e770 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -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 { diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 784f025847b..a50ccf4c923 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -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> + 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> >({}) 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) diff --git a/mobile/src/session/use-mobile-send-completion-generation.test.ts b/mobile/src/session/use-mobile-send-completion-generation.test.ts new file mode 100644 index 00000000000..0776ca717c6 --- /dev/null +++ b/mobile/src/session/use-mobile-send-completion-generation.test.ts @@ -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(() => 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()) + }) +}) diff --git a/mobile/src/session/use-mobile-send-completion-generation.ts b/mobile/src/session/use-mobile-send-completion-generation.ts new file mode 100644 index 00000000000..16f74aee396 --- /dev/null +++ b/mobile/src/session/use-mobile-send-completion-generation.ts @@ -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, []) +} diff --git a/mobile/src/terminal/buffered-terminal-draft-restoration.test.ts b/mobile/src/terminal/buffered-terminal-draft-restoration.test.ts new file mode 100644 index 00000000000..218feba117c --- /dev/null +++ b/mobile/src/terminal/buffered-terminal-draft-restoration.test.ts @@ -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 a–b ') + ).toEqual({ terminal: ' echo a–b ' }) + }) + + 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 exact–text ' + 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 + ) + }) +}) diff --git a/mobile/src/terminal/buffered-terminal-draft-restoration.ts b/mobile/src/terminal/buffered-terminal-draft-restoration.ts new file mode 100644 index 00000000000..6ca11c21652 --- /dev/null +++ b/mobile/src/terminal/buffered-terminal-draft-restoration.ts @@ -0,0 +1,115 @@ +export type BufferedTerminalDraftValue = string | ((current: string) => string) +export type BufferedTerminalDraftRestorationToken = { handle: string } + +export function updateBufferedTerminalDraft( + currentDrafts: Record, + handle: string | null, + value: BufferedTerminalDraftValue +): Record { + 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, + handle: string +): BufferedTerminalDraftRestorationToken { + const token = { handle } + pendingRestorations.set(handle, token) + return token +} + +export function invalidateBufferedTerminalDraftRestoration( + pendingRestorations: Map, + handle: string +): void { + pendingRestorations.delete(handle) +} + +export function settleBufferedTerminalDraftRestoration( + pendingRestorations: Map, + 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, + 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, + previousHandle: string, + nextHandle: string +): Record { + 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, + originHandle: string, + rejectedDraft: string +): Record { + if ((currentDrafts[originHandle] ?? '').length > 0) { + return currentDrafts + } + return updateBufferedTerminalDraft(currentDrafts, originHandle, rejectedDraft) +} + +export function pruneBufferedTerminalDrafts( + currentDrafts: Record, + retainedHandles: ReadonlySet +): Record { + 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, + retainedHandles: ReadonlySet +): void { + for (const handle of pendingRestorations.keys()) { + if (!retainedHandles.has(handle)) { + pendingRestorations.delete(handle) + } + } +} diff --git a/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts b/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts index 8dff075d85e..093ef8e17dc 100644 --- a/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts +++ b/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts @@ -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)') }) }) diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts index ce360091ce2..4a7b3bde817 100644 --- a/mobile/src/terminal/terminal-live-input-affordance.test.ts +++ b/mobile/src/terminal/terminal-live-input-affordance.test.ts @@ -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') diff --git a/mobile/src/terminal/use-buffered-terminal-drafts.test.tsx b/mobile/src/terminal/use-buffered-terminal-drafts.test.tsx new file mode 100644 index 00000000000..02ae3ddd206 --- /dev/null +++ b/mobile/src/terminal/use-buffered-terminal-drafts.test.tsx @@ -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 + +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 + 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 + 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 + 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 exact–text ')) + let send: ReturnType + 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 exact–text ') + 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 + 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 + 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 + 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 + act(() => { + resetSend = hook().beginBufferedTerminalDraftSend('terminal', hook().input) + hook().resetDrafts() + hook().restoreRejectedDraft(resetSend) + }) + expect(hook().input).toBe('') + }) +}) diff --git a/mobile/src/terminal/use-buffered-terminal-drafts.ts b/mobile/src/terminal/use-buffered-terminal-drafts.ts new file mode 100644 index 00000000000..58c154fb4c3 --- /dev/null +++ b/mobile/src/terminal/use-buffered-terminal-drafts.ts @@ -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 +} + +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>({}) + const pendingRestorationsRef = useRef>( + new Map() + ) + const handlesBySurfaceRef = useRef>(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): 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( + retainMissingSurfaces ? handlesBySurface.values() : [] + ) + const retainedSurfaces = new Set(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 + } +} diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts index ad1d1726798..acf2b0533ce 100644 --- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts @@ -90,6 +90,7 @@ function createAccessoryInputCommitHarness({ liveInputComposingRef, liveInputRef, liveInputTerminalHandles, + onInteraction: vi.fn(), pendingLiveInputHandleRef, sentLiveInputTextRef, sendLiveTerminalInputRef, diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts index 80a8422d085..36f33996eab 100644 --- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts @@ -32,6 +32,7 @@ type TerminalLiveAccessoryInputCommitOptions = { readonly liveInputComposingRef: RefObject readonly liveInputRef: RefObject readonly liveInputTerminalHandles: ReadonlySet + readonly onInteraction: () => void readonly pendingLiveInputHandleRef: RefObject readonly sentLiveInputTextRef: RefObject readonly sendLiveTerminalInputRef: RefObject @@ -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, diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index 6077ccb4033..87ad2b64292 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -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() diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index 952612f0a87..55bc23c3dcc 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -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 = { type TerminalLiveInputCommitHandlers = { readonly clearPendingLiveInputCommit: () => void readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise + readonly getLiveInputInteractionGeneration: () => number readonly handleLiveInputAccessoryBytes: ( input: TerminalLiveAccessoryInput ) => Promise readonly handleLiveInputChange: (event: TerminalLiveInputChangeEvent) => void readonly handleLiveInputKeyPress: (event: TerminalLiveInputKeyPressEvent) => void - readonly handleLiveInputSubmit: () => void + readonly handleLiveInputSubmit: () => Promise } export function useTerminalLiveInputCommit({ @@ -63,6 +64,10 @@ export function useTerminalLiveInputCommit({ sendLiveTerminalInputRef, setLiveInputCapture }: TerminalLiveInputCommitOptions): TerminalLiveInputCommitHandlers { + const liveInputInteractionGenerationRef = useRef(0) + const advanceLiveInputInteractionGeneration = useCallback(() => { + liveInputInteractionGenerationRef.current += 1 + }, []) const { applyLiveInputMirror, clearPendingLiveInputCommit, @@ -108,6 +113,7 @@ export function useTerminalLiveInputCommit({ const flushPendingLiveInputBeforeExternalSend = useCallback( async (handle: string): Promise => { + advanceLiveInputInteractionGeneration() const pendingHandle = pendingLiveInputHandleRef.current if (pendingHandle && pendingHandle !== handle) { clearPendingLiveInputCommit() @@ -120,7 +126,12 @@ export function useTerminalLiveInputCommit({ } return waitForPendingLiveInputFlush() }, - [clearPendingLiveInputCommit, flushPendingLiveInputText, waitForPendingLiveInputFlush] + [ + advanceLiveInputInteractionGeneration, + clearPendingLiveInputCommit, + flushPendingLiveInputText, + waitForPendingLiveInputFlush + ] ) const handleLiveInputChange = useCallback( @@ -132,6 +143,7 @@ export function useTerminalLiveInputCommit({ // 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({ }, [ activeHandle, + advanceLiveInputInteractionGeneration, applyLiveInputMirror, clearPendingLiveInputCommit, liveInputTerminalHandles, @@ -148,11 +161,17 @@ export function useTerminalLiveInputCommit({ ] ) + 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({ }, [ activeHandle, + advanceLiveInputInteractionGeneration, clearPendingLiveInputCommit, flushPendingLiveInputText, liveInputTerminalHandles, @@ -200,6 +220,7 @@ export function useTerminalLiveInputCommit({ liveInputComposingRef, liveInputRef, liveInputTerminalHandles, + onInteraction: advanceLiveInputInteractionGeneration, pendingLiveInputHandleRef, sentLiveInputTextRef, sendLiveTerminalInputRef, @@ -207,19 +228,27 @@ export function useTerminalLiveInputCommit({ waitForPendingLiveInputFlush }) - const handleLiveInputSubmit = useCallback(() => { + const handleLiveInputSubmit = useCallback((): Promise => { 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,