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()
+ }
+})