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
This commit is contained in:
Jinwoo-H
2026-10-11 22:37:09 -04:00
parent c4c058ad15
commit 4a5ccaa9a3
5 changed files with 143 additions and 73 deletions
@@ -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": []
}
}
@@ -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": []
}
}
@@ -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'
@@ -99,6 +99,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
const executionTarget = useNewWorkspaceExecutionTarget({
client,
connectionId: selectedRepoConnectionId,
server: repoServer,
visible
})
const setupScript = useNewWorkspaceSetupScript({ client, selectedRepo })
@@ -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<string>
}
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<string> | null
connect: () => Promise<void>
} {
const { client, connectionId, visible } = args
const [sshState, setSshState] = useState<WorkspaceSshRecord | null>(null)
const [connectingTargetId, setConnectingTargetId] = useState<string | null>(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<SshStateEntry | null>(null)
const setSshState = (record: WorkspaceSshRecord) => setSshEntry({ target, record })
const [connectingTarget, setConnectingTarget] = useState<string | null>(null)
const [detectedAgentIdsState, setDetectedAgentIdsState] = useState<DetectedAgentIdsState | null>(
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<void> {
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))
}
}