From 4a5ccaa9a3402a38a531719030c4acff2e6a903e Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 11 Oct 2026 22:37:09 -0400 Subject: [PATCH] fix(mobile): key the New Workspace SSH gate by the host holding the target The desktop and a server can each have an SSH target with the same id. The gate's state, Connect progress and detected agents were keyed by that id alone, so switching to the server's target showed the desktop's "connected" until the server answered, and let create through. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../goldens/components-target-local.json | 34 ++++---- ...target-local-preflight.detectagents-1.json | 86 +++++++++---------- .../src/components/NewWorktreeModal.test.tsx | 58 +++++++++++++ mobile/src/components/NewWorktreeModal.tsx | 1 + .../use-new-workspace-execution-target.ts | 37 +++++--- 5 files changed, 143 insertions(+), 73 deletions(-) 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)) } }