Gate unavailable project setup hosts in settings

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-11 12:34:00 -07:00
co-authored by Orca
parent 79ff2475c1
commit e00d3cbd4c
8 changed files with 145 additions and 13 deletions
@@ -4,6 +4,7 @@ import React, { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { toSshExecutionHostId } from '../../../../shared/execution-host'
import { RUNTIME_PROTOCOL_VERSION } from '../../../../shared/protocol-version'
import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection'
@@ -395,6 +396,70 @@ describe('RepositoryHostSetupsSection', () => {
})
})
it('shows unsupported runtime hosts without enabling setup actions', async () => {
const createProjectHostSetup = vi.fn()
const setupProjectClone = vi.fn()
const setupProjectExistingFolder = vi.fn()
const localRepo = makeRepo({
id: 'local-repo',
displayName: 'Orca',
path: '/Users/alice/orca'
})
useAppStore.setState({
repos: [localRepo],
projects: [makeProject({ id: 'github:stablyai/orca' })],
projectHostSetups: [
makeSetup({
id: 'local-repo',
projectId: 'github:stablyai/orca',
repoId: 'local-repo',
hostId: 'local',
path: '/Users/alice/orca'
})
],
settings: { activeRuntimeEnvironmentId: null } as never,
runtimeStatusByEnvironmentId: new Map([
[
'gpu',
{
checkedAt: 1,
appVersion: '1.7.0',
status: {
runtimeId: 'runtime-gpu',
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: 1,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion: 1,
capabilities: []
}
}
]
]),
createProjectHostSetup,
setupProjectClone,
setupProjectExistingFolder
})
renderSection(localRepo)
expect(container.textContent).toContain('Update Orca on this host to set up projects')
const trackButton = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === 'Track setup'
)
expect(trackButton?.disabled).toBe(true)
await act(async () => {
trackButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(createProjectHostSetup).not.toHaveBeenCalled()
expect(setupProjectClone).not.toHaveBeenCalled()
expect(setupProjectExistingFolder).not.toHaveBeenCalled()
})
it('offers inactive runtime hosts discovered from hydrated runtime status', async () => {
const createProjectHostSetup = vi.fn().mockResolvedValue({
project: makeProject({ id: 'github:stablyai/orca' }),
@@ -439,7 +504,7 @@ describe('RepositoryHostSetupsSection', () => {
authoritativeWindowId: 1,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion: 1,
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion: 1,
capabilities: ['project-host-setup.v1']
}
@@ -86,7 +86,12 @@ export function RepositoryHostSetupsSection({
const [isCloning, setIsCloning] = useState(false)
const [isCreatingPendingSetup, setIsCreatingPendingSetup] = useState(false)
const [deletingSetupId, setDeletingSetupId] = useState<string | null>(null)
const setupTargetHostId = selectedSetupHostId ?? setupHostOptions[0]?.id ?? null
const defaultSetupHostOption =
setupHostOptions.find((option) => option.isAvailable) ?? setupHostOptions[0] ?? null
const setupTargetHostId = selectedSetupHostId ?? defaultSetupHostOption?.id ?? null
const setupTargetHostOption =
setupHostOptions.find((option) => option.id === setupTargetHostId) ?? null
const canUseSetupTargetHost = setupTargetHostOption?.isAvailable ?? false
if (
(projectHostSetups.length <= 1 && setupHostOptions.length === 0) ||
@@ -209,8 +214,15 @@ export function RepositoryHostSetupsSection({
</SelectTrigger>
<SelectContent>
{setupHostOptions.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label}
<SelectItem key={option.id} value={option.id} disabled={!option.isAvailable}>
<span className="min-w-0">
<span className="block truncate">{option.label}</span>
{!option.isAvailable ? (
<span className="block truncate text-[11px] text-muted-foreground">
{option.detail}
</span>
) : null}
</span>
</SelectItem>
))}
</SelectContent>
@@ -245,9 +257,14 @@ export function RepositoryHostSetupsSection({
<Button
type="button"
size="sm"
disabled={!setupTargetHostId || !setupPath.trim() || isSettingUp}
disabled={!canUseSetupTargetHost || !setupPath.trim() || isSettingUp}
onClick={async () => {
if (!setupTargetHostId || !selectedProjectHostSetup || !setupPath.trim()) {
if (
!setupTargetHostId ||
!canUseSetupTargetHost ||
!selectedProjectHostSetup ||
!setupPath.trim()
) {
return
}
setIsSettingUp(true)
@@ -295,11 +312,12 @@ export function RepositoryHostSetupsSection({
type="button"
size="sm"
disabled={
!setupTargetHostId || !cloneUrl.trim() || !cloneDestination.trim() || isCloning
!canUseSetupTargetHost || !cloneUrl.trim() || !cloneDestination.trim() || isCloning
}
onClick={async () => {
if (
!setupTargetHostId ||
!canUseSetupTargetHost ||
!selectedProjectHostSetup ||
!cloneUrl.trim() ||
!cloneDestination.trim()
@@ -334,9 +352,9 @@ export function RepositoryHostSetupsSection({
type="button"
variant="outline"
size="sm"
disabled={!setupTargetHostId || isCreatingPendingSetup}
disabled={!canUseSetupTargetHost || isCreatingPendingSetup}
onClick={async () => {
if (!setupTargetHostId || !selectedProjectHostSetup) {
if (!setupTargetHostId || !canUseSetupTargetHost || !selectedProjectHostSetup) {
return
}
setIsCreatingPendingSetup(true)
@@ -1,11 +1,14 @@
import { getExecutionHostLabel, type ExecutionHostId } from '../../../../shared/execution-host'
import type { ExecutionHostRegistryEntry } from '../../../../shared/execution-host-registry'
import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type { ProjectHostSetup, ProjectHostSetupState } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
export type SetupHostOption = {
id: ExecutionHostId
label: string
detail: string
isAvailable: boolean
}
export function getSetupStateLabel(setupState: ProjectHostSetupState): string {
@@ -42,8 +45,45 @@ export function buildSetupHostOptions({
const setupHostIds = new Set(projectHostSetups.map((setup) => setup.hostId))
return hostOptions
.filter((host) => !setupHostIds.has(host.id))
.map((host) => ({
id: host.id,
label: host.label || getExecutionHostLabel(host.id)
}))
.map((host) => {
const availability = getHostSetupAvailability(host)
return {
id: host.id,
label: host.label || getExecutionHostLabel(host.id),
detail: availability.detail,
isAvailable: availability.isAvailable
}
})
}
function getHostSetupAvailability(host: ExecutionHostRegistryEntry): {
isAvailable: boolean
detail: string
} {
if (host.health === 'blocked') {
return {
isAvailable: false,
detail: translate(
'auto.components.settings.RepositoryPane.hostSetupBlockedVersion',
'Orca server version is incompatible'
)
}
}
if (
host.kind === 'runtime' &&
host.capabilities &&
!host.capabilities?.includes(PROJECT_HOST_SETUP_RUNTIME_CAPABILITY)
) {
return {
isAvailable: false,
detail: translate(
'auto.components.settings.RepositoryPane.hostSetupMissingCapability',
'Update Orca on this host to set up projects'
)
}
}
return {
isAvailable: true,
detail: host.detail
}
}
+2
View File
@@ -5058,6 +5058,8 @@
"setupPathPending": "Path pending",
"openSetup": "Open",
"removeSetup": "Remove",
"hostSetupBlockedVersion": "Orca server version is incompatible",
"hostSetupMissingCapability": "Update Orca on this host to set up projects",
"setupProjectOnHost": "Set up on another host",
"setupProjectOnHostHelp": "Choose a host, then import an existing checkout, clone the repository there, or track a setup that will be provisioned later.",
"setupExistingFolder": "Import existing folder",
+2
View File
@@ -5038,6 +5038,8 @@
"setupPathPending": "パス保留中",
"openSetup": "開く",
"removeSetup": "削除",
"hostSetupBlockedVersion": "Orca サーバーのバージョンに互換性がありません",
"hostSetupMissingCapability": "プロジェクトをセットアップするには、このホストの Orca を更新してください",
"setupProjectOnHost": "別のホストにセットアップ",
"setupProjectOnHostHelp": "ホストを選択し、既存のチェックアウトをインポートするか、そこでリポジトリをクローンするか、後でプロビジョニングされるセットアップを追跡します。",
"setupExistingFolder": "既存フォルダーをインポート",
+2
View File
@@ -5038,6 +5038,8 @@
"setupPathPending": "경로 대기 중",
"openSetup": "열기",
"removeSetup": "제거",
"hostSetupBlockedVersion": "Orca 서버 버전이 호환되지 않습니다",
"hostSetupMissingCapability": "프로젝트를 설정하려면 이 호스트의 Orca를 업데이트하세요",
"setupProjectOnHost": "다른 호스트에 설정",
"setupProjectOnHostHelp": "호스트를 선택한 다음 기존 체크아웃을 가져오거나, 그곳에 저장소를 클론하거나, 나중에 프로비저닝될 설정을 추적합니다.",
"setupExistingFolder": "기존 폴더 가져오기",
+2
View File
@@ -5038,6 +5038,8 @@
"setupPathPending": "路径待定",
"openSetup": "打开",
"removeSetup": "移除",
"hostSetupBlockedVersion": "Orca 服务器版本不兼容",
"hostSetupMissingCapability": "请更新此主机上的 Orca 以设置项目",
"setupProjectOnHost": "在另一台主机上设置",
"setupProjectOnHostHelp": "选择一台主机,然后导入现有检出目录、在该主机上克隆仓库,或跟踪稍后要预配的设置。",
"setupExistingFolder": "导入现有文件夹",
@@ -155,6 +155,7 @@ function getHostSetupAvailability(host: ExecutionHostRegistryEntry): {
}
if (
host.kind === 'runtime' &&
host.capabilities &&
!host.capabilities?.includes(PROJECT_HOST_SETUP_RUNTIME_CAPABILITY)
) {
return {