fix(mobile): name run targets and the desktop's own row as the host list does

The Run on picker showed a server's raw environment id behind "Remote ·" and
always a monitor glyph; it now takes the host list's badge labels and icon, as
the desktop's picker does. Server rows also made the desktop row read "This
computer" because its platform was only read for a mixed desktop catalog.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-10-11 05:02:52 -04:00
parent ac1c8a0455
commit ef6f2a2a1a
16 changed files with 142 additions and 81 deletions
+10
View File
@@ -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 <Icon size={size} color={colors.textMuted} />
}
@@ -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
@@ -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')
+4 -3
View File
@@ -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 =
@@ -20,6 +20,7 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
client,
serverClients,
hostId,
hostLabelById,
existingWorktreePaths,
existingWorktrees,
openExternalUrl,
@@ -57,6 +58,7 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
client={client}
serverClients={serverClients}
hostId={hostId}
hostLabelById={hostLabelById}
existingWorktreePaths={existingWorktreePaths}
existingWorktrees={existingWorktrees}
openExternalUrl={openExternalUrl}
@@ -1,10 +1,9 @@
import { View } from 'react-native'
import { getRepoExecutionHostId } from '../../../src/shared/execution-host'
import { Monitor } from 'lucide-react-native'
import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes'
import type { PasteRepoCandidate } from '../tasks/smart-source-paste-intent'
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
import { colors } from '../theme/mobile-theme'
import { HostKindIcon } from './HostKindIcon'
import { MobileAgentIcon } from './MobileAgentIcon'
import type { NewWorktreeAgentOption } from './new-worktree-agent-selection'
import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
@@ -90,7 +89,7 @@ export function NewWorktreeModalDrawers(props: {
selectedId={props.selectedRepo ? getRepoExecutionHostId(props.selectedRepo) : ''}
onSelect={(item) => props.onRepoChange(item.repo)}
onClose={props.onTransitionToForm}
renderIcon={() => <Monitor size={16} color={colors.textMuted} />}
renderIcon={(item) => <HostKindIcon hostId={item.hostId} size={16} />}
/>
<PickerListDrawer
@@ -1,5 +1,6 @@
import { View, Text, Pressable, StyleSheet, Platform } from 'react-native'
import { ChevronDown, Monitor } from 'lucide-react-native'
import { ChevronDown } from 'lucide-react-native'
import { HostKindIcon } from './HostKindIcon'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
type Selection = { label: string; detail?: string }
@@ -12,7 +13,7 @@ export function NewWorktreeProjectTargetFields({
onOpenRunTarget
}: {
project: Selection | null
runTarget: Selection | null
runTarget: (Selection & { hostId: string }) | null
projectBadgeColor: string | null
onOpenProject: () => void
onOpenRunTarget: () => void
@@ -33,7 +34,7 @@ export function NewWorktreeProjectTargetFields({
<View style={styles.field}>
<Text style={styles.label}>Run on</Text>
<Pressable style={styles.fieldButton} onPress={onOpenRunTarget}>
<Monitor size={14} color={colors.textMuted} />
<HostKindIcon hostId={runTarget?.hostId} size={14} />
<SelectionCopy selection={runTarget} placeholder="Select a run target" />
<ChevronDown size={14} color={colors.textMuted} />
</Pressable>
+4 -17
View File
@@ -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<T extends WorktreeListRowItem>({
.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' ? (
<Monitor size={10} color={colors.textMuted} />
) : (
<Server size={10} color={colors.textMuted} />
)}
<HostKindIcon hostId={item.hostContextHostId ?? item.hostId} size={10} />
<Text style={[styles.childBadgeText, styles.hostBadgeText]} numberOfLines={1}>
{item.hostContextLabel}
</Text>
@@ -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'
})
])
@@ -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<Repo, 'id' | 'displayName' | 'path'> &
Partial<
@@ -24,6 +20,7 @@ export type NewWorkspaceProjectOption<TRepo extends WorkspaceRepo> = {
export type NewWorkspaceRunTargetOption<TRepo extends WorkspaceRepo> = {
id: string
hostId: ExecutionHostId
label: string
detail: string
repo: TRepo
@@ -58,24 +55,23 @@ export function buildNewWorkspaceProjectOptions<TRepo extends WorkspaceRepo>(
})
}
/** 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<ExecutionHostId, string>
): {
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<TRepo extends WorkspaceRepo>(
repos: readonly TRepo[],
projectId: string | null,
localPlatform: NodeJS.Platform | null = null
localPlatform: NodeJS.Platform | null = null,
hostLabelById?: ReadonlyMap<ExecutionHostId, string>
): NewWorkspaceRunTargetOption<TRepo>[] {
if (!projectId) {
return []
@@ -98,7 +95,7 @@ export function buildNewWorkspaceRunTargetOptions<TRepo extends WorkspaceRepo>(
// 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
})
}
@@ -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<ExecutionHostId, RpcClient>
hostId?: string
/** The host list's badge labels, so a run target reads as its row does. */
hostLabelById?: ReadonlyMap<ExecutionHostId, string>
existingWorktreePaths?: readonly string[]
existingWorktrees?: readonly { repoId: string; branch: string }[]
openExternalUrl: (url: string) => void
@@ -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
@@ -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 })
@@ -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 () => {
@@ -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
])
}
@@ -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,