diff --git a/mobile/app/h/[hostId]/review/[worktreeId].tsx b/mobile/app/h/[hostId]/review/[worktreeId].tsx
index d03b6b05cd5..c88545c5799 100644
--- a/mobile/app/h/[hostId]/review/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/review/[worktreeId].tsx
@@ -1,4 +1,8 @@
import { withWorkspaceRoute } from '../../../../src/navigation/workspace-route'
+import {
+ useWorkspaceExecutionHost,
+ workspaceRouteParams
+} from '../../../../src/navigation/workspace-execution-host'
import { useLocalSearchParams } from 'expo-router'
import { MobileDiffReviewRouteScreen } from '../../../../src/session/MobileDiffReviewRouteScreen'
import { firstReviewParam } from '../../../../src/session/mobile-diff-review-screen-model'
@@ -32,20 +36,23 @@ export default withWorkspaceRoute(function MobileDiffReviewScreen() {
scope?: string | string[]
file?: string | string[]
area?: string | string[]
- executionHost?: string | string[]
}>()
const hostId = firstReviewParam(params.hostId)
const worktreeId = firstReviewParam(params.worktreeId)
+ const executionHost = useWorkspaceExecutionHost()
const native =
// The four query params are read by the screen itself, so they are carried across whole rather
// than re-derived here; each is omitted when empty, because the screen's own normalizers treat an
// absent scope, file or area differently from one named nothing.
- const routeParams = Object.fromEntries(
- (['name', 'scope', 'file', 'area', 'executionHost'] as const)
- .map((key) => [key, firstReviewParam(params[key])] as const)
- .filter(([, value]) => value !== '')
- )
+ const routeParams: Record = {
+ ...Object.fromEntries(
+ (['name', 'scope', 'file', 'area'] as const)
+ .map((key) => [key, firstReviewParam(params[key])] as const)
+ .filter(([, value]) => value !== '')
+ ),
+ ...workspaceRouteParams(executionHost)
+ }
const route =
hostId && worktreeId
? shellScreenRoute({
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index eb542069ca0..db19ac6c55d 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -1,4 +1,8 @@
import { withWorkspaceRoute } from '../../../../src/navigation/workspace-route'
+import {
+ useWorkspaceExecutionHost,
+ workspaceRouteParams
+} from '../../../../src/navigation/workspace-execution-host'
import { useCallback } from 'react'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { MobileSessionRouteScreen } from '../../../../src/session/MobileSessionRouteScreen'
@@ -39,10 +43,10 @@ export default withWorkspaceRoute(function MobileSessionScreen() {
created?: string | string[]
warning?: string | string[]
paneKey?: string | string[]
- executionHost?: string | string[]
}>()
const hostId = firstParam(params.hostId)
const worktreeId = firstParam(params.worktreeId)
+ const executionHost = useWorkspaceExecutionHost()
const router = useRouter()
const native =
const paneKey = firstParam(params.paneKey) ?? ''
@@ -66,11 +70,14 @@ export default withWorkspaceRoute(function MobileSessionScreen() {
// the create flow sets to `1`, `warning` is the host's own text, `name` is a label the screen
// otherwise derives from the workspace, and `paneKey` empty is exactly what the notification hook
// writes back to say the tap is spent.
- const routeParams = Object.fromEntries(
- (['name', 'created', 'warning', 'paneKey', 'executionHost'] as const)
- .map((key) => [key, firstParam(params[key])] as const)
- .filter(([, value]) => value !== '')
- )
+ const routeParams: Record = {
+ ...Object.fromEntries(
+ (['name', 'created', 'warning', 'paneKey'] as const)
+ .map((key) => [key, firstParam(params[key])] as const)
+ .filter(([, value]) => value !== '')
+ ),
+ ...workspaceRouteParams(executionHost)
+ }
const route =
hostId && worktreeId
? shellScreenRoute({
diff --git a/mobile/src/agent-history/worktree-navigation-actions.test.ts b/mobile/src/agent-history/worktree-navigation-actions.test.ts
index 68ac9ed2a54..1d2a7c6a2e6 100644
--- a/mobile/src/agent-history/worktree-navigation-actions.test.ts
+++ b/mobile/src/agent-history/worktree-navigation-actions.test.ts
@@ -33,6 +33,28 @@ describe('the worktree row action sheet', () => {
])
})
+ it('opens a server workspace on its server, and offers nothing when that server is unreachable', () => {
+ const forHost = (executionHost: 'runtime:vm' | null) => {
+ const targets: string[] = []
+ const actions = buildWorktreeNavigationActions({
+ hostId: 'host-a',
+ worktreeId: 'wt-1',
+ worktreeName: 'w',
+ executionHost,
+ hostCapabilities: [MOBILE_AI_VAULT_CAPABILITY],
+ navigate: (target) => targets.push(target),
+ onDone: () => {}
+ })
+ actions.forEach((action) => action.onPress())
+ return targets
+ }
+ expect(forHost('runtime:vm')).toEqual([
+ '/h/host-a/source-control/wt-1?name=w&origin=host&executionHost=runtime%3Avm',
+ '/h/host-a/agent-history/wt-1?name=w&executionHost=runtime%3Avm'
+ ])
+ expect(forHost(null)).toEqual([])
+ })
+
it('offers only source control on a host that does not', () => {
const actions = buildWorktreeNavigationActions({
hostId: 'host-a',
diff --git a/mobile/src/agent-history/worktree-navigation-actions.ts b/mobile/src/agent-history/worktree-navigation-actions.ts
index 599cbec3990..1afe24835c8 100644
--- a/mobile/src/agent-history/worktree-navigation-actions.ts
+++ b/mobile/src/agent-history/worktree-navigation-actions.ts
@@ -10,8 +10,8 @@ type Args = {
hostId: string
worktreeId: string
worktreeName: string
- /** The row's server when it runs on one, so the opened screen works there. */
- executionHost?: ExecutionHostId
+ /** The row's server when it runs on one, so the opened screen works there; null when unreachable. */
+ executionHost?: ExecutionHostId | null
hostCapabilities: readonly string[]
navigate: (target: string) => void
onDone: () => void
@@ -23,6 +23,10 @@ type Args = {
// navigate to a screen that would call a missing RPC method. Extracted from the
// host index action sheet to keep that file under its max-lines budget.
export function buildWorktreeNavigationActions(args: Args): ActionSheetAction[] {
+ if (args.executionHost === null) {
+ return []
+ }
+ const executionHost = args.executionHost
const actions: ActionSheetAction[] = [
{
label: 'Source Control',
@@ -32,7 +36,7 @@ export function buildWorktreeNavigationActions(args: Args): ActionSheetAction[]
args.navigate(
workspaceRouteHref(
`/h/${encodeURIComponent(args.hostId)}/source-control/${encodeURIComponent(args.worktreeId)}?${params.toString()}`,
- args.executionHost
+ executionHost
)
)
args.onDone()
@@ -49,7 +53,7 @@ export function buildWorktreeNavigationActions(args: Args): ActionSheetAction[]
args.navigate(
workspaceRouteHref(
`/h/${encodeURIComponent(args.hostId)}/agent-history/${encodeURIComponent(args.worktreeId)}?${params.toString()}`,
- args.executionHost
+ executionHost
)
)
args.onDone()
diff --git a/mobile/src/files/MobileFileExplorerPanel.tsx b/mobile/src/files/MobileFileExplorerPanel.tsx
index 28acba82604..fc624f85698 100644
--- a/mobile/src/files/MobileFileExplorerPanel.tsx
+++ b/mobile/src/files/MobileFileExplorerPanel.tsx
@@ -10,6 +10,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'
import { ChevronLeft, X } from 'lucide-react-native'
import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff'
+import { useForceReconnect } from '../transport/client-context'
import { useWorkspaceClient } from '../transport/use-workspace-client'
import { connectionRetryAction } from '../transport/connection-retry-action'
import { getWorktreeLabel } from '../session/worktree-label'
@@ -44,7 +45,8 @@ export function MobileFileExplorerPanel(props: {
}) {
const { hostId, worktreeId, name, embedded, onRequestClose } = props
const router = useWorkspaceRouteHandoff()
- const { client, state: connState, forceReconnect } = useWorkspaceClient(hostId)
+ const { client, state: connState } = useWorkspaceClient(hostId)
+ const forceReconnect = useForceReconnect()
const scopeRef = useRef('')
const scope = `${hostId}:${worktreeId}`
scopeRef.current = scope
diff --git a/mobile/src/files/MobileFilePreviewScreen.tsx b/mobile/src/files/MobileFilePreviewScreen.tsx
index d29cec31091..9a6f7fa276e 100644
--- a/mobile/src/files/MobileFilePreviewScreen.tsx
+++ b/mobile/src/files/MobileFilePreviewScreen.tsx
@@ -5,6 +5,7 @@ import { ChevronLeft, Save } from 'lucide-react-native'
import { useRouteHandoff } from '../navigation/route-handoff'
import { getWorktreeLabel } from '../session/worktree-label'
import { colors, spacing } from '../theme/mobile-theme'
+import { useForceReconnect } from '../transport/client-context'
import { useWorkspaceClient } from '../transport/use-workspace-client'
import { connectionRetryAction } from '../transport/connection-retry-action'
import {
@@ -37,7 +38,8 @@ type Props = {
export function MobileFilePreviewScreen({ route }: Props) {
const router = useRouteHandoff()
const previewParams = route.ok ? route.params : null
- const { client, state: connState, forceReconnect } = useWorkspaceClient(previewParams?.hostId)
+ const { client, state: connState } = useWorkspaceClient(previewParams?.hostId)
+ const forceReconnect = useForceReconnect()
const [preview, setPreview] = useState(() =>
route.ok ? { status: 'loading', message: 'Loading preview...' } : previewError(route.message)
)
diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx
index 10fa1d312c3..ba97420cd0c 100644
--- a/mobile/src/host-screen/host-screen-overlays.tsx
+++ b/mobile/src/host-screen/host-screen-overlays.tsx
@@ -2,7 +2,7 @@ import { openExternalLink } from '../platform/external-link'
import { Pressable, Text, View } from 'react-native'
import { Check, Moon } from 'lucide-react-native'
import { buildWorktreeNavigationActions } from '../agent-history/worktree-navigation-actions'
-import { canTargetExecutionHost } from '../transport/execution-host-scoped-rpc-client'
+import { workspaceRouteExecutionHost } from '../transport/execution-host-scoped-rpc-client'
import { ActionSheetContent } from '../components/ActionSheetModal'
import { BottomDrawer } from '../components/BottomDrawer'
import { ConfirmModal } from '../components/ConfirmModal'
@@ -165,11 +165,11 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
hostId,
worktreeId: actionTarget.worktreeId,
worktreeName: actionTarget.displayName || actionTarget.repo,
- executionHost:
- client &&
- canTargetExecutionHost(client, hostCapabilities, actionTarget.hostId)
- ? actionTarget.hostId
- : undefined,
+ executionHost: workspaceRouteExecutionHost(
+ client,
+ hostCapabilities,
+ actionTarget.hostId
+ ),
hostCapabilities,
navigate: actions.navigateFromHostList,
onDone: () => state.setActionTarget(null)
diff --git a/mobile/src/host-screen/host-server-workspaces-wiring.test.ts b/mobile/src/host-screen/host-server-workspaces-wiring.test.ts
index e56251e59d7..791d19e1859 100644
--- a/mobile/src/host-screen/host-server-workspaces-wiring.test.ts
+++ b/mobile/src/host-screen/host-server-workspaces-wiring.test.ts
@@ -290,6 +290,16 @@ describe("a desktop's server workspaces on the phone", () => {
expect(screen.notice()).toBe('Update Orca on ThinkPad to open its workspaces from your phone.')
})
+ it('opens nothing for a listed server workspace once the phone can no longer reach it', async () => {
+ const screen = await mountScreen(desktop(), RELAYS)
+ const oldShell = desktop({ carriesExecutionHost: false })
+ await screen.swapClient(oldShell)
+ expect(screen.rows().map((entry) => entry.worktreeId)).toContain('runtime:vm-wt')
+ screen.open('runtime:vm-wt')
+ expect(screen.navigations).toEqual([])
+ expect(calledMethods(oldShell)).not.toContain('worktree.activate')
+ })
+
it('pins a server workspace on that server, as the desktop does', async () => {
const client = desktop()
const screen = await mountScreen(client, RELAYS)
diff --git a/mobile/src/host-screen/use-host-worktree-actions.ts b/mobile/src/host-screen/use-host-worktree-actions.ts
index 3ed9c00bae6..483b1c68f37 100644
--- a/mobile/src/host-screen/use-host-worktree-actions.ts
+++ b/mobile/src/host-screen/use-host-worktree-actions.ts
@@ -7,8 +7,8 @@ import { removeHostAndCloseClient } from '../transport/host-removal-lifecycle'
import { isPageHostRemovalUnavailable } from '../transport/page-host-removal-refusal'
import type { RpcClient } from '../transport/rpc-client'
import {
- canTargetExecutionHost,
- rpcClientForExecutionHost
+ rpcClientForExecutionHost,
+ workspaceRouteExecutionHost
} from '../transport/execution-host-scoped-rpc-client'
import { serverUpdateNeededNotice } from '../host-route-notice'
import type { ConnectionState } from '../transport/types'
@@ -231,6 +231,10 @@ export function useHostWorktreeActions(args: {
setServerNotice(serverUpdateNeededNotice(server.label))
return
}
+ const routeHost = workspaceRouteExecutionHost(client, hostCapabilities, item.hostId)
+ if (routeHost === null) {
+ return
+ }
setOptimisticActiveWorktreeIdentity(getWorktreeRowIdentity(item))
const rowClient = clientForRow(item)
if (rowClient && connState === 'connected') {
@@ -245,10 +249,7 @@ export function useHostWorktreeActions(args: {
// `?? ''` and not a cast: the hook takes `hostId` optional and every other member guards it,
// so an absent one builds `/h//session/...` — a pathname the shell's segment rule refuses —
// rather than the string "undefined", which it would accept as a host named undefined.
- const executionHost =
- client && canTargetExecutionHost(client, hostCapabilities, item.hostId)
- ? `&executionHost=${encodeURIComponent(item.hostId)}`
- : ''
+ const executionHost = routeHost ? `&executionHost=${encodeURIComponent(routeHost)}` : ''
const target = `/h/${encodeURIComponent(hostId ?? '')}/session/${encodeURIComponent(item.worktreeId)}?name=${encodeURIComponent(item.displayName || item.repo)}${executionHost}`
navigateFromHostList(target)
},
diff --git a/mobile/src/mobile-web-shell/PageRouteUnavailableScreen.tsx b/mobile/src/mobile-web-shell/PageRouteUnavailableScreen.tsx
index 1e549ea4224..615435ae3e1 100644
--- a/mobile/src/mobile-web-shell/PageRouteUnavailableScreen.tsx
+++ b/mobile/src/mobile-web-shell/PageRouteUnavailableScreen.tsx
@@ -26,13 +26,19 @@ import { colors, radii, spacing, typography } from '../theme/mobile-theme'
* `.web.tsx` sibling, and there a bare replace navigates inside the WebView to a route the page
* does not carry instead of leaving it. The seam posts the target to the shell.
*/
-export function PageRouteUnavailableScreen({ hostId }: { hostId: string }) {
+export function PageRouteUnavailableScreen({
+ hostId,
+ message = 'This workspace screen is not available on this host.'
+}: {
+ hostId: string
+ message?: string
+}) {
const router = useRouteHandoff()
const target = hostId === '' ? '/' : hostStackHostRoute(hostId)
const label = hostId === '' ? 'Back to hosts' : 'Back to workspaces'
return (
- This workspace screen is not available on this host.
+ {message}
[styles.button, pressed && styles.pressed]}
accessibilityRole="button"
diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx
index b05bb37f1be..80d510fbb41 100644
--- a/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx
+++ b/mobile/src/mobile-web-shell/mobile-web-shell-agent-history-route.test.tsx
@@ -35,6 +35,10 @@ vi.mock('react-native', () => ({
View: 'View'
}))
+// The workspace route reads the desktop's connection to decide whether a server is reachable.
+vi.mock('../transport/client-context', () => ({
+ useHostClient: () => ({ client: null, state: 'disconnected' })
+}))
vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params }))
vi.mock('../agent-history/MobileAgentSessionHistoryPanel', () => ({
diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-files-route.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-files-route.test.tsx
index df09aad4670..ae6c25a4192 100644
--- a/mobile/src/mobile-web-shell/mobile-web-shell-files-route.test.tsx
+++ b/mobile/src/mobile-web-shell/mobile-web-shell-files-route.test.tsx
@@ -37,6 +37,10 @@ vi.mock('react-native', () => ({
View: 'View'
}))
+// The workspace route reads the desktop's connection to decide whether a server is reachable.
+vi.mock('../transport/client-context', () => ({
+ useHostClient: () => ({ client: null, state: 'disconnected' })
+}))
vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params }))
vi.mock('../files/MobileFileExplorerPanel', () => ({
diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-route.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-session-route.test.tsx
index cfddb1eeed8..fb04c910c83 100644
--- a/mobile/src/mobile-web-shell/mobile-web-shell-session-route.test.tsx
+++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-route.test.tsx
@@ -38,6 +38,10 @@ vi.mock('react-native', () => ({
View: 'View'
}))
+// The workspace route reads the desktop's connection to decide whether a server is reachable.
+vi.mock('../transport/client-context', () => ({
+ useHostClient: () => ({ client: null, state: 'disconnected' })
+}))
vi.mock('expo-router', () => ({
useLocalSearchParams: () => dependencies.params,
useRouter: () => ({
diff --git a/mobile/src/mobile-web-shell/shell-switch-null-flag.test.tsx b/mobile/src/mobile-web-shell/shell-switch-null-flag.test.tsx
index 49d2030d045..cfe9088c79f 100644
--- a/mobile/src/mobile-web-shell/shell-switch-null-flag.test.tsx
+++ b/mobile/src/mobile-web-shell/shell-switch-null-flag.test.tsx
@@ -56,6 +56,10 @@ vi.mock('react-native', () => ({
View: 'View'
}))
+// The workspace route reads the desktop's connection to decide whether a server is reachable.
+vi.mock('../transport/client-context', () => ({
+ useHostClient: () => ({ client: null, state: 'disconnected' })
+}))
vi.mock('expo-router', () => ({
Redirect: 'Redirect',
useLocalSearchParams: () => dependencies.params,
diff --git a/mobile/src/navigation/workspace-route-handoff.test.tsx b/mobile/src/navigation/workspace-route-handoff.test.tsx
index c1fab5197ce..47669eb1727 100644
--- a/mobile/src/navigation/workspace-route-handoff.test.tsx
+++ b/mobile/src/navigation/workspace-route-handoff.test.tsx
@@ -10,24 +10,22 @@ const route = vi.hoisted(() => {
navigate: (href: unknown) => opened.push(['navigate', href]),
back: () => opened.push(['back'])
}
- const state: { params: Record; opened: unknown[]; router: typeof router } = {
- params: {},
+ const state: { opened: unknown[]; router: typeof router } = {
opened,
router
}
return state
})
-vi.mock('expo-router', () => ({ useLocalSearchParams: () => route.params }))
vi.mock('./route-handoff', () => ({ useRouteHandoff: () => route.router }))
import { useWorkspaceRouteHandoff } from './workspace-route-handoff'
-import { WorkspaceRoute } from './workspace-route'
+import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
+import { WorkspaceExecutionHostContext } from './workspace-execution-host'
async function workspaceRouterFor(
params: Record
): Promise> {
- route.params = params
route.opened.length = 0
const held: { router: ReturnType | null } = { router: null }
function Probe(): null {
@@ -35,7 +33,13 @@ async function workspaceRouterFor(
return null
}
await act(async () => {
- create(createElement(WorkspaceRoute, null, createElement(Probe)))
+ create(
+ createElement(
+ WorkspaceExecutionHostContext.Provider,
+ { value: normalizeExecutionHostId(params.executionHost) ?? undefined },
+ createElement(Probe)
+ )
+ )
})
if (!held.router) {
throw new Error('probe did not render')
diff --git a/mobile/src/navigation/workspace-route-screens.test.tsx b/mobile/src/navigation/workspace-route-screens.test.tsx
deleted file mode 100644
index c1c215000f1..00000000000
--- a/mobile/src/navigation/workspace-route-screens.test.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { createElement } from 'react'
-import { act, create } from 'react-test-renderer'
-import { describe, expect, it, vi } from 'vitest'
-
-const seen = vi.hoisted(() => {
- const hosts: (string | undefined)[] = []
- return hosts
-})
-
-vi.mock('expo-router', () => ({
- useLocalSearchParams: () => ({
- hostId: 'host-1',
- worktreeId: 'wt-1',
- relativePath: 'README.md',
- executionHost: 'runtime:vm'
- })
-}))
-// Each workspace screen stands in as a probe reporting the server its subtree names.
-vi.mock('../files/MobileFileExplorerPanel', () => ({ MobileFileExplorerPanel: Probe }))
-vi.mock('../files/MobileFilePreviewScreen', () => ({ MobileFilePreviewScreen: Probe }))
-vi.mock('../source-control/MobileSourceControlPanel', () => ({ MobileSourceControlPanel: Probe }))
-vi.mock('../session/MobileDiffReviewRouteScreen', () => ({ MobileDiffReviewRouteScreen: Probe }))
-vi.mock('../session/MobileSessionRouteScreen', () => ({ MobileSessionRouteScreen: Probe }))
-vi.mock('../agent-history/MobileAgentSessionHistoryPanel', () => ({
- MobileAgentSessionHistoryPanel: Probe
-}))
-
-import { useWorkspaceExecutionHost } from './workspace-execution-host'
-
-function Probe(): null {
- seen.push(useWorkspaceExecutionHost())
- return null
-}
-
-const SCREENS = {
- files: () => import('../../app/h/[hostId]/files/[worktreeId].web'),
- 'file preview': () => import('../../app/h/[hostId]/files/preview/[worktreeId].web'),
- 'source control': () => import('../../app/h/[hostId]/source-control/[worktreeId].web'),
- review: () => import('../../app/h/[hostId]/review/[worktreeId].web'),
- session: () => import('../../app/h/[hostId]/session/[worktreeId].web'),
- 'agent history': () => import('../../app/h/[hostId]/agent-history/[worktreeId].web')
-}
-
-describe('a workspace route', () => {
- it.each(Object.entries(SCREENS))(
- 'names its server to everything under the %s screen',
- async (_name, load) => {
- const Screen = (await load()).default
- seen.length = 0
- await act(async () => {
- create(createElement(Screen))
- })
- expect(seen).toEqual(['runtime:vm'])
- }
- )
-})
diff --git a/mobile/src/navigation/workspace-route.test.tsx b/mobile/src/navigation/workspace-route.test.tsx
new file mode 100644
index 00000000000..5d7666b81cf
--- /dev/null
+++ b/mobile/src/navigation/workspace-route.test.tsx
@@ -0,0 +1,173 @@
+import { readdirSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { createElement, type ReactElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { describe, expect, it, vi } from 'vitest'
+
+const route = vi.hoisted(() => {
+ const state: {
+ params: Record
+ hostCapabilities: string[]
+ statusPending: boolean
+ seen: (string | undefined)[]
+ shellRoutes: { params?: Record }[]
+ redirects: { params: Record }[]
+ } = {
+ params: {},
+ hostCapabilities: [],
+ statusPending: false,
+ seen: [],
+ shellRoutes: [],
+ redirects: []
+ }
+ return state
+})
+
+vi.mock('expo-router', () => ({
+ useLocalSearchParams: () => route.params,
+ useRouter: () => ({ setParams: () => {}, replace: () => {} }),
+ Redirect: ({ href }: { href: { params: Record } }) => {
+ route.redirects.push(href)
+ return null
+ }
+}))
+vi.mock('react-native', () => ({
+ StyleSheet: { create: (styles: unknown) => styles },
+ View: 'View',
+ Text: 'Text',
+ Pressable: 'Pressable'
+}))
+vi.mock('../transport/client-context', () => ({
+ useHostClient: () => ({ client: {}, state: 'connected' })
+}))
+vi.mock('../components/host-protocol-gates-context', () => ({
+ useOptionalHostProtocolGates: () => ({
+ hostCapabilities: route.hostCapabilities,
+ statusPending: route.statusPending
+ })
+}))
+vi.mock('./route-handoff', () => ({ useRouteHandoff: () => ({ replace: () => {} }) }))
+// The native switch always hands the page its route, so the census reads what the page is told.
+vi.mock('../mobile-web-shell/shell-switch-decision', () => ({
+ useShellSwitchDecision: (shellRoute: unknown) =>
+ shellRoute ? { kind: 'shell', route: shellRoute } : { kind: 'native' }
+}))
+vi.mock('../mobile-web-shell/ShellSwitchPendingScreen', () => ({
+ ShellSwitchPendingScreen: () => null
+}))
+vi.mock('../mobile-web-shell/MobileWebShellScreen', () => ({
+ MobileWebShellScreen: ({ route: shellRoute }: { route: { params?: Record } }) => {
+ route.shellRoutes.push(shellRoute)
+ return createElement(Probe)
+ }
+}))
+// Each workspace screen stands in as a probe reporting the server its subtree names.
+vi.mock('../files/MobileFileExplorerPanel', () => ({ MobileFileExplorerPanel: Probe }))
+vi.mock('../files/MobileFilePreviewScreen', () => ({ MobileFilePreviewScreen: Probe }))
+vi.mock('../source-control/MobileSourceControlPanel', () => ({ MobileSourceControlPanel: Probe }))
+vi.mock('../session/MobileDiffReviewRouteScreen', () => ({ MobileDiffReviewRouteScreen: Probe }))
+vi.mock('../session/MobileSessionRouteScreen', () => ({ MobileSessionRouteScreen: Probe }))
+vi.mock('../agent-history/MobileAgentSessionHistoryPanel', () => ({
+ MobileAgentSessionHistoryPanel: Probe
+}))
+
+import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
+import { useWorkspaceExecutionHost } from './workspace-execution-host'
+import { WorkspaceRoute } from './workspace-route'
+
+function Probe(): null {
+ route.seen.push(useWorkspaceExecutionHost())
+ return null
+}
+
+const HOST_ROUTES = fileURLToPath(new URL('../../app/h/[hostId]/', import.meta.url))
+// Every screen keyed by a workspace, native and page, found on disk so a new one is counted.
+const WORKSPACE_ROUTE_FILES = readdirSync(HOST_ROUTES, { recursive: true, encoding: 'utf8' })
+ .map((entry) => entry.replaceAll('\\', '/'))
+ .filter((entry) => /(^|\/)\[worktreeId\](\.web)?\.tsx$/.test(entry))
+ .sort()
+
+async function render(element: ReactElement): Promise {
+ route.seen.length = 0
+ route.shellRoutes.length = 0
+ route.redirects.length = 0
+ let renderer: ReactTestRenderer | null = null
+ await act(async () => {
+ renderer = create(element)
+ })
+ if (!renderer) {
+ throw new Error('did not render')
+ }
+ return renderer
+}
+
+function texts(renderer: ReactTestRenderer): string[] {
+ return renderer.root
+ .findAll(() => true)
+ .flatMap((node) => node.children.filter((child) => typeof child === 'string'))
+}
+
+describe('every workspace route', () => {
+ it('is found on disk', () => {
+ expect(WORKSPACE_ROUTE_FILES.length).toBeGreaterThanOrEqual(14)
+ })
+
+ it.each(WORKSPACE_ROUTE_FILES)('keeps %s on the server its route names', async (file) => {
+ route.params = {
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ relativePath: 'README.md',
+ // Spelled loosely, so a screen reading the raw param instead of its route is caught.
+ executionHost: ' runtime:vm '
+ }
+ route.hostCapabilities = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY]
+ route.statusPending = false
+ const Screen = (await import(/* @vite-ignore */ `${HOST_ROUTES}${file}`)).default
+ await render(createElement(Screen))
+ if (route.redirects.length > 0) {
+ expect(route.redirects.map((href) => href.params.executionHost?.trim())).toEqual([
+ 'runtime:vm'
+ ])
+ return
+ }
+ expect(route.seen).toEqual(['runtime:vm'])
+ for (const shellRoute of route.shellRoutes) {
+ expect(shellRoute.params?.executionHost).toBe('runtime:vm')
+ }
+ })
+})
+
+describe('a server workspace the phone cannot reach', () => {
+ const child = createElement(Probe)
+
+ it('says so once the desktop has answered, instead of waiting forever', async () => {
+ route.params = { hostId: 'host-1', executionHost: 'runtime:vm' }
+ route.hostCapabilities = []
+ route.statusPending = false
+ const renderer = await render(createElement(WorkspaceRoute, null, child))
+ expect(route.seen).toEqual([])
+ expect(texts(renderer)).toContain(
+ "Your phone can't reach this workspace's server through this desktop."
+ )
+ })
+
+ it('waits while the desktop has not answered, and opens a reachable one', async () => {
+ route.params = { hostId: 'host-1', executionHost: 'runtime:vm' }
+ route.hostCapabilities = []
+ route.statusPending = true
+ await render(createElement(WorkspaceRoute, null, child))
+ expect(route.seen).toEqual(['runtime:vm'])
+ route.hostCapabilities = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY]
+ route.statusPending = false
+ await render(createElement(WorkspaceRoute, null, child))
+ expect(route.seen).toEqual(['runtime:vm'])
+ })
+
+ it('never stands in for the desktop’s own workspaces', async () => {
+ route.params = { hostId: 'host-1' }
+ route.hostCapabilities = []
+ route.statusPending = false
+ await render(createElement(WorkspaceRoute, null, child))
+ expect(route.seen).toEqual([undefined])
+ })
+})
diff --git a/mobile/src/navigation/workspace-route.tsx b/mobile/src/navigation/workspace-route.tsx
index d90011b3a8c..f38bf178dc4 100644
--- a/mobile/src/navigation/workspace-route.tsx
+++ b/mobile/src/navigation/workspace-route.tsx
@@ -3,14 +3,37 @@ import { useLocalSearchParams } from 'expo-router'
import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
import { firstParam } from './route-param-reader'
import { WorkspaceExecutionHostContext } from './workspace-execution-host'
+import { useOptionalHostProtocolGates } from '../components/host-protocol-gates-context'
+import { PageRouteUnavailableScreen } from '../mobile-web-shell/PageRouteUnavailableScreen'
+import { useHostClient } from '../transport/client-context'
+import { workspaceRouteExecutionHost } from '../transport/execution-host-scoped-rpc-client'
+
+const UNREACHABLE_SERVER_MESSAGE =
+ "Your phone can't reach this workspace's server through this desktop."
/**
* A workspace route's server, read once from its `executionHost` param: every call made and every
- * route pushed below it stays on that server. Absent for the desktop's own workspaces.
+ * route pushed below it stays on that server. Absent for the desktop's own workspaces; a server
+ * this phone can't reach (older desktop or shell, unreadable status) gets one explicit dead end.
*/
export function WorkspaceRoute({ children }: { children: ReactNode }) {
- const params = useLocalSearchParams<{ executionHost?: string | string[] }>()
+ const params = useLocalSearchParams<{
+ hostId?: string | string[]
+ executionHost?: string | string[]
+ }>()
+ const hostId = firstParam(params.hostId)
const executionHost = normalizeExecutionHostId(firstParam(params.executionHost)) ?? undefined
+ const { client, state } = useHostClient(hostId)
+ const gates = useOptionalHostProtocolGates()
+ // Why: once the desktop has answered, a server it can't reach for this phone is a dead end, not a spinner.
+ if (
+ state === 'connected' &&
+ gates &&
+ !gates.statusPending &&
+ workspaceRouteExecutionHost(client, gates.hostCapabilities, executionHost) === null
+ ) {
+ return
+ }
return (
{children}
diff --git a/mobile/src/session/MobileDiffReviewRouteScreen.tsx b/mobile/src/session/MobileDiffReviewRouteScreen.tsx
index 68e3731918b..2107e14f7b8 100644
--- a/mobile/src/session/MobileDiffReviewRouteScreen.tsx
+++ b/mobile/src/session/MobileDiffReviewRouteScreen.tsx
@@ -4,6 +4,7 @@ import { MobileDiffReviewScreenView } from '../components/MobileDiffReviewScreen
import { firstReviewParam, normalizeReviewFilterParam } from './mobile-diff-review-screen-model'
import { normalizeReviewAreaParam } from './mobile-diff-review-positioning'
import { useMobileDiffReviewController } from './use-mobile-diff-review-controller'
+import { useForceReconnect } from '../transport/client-context'
import { useWorkspaceClient } from '../transport/use-workspace-client'
import { useHostProtocolGates } from '../components/HostProtocolGate'
import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff'
@@ -42,7 +43,8 @@ export function MobileDiffReviewRouteScreen() {
// Not `useRouter`: inside the shell's page the session screen is native, so that replace has to
// be handed back to the app rather than posted into a document that does not render it.
const router = useWorkspaceRouteHandoff()
- const { client, state: connState, forceReconnect } = useWorkspaceClient(hostId)
+ const { client, state: connState } = useWorkspaceClient(hostId)
+ const forceReconnect = useForceReconnect()
const openSession = useCallback(() => {
const query = name ? `?${new URLSearchParams({ name }).toString()}` : ''
diff --git a/mobile/src/source-control/use-mobile-source-control-state.ts b/mobile/src/source-control/use-mobile-source-control-state.ts
index 165d3472f5b..940954ca069 100644
--- a/mobile/src/source-control/use-mobile-source-control-state.ts
+++ b/mobile/src/source-control/use-mobile-source-control-state.ts
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
+import { useForceReconnect } from '../transport/client-context'
import { useWorkspaceClient } from '../transport/use-workspace-client'
import { getWorktreeLabel } from '../session/worktree-label'
import { useMobileGitRequests } from './use-mobile-git-requests'
@@ -32,8 +33,6 @@ import {
type MobileBranchEntryView
} from './mobile-source-control-screen-state'
-type MobileGitLocalBranches = RuntimeGitLocalBranches
-
export type MobileSourceControlStateParams = {
hostId: string
worktreeId: string
@@ -61,12 +60,13 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
onOpenHistory
} = params
const insets = useSafeAreaInsets()
- const { client, state: connState, forceReconnect } = useWorkspaceClient(hostId)
+ const { client, state: connState } = useWorkspaceClient(hostId)
+ const forceReconnect = useForceReconnect()
const [busyAction, setBusyAction] = useState(null)
const [commitMessage, setCommitMessage] = useState('')
const [generatingMessage, setGeneratingMessage] = useState(false)
const [showBranchPicker, setShowBranchPicker] = useState(false)
- const [localBranches, setLocalBranches] = useState(null)
+ const [localBranches, setLocalBranches] = useState(null)
const [createdPrUrl, setCreatedPrUrl] = useState(null)
const [createdPrWarning, setCreatedPrWarning] = useState(null)
const [discardTarget, setDiscardTarget] = useState(null)
diff --git a/mobile/src/transport/execution-host-envelope.test.ts b/mobile/src/transport/execution-host-envelope.test.ts
index e4fd25c8112..858a9fd789b 100644
--- a/mobile/src/transport/execution-host-envelope.test.ts
+++ b/mobile/src/transport/execution-host-envelope.test.ts
@@ -2,8 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
import {
- canTargetExecutionHost,
- scopeRpcClientToExecutionHost
+ scopeRpcClientToExecutionHost,
+ workspaceRouteExecutionHost
} from './execution-host-scoped-rpc-client'
import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams'
import type { RpcClient } from './rpc-client'
@@ -151,9 +151,11 @@ describe('executionHost on the envelope', () => {
})
function fakeClient(carries?: boolean) {
- const sendRequest = vi.fn(
- async (): Promise => ({ id: 'reply', ok: true, result: null })
- )
+ const sendRequest = vi.fn(async (): Promise => ({
+ id: 'reply',
+ ok: true,
+ result: null
+ }))
const subscribe = vi.fn(() => () => {})
const close = vi.fn()
const client = {
@@ -194,14 +196,15 @@ describe('scopeRpcClientToExecutionHost', () => {
expect(scopeRpcClientToExecutionHost(client, 'runtime:env-2')).not.toBe(view)
})
- it('targets only server workspaces on a relaying desktop over a transport that keeps the field', () => {
+ it('routes a server workspace only through a relaying desktop over a transport that keeps the field', () => {
const relays = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY]
- expect(canTargetExecutionHost(fakeClient(), relays, SERVER)).toBe(true)
- expect(canTargetExecutionHost(fakeClient(true), relays, SERVER)).toBe(true)
- expect(canTargetExecutionHost(fakeClient(false), relays, SERVER)).toBe(false)
- expect(canTargetExecutionHost(fakeClient(), [], SERVER)).toBe(false)
- expect(canTargetExecutionHost(fakeClient(), relays, 'local')).toBe(false)
- expect(canTargetExecutionHost(fakeClient(), relays, 'ssh:box')).toBe(false)
- expect(canTargetExecutionHost(fakeClient(), relays, undefined)).toBe(false)
+ expect(workspaceRouteExecutionHost(fakeClient(), relays, SERVER)).toBe(SERVER)
+ expect(workspaceRouteExecutionHost(fakeClient(true), relays, SERVER)).toBe(SERVER)
+ expect(workspaceRouteExecutionHost(fakeClient(false), relays, SERVER)).toBeNull()
+ expect(workspaceRouteExecutionHost(fakeClient(), [], SERVER)).toBeNull()
+ expect(workspaceRouteExecutionHost(null, relays, SERVER)).toBeNull()
+ expect(workspaceRouteExecutionHost(fakeClient(), relays, 'local')).toBeUndefined()
+ expect(workspaceRouteExecutionHost(fakeClient(), relays, 'ssh:box')).toBeUndefined()
+ expect(workspaceRouteExecutionHost(fakeClient(), relays, undefined)).toBeUndefined()
})
})
diff --git a/mobile/src/transport/execution-host-scoped-rpc-client.ts b/mobile/src/transport/execution-host-scoped-rpc-client.ts
index 1d28fcbce05..c4200d9592b 100644
--- a/mobile/src/transport/execution-host-scoped-rpc-client.ts
+++ b/mobile/src/transport/execution-host-scoped-rpc-client.ts
@@ -15,13 +15,19 @@ export function relaysToServers(client: RpcClient, hostCapabilities: readonly st
)
}
-/** Local and SSH workspaces are the desktop's own; only a server's are named, and only if reachable. */
-export function canTargetExecutionHost(
- client: RpcClient,
+/**
+ * The `executionHost` a route into a workspace carries: none for the desktop's own (local, SSH),
+ * the server for a reachable one, and null for one this phone cannot reach, which must not open.
+ */
+export function workspaceRouteExecutionHost(
+ client: RpcClient | null,
hostCapabilities: readonly string[],
executionHost: ExecutionHostId | undefined
-): executionHost is `runtime:${string}` {
- return executionHost?.startsWith('runtime:') === true && relaysToServers(client, hostCapabilities)
+): ExecutionHostId | undefined | null {
+ if (!executionHost?.startsWith('runtime:')) {
+ return undefined
+ }
+ return client && relaysToServers(client, hostCapabilities) ? executionHost : null
}
/**
@@ -73,10 +79,9 @@ export function rpcClientForExecutionHost(
hostCapabilities: readonly string[],
executionHost: ExecutionHostId | undefined
): RpcClient | null {
- if (!executionHost?.startsWith('runtime:')) {
+ const routeHost = workspaceRouteExecutionHost(client, hostCapabilities, executionHost)
+ if (routeHost === undefined) {
return client
}
- return canTargetExecutionHost(client, hostCapabilities, executionHost)
- ? scopeRpcClientToExecutionHost(client, executionHost)
- : null
+ return routeHost && scopeRpcClientToExecutionHost(client, routeHost)
}
diff --git a/mobile/src/transport/use-workspace-client.test.tsx b/mobile/src/transport/use-workspace-client.test.tsx
index cc9a372c9ec..4931b37f5f3 100644
--- a/mobile/src/transport/use-workspace-client.test.tsx
+++ b/mobile/src/transport/use-workspace-client.test.tsx
@@ -3,8 +3,7 @@ import { act, create } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
const route = vi.hoisted(() => {
- const state: { params: Record; capabilities: string[] } = {
- params: {},
+ const state: { capabilities: string[] } = {
capabilities: []
}
return state
@@ -14,18 +13,16 @@ const desktop = vi.hoisted(() => {
return state
})
-vi.mock('expo-router', () => ({ useLocalSearchParams: () => route.params }))
-vi.mock('react-native', () => ({}))
vi.mock('../components/host-protocol-gates-context', () => ({
useOptionalHostProtocolGates: () => ({ hostCapabilities: route.capabilities })
}))
vi.mock('./client-context', () => ({
- useHostClient: () => ({ client: desktop.client, clientId: 'client-1', state: 'connected' }),
- useForceReconnect: () => () => undefined
+ useHostClient: () => ({ client: desktop.client, clientId: 'client-1', state: 'connected' })
}))
+import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
-import { WorkspaceRoute } from '../navigation/workspace-route'
+import { WorkspaceExecutionHostContext } from '../navigation/workspace-execution-host'
import { FakeSession } from './mobile-endpoint-supervisor-test-fakes'
import type { RpcClient } from './rpc-client'
import { useWorkspaceClient } from './use-workspace-client'
@@ -34,7 +31,6 @@ async function workspaceClientFor(
params: Record,
capabilities: string[]
): Promise<{ client: RpcClient | null; session: FakeSession }> {
- route.params = params
route.capabilities = capabilities
const session = new FakeSession('connected')
desktop.client = session
@@ -44,7 +40,13 @@ async function workspaceClientFor(
return null
}
await act(async () => {
- create(createElement(WorkspaceRoute, null, createElement(Probe)))
+ create(
+ createElement(
+ WorkspaceExecutionHostContext.Provider,
+ { value: normalizeExecutionHostId(params.executionHost) ?? undefined },
+ createElement(Probe)
+ )
+ )
})
return { client: held.client, session }
}
diff --git a/mobile/src/transport/use-workspace-client.ts b/mobile/src/transport/use-workspace-client.ts
index 8783a5a78ac..10150b30b53 100644
--- a/mobile/src/transport/use-workspace-client.ts
+++ b/mobile/src/transport/use-workspace-client.ts
@@ -1,21 +1,17 @@
import { useOptionalHostProtocolGates } from '../components/host-protocol-gates-context'
import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host'
-import { useForceReconnect, useHostClient } from './client-context'
+import { useHostClient } from './client-context'
import { rpcClientForExecutionHost } from './execution-host-scoped-rpc-client'
const NO_CAPABILITIES: readonly string[] = []
/** `useHostClient` for a workspace screen: its calls run where the workspace does. */
-export function useWorkspaceClient(
- hostId: string | undefined
-): ReturnType & { forceReconnect: ReturnType } {
+export function useWorkspaceClient(hostId: string | undefined): ReturnType {
const host = useHostClient(hostId)
const hostCapabilities = useOptionalHostProtocolGates()?.hostCapabilities ?? NO_CAPABILITIES
const executionHost = useWorkspaceExecutionHost()
- const forceReconnect = useForceReconnect()
return {
...host,
- forceReconnect,
client: host.client && rpcClientForExecutionHost(host.client, hostCapabilities, executionHost)
}
}
diff --git a/tests/e2e/paired-mobile-desktop-mirror.spec.ts b/tests/e2e/paired-mobile-desktop-mirror.spec.ts
index 353c628e660..746a6218ca4 100644
--- a/tests/e2e/paired-mobile-desktop-mirror.spec.ts
+++ b/tests/e2e/paired-mobile-desktop-mirror.spec.ts
@@ -3,6 +3,7 @@
* the server's workspaces through the desktop by naming them with `executionHost: runtime:`.
* The desktop relays as the phone's own delegated device on the server.
*/
+import { execFileSync } from 'node:child_process'
import { mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { expect, test } from './helpers/orca-app'
@@ -22,6 +23,8 @@ const SubscribedSchema = z.looseObject({ type: z.literal('subscribed'), streamId
const HostsSchema = z.object({
hosts: z.array(z.looseObject({ hostId: z.string(), relay: z.string() }))
})
+const DirectorySchema = z.array(z.looseObject({ name: z.string() }))
+const GitStatusSchema = z.looseObject({ entries: z.array(z.looseObject({ path: z.string() })) })
const HostWorktreesSchema = z.looseObject({
worktrees: z.array(
z.looseObject({ worktreeId: z.string(), path: z.string(), hostId: z.string() })
@@ -132,6 +135,15 @@ test('phone paired with a desktop lists, opens and types into a workspace on its
const serverFolder = testInfo.outputPath('server-folder')
mkdirSync(serverFolder, { recursive: true })
writeFileSync(path.join(serverFolder, 'README.md'), 'server workspace\n')
+ // A git repo on the server too, so Source Control has a change to read there.
+ const serverRepo = testInfo.outputPath('server-repo')
+ mkdirSync(serverRepo, { recursive: true })
+ writeFileSync(path.join(serverRepo, 'NOTES.md'), 'first\n')
+ const git = (...args: string[]) => execFileSync('git', args, { cwd: serverRepo, stdio: 'pipe' })
+ git('init')
+ git('add', 'NOTES.md')
+ git('-c', 'user.name=E2E', '-c', 'user.email=e2e@test.local', 'commit', '-m', 'init')
+ writeFileSync(path.join(serverRepo, 'NOTES.md'), 'changed on the server\n')
const { host, desktop, phone, dispose } = await launchPhoneMirrorTopology(
{ phoneTo: 'desktop', pinnedServePort: true },
@@ -139,6 +151,7 @@ test('phone paired with a desktop lists, opens and types into a workspace on its
)
try {
await host.client.call('repo.add', { path: serverFolder, kind: 'folder' })
+ await host.client.call('repo.add', { path: serverRepo, kind: 'git' })
await new RuntimeClient(desktop.userDataDir, 5_000).call('repo.add', {
path: testRepoPath,
kind: 'git'
@@ -181,12 +194,45 @@ test('phone paired with a desktop lists, opens and types into a workspace on its
expect(hosts).toContainEqual(expect.objectContaining({ hostId: serverHostId, relay: 'ready' }))
let listed: Awaited> = null
await expect
- .poll(async () => (listed = await hostWorktrees(socket, serverHostId))?.stale)
- .toBe(false)
+ .poll(async () => {
+ listed = await hostWorktrees(socket, serverHostId)
+ return listed?.stale === false && listed.worktrees.some((row) => row.path === serverRepo)
+ })
+ .toBe(true)
const serverRow = listed!.worktrees.find((row) => row.path === serverFolder)
+ const serverRepoRow = listed!.worktrees.find((row) => row.path === serverRepo)
expect(serverRow, 'the phone lists the server workspace through the desktop').toBeDefined()
expect(new Set(listed!.worktrees.map((row) => row.hostId))).toEqual(new Set([serverHostId]))
+ // Files and Source Control read the server's workspace there, and never on the desktop.
+ const readDir = (worktreeId: string) => ({
+ method: 'files.readDir',
+ params: { worktree: `id:${worktreeId}`, relativePath: '' }
+ })
+ const gitStatus = (worktreeId: string) => ({
+ method: 'git.status',
+ params: { worktree: `id:${worktreeId}` }
+ })
+ const entries = await call(
+ socket,
+ { id: 'files', ...readDir(serverRow!.worktreeId), executionHost: serverHostId },
+ DirectorySchema
+ )
+ expect(entries.map((entry) => entry.name)).toContain('README.md')
+ const changes = await call(
+ socket,
+ { id: 'git', ...gitStatus(serverRepoRow!.worktreeId), executionHost: serverHostId },
+ GitStatusSchema
+ )
+ expect(changes.entries.map((entry) => entry.path)).toContain('NOTES.md')
+ for (const [id, request] of [
+ ['files-desktop', readDir(serverRow!.worktreeId)],
+ ['git-desktop', gitStatus(serverRepoRow!.worktreeId)]
+ ] as const) {
+ socket.send(id, request.method, request.params)
+ expect((await reply(socket, id)).ok, `${request.method} untargeted`).toBe(false)
+ }
+
const relayed = await openTerminal(socket, serverRow!.worktreeId, serverHostId)
await relayed.runMarker('MIRROR')
const relayedMs: number[] = []
@@ -229,6 +275,15 @@ test('phone paired with a desktop lists, opens and types into a workspace on its
const stopped = await reply(socket, 'ps-stopped', 60_000)
expect(stopped.ok).toBe(false)
expect(stopped.error?.code).toBe('remote_runtime_unavailable')
+ for (const [id, request] of [
+ ['files-stopped', readDir(serverRow!.worktreeId)],
+ ['git-stopped', gitStatus(serverRepoRow!.worktreeId)]
+ ] as const) {
+ socket.send(id, request.method, request.params, serverHostId)
+ const failed = await reply(socket, id, 60_000)
+ expect(failed.ok, `${request.method} while the server is down`).toBe(false)
+ expect(failed.error?.code).toBe('remote_runtime_unavailable')
+ }
}
})
// Back up: the listing is fresh again, and the same phone socket reaches the server.