From c679558074856a88ce36d6925e601f91acd0eda3 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 03:33:46 -0400 Subject: [PATCH] fix(mobile): never open an unreachable server workspace, and prove the relay in e2e A server workspace the phone can't reach now gets one explicit screen at its route and no row tap or action-sheet entry opens it; native routes read the route's host, a census pins every workspace route, and the e2e reads files and git on the server. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- mobile/app/h/[hostId]/review/[worktreeId].tsx | 19 +- .../app/h/[hostId]/session/[worktreeId].tsx | 19 +- .../worktree-navigation-actions.test.ts | 22 +++ .../worktree-navigation-actions.ts | 12 +- mobile/src/files/MobileFileExplorerPanel.tsx | 4 +- mobile/src/files/MobileFilePreviewScreen.tsx | 4 +- .../src/host-screen/host-screen-overlays.tsx | 12 +- .../host-server-workspaces-wiring.test.ts | 10 + .../host-screen/use-host-worktree-actions.ts | 13 +- .../PageRouteUnavailableScreen.tsx | 10 +- ...ile-web-shell-agent-history-route.test.tsx | 4 + .../mobile-web-shell-files-route.test.tsx | 4 + .../mobile-web-shell-session-route.test.tsx | 4 + .../shell-switch-null-flag.test.tsx | 4 + .../workspace-route-handoff.test.tsx | 16 +- .../workspace-route-screens.test.tsx | 56 ------ .../src/navigation/workspace-route.test.tsx | 173 ++++++++++++++++++ mobile/src/navigation/workspace-route.tsx | 27 ++- .../session/MobileDiffReviewRouteScreen.tsx | 4 +- .../use-mobile-source-control-state.ts | 8 +- .../transport/execution-host-envelope.test.ts | 29 +-- .../execution-host-scoped-rpc-client.ts | 23 ++- .../transport/use-workspace-client.test.tsx | 20 +- mobile/src/transport/use-workspace-client.ts | 8 +- .../e2e/paired-mobile-desktop-mirror.spec.ts | 59 +++++- 25 files changed, 424 insertions(+), 140 deletions(-) delete mode 100644 mobile/src/navigation/workspace-route-screens.test.tsx create mode 100644 mobile/src/navigation/workspace-route.test.tsx 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.