mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
fix(mobile): keep the resume route across a hosted package swap
A desktop update swaps the hosted page under the user; the shell reset the remembered route to the workspace list on every session id, so a session only came back through cold resume after the list mounted and fetched. Scope the memory to the host so init replays the session route directly. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
+27
-14
@@ -28,6 +28,7 @@ import { useMobileWebAppForegroundAuthority } from '../src/mobile-web/use-mobile
|
||||
import { useMobileWebHostCatalog } from '../src/mobile-web/use-mobile-web-host-catalog'
|
||||
import { mobileWebDiagnosticsStore } from '../src/mobile-web/mobile-web-diagnostics-store'
|
||||
import { useMobileWebBridgeRuntimeRef } from '../src/mobile-web/use-mobile-web-bridge-runtime-ref'
|
||||
import { useMobileWebResumeRouteMemory } from '../src/mobile-web/use-mobile-web-resume-route-memory'
|
||||
import { useMobileWebHardwareBackHandoff } from '../src/mobile-web/use-mobile-web-hardware-back-handoff'
|
||||
import { MobileWebNativeRouteHandoff } from '../src/mobile-web/mobile-web-native-route-handoff'
|
||||
import { useMobileWebNavigationAuthority } from '../src/mobile-web/use-mobile-web-navigation-authority'
|
||||
@@ -49,7 +50,6 @@ export default function HybridScreen() {
|
||||
const viewRef = useRef<MobileWebShellViewRef>(null)
|
||||
const activeSessionIdRef = useRef<string | undefined>(undefined)
|
||||
const initializedSessionRef = useRef<string | undefined>(undefined)
|
||||
const resumeRouteRef = useRef<MobileWebResumeRoute>({ kind: 'workspaceList' })
|
||||
const healthDeadlineRef = useRef(new MobileWebHealthDeadline(10_000))
|
||||
const brokerRef = useRef<MobileWebCapabilityBroker | null>(null)
|
||||
const postInitRef = useRef<() => Promise<void>>(() => Promise.resolve())
|
||||
@@ -64,6 +64,7 @@ export default function HybridScreen() {
|
||||
const [brokerSessionId, setBrokerSessionId] = useState<string>()
|
||||
const [hostedViewActive, setHostedViewActive] = useState(true)
|
||||
const selectHost = useCallback((hostId: string | undefined) => setSelectedHostId(hostId), [])
|
||||
const resumeRoute = useMobileWebResumeRouteMemory(selectedHostId)
|
||||
const e2eHostId = useMobileWebE2eHostSelection(hosts, selectedHostId, selectHost)
|
||||
const { client, state } = useHostClient(selectedHostId)
|
||||
const closeHostClient = useForgetHostClient()
|
||||
@@ -139,10 +140,6 @@ export default function HybridScreen() {
|
||||
}
|
||||
}, [params.hostId])
|
||||
|
||||
useEffect(() => {
|
||||
resumeRouteRef.current = { kind: 'workspaceList' }
|
||||
}, [session?.sessionId])
|
||||
|
||||
// A view-epoch bump replaces the document, so every page-scoped grant retires with it.
|
||||
useEffect(() => {
|
||||
initializedSessionRef.current = undefined
|
||||
@@ -206,7 +203,7 @@ export default function HybridScreen() {
|
||||
onTerminalResync: (reason) =>
|
||||
mobileWebDiagnosticsStore.terminalResync(selectedHost.id, reason),
|
||||
rememberRoute(route) {
|
||||
resumeRouteRef.current = route
|
||||
resumeRoute.remember(route)
|
||||
},
|
||||
rememberHostRoute: coldResumeRoute.rememberHostRoute,
|
||||
randomBytes: ExpoCrypto.getRandomBytes
|
||||
@@ -216,6 +213,7 @@ export default function HybridScreen() {
|
||||
coldResumeRoute.rememberHostRoute,
|
||||
navigationAuthority,
|
||||
postToWeb,
|
||||
resumeRoute,
|
||||
selectedHost?.deviceToken,
|
||||
selectedHost?.id,
|
||||
selectedHost?.publicKeyB64,
|
||||
@@ -260,10 +258,10 @@ export default function HybridScreen() {
|
||||
hostDisplayName: hostName,
|
||||
reconnectAttempts: reconnects,
|
||||
lastConnectedAt: lastConnected,
|
||||
resumeRoute: resumeRouteRef.current,
|
||||
resumeRoute: resumeRoute.current(),
|
||||
grants: [...MOBILE_WEB_PRODUCTION_GRANTS]
|
||||
})
|
||||
}, [hostName, lastConnected, onHealthTimeout, postToWeb, reconnects, session, state])
|
||||
}, [hostName, lastConnected, onHealthTimeout, postToWeb, reconnects, resumeRoute, session, state])
|
||||
useEffect(() => {
|
||||
postInitRef.current = postInit
|
||||
}, [postInit])
|
||||
@@ -320,13 +318,27 @@ export default function HybridScreen() {
|
||||
viewRef,
|
||||
routeHandoff: nativeRouteHandoffRef.current,
|
||||
setHostedViewActive,
|
||||
navigateToTerminalSettings: () => router.push('/terminal-settings'),
|
||||
onNavigationFailure: () => showWarning('Couldn’t open Terminal settings.')
|
||||
navigateToNativeRoute: (destination) => {
|
||||
if (destination === 'connectionLog') {
|
||||
router.push({
|
||||
pathname: '/connection-log',
|
||||
params: { hostId: selectedHostId ?? '' }
|
||||
})
|
||||
return
|
||||
}
|
||||
router.push('/terminal-settings')
|
||||
},
|
||||
onNavigationFailure: (destination) =>
|
||||
showWarning(
|
||||
destination === 'connectionLog'
|
||||
? 'Couldn’t open network diagnostics.'
|
||||
: 'Couldn’t open Terminal settings.'
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[hardwareBackHandoff, markHealthy, postInit, router, session, showWarning]
|
||||
[hardwareBackHandoff, markHealthy, postInit, router, selectedHostId, session, showWarning]
|
||||
)
|
||||
const shellContext = useMemo(
|
||||
() => (session ? { sessionId: session.sessionId, buildId: session.buildId } : null),
|
||||
@@ -334,9 +346,10 @@ export default function HybridScreen() {
|
||||
)
|
||||
|
||||
const getBroker = useCallback(() => brokerRef.current, [])
|
||||
const rememberRoute = useCallback((route: MobileWebResumeRoute) => {
|
||||
resumeRouteRef.current = route
|
||||
}, [])
|
||||
const rememberRoute = useCallback(
|
||||
(route: MobileWebResumeRoute) => resumeRoute.remember(route),
|
||||
[resumeRoute]
|
||||
)
|
||||
useMobileWebNavigationIntentHandoff({
|
||||
hosts,
|
||||
hostsLoading,
|
||||
|
||||
@@ -70,9 +70,9 @@ describe('mobile native shell route ownership', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('opens terminal settings in the shell without clearing the hosted session', () => {
|
||||
it('opens shell-owned screens without clearing the hosted session', () => {
|
||||
const settingsBranch = navigationAuthority.match(
|
||||
/if \(destination === 'terminalSettings'\) \{([\s\S]*?)\n\s*\}/
|
||||
/if \(isMobileWebNativeRoute\(destination\)\) \{([\s\S]*?)\n\s*\}/
|
||||
)?.[1]
|
||||
expect(settingsBranch).toContain('routeHandoffRef.current.record(requestId, destination)')
|
||||
expect(hybridShell).toContain('routeHandoffRef: nativeRouteHandoffRef')
|
||||
@@ -82,6 +82,7 @@ describe('mobile native shell route ownership', () => {
|
||||
expect(brokerMessageHandoff).toContain('await view.deactivateSessionView()')
|
||||
expect(brokerMessageHandoff).toContain('setHostedViewActive: args.setHostedViewActive')
|
||||
expect(hybridShell).toContain("router.push('/terminal-settings')")
|
||||
expect(hybridShell).toContain("pathname: '/connection-log'")
|
||||
expect(hybridShell).toContain('void view.activateSessionView(sessionId)')
|
||||
expect(hybridShell).toContain('return () => setHostedViewActive(false)')
|
||||
expect(settingsBranch).not.toContain('clearRoute')
|
||||
@@ -95,6 +96,12 @@ describe('mobile native shell route ownership', () => {
|
||||
expect(hybridShell).toContain('postInitRef.current = postInit')
|
||||
})
|
||||
|
||||
it('replays the hosted route across a package swap instead of resetting per session', () => {
|
||||
expect(hybridShell).toContain('useMobileWebResumeRouteMemory(selectedHostId)')
|
||||
expect(hybridShell).toContain('resumeRoute: resumeRoute.current()')
|
||||
expect(hybridShell).not.toMatch(/resumeRoute[\s\S]{0,80}\}, \[session\?\.sessionId\]\)/)
|
||||
})
|
||||
|
||||
it('does not echo init after the hosted page acknowledges it', () => {
|
||||
const readyBranch = hybridShell.match(
|
||||
/if \(parsed\.value\.type === 'ready'\) \{([\s\S]*?)\} else if/
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { MobileWebResumeRoute } from '../../../src/shared/mobile-web/bridge-contract'
|
||||
import {
|
||||
useMobileWebResumeRouteMemory,
|
||||
type MobileWebResumeRouteMemory
|
||||
} from './use-mobile-web-resume-route-memory'
|
||||
|
||||
const SESSION_ROUTE: MobileWebResumeRoute = {
|
||||
kind: 'session',
|
||||
workspaceId: 'repo::wt',
|
||||
workspaceName: 'Workspace one'
|
||||
}
|
||||
|
||||
describe('useMobileWebResumeRouteMemory', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let memory: MobileWebResumeRouteMemory | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
renderer = null
|
||||
memory = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
})
|
||||
|
||||
function Harness(props: { hostId: string | undefined; shellSessionId: string | undefined }) {
|
||||
memory = useMobileWebResumeRouteMemory(props.hostId)
|
||||
// Why: a package swap mints a new shell session, so the harness re-renders like the shell does.
|
||||
void props.shellSessionId
|
||||
return null
|
||||
}
|
||||
|
||||
function render(hostId: string | undefined, shellSessionId: string | undefined): void {
|
||||
act(() => {
|
||||
const element = createElement(Harness, { hostId, shellSessionId })
|
||||
if (renderer) {
|
||||
renderer.update(element)
|
||||
} else {
|
||||
renderer = create(element)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('starts at the workspace list', () => {
|
||||
render('host-1', undefined)
|
||||
expect(memory?.current()).toEqual({ kind: 'workspaceList' })
|
||||
})
|
||||
|
||||
it('replays the remembered route after a package swap mints a new shell session', () => {
|
||||
render('host-1', 'session-1')
|
||||
act(() => memory?.remember(SESSION_ROUTE))
|
||||
|
||||
render('host-1', 'session-2')
|
||||
|
||||
expect(memory?.current()).toEqual(SESSION_ROUTE)
|
||||
})
|
||||
|
||||
it('forgets a route belonging to another host', () => {
|
||||
render('host-1', 'session-1')
|
||||
act(() => memory?.remember(SESSION_ROUTE))
|
||||
|
||||
render('host-2', 'session-2')
|
||||
|
||||
expect(memory?.current()).toEqual({ kind: 'workspaceList' })
|
||||
})
|
||||
|
||||
it('keeps the workspace list once the page navigates back to it', () => {
|
||||
render('host-1', 'session-1')
|
||||
act(() => memory?.remember(SESSION_ROUTE))
|
||||
act(() => memory?.remember({ kind: 'workspaceList' }))
|
||||
|
||||
render('host-1', 'session-2')
|
||||
|
||||
expect(memory?.current()).toEqual({ kind: 'workspaceList' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMemo, useRef } from 'react'
|
||||
import type { MobileWebResumeRoute } from '../../../src/shared/mobile-web/bridge-contract'
|
||||
|
||||
export type MobileWebResumeRouteMemory = {
|
||||
remember: (route: MobileWebResumeRoute) => void
|
||||
current: () => MobileWebResumeRoute
|
||||
}
|
||||
|
||||
/** The route the shell replays in `init` when the hosted document reloads. A package swap or a
|
||||
* recovery mints a new shell session on the same host, and the reloaded page belongs on the route
|
||||
* the user was reading — discarding it there boots the page on the workspace list and leaves the
|
||||
* slow cold-resume round trip to bring the session back. Only another host makes the remembered
|
||||
* workspace id meaningless. */
|
||||
export function useMobileWebResumeRouteMemory(
|
||||
hostId: string | undefined
|
||||
): MobileWebResumeRouteMemory {
|
||||
const rememberedRef = useRef<{ hostId: string | undefined; route: MobileWebResumeRoute }>({
|
||||
hostId,
|
||||
route: { kind: 'workspaceList' }
|
||||
})
|
||||
return useMemo(
|
||||
() => ({
|
||||
remember: (route) => {
|
||||
rememberedRef.current = { hostId, route }
|
||||
},
|
||||
current: () =>
|
||||
rememberedRef.current.hostId === hostId
|
||||
? rememberedRef.current.route
|
||||
: { kind: 'workspaceList' }
|
||||
}),
|
||||
[hostId]
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user