fix(mobile): restore session parity after extraction

(cherry picked from commit 47706a5388)
This commit is contained in:
Neil
2026-08-31 21:16:45 -07:00
parent 40edfd9552
commit 32b21b6602
31 changed files with 590 additions and 287 deletions
@@ -72,6 +72,8 @@ export function MobileSessionActiveContent({
activeFileTab,
activeBrowserTab,
activePendingTerminalTab,
isPendingTerminalRecoveryParked,
retryPendingTerminalRecovery,
showLoadingState,
showEmptyState,
keyboardLift,
@@ -170,8 +172,24 @@ export function MobileSessionActiveContent({
</View>
) : activePendingTerminalTab ? (
<View style={styles.emptyState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.emptyText}>{activePendingTerminalTab.title || 'Loading terminal'}</Text>
{!isPendingTerminalRecoveryParked && (
<ActivityIndicator size="small" color={colors.textSecondary} />
)}
<Text style={styles.emptyText}>
{isPendingTerminalRecoveryParked
? 'Terminal is taking longer than expected'
: activePendingTerminalTab.title || 'Loading terminal'}
</Text>
{isPendingTerminalRecoveryParked && (
<Pressable
accessibilityRole="button"
accessibilityLabel="Retry loading terminal"
style={({ pressed }) => [styles.createButton, pressed && styles.newTerminalButtonPressed]}
onPress={() => void retryPendingTerminalRecovery()}
>
<Text style={styles.createButtonText}>Retry</Text>
</Pressable>
)}
</View>
) : (
<View
@@ -225,6 +243,8 @@ export function MobileSessionActiveContent({
inputLockReason={nativeChatInputLockReason}
sendErrorMessage={nativeChatSendError.message}
onClearSendError={nativeChatSendError.clear}
sendSurfaceId={controller.nativeChatScopeKey ?? ''}
getSendCompletionGeneration={controller.getSendCompletionGeneration}
keyboardInset={keyboardLift}
/>
{toastMessage && (
@@ -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()}
@@ -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('
)
@@ -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)
})
})
@@ -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'
@@ -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
}
}
@@ -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
@@ -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),
@@ -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,
@@ -58,6 +58,8 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel)
const [coveredStreamRevision, setCoveredStreamRevision] = useState(0)
const [activeSessionTabId, setActiveSessionTabId] = useState<string | null>(null)
const activeSessionTabIdRef = useRef<string | null>(null)
// Preserve an explicit phone tab pick while a host snapshot is transiently incomplete.
const selectedSessionTabIdRef = useRef<string | null>(null)
// Auto-scroll the tab strip so the desktop-synced active tab is revealed without a manual scroll.
const tabStripRef = useRef<ScrollView>(null)
const tabStripOffsetRef = useRef(0)
@@ -168,6 +170,7 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel)
activeSessionTabId,
setActiveSessionTabId,
activeSessionTabIdRef,
selectedSessionTabIdRef,
tabStripRef,
tabStripOffsetRef,
tabStripViewportWidthRef,
@@ -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
])
@@ -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)
@@ -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<string | null>(
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<SessionTabsResult>({
worktreeId,
diagnosticsRef: terminalDiagnosticsRef
})
const { fetchSessionTabs, ensureSessionTabs, fetchPendingBrowserSessionTabs } =
useMobileSessionTabsReconciliation<SessionTabsResult, MobileSessionTab>({
client,
connState,
worktreeId,
applySessionTabs,
consumeAcceptedSessionTabs,
fetchTerminals,
hasRecoveryNeed: hasSessionTabsRecoveryNeed,
getApplicationRevision: getSessionTabsApplicationRevision,
...sessionTabsFetchReporting
})
const {
fetchSessionTabs,
ensureSessionTabs,
fetchPendingBrowserSessionTabs,
retryPendingTerminalRecovery,
requestTerminalInventoryRecovery
} = useMobileSessionTabsReconciliation<SessionTabsResult, MobileSessionTab>({
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
}
}
@@ -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<boolean> => {
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<string>()
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<string>()
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
}
}
@@ -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<Map<string, () => void>>(new Map())
const subscribingHandlesRef = useRef<Set<string>>(new Set())
// Lease-only streams do not render, so reconciliation tracks them separately.
const leaseOnlyHandlesRef = useRef<Set<string>>(new Set())
const initializedHandlesRef = useRef<Set<string>>(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<Set<string>>(new Set())
const activeHandleRef = useRef<string | null>(null)
const bufferedTerminalDraftState = useBufferedTerminalDrafts({ activeHandle, activeHandleRef })
const reconcileBufferedDraftsRef = useRef(bufferedTerminalDraftState.reconcileTerminalTabs)
reconcileBufferedDraftsRef.current = bufferedTerminalDraftState.reconcileTerminalTabs
const activeSessionTabTypeRef = useRef<MobileSessionTabType | null>(null)
const pendingActiveSessionTabIdRef = useRef<string | null>(null)
const pendingActiveTerminalHandleRef = useRef<string | null>(null)
@@ -79,6 +86,9 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
const pendingTerminalActivationAttemptRef = useRef<string | null>(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<boolean>) | null>(null)
const terminalInventoryRecoveryScope = JSON.stringify([hostId, worktreeId])
const { registerTerminalInventoryRecoveryAction, signalTerminalInventoryRecovery } =
useMobileTerminalInventoryRecoveryBridge(terminalInventoryRecoveryScope)
const initialSessionAutoCreateRef = useRef(createInitialSessionAutoCreateState())
const markdownSaveSeqRef = useRef<Map<string, number>>(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
}
}
@@ -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
}
}
@@ -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,
@@ -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()
@@ -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
@@ -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({'
@@ -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 })
})
@@ -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)')
})
})
@@ -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"')
})
})
@@ -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'
@@ -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')
@@ -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)', () => {
@@ -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;')
@@ -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')
}
@@ -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 {
@@ -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(
@@ -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'")