diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx index 2eda461fa43..8e3605aedf5 100644 --- a/mobile/src/host-screen/host-screen-overlays.tsx +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -1,11 +1,10 @@ -import { useMemo } from 'react' +import { useState } from 'react' 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 { workspaceRouteHref } from '../navigation/workspace-execution-host' -import { reachableServerClients } from '../transport/execution-host-scoped-rpc-client' -import { useWorkspaceServer } from '../transport/workspace-server' +import { useReachableServerClients, useWorkspaceServer } from '../transport/workspace-server' import { ActionSheetContent } from '../components/ActionSheetModal' import { BottomDrawer } from '../components/BottomDrawer' import { ConfirmModal } from '../components/ConfirmModal' @@ -44,9 +43,11 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr desktopCapabilities: hostCapabilities, executionHost: actionTarget?.hostId }) - const serverClients = useMemo( - () => reachableServerClients(client, hostCapabilities, state.serverWorkspaces.hosts), - [client, hostCapabilities, state.serverWorkspaces.hosts] + // Why its own read: the host list's rows stop refreshing while the sheet is open. + const [newWorktreeVisible, setNewWorktreeVisible] = useState(false) + const serverClients = useReachableServerClients( + newWorktreeVisible ? client : null, + hostCapabilities ) return ( @@ -238,6 +239,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr openExternalUrl={openExternalLink} onVisibleChange={(visible) => { state.newWorktreeModalVisibleRef.current = visible + setNewWorktreeVisible(visible) }} onCreated={(worktreeId, worktreeName, warning, executionHost) => { void catalog.fetchWorktrees({ allowDuringModal: true }) diff --git a/mobile/src/transport/workspace-server.test.tsx b/mobile/src/transport/workspace-server.test.tsx new file mode 100644 index 00000000000..730d4faf3cf --- /dev/null +++ b/mobile/src/transport/workspace-server.test.tsx @@ -0,0 +1,60 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('react-native', () => ({ AppState: { currentState: 'active' } })) +// The server status gates beside this hook persist what a desktop reports; nothing here reads them. +vi.mock('./host-app-version-store', () => ({ recordHostAppVersion: async () => {} })) +vi.mock('./host-descriptor-recorder', () => ({ recordHostDescriptorFromStatus: () => {} })) + +import type { ExecutionHostId } from '../../../src/shared/execution-host' +import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract' +import { MOBILE_RELAY_HOSTS_LIST_METHOD } from '../../../src/shared/mobile-relay-hosts-contract' +import { FakeSession } from './mobile-endpoint-supervisor-test-fakes' +import type { RpcClient } from './rpc-client' +import { useReachableServerClients } from './workspace-server' + +const RELAYS = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY] + +/** A desktop that lists one ready server. */ +function desktop(): FakeSession { + const session = new FakeSession('connected') + session.sendRequest.mockImplementation(async (method: string) => ({ + id: 'reply', + ok: true, + result: + method === MOBILE_RELAY_HOSTS_LIST_METHOD + ? { hosts: [{ hostId: 'runtime:vm', label: 'vm', health: 'available', relay: 'ready' }] } + : {}, + _meta: { runtimeId: 'runtime-1' } + })) + return session +} + +function listCalls(session: FakeSession): number { + return session.sendRequest.mock.calls.filter( + ([method]) => method === MOBILE_RELAY_HOSTS_LIST_METHOD + ).length +} + +describe('the servers a new workspace can run on', () => { + it('are offered when the sheet opens, whether or not the host list has loaded their rows', async () => { + const client = desktop() + const seen: { clients: ReadonlyMap | null } = { clients: null } + function Sheet({ open }: { open: boolean }): null { + seen.clients = useReachableServerClients(open ? client : null, RELAYS) + return null + } + const rendered: { tree: ReturnType | null } = { tree: null } + await act(async () => { + rendered.tree = create(createElement(Sheet, { open: false })) + }) + // Closed, it asks nothing of the desktop. + expect(listCalls(client)).toBe(0) + expect([...(seen.clients?.keys() ?? [])]).toEqual([]) + await act(async () => { + rendered.tree?.update(createElement(Sheet, { open: true })) + }) + expect([...(seen.clients?.keys() ?? [])]).toEqual(['runtime:vm']) + }) +}) diff --git a/mobile/src/transport/workspace-server.ts b/mobile/src/transport/workspace-server.ts index 4df40813200..48056d813e9 100644 --- a/mobile/src/transport/workspace-server.ts +++ b/mobile/src/transport/workspace-server.ts @@ -1,8 +1,10 @@ -import { useCallback, useSyncExternalStore } from 'react' +import { useCallback, useMemo, useSyncExternalStore } from 'react' import type { ExecutionHostId } from '../../../src/shared/execution-host' +import type { MobileRelayHost } from '../../../src/shared/mobile-relay-hosts-contract' import { relayHostsFeed } from '../worktree/relay-hosts-feed' import { serverHostHealth } from '../worktree/server-workspaces' import { + reachableServerClients, scopeRpcClientToExecutionHost, workspaceRouteExecutionHost } from './execution-host-scoped-rpc-client' @@ -34,6 +36,17 @@ export function useWorkspaceServer(args: { const noSubscription = (): (() => void) => () => {} +/** The desktop's servers from the shared feed while `client` is set; null before it answers. */ +function useRelayHosts(client: RpcClient | null): readonly MobileRelayHost[] | null { + const feed = client ? relayHostsFeed(client) : null + const subscribe = useCallback( + (listener: () => void) => (feed ? feed.subscribe(listener) : noSubscription()), + [feed] + ) + const readHosts = () => feed?.getSnapshot() ?? null + return useSyncExternalStore(subscribe, readHosts, readHosts) +} + /** * The connection state a server workspace's screens see: the desktop's, unless the desktop reports * that server down. Desktop-owned workspaces (`routeHost` absent) keep the desktop's state untouched. @@ -45,13 +58,7 @@ export function useWorkspaceServerConnState(args: { }): ConnectionState { const { client, connState, routeHost } = args // The same feed the host list reads, so a row's badge and its screens agree. - const feed = client && routeHost ? relayHostsFeed(client) : null - const subscribe = useCallback( - (listener: () => void) => (feed ? feed.subscribe(listener) : noSubscription()), - [feed] - ) - const readHosts = () => feed?.getSnapshot() ?? null - const hosts = useSyncExternalStore(subscribe, readHosts, readHosts) + const hosts = useRelayHosts(routeHost ? client : null) if (connState !== 'connected' || !routeHost || !hosts) { return connState } @@ -69,3 +76,18 @@ export function useWorkspaceServerConnState(args: { return 'disconnected' } } + +/** + * The servers a new workspace can run on, read from the desktop's server-list feed while `client` + * is set (the sheet is open), not from the host list's rows, which pause while the sheet is open. + */ +export function useReachableServerClients( + client: RpcClient | null, + hostCapabilities: readonly string[] +): ReadonlyMap { + const hosts = useRelayHosts(client) + return useMemo( + () => reachableServerClients(client, hostCapabilities, hosts ?? []), + [client, hostCapabilities, hosts] + ) +}