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

The sheet's run targets came from the host list's server rows, which never load while
the sheet is open (the list deliberately pauses its refresh behind it). From Home's
New Workspace that left only the desktop's repos. The sheet now reads the desktop's
server-list feed itself while it is open.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-10-11 06:01:02 -04:00
parent 95e44fae04
commit 6ea1d86e05
3 changed files with 98 additions and 14 deletions
@@ -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 })
@@ -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<ExecutionHostId, RpcClient> | null } = { clients: null }
function Sheet({ open }: { open: boolean }): null {
seen.clients = useReachableServerClients(open ? client : null, RELAYS)
return null
}
const rendered: { tree: ReturnType<typeof create> | 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'])
})
})
+30 -8
View File
@@ -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<ExecutionHostId, RpcClient> {
const hosts = useRelayHosts(client)
return useMemo(
() => reachableServerClients(client, hostCapabilities, hosts ?? []),
[client, hostCapabilities, hosts]
)
}