From 752dbd5d93b7b7a67b2ccbfe1ac9ea865e919277 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Fri, 7 Aug 2026 20:10:44 -0700
Subject: [PATCH] fix(remote): surface a reachable server whose workspace
window is closed
Merges #12352's review feedback: one host-health derivation shared by the
status bar and Settings.
Co-authored-by: Orca
---
.../settings/RepositoryHostSetupsSection.tsx | 60 +++++--
...ostSetupsSection.workspace-window.test.tsx | 156 ++++++++++++++++++
.../status-bar/RuntimeHostStatusRow.test.tsx | 29 ++++
.../status-bar/RuntimeHostStatusRow.tsx | 19 ++-
.../status-bar/SshStatusSegment.test.ts | 22 +--
.../status-bar/SshStatusSegment.tsx | 137 ++-------------
.../remote-host-connection-status.ts | 76 +++++++++
src/renderer/src/i18n/locales/en.json | 5 +-
.../runtime-host-connection-state.test.ts | 100 +++++++++++
.../runtime/runtime-host-connection-state.ts | 66 ++++++++
...time-workspace-window-availability.test.ts | 76 +++++++++
.../runtime-workspace-window-availability.ts | 14 ++
12 files changed, 600 insertions(+), 160 deletions(-)
create mode 100644 src/renderer/src/components/settings/RepositoryHostSetupsSection.workspace-window.test.tsx
create mode 100644 src/renderer/src/components/status-bar/remote-host-connection-status.ts
create mode 100644 src/renderer/src/runtime/runtime-host-connection-state.test.ts
create mode 100644 src/renderer/src/runtime/runtime-host-connection-state.ts
create mode 100644 src/shared/runtime-workspace-window-availability.test.ts
create mode 100644 src/shared/runtime-workspace-window-availability.ts
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) ? (
void handleAction()}
diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.test.ts b/src/renderer/src/components/status-bar/SshStatusSegment.test.ts
index 406de54c6f6..c6fa99fe1ea 100644
--- a/src/renderer/src/components/status-bar/SshStatusSegment.test.ts
+++ b/src/renderer/src/components/status-bar/SshStatusSegment.test.ts
@@ -1,25 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
-import {
- connectRuntimeHostForNavigation,
- isConnectedRuntimeHostState,
- runtimeStatusForOverall
-} from './SshStatusSegment'
-
-describe('SshStatusSegment host status helpers', () => {
- it('counts connected remote servers as connected hosts', () => {
- // Why: "connected" = attached/reachable (active-agnostic), matching Settings.
- // There is no separate "available" state — a reachable host is just Connected.
- expect(runtimeStatusForOverall('connected')).toBe('connected')
- expect(isConnectedRuntimeHostState('connected')).toBe(true)
- })
-
- it('keeps reconnecting and disconnected remote servers out of the connected count', () => {
- expect(runtimeStatusForOverall('reconnecting')).toBe('connecting')
- expect(runtimeStatusForOverall('disconnected')).toBe('disconnected')
- expect(isConnectedRuntimeHostState('reconnecting')).toBe(false)
- expect(isConnectedRuntimeHostState('disconnected')).toBe(false)
- })
-})
+import { connectRuntimeHostForNavigation } from './SshStatusSegment'
describe('connectRuntimeHostForNavigation', () => {
it('loads the transient host catalog without writing Active Server', async () => {
diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx
index fc417d679f1..570ebaec162 100644
--- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx
+++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx
@@ -17,134 +17,25 @@ import {
toRuntimeExecutionHostId
} from '../../../../shared/execution-host'
import { isUserManagedRuntimeEnvironment } from '../../../../shared/runtime-environments'
-import { RuntimeHostStatusRow, type RuntimeHostConnectionState } from './RuntimeHostStatusRow'
+import { RuntimeHostStatusRow } from './RuntimeHostStatusRow'
import {
connectedHostCountLabel,
connectingHostsLabel,
workspaceSyncProblemLabel
} from './ssh-status-segment-copy'
import { SshTargetStatusRow } from './SshTargetStatusRow'
-import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types'
import { connectRuntimeEnvironmentAndRecordStatus } from './runtime-environment-explicit-connect'
-import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
-
-type HostStatus = 'connected' | 'disconnected' | 'connecting'
-
-function overallStatus(
- statuses: HostStatus[]
-): 'connected' | 'partial' | 'disconnected' | 'connecting' {
- if (statuses.length === 0) {
- return 'disconnected'
- }
- if (statuses.every((s) => s === 'connected')) {
- return 'connected'
- }
- if (statuses.some((s) => s === 'connecting')) {
- return 'connecting'
- }
- if (statuses.some((s) => s === 'connected')) {
- return 'partial'
- }
- return 'disconnected'
-}
-
-function overallDotColor(
- status: 'connected' | 'partial' | 'disconnected' | 'connecting',
- connectedCount: number
-): string {
- switch (status) {
- case 'connected':
- return 'bg-emerald-500'
- case 'partial':
- return connectedCount > 0 ? 'bg-emerald-500' : 'bg-muted-foreground/40'
- case 'connecting':
- return 'bg-yellow-500'
- case 'disconnected':
- return 'bg-muted-foreground/40'
- }
-}
-
-function sshStatusForOverall(status: SshConnectionStatus): HostStatus {
- if (status === 'connected') {
- return 'connected'
- }
- return isConnectingSshStatus(status) ? 'connecting' : 'disconnected'
-}
-
-function runtimeHostConnectionState({
- hasStatus,
- online,
- remoteControl
-}: {
- hasStatus: boolean
- online: boolean
- remoteControl?: RemoteRuntimeSharedConnectionDiagnostics | null
-}): RuntimeHostConnectionState {
- if (!hasStatus) {
- return 'checking'
- }
- if (remoteControl?.state === 'reconnecting') {
- return 'reconnecting'
- }
- if (!online) {
- return 'disconnected'
- }
- if (remoteControl?.state === 'closed' && remoteControl.lastError) {
- return 'disconnected'
- }
- // Why: "connected" means attached/reachable, NOT "is the active default host".
- // Both surfaces (this status bar and Settings > Remote Orca Servers) must agree
- // on that single definition, or a reachable-but-not-active host reads
- // "Connected" in one place and "Available" in the other. Active/default is a
- // separate concept (surfaced elsewhere), so it must not change this state.
- return 'connected'
-}
-
-function runtimeHostConnectionDetail(
- remoteControl?: RemoteRuntimeSharedConnectionDiagnostics | null
-): string | undefined {
- if (!remoteControl) {
- return undefined
- }
- if (remoteControl.lastError) {
- return remoteControl.lastError
- }
- if (remoteControl.lastClose?.reason) {
- return translate(
- 'auto.components.status.bar.SshStatusSegment.runtime_last_close_reason',
- 'Closed: {{value0}}',
- { value0: remoteControl.lastClose.reason }
- )
- }
- if (remoteControl.state === 'reconnecting') {
- return translate(
- 'auto.components.status.bar.SshStatusSegment.runtime_reconnect_attempt',
- 'Attempt {{value0}}',
- { value0: String(remoteControl.reconnectAttempt + 1) }
- )
- }
- // Why: pending-request / subscription counts are internal RPC plumbing (e.g. a
- // live browser screencast shows as "N streams"). They're noise in a user-facing
- // status row and make the line truncate — only surface actionable detail
- // (errors, close reasons, reconnect attempts) above.
- return undefined
-}
-
-export function runtimeStatusForOverall(state: RuntimeHostConnectionState): HostStatus {
- switch (state) {
- case 'connected':
- return 'connected'
- case 'checking':
- case 'reconnecting':
- return 'connecting'
- case 'disconnected':
- return 'disconnected'
- }
-}
-
-export function isConnectedRuntimeHostState(state: RuntimeHostConnectionState): boolean {
- return state === 'connected'
-}
+import {
+ overallDotColor,
+ overallStatus,
+ runtimeHostConnectionDetail,
+ sshStatusForOverall
+} from './remote-host-connection-status'
+import {
+ isConnectedRuntimeHostState,
+ runtimeHostConnectionState,
+ runtimeStatusForOverall
+} from '@/runtime/runtime-host-connection-state'
export async function connectRuntimeHostForNavigation(args: {
environmentId: string
@@ -205,8 +96,8 @@ export function SshStatusSegment({
return {
id: environment.id,
label: override || environment.name || environment.id,
- hasStatus: Boolean(statusEntry),
- online: Boolean(statusEntry?.status),
+ hasStatusEntry: Boolean(statusEntry),
+ status: statusEntry?.status ?? null,
active: settings?.activeRuntimeEnvironmentId === environment.id,
remoteControl: statusEntry?.status?.remoteControl ?? null
}
diff --git a/src/renderer/src/components/status-bar/remote-host-connection-status.ts b/src/renderer/src/components/status-bar/remote-host-connection-status.ts
new file mode 100644
index 00000000000..1694dbd596c
--- /dev/null
+++ b/src/renderer/src/components/status-bar/remote-host-connection-status.ts
@@ -0,0 +1,76 @@
+import { translate } from '@/i18n/i18n'
+import type { SshConnectionStatus } from '../../../../shared/ssh-types'
+import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types'
+import type { HostStatus } from '@/runtime/runtime-host-connection-state'
+import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
+
+export function overallStatus(
+ statuses: HostStatus[]
+): 'connected' | 'partial' | 'disconnected' | 'connecting' {
+ if (statuses.length === 0) {
+ return 'disconnected'
+ }
+ if (statuses.every((s) => s === 'connected')) {
+ return 'connected'
+ }
+ if (statuses.some((s) => s === 'connecting')) {
+ return 'connecting'
+ }
+ if (statuses.some((s) => s === 'connected')) {
+ return 'partial'
+ }
+ return 'disconnected'
+}
+
+export function overallDotColor(
+ status: 'connected' | 'partial' | 'disconnected' | 'connecting',
+ connectedCount: number
+): string {
+ switch (status) {
+ case 'connected':
+ return 'bg-emerald-500'
+ case 'partial':
+ return connectedCount > 0 ? 'bg-emerald-500' : 'bg-muted-foreground/40'
+ case 'connecting':
+ return 'bg-yellow-500'
+ case 'disconnected':
+ return 'bg-muted-foreground/40'
+ }
+}
+
+export function sshStatusForOverall(status: SshConnectionStatus): HostStatus {
+ if (status === 'connected') {
+ return 'connected'
+ }
+ return isConnectingSshStatus(status) ? 'connecting' : 'disconnected'
+}
+
+export function runtimeHostConnectionDetail(
+ remoteControl?: RemoteRuntimeSharedConnectionDiagnostics | null
+): string | undefined {
+ if (!remoteControl) {
+ return undefined
+ }
+ if (remoteControl.lastError) {
+ return remoteControl.lastError
+ }
+ if (remoteControl.lastClose?.reason) {
+ return translate(
+ 'auto.components.status.bar.SshStatusSegment.runtime_last_close_reason',
+ 'Closed: {{value0}}',
+ { value0: remoteControl.lastClose.reason }
+ )
+ }
+ if (remoteControl.state === 'reconnecting') {
+ return translate(
+ 'auto.components.status.bar.SshStatusSegment.runtime_reconnect_attempt',
+ 'Attempt {{value0}}',
+ { value0: String(remoteControl.reconnectAttempt + 1) }
+ )
+ }
+ // Why: pending-request / subscription counts are internal RPC plumbing (e.g. a
+ // live browser screencast shows as "N streams"). They're noise in a user-facing
+ // status row and make the line truncate — only surface actionable detail
+ // (errors, close reasons, reconnect attempts) above.
+ return undefined
+}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 9c854c238ba..2cf7f05ee1b 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -3279,6 +3279,7 @@
"runtime_reconnecting": "Reconnecting",
"runtime_last_close_reason": "Closed: {{value0}}",
"runtime_reconnect_attempt": "Attempt {{value0}}",
+ "runtime_workspace_window_closed": "Workspace window closed",
"connectedHostCount_one": "{{count}} host",
"connectedHostCount_other": "{{count}} hosts",
"connecting": "Connecting…",
@@ -6860,7 +6861,9 @@
"projectRuntimeDescription": "Choose whether this project runs on Windows or WSL.",
"hostStateDisconnected": "Disconnected",
"hostStateUnknown": "Unknown",
- "nestedHostLabel": "{{value0}} via {{value1}}"
+ "nestedHostLabel": "{{value0}} via {{value1}}",
+ "hostStateWorkspaceWindowClosed": "Workspace window closed",
+ "hostWorkspaceWindowClosedHelp": "The server is reachable but its Orca window is closed. Open Orca on {{value0}} to use this setup."
},
"RepositorySourceControlAiActionRows": {
"548a6e1281": "Command template",
diff --git a/src/renderer/src/runtime/runtime-host-connection-state.test.ts b/src/renderer/src/runtime/runtime-host-connection-state.test.ts
new file mode 100644
index 00000000000..17acb2395bb
--- /dev/null
+++ b/src/renderer/src/runtime/runtime-host-connection-state.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, it } from 'vitest'
+import type { RuntimeStatus } from '../../../shared/runtime-types'
+import {
+ isConnectedRuntimeHostState,
+ runtimeHostConnectionState,
+ runtimeStatusForOverall
+} from './runtime-host-connection-state'
+
+function makeStatus(overrides: Partial = {}): RuntimeStatus {
+ return {
+ runtimeId: 'runtime-hub',
+ rendererGraphEpoch: 1,
+ graphStatus: 'ready',
+ authoritativeWindowId: 1,
+ desktopWindowStatus: 'available',
+ liveTabCount: 0,
+ liveLeafCount: 0,
+ ...overrides
+ }
+}
+
+const windowClosedStatus = makeStatus({
+ graphStatus: 'unavailable',
+ authoritativeWindowId: null,
+ desktopWindowStatus: 'openable'
+})
+
+describe('runtime host connection state', () => {
+ it('counts connected remote servers as connected hosts', () => {
+ // Why: "connected" = attached/reachable (active-agnostic), matching Settings.
+ // There is no separate "available" state — a reachable host is just Connected.
+ expect(runtimeStatusForOverall('connected')).toBe('connected')
+ expect(isConnectedRuntimeHostState('connected')).toBe(true)
+ })
+
+ it('keeps reconnecting and disconnected remote servers out of the connected count', () => {
+ expect(runtimeStatusForOverall('reconnecting')).toBe('connecting')
+ expect(runtimeStatusForOverall('disconnected')).toBe('disconnected')
+ expect(isConnectedRuntimeHostState('reconnecting')).toBe(false)
+ expect(isConnectedRuntimeHostState('disconnected')).toBe(false)
+ })
+
+ it('still counts a workspace-window-closed remote server as a connected host', () => {
+ // Why: the transport is healthy, so demoting it to disconnected would be a lie
+ // in the other direction — only the wording changes (#12350).
+ expect(runtimeStatusForOverall('workspace-window-closed')).toBe('connected')
+ expect(isConnectedRuntimeHostState('workspace-window-closed')).toBe(true)
+ })
+
+ it('distinguishes a reachable host whose workspace window is closed', () => {
+ expect(runtimeHostConnectionState({ hasStatusEntry: true, status: windowClosedStatus })).toBe(
+ 'workspace-window-closed'
+ )
+ expect(runtimeHostConnectionState({ hasStatusEntry: true, status: makeStatus() })).toBe(
+ 'connected'
+ )
+ })
+
+ it('keeps transport failures ahead of a closed workspace window', () => {
+ expect(runtimeHostConnectionState({ hasStatusEntry: false, status: null })).toBe('checking')
+ expect(runtimeHostConnectionState({ hasStatusEntry: true, status: null })).toBe('disconnected')
+ expect(
+ runtimeHostConnectionState({
+ hasStatusEntry: true,
+ status: {
+ ...windowClosedStatus,
+ remoteControl: {
+ state: 'reconnecting',
+ pendingRequestCount: 0,
+ subscriptionCount: 0,
+ reconnectAttempt: 0,
+ lastConnectedAt: null,
+ lastClose: null,
+ lastError: null
+ }
+ }
+ })
+ ).toBe('reconnecting')
+ })
+
+ it('does not hide a closed control channel behind workspace-window diagnostics', () => {
+ expect(
+ runtimeHostConnectionState({
+ hasStatusEntry: true,
+ status: {
+ ...windowClosedStatus,
+ remoteControl: {
+ state: 'closed',
+ pendingRequestCount: 0,
+ subscriptionCount: 0,
+ reconnectAttempt: 0,
+ lastConnectedAt: null,
+ lastClose: null,
+ lastError: 'Connection closed'
+ }
+ }
+ })
+ ).toBe('disconnected')
+ })
+})
diff --git a/src/renderer/src/runtime/runtime-host-connection-state.ts b/src/renderer/src/runtime/runtime-host-connection-state.ts
new file mode 100644
index 00000000000..6b3f97bd778
--- /dev/null
+++ b/src/renderer/src/runtime/runtime-host-connection-state.ts
@@ -0,0 +1,66 @@
+import type { RuntimeStatus } from '../../../shared/runtime-types'
+import { isRuntimeWorkspaceWindowClosed } from '../../../shared/runtime-workspace-window-availability'
+
+export type HostStatus = 'connected' | 'disconnected' | 'connecting'
+
+// Why: 'workspace-window-closed' is a reachable host that cannot serve graph-backed
+// work — connected for counting purposes, but not interchangeable with 'connected'.
+export type RuntimeHostConnectionState =
+ | 'connected'
+ | 'workspace-window-closed'
+ | 'checking'
+ | 'reconnecting'
+ | 'disconnected'
+
+// Why: one derivation for every host surface (status bar + Settings > Available Hosts),
+// so a degraded host can never read "Connected" in one place and "Ready" in the other.
+export function runtimeHostConnectionState({
+ hasStatusEntry,
+ status
+}: {
+ hasStatusEntry: boolean
+ status: RuntimeStatus | null | undefined
+}): RuntimeHostConnectionState {
+ if (!hasStatusEntry) {
+ return 'checking'
+ }
+ const remoteControl = status?.remoteControl
+ if (remoteControl?.state === 'reconnecting') {
+ return 'reconnecting'
+ }
+ if (!status) {
+ return 'disconnected'
+ }
+ if (remoteControl?.state === 'closed' && remoteControl.lastError) {
+ return 'disconnected'
+ }
+ // Why: reachable but graph-less — the transport is fine, so this is not a network
+ // disconnect, but calling it "Connected" hides that nothing will run there.
+ if (isRuntimeWorkspaceWindowClosed(status)) {
+ return 'workspace-window-closed'
+ }
+ // Why: "connected" means attached/reachable, NOT "is the active default host".
+ // Both surfaces must agree on that single definition, or a reachable-but-not-active
+ // host reads "Connected" in one place and "Available" in the other. Active/default is
+ // a separate concept (surfaced elsewhere), so it must not change this state.
+ return 'connected'
+}
+
+export function runtimeStatusForOverall(state: RuntimeHostConnectionState): HostStatus {
+ switch (state) {
+ // Why: a closed workspace window is a degraded host, not a lost connection —
+ // it must keep counting toward the connected-host total.
+ case 'connected':
+ case 'workspace-window-closed':
+ return 'connected'
+ case 'checking':
+ case 'reconnecting':
+ return 'connecting'
+ case 'disconnected':
+ return 'disconnected'
+ }
+}
+
+export function isConnectedRuntimeHostState(state: RuntimeHostConnectionState): boolean {
+ return state === 'connected' || state === 'workspace-window-closed'
+}
diff --git a/src/shared/runtime-workspace-window-availability.test.ts b/src/shared/runtime-workspace-window-availability.test.ts
new file mode 100644
index 00000000000..9eb37db77da
--- /dev/null
+++ b/src/shared/runtime-workspace-window-availability.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from 'vitest'
+import type { RuntimeStatus } from './runtime-types'
+import { isRuntimeWorkspaceWindowClosed } from './runtime-workspace-window-availability'
+
+function makeStatus(overrides: Partial): RuntimeStatus {
+ return {
+ runtimeId: 'runtime-hub',
+ rendererGraphEpoch: 1,
+ graphStatus: 'ready',
+ authoritativeWindowId: 1,
+ liveTabCount: 0,
+ liveLeafCount: 0,
+ ...overrides
+ }
+}
+
+describe('isRuntimeWorkspaceWindowClosed', () => {
+ it('flags a reachable host whose graph is gone but whose desktop window can be opened', () => {
+ expect(
+ isRuntimeWorkspaceWindowClosed(
+ makeStatus({
+ graphStatus: 'unavailable',
+ authoritativeWindowId: null,
+ desktopWindowStatus: 'openable'
+ })
+ )
+ ).toBe(true)
+ })
+
+ it('leaves graph-ready headless servers alone', () => {
+ // Why: #6844 headless serve owns a graph without a desktop window — it must
+ // never read as degraded just because a window could be opened.
+ expect(
+ isRuntimeWorkspaceWindowClosed(
+ makeStatus({ graphStatus: 'ready', desktopWindowStatus: 'openable' })
+ )
+ ).toBe(false)
+ })
+
+ it('ignores a renderer reload that still owns its window', () => {
+ expect(
+ isRuntimeWorkspaceWindowClosed(
+ makeStatus({ graphStatus: 'reloading', desktopWindowStatus: 'available' })
+ )
+ ).toBe(false)
+ })
+
+ it('flags a reload whose window disappeared before the graph was marked unavailable', () => {
+ // Why: 'openable' already means no live renderer window, so a mid-reload window
+ // teardown is just as unusable as 'unavailable'.
+ expect(
+ isRuntimeWorkspaceWindowClosed(
+ makeStatus({ graphStatus: 'reloading', desktopWindowStatus: 'openable' })
+ )
+ ).toBe(true)
+ })
+
+ it('treats a missing desktop window claim as no claim', () => {
+ expect(isRuntimeWorkspaceWindowClosed(makeStatus({ graphStatus: 'unavailable' }))).toBe(false)
+ expect(
+ isRuntimeWorkspaceWindowClosed(
+ makeStatus({ graphStatus: 'unavailable', desktopWindowStatus: 'initializing' })
+ )
+ ).toBe(false)
+ expect(
+ isRuntimeWorkspaceWindowClosed(
+ makeStatus({ graphStatus: 'unavailable', desktopWindowStatus: 'blocked' })
+ )
+ ).toBe(false)
+ })
+
+ it('is not a substitute for an unreachable host', () => {
+ expect(isRuntimeWorkspaceWindowClosed(null)).toBe(false)
+ expect(isRuntimeWorkspaceWindowClosed(undefined)).toBe(false)
+ })
+})
diff --git a/src/shared/runtime-workspace-window-availability.ts b/src/shared/runtime-workspace-window-availability.ts
new file mode 100644
index 00000000000..1a40903e768
--- /dev/null
+++ b/src/shared/runtime-workspace-window-availability.ts
@@ -0,0 +1,14 @@
+import type { RuntimeStatus } from './runtime-types'
+
+// Why: a headed Orca server keeps answering status.get after its workspace window
+// closes, so "a status came back" is not evidence that graph-backed workspace work
+// will succeed there. desktopWindowStatus is only 'openable' when no live renderer
+// window exists (orca-runtime.ts:4806), so pairing it with a non-ready graph is the
+// narrow "reachable, but nothing can run" signal: a graph-ready headless serve
+// (#6844) and hosts that omit desktopWindowStatus (older builds) stay untouched.
+export function isRuntimeWorkspaceWindowClosed(status: RuntimeStatus | null | undefined): boolean {
+ if (!status) {
+ return false
+ }
+ return status.graphStatus !== 'ready' && status.desktopWindowStatus === 'openable'
+}