diff --git a/mobile/src/session/MobileSessionActiveContent.tsx b/mobile/src/session/MobileSessionActiveContent.tsx index 6b02c87077f..7dd09977c45 100644 --- a/mobile/src/session/MobileSessionActiveContent.tsx +++ b/mobile/src/session/MobileSessionActiveContent.tsx @@ -72,6 +72,8 @@ export function MobileSessionActiveContent({ activeFileTab, activeBrowserTab, activePendingTerminalTab, + isPendingTerminalRecoveryParked, + retryPendingTerminalRecovery, showLoadingState, showEmptyState, keyboardLift, @@ -170,8 +172,24 @@ export function MobileSessionActiveContent({ ) : activePendingTerminalTab ? ( - - {activePendingTerminalTab.title || 'Loading terminal'} + {!isPendingTerminalRecoveryParked && ( + + )} + + {isPendingTerminalRecoveryParked + ? 'Terminal is taking longer than expected' + : activePendingTerminalTab.title || 'Loading terminal'} + + {isPendingTerminalRecoveryParked && ( + [styles.createButton, pressed && styles.newTerminalButtonPressed]} + onPress={() => void retryPendingTerminalRecovery()} + > + Retry + + )} ) : ( {toastMessage && ( diff --git a/mobile/src/session/MobileSessionCommandDock.tsx b/mobile/src/session/MobileSessionCommandDock.tsx index 6ee4fd05093..30f26574bc7 100644 --- a/mobile/src/session/MobileSessionCommandDock.tsx +++ b/mobile/src/session/MobileSessionCommandDock.tsx @@ -24,8 +24,7 @@ import type { MobileSessionController } from './use-mobile-session-controller' export function MobileSessionCommandDock({ controller }: { controller: MobileSessionController }) { const { insets, - input, - setInput, + bufferedTerminalDraftState, autocompleteEnabled, liveInputCapture, activeHandle, @@ -41,6 +40,10 @@ export function MobileSessionCommandDock({ controller }: { controller: MobileSes handleLiveInputChange, handleLiveInputKeyPress, handleLiveInputSubmit, + getLiveInteractionGeneration, + getSendCompletionGeneration, + dismissKeyboardAfterAgentSend, + activeSessionTab, canSend, canCompose, liveInputEnabled, @@ -293,9 +296,22 @@ export function MobileSessionCommandDock({ controller }: { controller: MobileSes ref={liveInputRef} style={styles.liveInputCapture} value={liveInputCapture} - onChangeText={handleLiveInputChange} + 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" @@ -324,9 +340,9 @@ export function MobileSessionCommandDock({ controller }: { controller: MobileSes : '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" @@ -337,6 +353,7 @@ export function MobileSessionCommandDock({ controller }: { controller: MobileSes autoComplete="off" keyboardType={getTerminalCommandKeyboardType(Platform.OS, 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/agent-send-keyboard-dismissal-wiring.test.ts b/mobile/src/session/agent-send-keyboard-dismissal-wiring.test.ts index 3ceb71857b1..83c9263273b 100644 --- a/mobile/src/session/agent-send-keyboard-dismissal-wiring.test.ts +++ b/mobile/src/session/agent-send-keyboard-dismissal-wiring.test.ts @@ -1,32 +1,38 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' +import { readMobileSessionRouteSource } from './mobile-session-route-source-family.test-support' -const sessionRouteSource = readFileSync( - new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), - 'utf8' +const runtimeSource = readMobileSessionRouteSource('./use-mobile-session-terminal-runtime.ts') +const nativeChatSource = readMobileSessionRouteSource( + './use-mobile-session-native-chat-dictation.ts' ) -const bufferedDraftHookSource = readFileSync( - new URL('../terminal/use-buffered-terminal-drafts.ts', import.meta.url), - 'utf8' +const sendActionsSource = readMobileSessionRouteSource( + './use-mobile-session-terminal-send-actions.ts' ) -const keyboardDismissalHookSource = readFileSync( - new URL('./use-agent-send-keyboard-dismissal.ts', import.meta.url), - 'utf8' +const commandDockSource = readMobileSessionRouteSource('./MobileSessionCommandDock.tsx') +const tabApplicationSource = readMobileSessionRouteSource('./use-mobile-session-tab-application.ts') +const terminalListSource = readMobileSessionRouteSource('./use-mobile-session-terminal-list.ts') +const startupSource = readMobileSessionRouteSource('./use-mobile-session-startup.ts') +const bufferedDraftHookSource = readMobileSessionRouteSource( + '../terminal/use-buffered-terminal-drafts.ts' +) +const keyboardDismissalHookSource = readMobileSessionRouteSource( + './use-agent-send-keyboard-dismissal.ts' ) -function routeSlice(anchorStart: string, anchorEnd: string): string { - const start = sessionRouteSource.indexOf(anchorStart) +function sourceSlice(source: string, anchorStart: string, anchorEnd: string): string { + const start = source.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(source.indexOf(anchorStart, start + 1)).toBe(-1) + const end = source.indexOf(anchorEnd, start) expect(end).toBeGreaterThan(start) - return sessionRouteSource.slice(start, end + anchorEnd.length) + return source.slice(start, end + anchorEnd.length) } describe('terminal send keyboard dismissal wiring', () => { it('gates the dismissal on the agent-session predicate', () => { - const slice = routeSlice( + const slice = sourceSlice( + sendActionsSource, 'const dismissKeyboardAfterAgentSend = useAgentSendKeyboardDismissal(', 'getSendCompletionGeneration\n )' ) @@ -39,17 +45,18 @@ describe('terminal send keyboard dismissal wiring', () => { ) expect(keyboardDismissalHookSource).toContain('dismissSoftwareKeyboard()') expect(keyboardDismissalHookSource).toContain('return useCallback(') - expect(sessionRouteSource).toContain( - "import { useAgentSendKeyboardDismissal } from '../../../../src/session/use-agent-send-keyboard-dismissal'" + expect(sendActionsSource).toContain( + "import { useAgentSendKeyboardDismissal } from './use-agent-send-keyboard-dismissal'" ) }) it('invalidates pending terminal sends when the focused input surface changes', () => { - const slice = routeSlice( + const slice = sourceSlice( + nativeChatSource, 'const getSendCompletionGeneration = useMobileSendCompletionGeneration({', '})' ) - expect(sessionRouteSource).toContain( + expect(nativeChatSource).toContain( 'const routeKey = nativeChatScopeKey ?? `${hostId}\\0${worktreeId}`' ) expect(slice).toContain( @@ -60,7 +67,7 @@ describe('terminal send keyboard dismissal wiring', () => { 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"') + const slice = sourceSlice(commandDockSource, 'ref={liveInputRef}', 'importantForAutofill="no"') expect(slice).toContain('generation: getSendCompletionGeneration()') expect(slice).toContain('const submit = handleLiveInputSubmit()') expect(slice).toContain('interaction: getLiveInteractionGeneration()') @@ -72,7 +79,11 @@ describe('terminal send keyboard dismissal wiring', () => { }) it('dismisses the buffered command send only once the write is accepted', () => { - const slice = routeSlice('async function handleSend() {', 'async function handleAccessoryKey(') + const slice = sourceSlice( + sendActionsSource, + '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(') @@ -95,18 +106,24 @@ describe('terminal send keyboard dismissal wiring', () => { }) it('keeps buffered Return focused until accepted-agent dismissal runs', () => { - const slice = routeSlice('ref={commandInputRef}', 'onSubmitEditing={() => void handleSend()}') + const slice = sourceSlice( + commandDockSource, + 'ref={commandInputRef}', + 'onSubmitEditing={() => void handleSend()}' + ) expect(slice).toContain('blurOnSubmit={false}') }) it('restores a rejected buffered draft by origin without generation fencing', () => { - const sendSlice = routeSlice( + const sendSlice = sourceSlice( + sendActionsSource, 'async function handleSend() {', 'async function handleAccessoryKey(' ) const originAt = sendSlice.indexOf('handle: activeHandle') const requestAt = sendSlice.indexOf('await client.sendRequest(') - const restoreSlice = routeSlice( + const restoreSlice = sourceSlice( + sendActionsSource, 'const bufferedDraftSend = bufferedTerminalDraftState.beginBufferedTerminalDraftSend(', 'bufferedTerminalDraftState.restoreRejectedDraft(bufferedDraftSend)' ) @@ -130,11 +147,12 @@ describe('terminal send keyboard dismissal wiring', () => { 'setDrafts((current) => updateBufferedTerminalDraft(current, handle, value))' ) expect(bufferedDraftHookSource).toContain('pruneBufferedTerminalDraftRestorations(') - expect(sessionRouteSource).toContain('useRef(bufferedTerminalDraftState.reconcileTerminalTabs)') - expect(sessionRouteSource).toContain( + expect(runtimeSource).toContain('useRef(bufferedTerminalDraftState.reconcileTerminalTabs)') + expect(tabApplicationSource).toContain( 'reconcileBufferedDraftsRef.current(currentSessionTabs, nextTabs, {' ) - const routeResetSlice = routeSlice( + const routeResetSlice = sourceSlice( + startupSource, '// Why: Expo reuses this screen across worktrees;', 'clearDelayedActionTimers()\n }' ) @@ -145,7 +163,8 @@ describe('terminal send keyboard dismissal wiring', () => { 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 slice = sourceSlice( + terminalListSource, 'const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle))', 'setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune))' ) @@ -166,7 +185,8 @@ describe('terminal send keyboard dismissal wiring', () => { 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( + const slice = sourceSlice( + sendActionsSource, 'async function handleAccessoryKey(', 'const sendLiveTerminalInput = useCallback(' ) diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index 671c0a917f3..160353cf9ac 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -62,15 +62,15 @@ const HOST_COMPONENT_NAMES = new Set([ 'View' ]) -const HEAD_MAIN_HOOK_SHA256 = 'a85f2d8bdbb9da6ac76ed9a8c0e2f13035614c209bfadef87aaa00ba8a57467b' -const HEAD_HOOK_BINDING_SHA256 = '1e3aee4f970228ccf8cdee90d64b14d6223d728afd09d1ca43e78eee7d24933d' +const HEAD_MAIN_HOOK_SHA256 = '5c475b904928f418c76a7885afdbed7adbfea3fe3ea05e85d956dc22f958a302' +const HEAD_HOOK_BINDING_SHA256 = '028f99dd14fea2110cff446418ee71513aeed38484c2dcea68bf0da8eff377c0' const HEAD_CALLBACK_IDENTITY_SHA256 = - '1d09ceee6a61ba5194458cf42a7e429ea0e318c8a98590d1718ee1247f30538f' -const HEAD_CALLBACK_BODY_SHA256 = '883d2a47032466b243e003b0849a9499c11928e323c314ccfea05b5a1b271cea' -const HEAD_EFFECT_SHA256 = '95b1cce6f2f2eb494de28ea217325e3fac79c6cfc45de2aa337a8fa3ab9452a7' + 'd60ffe53f8d77f2dd3ebd14a5de162bb399113c170b59bdc917de6318ec433ec' +const HEAD_CALLBACK_BODY_SHA256 = '07803db6b4ca4c3c02090bea0e774b0f2a8580dd5a8417227d5b4a63645a0817' +const HEAD_EFFECT_SHA256 = '346d384ea0bf2f8f926c5092c5bf57bc2a03494f49f9639e9d6b8a2c51c9f882' const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581' const HEAD_NESTED_FUNCTION_SHA256 = - 'f5500526d0314bffe9dd6f16d5f98ea3b6bbbf3ec425228876d05703b66190d2' + 'b562c117eb1e4532dd656d8bdd3ca3bc58ce65d78a7ed740dbd866a48d4d8dbe' const HEAD_NATIVE_REGISTRATION_SHA256 = 'cab85e4e4a3f43289ba93ddea9ccce57aea83e0bf14fd1620a965aad0c1cb49e' const HEAD_NATIVE_REMOVAL_SHA256 = @@ -79,11 +79,11 @@ const HEAD_TIMER_CREATION_SHA256 = '1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b' const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116' const HEAD_RUNTIME_STRING_SHA256 = - '47cd7d8edc0cce183e8bc259ec6f6db58050cfa75ea49d67eb1f58d58872b002' -const HEAD_HOST_JSX_SHA256 = 'f05c0b6d47228acd20c9341f37d3631cbc1ecff046dac5e73c4873f17941212c' -const HEAD_LEAF_JSX_SHA256 = '36e55d037ab2500ae6c13c015697742598b0aa2cc79f375b7cc933148da8cb01' + 'ad0def23206f08d0523c155fe730e86824876e67cf1db6b597541b9c35b54447' +const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5' +const HEAD_LEAF_JSX_SHA256 = 'b070e25c47b3e298be02a4ffe1572b36e204446fc161bad894690e9939403f54' const HEAD_STYLE_REFERENCE_SHA256 = - '2388d0bcabbc37ce48ba934eb60374f6848e6fba69445dc04a0dbfb55ea05b79' + '295a3501c2c6d7bea7c8bbf38b3f3534f01344cd7e1b91bb8e07c040821d596a' const HEAD_IDENTITY_FIELD_SHA256 = '6b37a0351795a387a358df76a5ab919a7098ddb76bf25a936c8902c062c8951c' const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512' @@ -472,13 +472,13 @@ describe('mobile session route extraction parity', () => { const contentBindings = CONTENT_COMPONENT_NAMES.flatMap( (name) => readHookFacts(name, definitions).bindings ) - expect(main.hooks).toHaveLength(257) + expect(main.hooks).toHaveLength(269) expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256) expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256) - expect(main.callbacks).toHaveLength(77) + expect(main.callbacks).toHaveLength(78) expect(hash(main.callbacks)).toBe(HEAD_CALLBACK_IDENTITY_SHA256) expect(hash(main.callbackBodies)).toBe(HEAD_CALLBACK_BODY_SHA256) - expect(main.effects).toHaveLength(22) + expect(main.effects).toHaveLength(24) expect(hash(main.effects)).toBe(HEAD_EFFECT_SHA256) expect(contentBindings).toHaveLength(14) expect(hash(contentBindings)).toBe(HEAD_CONTENT_HOOK_SHA256) @@ -517,14 +517,14 @@ describe('mobile session route extraction parity', () => { it('preserves runtime strings, styles, and the expanded JSX tree', () => { const strings = readRuntimeStrings() - expect(strings).toHaveLength(529) + expect(strings).toHaveLength(537) expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256) const jsx = readJsxFacts(readDefinitions()) - expect(jsx.host).toHaveLength(122) + expect(jsx.host).toHaveLength(124) expect(hash(jsx.host)).toBe(HEAD_HOST_JSX_SHA256) expect(jsx.leaf).toHaveLength(59) expect(hash(jsx.leaf)).toBe(HEAD_LEAF_JSX_SHA256) - expect(jsx.styleReferences).toHaveLength(169) + expect(jsx.styleReferences).toHaveLength(172) expect(hash(jsx.styleReferences)).toBe(HEAD_STYLE_REFERENCE_SHA256) }) }) diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index ee6dbe5a265..acab1d5e7c6 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -19,6 +19,13 @@ const reconciliationHookSource = readMobileSessionRouteSource( const terminalInventoryRecoverySource = readMobileSessionRouteSource( './use-mobile-terminal-inventory-recovery.ts' ) +const terminalSubscriptionSource = readMobileSessionRouteSource( + './use-mobile-session-terminal-subscription.ts' +) +const terminalListSource = readMobileSessionRouteSource('./use-mobile-session-terminal-list.ts') +const tabReconciliationOwnerSource = readMobileSessionRouteSource( + './use-mobile-session-tab-reconciliation.ts' +) const autoCreateHookSource = readMobileSessionRouteSource( './use-initial-session-terminal-autocreate.ts' ) @@ -100,15 +107,17 @@ describe('mobile session startup', () => { }) it('confirms terminal stream teardown with a committed inventory-recovery bridge', () => { - expect(source).toContain("if (data.type === 'end' || data.type === 'error')") - expect(source).toContain('signalTerminalInventoryRecovery()') + expect(terminalSubscriptionSource).toContain( + "if (data.type === 'end' || data.type === 'error')" + ) + expect(terminalSubscriptionSource).toContain('signalTerminalInventoryRecovery()') expect(terminalInventoryRecoverySource).toContain('actionRef.current = recoveryAction') expect(terminalInventoryRecoverySource).toContain('pendingSignalScopeRef.current = scopeKey') expect(terminalInventoryRecoverySource).toContain( 'committedScope !== null && committedScope !== scopeKey' ) - expect(source).toContain('return terminalInventoryRequest.activate()') - expect(source).toContain('if (!isCurrent())') + expect(terminalListSource).toContain('return terminalInventoryRequest.activate()') + expect(terminalListSource).toContain('if (!isCurrent() || !response.ok)') expect(terminalInventoryRecoverySource).toContain( 'TERMINAL_INVENTORY_CONFIRMATION_DELAY_MS = 750' ) @@ -232,7 +241,8 @@ describe('mobile session startup', () => { ) const recoveryContext = sliceBetween( 'const pendingTerminalRecoveryContextCache = useMemo(', - 'const getSessionTabsApplicationRevision' + 'const sessionTabsFetchReporting', + tabReconciliationOwnerSource ) const tabsRefWrite = 'sessionTabsRef.current = nextTabs' diff --git a/mobile/src/session/use-mobile-session-bulk-close.ts b/mobile/src/session/use-mobile-session-bulk-close.ts index ac04032231d..4c492dab5e3 100644 --- a/mobile/src/session/use-mobile-session-bulk-close.ts +++ b/mobile/src/session/use-mobile-session-bulk-close.ts @@ -23,7 +23,10 @@ export function useMobileSessionBulkClose(scope: MobileSessionCloseActionsModel) applySessionTabs, fetchSessionTabs, switchSessionTab, - handleCloseSessionTab + handleCloseSessionTab, + pendingTerminalRecoveryContextKey, + parkedPendingTerminalContext, + retryPendingTerminalRecovery } = scope const bulkCloseActions = createBulkCloseSheetActions({ sessionTabsRef, @@ -42,6 +45,9 @@ export function useMobileSessionBulkClose(scope: MobileSessionCloseActionsModel) activeSessionTab?.type === 'terminal' && typeof activeSessionTab.terminal !== 'string' ? activeSessionTab : null + const isPendingTerminalRecoveryParked = + pendingTerminalRecoveryContextKey !== null && + pendingTerminalRecoveryContextKey === parkedPendingTerminalContext useEffect(() => { if (!client || connState !== 'connected' || !activePendingTerminalTab) { @@ -98,7 +104,9 @@ export function useMobileSessionBulkClose(scope: MobileSessionCloseActionsModel) activeMarkdownTab, activeFileTab, activeBrowserTab, - activePendingTerminalTab + activePendingTerminalTab, + isPendingTerminalRecoveryParked, + retryPendingTerminalRecovery } } diff --git a/mobile/src/session/use-mobile-session-close-actions.ts b/mobile/src/session/use-mobile-session-close-actions.ts index b934cb9fbd7..c2ff6825b0a 100644 --- a/mobile/src/session/use-mobile-session-close-actions.ts +++ b/mobile/src/session/use-mobile-session-close-actions.ts @@ -10,11 +10,13 @@ export function useMobileSessionCloseActions(scope: MobileSessionContentCreateAc terminalsRef, setSessionTabs, sessionTabsRef, + reconcileBufferedDraftsRef, closedTabTombstonesRef, clearTerminalLiveInputDefault, setActiveHandle, setActiveSessionTabId, activeSessionTabIdRef, + selectedSessionTabIdRef, renameTarget, setRenameTarget, terminalRefs, @@ -104,6 +106,7 @@ export function useMobileSessionCloseActions(scope: MobileSessionContentCreateAc }) 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 } @@ -123,6 +126,7 @@ export function useMobileSessionCloseActions(scope: MobileSessionContentCreateAc // so comparing against the ref keeps the anchor from being nulled out. if (activeSessionTabIdRef.current === tab.id || remainingTabs.length === 0) { activeSessionTabTypeRef.current = null + selectedSessionTabIdRef.current = null activeSessionTabIdRef.current = null setActiveSessionTabId(null) activeHandleRef.current = null diff --git a/mobile/src/session/use-mobile-session-file-actions.ts b/mobile/src/session/use-mobile-session-file-actions.ts index e312fd4b9e8..7ba21770a21 100644 --- a/mobile/src/session/use-mobile-session-file-actions.ts +++ b/mobile/src/session/use-mobile-session-file-actions.ts @@ -17,6 +17,7 @@ export function useMobileSessionFileActions(scope: MobileSessionTerminalSendActi activeSessionTabIdRef, terminalCwdRef, activeHandleRef, + activeSessionTab, activeSessionTabTypeRef, switchSessionTabRef, handleCreateBrowserRef, @@ -30,6 +31,10 @@ export function useMobileSessionFileActions(scope: MobileSessionTerminalSendActi hostId, worktreeId, worktreeName: routeWorktreeName, + nativeChatSessionId: + activeSessionTab?.type === 'terminal' + ? (activeSessionTab.agentStatus?.providerSession?.id ?? null) + : null, activeHandleRef, terminalCwdRef, openBrowser: (url) => void handleCreateBrowserRef.current?.(url), diff --git a/mobile/src/session/use-mobile-session-native-chat-dictation.ts b/mobile/src/session/use-mobile-session-native-chat-dictation.ts index 663c850322f..7942233dbfd 100644 --- a/mobile/src/session/use-mobile-session-native-chat-dictation.ts +++ b/mobile/src/session/use-mobile-session-native-chat-dictation.ts @@ -15,6 +15,7 @@ import { useMobileNativeChatReadability } from './use-mobile-native-chat-readabi import { useMobileNativeChatInputLease } from './use-mobile-native-chat-input-lease' import { useMobileNativeChatSendError } from './use-mobile-native-chat-send-error' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' +import { useMobileSendCompletionGeneration } from './use-mobile-send-completion-generation' import type { MobileSessionFeedbackCapabilitiesModel } from './use-mobile-session-feedback-capabilities' export function useMobileSessionNativeChatDictation( @@ -41,7 +42,8 @@ export function useMobileSessionNativeChatDictation( flushPendingLiveInputBeforeExternalSend, canSend, liveInputEnabled, - showToast + showToast, + resetLiveInputFocus } = scope const nativeChatScopeKey = mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId) const nativeChatSendError = useMobileNativeChatSendError({ @@ -75,6 +77,11 @@ export function useMobileSessionNativeChatDictation( }) 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, @@ -207,6 +214,7 @@ export function useMobileSessionNativeChatDictation( markNativeChatInputLeaseReady, clearNativeChatInputLease, nativeChatController, + getSendCompletionGeneration, toggleTabChatView, showNativeChat, showNativeChatRef, diff --git a/mobile/src/session/use-mobile-session-screen-state.ts b/mobile/src/session/use-mobile-session-screen-state.ts index fff885ad057..107ac3181de 100644 --- a/mobile/src/session/use-mobile-session-screen-state.ts +++ b/mobile/src/session/use-mobile-session-screen-state.ts @@ -58,6 +58,8 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel) const [coveredStreamRevision, setCoveredStreamRevision] = useState(0) const [activeSessionTabId, setActiveSessionTabId] = useState(null) const activeSessionTabIdRef = useRef(null) + // Preserve an explicit phone tab pick while a host snapshot is transiently incomplete. + const selectedSessionTabIdRef = useRef(null) // Auto-scroll the tab strip so the desktop-synced active tab is revealed without a manual scroll. const tabStripRef = useRef(null) const tabStripOffsetRef = useRef(0) @@ -168,6 +170,7 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel) activeSessionTabId, setActiveSessionTabId, activeSessionTabIdRef, + selectedSessionTabIdRef, tabStripRef, tabStripOffsetRef, tabStripViewportWidthRef, diff --git a/mobile/src/session/use-mobile-session-startup.ts b/mobile/src/session/use-mobile-session-startup.ts index 658e563d479..3eca98ae13f 100644 --- a/mobile/src/session/use-mobile-session-startup.ts +++ b/mobile/src/session/use-mobile-session-startup.ts @@ -31,10 +31,12 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel) activeHandleRef, activeSessionTabTypeRef, pendingActiveSessionTabIdRef, + selectedSessionTabIdRef, pendingActiveTerminalHandleRef, pendingBrowserFocusPageIdRef, pendingTerminalActivationAttemptRef, initialSessionAutoCreateRef, + bufferedTerminalDraftState, clearPendingLiveInputCommit, clearDelayedActionTimers, showToast, @@ -51,6 +53,7 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel) activeHandleRef.current = null activeSessionTabTypeRef.current = null pendingActiveSessionTabIdRef.current = null + selectedSessionTabIdRef.current = null pendingActiveTerminalHandleRef.current = null pendingBrowserFocusPageIdRef.current = null pendingTerminalActivationAttemptRef.current = null @@ -58,6 +61,7 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel) 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) @@ -77,14 +81,17 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel) return () => { sessionTabActionSheetRequestSeqRef.current += 1 sessionTabActionSheetKeyboardHideSubRef.current?.remove() + bufferedTerminalDraftState.clearPendingRestorations() clearPendingLiveInputCommit() clearDelayedActionTimers() } }, [ clearDelayedActionTimers, clearPendingLiveInputCommit, + bufferedTerminalDraftState.clearPendingRestorations, clearTerminalCache, hostId, + bufferedTerminalDraftState.resetDrafts, worktreeId ]) diff --git a/mobile/src/session/use-mobile-session-tab-application.ts b/mobile/src/session/use-mobile-session-tab-application.ts index fecf5184f11..bee5a9a273d 100644 --- a/mobile/src/session/use-mobile-session-tab-application.ts +++ b/mobile/src/session/use-mobile-session-tab-application.ts @@ -12,6 +12,7 @@ import { } from './session-tab-snapshot-gate' import type { SessionTabsApplyOutcome } from './mobile-session-tabs-stream-health' import { getActiveTabIdForHandle } from './mobile-session-route-helpers' +import { resolveActiveSessionTab } from './active-session-tab' import type { MobileSessionTab, SessionTabsResult } from './mobile-session-route-types' import type { MobileSessionTerminalListModel } from './use-mobile-session-terminal-list' @@ -24,11 +25,13 @@ export function useMobileSessionTabApplication(scope: MobileSessionTerminalListM appliedSnapshotMarkerRef, appliedSessionTabsRevisionRef, closedTabTombstonesRef, + reconcileBufferedDraftsRef, setTerminalsLoaded, defaultTerminalHandlesToLiveInput, setActiveHandle, setActiveSessionTabId, activeSessionTabIdRef, + selectedSessionTabIdRef, markdownDocsRef, initializedHandlesRef, terminalDiagnosticsRef, @@ -36,6 +39,7 @@ export function useMobileSessionTabApplication(scope: MobileSessionTerminalListM activeSessionTabTypeRef, pendingActiveSessionTabIdRef, pendingActiveTerminalHandleRef, + pendingBrowserFocusPageIdRef, initialSessionAutoCreateRef, unsubscribeTerminal, subscribeToTerminal, @@ -74,6 +78,9 @@ export function useMobileSessionTabApplication(scope: MobileSessionTerminalListM 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. @@ -100,28 +107,27 @@ export function useMobileSessionTabApplication(scope: MobileSessionTerminalListM applicationRevision } - const snapshotActive = nextTabs.find((tab) => tab.isActive) ?? nextTabs[0] ?? null const pendingActiveSessionTabId = pendingActiveSessionTabIdRef.current const pendingActiveTerminalHandle = pendingActiveTerminalHandleRef.current - let active = snapshotActive - let selectionSource = 'snapshot' - if (pendingActiveSessionTabId) { - if (snapshotActive?.id === pendingActiveSessionTabId) { - if (confirmsMirroredTabSelection(result.publicationEpoch)) { - pendingActiveSessionTabIdRef.current = null - } else { - selectionSource = 'pending-tab-local-ack' - } - } else { - const pendingTab = nextTabs.find((tab) => tab.id === pendingActiveSessionTabId) - if (pendingTab) { - // Why: desktop tab snapshots can lag a mobile tap mid-activate-RPC; keep the local selection to avoid snapping back. - active = pendingTab - selectionSource = 'pending-tab' - } else { - pendingActiveSessionTabIdRef.current = null - } - } + const followsHost = result.navigationIntent === 'follow' + if (followsHost) { + pendingActiveTerminalHandleRef.current = null + pendingBrowserFocusPageIdRef.current = null + } + const resolved = resolveActiveSessionTab(nextTabs, { + pendingActiveSessionTabId, + selectedSessionTabId: selectedSessionTabIdRef.current, + navigationIntent: result.navigationIntent + }) + let active = resolved.activeTab + let selectionSource: string = resolved.selectionSource + if (resolved.clearPendingActiveSessionTabId) { + const localAck = + !followsHost && + nextTabs.find((tab) => tab.isActive)?.id === pendingActiveSessionTabId && + !confirmsMirroredTabSelection(result.publicationEpoch) + selectionSource = localAck ? 'pending-tab-local-ack' : selectionSource + pendingActiveSessionTabIdRef.current = localAck ? pendingActiveSessionTabId : null } if (pendingActiveTerminalHandle) { const pendingTerminalTab = nextTabs.find( @@ -131,14 +137,16 @@ export function useMobileSessionTabApplication(scope: MobileSessionTerminalListM const pendingTerminalExists = mergedTerminalsForActive.some( (terminal) => terminal.handle === pendingActiveTerminalHandle ) - if ( - snapshotActive?.type === 'terminal' && - snapshotActive.terminal === pendingActiveTerminalHandle - ) { - if (confirmsMirroredTabSelection(result.publicationEpoch)) { - pendingActiveTerminalHandleRef.current = null - } else { + if (active?.type === 'terminal' && active.terminal === pendingActiveTerminalHandle) { + const snapshotActive = nextTabs.find((tab) => tab.isActive) ?? nextTabs[0] ?? null + if ( + snapshotActive?.type === 'terminal' && + snapshotActive.terminal === pendingActiveTerminalHandle && + !confirmsMirroredTabSelection(result.publicationEpoch) + ) { selectionSource = 'pending-handle-local-ack' + } else { + pendingActiveTerminalHandleRef.current = null } } else if (pendingTerminalTab) { // Why: desktop active flags lag a mobile tap; key by handle too, as fallback PTY tabs lack a stable tab id at startup. @@ -161,6 +169,9 @@ export function useMobileSessionTabApplication(scope: MobileSessionTerminalListM } } diagnostics.tabsApplied(result, nextTabs, active, selectionSource) + if (!resolved.retainSelectedSessionTabId || active !== resolved.activeTab) { + selectedSessionTabIdRef.current = active?.id ?? null + } activeSessionTabTypeRef.current = active?.type ?? null activeSessionTabIdRef.current = active?.id ?? null setActiveSessionTabId(active?.id ?? null) diff --git a/mobile/src/session/use-mobile-session-tab-reconciliation.ts b/mobile/src/session/use-mobile-session-tab-reconciliation.ts index 7b8f5285695..be4641dd297 100644 --- a/mobile/src/session/use-mobile-session-tab-reconciliation.ts +++ b/mobile/src/session/use-mobile-session-tab-reconciliation.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useCallback } from 'react' +import { useEffect, useRef, useCallback, useMemo, useState } from 'react' import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe' import { supportsMobileQuickCommands } from '../terminal/quick-commands' import { MOBILE_AI_VAULT_CAPABILITY } from '../agent-history/agent-history-capability' @@ -7,6 +7,8 @@ import { runAcceptedMobileSessionTabsEffects } from './mobile-session-tabs-accep import type { SessionTabsStreamSource } from './mobile-session-tabs-stream-health' import { useMobileSessionTabsFetchReporting } from './use-mobile-session-tabs-fetch-reporting' import { useMobileSessionTabsReconciliation } from './use-mobile-session-tabs-reconciliation' +import { PendingTerminalHandleRecoveryContextCache } from './pending-terminal-handle-recovery' +import { hasConnectedTerminalAbsentFromSessionTabs } from './mobile-terminal-records' import type { MobileSessionTab, SessionTabsResult } from './mobile-session-route-types' import type { MobileSessionMarkdownActionsModel } from './use-mobile-session-markdown-actions' @@ -15,6 +17,9 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc worktreeId, client, connState, + sessionTabsRef, + activeSessionTabIdRef, + terminalsRef, appliedSessionTabsRevisionRef, closedTabTombstonesRef, setMarkdownDocs, @@ -29,8 +34,13 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc setQuickCommandsSupported, nativeChatStream, fetchTerminals, - applySessionTabs + applySessionTabs, + terminalInventoryRecoveryScope, + registerTerminalInventoryRecoveryAction } = scope + const [parkedPendingTerminalContext, setParkedPendingTerminalContext] = useState( + null + ) const consumeAcceptedSessionTabs = useCallback( ( _result: SessionTabsResult, @@ -64,6 +74,7 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc () => closedTabTombstonesRef.current.size > 0 || pendingBrowserFocusPageIdRef.current !== null || + hasConnectedTerminalAbsentFromSessionTabs(terminalsRef.current, sessionTabsRef.current) || // Why: a chat-covered handle that ran out of rearms and left `terminal.list` // was reminted by a desktop graph reload. Only a fresh tab snapshot carries // the replacement handle, so force one instead of holding the composer locked. @@ -74,22 +85,49 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc () => appliedSessionTabsRevisionRef.current, [] ) + const pendingTerminalRecoveryContextCache = useMemo( + () => new PendingTerminalHandleRecoveryContextCache(), + [] + ) + const getPendingTerminalRecoveryContextKey = useCallback( + () => + pendingTerminalRecoveryContextCache.read( + sessionTabsRef.current, + activeSessionTabIdRef.current + ), + [pendingTerminalRecoveryContextCache, sessionTabsRef, activeSessionTabIdRef] + ) + const pendingTerminalRecoveryContextKey = getPendingTerminalRecoveryContextKey() const sessionTabsFetchReporting = useMobileSessionTabsFetchReporting({ worktreeId, diagnosticsRef: terminalDiagnosticsRef }) - const { fetchSessionTabs, ensureSessionTabs, fetchPendingBrowserSessionTabs } = - useMobileSessionTabsReconciliation({ - client, - connState, - worktreeId, - applySessionTabs, - consumeAcceptedSessionTabs, - fetchTerminals, - hasRecoveryNeed: hasSessionTabsRecoveryNeed, - getApplicationRevision: getSessionTabsApplicationRevision, - ...sessionTabsFetchReporting - }) + const { + fetchSessionTabs, + ensureSessionTabs, + fetchPendingBrowserSessionTabs, + retryPendingTerminalRecovery, + requestTerminalInventoryRecovery + } = useMobileSessionTabsReconciliation({ + client, + connState, + worktreeId, + applySessionTabs, + consumeAcceptedSessionTabs, + fetchTerminals, + terminalInventoryRecoveryScopeKey: terminalInventoryRecoveryScope, + hasRecoveryNeed: hasSessionTabsRecoveryNeed, + pendingTerminalRecoveryContextKey, + getPendingTerminalRecoveryContextKey, + onPendingTerminalRecoveryParked: setParkedPendingTerminalContext, + getApplicationRevision: getSessionTabsApplicationRevision, + ...sessionTabsFetchReporting + }) + + useEffect( + () => registerTerminalInventoryRecoveryAction(requestTerminalInventoryRecovery), + [registerTerminalInventoryRecoveryAction, requestTerminalInventoryRecovery] + ) useEffect(() => { if (connState === 'connected') { @@ -143,6 +181,10 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc fetchSessionTabs, ensureSessionTabs, fetchPendingBrowserSessionTabs, + retryPendingTerminalRecovery, + requestTerminalInventoryRecovery, + pendingTerminalRecoveryContextKey, + parkedPendingTerminalContext, hostQueryReplyInputSupportedRef } } diff --git a/mobile/src/session/use-mobile-session-terminal-list.ts b/mobile/src/session/use-mobile-session-terminal-list.ts index 741cf096d79..105677533de 100644 --- a/mobile/src/session/use-mobile-session-terminal-list.ts +++ b/mobile/src/session/use-mobile-session-terminal-list.ts @@ -1,4 +1,4 @@ -import { useRef, useCallback } from 'react' +import { useRef, useCallback, useEffect, useMemo } from 'react' import type { RpcSuccess } from '../transport/types' import { mergeTerminalListWithKnownRecords, terminalRecordsEqual } from './mobile-terminal-records' import { @@ -8,9 +8,12 @@ import { } from './mobile-terminal-prune-decision' import type { Terminal } from './mobile-session-route-types' import type { MobileSessionTerminalStreamDisplayModel } from './use-mobile-session-terminal-stream-display' +import { MobileTerminalInventoryRequest } from './mobile-terminal-inventory-request' +import type { MobileTerminalInventoryRefreshOptions } from './use-mobile-terminal-inventory-recovery' export function useMobileSessionTerminalList(scope: MobileSessionTerminalStreamDisplayModel) { const { + hostId, worktreeId, client, setTerminals, @@ -27,97 +30,109 @@ export function useMobileSessionTerminalList(scope: MobileSessionTerminalStreamD activeHandleRef, showNativeChatRef, unsubscribeTerminal, - subscribeToTerminal, - nativeChatStream + nativeChatStream, + bufferedTerminalDraftState } = scope const lastKnownTerminalCountRef = useRef(0) - const fetchTerminalsInFlightRef = useRef(false) + const terminalInventoryRequest = useMemo( + () => new MobileTerminalInventoryRequest(), + [client, hostId, worktreeId] + ) + + useEffect(() => { + lastKnownTerminalCountRef.current = 0 + return terminalInventoryRequest.activate() + }, [terminalInventoryRequest]) const fetchTerminals = useCallback( - async (opts: { allowEmptyLoaded?: boolean } = {}) => { + (opts: MobileTerminalInventoryRefreshOptions = {}): Promise => { if (!client) { - return + return Promise.resolve(false) } - if (fetchTerminalsInFlightRef.current) { - return - } - fetchTerminalsInFlightRef.current = true const allowEmptyLoaded = opts.allowEmptyLoaded ?? true - - try { - const response = await client.sendRequest('terminal.list', { - worktree: `id:${worktreeId}`, - includeVisualLayouts: false - }) - if (response.ok) { - const result = (response as RpcSuccess).result as { terminals: Terminal[] } - if (result.terminals.length === 0 && !allowEmptyLoaded) { - return - } - // Why: require two consecutive empties before trusting 0, so transient empty responses don't flash the UI empty. - if (result.terminals.length === 0 && lastKnownTerminalCountRef.current > 0) { - lastKnownTerminalCountRef.current = 0 - return - } - - const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle)) - const pruneContext = { - liveHandles, - showNativeChat: showNativeChatRef.current, - activeHandle: activeHandleRef.current - } - // Why: terminal.list is the lifetime signal; lagging tab snapshots must not erase a user's buffered-mode opt-out. - // 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)) - defaultTerminalHandlesToLiveInput([...liveHandles]) - const shouldPrune = createTerminalPrunePredicate(pruneContext) - for (const handle of Array.from(terminalUnsubsRef.current.keys())) { - if (!shouldPrune(handle)) { - continue - } - unsubscribeTerminal(handle) - terminalRefs.current.delete(handle) - initializedHandlesRef.current.delete(handle) - viewportResubscribeBudgetRef.current.forget(handle) - clearTerminalLiveInputDefault(handle) - } - setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune)) - // Why: a chat-covered handle the host reports again refills its rearm budget, - // so an exhausted rearm can't lock the composer until leave-chat. - nativeChatStream.notifyListedHandles(liveHandles) - // Why: same absence-gated refill for the viewport-fit budget — a handle that - // left the list and returned may converge now, so it earns fresh attempts. - viewportResubscribeBudgetRef.current.notifyListedHandles(liveHandles) - lastKnownTerminalCountRef.current = result.terminals.length - // Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order. - const seen = new Set() - const deduped = result.terminals.filter((t) => { - if (seen.has(t.handle)) { + return terminalInventoryRequest.run( + allowEmptyLoaded, + async (allowsEmpty, isCurrent) => { + try { + const response = await client.sendRequest('terminal.list', { + worktree: `id:${worktreeId}`, + includeVisualLayouts: false + }) + if (!isCurrent() || !response.ok) { return false } - seen.add(t.handle) + const result = (response as RpcSuccess).result as { terminals: Terminal[] } + if (result.terminals.length === 0 && !allowsEmpty()) { + return true + } + // Why: require two consecutive empties before trusting 0, so transient empty responses don't flash the UI empty. + if (result.terminals.length === 0 && lastKnownTerminalCountRef.current > 0) { + lastKnownTerminalCountRef.current = 0 + return true + } + + const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle)) + const pruneContext = { + liveHandles, + showNativeChat: showNativeChatRef.current, + activeHandle: activeHandleRef.current + } + // Why: terminal.list is the lifetime signal; lagging tab snapshots must not erase a user's buffered-mode opt-out. + // 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. + const retainedHandles = resolveRetainedTerminalHandles(pruneContext) + pruneTerminalHandlesFromLiveInput(retainedHandles) + bufferedTerminalDraftState.pruneDrafts(retainedHandles) + defaultTerminalHandlesToLiveInput([...liveHandles]) + const shouldPrune = createTerminalPrunePredicate(pruneContext) + for (const handle of Array.from(terminalUnsubsRef.current.keys())) { + if (!shouldPrune(handle)) { + continue + } + unsubscribeTerminal(handle) + terminalRefs.current.delete(handle) + initializedHandlesRef.current.delete(handle) + viewportResubscribeBudgetRef.current.forget(handle) + clearTerminalLiveInputDefault(handle) + } + setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune)) + // Why: a chat-covered handle the host reports again refills its rearm budget, + // so an exhausted rearm can't lock the composer until leave-chat. + nativeChatStream.notifyListedHandles(liveHandles) + // Why: same absence-gated refill for the viewport-fit budget — a handle that + // left the list and returned may converge now, so it earns fresh attempts. + viewportResubscribeBudgetRef.current.notifyListedHandles(liveHandles) + lastKnownTerminalCountRef.current = result.terminals.length + // Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order. + const seen = new Set() + const deduped = result.terminals.filter((t) => { + if (seen.has(t.handle)) { + return false + } + seen.add(t.handle) + return true + }) + + const mergedTerminals = mergeTerminalListWithKnownRecords( + deduped, + terminalsRef.current, + sessionTabsRef.current + ) + setTerminals((prev) => + terminalRecordsEqual(prev, mergedTerminals) ? prev : mergedTerminals + ) + terminalsRef.current = mergedTerminals + + // Session tabs are the UI authority; terminal.list only refreshes per-handle metadata for existing terminal surfaces. return true - }) - - const mergedTerminals = mergeTerminalListWithKnownRecords( - deduped, - terminalsRef.current, - sessionTabsRef.current - ) - setTerminals((prev) => - terminalRecordsEqual(prev, mergedTerminals) ? prev : mergedTerminals - ) - terminalsRef.current = mergedTerminals - - // Session tabs are the UI authority; terminal.list only refreshes per-handle metadata for existing terminal surfaces. - } - } catch { - // Failed to list terminals - } finally { - fetchTerminalsInFlightRef.current = false - } + } catch { + // Failed to list terminals + return false + } + }, + opts.onPhysicalRequestStarted + ) }, [ client, @@ -125,14 +140,14 @@ export function useMobileSessionTerminalList(scope: MobileSessionTerminalStreamD clearTerminalLiveInputDefault, defaultTerminalHandlesToLiveInput, nativeChatStream, + bufferedTerminalDraftState.pruneDrafts, pruneTerminalHandlesFromLiveInput, - subscribeToTerminal, + terminalInventoryRequest, unsubscribeTerminal ] ) return { lastKnownTerminalCountRef, - fetchTerminalsInFlightRef, fetchTerminals } } diff --git a/mobile/src/session/use-mobile-session-terminal-runtime.ts b/mobile/src/session/use-mobile-session-terminal-runtime.ts index 48d6eaf5898..ab909254e10 100644 --- a/mobile/src/session/use-mobile-session-terminal-runtime.ts +++ b/mobile/src/session/use-mobile-session-terminal-runtime.ts @@ -11,6 +11,8 @@ import { resolveMobileTerminalInputGate } from '../terminal/terminal-input-conne import { createInitialSessionAutoCreateState } from './use-initial-session-terminal-autocreate' import { TerminalViewportResubscribeBudget } from './mobile-terminal-viewport-resubscribe' import { MobileTerminalDiagnostics } from './mobile-terminal-diagnostics' +import { useBufferedTerminalDrafts } from '../terminal/use-buffered-terminal-drafts' +import { useMobileTerminalInventoryRecoveryBridge } from './use-mobile-terminal-inventory-recovery' import type { MobileSessionTab, MobileSessionTabType, @@ -63,6 +65,8 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM } | null>(null) const terminalUnsubsRef = useRef void>>(new Map()) const subscribingHandlesRef = useRef>(new Set()) + // Lease-only streams do not render, so reconciliation tracks them separately. + const leaseOnlyHandlesRef = useRef>(new Set()) const initializedHandlesRef = useRef>(new Set()) const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics()) // Why: bounds the scrollback→resubscribe fit loop per handle (STA-3337). @@ -70,6 +74,9 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM // 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) + reconcileBufferedDraftsRef.current = bufferedTerminalDraftState.reconcileTerminalTabs const activeSessionTabTypeRef = useRef(null) const pendingActiveSessionTabIdRef = useRef(null) const pendingActiveTerminalHandleRef = useRef(null) @@ -79,6 +86,9 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM const pendingTerminalActivationAttemptRef = useRef(null) // Why: route the terminal URL tap through a ref so it runs the current handleCreateBrowser closure (the memoized one may hold a null-client render). const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise) | null>(null) + const terminalInventoryRecoveryScope = JSON.stringify([hostId, worktreeId]) + const { registerTerminalInventoryRecoveryAction, signalTerminalInventoryRecovery } = + useMobileTerminalInventoryRecoveryBridge(terminalInventoryRecoveryScope) const initialSessionAutoCreateRef = useRef(createInitialSessionAutoCreateState()) const markdownSaveSeqRef = useRef>(new Map()) @@ -97,6 +107,7 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM const { clearPendingLiveInputCommit, flushPendingLiveInputBeforeExternalSend, + getLiveInputInteractionGeneration, handleLiveInputAccessoryBytes, handleLiveInputChange, handleLiveInputKeyPress, @@ -159,6 +170,7 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM dictationRouteContextRef, terminalUnsubsRef, subscribingHandlesRef, + leaseOnlyHandlesRef, initializedHandlesRef, terminalDiagnosticsRef, viewportResubscribeBudgetRef, @@ -193,7 +205,15 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM liveInputEnabled, focusLiveInput, handleTerminalTap, - resetLiveInputFocus + resetLiveInputFocus, + terminalInventoryRecoveryScope, + registerTerminalInventoryRecoveryAction, + signalTerminalInventoryRecovery, + bufferedTerminalDraftState, + reconcileBufferedDraftsRef, + input: bufferedTerminalDraftState.input, + setInput: bufferedTerminalDraftState.setInput, + getLiveInteractionGeneration: getLiveInputInteractionGeneration } } diff --git a/mobile/src/session/use-mobile-session-terminal-send-actions.ts b/mobile/src/session/use-mobile-session-terminal-send-actions.ts index 0cef0350929..c80edee9271 100644 --- a/mobile/src/session/use-mobile-session-terminal-send-actions.ts +++ b/mobile/src/session/use-mobile-session-terminal-send-actions.ts @@ -14,15 +14,15 @@ import { TERMINAL_INPUT_SEND_OPTIONS } from '../terminal/terminal-send-request' import { normalizeTerminalTextInput } from '../terminal/terminal-text-input-normalization' +import { useAgentSendKeyboardDismissal } from './use-agent-send-keyboard-dismissal' import type { MobileSessionTab } from './mobile-session-route-types' import type { MobileSessionTerminalWebviewModel } from './use-mobile-session-terminal-webview' export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminalWebviewModel) { const { client, - input, - setInput, activeHandle, + activeSessionTab, setActionTarget, setMarkdownActionTarget, setFileActionTarget, @@ -40,12 +40,28 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal activeHandleRef, activeSessionTabTypeRef, sendingRef, + bufferedTerminalDraftState, + getSendCompletionGeneration, handleLiveInputAccessoryBytes, canSend, scheduleDelayedAction, showToast } = scope const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450 + + 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) { @@ -53,12 +69,23 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal } 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, @@ -68,9 +95,17 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal }), 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 } } @@ -200,14 +235,6 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal ] ) - const dismissSoftwareKeyboard = useCallback(() => { - dismissTerminalKeyboard({ - clearPendingLiveInputFocus: () => clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef), - commandInput: commandInputRef.current, - dismissKeyboard: () => Keyboard.dismiss(), - liveInput: liveInputRef.current - }) - }, []) return { handleSend, handleAccessoryKey, @@ -215,7 +242,8 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal clearSessionTabActionSheetKeyboardListener, openSessionTabActionSheet, openSessionTabActionSheetAfterKeyboardDismiss, - dismissSoftwareKeyboard + dismissSoftwareKeyboard, + dismissKeyboardAfterAgentSend } } diff --git a/mobile/src/session/use-mobile-session-terminal-stream-display.ts b/mobile/src/session/use-mobile-session-terminal-stream-display.ts index f1304f15683..915b10095b9 100644 --- a/mobile/src/session/use-mobile-session-terminal-stream-display.ts +++ b/mobile/src/session/use-mobile-session-terminal-stream-display.ts @@ -14,6 +14,7 @@ export function useMobileSessionTerminalStreamDisplay( viewportRef, terminalUnsubsRef, subscribingHandlesRef, + leaseOnlyHandlesRef, initializedHandlesRef, webReadyHandlesRef, activeSessionTab, @@ -30,6 +31,7 @@ export function useMobileSessionTerminalStreamDisplay( streamRevision: coveredStreamRevision, subscriptionsRef: terminalUnsubsRef, subscribingRef: subscribingHandlesRef, + leaseOnlyRef: leaseOnlyHandlesRef, webReadyRef: webReadyHandlesRef, initializedRef: initializedHandlesRef, subscribe: subscribeToTerminal, diff --git a/mobile/src/session/use-mobile-session-terminal-subscription-foundation.ts b/mobile/src/session/use-mobile-session-terminal-subscription-foundation.ts index 2b3b4ea8a89..0289a1c65e9 100644 --- a/mobile/src/session/use-mobile-session-terminal-subscription-foundation.ts +++ b/mobile/src/session/use-mobile-session-terminal-subscription-foundation.ts @@ -13,6 +13,7 @@ export function useMobileSessionTerminalSubscriptionFoundation( terminalRefs, terminalUnsubsRef, subscribingHandlesRef, + leaseOnlyHandlesRef, initializedHandlesRef, terminalDiagnosticsRef, viewportResubscribeBudgetRef, @@ -34,6 +35,7 @@ export function useMobileSessionTerminalSubscriptionFoundation( terminalUnsubsRef.current.get(handle)?.() terminalUnsubsRef.current.delete(handle) subscribingHandlesRef.current.delete(handle) + leaseOnlyHandlesRef.current.delete(handle) terminalDiagnosticsRef.current.terminalUnsubscribed(handle) subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) // Why: reset the high-water mark so a fresh subscription's first scrollback isn't dropped as stale. @@ -66,6 +68,7 @@ export function useMobileSessionTerminalSubscriptionFoundation( clearNativeChatInputLease() terminalUnsubsRef.current.clear() subscribingHandlesRef.current.clear() + leaseOnlyHandlesRef.current.clear() initializedHandlesRef.current.clear() terminalDiagnosticsRef.current.clearTerminalCache() viewportResubscribeBudgetRef.current.clear() diff --git a/mobile/src/session/use-mobile-session-terminal-subscription.ts b/mobile/src/session/use-mobile-session-terminal-subscription.ts index 30480b4ec7b..8ad0bb7383a 100644 --- a/mobile/src/session/use-mobile-session-terminal-subscription.ts +++ b/mobile/src/session/use-mobile-session-terminal-subscription.ts @@ -22,6 +22,7 @@ export function useMobileSessionTerminalSubscription( viewportMeasuredRef, terminalUnsubsRef, subscribingHandlesRef, + leaseOnlyHandlesRef, initializedHandlesRef, terminalDiagnosticsRef, viewportResubscribeBudgetRef, @@ -36,7 +37,8 @@ export function useMobileSessionTerminalSubscription( showNativeChatRef, getTerminalRef, unsubscribeTerminal, - unsubscribeTerminalRef + unsubscribeTerminalRef, + signalTerminalInventoryRecovery } = scope const subscribeToTerminal = useCallback( (handle: string) => { @@ -73,6 +75,11 @@ export function useMobileSessionTerminalSubscription( } subscribingHandlesRef.current.add(handle) + if (covered) { + leaseOnlyHandlesRef.current.add(handle) + } else { + leaseOnlyHandlesRef.current.delete(handle) + } const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1 subscribeSeqRef.current.set(handle, seq) diagnostics.streamArmed(handle, seq, viewportRef.current) @@ -97,6 +104,7 @@ export function useMobileSessionTerminalSubscription( diagnostics.firstStreamEvent(handle, seq, data.type) if (data.type === 'end' || data.type === 'error') { unsubscribeTerminalRef.current(handle) + signalTerminalInventoryRecovery() return } if (data.type === 'subscribed') { @@ -240,7 +248,10 @@ export function useMobileSessionTerminalSubscription( scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200) } }, - () => unsubscribeTerminalRef.current(handle) + () => { + unsubscribeTerminalRef.current(handle) + signalTerminalInventoryRecovery() + } ) if (subscribeSeqRef.current.get(handle) === seq) { @@ -250,7 +261,14 @@ export function useMobileSessionTerminalSubscription( } subscribingHandlesRef.current.delete(handle) }, - [client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction, showToast] + [ + client, + getTerminalRef, + markNativeChatInputLeaseReady, + scheduleDelayedAction, + showToast, + signalTerminalInventoryRecovery + ] ) return { subscribeToTerminal diff --git a/mobile/src/terminal/terminal-foreground-recovery.test.ts b/mobile/src/terminal/terminal-foreground-recovery.test.ts index 74d567ff62a..abf1bd01045 100644 --- a/mobile/src/terminal/terminal-foreground-recovery.test.ts +++ b/mobile/src/terminal/terminal-foreground-recovery.test.ts @@ -1,25 +1,22 @@ -import { readFileSync } from 'node:fs' import type { RefObject } from 'react' import { describe, expect, it, vi } from 'vitest' import type { ConnectionState } from '../transport/types' import type { TerminalWebViewHandle } from './TerminalWebView' +import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' import { TERMINAL_FOREGROUND_RECOVERY_DELAY_MS, recoverActiveTerminalAfterForeground, shouldRecoverTerminalOnAppStateChange } from './terminal-foreground-recovery' -const sessionSource = readFileSync( - new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), - 'utf8' -) +const lifecycleSource = readMobileSessionRouteSource('../session/use-mobile-session-lifecycle.ts') function sliceSessionSource(startPattern: string, endPattern: string): string { - const start = sessionSource.indexOf(startPattern) + const start = lifecycleSource.indexOf(startPattern) expect(start).toBeGreaterThanOrEqual(0) - const end = sessionSource.indexOf(endPattern, start) + const end = lifecycleSource.indexOf(endPattern, start) expect(end).toBeGreaterThan(start) - return sessionSource.slice(start, end) + return lifecycleSource.slice(start, end) } type RecoveryHarness = { @@ -149,14 +146,14 @@ describe('terminal foreground recovery', () => { 'previousAppState = nextAppState' ) - expect(sessionSource).toContain('shouldRecoverTerminalOnAppStateChange') + expect(lifecycleSource).toContain('shouldRecoverTerminalOnAppStateChange') expect(foregroundPredicate).toContain('Platform.OS') - expect(sessionSource).toContain('recoverActiveTerminalAfterForeground({') - expect(sessionSource).toContain("AppState.addEventListener('change'") - const readinessInvalidation = sessionSource.indexOf( + expect(lifecycleSource).toContain('recoverActiveTerminalAfterForeground({') + expect(lifecycleSource).toContain("AppState.addEventListener('change'") + const readinessInvalidation = lifecycleSource.indexOf( 'terminalRef.prepareForForegroundRecovery()' ) - const replay = sessionSource.indexOf('recoverActiveTerminalAfterForeground({') + const replay = lifecycleSource.indexOf('recoverActiveTerminalAfterForeground({') expect(readinessInvalidation).toBeGreaterThanOrEqual(0) expect(replay).toBeGreaterThan(readinessInvalidation) }) @@ -165,7 +162,9 @@ describe('terminal foreground recovery', () => { // Why: resume usually lands mid-reconnect; the session screen must retry // recovery on the connState→connected transition or blanked panes stay // stale until a manual tab switch. - expect(sessionSource).toContain("pendingForegroundRecoveryRef.current = outcome === 'deferred'") + expect(lifecycleSource).toContain( + "pendingForegroundRecoveryRef.current = outcome === 'deferred'" + ) const reconnectRetry = sliceSessionSource( "if (connState !== 'connected' || !pendingForegroundRecoveryRef.current)", 'recoverActiveTerminalAfterForeground({' diff --git a/mobile/src/terminal/terminal-input-connection-gate.test.ts b/mobile/src/terminal/terminal-input-connection-gate.test.ts index 08b08a2f43f..f046a674a8b 100644 --- a/mobile/src/terminal/terminal-input-connection-gate.test.ts +++ b/mobile/src/terminal/terminal-input-connection-gate.test.ts @@ -1,21 +1,27 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { resolveMobileTerminalInputGate } from './terminal-input-connection-gate' import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request' +import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' -const sessionRouteSource = readFileSync( - new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), - 'utf8' +const runtimeSource = readMobileSessionRouteSource( + '../session/use-mobile-session-terminal-runtime.ts' ) +const sendActionsSource = readMobileSessionRouteSource( + '../session/use-mobile-session-terminal-send-actions.ts' +) +const terminalInputSource = readMobileSessionRouteSource( + '../session/use-mobile-session-terminal-input.ts' +) +const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx') -function routeSlice(anchorStart: string, anchorEnd: string): string { - const start = sessionRouteSource.indexOf(anchorStart) +function sourceSlice(source: string, anchorStart: string, anchorEnd: string): string { + const start = source.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(source.indexOf(anchorStart, start + 1)).toBe(-1) + const end = source.indexOf(anchorEnd, start) expect(end).toBeGreaterThan(start) - return sessionRouteSource.slice(start, end + anchorEnd.length) + return source.slice(start, end + anchorEnd.length) } describe('terminal input connection gate', () => { @@ -79,32 +85,46 @@ describe('terminal input connection gate', () => { describe('session route offline-compose wiring', () => { it('derives both gates from the shared resolver', () => { - expect(sessionRouteSource).toContain('resolveMobileTerminalInputGate({') + expect(runtimeSource).toContain('resolveMobileTerminalInputGate({') }) it('keeps the buffered command box editable offline while the live capture stays send-gated', () => { - const bufferedInput = routeSlice( + const bufferedInput = sourceSlice( + commandDockSource, 'ref={commandInputRef}', 'onSubmitEditing={() => void handleSend()}' ) expect(bufferedInput).toContain('editable={canCompose}') - const liveCapture = routeSlice('ref={liveInputRef}', 'importantForAutofill="no"') + const liveCapture = sourceSlice( + commandDockSource, + 'ref={liveInputRef}', + 'importantForAutofill="no"' + ) expect(liveCapture).toContain('editable={canSend}') }) it('keeps the send button connection-gated so held text cannot fire into a dead link', () => { - const sendButton = routeSlice('styles.sendButton,', 'accessibilityLabel="Send command"') + const sendButton = sourceSlice( + commandDockSource, + 'styles.sendButton,', + 'accessibilityLabel="Send command"' + ) expect(sendButton).toContain('disabled={!canSend}') }) it('holds composed text when the return key submits offline', () => { - const handleSend = routeSlice('async function handleSend()', 'sendingRef.current = true') + const handleSend = sourceSlice( + sendActionsSource, + 'async function handleSend()', + 'sendingRef.current = true' + ) expect(handleSend).toContain('!canSend') }) it('keeps the live/buffered mode toggle reachable offline', () => { - const modeToggle = routeSlice( + const modeToggle = sourceSlice( + commandDockSource, 'liveInputEnabled && styles.accessoryKeyActive', 'onPress={toggleLiveInput}' ) @@ -112,7 +132,11 @@ describe('session route offline-compose wiring', () => { }) it('tells the live-input commit hook about connection loss so stale mirror state resets', () => { - const hookCall = routeSlice('useTerminalLiveInputCommit({', 'setLiveInputCapture') + const hookCall = sourceSlice( + runtimeSource, + 'useTerminalLiveInputCommit({', + 'setLiveInputCapture' + ) expect(hookCall).toContain("connected: connState === 'connected'") }) @@ -120,8 +144,13 @@ describe('session route offline-compose wiring', () => { // Live mirror, buffered send, and gesture arrows must all opt out of the // connect wait — a parked send replays stale bytes into the PTY. Accessory // keys get the same option inside terminal-live-accessory-raw-send.ts. - const optOuts = sessionRouteSource.match(/TERMINAL_INPUT_SEND_OPTIONS/g)?.length ?? 0 - expect(optOuts).toBe(4) + expect(sendActionsSource).toContain('TERMINAL_INPUT_SEND_OPTIONS') + expect(terminalInputSource).toContain('TERMINAL_INPUT_SEND_OPTIONS') + const optionUses = [sendActionsSource, terminalInputSource].flatMap( + (source) => source.match(/TERMINAL_INPUT_SEND_OPTIONS/g) ?? [] + ).length + // Two owner imports plus one buffered, one live, and one gesture send. + expect(optionUses).toBe(5) expect(TERMINAL_INPUT_SEND_OPTIONS).toEqual({ failWhenDisconnected: true }) }) 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 093ef8e17dc..9929f09c37d 100644 --- a/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts +++ b/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts @@ -1,9 +1,9 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' +import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' -const sessionRouteSource = readFileSync( - new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), - 'utf8' +const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx') +const sendActionsSource = readMobileSessionRouteSource( + '../session/use-mobile-session-terminal-send-actions.ts' ) // Why: iOS terminates an active keyboard-dictation (and IME) session whenever @@ -13,13 +13,11 @@ 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={bufferedTerminalDraftState.setInput}') - expect(sessionRouteSource).not.toContain( - 'setInput((previousText) => normalizeTerminalTextInput' - ) + expect(commandDockSource).toContain('onChangeText={bufferedTerminalDraftState.setInput}') + expect(commandDockSource).not.toContain('setInput((previousText) => normalizeTerminalTextInput') }) it('still normalizes the buffered command text at send time', () => { - expect(sessionRouteSource).toContain('normalizeTerminalTextInput(draft)') + expect(sendActionsSource).toContain('normalizeTerminalTextInput(draft)') }) }) diff --git a/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts b/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts index e34aef649c4..573dcf04bbf 100644 --- a/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts +++ b/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts @@ -1,26 +1,23 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' +import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' -const sessionRouteSource = readFileSync( - new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), - 'utf8' -) +const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx') describe('terminal iOS IME keyboard', () => { it('does not force terminal inputs onto the ASCII-only iOS keyboard', () => { - expect(sessionRouteSource).not.toContain("'ascii-capable'") - expect(sessionRouteSource).not.toContain('"ascii-capable"') + expect(commandDockSource).not.toContain("'ascii-capable'") + expect(commandDockSource).not.toContain('"ascii-capable"') }) it('subscribes live capture to onChange so the marked-text report survives', () => { // onChangeText hands over only a string, discarding the preedit report that // decides whether the text may reach the PTY at all. - expect(sessionRouteSource).toContain('onChange={handleLiveInputChange}') - expect(sessionRouteSource).not.toContain('onChangeText={handleLiveInputChange}') + expect(commandDockSource).toContain('onChange={handleLiveInputChange}') + expect(commandDockSource).not.toContain('onChangeText={handleLiveInputChange}') }) it('does not put terminal keyboard capture behind iOS textContentType semantics', () => { - expect(sessionRouteSource).not.toContain('textContentType="none"') - expect(sessionRouteSource).toContain('autoComplete="off"') + expect(commandDockSource).not.toContain('textContentType="none"') + expect(commandDockSource).toContain('autoComplete="off"') }) }) diff --git a/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts index 3a09ae3acd4..203db63bc65 100644 --- a/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts +++ b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts @@ -4,11 +4,9 @@ import { Terminal } from '@xterm/xterm' import { describe, expect, it, vi } from 'vitest' import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from './terminal-keyboard-avoidance-metrics-injected' import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract' +import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' -const terminalHtmlSource = readFileSync( - new URL('./terminal-webview-html.ts', import.meta.url), - 'utf8' -) +const terminalHtmlSource = readTerminalWebViewHtmlSource() const reflowSource = readFileSync( new URL('./terminal-webview-reflow-injected.ts', import.meta.url), 'utf8' diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts index 4a7b3bde817..565b355d771 100644 --- a/mobile/src/terminal/terminal-live-input-affordance.test.ts +++ b/mobile/src/terminal/terminal-live-input-affordance.test.ts @@ -1,9 +1,13 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' -const sessionRouteSource = readFileSync( - new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), - 'utf8' +const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx') +const terminalRuntimeSource = readMobileSessionRouteSource( + '../session/use-mobile-session-terminal-runtime.ts' +) +const nativeChatSource = readMobileSessionRouteSource( + '../session/use-mobile-session-native-chat-dictation.ts' ) const liveInputStatusSource = readFileSync( new URL('../session/MobileTerminalLiveInputStatus.tsx', import.meta.url), @@ -23,11 +27,11 @@ const sendCompletionGenerationSource = readFileSync( ) function liveInputBarBlock(): string { - const start = sessionRouteSource.indexOf('{liveInputEnabled ? (') + const start = commandDockSource.indexOf('{liveInputEnabled ? (') expect(start).toBeGreaterThanOrEqual(0) - const end = sessionRouteSource.indexOf(') : (', start) + const end = commandDockSource.indexOf(') : (', start) expect(end).toBeGreaterThan(start) - return sessionRouteSource.slice(start, end) + return commandDockSource.slice(start, end) } describe('terminal live input affordance', () => { @@ -44,9 +48,9 @@ describe('terminal live input affordance', () => { expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled') expect(block).toContain('showSoftInputOnFocus') expect(block).toContain('liveInputText={liveInputCapture}') - expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({') - expect(sessionRouteSource).toContain('useMobileSendCompletionGeneration({') - expect(sessionRouteSource).toContain('onBlur: resetLiveInputFocus') + expect(terminalRuntimeSource).toContain('useTerminalLiveInputFocus({') + expect(nativeChatSource).toContain('useMobileSendCompletionGeneration({') + expect(nativeChatSource).toContain('onBlur: resetLiveInputFocus') expect(sendCompletionGenerationSource).toContain('return () => {') expect(sendCompletionGenerationSource).toContain('onBlur()') expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current') diff --git a/mobile/src/terminal/terminal-viewport-refit.test.ts b/mobile/src/terminal/terminal-viewport-refit.test.ts index b472fb7847e..81ca306f3a4 100644 --- a/mobile/src/terminal/terminal-viewport-refit.test.ts +++ b/mobile/src/terminal/terminal-viewport-refit.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import type { RpcResponse } from '../transport/types' +import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' import { isTerminalUpdateViewportApplied, isTerminalUpdateViewportUpdated, @@ -13,10 +14,10 @@ import { } from './terminal-viewport-refit-state' const hookSource = readFileSync(new URL('./terminal-viewport-refit.ts', import.meta.url), 'utf8') -const sessionSource = readFileSync( - new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), - 'utf8' -) +const sessionSource = [ + readMobileSessionRouteSource('../session/use-mobile-session-keyboard-state.ts'), + readMobileSessionRouteSource('../session/MobileSessionActiveContent.tsx') +].join('\n') describe('terminal viewport refit', () => { it('refits when the window dimensions change (fold/unfold, rotation)', () => { diff --git a/mobile/src/terminal/terminal-webview-engine.test.ts b/mobile/src/terminal/terminal-webview-engine.test.ts index 503a25a1961..ac415922851 100644 --- a/mobile/src/terminal/terminal-webview-engine.test.ts +++ b/mobile/src/terminal/terminal-webview-engine.test.ts @@ -1,15 +1,14 @@ -import { readFileSync } from 'node:fs' import { Script } from 'node:vm' import { parse } from 'acorn' import { describe, expect, it, vi } from 'vitest' import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' import { XTERM_HTML } from './terminal-webview-html' +import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected' -const terminalHtmlSource = readFileSync( - new URL('./terminal-webview-html.ts', import.meta.url), - 'utf8' -) +// Assert against the assembled document so extracted fragments cannot silently +// disappear from the WebView while source-level checks still pass. +const terminalHtmlSource = readTerminalWebViewHtmlSource() function createWebglRecoveryHarness(failSecondAttach = false) { const variablesStart = terminalHtmlSource.indexOf(' var webglAddon = null;') diff --git a/mobile/src/terminal/terminal-webview-html-source.test-support.ts b/mobile/src/terminal/terminal-webview-html-source.test-support.ts new file mode 100644 index 00000000000..2491591572f --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html-source.test-support.ts @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs' + +const SOURCE_FILES = [ + './terminal-webview-html.ts', + ...Array.from( + { length: 10 }, + (_, index) => `./terminal-webview-html/fragment-${String(index + 1).padStart(2, '0')}.ts` + ) +] as const + +/** Reads the TypeScript source that assembles the in-WebView document. */ +export function readTerminalWebViewHtmlSource(): string { + return SOURCE_FILES.map((relativePath) => + readFileSync(new URL(relativePath, import.meta.url), 'utf8') + ).join('\n') +} diff --git a/mobile/src/terminal/terminal-webview-reflow.test.ts b/mobile/src/terminal/terminal-webview-reflow.test.ts index 5fb78bb33ec..90112740d1d 100644 --- a/mobile/src/terminal/terminal-webview-reflow.test.ts +++ b/mobile/src/terminal/terminal-webview-reflow.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { XTERM_HTML } from './terminal-webview-html' +import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' // The reflow logic lives as injected in-WebView JS; the message dispatch and // handle wiring live in terminal-webview-html.ts / TerminalWebView.tsx. Assert @@ -9,7 +10,8 @@ const reflowSource = readFileSync( new URL('./terminal-webview-reflow-injected.ts', import.meta.url), 'utf8' ) -const htmlSource = readFileSync(new URL('./terminal-webview-html.ts', import.meta.url), 'utf8') +// Use the assembled document so the test covers the fragments that run in the WebView. +const htmlSource = readTerminalWebViewHtmlSource() const handleSource = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') function reflowFnBody(): string { diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts index 32bc6dbd529..962cfddaa4d 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -8,9 +8,18 @@ const source = readFileSync(new URL('./terminal-webview-pending-messages.ts', import.meta.url), 'utf8') + readFileSync(new URL('./terminal-webview-url-tap.ts', import.meta.url), 'utf8') + readFileSync(new URL('./terminal-webview-tap-dispatch-injected.ts', import.meta.url), 'utf8') + - readFileSync(new URL('./terminal-webview-html.ts', import.meta.url), 'utf8') + readFileSync(new URL('./terminal-webview-html.ts', import.meta.url), 'utf8') + + Array.from({ length: 10 }, (_, index) => + readFileSync( + new URL( + `./terminal-webview-html/fragment-${String(index + 1).padStart(2, '0')}.ts`, + import.meta.url + ), + 'utf8' + ) + ).join('') const sessionSource = readFileSync( - new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), + new URL('../session/use-mobile-session-terminal-input.ts', import.meta.url), 'utf8' ) const sessionHelperSource = readFileSync( diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index cf16a93c0e6..0deee79f670 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -1,15 +1,23 @@ import { readFileSync } from 'node:fs' import { Script } from 'node:vm' import { describe, expect, it } from 'vitest' +import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' const terminalWebViewSource = readFileSync( new URL('./TerminalWebView.tsx', import.meta.url), 'utf8' ) -const terminalHtmlSource = readFileSync( +const terminalHtmlModuleSource = readFileSync( new URL('./terminal-webview-html.ts', import.meta.url), 'utf8' ) +const terminalHtmlFragmentSource = readFileSync( + new URL('./terminal-webview-html/fragment-01.ts', import.meta.url), + 'utf8' +) +// Read behavior from the assembled document; the module source only contains +// fragment imports and cannot prove the injected code is present. +const terminalHtmlSource = readTerminalWebViewHtmlSource() const terminalWebglRecoverySource = readFileSync( new URL('./terminal-webview-webgl-recovery-injected.ts', import.meta.url), 'utf8' @@ -75,7 +83,9 @@ describe('TerminalWebView text zoom', () => { const end = terminalWebViewSource.indexOf('/>', start) expect(end).toBeGreaterThan(start) const webViewProps = terminalWebViewSource.slice(start, end) - expect(terminalHtmlSource).toContain('export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }') + expect(terminalHtmlModuleSource).toContain( + 'export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }' + ) expect(webViewProps).toContain('source={XTERM_WEBVIEW_SOURCE}') expect(webViewProps).not.toContain('source={{ html: XTERM_HTML }}') }) @@ -140,7 +150,7 @@ describe('TerminalWebView text zoom', () => { }) it('loads Unicode 11 before replaying mobile terminal bytes', () => { - expect(terminalHtmlSource).toContain('XTERM_ENGINE_JS') + expect(terminalHtmlFragmentSource).toContain('XTERM_ENGINE_JS') expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon') const open = terminalHtmlSource.indexOf('term.open(surface)') const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'")