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 23aabfbff21..f609d2a7fb5 100644 --- a/mobile/src/components/NewWorktreeModal.test.tsx +++ b/mobile/src/components/NewWorktreeModal.test.tsx @@ -1,6 +1,7 @@ import { createElement, Suspense, type ReactElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' import type { RpcClient } from '../transport/rpc-client' const asyncStorage = vi.hoisted(() => ({ @@ -11,6 +12,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() }, @@ -44,8 +46,15 @@ vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' })) 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 RelayedOptionsSchema = z.object({ envelope: z.object({ executionHost: z.string() }) }) const LOCAL_HOST_LABEL = getLocalExecutionHostLabel('darwin') @@ -75,6 +84,15 @@ function sourceInputs(renderer: ReactTestRenderer) { ) } +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the modal only sends requests. +const requestsOnly = (sendRequest: unknown) => ({ sendRequest }) as unknown as RpcClient + +function runTargetPicker(renderer: ReactTestRenderer) { + return renderer.root + .findAll((node) => node.type === 'PickerListDrawer') + .find((node) => node.props.title === 'Run on')! +} + async function flushUpdates(): Promise { await act(async () => { await Promise.resolve() @@ -174,7 +192,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' }) ]) @@ -306,6 +324,193 @@ describe('NewWorktreeModal project targets', () => { expect(sendRequest).not.toHaveBeenCalledWith('ssh.getState', expect.anything()) }) + it("creates a server repo's workspace on that server and names it for the route", async () => { + const serverRepo = { ...repos[0]!, id: 'server-repo', path: '/srv/orca' } + const serverSshRepo = { ...serverRepo, id: 'server-ssh', connectionId: 'its-own-ssh' } + const reply = (method: string, listed: unknown[], hostPlatform: string) => { + if (method === 'repo.list') { + return Promise.resolve({ ok: true, result: { repos: listed } }) + } + if (method === 'status.get') { + return Promise.resolve({ ok: true, result: { hostPlatform } }) + } + if (method === 'worktree.create') { + return Promise.resolve({ + ok: true, + result: { worktree: { id: 'srv-wt', displayName: 'srv-wt', path: '/srv/orca-wt' } } + }) + } + if (method === 'settings.get') { + return Promise.resolve({ ok: true, result: { settings: {} } }) + } + return Promise.resolve({ ok: true, result: {} }) + } + const desktop = vi.fn((method: string) => reply(method, repos, 'darwin')) + const server = vi.fn((method: string) => reply(method, [serverRepo, serverSshRepo], 'linux')) + const onCreated = vi.fn() + recordHostDescriptor('host-1', { machineName: null, platform: 'darwin' }) + + await act(async () => { + renderer = create( + createElement(NewWorktreeModal, { + visible: true, + client: requestsOnly(desktop), + serverClients: new Map([['runtime:env-1', requestsOnly(server)]]), + hostId: 'host-1', + hostLabelById: new Map([['runtime:env-1', 'scratch-server']]), + onCreated, + onClose: () => {} + }) + ) + }) + await flushUpdates() + + const runTargets = pickerItems(renderer, 'Run on') + 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') + await act(async () => form.props.onCreate()) + await flushUpdates() + + expect(server).toHaveBeenCalledWith( + 'worktree.create', + expect.objectContaining({ repo: 'id:server-repo' }), + expect.anything() + ) + expect(desktop).not.toHaveBeenCalledWith( + 'worktree.create', + expect.anything(), + expect.anything() + ) + expect(onCreated).toHaveBeenCalledWith('srv-wt', expect.any(String), undefined, 'runtime:env-1') + }) + + 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 relayed = RelayedOptionsSchema.safeParse(rest[1]).data?.envelope.executionHost + 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' + ? Promise.resolve({ ok: true, result: { repos } }) + : new Promise(() => {}) + ) + const hungServer = vi.fn(() => new Promise(() => {})) + + await act(async () => { + renderer = create( + createElement(NewWorktreeModal, { + visible: true, + client: requestsOnly(desktop), + serverClients: new Map([['runtime:env-1', requestsOnly(hungServer)]]), + hostId: 'host-cold', + onCreated: () => {}, + onClose: () => {} + }) + ) + }) + await flushUpdates() + + expect(hungServer).toHaveBeenCalledWith('repo.list') + expect(pickerItems(renderer, 'Run on').map((item) => item.detail)).toEqual(['/src/orca']) + }) + + it('keeps a server repo picked when the desktop lists a repo with the same id', async () => { + const serverRepo = { ...repos[0]!, path: '/srv/orca' } + const listing = (listed: unknown[]) => + vi.fn((method: string) => + method === 'repo.list' + ? Promise.resolve({ ok: true, result: { repos: listed } }) + : new Promise(() => {}) + ) + + await act(async () => { + renderer = create( + createElement(NewWorktreeModal, { + visible: true, + client: requestsOnly(listing(repos)), + serverClients: new Map([['runtime:env-1', requestsOnly(listing([serverRepo]))]]), + hostId: 'host-1', + onCreated: () => {}, + onClose: () => {} + }) + ) + }) + await flushUpdates() + const picker = runTargetPicker(renderer) + await act(async () => picker.props.onSelect(picker.props.items[1])) + await flushUpdates() + + expect(runTargetPicker(renderer).props.selectedId).toBe('runtime:env-1') + expect( + renderer.root.find((node) => node.props.runTarget !== undefined).props.runTarget.detail + ).toBe('/srv/orca') + }) + it('ignores a stale repo list after the client changes', async () => { let resolveOldList: ((value: unknown) => void) | undefined const oldList = new Promise((resolve) => { diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 0e343f589e0..222083fd70b 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -1,12 +1,15 @@ import { useMemo, useState } from 'react' import { Keyboard } from 'react-native' import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../src/shared/execution-host' import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection' import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source' import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes' import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent' import { useMobileComposerSource } from '../tasks/use-mobile-composer-source' import { useNewWorktreeRuntimeCapabilities } from '../tasks/worktree-create-capability' +import { useHostDescriptor } from '../transport/host-descriptor-store' +import { isSameMobileWorkspaceRepo } from '../worktree/new-workspace-dialog-repo-selection' import { buildRetiredWorktreeNamesRefreshKey, useRetiredWorktreeNames @@ -32,6 +35,12 @@ import { useNewWorkspaceRuntimeContext } from './use-new-workspace-runtime-conte import { useNewWorkspaceSetupScript } from './use-new-workspace-setup-script' import { useNewWorktreeDrawerNavigation } from './use-new-worktree-drawer-navigation' +/** The server a repo runs on; undefined for the desktop's own (local, SSH, folder). */ +function serverOf(repo: MobileWorkspaceRepo): `runtime:${string}` | undefined { + const host = parseExecutionHostId(getRepoExecutionHostId(repo)) + return host?.kind === 'runtime' ? host.id : undefined +} + export function NewWorktreeModal(props: NewWorktreeModalProps) { // Why: each drawer opening is a fresh form session; remounting resets local // form state before paint instead of clearing it in a visible-prop Effect. @@ -57,8 +66,10 @@ export function NewWorktreeModal(props: NewWorktreeModalProps) { function NewWorktreeModalContent(props: NewWorktreeModalProps) { const { visible, - client, + client: desktopClient, + serverClients, hostId, + hostLabelById, existingWorktreePaths, existingWorktrees, openExternalUrl, @@ -66,16 +77,24 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) { onClose } = props const { repos, selectedRepo, setSelectedRepo, loading } = useNewWorkspaceRepositories({ - client, + client: desktopClient, + serverClients, hostId, visible }) + // Why: everything about the picked repo runs where it does; the routing table keeps the + // desktop's own concerns (settings, accounts) on the desktop. + const repoServer = selectedRepo ? serverOf(selectedRepo) : undefined + const client = repoServer ? (serverClients?.get(repoServer) ?? null) : desktopClient const navigation = useNewWorktreeDrawerNavigation(visible) const [note, setNote] = useState('') const [error, setError] = useState('') const runtime = useNewWorkspaceRuntimeContext(client, visible, hostId) - const { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport, getAgentLaunchSupport } = - useNewWorktreeRuntimeCapabilities(client, visible) + const repoRuntime = useNewWorktreeRuntimeCapabilities(client, visible) + const { tasksSupported, getWorktreeCreateCutoverSupport, getAgentLaunchSupport } = repoRuntime + // The desktop's own run target is named for the desktop, whichever host the picked repo is on. + const desktopPlatform = useHostDescriptor(hostId)?.platform ?? null + const hostPlatform = repoServer ? desktopPlatform : repoRuntime.hostPlatform const selectedRepoConnectionId = selectedRepo?.connectionId ?? null const executionTarget = useNewWorkspaceExecutionTarget({ client, @@ -132,7 +151,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) { getAgentLaunchSupport, transitionDrawer: navigation.transitionDrawer, setError, - onCreated, + onCreated: (worktreeId, name, warning) => onCreated(worktreeId, name, warning, repoServer), onClose }) @@ -145,25 +164,30 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) { gitlabAvailable: runtime.availableProviders.includes('gitlab'), linearAvailable: runtime.availableProviders.includes('linear') } + // Why one client's: a source's repo switch is looked up through the picked repo's client. + const hostRepos = useMemo( + () => repos.filter((repo) => serverOf(repo) === repoServer), + [repoServer, repos] + ) const pasteRepos = useMemo( () => - repos.map((repo) => ({ + hostRepos.map((repo) => ({ id: repo.id, displayName: repo.displayName, slug: deriveRepoSlug(repo) })), - [repos] + [hostRepos] ) const projectPickerItems = useMemo(() => buildNewWorkspaceProjectOptions(repos), [repos]) const selectedProjectId = selectedRepo ? getProjectIdentityKey(selectedRepo) : null 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 = @@ -178,7 +202,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) { } function selectRepo(repo: MobileWorkspaceRepo, clearRepoScopedSource: boolean): void { - const repoChanged = repo.id !== selectedRepo?.id + const repoChanged = !selectedRepo || !isSameMobileWorkspaceRepo(repo, selectedRepo) setSelectedRepo(repo) if ( clearRepoScopedSource && @@ -247,7 +271,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) { composer={composer} sourceAvailability={sourceAvailability} selectedRepo={selectedRepo} - repos={repos} + repos={hostRepos} pasteRepos={pasteRepos} sshReady={!executionTarget.sshGate.requiresConnection} projectPickerItems={projectPickerItems} diff --git a/mobile/src/components/NewWorktreeModalController.tsx b/mobile/src/components/NewWorktreeModalController.tsx index 91f67e38e8d..3210c67e209 100644 --- a/mobile/src/components/NewWorktreeModalController.tsx +++ b/mobile/src/components/NewWorktreeModalController.tsx @@ -1,22 +1,27 @@ -import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from 'react' +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react' -import type { RpcClient } from '../transport/rpc-client' +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' export type NewWorktreeModalControllerHandle = { open: () => void } -type Props = { +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 - client: RpcClient | null - hostId?: string - existingWorktreePaths?: readonly string[] - existingWorktrees?: readonly { repoId: string; branch: string }[] - openExternalUrl: (url: string) => void onVisibleChange?: (visible: boolean) => void onRouteVisibleChange: (visible: boolean) => void - onCreated: (worktreeId: string, name: string, warning?: string) => void } export const NewWorktreeModalController = forwardRef( @@ -24,7 +29,9 @@ export const NewWorktreeModalController = forwardRef reachableServerClients(client, hostCapabilities, servers ?? []), + [client, hostCapabilities, servers] + ) + const serverLabelById = useMemo( + () => withServerHosts(hostLabelById, servers ?? [], (host) => host.label), + [hostLabelById, servers] + ) useImperativeHandle( ref, @@ -60,7 +77,9 @@ 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 eed373b010e..68e1f520483 100644 --- a/mobile/src/components/WorktreeListRow.tsx +++ b/mobile/src/components/WorktreeListRow.tsx @@ -1,21 +1,13 @@ 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 } from '../../../src/shared/execution-host' 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 type { Worktree } from '../worktree/workspace-list-types' import { areStructurallyEqual } from '../worktree/structural-equality' import { AgentSpinner } from './AgentSpinner' +import { HostKindIcon } from './HostKindIcon' import { MobileRepoIcon } from './MobileRepoIcon' import { WorktreeAgentList } from './WorktreeAgentList' import { WorktreeMetaGlyphs, prStateColor } from './WorktreeMetaGlyphs' @@ -140,13 +132,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 d77f89db412..bfccb682ef9 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' + } as const + 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', () => { @@ -87,8 +91,12 @@ describe('new workspace project targets', () => { const projectId = buildNewWorkspaceProjectOptions(repos)[0]?.id ?? null expect(buildNewWorkspaceRunTargetOptions(repos, projectId, 'darwin')).toEqual([ - expect.objectContaining({ id: 'local-a', label: LOCAL_HOST_LABEL, detail: '/src/orca-a' }), - expect.objectContaining({ id: 'ssh', label: 'SSH · build-server', detail: '/home/dev/orca' }) + expect.objectContaining({ id: 'local', label: LOCAL_HOST_LABEL, detail: '/src/orca-a' }), + expect.objectContaining({ + id: '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 5e45c35a3e9..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 [] @@ -95,9 +92,10 @@ export function buildNewWorkspaceRunTargetOptions( } const hostId = getRepoExecutionHostId(repo) if (!options.has(hostId)) { + // Keyed by host: a repo id names one repo per host, so two hosts can share one. options.set(hostId, { - id: repo.id, - ...getNewWorkspaceRunTarget(repo, localPlatform), + id: hostId, + ...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 368d59032cb..b943d06b608 100644 --- a/mobile/src/components/new-worktree-modal-types.ts +++ b/mobile/src/components/new-worktree-modal-types.ts @@ -1,3 +1,4 @@ +import type { ExecutionHostId } from '../../../src/shared/execution-host' import type { Repo as SharedRepo } from '../../../src/shared/repo-types' import type { RpcClient } from '../transport/rpc-client' import type { SetupHookTrust } from '../tasks/setup-hook-trust' @@ -20,11 +21,21 @@ export type MobileWorkspaceRepo = Pick 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 - onCreated: (worktreeId: string, name: string, warning?: string) => void + /** `executionHost` names the server the workspace was created on; absent for the desktop's own. */ + onCreated: ( + worktreeId: string, + name: string, + warning?: string, + executionHost?: ExecutionHostId + ) => void onClose: () => void } diff --git a/mobile/src/components/use-new-workspace-repositories.ts b/mobile/src/components/use-new-workspace-repositories.ts index 52ba6716143..d4a26efbdd0 100644 --- a/mobile/src/components/use-new-workspace-repositories.ts +++ b/mobile/src/components/use-new-workspace-repositories.ts @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +import type { ExecutionHostId } from '../../../src/shared/execution-host' import type { RpcClient } from '../transport/rpc-client' import { nativeChatRepoListRead } from '../session/mobile-session-read-operations' import { getCachedRepos, setCachedRepos } from '../cache/repo-cache' @@ -10,8 +11,39 @@ import { } from '../worktree/new-workspace-dialog-repo-selection' import type { MobileWorkspaceRepo } from './new-worktree-modal-types' +const NO_SERVER_CLIENTS: ReadonlyMap = new Map() +const NO_SERVER_REPOS: ReadonlyMap = new Map() + +/** One host's repos, or null when it refused or failed to answer. */ +async function listHostRepos(client: RpcClient): Promise { + const listed = nativeChatRepoListRead.interpret(await nativeChatRepoListRead.request(client)) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + return listed.accepted ? (listed.value as MobileWorkspaceRepo[]) : null +} + +/** + * A server's repos tagged with that server, as the desktop's composer offers them. Its SSH repos + * are left out: their connection is the server's, which the phone's SSH gate cannot read. + */ +async function listServerRepos( + executionHostId: ExecutionHostId, + client: RpcClient +): Promise { + const listed = await listHostRepos(client).catch(() => null) + return ( + listed?.filter((repo) => !repo.connectionId).map((repo) => ({ ...repo, executionHostId })) ?? + null + ) +} + +/** + * The desktop's repos, then each reachable server's as it answers: a slow server never holds back + * the desktop's own list. + */ export function useNewWorkspaceRepositories(args: { client: RpcClient | null + /** The desktop's servers this phone can reach now, whose repos are offered too. */ + serverClients?: ReadonlyMap hostId?: string visible: boolean }): { @@ -20,14 +52,27 @@ export function useNewWorkspaceRepositories(args: { setSelectedRepo: (repo: MobileWorkspaceRepo | null) => void loading: boolean } { - const { client, hostId, visible } = args + const { client, serverClients = NO_SERVER_CLIENTS, hostId, visible } = args const [initialRepos] = useState(() => hostId ? (getCachedRepos(hostId) as MobileWorkspaceRepo[] | null) : null ) - const [repos, setRepos] = useState(initialRepos ?? []) - const [selectedRepo, setSelectedRepo] = useState(null) + const [desktopRepos, setDesktopRepos] = useState(initialRepos ?? []) + const [serverRepos, setServerRepos] = useState(NO_SERVER_REPOS) + const [pickedRepo, setSelectedRepo] = useState(null) const [loading, setLoading] = useState(initialRepos == null) const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible) + const repos = useMemo( + () => + serverRepos.size === 0 + ? desktopRepos + : [...desktopRepos, ...[...serverRepos.values()].flat()], + [desktopRepos, serverRepos] + ) + // Why derived: the pick follows its repo's latest listing, and goes once its host stops listing it. + const selectedRepo = useMemo( + () => refreshMobileNewWorkspaceDialogSelectedRepo(repos, pickedRepo), + [pickedRepo, repos] + ) useEffect(() => { if (!visible || !lastVisitedRepo.loaded || selectedRepo || repos.length === 0) { @@ -50,25 +95,15 @@ export function useNewWorkspaceRepositories(args: { } let stale = false setLoading(true) - void nativeChatRepoListRead - .request(client) - .then((response) => { - if (stale) { + void listHostRepos(client) + .then((listed) => { + if (stale || !listed) { return } - const listed = nativeChatRepoListRead.interpret(response) - if (!listed.accepted) { - return - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const listedRepos = listed.value as MobileWorkspaceRepo[] - setRepos(listedRepos) + setDesktopRepos(listed) if (hostId) { - setCachedRepos(hostId, listedRepos) + setCachedRepos(hostId, listed) } - setSelectedRepo((current) => - refreshMobileNewWorkspaceDialogSelectedRepo(listedRepos, current) - ) }) .catch(() => undefined) .finally(() => { @@ -81,5 +116,27 @@ export function useNewWorkspaceRepositories(args: { } }, [visible, client, hostId]) + useEffect(() => { + // A server the phone can no longer reach takes its repos with it. + setServerRepos((previous) => { + const kept = [...previous].filter(([host]) => serverClients.has(host)) + return kept.length === previous.size ? previous : new Map(kept) + }) + if (!visible) { + return + } + let stale = false + for (const [executionHostId, serverClient] of serverClients) { + void listServerRepos(executionHostId, serverClient).then((listed) => { + if (!stale && listed) { + setServerRepos((previous) => new Map(previous).set(executionHostId, listed)) + } + }) + } + return () => { + stale = true + } + }, [visible, serverClients]) + return { repos, selectedRepo, setSelectedRepo, loading: loading && repos.length === 0 } } diff --git a/mobile/src/host-screen/host-screen-operations.ts b/mobile/src/host-screen/host-screen-operations.ts index fa374ce6bc4..7a66e623405 100644 --- a/mobile/src/host-screen/host-screen-operations.ts +++ b/mobile/src/host-screen/host-screen-operations.ts @@ -92,6 +92,17 @@ export const worktreePinWrite = bindDeferredRpcOperation( }) ) +/** Sleeping a row. Fire and forget: the row shows slept at once, and nothing reads the reply. */ +export const worktreeSleep = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'worktree.sleep-or-skip', + method: 'worktree.sleep', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcResultVariant('worktree-slept', hostScreenUnreadReplySchema) + }) +) + /** Deleting a row. Only acceptance is read: a refusal is what puts the row back. */ export const worktreeRemove = bindDeferredRpcOperation( defineRpcOperation({ diff --git a/mobile/src/host-screen/host-screen-overlays.test.tsx b/mobile/src/host-screen/host-screen-overlays.test.tsx new file mode 100644 index 00000000000..7a881bf43b5 --- /dev/null +++ b/mobile/src/host-screen/host-screen-overlays.test.tsx @@ -0,0 +1,83 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('react-native', () => ({ + 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 360eccbea18..f02078e0e3a 100644 --- a/mobile/src/host-screen/host-screen-overlays.tsx +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -2,6 +2,7 @@ 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 { useWorkspaceServer } from '../transport/workspace-server' import { ActionSheetContent } from '../components/ActionSheetModal' import { BottomDrawer } from '../components/BottomDrawer' @@ -10,7 +11,6 @@ import { NewWorktreeModalController } from '../components/NewWorktreeModalContro import { PickerModal } from '../components/PickerModal' import { colors } from '../theme/mobile-theme' import { hostNewWorktreeSessionRoute } from '../host-route-action-state' -import { getWorktreeRowIdentity } from '../worktree/worktree-host-row-identity' import { WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS, WORKSPACE_SORT_OPTIONS as SORT_OPTIONS, @@ -179,23 +179,19 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr navigate: actions.navigateFromHostList, onDone: () => state.setActionTarget(null) }), - { - label: 'Sleep', - icon: Moon, - onPress: () => { - if (client) { - state.setSleptIds((prev) => - new Set(prev).add(getWorktreeRowIdentity(actionTarget)) - ) - void client - .sendRequest('worktree.sleep', { - worktree: `id:${actionTarget.worktreeId}` - }) - .catch(() => null) - } - 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: () => { @@ -230,7 +226,9 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr ref={state.newWorktreeModalRef} routeVisible={showNewWorktree} client={client} + hostCapabilities={hostCapabilities} hostId={hostId} + hostLabelById={state.hostLabelById} existingWorktreePaths={existingWorktreePaths} existingWorktrees={state.worktrees} // The seam, not react-native's `Linking`: this screen is in the tasks page closure, and @@ -239,10 +237,13 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr onVisibleChange={(visible) => { state.newWorktreeModalVisibleRef.current = visible }} - onCreated={(worktreeId, worktreeName, warning) => { + onCreated={(worktreeId, worktreeName, warning, executionHost) => { void catalog.fetchWorktrees({ allowDuringModal: true }) actions.navigateFromHostList( - hostNewWorktreeSessionRoute(hostId, worktreeId, worktreeName, warning) + workspaceRouteHref( + hostNewWorktreeSessionRoute(hostId, worktreeId, worktreeName, warning), + executionHost + ) ) }} onRouteVisibleChange={actions.setShowNewWorktreeVisible} 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 f62e486a08b..d1a3323d62f 100644 --- a/mobile/src/host-screen/host-server-workspaces-wiring.test.ts +++ b/mobile/src/host-screen/host-server-workspaces-wiring.test.ts @@ -19,6 +19,10 @@ vi.mock('../transport/use-worktree-resync', () => ({ import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract' import { createBridgePortPair } from '../mobile-web-shell/bridge/bridge-port-pair-test-harness' +import { + recordHostDescriptor, + resetHostDescriptorStoreForTests +} from '../transport/host-descriptor-store' import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse } from '../transport/types' @@ -99,6 +103,8 @@ type Screen = { notice: () => string | null catalogError: () => string | null pin: (worktreeId: string) => void + sleep: (worktreeId: string) => void + slept: () => ReadonlySet | undefined serverWorkspaces: () => unknown swapClient: (next: RpcClient) => Promise navigations: string[] @@ -145,7 +151,7 @@ async function mountScreen(first: RpcClient, hostCapabilities: string[]): Promis }) held.state = state held.fetch = catalog.fetchWorktrees - held.rows = useHostDisplayWorktrees('connected', state) + held.rows = useHostDisplayWorktrees('connected', state, 'host-1') return null } await act(async () => { @@ -174,6 +180,13 @@ async function mountScreen(first: RpcClient, hostCapabilities: string[]): Promis act(() => { held.actions?.togglePin(held.rows.find((entry) => entry.worktreeId === worktreeId)!) }), + sleep: (worktreeId) => + act(() => { + held.actions?.putWorktreeToSleep( + held.rows.find((entry) => entry.worktreeId === worktreeId)! + ) + }), + slept: () => held.state?.sleptIds, serverWorkspaces: () => held.state?.serverWorkspaces, swapClient: async (next) => { client = next @@ -206,6 +219,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({ carriesRpcEnvelope: false }) @@ -339,6 +365,14 @@ describe("a desktop's server workspaces on the phone", () => { expect(screen.rows().find((entry) => entry.worktreeId === 'runtime:vm-wt')?.isPinned).toBe(true) }) + it('sleeps a desktop workspace on the desktop and shows it slept at once', async () => { + const client = desktop() + const screen = await mountScreen(client, RELAYS) + screen.sleep('mac-wt') + expect(client.sendRequest).toHaveBeenCalledWith('worktree.sleep', { worktree: 'id:mac-wt' }) + expect(screen.slept()?.size).toBe(1) + }) + it('reads a replaced client\u2019s servers at once, and keeps an unchanged poll\u2019s list', async () => { const stuck = desktop() const base = stuck.sendRequest.getMockImplementation()! 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 bc905d515ff..1d9d822d1d5 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 @@ -69,7 +69,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, 'host-1') 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..a4081a1b7fa 100644 --- a/mobile/src/host-screen/use-host-display-worktrees.ts +++ b/mobile/src/host-screen/use-host-display-worktrees.ts @@ -1,14 +1,18 @@ 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 { serverHostHealth, withServerHosts } from '../worktree/server-workspaces' import { filterVisibleHostRows } from '../worktree/visible-host-rows' import { applyWorktreeHostContextLabels } 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) { +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( @@ -19,6 +23,9 @@ 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(() => { // Why: live `worktrees` is authoritative only while connected; under the amber // mount default, connecting/handshaking must keep the pre-reconnect list too. @@ -33,7 +40,7 @@ export function useHostDisplayWorktrees(connState: ConnectionState, state: HostS { repoHostIdByRepoId: state.repoHostIdByRepoId, hostLabelById, - hostPlatform: state.hostPlatform, + hostPlatform, hostHealthById } ) @@ -47,22 +54,7 @@ export function useHostDisplayWorktrees(connState: ConnectionState, state: HostS state.optimisticActiveWorktreeIdentity, state.repoHostIdByRepoId, hostLabelById, - state.hostPlatform, + 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 b3e8620b54f..3842161f333 100644 --- a/mobile/src/host-screen/use-host-screen-controller.ts +++ b/mobile/src/host-screen/use-host-screen-controller.ts @@ -103,7 +103,7 @@ export function useHostScreenController({ } const showNewWorktree = resolvedRouteActionState.showNewWorktree - const displayWorktrees = useHostDisplayWorktrees(connState, state) + const displayWorktrees = useHostDisplayWorktrees(connState, state, hostId) const sectionsResult = useWorkspaceSections({ displayWorktrees, sortMode: state.sortMode, diff --git a/mobile/src/host-screen/use-host-worktree-actions.ts b/mobile/src/host-screen/use-host-worktree-actions.ts index 483b1c68f37..07639fbd590 100644 --- a/mobile/src/host-screen/use-host-worktree-actions.ts +++ b/mobile/src/host-screen/use-host-worktree-actions.ts @@ -20,7 +20,12 @@ import { removeWorktreeRow } from '../worktree/worktree-host-row-identity' import { isWorktreePinned, type Worktree } from '../worktree/workspace-list-sections' -import { worktreeActivate, worktreePinWrite, worktreeRemove } from './host-screen-operations' +import { + worktreeActivate, + worktreePinWrite, + worktreeRemove, + worktreeSleep +} from './host-screen-operations' import type { HostScreenState } from './use-host-screen-state' export function useHostWorktreeActions(args: { @@ -60,6 +65,7 @@ export function useHostWorktreeActions(args: { setRouteActionState, setServerNotice, setServerWorkspaces, + setSleptIds, setWorktrees, worktrees } = state @@ -153,6 +159,17 @@ export function useHostWorktreeActions(args: { [client, clientForRow, worktrees, pinnedIds, updateLocalPins] ) + const putWorktreeToSleep = useCallback( + (item: Worktree) => { + if (!client) { + return + } + setSleptIds((prev) => new Set(prev).add(getWorktreeRowIdentity(item))) + void worktreeSleep.request(client, { worktree: `id:${item.worktreeId}` }).catch(() => null) + }, + [client, setSleptIds] + ) + const handleDeleteWorktree = useCallback( async (item: Worktree) => { const rowClient = clientForRow(item) @@ -279,6 +296,7 @@ export function useHostWorktreeActions(args: { openNewWorktreeModal, openWorktreeSession, setShowNewWorktreeVisible, + putWorktreeToSleep, togglePin } } diff --git a/mobile/src/transport/execution-host-envelope.test.ts b/mobile/src/transport/execution-host-envelope.test.ts index def9dad3ed1..961b8eb3941 100644 --- a/mobile/src/transport/execution-host-envelope.test.ts +++ b/mobile/src/transport/execution-host-envelope.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract' import { + reachableServerClients, relaysToServers, scopeRpcClientToExecutionHost, workspaceRouteExecutionHost @@ -157,4 +158,19 @@ describe('scopeRpcClientToExecutionHost', () => { false ) }) + + it('offers only the servers the desktop can relay to now, each as its scoped view', () => { + const client = fakeClient() + const relays = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY] + const hosts = [ + { hostId: SERVER, relay: 'ready' }, + { hostId: 'runtime:old', relay: 'update-needed' }, + { hostId: 'runtime:down', relay: 'unavailable' } + ] as const + const clients = reachableServerClients(client, relays, hosts) + expect([...clients.keys()]).toEqual([SERVER]) + expect(clients.get(SERVER)).toBe(scopeRpcClientToExecutionHost(client, SERVER)) + expect(reachableServerClients(client, [], hosts).size).toBe(0) + expect(reachableServerClients(fakeClient(false), relays, hosts).size).toBe(0) + }) }) diff --git a/mobile/src/transport/execution-host-scoped-rpc-client.ts b/mobile/src/transport/execution-host-scoped-rpc-client.ts index 06a976545d9..bd3f54628cb 100644 --- a/mobile/src/transport/execution-host-scoped-rpc-client.ts +++ b/mobile/src/transport/execution-host-scoped-rpc-client.ts @@ -91,3 +91,21 @@ export function rpcClientForExecutionHost( } return routeHost && scopeRpcClientToExecutionHost(client, routeHost) } + +/** A scoped client for each of the desktop's servers this phone can reach now, by host. */ +export function reachableServerClients( + client: RpcClient | null, + hostCapabilities: readonly string[], + hosts: readonly { hostId: `runtime:${string}`; relay: string }[] +): ReadonlyMap { + const clients = new Map() + if (!client || !relaysToServers(client, hostCapabilities)) { + return clients + } + for (const host of hosts) { + if (host.relay === 'ready') { + clients.set(host.hostId, scopeRpcClientToExecutionHost(client, host.hostId)) + } + } + return clients +} diff --git a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts index eb012612199..d6a9e0463e4 100644 --- a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts +++ b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts @@ -100,16 +100,10 @@ export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcReques // in tasks/mobile-workspace-source-operations.ts, and the repo.list readers the dialog now shares // in session/mobile-session-read-operations.ts. - // src/host-screen/ — host screen catalog and actions. The repo and label metadata reads, the - // desktop view-settings mirror and the list's pin, remove and activate mutations migrated in - // step 4; see host-screen-operations.ts. - // Holdout: the last `worktree.sleep` is an `onPress` this file builds for `ActionSheetContent`, - // which renders only inside an open `BottomDrawer`. Nothing gates those children — the drawer - // mounts on `visible || mounted` and `MountedBottomDrawer` renders them unconditionally inside - // its `Modal`. The block is that module's imports: reanimated and gesture-handler, neither of - // which has a substitute, so reaching this send means standing in for both engines rather than - // pinning a device input. - { file: 'src/host-screen/host-screen-overlays.tsx', references: 1 }, + // src/host-screen/ — host screen catalog and actions. Nothing is left here: the repo and label + // metadata reads, the desktop view-settings mirror and the list's pin, remove and activate + // mutations migrated in step 4, and the row sleep followed once it moved into the actions hook; + // see host-screen-operations.ts. // src/notifications/ — push registration and delivery. Nothing is left here. Registration and // unregistration migrated in step 4; see mobile-push-registration-operations.ts. Tray diff --git a/mobile/src/worktree/new-workspace-dialog-repo-selection.test.ts b/mobile/src/worktree/new-workspace-dialog-repo-selection.test.ts index c6a69e8f055..4921ceae2b0 100644 --- a/mobile/src/worktree/new-workspace-dialog-repo-selection.test.ts +++ b/mobile/src/worktree/new-workspace-dialog-repo-selection.test.ts @@ -94,4 +94,14 @@ describe('mobile new workspace dialog repo selection', () => { refreshMobileNewWorkspaceDialogSelectedRepo([makeRepo('fresh')], makeRepo('stale')) ).toBe(null) }) + + it('keeps a repo picked on a server when the desktop lists one with the same id', () => { + const onDesktop = makeRepo('repo', { path: '/mac/repo' }) + const onServer = makeRepo('repo', { path: '/srv/repo', executionHostId: 'runtime:env-1' }) + + expect(refreshMobileNewWorkspaceDialogSelectedRepo([onDesktop, onServer], onServer)).toBe( + onServer + ) + expect(refreshMobileNewWorkspaceDialogSelectedRepo([onDesktop], onServer)).toBe(null) + }) }) diff --git a/mobile/src/worktree/new-workspace-dialog-repo-selection.ts b/mobile/src/worktree/new-workspace-dialog-repo-selection.ts index 498705d3830..bbd11b93e2c 100644 --- a/mobile/src/worktree/new-workspace-dialog-repo-selection.ts +++ b/mobile/src/worktree/new-workspace-dialog-repo-selection.ts @@ -52,12 +52,20 @@ export function resolveMobileNewWorkspaceDialogRepoId({ return resolvedRepo?.id ?? '' } -export function refreshMobileNewWorkspaceDialogSelectedRepo( +/** Whether two listed repos are the same one: a repo id names one repo per host, not overall. */ +export function isSameMobileWorkspaceRepo( + a: MobileNewWorkspaceDialogRepo, + b: MobileNewWorkspaceDialogRepo +): boolean { + return a.id === b.id && getMobileRepoExecutionHostId(a) === getMobileRepoExecutionHostId(b) +} + +export function refreshMobileNewWorkspaceDialogSelectedRepo( repos: readonly T[], current: T | null ): T | null { if (!current) { return null } - return repos.find((repo) => repo.id === current.id) ?? null + return repos.find((repo) => isSameMobileWorkspaceRepo(repo, current)) ?? null } diff --git a/mobile/src/worktree/server-workspaces.test.ts b/mobile/src/worktree/server-workspaces.test.ts new file mode 100644 index 00000000000..514369bf056 --- /dev/null +++ b/mobile/src/worktree/server-workspaces.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' + +// The desktop's server-list feed loads react-native; its timer never runs without a subscriber. +vi.mock('react-native', () => ({})) +import type { MobileRelayHost } from '../../../src/shared/mobile-relay-hosts-contract' +import { applyServerWorkspaces, NO_SERVER_WORKSPACES } from './server-workspaces' +import type { Worktree } from './workspace-list-types' + +const host: MobileRelayHost = { + hostId: 'runtime:env-1', + label: 'Box', + health: 'available', + relay: 'ready' +} + +function row(worktreeId: string): Worktree { + return { + worktreeId, + repoId: 'repo-1', + hostId: host.hostId, + repo: 'orca', + branch: 'main', + displayName: worktreeId, + path: `/srv/${worktreeId}`, + liveTerminalCount: 0, + hasAttachedPty: false, + unread: false, + isPinned: false, + linkedPR: null + } +} + +describe('applyServerWorkspaces', () => { + it('keeps the same hosts list when only the rows change', () => { + const first = applyServerWorkspaces(NO_SERVER_WORKSPACES, { hosts: [host], rows: [[row('a')]] }) + const next = applyServerWorkspaces(first, { + hosts: [{ ...host }], + rows: [[row('a'), row('b')]] + }) + expect(next.worktrees.map((worktree) => worktree.worktreeId)).toEqual(['a', 'b']) + expect(next.hosts).toBe(first.hosts) + const relabeled = applyServerWorkspaces(next, { + hosts: [{ ...host, relay: 'unavailable' }], + rows: [null] + }) + expect(relabeled.hosts).not.toBe(first.hosts) + }) +}) diff --git a/mobile/src/worktree/server-workspaces.ts b/mobile/src/worktree/server-workspaces.ts index 7ad1ffda130..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) => @@ -83,8 +99,11 @@ export function applyServerWorkspaces( (host, index) => fetched.rows[index] ?? previous.worktrees.filter((row) => row.hostId === host.hostId) ) - const unchanged = - JSON.stringify(fetched.hosts) === JSON.stringify(previous.hosts) && - areWorktreeListsEqual(worktrees, previous.worktrees) - return unchanged ? previous : { hosts: fetched.hosts, worktrees } + // Why kept: screens key per-server clients on `hosts`, which rows changing must not rebuild. + const hosts = + JSON.stringify(fetched.hosts) === JSON.stringify(previous.hosts) + ? previous.hosts + : fetched.hosts + const unchanged = hosts === previous.hosts && areWorktreeListsEqual(worktrees, previous.worktrees) + return unchanged ? previous : { hosts, worktrees } } diff --git a/tests/e2e/paired-mobile-desktop-mirror.spec.ts b/tests/e2e/paired-mobile-desktop-mirror.spec.ts index 86a1e5e0e72..78bf9cc127f 100644 --- a/tests/e2e/paired-mobile-desktop-mirror.spec.ts +++ b/tests/e2e/paired-mobile-desktop-mirror.spec.ts @@ -6,6 +6,7 @@ import { execFileSync } from 'node:child_process' import { mkdirSync, writeFileSync } from 'node:fs' import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' import { launchPhoneMirrorTopology } from './helpers/phone-mirror-topology' import { openPairedSocket, type PairedMobileSocket } from './helpers/paired-mobile-client' @@ -19,6 +20,11 @@ const WorktreeListSchema = z.object({ worktrees: z.array(z.looseObject({ worktreeId: z.string(), path: z.string() })) }) const CreatedTerminalSchema = z.object({ tab: z.looseObject({ terminal: z.string().nullable() }) }) +const CreatedTabSchema = z.object({ tab: z.looseObject({ id: z.string(), terminal: z.string() }) }) +const ReposSchema = z.object({ + repos: z.array(z.looseObject({ id: z.string(), path: z.string() })) +}) +const ServerTabsSchema = z.looseObject({ tabs: z.array(z.looseObject({ id: z.string() })) }) const SubscribedSchema = z.looseObject({ type: z.literal('subscribed'), streamId: z.number() }) const HostsSchema = z.object({ hosts: z.array(z.looseObject({ hostId: z.string(), health: z.string(), relay: z.string() })) @@ -315,3 +321,118 @@ test('phone paired with a desktop lists, opens and types into a workspace on its await dispose() } }) + +/** The terminal handles the desktop window shows for a workspace (its remote pty ids end in one). */ +function desktopTerminalHandles(desktop: { page: Page }, worktreeId: string): Promise { + return desktop.page.evaluate( + (id) => + (window.__store?.getState().tabsByWorktree[id] ?? []).map( + (tab) => tab.ptyId?.split('@@').at(-1) ?? '' + ), + worktreeId + ) +} + +test('phone tab and workspace actions on a server show on the server and the desktop', async (// oxlint-disable-next-line no-empty-pattern -- this test owns its topology launch. +{}, testInfo) => { + test.setTimeout(180_000) + const serverFolder = testInfo.outputPath('server-folder') + mkdirSync(serverFolder, { recursive: true }) + const serverRepo = testInfo.outputPath('server-repo') + mkdirSync(serverRepo, { recursive: true }) + writeFileSync(path.join(serverRepo, 'README.md'), 'server repo\n') + const git = (...args: string[]) => execFileSync('git', args, { cwd: serverRepo, stdio: 'pipe' }) + git('init', '-b', 'main') + git('add', 'README.md') + git('-c', 'user.name=E2E', '-c', 'user.email=e2e@test.local', 'commit', '-m', 'init') + const { host, desktop, phone, dispose } = await launchPhoneMirrorTopology( + { phoneTo: 'desktop' }, + testInfo + ) + try { + await host.client.call('repo.add', { path: serverFolder, kind: 'folder' }) + await host.client.call('repo.add', { path: serverRepo, kind: 'git' }) + const socket = await phone.openSocket() + const { hosts } = await call( + socket, + { id: 'hosts', method: 'mobileRelay.hosts.list', params: {} }, + HostsSchema + ) + const serverHostId = hosts[0]?.hostId ?? '' + // The new-workspace sheet lists the server's repos there and creates on it. + const { repos } = await call( + socket, + { id: 'repos', method: 'repo.list', params: {}, executionHost: serverHostId }, + ReposSchema + ) + const repoId = repos.find((repo) => repo.path === serverRepo)?.id ?? '' + expect(repoId, 'the server lists its own repo for the phone').not.toBe('') + await call( + socket, + { + id: 'create-workspace', + method: 'worktree.create', + params: { repo: `id:${repoId}`, name: 'from-phone', setupDecision: 'skip' }, + executionHost: serverHostId + }, + z.unknown() + ) + await expect + .poll(async () => + (await hostWorktrees(socket, serverHostId))?.worktrees.some((row) => + row.path.endsWith('from-phone') + ) + ) + .toBe(true) + let worktreeId = '' + await expect + .poll(async () => { + const listed = await hostWorktrees(socket, serverHostId) + worktreeId = listed?.worktrees.find((row) => row.path === serverFolder)?.worktreeId ?? '' + return worktreeId + }) + .not.toBe('') + const worktree = `id:${worktreeId}` + const serverTabs = async () => + ServerTabsSchema.parse((await host.client.call('session.tabs.list', { worktree })).result) + .tabs + const create = (id: string) => + call( + socket, + { + id, + method: 'session.tabs.createTerminal', + params: { worktree, activate: false, select: false, navigation: 'caller' }, + executionHost: serverHostId + }, + CreatedTabSchema + ) + const { tab: kept } = await create('create-kept') + const { tab: closed } = await create('create-closed') + expect((await serverTabs()).map((tab) => tab.id)).toEqual( + expect.arrayContaining([kept.id, closed.id]) + ) + await expect + .poll(() => desktopTerminalHandles(desktop, worktreeId), { timeout: 30_000 }) + .toEqual(expect.arrayContaining([kept.terminal, closed.terminal])) + + await call( + socket, + { + id: 'close', + method: 'session.tabs.close', + params: { worktree, tabId: closed.id, reason: 'user' }, + executionHost: serverHostId + }, + z.unknown() + ) + await expect + .poll(async () => (await serverTabs()).map((tab) => tab.id), { timeout: 30_000 }) + .toEqual([kept.id]) + await expect + .poll(() => desktopTerminalHandles(desktop, worktreeId), { timeout: 30_000 }) + .toEqual([kept.terminal]) + } finally { + await dispose() + } +})