diff --git a/mobile/src/components/NewWorktreeModal.test.tsx b/mobile/src/components/NewWorktreeModal.test.tsx index b7d7b24bb91..99bd441327d 100644 --- a/mobile/src/components/NewWorktreeModal.test.tsx +++ b/mobile/src/components/NewWorktreeModal.test.tsx @@ -11,6 +11,7 @@ const asyncStorage = vi.hoisted(() => ({ vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) vi.mock('react-native', () => ({ ActivityIndicator: 'ActivityIndicator', + AppState: { currentState: 'active' }, FlatList: 'FlatList', Image: 'Image', Keyboard: { dismiss: vi.fn() }, @@ -45,8 +46,12 @@ vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: 'TaskProviderLogo' })) import { setCachedRepos } from '../cache/repo-cache' import { recordHostDescriptor } from '../transport/host-descriptor-store' +import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes' import { getLocalExecutionHostLabel } 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 { NewWorktreeModal } from './NewWorktreeModal' +import { NewWorktreeModalController } from './NewWorktreeModalController' const LOCAL_HOST_LABEL = getLocalExecutionHostLabel('darwin') @@ -548,6 +553,58 @@ describe('NewWorktreeModal project targets', () => { expect(server).not.toHaveBeenCalledWith('worktree.create', expect.anything(), expect.anything()) }) + it("offers the desktop's servers, by name, in a sheet opened before the list has them", async () => { + const serverRepo = { ...repos[0]!, id: 'server-repo', path: '/srv/orca' } + // One desktop: its own repos, its server list, and a server's repos relayed through it. + const desktop = new FakeSession('connected') + desktop.sendRequest.mockImplementation(async (method: string, ...rest: unknown[]) => { + const options = rest[1] + const relayed = + typeof options === 'object' && options !== null && 'executionHost' in options + ? options.executionHost + : undefined + const result = + method === MOBILE_RELAY_HOSTS_LIST_METHOD + ? { + hosts: [ + { hostId: 'runtime:vm', label: 'scratch-vm', health: 'available', relay: 'ready' } + ] + } + : method === 'repo.list' + ? { repos: relayed === 'runtime:vm' ? [serverRepo] : repos } + : {} + return { id: 'reply', ok: true, result, _meta: { runtimeId: 'runtime-1' } } + }) + const listCalls = () => + desktop.sendRequest.mock.calls.filter(([method]) => method === MOBILE_RELAY_HOSTS_LIST_METHOD) + // The host list's own names: no server rows have loaded behind a sheet opened from Home. + const sheet = (routeVisible: boolean) => + createElement(NewWorktreeModalController, { + routeVisible, + client: desktop, + hostCapabilities: [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY], + hostId: 'host-1', + hostLabelById: new Map(), + openExternalUrl: () => {}, + onCreated: () => {}, + onRouteVisibleChange: () => {} + }) + + await act(async () => { + renderer = create(sheet(false)) + }) + await flushUpdates() + // Closed, it asks nothing of the desktop. + expect(listCalls()).toHaveLength(0) + + await act(async () => renderer.update(sheet(true))) + await flushUpdates() + expect(pickerItems(renderer, 'Run on').map((item) => [item.label, item.detail])).toEqual([ + [expect.any(String), '/src/orca'], + ['scratch-vm', '/srv/orca'] + ]) + }) + it("lists the desktop's repos at once while a server has not answered", async () => { const desktop = vi.fn((method: string) => method === 'repo.list' diff --git a/mobile/src/components/NewWorktreeModalController.tsx b/mobile/src/components/NewWorktreeModalController.tsx index c00dfc03e9e..3210c67e209 100644 --- a/mobile/src/components/NewWorktreeModalController.tsx +++ b/mobile/src/components/NewWorktreeModalController.tsx @@ -1,5 +1,9 @@ -import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from 'react' +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react' +import type { ExecutionHostId } from '../../../src/shared/execution-host' +import { reachableServerClients } from '../transport/execution-host-scoped-rpc-client' +import { useRelayHosts } from '../worktree/relay-hosts-feed' +import { withServerHosts } from '../worktree/server-workspaces' import { NewWorktreeModal } from './NewWorktreeModal' import type { NewWorktreeModalProps } from './new-worktree-modal-types' @@ -7,7 +11,14 @@ export type NewWorktreeModalControllerHandle = { open: () => void } -type Props = Omit & { +type Props = Omit< + NewWorktreeModalProps, + 'visible' | 'onClose' | 'serverClients' | 'hostLabelById' +> & { + /** The desktop's capabilities, which say whether its servers are reachable through it. */ + hostCapabilities: readonly string[] + /** The desktop's own host names; the sheet adds its servers' from the feed. */ + hostLabelById: ReadonlyMap routeVisible: boolean onVisibleChange?: (visible: boolean) => void onRouteVisibleChange: (visible: boolean) => void @@ -18,7 +29,7 @@ export const NewWorktreeModalController = forwardRef reachableServerClients(client, hostCapabilities, servers ?? []), + [client, hostCapabilities, servers] + ) + const serverLabelById = useMemo( + () => withServerHosts(hostLabelById, servers ?? [], (host) => host.label), + [hostLabelById, servers] + ) useImperativeHandle( ref, @@ -58,7 +79,7 @@ export const NewWorktreeModalController = forwardRef ({ + AppState: { currentState: 'active' }, + Linking: { openURL: vi.fn() }, + Platform: { OS: 'ios', select: (options: { ios?: unknown }) => options.ios }, + Pressable: 'Pressable', + StyleSheet: { create: (styles: T) => styles }, + Text: 'Text', + View: 'View' +})) +vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' })) +vi.mock('lucide-react-native', () => ({ Check: 'Check', Moon: 'Moon' })) +vi.mock('../agent-history/worktree-navigation-actions', () => ({ + buildWorktreeNavigationActions: () => [] +})) +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetContent: 'ActionSheetContent' })) +vi.mock('../components/BottomDrawer', () => ({ BottomDrawer: 'BottomDrawer' })) +vi.mock('../components/ConfirmModal', () => ({ ConfirmModal: 'ConfirmModal' })) +vi.mock('../components/NewWorktreeModalController', () => ({ + NewWorktreeModalController: 'NewWorktreeModalController' +})) +vi.mock('../components/PickerModal', () => ({ PickerModal: 'PickerModal' })) + +import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract' +import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes' +import type { Worktree } from '../worktree/workspace-list-types' +import { HostScreenOverlays } from './host-screen-overlays' + +let renderer: ReactTestRenderer | null = null + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null +}) + +/** The long-press sheet's action labels for `row`. */ +async function actionLabels(row: Partial): Promise { + const controller = { + actions: {}, + catalog: {}, + client: new FakeSession('connected'), + connState: 'connected', + existingWorktreePaths: [], + hostCapabilities: [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY], + hostId: 'host-1', + sectionsResult: { uniqueRepos: [] }, + settings: { activeFilterCount: 0 }, + showNewWorktree: false, + state: { + actionTarget: { worktreeId: 'wt', repo: 'orca', branch: 'main', ...row }, + confirmDelete: null, + filters: { filterRepoIds: new Set() }, + hostLabelById: new Map(), + pinnedIds: new Set() + } + } + await act(async () => { + renderer = create( + createElement(HostScreenOverlays, { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the action sheet reads only these members; the drawers and pickers that read the rest are mocked. + controller: controller as unknown as Parameters[0]['controller'] + }) + ) + }) + const sheet = renderer!.root.find((node) => String(node.type) === 'ActionSheetContent') + return sheet.props.actions.map((action: { label: string }) => action.label) +} + +describe('the workspace long-press sheet', () => { + it("offers Sleep for the desktop's own workspace", async () => { + expect(await actionLabels({})).toContain('Sleep') + }) + + it("does not offer Sleep for a server's workspace, which the desktop would sleep by its own id", async () => { + expect(await actionLabels({ worktreeId: 'runtime:vm-wt', hostId: 'runtime:vm' })).toEqual([ + 'Pin', + 'Delete' + ]) + }) +}) diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx index a4bd353c0f0..f02078e0e3a 100644 --- a/mobile/src/host-screen/host-screen-overlays.tsx +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react' import { openExternalLink } from '../platform/external-link' import { Pressable, Text, View } from 'react-native' import { Check, Moon } from 'lucide-react-native' @@ -19,7 +18,6 @@ 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 }) { @@ -31,7 +29,6 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr existingWorktreePaths, hostCapabilities, hostId, - hostLabels, settings, showNewWorktree, state @@ -44,13 +41,6 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr desktopCapabilities: hostCapabilities, executionHost: actionTarget?.hostId }) - const [newWorktreeVisible, setNewWorktreeVisible] = useState(false) - const newWorkspaceServers = useNewWorkspaceServers({ - client, - hostCapabilities, - hostLabelById: hostLabels.hostLabelById, - open: newWorktreeVisible - }) return ( <> @@ -189,14 +179,19 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr navigate: actions.navigateFromHostList, onDone: () => state.setActionTarget(null) }), - { - label: 'Sleep', - icon: Moon, - onPress: () => { - actions.putWorktreeToSleep(actionTarget) - state.setActionTarget(null) - } - }, + // Server sleep is not offered yet; the desktop would sleep its own id. + ...(actionServer.server + ? [] + : [ + { + label: 'Sleep', + icon: Moon, + onPress: () => { + actions.putWorktreeToSleep(actionTarget) + state.setActionTarget(null) + } + } + ]), { label: isWorktreePinned(actionTarget, state.pinnedIds) ? 'Unpin' : 'Pin', onPress: () => { @@ -231,9 +226,9 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr ref={state.newWorktreeModalRef} routeVisible={showNewWorktree} client={client} - serverClients={newWorkspaceServers.serverClients} + hostCapabilities={hostCapabilities} hostId={hostId} - hostLabelById={newWorkspaceServers.hostLabelById} + hostLabelById={state.hostLabelById} existingWorktreePaths={existingWorktreePaths} existingWorktrees={state.worktrees} // The seam, not react-native's `Linking`: this screen is in the tasks page closure, and @@ -241,7 +236,6 @@ 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/host-screen/host-server-workspaces-wiring.test.ts b/mobile/src/host-screen/host-server-workspaces-wiring.test.ts index 9813d2a6a5e..1d0aada05c5 100644 --- a/mobile/src/host-screen/host-server-workspaces-wiring.test.ts +++ b/mobile/src/host-screen/host-server-workspaces-wiring.test.ts @@ -26,7 +26,7 @@ import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes' import type { RpcResponse } from '../transport/types' import { readVisibleHostIds } from '../worktree/visible-host-rows' import type { Worktree } from '../worktree/workspace-list-types' -import { useHostDisplayWorktrees, useHostLabelSources } from './use-host-display-worktrees' +import { useHostDisplayWorktrees } from './use-host-display-worktrees' import { useHostScreenState } from './use-host-screen-state' import { useHostWorktreeActions } from './use-host-worktree-actions' import { useHostWorktreeCatalog } from './use-host-worktree-catalog' @@ -149,7 +149,7 @@ async function mountScreen(first: FakeSession, hostCapabilities: string[]): Prom }) held.state = state held.fetch = catalog.fetchWorktrees - held.rows = useHostDisplayWorktrees('connected', state, useHostLabelSources('host-1', state)) + held.rows = useHostDisplayWorktrees('connected', state, 'host-1') return null } await act(async () => { diff --git a/mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts b/mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts index 5984b4df144..9f47bafa747 100644 --- a/mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts +++ b/mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts @@ -13,7 +13,7 @@ vi.mock('../cache/repo-cache', () => ({ setCachedRepos: () => {} })) import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes' import type { RpcResponse } from '../transport/types' import type { Worktree } from '../worktree/workspace-list-types' -import { useHostDisplayWorktrees, useHostLabelSources } from './use-host-display-worktrees' +import { useHostDisplayWorktrees } from './use-host-display-worktrees' import { useHostRepoMetadata } from './use-host-repo-metadata' import { useHostScreenState } from './use-host-screen-state' @@ -70,7 +70,7 @@ async function displayedRows(args: { state.clientRef.current = client setWorktrees = state.setWorktrees seen.refresh = useHostRepoMetadata({ client, connState: 'connected', hostId: 'host-1', state }) - seen.rows = useHostDisplayWorktrees('connected', state, useHostLabelSources('host-1', state)) + seen.rows = useHostDisplayWorktrees('connected', state, 'host-1') return null } await act(async () => { diff --git a/mobile/src/host-screen/new-workspace-servers.test.tsx b/mobile/src/host-screen/new-workspace-servers.test.tsx deleted file mode 100644 index 704d76f359c..00000000000 --- a/mobile/src/host-screen/new-workspace-servers.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -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' } })) -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 '../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 { - 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, by name, when the sheet opens before the host list has loaded them', async () => { - const client = desktop() - const seen: { value: ReturnType | null } = { value: null } - function Sheet({ open }: { open: boolean }): null { - seen.value = useNewWorkspaceServers({ - client, - hostCapabilities: RELAYS, - hostLabelById: DESKTOP_LABELS, - open - }) - 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.value?.serverClients.keys() ?? [])]).toEqual([]) - await act(async () => { - rendered.tree?.update(createElement(Sheet, { open: true })) - }) - 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 1fbb0070689..a4081a1b7fa 100644 --- a/mobile/src/host-screen/use-host-display-worktrees.ts +++ b/mobile/src/host-screen/use-host-display-worktrees.ts @@ -1,24 +1,18 @@ 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 { serverHostHealth, withServerHosts } from '../worktree/server-workspaces' import { filterVisibleHostRows } from '../worktree/visible-host-rows' -import { - applyWorktreeHostContextLabels, - type HostLabelSources -} from '../worktree/worktree-host-context-labels' +import { applyWorktreeHostContextLabels } from '../worktree/worktree-host-context-labels' import { applyWorktreeRowDisplayState } from '../worktree/worktree-host-row-identity' import type { HostScreenState } from './use-host-screen-state' -/** What names each host on this screen: the row badges and the new-workspace run targets. */ -export function useHostLabelSources( - hostId: string | undefined, - state: HostScreenState -): HostLabelSources { +/** The rows the host list renders: the live or last-known catalog with display state and host badges. */ +export function useHostDisplayWorktrees( + connState: ConnectionState, + state: HostScreenState, + hostId: string | undefined +) { const { serverWorkspaces } = state // Why: a server's label and health come with its rows, alongside the desktop's SSH ones. const hostLabelById = useMemo( @@ -32,84 +26,35 @@ export function useHostLabelSources( const descriptorPlatform = useHostDescriptor(hostId)?.platform ?? null // Why: metadata reads the platform only for a mixed desktop catalog; server rows mix it too. const hostPlatform = state.hostPlatform ?? descriptorPlatform - return useMemo( - () => ({ - repoHostIdByRepoId: state.repoHostIdByRepoId, - hostLabelById, - hostPlatform, - hostHealthById - }), - [state.repoHostIdByRepoId, hostLabelById, hostPlatform, hostHealthById] - ) -} - -/** - * 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, - state: HostScreenState, - labels: HostLabelSources -) { return useMemo(() => { // Why: live `worktrees` is authoritative only while connected; under the amber // mount default, connecting/handshaking must keep the pre-reconnect list too. const base = connState === 'connected' ? state.worktrees : state.lastKnownWorktrees const rows = filterVisibleHostRows( - [...base, ...state.serverWorkspaces.worktrees], + [...base, ...serverWorkspaces.worktrees], state.visibleHostIds, state.repoHostIdByRepoId ) return applyWorktreeHostContextLabels( applyWorktreeRowDisplayState(rows, state.sleptIds, state.optimisticActiveWorktreeIdentity), - labels + { + repoHostIdByRepoId: state.repoHostIdByRepoId, + hostLabelById, + hostPlatform, + hostHealthById + } ) }, [ connState, state.worktrees, state.lastKnownWorktrees, - state.serverWorkspaces.worktrees, + serverWorkspaces.worktrees, state.visibleHostIds, state.sleptIds, state.optimisticActiveWorktreeIdentity, state.repoHostIdByRepoId, - labels + hostLabelById, + hostPlatform, + hostHealthById ]) } - -function withServerHosts( - desktop: ReadonlyMap, - hosts: HostScreenState['serverWorkspaces']['hosts'], - read: (host: HostScreenState['serverWorkspaces']['hosts'][number]) => Value -): ReadonlyMap { - if (hosts.length === 0) { - return desktop - } - const merged = new Map(desktop) - for (const host of hosts) { - merged.set(host.hostId, read(host)) - } - return merged -} diff --git a/mobile/src/host-screen/use-host-screen-controller.ts b/mobile/src/host-screen/use-host-screen-controller.ts index cbb91f89b3f..3842161f333 100644 --- a/mobile/src/host-screen/use-host-screen-controller.ts +++ b/mobile/src/host-screen/use-host-screen-controller.ts @@ -15,7 +15,7 @@ import { useRelayRecoveryStatus } from '../transport/client-context-connection-metrics' import { useWorkspaceSections } from '../worktree/use-workspace-sections' -import { useHostDisplayWorktrees, useHostLabelSources } from './use-host-display-worktrees' +import { useHostDisplayWorktrees } from './use-host-display-worktrees' import { useHostRepoMetadata } from './use-host-repo-metadata' import { useHostScreenIdentity } from './use-host-screen-identity' import { useHostScreenState } from './use-host-screen-state' @@ -103,8 +103,7 @@ export function useHostScreenController({ } const showNewWorktree = resolvedRouteActionState.showNewWorktree - const hostLabels = useHostLabelSources(hostId, state) - const displayWorktrees = useHostDisplayWorktrees(connState, state, hostLabels) + const displayWorktrees = useHostDisplayWorktrees(connState, state, hostId) const sectionsResult = useWorkspaceSections({ displayWorktrees, sortMode: state.sortMode, @@ -136,7 +135,6 @@ export function useHostScreenController({ hostCapabilities, hostDisplay, hostId, - hostLabels, insets, isReadOnly: connState === 'auth-failed', isWideLayout, diff --git a/mobile/src/worktree/server-workspaces.ts b/mobile/src/worktree/server-workspaces.ts index 12c0553631d..29f0e54cbfa 100644 --- a/mobile/src/worktree/server-workspaces.ts +++ b/mobile/src/worktree/server-workspaces.ts @@ -19,6 +19,22 @@ export function serverHostHealth(host: MobileRelayHost): ExecutionHostHealth { return host.relay === 'update-needed' ? 'blocked' : host.health } +/** `desktop`'s per-host values with each server's read from its feed entry. */ +export function withServerHosts( + desktop: ReadonlyMap, + hosts: readonly MobileRelayHost[], + read: (host: MobileRelayHost) => Value +): ReadonlyMap { + if (hosts.length === 0) { + return desktop + } + const merged = new Map(desktop) + for (const host of hosts) { + merged.set(host.hostId, read(host)) + } + return merged +} + function serverRowSchema(hostId: ExecutionHostId) { return z.custom( (row) =>