mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 16:02:03 +00:00
fix(mobile): restore session parity after extraction
(cherry picked from commit 47706a5388)
This commit is contained in:
@@ -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'")
|
||||
|
||||
Reference in New Issue
Block a user