diff --git a/mobile/src/components/HostProtocolGate.test.ts b/mobile/src/components/HostProtocolGate.test.ts index b8b5ffb175e..31acec292a0 100644 --- a/mobile/src/components/HostProtocolGate.test.ts +++ b/mobile/src/components/HostProtocolGate.test.ts @@ -414,6 +414,52 @@ describe('HostProtocolGate', () => { }) }) + it('sends one activate when hydration lands after the creation route is consumed', async () => { + vi.useFakeTimers() + startupScope.created = '1' + // Slow hydration is the window the recovery timer must not beat. + startupScope.ensureSessionTabs = () => new Promise((resolve) => setTimeout(resolve, 3000)) + const { activate, client, settle } = clientWithDeferredStatus() + hostClient.current = { client: null, state: 'connecting' } + renderer = await renderStartupGate() + await connectWithPendingStatus(client) + await settleVerdict(settle) + + await act(async () => { + await vi.advanceTimersByTimeAsync(3000) + }) + // Hydration landed: a tab claims the active handle and autocreate consumes ?created=1. + startupScope.activeHandleRef.current = 'term-1' + startupScope.created = undefined + await act(async () => { + renderer?.update(startupGateElement()) + await Promise.resolve() + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(5000) + }) + + expect(activate).toHaveBeenCalledTimes(1) + }) + + it('never activates when the verdict comes back blocked', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { activate, client, settle } = clientWithDeferredStatus() + hostClient.current = { client: null, state: 'connecting' } + renderer = await renderStartupGate() + await connectWithPendingStatus(client) + + await act(async () => { + settle({ protocolVersion: 1, minCompatibleMobileVersion: 0 }) + await Promise.resolve() + }) + + const output = renderedText(renderer) + expect(output).toContain('Update Orca on your computer') + expect(output).not.toContain('StartupProbe') + expect(activate).not.toHaveBeenCalled() + }) + it('skips the created-workspace activate recovery when a terminal is already active', async () => { vi.useFakeTimers() startupScope.created = '1' diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index 94c9f7fb310..2a0bf0f503e 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -62,12 +62,12 @@ const HOST_COMPONENT_NAMES = new Set([ 'View' ]) -const HEAD_MAIN_HOOK_SHA256 = 'c4e5825b6fe1ba9c64b5555fb3b0c6d37e7a72558cb5bd27df306b6649555891' -const HEAD_HOOK_BINDING_SHA256 = '969b8bc0193af0e817dcdb682fbed4cfd5a44c228b3e664860c4c94dbd426b4f' +const HEAD_MAIN_HOOK_SHA256 = '06878d6a74b932f5279fdf0bbe89f4f6410e2890271da386deb3b6c04ed178bf' +const HEAD_HOOK_BINDING_SHA256 = 'c7997ece2e5643276f7569455cc24d88381626b410f590281b1ca5b7539ec739' const HEAD_CALLBACK_IDENTITY_SHA256 = '2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb' const HEAD_CALLBACK_BODY_SHA256 = '22103ba85a86e3a3fcb80a7509c7a455d79863010cde3af02db6565b55e3ebe9' -const HEAD_EFFECT_SHA256 = '327c84aa19f531e2303cd8efa6cbe35304965131697c5081f09edba29fa3d645' +const HEAD_EFFECT_SHA256 = '258135b4b1809f15033abcbfd12cd6249f4985feb7ac9d320b6ba275a4e090ca' const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581' const HEAD_NESTED_FUNCTION_SHA256 = '0e553eb5ec7aeda8f8336b8da85ff87eb3657a21fa32d3c75c9cc32e36860244' @@ -472,7 +472,7 @@ describe('mobile session route extraction parity', () => { const contentBindings = CONTENT_COMPONENT_NAMES.flatMap( (name) => readHookFacts(name, definitions).bindings ) - expect(main.hooks).toHaveLength(268) + 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) diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index 4096ee2c3dc..4226dde1384 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -149,7 +149,7 @@ describe('mobile session startup', () => { it('loads session tabs without waiting for desktop activation', () => { const readsEffect = sliceBetween( - 'void (async () => {', + 'hydrationRef.current = (async () => {', 'return () => {\n disposed = true', startupSource ) @@ -188,6 +188,11 @@ describe('mobile session startup', () => { expect(activateEffect).toContain('notifyClients: false') expect(activateEffect).toContain("navigation: 'caller'") expect(activateEffect).not.toContain('await ensureSessionTabs()') + // The recovery arms only once hydration settled, so a consumed `created` cannot add a second send. + expect(activateEffect).toContain('await hydrationRef.current') + expect(activateEffect.indexOf('await hydrationRef.current')).toBeLessThan( + activateEffect.indexOf('}, 1800)') + ) // The created-workspace recovery still yields to a terminal that claimed the route first. expect(activateEffect).toContain('if (activeHandleRef.current)') expect(activateEffect).toContain('}, 1800)') diff --git a/mobile/src/session/use-mobile-session-startup.ts b/mobile/src/session/use-mobile-session-startup.ts index 7bf630d2538..ce25b6421c4 100644 --- a/mobile/src/session/use-mobile-session-startup.ts +++ b/mobile/src/session/use-mobile-session-startup.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import type { RpcSuccess } from '../transport/types' import { headlessActivationNeedsHostRenderer } from '../worktree/worktree-activation-result' import { createInitialSessionAutoCreateState } from './use-initial-session-terminal-autocreate' @@ -45,6 +45,8 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel) fetchTerminals, ensureSessionTabs } = scope + // Holds the in-flight tab/terminal hydration so the activation effect can wait on it. + const hydrationRef = useRef | null>(null) useEffect(() => { // Why: Expo reuses this screen across worktrees; reset route state so it can't open stale UI or reject the next snapshot. sessionTabActionSheetRequestSeqRef.current += 1 @@ -116,7 +118,7 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel) } timers.push(setTimeout(fn, ms)) } - void (async () => { + hydrationRef.current = (async () => { await ensureSessionTabs().catch(() => null) if (disposed) { return @@ -177,26 +179,34 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel) .then((response) => reportActivationOutcome(response.ok ? response : null)) .catch(() => null) } else { - addTimer(() => { - if (activeHandleRef.current) { + // Why: hydration can claim the active handle and consume `created`; arming the recovery + // before it settles would let this route send a second activate once `created` clears. + void (async () => { + await hydrationRef.current + if (disposed) { return } - void (async () => { - const activationResponse = await client - .sendRequest('worktree.activate', { - worktree: `id:${worktreeId}`, - notifyClients: false, - navigation: 'caller' - }) - .catch(() => null) - reportActivationOutcome(activationResponse?.ok ? activationResponse : null) - if (disposed) { + addTimer(() => { + if (activeHandleRef.current) { return } - await fetchTerminals({ allowEmptyLoaded: true }) - addTimer(() => void fetchTerminals({ allowEmptyLoaded: true }), 750) - })() - }, 1800) + void (async () => { + const activationResponse = await client + .sendRequest('worktree.activate', { + worktree: `id:${worktreeId}`, + notifyClients: false, + navigation: 'caller' + }) + .catch(() => null) + reportActivationOutcome(activationResponse?.ok ? activationResponse : null) + if (disposed) { + return + } + await fetchTerminals({ allowEmptyLoaded: true }) + addTimer(() => void fetchTerminals({ allowEmptyLoaded: true }), 750) + })() + }, 1800) + })() } return () => { disposed = true