From 32b21b660261fc44cf63e886ac8024b728e894ba Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Mon, 31 Aug 2026 03:43:38 -0700
Subject: [PATCH] fix(mobile): restore session parity after extraction
(cherry picked from commit 47706a5388a0ca3c9caea8d13f3d0e1ed7a7ad02)
---
.../session/MobileSessionActiveContent.tsx | 24 ++-
.../src/session/MobileSessionCommandDock.tsx | 29 ++-
...ent-send-keyboard-dismissal-wiring.test.ts | 80 +++++---
.../mobile-session-route-parity.test.ts | 32 +--
.../mobile-session-startup-source.test.ts | 20 +-
.../session/use-mobile-session-bulk-close.ts | 12 +-
.../use-mobile-session-close-actions.ts | 4 +
.../use-mobile-session-file-actions.ts | 5 +
...se-mobile-session-native-chat-dictation.ts | 10 +-
.../use-mobile-session-screen-state.ts | 3 +
.../src/session/use-mobile-session-startup.ts | 7 +
.../use-mobile-session-tab-application.ts | 65 ++++---
.../use-mobile-session-tab-reconciliation.ts | 70 +++++--
.../use-mobile-session-terminal-list.ts | 183 ++++++++++--------
.../use-mobile-session-terminal-runtime.ts | 22 ++-
...se-mobile-session-terminal-send-actions.ts | 58 ++++--
...-mobile-session-terminal-stream-display.ts | 2 +
...ession-terminal-subscription-foundation.ts | 3 +
...se-mobile-session-terminal-subscription.ts | 24 ++-
.../terminal-foreground-recovery.test.ts | 27 ++-
.../terminal-input-connection-gate.test.ts | 65 +++++--
.../terminal-ios-dictation-write-back.test.ts | 16 +-
.../terminal-ios-ime-keyboard.test.ts | 19 +-
...erminal-keyboard-avoidance-webview.test.ts | 6 +-
.../terminal-live-input-affordance.test.ts | 24 ++-
.../terminal/terminal-viewport-refit.test.ts | 9 +-
.../terminal/terminal-webview-engine.test.ts | 9 +-
...rminal-webview-html-source.test-support.ts | 16 ++
.../terminal/terminal-webview-reflow.test.ts | 4 +-
.../terminal-webview-scroll-routing.test.ts | 13 +-
.../terminal-webview-text-zoom.test.ts | 16 +-
31 files changed, 590 insertions(+), 287 deletions(-)
create mode 100644 mobile/src/terminal/terminal-webview-html-source.test-support.ts
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