feat(mobile): offer a server's SSH repos on the New Workspace sheet

The desktop's composer offers a paired server's own SSH repos and connects them through that
server. The phone left them out only because ssh.* was the Mac's own; it now reaches the server
(#26519), so the drawer's existing SSH gate connects through the picked repo's server client.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-10-11 22:29:15 -04:00
parent c1b46804dc
commit c4c058ad15
2 changed files with 95 additions and 5 deletions
@@ -397,6 +397,99 @@ describe('NewWorktreeModal project targets', () => {
expect(onCreated).toHaveBeenCalledWith('srv-wt', expect.any(String), undefined, 'runtime:env-1')
})
it("connects and creates on a server's own SSH repo through that server", async () => {
const serverSshRepo = {
...repos[0]!,
id: 'server-ssh',
path: '/home/dev/orca',
connectionId: 'devbox'
}
const sshState = (status: string) =>
Promise.resolve({
ok: true,
result: { state: { targetId: 'devbox', status, error: null, reconnectAttempt: 0 } }
})
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 === 'ssh.getState') {
return sshState('disconnected')
}
if (method === 'ssh.connect') {
return sshState('connected')
}
if (method === 'worktree.create') {
return Promise.resolve({
ok: true,
result: { worktree: { id: 'ssh-wt', displayName: 'ssh-wt', path: '/home/dev/ssh-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, [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()
expect(pickerItems(renderer, 'Run on').map((item) => [item.label, item.detail])).toEqual([
[LOCAL_HOST_LABEL, '/src/orca'],
['scratch-server', '/home/dev/orca']
])
const picker = runTargetPicker(renderer)
await act(async () => picker.props.onSelect(picker.props.items[1]))
await flushUpdates()
expect(server).toHaveBeenCalledWith('ssh.getState', { targetId: 'devbox' })
const connectable = renderer.root.find((node) => typeof node.props.onConnect === 'function')
await act(async () => connectable.props.onConnect())
await flushUpdates()
expect(server).toHaveBeenCalledWith(
'ssh.connect',
{ targetId: 'devbox' },
expect.objectContaining({ timeoutMs: 120_000 })
)
expect(server).toHaveBeenCalledWith('preflight.detectRemoteAgents', { connectionId: 'devbox' })
act(() => sourceInputs(renderer)[0]!.props.onChangeText('ssh-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-ssh' }),
expect.anything()
)
expect(onCreated).toHaveBeenCalledWith('ssh-wt', expect.any(String), undefined, 'runtime:env-1')
// The server holds this SSH target, so the desktop is never asked about it.
const desktopMethods = desktop.mock.calls.map(([method]) => method)
expect(desktopMethods.filter((method) => method.startsWith('ssh.'))).toEqual([])
expect(desktopMethods).not.toContain('preflight.detectRemoteAgents')
expect(desktopMethods).not.toContain('worktree.create')
})
it("lists the desktop's repos at once while a server has not answered", async () => {
const desktop = vi.fn((method: string) =>
method === 'repo.list'
@@ -23,17 +23,14 @@ async function listHostRepos(client: RpcClient): Promise<MobileWorkspaceRepo[] |
/**
* 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.
* keep their connection id, which the drawer connects through that server's client.
*/
async function listServerRepos(
executionHostId: ExecutionHostId,
client: RpcClient
): Promise<MobileWorkspaceRepo[] | null> {
const listed = await listHostRepos(client).catch(() => null)
return (
listed?.filter((repo) => !repo.connectionId).map((repo) => ({ ...repo, executionHostId })) ??
null
)
return listed?.map((repo) => ({ ...repo, executionHostId })) ?? null
}
/**