diff --git a/mobile/src/components/HostKindIcon.tsx b/mobile/src/components/HostKindIcon.tsx new file mode 100644 index 00000000000..11fb9bb0b55 --- /dev/null +++ b/mobile/src/components/HostKindIcon.tsx @@ -0,0 +1,10 @@ +import { Monitor, Server } from 'lucide-react-native' +import { parseExecutionHostId } from '../../../src/shared/execution-host' +import { colors } from '../theme/mobile-theme' + +/** The desktop's own machine is a monitor, any other host a server, as on the desktop's run targets. */ +export function HostKindIcon({ hostId, size }: { hostId: string | undefined; size: number }) { + // Rows from hosts that predate hostId stamping are local: a remote row always carries one. + const Icon = (parseExecutionHostId(hostId)?.kind ?? 'local') === 'local' ? Monitor : Server + return +} diff --git a/mobile/src/components/NewWorktreeFormSheet.tsx b/mobile/src/components/NewWorktreeFormSheet.tsx index 895b601b84b..3f4f8a725e2 100644 --- a/mobile/src/components/NewWorktreeFormSheet.tsx +++ b/mobile/src/components/NewWorktreeFormSheet.tsx @@ -24,7 +24,7 @@ export function NewWorktreeFormSheet(props: { loading: boolean hasRepos: boolean project: Selection | null - runTarget: Selection | null + runTarget: (Selection & { hostId: string }) | null projectBadgeColor: string | null selectedRepoIsGit: boolean selectedRepoConnectionId: string | null diff --git a/mobile/src/components/NewWorktreeModal.test.tsx b/mobile/src/components/NewWorktreeModal.test.tsx index 459fdf4a4f2..5d1bbf450ff 100644 --- a/mobile/src/components/NewWorktreeModal.test.tsx +++ b/mobile/src/components/NewWorktreeModal.test.tsx @@ -184,7 +184,7 @@ describe('NewWorktreeModal project targets', () => { expect(pickerItems(renderer, 'Run on')).toEqual([ expect.objectContaining({ label: LOCAL_HOST_LABEL, detail: '/src/orca' }), expect.objectContaining({ - label: 'SSH · build-server', + label: 'build-server', detail: '/home/dev/orca' }) ]) @@ -349,6 +349,7 @@ describe('NewWorktreeModal project targets', () => { client: requestsOnly(desktop), serverClients: new Map([['runtime:env-1', requestsOnly(server)]]), hostId: 'host-1', + hostLabelById: new Map([['runtime:env-1', 'scratch-server']]), onCreated, onClose: () => {} }) @@ -357,10 +358,26 @@ describe('NewWorktreeModal project targets', () => { await flushUpdates() const runTargets = pickerItems(renderer, 'Run on') - expect(runTargets.map((item) => item.detail)).toEqual(['/src/orca', '/srv/orca']) + expect(runTargets.map((item) => [item.label, item.detail])).toEqual([ + [LOCAL_HOST_LABEL, '/src/orca'], + ['scratch-server', '/srv/orca'] + ]) const serverTarget = runTargetPicker(renderer) + const iconOf = (item: unknown) => { + let icon: ReactTestRenderer | undefined + act(() => { + icon = create(serverTarget.props.renderIcon(item)) + }) + return icon?.toJSON() + } + expect(serverTarget.props.items.map(iconOf)).toMatchObject([ + { type: 'Monitor' }, + { type: 'Server' } + ]) await act(async () => serverTarget.props.onSelect(serverTarget.props.items[1])) await flushUpdates() + // The Run on field shows the picked server's glyph, as its row does. + expect(renderer.root.findAll((node) => node.type === 'Server')).toHaveLength(1) expect(pickerItems(renderer, 'Run on')[0]?.label).toBe(LOCAL_HOST_LABEL) act(() => sourceInputs(renderer)[0]!.props.onChangeText('srv-wt')) const form = renderer.root.find((node) => typeof node.props.onCreate === 'function') diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 1c3a310a674..222083fd70b 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -69,6 +69,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) { client: desktopClient, serverClients, hostId, + hostLabelById, existingWorktreePaths, existingWorktrees, openExternalUrl, @@ -182,11 +183,11 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) { const selectedProject = projectPickerItems.find((project) => project.id === selectedProjectId) ?? null const runTargetPickerItems = useMemo( - () => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId, hostPlatform), - [hostPlatform, repos, selectedProjectId] + () => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId, hostPlatform, hostLabelById), + [hostLabelById, hostPlatform, repos, selectedProjectId] ) const selectedRunTarget = selectedRepo - ? getNewWorkspaceRunTarget(selectedRepo, hostPlatform) + ? getNewWorkspaceRunTarget(selectedRepo, hostPlatform, hostLabelById) : null const needsSetupChoice = Boolean(setupScript.setupCommand) && setupScript.setupRunPolicy === 'ask' const canCreate = diff --git a/mobile/src/components/NewWorktreeModalController.tsx b/mobile/src/components/NewWorktreeModalController.tsx index f73556bdc48..c00dfc03e9e 100644 --- a/mobile/src/components/NewWorktreeModalController.tsx +++ b/mobile/src/components/NewWorktreeModalController.tsx @@ -20,6 +20,7 @@ export const NewWorktreeModalController = forwardRef props.onRepoChange(item.repo)} onClose={props.onTransitionToForm} - renderIcon={() => } + renderIcon={(item) => } /> void onOpenRunTarget: () => void @@ -33,7 +34,7 @@ export function NewWorktreeProjectTargetFields({ Run on - + diff --git a/mobile/src/components/WorktreeListRow.tsx b/mobile/src/components/WorktreeListRow.tsx index 6a01c8f8766..3faaf00635a 100644 --- a/mobile/src/components/WorktreeListRow.tsx +++ b/mobile/src/components/WorktreeListRow.tsx @@ -1,21 +1,14 @@ import { memo } from 'react' -import { - Bell, - ChevronDown, - ChevronRight, - GitBranch, - GitPullRequest, - Monitor, - Server -} from 'lucide-react-native' +import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native' import { Pressable, StyleSheet, Text, View } from 'react-native' -import { parseExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host' +import type { ExecutionHostId } from '../../../src/shared/execution-host' import type { AgentWorkingMode } from '../../../src/shared/agent-status-types' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' import type { MobileRenderableRepoIcon } from '../host-screen/host-screen-reply-schema' import { triggerMediumImpact } from '../platform/haptics' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import { AgentSpinner } from './AgentSpinner' +import { HostKindIcon } from './HostKindIcon' import { MobileRepoIcon } from './MobileRepoIcon' import { WorktreeAgentList } from './WorktreeAgentList' import { WorktreeMetaGlyphs, prStateColor } from './WorktreeMetaGlyphs' @@ -173,13 +166,7 @@ function WorktreeListRowComponent({ .filter(Boolean) .join(', ')} > - {/* Rows from hosts that predate hostId stamping are local: a remote row always carries one. */} - {(parseExecutionHostId(item.hostContextHostId ?? item.hostId)?.kind ?? 'local') === - 'local' ? ( - - ) : ( - - )} + {item.hostContextLabel} diff --git a/mobile/src/components/new-workspace-project-targets.test.ts b/mobile/src/components/new-workspace-project-targets.test.ts index 18c379f2d14..d1e83550e9c 100644 --- a/mobile/src/components/new-workspace-project-targets.test.ts +++ b/mobile/src/components/new-workspace-project-targets.test.ts @@ -43,16 +43,16 @@ describe('new workspace project targets', () => { expect(options[0]).toMatchObject({ label: 'orca', detail: 'stablyai/orca' }) }) - it('labels local, SSH, and paired runtime targets', () => { + it('labels local, SSH, and paired runtime targets as the host list names them', () => { expect( getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: '/src/orca' }, 'darwin') - ).toEqual({ label: LOCAL_HOST_LABEL, detail: '/src/orca' }) + ).toEqual({ hostId: 'local', label: LOCAL_HOST_LABEL, detail: '/src/orca' }) expect( getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: 'C:\\src\\orca' }) - ).toEqual({ label: 'This computer', detail: 'C:\\src\\orca' }) + ).toMatchObject({ label: 'This computer' }) expect( getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: 'C:\\src\\orca' }, 'win32') - ).toEqual({ label: 'Local Windows', detail: 'C:\\src\\orca' }) + ).toMatchObject({ label: 'Local Windows' }) expect( getNewWorkspaceRunTarget({ id: 'ssh', @@ -60,15 +60,19 @@ describe('new workspace project targets', () => { path: 'C:\\src\\orca', executionHostId: 'ssh:Windows%20VM' }) - ).toEqual({ label: 'SSH · Windows VM', detail: 'C:\\src\\orca' }) - expect( - getNewWorkspaceRunTarget({ - id: 'runtime', - displayName: 'orca', - path: '/src/orca', - executionHostId: 'runtime:devbox' - }) - ).toEqual({ label: 'Remote · devbox', detail: '/src/orca' }) + ).toEqual({ hostId: 'ssh:Windows%20VM', label: 'Windows VM', detail: 'C:\\src\\orca' }) + const server = { + id: 'runtime', + displayName: 'orca', + path: '/src/orca', + executionHostId: 'runtime:20269ff3-60aa-4e79-a581-513a3f8e394b' + } + const labels = new Map([[server.executionHostId, 'scratch-server']] as const) + expect(getNewWorkspaceRunTarget(server, 'darwin', labels)).toEqual({ + hostId: server.executionHostId, + label: 'scratch-server', + detail: '/src/orca' + }) }) it('shows one target per host when the project has multiple local worktrees', () => { @@ -90,7 +94,7 @@ describe('new workspace project targets', () => { expect.objectContaining({ id: 'local', label: LOCAL_HOST_LABEL, detail: '/src/orca-a' }), expect.objectContaining({ id: 'ssh:build-server', - label: 'SSH · build-server', + label: 'build-server', detail: '/home/dev/orca' }) ]) diff --git a/mobile/src/components/new-workspace-project-targets.ts b/mobile/src/components/new-workspace-project-targets.ts index 20c46aafd3b..c3f5d436a40 100644 --- a/mobile/src/components/new-workspace-project-targets.ts +++ b/mobile/src/components/new-workspace-project-targets.ts @@ -1,14 +1,10 @@ import type { Repo } from '../../../src/shared/repo-types' -import { - getExecutionHostLabel, - getLocalExecutionHostLabel, - getRepoExecutionHostId, - parseExecutionHostId -} from '../../../src/shared/execution-host' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host' import { getProjectIdentityKey, getProjectProviderIdentity } from '../../../src/shared/project-host-setup-projection' +import { getHostContextLabel } from '../../../src/shared/worktree/host-context-labels' type WorkspaceRepo = Pick & Partial< @@ -24,6 +20,7 @@ export type NewWorkspaceProjectOption = { export type NewWorkspaceRunTargetOption = { id: string + hostId: ExecutionHostId label: string detail: string repo: TRepo @@ -58,24 +55,23 @@ export function buildNewWorkspaceProjectOptions( }) } +/** Named like the host list's badge; the desktop's picker shows the bare host label too. */ export function getNewWorkspaceRunTarget( repo: WorkspaceRepo, - localPlatform: NodeJS.Platform | null = null + localPlatform: NodeJS.Platform | null = null, + hostLabelById?: ReadonlyMap ): { + hostId: ExecutionHostId label: string detail: string } { const hostId = getRepoExecutionHostId(repo) - const host = parseExecutionHostId(hostId) - const hostLabel = getExecutionHostLabel(hostId) - if (host?.kind === 'ssh') { - return { label: `SSH · ${hostLabel}`, detail: repo.path } - } - if (host?.kind === 'runtime') { - return { label: `Remote · ${hostLabel}`, detail: repo.path } - } return { - label: localPlatform ? getLocalExecutionHostLabel(localPlatform) : 'This computer', + hostId, + label: getHostContextLabel(hostId, { + hostLabelById, + hostPlatform: localPlatform + }), detail: repo.path } } @@ -83,7 +79,8 @@ export function getNewWorkspaceRunTarget( export function buildNewWorkspaceRunTargetOptions( repos: readonly TRepo[], projectId: string | null, - localPlatform: NodeJS.Platform | null = null + localPlatform: NodeJS.Platform | null = null, + hostLabelById?: ReadonlyMap ): NewWorkspaceRunTargetOption[] { if (!projectId) { return [] @@ -98,7 +95,7 @@ export function buildNewWorkspaceRunTargetOptions( // Keyed by host: a repo id names one repo per host, so two hosts can share one. options.set(hostId, { id: hostId, - ...getNewWorkspaceRunTarget(repo, localPlatform), + ...getNewWorkspaceRunTarget(repo, localPlatform, hostLabelById), repo }) } diff --git a/mobile/src/components/new-worktree-modal-types.ts b/mobile/src/components/new-worktree-modal-types.ts index 13cfd38b101..b943d06b608 100644 --- a/mobile/src/components/new-worktree-modal-types.ts +++ b/mobile/src/components/new-worktree-modal-types.ts @@ -24,6 +24,8 @@ export type NewWorktreeModalProps = { /** The desktop's servers this phone can reach now; their repos are offered and created there. */ serverClients?: ReadonlyMap hostId?: string + /** The host list's badge labels, so a run target reads as its row does. */ + hostLabelById?: ReadonlyMap existingWorktreePaths?: readonly string[] existingWorktrees?: readonly { repoId: string; branch: string }[] openExternalUrl: (url: string) => void diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx index 153c90cc3ce..2eda461fa43 100644 --- a/mobile/src/host-screen/host-screen-overlays.tsx +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -31,6 +31,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr existingWorktreePaths, hostCapabilities, hostId, + hostLabels, settings, showNewWorktree, state @@ -229,6 +230,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr client={client} serverClients={serverClients} hostId={hostId} + hostLabelById={hostLabels.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/host-screen/host-server-workspaces-wiring.test.ts b/mobile/src/host-screen/host-server-workspaces-wiring.test.ts index 39dfea137dc..9813d2a6a5e 100644 --- a/mobile/src/host-screen/host-server-workspaces-wiring.test.ts +++ b/mobile/src/host-screen/host-server-workspaces-wiring.test.ts @@ -18,11 +18,15 @@ vi.mock('../transport/use-worktree-resync', () => ({ })) import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract' +import { + recordHostDescriptor, + resetHostDescriptorStoreForTests +} from '../transport/host-descriptor-store' 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 } from './use-host-display-worktrees' +import { useHostDisplayWorktrees, useHostLabelSources } 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' @@ -145,7 +149,7 @@ async function mountScreen(first: FakeSession, hostCapabilities: string[]): Prom }) held.state = state held.fetch = catalog.fetchWorktrees - held.rows = useHostDisplayWorktrees('connected', state) + held.rows = useHostDisplayWorktrees('connected', state, useHostLabelSources('host-1', state)) return null } await act(async () => { @@ -213,6 +217,19 @@ describe("a desktop's server workspaces on the phone", () => { ]) }) + it("names the desktop's own row for the desktop's platform once servers mix the list", async () => { + recordHostDescriptor('host-1', { machineName: null, platform: 'darwin' }) + try { + const screen = await mountScreen(desktop(), RELAYS) + expect(screen.rows()[0]).toMatchObject({ + worktreeId: 'mac-wt', + hostContextLabel: 'Local Mac' + }) + } finally { + resetHostDescriptorStoreForTests() + } + }) + it('keeps showing today’s view under an older desktop or an older shell', async () => { const oldDesktop = desktop() const oldShell = desktop({ carriesExecutionHost: false }) 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 ffd7e73f63a..90ff4e31cd8 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 @@ -11,7 +11,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 } from './use-host-display-worktrees' +import { useHostDisplayWorktrees, useHostLabelSources } from './use-host-display-worktrees' import { useHostRepoMetadata } from './use-host-repo-metadata' import { useHostScreenState } from './use-host-screen-state' @@ -68,7 +68,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) + seen.rows = useHostDisplayWorktrees('connected', state, useHostLabelSources('host-1', state)) return null } await act(async () => { diff --git a/mobile/src/host-screen/use-host-display-worktrees.ts b/mobile/src/host-screen/use-host-display-worktrees.ts index 3631d1f4cc3..86f85c9c94d 100644 --- a/mobile/src/host-screen/use-host-display-worktrees.ts +++ b/mobile/src/host-screen/use-host-display-worktrees.ts @@ -1,14 +1,21 @@ import { useMemo } from 'react' import type { ExecutionHostId } from '../../../src/shared/execution-host' +import { useHostDescriptor } from '../transport/host-descriptor-store' import type { ConnectionState } from '../transport/types' import { serverHostHealth } from '../worktree/server-workspaces' import { filterVisibleHostRows } from '../worktree/visible-host-rows' -import { applyWorktreeHostContextLabels } from '../worktree/worktree-host-context-labels' +import { + applyWorktreeHostContextLabels, + type HostLabelSources +} from '../worktree/worktree-host-context-labels' import { applyWorktreeRowDisplayState } from '../worktree/worktree-host-row-identity' import type { HostScreenState } from './use-host-screen-state' -/** 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) { +/** 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 { const { serverWorkspaces } = state // Why: a server's label and health come with its rows, alongside the desktop's SSH ones. const hostLabelById = useMemo( @@ -19,36 +26,49 @@ export function useHostDisplayWorktrees(connState: ConnectionState, state: HostS () => withServerHosts(state.hostHealthById, serverWorkspaces.hosts, serverHostHealth), [state.hostHealthById, serverWorkspaces.hosts] ) + 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 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, ...serverWorkspaces.worktrees], + [...base, ...state.serverWorkspaces.worktrees], state.visibleHostIds, state.repoHostIdByRepoId ) return applyWorktreeHostContextLabels( applyWorktreeRowDisplayState(rows, state.sleptIds, state.optimisticActiveWorktreeIdentity), - { - repoHostIdByRepoId: state.repoHostIdByRepoId, - hostLabelById, - hostPlatform: state.hostPlatform, - hostHealthById - } + labels ) }, [ connState, state.worktrees, state.lastKnownWorktrees, - serverWorkspaces.worktrees, + state.serverWorkspaces.worktrees, state.visibleHostIds, state.sleptIds, state.optimisticActiveWorktreeIdentity, state.repoHostIdByRepoId, - hostLabelById, - state.hostPlatform, - hostHealthById + labels ]) } diff --git a/mobile/src/host-screen/use-host-screen-controller.ts b/mobile/src/host-screen/use-host-screen-controller.ts index b3e8620b54f..cbb91f89b3f 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 } from './use-host-display-worktrees' +import { useHostDisplayWorktrees, useHostLabelSources } 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,7 +103,8 @@ export function useHostScreenController({ } const showNewWorktree = resolvedRouteActionState.showNewWorktree - const displayWorktrees = useHostDisplayWorktrees(connState, state) + const hostLabels = useHostLabelSources(hostId, state) + const displayWorktrees = useHostDisplayWorktrees(connState, state, hostLabels) const sectionsResult = useWorkspaceSections({ displayWorktrees, sortMode: state.sortMode, @@ -135,6 +136,7 @@ export function useHostScreenController({ hostCapabilities, hostDisplay, hostId, + hostLabels, insets, isReadOnly: connState === 'auth-failed', isWideLayout,