mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Add connect affordance for offline run targets
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user