Add connect affordance for offline run targets

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-08 18:43:07 -04:00
co-authored by Orca
parent 4a9577c2ca
commit f69dae30f4
4 changed files with 278 additions and 25 deletions
@@ -3,7 +3,7 @@
import React from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import NewWorkspaceComposerCard from './NewWorkspaceComposerCard'
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options'
@@ -12,20 +12,34 @@ const storeMocks = vi.hoisted(() => ({
closeModal: vi.fn(),
openModal: vi.fn(),
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn()
openSettingsTarget: vi.fn(),
setRuntimeEnvironmentStatus: vi.fn()
}))
const apiMocks = vi.hoisted(() => ({
runtimeGetStatus: vi.fn(),
sshConnect: vi.fn()
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
closeModal: storeMocks.closeModal,
openModal: storeMocks.openModal,
openSettingsPage: storeMocks.openSettingsPage,
openSettingsTarget: storeMocks.openSettingsTarget,
activeModal: 'none',
settings: { defaultTuiAgent: null, disabledTuiAgents: [] },
updateSettings: vi.fn()
})
useAppStore: Object.assign(
(selector: (state: unknown) => unknown) =>
selector({
closeModal: storeMocks.closeModal,
openModal: storeMocks.openModal,
openSettingsPage: storeMocks.openSettingsPage,
openSettingsTarget: storeMocks.openSettingsTarget,
setRuntimeEnvironmentStatus: storeMocks.setRuntimeEnvironmentStatus,
activeModal: 'none',
settings: { defaultTuiAgent: null, disabledTuiAgents: [] },
updateSettings: vi.fn()
}),
{
getState: () => ({
setRuntimeEnvironmentStatus: storeMocks.setRuntimeEnvironmentStatus
})
}
)
}))
vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({
@@ -137,6 +151,17 @@ const devboxNeedsSetupHostOption: ProjectHostSetupOption = {
isAvailable: true
}
const disconnectedDevboxNeedsSetupHostOption: ProjectHostSetupOption = {
kind: 'needs-setup',
id: 'needs-setup:ssh:devbox',
projectId: 'project-group:platform',
hostId: 'ssh:devbox',
label: 'Devbox',
detail: 'Connect this host to set up projects',
isAvailable: false,
connectAction: { kind: 'ssh', targetId: 'devbox' }
}
function renderCard(
overrides: Partial<React.ComponentProps<typeof NewWorkspaceComposerCard>> = {}
) {
@@ -242,6 +267,31 @@ function findRunTargetItem(label: string): HTMLElement | undefined {
let current: { container: HTMLDivElement; root: Root } | null = null
describe('NewWorkspaceComposerCard folder task source mode', () => {
beforeEach(() => {
;(window as unknown as { api: unknown }).api = {
runtimeEnvironments: {
getStatus: apiMocks.runtimeGetStatus
},
ssh: {
connect: apiMocks.sshConnect
}
}
apiMocks.runtimeGetStatus.mockResolvedValue({
id: 'status',
ok: true,
result: {
runtimeId: 'runtime-devbox',
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0
},
_meta: { runtimeId: 'runtime-devbox' }
})
apiMocks.sshConnect.mockResolvedValue(undefined)
})
afterEach(() => {
act(() => current?.root.unmount())
current?.container.remove()
@@ -509,6 +559,33 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
expect(hostChanges).toEqual([])
})
it('connects disconnected setup-needed SSH hosts without selecting them', async () => {
const hostChanges: string[] = []
current = renderCard({
projectHostSetupOptions: [localReadyHostOption, disconnectedDevboxNeedsSetupHostOption],
selectedProjectHostSetupId: 'setup-local',
onProjectHostSetupChange: (setupId) => hostChanges.push(setupId)
})
openRunTargetPicker(current.container)
const devboxItem = findRunTargetItem('Devbox')
expect(
devboxItem?.getAttribute('aria-disabled') === 'true' ||
devboxItem?.hasAttribute('data-disabled')
).toBe(true)
const connectButton = [...(devboxItem?.querySelectorAll('button') ?? [])].find((button) =>
button.textContent?.includes('Connect')
)
expect(connectButton).toBeTruthy()
await act(async () => {
connectButton?.click()
})
expect(apiMocks.sshConnect).toHaveBeenCalledWith({ targetId: 'devbox' })
expect(hostChanges).toEqual([])
})
it('opens the SSH host add form from the run target picker', () => {
current = renderCard({
projectHostSetupOptions: [localReadyHostOption, devboxNeedsSetupHostOption],
@@ -57,12 +57,15 @@ import ProjectCombobox from '@/components/new-workspace/ProjectCombobox'
import type { SetupConfig } from '@/lib/new-workspace'
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
import type {
NeedsSetupProjectHostOption,
ProjectHostSetupOption,
ReadyProjectHostSetupOption
} from '@/lib/project-host-setup-options'
import type { WorkspaceCreateErrorDisplay } from '@/lib/workspace-create-error-format'
import type { SshConnectionStatus } from '../../../shared/ssh-types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import type { RuntimeStatus } from '../../../shared/runtime-types'
import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client'
import { translate } from '@/i18n/i18n'
type RepoOption = React.ComponentProps<typeof RepoCombobox>['repos'][number]
@@ -231,6 +234,7 @@ type WorkspaceRunTargetComboboxProps = {
onRecipeChange?: (recipeId: string | null) => void
onAddRemoteServer?: () => void
onAddSshHost?: () => void
onConnectHost?: (option: NeedsSetupProjectHostOption) => Promise<void> | void
}
function WorkspaceRunTargetCombobox({
@@ -241,11 +245,14 @@ function WorkspaceRunTargetCombobox({
recipeValue,
onRecipeChange,
onAddRemoteServer,
onAddSshHost
onAddSshHost,
onConnectHost
}: WorkspaceRunTargetComboboxProps): React.JSX.Element {
const [open, setOpen] = React.useState(false)
const [vmRecipesOpen, setVmRecipesOpen] = React.useState(false)
const [hostActionsOpen, setHostActionsOpen] = React.useState(false)
const [connectingHostId, setConnectingHostId] = React.useState<string | null>(null)
const mountedRef = React.useRef(true)
const readyHostOptions = React.useMemo(
() =>
hostOptions.filter(
@@ -266,6 +273,12 @@ function WorkspaceRunTargetCombobox({
'Per-Workspace Environment'
)
React.useEffect(() => {
return () => {
mountedRef.current = false
}
}, [])
const handleHostSelect = React.useCallback(
(setupId: string): void => {
if (!readyHostOptions.some((candidate) => candidate.id === setupId)) {
@@ -290,6 +303,23 @@ function WorkspaceRunTargetCombobox({
[onRecipeChange, recipes]
)
const connectHost = React.useCallback(
async (option: NeedsSetupProjectHostOption): Promise<void> => {
if (!option.connectAction || !onConnectHost || connectingHostId !== null) {
return
}
setConnectingHostId(option.hostId)
try {
await onConnectHost(option)
} finally {
if (mountedRef.current) {
setConnectingHostId((current) => (current === option.hostId ? null : current))
}
}
},
[connectingHostId, onConnectHost]
)
const handleAddSshHost = React.useCallback((): void => {
setHostActionsOpen(false)
setOpen(false)
@@ -378,20 +408,39 @@ function WorkspaceRunTargetCombobox({
key={option.id}
value={option.id}
disabled
className="items-center gap-2 px-3 py-2"
className="items-center gap-2 px-3 py-2 data-[disabled=true]:pointer-events-auto data-[disabled=true]:opacity-100"
>
<Check className="size-4 opacity-0" />
{option.isAvailable ? (
<Server className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<AlertTriangle className="size-3.5 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{option.label}</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{option.detail}
<div className="flex min-w-0 flex-1 items-center gap-2 opacity-50">
<Check className="size-4 opacity-0" />
{option.isAvailable ? (
<Server className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<AlertTriangle className="size-3.5 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{option.label}</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{option.detail}
</div>
</div>
</div>
{option.connectAction && onConnectHost ? (
<Button
type="button"
variant="ghost"
size="xs"
className="ml-1 shrink-0 text-muted-foreground hover:text-foreground"
disabled={connectingHostId !== null}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void connectHost(option)
}}
>
{translate('auto.components.NewWorkspaceComposerCard.connectHost', 'Connect')}
</Button>
) : null}
</CommandItem>
)
)}
@@ -876,6 +925,48 @@ export default function NewWorkspaceComposerCard({
const handleAddRemoteServer = React.useCallback((): void => {
openHostSettingsTarget('add-remote-orca-server')
}, [openHostSettingsTarget])
const handleConnectRunTargetHost = React.useCallback(
async (option: NeedsSetupProjectHostOption): Promise<void> => {
const action = option.connectAction
if (!action) {
return
}
try {
if (action.kind === 'ssh') {
await window.api.ssh.connect({ targetId: action.targetId })
return
}
const response = await window.api.runtimeEnvironments.getStatus({
selector: action.environmentId,
timeoutMs: 15_000
})
const runtimeStatus = unwrapRuntimeRpcResult<RuntimeStatus>(response)
// Why: the composer button is only a reachability retry; the separate
// project setup flow remains a follow-up once the host is online.
useAppStore.getState().setRuntimeEnvironmentStatus(action.environmentId, {
status: runtimeStatus,
checkedAt: Date.now()
})
} catch (error) {
if (action.kind === 'runtime') {
useAppStore.getState().setRuntimeEnvironmentStatus(action.environmentId, {
status: null,
checkedAt: Date.now()
})
}
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.NewWorkspaceComposerCard.hostConnectionFailed',
'Connection failed'
)
)
}
},
[]
)
const handleNotePaste = React.useCallback((event: React.ClipboardEvent<HTMLTextAreaElement>) => {
const text = event.clipboardData.getData('text/plain')
const byteLengthMeasurement = measureTextControlPasteByteLength(text, {
@@ -1031,6 +1122,7 @@ export default function NewWorkspaceComposerCard({
onRecipeChange={onEphemeralVmRecipeChange}
onAddSshHost={handleAddSshHost}
onAddRemoteServer={handleAddRemoteServer}
onConnectHost={handleConnectRunTargetHost}
/>
{ephemeralVmRecipeError ? (
<p className="whitespace-pre-line text-[11px] text-destructive">
@@ -243,6 +243,71 @@ describe('buildProjectHostSetupOptions', () => {
}
)
it.each([
['ssh:builder' as const, { kind: 'ssh', targetId: 'builder' }],
['runtime:gpu' as const, { kind: 'runtime', environmentId: 'gpu' }]
])('adds a connect action for disconnected %s setup-needed hosts', (hostId, connectAction) => {
const options = buildProjectHostSetupOptions({
projectId: 'project-1',
eligibleRepos: [repo('local-repo')],
hosts: [
host('local'),
host(hostId, {
label: 'Builder',
health: 'disconnected'
})
],
projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')]
})
expect(options.at(-1)).toMatchObject({
id: `needs-setup:${hostId}`,
kind: 'needs-setup',
connectAction
})
})
it('adds a connect action for errored setup-needed hosts', () => {
const options = buildProjectHostSetupOptions({
projectId: 'project-1',
eligibleRepos: [repo('local-repo')],
hosts: [
host('local'),
host('ssh:builder', {
label: 'Builder',
health: 'error'
})
],
projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')]
})
expect(options.at(-1)).toMatchObject({
id: 'needs-setup:ssh:builder',
kind: 'needs-setup',
connectAction: { kind: 'ssh', targetId: 'builder' }
})
})
it.each(['available' as const, 'connecting' as const, 'blocked' as const])(
'does not add a connect action for %s setup-needed hosts',
(health) => {
const options = buildProjectHostSetupOptions({
projectId: 'project-1',
eligibleRepos: [repo('local-repo')],
hosts: [
host('local'),
host('ssh:builder', {
label: 'Builder',
health
})
],
projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')]
})
expect(options.at(-1)).not.toHaveProperty('connectAction')
}
)
it('shows pending setup status for known hosts with non-ready setup metadata', () => {
const options = buildProjectHostSetupOptions({
projectId: 'project-1',
@@ -32,6 +32,7 @@ export type ProjectHostSetupOption =
label: string
detail: string
isAvailable: boolean
connectAction?: { kind: 'ssh'; targetId: string } | { kind: 'runtime'; environmentId: string }
}
export type ReadyProjectHostSetupOption = Extract<ProjectHostSetupOption, { kind: 'ready' }>
@@ -150,6 +151,7 @@ function buildNeedsSetupOptions({
.map((host) => {
const pendingSetup = pendingSetupByHost.get(host.id)
const availability = getHostSetupAvailability(host)
const connectAction = getHostConnectAction(host)
return {
id: `needs-setup:${host.id}`,
kind: 'needs-setup' as const,
@@ -161,7 +163,8 @@ function buildNeedsSetupOptions({
? getPendingSetupDetail(pendingSetup)
: 'Project not set up on this host'
: availability.detail,
isAvailable: availability.isAvailable
isAvailable: availability.isAvailable,
...(connectAction ? { connectAction } : {})
}
})
}
@@ -237,6 +240,22 @@ function getHostHealthUnavailableDetail(
}
}
function getHostConnectAction(
host: ExecutionHostRegistryEntry
): NeedsSetupProjectHostOption['connectAction'] | undefined {
if (host.health !== 'disconnected' && host.health !== 'error') {
return undefined
}
const parsed = parseExecutionHostId(host.id)
if (parsed?.kind === 'ssh') {
return { kind: 'ssh', targetId: parsed.targetId }
}
if (parsed?.kind === 'runtime') {
return { kind: 'runtime', environmentId: parsed.environmentId }
}
return undefined
}
function getPendingSetupDetail(setup: ProjectHostSetup): string {
switch (setup.setupState) {
case 'not-set-up':