diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 61b3404b2a3..585c5eb7ed3 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -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> = {} ) { @@ -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], diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index b4c00a0c3df..b13d0613f81 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -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['repos'][number] @@ -231,6 +234,7 @@ type WorkspaceRunTargetComboboxProps = { onRecipeChange?: (recipeId: string | null) => void onAddRemoteServer?: () => void onAddSshHost?: () => void + onConnectHost?: (option: NeedsSetupProjectHostOption) => Promise | 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(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 => { + 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" > - - {option.isAvailable ? ( - - ) : ( - - )} -
-
{option.label}
-
- {option.detail} +
+ + {option.isAvailable ? ( + + ) : ( + + )} +
+
{option.label}
+
+ {option.detail} +
+ {option.connectAction && onConnectHost ? ( + + ) : null} ) )} @@ -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 => { + 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(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) => { 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 ? (

diff --git a/src/renderer/src/lib/project-host-setup-options.test.ts b/src/renderer/src/lib/project-host-setup-options.test.ts index 75efcc95083..2bd8d395300 100644 --- a/src/renderer/src/lib/project-host-setup-options.test.ts +++ b/src/renderer/src/lib/project-host-setup-options.test.ts @@ -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', diff --git a/src/renderer/src/lib/project-host-setup-options.ts b/src/renderer/src/lib/project-host-setup-options.ts index 2f7a5790dbe..ba5512c2f5b 100644 --- a/src/renderer/src/lib/project-host-setup-options.ts +++ b/src/renderer/src/lib/project-host-setup-options.ts @@ -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 @@ -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':