diff --git a/mobile/rpc-foundation/goldens/components-target-local.json b/mobile/rpc-foundation/goldens/components-target-local.json index 52042bc33d8..3717d27718f 100644 --- a/mobile/rpc-foundation/goldens/components-target-local.json +++ b/mobile/rpc-foundation/goldens/components-target-local.json @@ -4,25 +4,12 @@ "family": "components.execution-target-local", "namedDeltas": [], "values": { - "1e4520fe6576": { + "305921cd9555": { "detected": { "$rpc": "null" }, "gate": { - "connectInProgress": true, - "error": { - "$rpc": "null" - }, - "requiresConnection": false, - "status": { - "$rpc": "null" - } - } - }, - "986a776213e8": { - "detected": ["claude"], - "gate": { - "connectInProgress": true, + "connectInProgress": false, "error": { "$rpc": "null" }, @@ -63,6 +50,19 @@ "startedAt": 0 } }, + "b7f951970a65": { + "detected": ["claude"], + "gate": { + "connectInProgress": false, + "error": { + "$rpc": "null" + }, + "requiresConnection": false, + "status": { + "$rpc": "null" + } + } + }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -115,7 +115,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "1e4520fe6576", + "state": "305921cd9555", "effects": [] } }, @@ -127,7 +127,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "986a776213e8", + "state": "b7f951970a65", "effects": [] } } diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json index e4254f30cc9..cdb240e6a35 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json @@ -4,21 +4,6 @@ "family": "components.execution-target-local", "namedDeltas": [], "values": { - "1e4520fe6576": { - "detected": { - "$rpc": "null" - }, - "gate": { - "connectInProgress": true, - "error": { - "$rpc": "null" - }, - "requiresConnection": false, - "status": { - "$rpc": "null" - } - } - }, "2498f3d3369f": { "name": "preflight.detectAgents#1", "ordinal": 1, @@ -51,10 +36,12 @@ } } }, - "365a6864b043": { - "detected": [], + "305921cd9555": { + "detected": { + "$rpc": "null" + }, "gate": { - "connectInProgress": true, + "connectInProgress": false, "error": { "$rpc": "null" }, @@ -136,6 +123,19 @@ } } }, + "5990695f92a7": { + "detected": [], + "gate": { + "connectInProgress": false, + "error": { + "$rpc": "null" + }, + "requiresConnection": false, + "status": { + "$rpc": "null" + } + } + }, "74def3993a7c": { "name": "preflight.detectAgents#1", "ordinal": 1, @@ -272,19 +272,6 @@ } } }, - "986a776213e8": { - "detected": ["claude"], - "gate": { - "connectInProgress": true, - "error": { - "$rpc": "null" - }, - "requiresConnection": false, - "status": { - "$rpc": "null" - } - } - }, "9b8ae0c8eec5": { "name": "preflight.detectAgents#1", "ordinal": 2, @@ -347,6 +334,19 @@ } } }, + "b7f951970a65": { + "detected": ["claude"], + "gate": { + "connectInProgress": false, + "error": { + "$rpc": "null" + }, + "requiresConnection": false, + "status": { + "$rpc": "null" + } + } + }, "db1ccb3eedf8": { "name": "preflight.detectAgents#1", "ordinal": 1, @@ -468,7 +468,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "1e4520fe6576", + "state": "305921cd9555", "effects": [] } }, @@ -480,7 +480,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "986a776213e8", + "state": "b7f951970a65", "effects": [] } }, @@ -492,7 +492,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "5990695f92a7", "effects": [] } }, @@ -504,7 +504,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "5990695f92a7", "effects": [] } }, @@ -516,7 +516,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "5990695f92a7", "effects": [] } }, @@ -528,7 +528,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "5990695f92a7", "effects": [] } }, @@ -540,7 +540,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "5990695f92a7", "effects": [] } }, @@ -552,7 +552,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "5990695f92a7", "effects": [] } }, @@ -564,7 +564,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "5990695f92a7", "effects": [] } }, @@ -576,7 +576,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "5990695f92a7", "effects": [] } }, @@ -588,7 +588,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "5990695f92a7", "effects": [] } }, @@ -600,7 +600,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "5990695f92a7", "effects": [] } } diff --git a/mobile/src/components/NewWorktreeModal.test.tsx b/mobile/src/components/NewWorktreeModal.test.tsx index b0ddd8db7cd..b7d7b24bb91 100644 --- a/mobile/src/components/NewWorktreeModal.test.tsx +++ b/mobile/src/components/NewWorktreeModal.test.tsx @@ -490,6 +490,64 @@ describe('NewWorktreeModal project targets', () => { expect(desktopMethods).not.toContain('worktree.create') }) + it("never shows the desktop's SSH state for a server target with the same id", async () => { + const sshRepo = { ...repos[0]!, id: 'ssh-repo', path: '/home/dev/orca', connectionId: 'devbox' } + const desktop = vi.fn((method: string) => { + if (method === 'repo.list') { + return Promise.resolve({ ok: true, result: { repos: [sshRepo] } }) + } + if (method === 'ssh.getState') { + return Promise.resolve({ + ok: true, + result: { + state: { targetId: 'devbox', status: 'connected', error: null, reconnectAttempt: 0 } + } + }) + } + if (method === 'preflight.detectRemoteAgents') { + return Promise.resolve({ ok: true, result: ['codex'] }) + } + return new Promise(() => {}) + }) + // The server has not answered yet, so it has no state to show. + const server = vi.fn((method: string) => + method === 'repo.list' + ? Promise.resolve({ ok: true, result: { repos: [{ ...sshRepo, path: '/srv/orca' }] } }) + : new Promise(() => {}) + ) + + await act(async () => { + renderer = create( + createElement(NewWorktreeModal, { + visible: true, + client: requestsOnly(desktop), + serverClients: new Map([['runtime:env-1', requestsOnly(server)]]), + hostId: 'host-same-ssh-id', + onCreated: () => {}, + onClose: () => {} + }) + ) + }) + await flushUpdates() + const gate = () => renderer.root.find((node) => node.props.sshGate !== undefined).props.sshGate + const picker = runTargetPicker(renderer) + expect(picker.props.items.map((item: { id: string }) => item.id)).toEqual([ + 'ssh:devbox', + 'runtime:env-1' + ]) + await act(async () => picker.props.onSelect(picker.props.items[0])) + await flushUpdates() + expect(gate().status).toBe('connected') + + await act(async () => runTargetPicker(renderer).props.onSelect(picker.props.items[1])) + await flushUpdates() + + expect(runTargetPicker(renderer).props.selectedId).toBe('runtime:env-1') + expect(server).toHaveBeenCalledWith('ssh.getState', { targetId: 'devbox' }) + expect(gate()).toMatchObject({ status: null, requiresConnection: true }) + expect(server).not.toHaveBeenCalledWith('worktree.create', expect.anything(), expect.anything()) + }) + it("lists the desktop's repos at once while a server has not answered", async () => { const desktop = vi.fn((method: string) => method === 'repo.list' diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 222083fd70b..f6dbb7a5ee4 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -99,6 +99,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) { const executionTarget = useNewWorkspaceExecutionTarget({ client, connectionId: selectedRepoConnectionId, + server: repoServer, visible }) const setupScript = useNewWorkspaceSetupScript({ client, selectedRepo }) diff --git a/mobile/src/components/use-new-workspace-execution-target.ts b/mobile/src/components/use-new-workspace-execution-target.ts index ce7a7a3f307..4c347c7b959 100644 --- a/mobile/src/components/use-new-workspace-execution-target.ts +++ b/mobile/src/components/use-new-workspace-execution-target.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import type { ExecutionHostId } from '../../../src/shared/execution-host' import type { RpcClient } from '../transport/rpc-client' import { localAgentDetectionRead, @@ -13,10 +14,15 @@ import { } from '../tasks/workspace-ssh-gate' type DetectedAgentIdsState = { - connectionId: string | null + target: string ids: Set } +type SshStateEntry = { + target: string + record: WorkspaceSshRecord +} + function fallbackSshState( targetId: string, status: WorkspaceSshRecord['status'], @@ -28,25 +34,30 @@ function fallbackSshState( export function useNewWorkspaceExecutionTarget(args: { client: RpcClient | null connectionId: string | null + /** The server the repo runs on, which holds its SSH target; undefined for the desktop's own. */ + server?: ExecutionHostId visible: boolean }): { sshGate: WorkspaceSshGate detectedAgentIds: Set | null connect: () => Promise } { - const { client, connectionId, visible } = args - const [sshState, setSshState] = useState(null) - const [connectingTargetId, setConnectingTargetId] = useState(null) + const { client, connectionId, server, visible } = args + // Why the host too: the desktop and a server can each hold a target with the same id. + const target = JSON.stringify([server ?? null, connectionId]) + const [sshState, setSshEntry] = useState(null) + const setSshState = (record: WorkspaceSshRecord) => setSshEntry({ target, record }) + const [connectingTarget, setConnectingTarget] = useState(null) const [detectedAgentIdsState, setDetectedAgentIdsState] = useState( null ) const sshGate = deriveWorkspaceSshGate({ connectionId, - state: sshState, - connecting: connectingTargetId === connectionId + state: sshState?.target === target ? sshState.record : null, + connecting: connectingTarget === target }) const detectedAgentIds = - detectedAgentIdsState?.connectionId === connectionId && + detectedAgentIdsState?.target === target && (connectionId === null || sshGate.status === 'connected') ? detectedAgentIdsState.ids : null @@ -79,7 +90,7 @@ export function useNewWorkspaceExecutionTarget(args: { return () => { stale = true } - }, [client, connectionId, visible]) + }, [client, target, visible]) useEffect(() => { if (!visible || !client || (connectionId && sshGate.status !== 'connected')) { @@ -95,26 +106,26 @@ export function useNewWorkspaceExecutionTarget(args: { : localAgentDetectionRead.interpret(await localAgentDetectionRead.request(client)) if (!stale) { setDetectedAgentIdsState({ - connectionId, + target, ids: detected.accepted ? new Set(detected.value) : new Set() }) } } catch { if (!stale) { - setDetectedAgentIdsState({ connectionId, ids: new Set() }) + setDetectedAgentIdsState({ target, ids: new Set() }) } } })() return () => { stale = true } - }, [client, connectionId, sshGate.status, visible]) + }, [client, target, sshGate.status, visible]) async function connect(): Promise { if (!client || !connectionId) { return } - setConnectingTargetId(connectionId) + setConnectingTarget(target) setSshState(fallbackSshState(connectionId, 'connecting', null)) try { const reply = await sshRepoConnectRun.request( @@ -133,7 +144,7 @@ export function useNewWorkspaceExecutionTarget(args: { ) ) } finally { - setConnectingTargetId((current) => (current === connectionId ? null : current)) + setConnectingTarget((current) => (current === target ? null : current)) } }