diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx index f91d183bebb..9fa983138bf 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react' import { getExecutionHostLabel, parseExecutionHostId, + toRuntimeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { buildExecutionHostRegistry } from '../../../../shared/execution-host-registry' @@ -24,6 +25,10 @@ import { selectRuntimeAwareSshStatus, selectRuntimeAwareSshTargetLabel } from '@/store/slices/runtime-environment-ssh' +import { + isConnectedRuntimeHostState, + runtimeHostConnectionState +} from '@/runtime/runtime-host-connection-state' type RepositoryHostSetupsSectionProps = { repo: Repo @@ -215,9 +220,27 @@ export function RepositoryHostSetupsSection({ const runtimeOwnerEnvironmentId = setup.runtimeOwnerEnvironmentId?.trim() || (transportHost?.kind === 'runtime' ? transportHost.environmentId : null) + // Why: share one host-health derivation with the status bar so a degraded + // owner can never read "Ready" here and "Connected"/"Disconnected" there. + const runtimeOwnerStatusEntry = runtimeOwnerEnvironmentId + ? runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId) + : undefined + const runtimeOwnerState = runtimeOwnerEnvironmentId + ? runtimeHostConnectionState({ + hasStatusEntry: Boolean(runtimeOwnerStatusEntry), + status: runtimeOwnerStatusEntry?.status + }) + : null const runtimeOwnerReachable = - !runtimeOwnerEnvironmentId || - Boolean(runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId)?.status) + runtimeOwnerState === null || isConnectedRuntimeHostState(runtimeOwnerState) + const runtimeOwnerWorkspaceWindowClosed = runtimeOwnerState === 'workspace-window-closed' + const runtimeOwnerHostId = runtimeOwnerEnvironmentId + ? toRuntimeExecutionHostId(runtimeOwnerEnvironmentId) + : null + const runtimeOwnerHostLabel = runtimeOwnerHostId + ? (hostOptionById.get(runtimeOwnerHostId)?.label ?? + getExecutionHostLabel(runtimeOwnerHostId)) + : '' const nestedSshStatus = runtimeOwnerEnvironmentId && executionHost?.kind === 'ssh' ? selectRuntimeAwareSshStatus( @@ -236,20 +259,26 @@ export function RepositoryHostSetupsSection({ const setupReady = setup.setupState === 'ready' && runtimeOwnerReachable && + !runtimeOwnerWorkspaceWindowClosed && (nestedSshStatus === undefined || nestedSshStatus === 'connected') const setupStateLabel = !runtimeOwnerReachable ? translate( 'auto.components.settings.RepositoryPane.hostStateDisconnected', 'Disconnected' ) - : nestedSshStatus === null - ? translate('auto.components.settings.RepositoryPane.hostStateUnknown', 'Unknown') - : nestedSshStatus !== undefined && nestedSshStatus !== 'connected' - ? translate( - 'auto.components.settings.RepositoryPane.hostStateDisconnected', - 'Disconnected' - ) - : getSetupStateLabel(setup.setupState) + : runtimeOwnerWorkspaceWindowClosed + ? translate( + 'auto.components.settings.RepositoryPane.hostStateWorkspaceWindowClosed', + 'Workspace window closed' + ) + : nestedSshStatus === null + ? translate('auto.components.settings.RepositoryPane.hostStateUnknown', 'Unknown') + : nestedSshStatus !== undefined && nestedSshStatus !== 'connected' + ? translate( + 'auto.components.settings.RepositoryPane.hostStateDisconnected', + 'Disconnected' + ) + : getSetupStateLabel(setup.setupState) const setupHostLabel = runtimeOwnerEnvironmentId && executionHost?.kind === 'ssh' ? translate( @@ -299,6 +328,17 @@ export function RepositoryHostSetupsSection({ 'Path pending' )}

+ {runtimeOwnerWorkspaceWindowClosed ? ( + // Why: no runtime RPC can open a remote desktop window, so the only + // honest affordance is telling the user what to do on that host. +

+ {translate( + 'auto.components.settings.RepositoryPane.hostWorkspaceWindowClosedHelp', + 'The server is reachable but its Orca window is closed. Open Orca on {{value0}} to use this setup.', + { value0: runtimeOwnerHostLabel } + )} +

+ ) : null} {isCurrentSetup ? ( diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.workspace-window.test.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.workspace-window.test.tsx new file mode 100644 index 00000000000..81cdefa0a55 --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.workspace-window.test.tsx @@ -0,0 +1,156 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + RUNTIME_PROTOCOL_VERSION, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' +import { useAppStore } from '../../store' +import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection' + +let container: HTMLDivElement +let root: Root + +const repo: Repo = { + id: 'remote-repo', + displayName: 'Orca', + path: '/srv/orca', + badgeColor: '#737373', + addedAt: 100, + kind: 'git', + executionHostId: 'runtime:hub' +} +const project: Project = { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#737373', + sourceRepoIds: [repo.id], + createdAt: 100, + updatedAt: 100 +} +const setup: ProjectHostSetup = { + id: 'hub-setup', + projectId: project.id, + repoId: repo.id, + hostId: 'runtime:hub', + runtimeOwnerEnvironmentId: 'hub', + path: repo.path, + displayName: repo.displayName, + kind: 'git', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 100, + updatedAt: 100 +} + +function makeStatus(overrides: Partial): RuntimeStatus { + return { + runtimeId: 'runtime-hub', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + desktopWindowStatus: 'available', + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: 1, + capabilities: [PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY], + ...overrides + } +} + +function renderWithOwnerStatus(status: RuntimeStatus | null): void { + useAppStore.setState({ + repos: [repo], + projects: [project], + projectHostSetups: [setup], + runtimeStatusByEnvironmentId: new Map([['hub', { checkedAt: 1, appVersion: '1.8.0', status }]]) + }) + act(() => { + root.render( + React.createElement(RepositoryHostSetupsSection, { + repo, + forceVisible: true, + searchQuery: '', + searchEntries: [] + }) + ) + }) +} + +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + useAppStore.setState(useAppStore.getInitialState(), true) +}) + +// Why: #12350 — a reachable remote server whose renderer graph is gone still +// answered status.get, so every setup it owned read "Ready". +describe('RepositoryHostSetupsSection workspace window availability', () => { + it('flags a reachable runtime owner whose workspace window is closed instead of Ready', () => { + renderWithOwnerStatus( + makeStatus({ + graphStatus: 'unavailable', + authoritativeWindowId: null, + desktopWindowStatus: 'openable' + }) + ) + + const currentSetup = container.querySelector('[data-current="true"]') + expect(currentSetup?.textContent).toContain('Workspace window closed') + expect(currentSetup?.textContent).not.toContain('Ready') + // The host is reachable — this must not be reported as a lost connection. + expect(container.textContent).not.toContain('Disconnected') + expect(container.textContent).toContain('Open Orca on') + }) + + it('keeps a graph-ready runtime owner Ready when it reports no desktop window', () => { + // Why: headless `orca serve` (#6844) owns a ready graph with an openable + // desktop window — the degraded check must not widen into a renderer requirement. + renderWithOwnerStatus(makeStatus({ graphStatus: 'ready', desktopWindowStatus: 'openable' })) + + expect(container.textContent).toContain('Ready') + expect(container.textContent).not.toContain('Workspace window closed') + expect(container.textContent).not.toContain('Disconnected') + }) + + it('keeps an unreachable runtime owner disconnected', () => { + renderWithOwnerStatus(null) + + expect(container.textContent).toContain('Disconnected') + expect(container.textContent).not.toContain('Workspace window closed') + }) + + it('does not call a setup Ready when the owner control channel closed with an error', () => { + renderWithOwnerStatus( + makeStatus({ + remoteControl: { + state: 'closed', + pendingRequestCount: 0, + subscriptionCount: 0, + reconnectAttempt: 0, + lastConnectedAt: null, + lastClose: null, + lastError: 'Connection closed' + } + }) + ) + + expect(container.textContent).toContain('Disconnected') + expect(container.textContent).not.toContain('Workspace window closed') + }) +}) diff --git a/src/renderer/src/components/status-bar/RuntimeHostStatusRow.test.tsx b/src/renderer/src/components/status-bar/RuntimeHostStatusRow.test.tsx index 46803ebde03..34aedf06b9b 100644 --- a/src/renderer/src/components/status-bar/RuntimeHostStatusRow.test.tsx +++ b/src/renderer/src/components/status-bar/RuntimeHostStatusRow.test.tsx @@ -30,6 +30,35 @@ describe('RuntimeHostStatusRow', () => { expect(markup).toContain('Connect') }) + it('names the closed workspace window while keeping the disconnect action', () => { + const markup = renderToStaticMarkup( + {}} + onDisconnect={async () => {}} + /> + ) + + expect(markup).toContain('Dev Box') + expect(markup).toContain('Workspace window closed') + expect(markup).toContain('Disconnect') + expect(markup).not.toContain('>Connect') + // The host is still reachable — the row must not read as a lost connection. + expect(markup).not.toContain('Disconnected') + // Why: proves the row routes the action to onDisconnect — with only a connect + // handler there is nothing to offer, so no button renders. + expect( + renderToStaticMarkup( + {}} + /> + ) + ).not.toContain(' { const markup = renderToStaticMarkup( {}} /> diff --git a/src/renderer/src/components/status-bar/RuntimeHostStatusRow.tsx b/src/renderer/src/components/status-bar/RuntimeHostStatusRow.tsx index 3b5019f4719..3e7e8488f74 100644 --- a/src/renderer/src/components/status-bar/RuntimeHostStatusRow.tsx +++ b/src/renderer/src/components/status-bar/RuntimeHostStatusRow.tsx @@ -2,13 +2,20 @@ import { useCallback, useState } from 'react' import { Loader2 } from 'lucide-react' import { translate } from '@/i18n/i18n' import { useMountedRef } from '@/hooks/useMountedRef' - -export type RuntimeHostConnectionState = 'connected' | 'checking' | 'reconnecting' | 'disconnected' +import { + isConnectedRuntimeHostState, + type RuntimeHostConnectionState +} from '@/runtime/runtime-host-connection-state' function runtimeStatusLabel(state: RuntimeHostConnectionState): string { switch (state) { case 'connected': return translate('auto.components.status.bar.SshStatusSegment.runtime_online', 'Connected') + case 'workspace-window-closed': + return translate( + 'auto.components.status.bar.SshStatusSegment.runtime_workspace_window_closed', + 'Workspace window closed' + ) case 'checking': return translate('auto.components.status.bar.SshStatusSegment.runtime_checking', 'Checking') case 'reconnecting': @@ -28,6 +35,7 @@ function runtimeDotColor(state: RuntimeHostConnectionState): string { switch (state) { case 'connected': return 'bg-emerald-500' + case 'workspace-window-closed': case 'checking': case 'reconnecting': return 'bg-yellow-500' @@ -37,7 +45,7 @@ function runtimeDotColor(state: RuntimeHostConnectionState): string { } function runtimeStatusTone(state: RuntimeHostConnectionState): string { - if (state === 'checking' || state === 'reconnecting') { + if (state === 'checking' || state === 'reconnecting' || state === 'workspace-window-closed') { return 'text-yellow-500' } return 'text-muted-foreground' @@ -46,6 +54,7 @@ function runtimeStatusTone(state: RuntimeHostConnectionState): string { function runtimeActionLabel(state: RuntimeHostConnectionState): string | null { switch (state) { case 'connected': + case 'workspace-window-closed': return translate('auto.components.status.bar.SshStatusSegment.59b553e2aa', 'Disconnect') case 'disconnected': return translate('auto.components.status.bar.SshStatusSegment.63f36455cc', 'Connect') @@ -73,7 +82,7 @@ export function RuntimeHostStatusRow({ const actionLabel = runtimeActionLabel(state) const handleAction = useCallback(async () => { - const action = state === 'connected' ? onDisconnect : onConnect + const action = isConnectedRuntimeHostState(state) ? onDisconnect : onConnect if (!action) { return } @@ -116,7 +125,7 @@ export function RuntimeHostStatusRow({ {busy ? ( - ) : actionLabel && (state === 'connected' ? onDisconnect : onConnect) ? ( + ) : actionLabel && (isConnectedRuntimeHostState(state) ? onDisconnect : onConnect) ? (