diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx index 8e3605aedf5..a4bd353c0f0 100644 --- a/mobile/src/host-screen/host-screen-overlays.tsx +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -4,7 +4,7 @@ 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 { useReachableServerClients, useWorkspaceServer } from '../transport/workspace-server' +import { useWorkspaceServer } from '../transport/workspace-server' import { ActionSheetContent } from '../components/ActionSheetModal' import { BottomDrawer } from '../components/BottomDrawer' import { ConfirmModal } from '../components/ConfirmModal' @@ -19,6 +19,7 @@ import { } from '../worktree/workspace-list-picker-options' import { isWorktreePinned } from '../worktree/workspace-list-sections' import { hostScreenStyles as styles } from './host-screen-styles' +import { useNewWorkspaceServers } from './use-host-display-worktrees' import type { HostScreenController } from './use-host-screen-controller' export function HostScreenOverlays({ controller }: { controller: HostScreenController }) { @@ -43,12 +44,13 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr desktopCapabilities: hostCapabilities, executionHost: actionTarget?.hostId }) - // 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 - ) + const newWorkspaceServers = useNewWorkspaceServers({ + client, + hostCapabilities, + hostLabelById: hostLabels.hostLabelById, + open: newWorktreeVisible + }) return ( <> @@ -229,9 +231,9 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr ref={state.newWorktreeModalRef} routeVisible={showNewWorktree} client={client} - serverClients={serverClients} + serverClients={newWorkspaceServers.serverClients} hostId={hostId} - hostLabelById={hostLabels.hostLabelById} + hostLabelById={newWorkspaceServers.hostLabelById} existingWorktreePaths={existingWorktreePaths} existingWorktrees={state.worktrees} // The seam, not react-native's `Linking`: this screen is in the tasks page closure, and diff --git a/mobile/src/transport/workspace-server.test.tsx b/mobile/src/host-screen/new-workspace-servers.test.tsx similarity index 63% rename from mobile/src/transport/workspace-server.test.tsx rename to mobile/src/host-screen/new-workspace-servers.test.tsx index 730d4faf3cf..704d76f359c 100644 --- a/mobile/src/transport/workspace-server.test.tsx +++ b/mobile/src/host-screen/new-workspace-servers.test.tsx @@ -3,18 +3,17 @@ 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: () => {} })) +vi.mock('../transport/host-descriptor-store', () => ({ useHostDescriptor: () => null })) 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' +import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes' +import { useNewWorkspaceServers } from './use-host-display-worktrees' const RELAYS = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY] +// The host list's own labels: what a sheet opened from Home sees before the list loads a server. +const DESKTOP_LABELS: ReadonlyMap = new Map() /** A desktop that lists one ready server. */ function desktop(): FakeSession { @@ -38,11 +37,16 @@ function listCalls(session: FakeSession): number { } 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 () => { + it('are offered, by name, when the sheet opens before the host list has loaded them', async () => { const client = desktop() - const seen: { clients: ReadonlyMap | null } = { clients: null } + const seen: { value: ReturnType | null } = { value: null } function Sheet({ open }: { open: boolean }): null { - seen.clients = useReachableServerClients(open ? client : null, RELAYS) + seen.value = useNewWorkspaceServers({ + client, + hostCapabilities: RELAYS, + hostLabelById: DESKTOP_LABELS, + open + }) return null } const rendered: { tree: ReturnType | null } = { tree: null } @@ -51,10 +55,11 @@ describe('the servers a new workspace can run on', () => { }) // Closed, it asks nothing of the desktop. expect(listCalls(client)).toBe(0) - expect([...(seen.clients?.keys() ?? [])]).toEqual([]) + expect([...(seen.value?.serverClients.keys() ?? [])]).toEqual([]) await act(async () => { rendered.tree?.update(createElement(Sheet, { open: true })) }) - expect([...(seen.clients?.keys() ?? [])]).toEqual(['runtime:vm']) + expect([...(seen.value?.serverClients.keys() ?? [])]).toEqual(['runtime:vm']) + expect(seen.value?.hostLabelById.get('runtime:vm')).toBe('vm') }) }) diff --git a/mobile/src/host-screen/use-host-display-worktrees.ts b/mobile/src/host-screen/use-host-display-worktrees.ts index 86f85c9c94d..1fbb0070689 100644 --- a/mobile/src/host-screen/use-host-display-worktrees.ts +++ b/mobile/src/host-screen/use-host-display-worktrees.ts @@ -1,7 +1,10 @@ import { useMemo } from 'react' import type { ExecutionHostId } from '../../../src/shared/execution-host' +import { reachableServerClients } from '../transport/execution-host-scoped-rpc-client' import { useHostDescriptor } from '../transport/host-descriptor-store' +import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' +import { useRelayHosts } from '../worktree/relay-hosts-feed' import { serverHostHealth } from '../worktree/server-workspaces' import { filterVisibleHostRows } from '../worktree/visible-host-rows' import { @@ -40,6 +43,30 @@ export function useHostLabelSources( ) } +/** + * The servers the new-workspace sheet offers, and their names, read from the desktop's server-list + * feed while it is open: the list's own server rows stop refreshing behind the sheet. + */ +export function useNewWorkspaceServers(args: { + client: RpcClient | null + hostCapabilities: readonly string[] + hostLabelById: ReadonlyMap + open: boolean +}): { + serverClients: ReadonlyMap + hostLabelById: ReadonlyMap +} { + const { client, hostCapabilities, hostLabelById, open } = args + const hosts = useRelayHosts(open ? client : null) + return useMemo(() => { + const listed = hosts ?? [] + return { + serverClients: reachableServerClients(client, hostCapabilities, listed), + hostLabelById: withServerHosts(hostLabelById, listed, (host) => host.label) + } + }, [client, hostCapabilities, hostLabelById, hosts]) +} + /** The rows the host list renders: the live or last-known catalog with display state and host badges. */ export function useHostDisplayWorktrees( connState: ConnectionState, diff --git a/mobile/src/transport/workspace-server.ts b/mobile/src/transport/workspace-server.ts index 48056d813e9..96f499f22b2 100644 --- a/mobile/src/transport/workspace-server.ts +++ b/mobile/src/transport/workspace-server.ts @@ -1,10 +1,7 @@ -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 { useRelayHosts } from '../worktree/relay-hosts-feed' import { serverHostHealth } from '../worktree/server-workspaces' import { - reachableServerClients, scopeRpcClientToExecutionHost, workspaceRouteExecutionHost } from './execution-host-scoped-rpc-client' @@ -34,19 +31,6 @@ export function useWorkspaceServer(args: { return { routeHost, server: routeHost === undefined ? null : { client: serverClient, gates } } } -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. @@ -76,18 +60,3 @@ 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] - ) -} diff --git a/mobile/src/worktree/relay-hosts-feed.ts b/mobile/src/worktree/relay-hosts-feed.ts index adca7ae3f1a..e5cfa3cfbe2 100644 --- a/mobile/src/worktree/relay-hosts-feed.ts +++ b/mobile/src/worktree/relay-hosts-feed.ts @@ -2,6 +2,7 @@ // list and every open server workspace share one poll and one answer, so a row's badge and its // workspace screen never disagree and two readers never double the desktop's load. +import { useCallback, useSyncExternalStore } from 'react' import { AppState } from 'react-native' import type { MobileRelayHost } from '../../../src/shared/mobile-relay-hosts-contract' import type { RpcClient } from '../transport/rpc-client' @@ -127,3 +128,16 @@ export function relayHostsFeed(client: RpcClient): RelayHostsFeed { } return feed } + +const noSubscription = (): (() => void) => () => {} + +/** The desktop's servers from the shared feed while `client` is set; null before it answers. */ +export 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) +}