fix(mobile): name the desktop's servers in a new workspace opened before the list loads

The previous fix offered the servers but still named them from the host list's rows,
so a sheet opened from Home showed a server's raw id under Run on. The sheet now takes
both the servers and their names from the server-list feed while it is open.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-10-11 06:04:45 -04:00
parent 6ea1d86e05
commit 8ea1425af6
5 changed files with 68 additions and 51 deletions
@@ -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
@@ -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<ExecutionHostId, string> = 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<ExecutionHostId, RpcClient> | null } = { clients: null }
const seen: { value: ReturnType<typeof useNewWorkspaceServers> | 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<typeof create> | 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')
})
})
@@ -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<ExecutionHostId, string>
open: boolean
}): {
serverClients: ReadonlyMap<ExecutionHostId, RpcClient>
hostLabelById: ReadonlyMap<ExecutionHostId, string>
} {
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,
+1 -32
View File
@@ -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<ExecutionHostId, RpcClient> {
const hosts = useRelayHosts(client)
return useMemo(
() => reachableServerClients(client, hostCapabilities, hosts ?? []),
[client, hostCapabilities, hosts]
)
}
+14
View File
@@ -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)
}