Live runtime status in sidebar; focused-host creation default; SSH auth-needed label

- New runtime-status slice holds per-environment status (boot hydration,
  settings-pane probes, host-header checks all write through it); stale
  environments dropped on list reload
- buildSidebarHostOptions forwards live status to the host registry at
  all call sites, so compat verdicts and blocked health render in the
  scope strip, options menu, host headers, and browser pane
- New-workspace composer defaults to a repo on the focused host scope
  (explicit draft/initial/active choices still win)
- SSH hosts with expired auth say "Authentication needed" in the host
  header instead of icon-only

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-09 19:39:07 -07:00
co-authored by Orca
parent 3412919373
commit 1384d9f6bf
22 changed files with 512 additions and 47 deletions
@@ -154,7 +154,8 @@ function getComposerPrefetchRepoId(
return resolveComposerGitRepoId({
eligibleRepos: getComposerEligibleRepos(state.repos),
initialRepoId,
activeRepoId: state.activeRepoId
activeRepoId: state.activeRepoId,
focusedHostScope: state.workspaceHostScope
})
}
@@ -55,6 +55,7 @@ export function BrowserPane({
const repos = useAppStore((s) => s.repos)
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId)
const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment)
const detectedBrowsers = useAppStore((s) => s.detectedBrowsers)
const browserSessionImportState = useAppStore((s) => s.browserSessionImportState)
@@ -100,7 +101,13 @@ export function BrowserPane({
const linkRoutingDescription = getBrowserLinkRoutingDescription({ isMac })
const browserSessionHostOptions = useMemo(
() =>
buildSidebarHostOptions({ repos, sshTargetLabels, sshConnectionStates, settings })
buildSidebarHostOptions({
repos,
sshTargetLabels,
sshConnectionStates,
settings,
runtimeStatusByEnvironmentId
})
.filter((host) => host.kind === 'local' || host.kind === 'runtime')
.map((host) => ({
id: host.id,
@@ -113,7 +120,7 @@ export function BrowserPane({
'Browser profiles on this Orca server.'
)
})),
[repos, sshTargetLabels, sshConnectionStates, settings]
[repos, sshTargetLabels, sshConnectionStates, settings, runtimeStatusByEnvironmentId]
)
const selectedBrowserSessionHostId = getSettingsFocusedExecutionHostId(settings)
const selectBrowserSessionHost = useCallback(
@@ -35,6 +35,7 @@ import {
getWebRuntimeEnvironmentsSearchEntry
} from './runtime-environments-search'
import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
const LOCAL_RUNTIME_VALUE = '__local__'
@@ -120,6 +121,11 @@ export function RuntimeEnvironmentsPane({
}
try {
const nextEnvironments = await window.api.runtimeEnvironments.list()
// Why: drop store status for servers no longer saved so stale hosts don't
// linger in the sidebar registry.
useAppStore
.getState()
.retainRuntimeEnvironmentStatuses(nextEnvironments.map((environment) => environment.id))
if (mountedRef.current) {
setEnvironments(nextEnvironments)
setDetailsByEnvironmentId((current) => {
@@ -143,6 +149,12 @@ export function RuntimeEnvironmentsPane({
timeoutMs: 10_000
})
const runtimeStatus = unwrapRuntimeRpcResult<RuntimeStatus>(response)
// Why: feed the live status into the store so sidebar host pickers
// reflect manual refreshes, not just the settings pane.
useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, {
status: runtimeStatus,
checkedAt: Date.now()
})
if (!mountedRef.current) {
return
}
@@ -156,6 +168,12 @@ export function RuntimeEnvironmentsPane({
}
}))
} catch (error) {
// Why: record the failed probe (null status) so the sidebar can
// distinguish unreachable from never-checked.
useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, {
status: null,
checkedAt: Date.now()
})
if (!mountedRef.current) {
return
}
@@ -129,7 +129,13 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS
selector: parsed.environmentId,
timeoutMs: 10_000
})
unwrapRuntimeRpcResult<RuntimeStatus>(response)
const runtimeStatus = unwrapRuntimeRpcResult<RuntimeStatus>(response)
// Why: feed the probe result into the shared store so the host header and
// other host pickers reflect this check without a separate fetch.
useAppStore.getState().setRuntimeEnvironmentStatus(parsed.environmentId, {
status: runtimeStatus,
checkedAt: Date.now()
})
toast.success(
translate(
'auto.components.sidebar.HostSectionHeaderMenu.7f1a2b3c4d',
@@ -140,6 +146,12 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS
)
)
} catch (err) {
// Why: record the failed probe so the host registry can drop a previously
// healthy verdict instead of showing stale "compatible" state.
useAppStore.getState().setRuntimeEnvironmentStatus(parsed.environmentId, {
status: null,
checkedAt: Date.now()
})
toast.error(
err instanceof Error
? err.message
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react'
import React from 'react'
import { AlertTriangle, ChevronDown, Circle, Loader2, Monitor, Server } from 'lucide-react'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
@@ -11,13 +11,12 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
buildSidebarHostOptions,
buildSidebarHostScopeOptions,
getSidebarHostHealthLabel,
getSidebarHostScopeLabel,
shouldShowHostScopeControls,
type SidebarHostScopeOption
} from './sidebar-host-options'
import { useSidebarHostScopeOptions } from './use-sidebar-host-scope-options'
function HostHealthDot({ health }: { health: SidebarHostScopeOption['health'] }) {
if (health === 'connecting') {
@@ -41,18 +40,9 @@ function HostHealthDot({ health }: { health: SidebarHostScopeOption['health'] })
}
const SidebarHostScopeStrip = React.memo(function SidebarHostScopeStrip() {
const repos = useAppStore((s) => s.repos)
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const settings = useAppStore((s) => s.settings)
const workspaceHostScope = useAppStore((s) => s.workspaceHostScope)
const setWorkspaceHostScope = useAppStore((s) => s.setWorkspaceHostScope)
const hostOptions = useMemo(
() => buildSidebarHostOptions({ repos, sshTargetLabels, sshConnectionStates, settings }),
[repos, sshTargetLabels, sshConnectionStates, settings]
)
const hostScopeOptions = useMemo(() => buildSidebarHostScopeOptions(hostOptions), [hostOptions])
const { hostOptions, hostScopeOptions } = useSidebarHostScopeOptions()
if (!shouldShowHostScopeControls(hostOptions)) {
return null
@@ -21,12 +21,8 @@ import type { AgentActivityDisplayMode } from '../../../../shared/types'
import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants'
import SidebarRepositoryFilterSection from './SidebarRepositoryFilterSection'
import SidebarWorkspaceFilterSection from './SidebarWorkspaceFilterSection'
import {
buildSidebarHostOptions,
buildSidebarHostScopeOptions,
getSidebarHostScopeLabel,
shouldShowHostScopeControls
} from './sidebar-host-options'
import { getSidebarHostScopeLabel, shouldShowHostScopeControls } from './sidebar-host-options'
import { useSidebarHostScopeOptions } from './use-sidebar-host-scope-options'
import { SidebarHostScopeMenuSection } from './SidebarHostScopeMenuSection'
import {
AGENT_ACTIVITY_DISPLAY_OPTIONS,
@@ -55,8 +51,6 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
const toggleWorktreeCardProperty = useAppStore((s) => s.toggleWorktreeCardProperty)
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const workspaceHostScope = useAppStore((s) => s.workspaceHostScope)
const setWorkspaceHostScope = useAppStore((s) => s.setWorkspaceHostScope)
const agentActivityDisplayMode = useAppStore((s) => s.agentActivityDisplayMode)
@@ -69,12 +63,8 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
const setProjectOrderBy = useAppStore((s) => s.setProjectOrderBy)
const [open, setOpen] = useState(false)
const hostOptions = useMemo(
() => buildSidebarHostOptions({ repos, sshTargetLabels, sshConnectionStates, settings }),
[repos, sshTargetLabels, sshConnectionStates, settings]
)
const { hostOptions, hostScopeOptions } = useSidebarHostScopeOptions()
const showHostScopeControls = shouldShowHostScopeControls(hostOptions)
const hostScopeOptions = useMemo(() => buildSidebarHostScopeOptions(hostOptions), [hostOptions])
const handleOpenChange = useCallback(
(next: boolean) => {
@@ -505,10 +505,32 @@ function HostHeaderHealthIcon({ health }: { health: HostHeaderRow['health'] }):
)
}
function HostSectionHeader({ row }: { row: HostHeaderRow }): React.JSX.Element {
function getHostHeaderDetail(row: HostHeaderRow): { text: string; isWarning: boolean } {
// Why: a blocked compatibility verdict gets a compact warning treatment so one
// skewed host stands out without altering how its siblings render.
if (row.health === 'blocked') {
return {
text: translate('auto.components.sidebar.WorktreeList.7a8b9c0d1e', 'Update required'),
isWarning: true
}
}
// Why: auth-expired SSH hosts must say so in words — the plan requires a clear
// auth-needed status, and the health icon alone doesn't explain the fix.
if (row.connectionStatus === 'auth-failed') {
return {
text: translate(
'auto.components.sidebar.WorktreeList.hostAuthNeeded',
'Authentication needed'
),
isWarning: true
}
}
return { text: row.detail, isWarning: false }
}
function HostSectionHeader({ row }: { row: HostHeaderRow }): React.JSX.Element {
const isBlocked = row.health === 'blocked'
const detail = getHostHeaderDetail(row)
return (
<div className="px-2 pt-1">
<div
@@ -528,12 +550,10 @@ function HostSectionHeader({ row }: { row: HostHeaderRow }): React.JSX.Element {
<div
className={cn(
'mt-0.5 truncate text-[10px] leading-none',
isBlocked ? 'text-destructive' : 'text-muted-foreground'
detail.isWarning ? 'text-destructive' : 'text-muted-foreground'
)}
>
{isBlocked
? translate('auto.components.sidebar.WorktreeList.7a8b9c0d1e', 'Update required')
: row.detail}
{detail.text}
</div>
</div>
<SectionMetricsBadge count={row.count} />
@@ -3963,6 +3983,7 @@ const WorktreeList = React.memo(function WorktreeList({
const settings = useAppStore((s) => s.settings)
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId)
const sortEpoch = useAppStore((s) => s.sortEpoch)
@@ -4406,8 +4427,15 @@ const WorktreeList = React.memo(function WorktreeList({
)
const defaultHostId = getSettingsFocusedExecutionHostId(settings)
const hostOptions = useMemo(
() => buildSidebarHostOptions({ repos, sshTargetLabels, sshConnectionStates, settings }),
[repos, sshTargetLabels, sshConnectionStates, settings]
() =>
buildSidebarHostOptions({
repos,
sshTargetLabels,
sshConnectionStates,
settings,
runtimeStatusByEnvironmentId
}),
[repos, sshTargetLabels, sshConnectionStates, settings, runtimeStatusByEnvironmentId]
)
const sectionRows = useMemo(
() =>
@@ -232,6 +232,41 @@ describe('addHostSectionRows', () => {
])
})
it('carries the SSH connection status through to the host header row', () => {
const ssh = repo('ssh', 'ssh-1')
const rows = [repoHeader(ssh), item('ssh-wt', ssh)]
const sectioned = addHostSectionRows({
rows,
hostOptions: [
{
id: 'local',
kind: 'local',
label: 'Local Mac',
detail: 'This computer',
health: 'local'
},
{
id: 'ssh:ssh-1',
kind: 'ssh',
label: 'Builder',
detail: 'SSH',
health: 'error',
connectionStatus: 'auth-failed'
}
],
workspaceHostScope: 'all',
defaultHostId: 'local'
})
expect(sectioned[0]).toMatchObject({
type: 'host-header',
hostId: 'ssh:ssh-1',
health: 'error',
connectionStatus: 'auth-failed'
})
})
it('uses the focused runtime as the owner for non-SSH repos', () => {
const project = repo('runtime-project')
const rows = [repoHeader(project), item('runtime-wt', project)]
@@ -8,6 +8,7 @@ import {
} from '../../../../shared/execution-host'
import type { ExecutionHostHealth } from '../../../../shared/execution-host-registry'
import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
import type { Repo } from '../../../../shared/types'
import type { Row } from './worktree-list-groups'
@@ -22,6 +23,7 @@ export type HostHeaderRow = {
// Why: blocked-host guidance in the header menu needs the verdict reason so
// it can deep-link an "Update server/client required" row per skew direction.
compatibility?: RuntimeCompatVerdict
connectionStatus?: SshConnectionStatus
count: number
}
@@ -34,6 +36,7 @@ export type HostSectionOption = {
detail: string
health: ExecutionHostHealth
compatibility?: RuntimeCompatVerdict
connectionStatus?: SshConnectionStatus
}
function getRepoHostId(
@@ -145,6 +148,7 @@ export function addHostSectionRows(args: {
detail: host.detail,
health: host.health,
compatibility: host.compatibility,
connectionStatus: host.connectionStatus,
count: countWorktreeRows(hostRows)
})
result.push(...hostRows)
@@ -76,6 +76,70 @@ describe('sidebar host options', () => {
})
})
it('marks a runtime host blocked when its live status fails compat', () => {
const hosts = buildSidebarHostOptions({
repos: [],
sshTargetLabels: new Map(),
settings: { activeRuntimeEnvironmentId: 'runtime-1' },
// Why: protocol 0 is below the minimum compatible server version, so the
// registry must surface a 'server-too-old' blocked verdict + health when
// the live status map is passed.
runtimeStatusByEnvironmentId: new Map([
[
'runtime-1',
{
status: {
runtimeId: 'rt',
rendererGraphEpoch: 0,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion: 0,
minCompatibleRuntimeClientVersion: 0
}
}
]
])
})
const runtimeHost = hosts.find((host) => host.id === 'runtime:runtime-1')
expect(runtimeHost?.health).toBe('blocked')
expect(runtimeHost?.compatibility).toMatchObject({
kind: 'blocked',
reason: 'server-too-old'
})
})
it('leaves a runtime host available when its live status is compatible', () => {
const hosts = buildSidebarHostOptions({
repos: [],
sshTargetLabels: new Map(),
settings: { activeRuntimeEnvironmentId: 'runtime-1' },
runtimeStatusByEnvironmentId: new Map([
[
'runtime-1',
{
status: {
runtimeId: 'rt',
rendererGraphEpoch: 0,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 3
}
}
]
])
})
const runtimeHost = hosts.find((host) => host.id === 'runtime:runtime-1')
expect(runtimeHost?.health).toBe('available')
expect(runtimeHost?.compatibility?.kind).toBe('ok')
})
it('builds all-host plus focused-host scope options', () => {
const hosts = buildSidebarHostOptions({
repos: [{ connectionId: 'ssh-1' }],
@@ -9,7 +9,8 @@ import {
type ExecutionHostHealth
} from '../../../../shared/execution-host-registry'
import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat'
import type { SshConnectionState } from '../../../../shared/ssh-types'
import type { SshConnectionState, SshConnectionStatus } from '../../../../shared/ssh-types'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import { translate } from '@/i18n/i18n'
export type SidebarHostOption = {
@@ -20,6 +21,8 @@ export type SidebarHostOption = {
health: ExecutionHostHealth
// Why: surfaced to the sidebar host-header menu so it can warn on version skew.
compatibility?: RuntimeCompatVerdict
// Why: lets host headers spell out auth-needed SSH states, not just an icon.
connectionStatus?: SshConnectionStatus
}
export type SidebarHostScopeOption = {
@@ -34,12 +37,19 @@ export function buildSidebarHostOptions(args: {
sshTargetLabels: ReadonlyMap<string, string>
sshConnectionStates?: ReadonlyMap<string, SshConnectionState>
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
// Why: live per-environment runtime status lets the registry surface compat
// verdicts and blocked health in the sidebar without re-probing servers.
runtimeStatusByEnvironmentId?: ReadonlyMap<
string,
{ status?: RuntimeStatus | null; appVersion?: string | null }
>
}): SidebarHostOption[] {
return buildExecutionHostRegistry({
repos: args.repos,
settings: args.settings,
sshTargetLabels: args.sshTargetLabels,
sshConnectionStates: args.sshConnectionStates
sshConnectionStates: args.sshConnectionStates,
runtimeStatusByEnvironmentId: args.runtimeStatusByEnvironmentId
})
}
@@ -0,0 +1,37 @@
import { useMemo } from 'react'
import { useAppStore } from '@/store'
import {
buildSidebarHostOptions,
buildSidebarHostScopeOptions,
type SidebarHostOption,
type SidebarHostScopeOption
} from './sidebar-host-options'
/** Shared host-scope derivation for the sidebar scope strip and the workspace
* options menu so both surfaces consume the same live runtime status without
* duplicating store wiring. */
export function useSidebarHostScopeOptions(): {
hostOptions: SidebarHostOption[]
hostScopeOptions: SidebarHostScopeOption[]
} {
const repos = useAppStore((s) => s.repos)
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const settings = useAppStore((s) => s.settings)
const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId)
const hostOptions = useMemo(
() =>
buildSidebarHostOptions({
repos,
sshTargetLabels,
sshConnectionStates,
settings,
runtimeStatusByEnvironmentId
}),
[repos, sshTargetLabels, sshConnectionStates, settings, runtimeStatusByEnvironmentId]
)
const hostScopeOptions = useMemo(() => buildSidebarHostScopeOptions(hostOptions), [hostOptions])
return { hostOptions, hostScopeOptions }
}
+3 -1
View File
@@ -318,6 +318,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration)
const workspaceHostScope = useAppStore((s) => s.workspaceHostScope)
const eligibleRepos = useMemo(() => getComposerEligibleRepos(repos), [repos])
const draftRepoId = persistDraft ? (newWorkspaceDraft?.repoId ?? null) : null
const resolvedInitialWorkspaceStatus = useMemo(
@@ -332,7 +333,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
eligibleRepos,
draftRepoId,
initialRepoId,
activeRepoId
activeRepoId,
focusedHostScope: workspaceHostScope
})
const [internalRepoId, setInternalRepoId] = useState<string>(resolvedInitialRepoId)
@@ -54,4 +54,46 @@ describe('new-workspace-composer-repo', () => {
getComposerEligibleRepos([makeRepo('missing-path', { path: '' }), makeRepo('repo')])
).toEqual([expect.objectContaining({ id: 'repo' })])
})
it('defaults to a repo on the focused host when no explicit repo is chosen', () => {
const eligibleRepos = [
makeRepo('local-repo'),
makeRepo('ssh-repo', { connectionId: 'win-vm' }),
makeRepo('runtime-repo', { executionHostId: 'runtime:env-1' })
]
expect(resolveComposerRepoId({ eligibleRepos, focusedHostScope: 'ssh:win-vm' })).toBe(
'ssh-repo'
)
expect(resolveComposerRepoId({ eligibleRepos, focusedHostScope: 'runtime:env-1' })).toBe(
'runtime-repo'
)
expect(resolveComposerRepoId({ eligibleRepos, focusedHostScope: 'local' })).toBe('local-repo')
})
it('lets explicit draft/initial/active choices win over the focused host', () => {
const eligibleRepos = [makeRepo('local-repo'), makeRepo('ssh-repo', { connectionId: 'win-vm' })]
expect(
resolveComposerRepoId({
eligibleRepos,
activeRepoId: 'local-repo',
focusedHostScope: 'ssh:win-vm'
})
).toBe('local-repo')
})
it('ignores host scope "all" and falls back to the first eligible repo', () => {
const eligibleRepos = [makeRepo('local-repo'), makeRepo('ssh-repo', { connectionId: 'win-vm' })]
expect(resolveComposerRepoId({ eligibleRepos, focusedHostScope: 'all' })).toBe('local-repo')
})
it('falls back to the first eligible repo when the focused host has no repos', () => {
const eligibleRepos = [makeRepo('local-repo')]
expect(resolveComposerRepoId({ eligibleRepos, focusedHostScope: 'ssh:gone' })).toBe(
'local-repo'
)
})
})
@@ -1,3 +1,8 @@
import {
ALL_EXECUTION_HOSTS_SCOPE,
getRepoExecutionHostId,
type ExecutionHostScope
} from '../../../shared/execution-host'
import { isGitRepoKind } from '../../../shared/repo-kind'
import type { Repo } from '../../../shared/types'
@@ -9,17 +14,28 @@ export function resolveComposerRepoId({
eligibleRepos,
draftRepoId,
initialRepoId,
activeRepoId
activeRepoId,
focusedHostScope
}: {
eligibleRepos: readonly Repo[]
draftRepoId?: string | null
initialRepoId?: string | null
activeRepoId?: string | null
focusedHostScope?: ExecutionHostScope | null
}): string {
// Why: explicit choices (draft/initial/active) win, but the generic fallback
// must honor the focused host scope so "new workspace defaults to the
// focused host" holds for Landing/Cmd+J entry points (multi-host plan).
const focusedHostRepo =
focusedHostScope && focusedHostScope !== ALL_EXECUTION_HOSTS_SCOPE
? eligibleRepos.find((repo) => getRepoExecutionHostId(repo) === focusedHostScope)
: undefined
const resolvedRepo =
(draftRepoId && eligibleRepos.find((repo) => repo.id === draftRepoId)) ||
(initialRepoId && eligibleRepos.find((repo) => repo.id === initialRepoId)) ||
(activeRepoId && eligibleRepos.find((repo) => repo.id === activeRepoId)) ||
focusedHostRepo ||
eligibleRepos[0]
return resolvedRepo?.id ?? ''
@@ -30,6 +46,7 @@ export function resolveComposerGitRepoId(args: {
draftRepoId?: string | null
initialRepoId?: string | null
activeRepoId?: string | null
focusedHostScope?: ExecutionHostScope | null
}): string | null {
const repoId = resolveComposerRepoId(args)
const repo = repoId ? args.eligibleRepos.find((entry) => entry.id === repoId) : null
+3 -1
View File
@@ -29,6 +29,7 @@ import { createDetectedAgentsSlice } from './slices/detected-agents'
import { createWorktreeNavHistorySlice } from './slices/worktree-nav-history'
import { createDictationSlice } from './slices/dictation'
import { createWorkspaceCleanupSlice } from './slices/workspace-cleanup'
import { createRuntimeStatusSlice } from './slices/runtime-status'
import { e2eConfig } from '@/lib/e2e-config'
import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
@@ -61,7 +62,8 @@ export const useAppStore = create<AppState>()((...a) => ({
...createDetectedAgentsSlice(...a),
...createWorktreeNavHistorySlice(...a),
...createDictationSlice(...a),
...createWorkspaceCleanupSlice(...a)
...createWorkspaceCleanupSlice(...a),
...createRuntimeStatusSlice(...a)
}))
registerHttpLinkStoreAccessor(() => useAppStore.getState())
@@ -136,6 +136,7 @@ import { createDetectedAgentsSlice } from './detected-agents'
import { createWorktreeNavHistorySlice } from './worktree-nav-history'
import { createDictationSlice } from './dictation'
import { createWorkspaceCleanupSlice } from './workspace-cleanup'
import { createRuntimeStatusSlice } from './runtime-status'
function createTestStore() {
return create<AppState>()((...a) => ({
@@ -167,7 +168,8 @@ function createTestStore() {
...createDetectedAgentsSlice(...a),
...createWorktreeNavHistorySlice(...a),
...createDictationSlice(...a),
...createWorkspaceCleanupSlice(...a)
...createWorkspaceCleanupSlice(...a),
...createRuntimeStatusSlice(...a)
}))
}
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import { create } from 'zustand'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import { createRuntimeStatusSlice, type RuntimeStatusSlice } from './runtime-status'
function createSliceStore() {
return create<RuntimeStatusSlice>()((...a) => ({
...createRuntimeStatusSlice(...(a as unknown as Parameters<typeof createRuntimeStatusSlice>))
}))
}
function makeStatus(overrides: Partial<RuntimeStatus> = {}): RuntimeStatus {
return {
runtimeId: 'rt',
rendererGraphEpoch: 0,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 3,
...overrides
} as RuntimeStatus
}
describe('runtime-status slice', () => {
it('starts with an empty map', () => {
const store = createSliceStore()
expect(store.getState().runtimeStatusByEnvironmentId.size).toBe(0)
})
it('merges per environment id and produces a new map reference', () => {
const store = createSliceStore()
const before = store.getState().runtimeStatusByEnvironmentId
store.getState().setRuntimeEnvironmentStatus('env-a', {
status: makeStatus(),
checkedAt: 1
})
const afterFirst = store.getState().runtimeStatusByEnvironmentId
expect(afterFirst).not.toBe(before)
expect(afterFirst.get('env-a')?.checkedAt).toBe(1)
store.getState().setRuntimeEnvironmentStatus('env-b', {
status: null,
checkedAt: 2
})
const afterSecond = store.getState().runtimeStatusByEnvironmentId
expect(afterSecond.size).toBe(2)
expect(afterSecond.get('env-a')?.checkedAt).toBe(1)
expect(afterSecond.get('env-b')?.status).toBeNull()
})
it('overwrites the prior entry for the same id', () => {
const store = createSliceStore()
store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 5 })
const map = store.getState().runtimeStatusByEnvironmentId
expect(map.size).toBe(1)
expect(map.get('env-a')).toEqual({ status: null, checkedAt: 5 })
})
it('clears a single environment entry', () => {
const store = createSliceStore()
store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 })
store.getState().setRuntimeEnvironmentStatus('env-b', { status: makeStatus(), checkedAt: 1 })
store.getState().clearRuntimeEnvironmentStatus('env-a')
expect(store.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false)
expect(store.getState().runtimeStatusByEnvironmentId.has('env-b')).toBe(true)
})
it('no-ops clearing an unknown id without creating a new reference', () => {
const store = createSliceStore()
const before = store.getState().runtimeStatusByEnvironmentId
store.getState().clearRuntimeEnvironmentStatus('missing')
expect(store.getState().runtimeStatusByEnvironmentId).toBe(before)
})
it('retains only saved environment ids', () => {
const store = createSliceStore()
store.getState().setRuntimeEnvironmentStatus('keep', { status: makeStatus(), checkedAt: 1 })
store.getState().setRuntimeEnvironmentStatus('drop', { status: makeStatus(), checkedAt: 1 })
store.getState().retainRuntimeEnvironmentStatuses(['keep'])
const map = store.getState().runtimeStatusByEnvironmentId
expect(map.has('keep')).toBe(true)
expect(map.has('drop')).toBe(false)
})
it('no-ops retain when nothing is dropped', () => {
const store = createSliceStore()
store.getState().setRuntimeEnvironmentStatus('keep', { status: makeStatus(), checkedAt: 1 })
const before = store.getState().runtimeStatusByEnvironmentId
store.getState().retainRuntimeEnvironmentStatuses(['keep', 'unrelated'])
expect(store.getState().runtimeStatusByEnvironmentId).toBe(before)
})
})
@@ -0,0 +1,96 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client'
/** Live status for one saved runtime environment, as last observed by the
* renderer. `status === null` records a probe that failed or timed out so the
* sidebar can still distinguish "unknown/unreachable" from "never checked". */
export type RuntimeEnvironmentStatus = {
status: RuntimeStatus | null
appVersion?: string | null
checkedAt: number
}
export type RuntimeStatusSlice = {
/** Keyed by runtime environment id. Fed into buildExecutionHostRegistry so
* compat verdicts/blocked health show live in the sidebar host pickers. */
runtimeStatusByEnvironmentId: Map<string, RuntimeEnvironmentStatus>
/** Merges one environment's status. Replaces the prior entry for that id. */
setRuntimeEnvironmentStatus: (environmentId: string, status: RuntimeEnvironmentStatus) => void
/** Drops a removed environment so stale hosts don't linger in the registry. */
clearRuntimeEnvironmentStatus: (environmentId: string) => void
/** Drops every entry whose id is not in the saved-environments set. */
retainRuntimeEnvironmentStatuses: (environmentIds: Iterable<string>) => void
/** Best-effort: list saved environments and probe each so the sidebar shows
* live health at boot, before the settings pane is ever opened. */
hydrateRuntimeEnvironmentStatuses: () => Promise<void>
}
export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeStatusSlice> = (
set,
get
) => ({
runtimeStatusByEnvironmentId: new Map(),
setRuntimeEnvironmentStatus: (environmentId, status) =>
set((s) => {
const next = new Map(s.runtimeStatusByEnvironmentId)
next.set(environmentId, status)
return { runtimeStatusByEnvironmentId: next }
}),
clearRuntimeEnvironmentStatus: (environmentId) =>
set((s) => {
if (!s.runtimeStatusByEnvironmentId.has(environmentId)) {
return s
}
const next = new Map(s.runtimeStatusByEnvironmentId)
next.delete(environmentId)
return { runtimeStatusByEnvironmentId: next }
}),
retainRuntimeEnvironmentStatuses: (environmentIds) =>
set((s) => {
const keep = new Set(environmentIds)
let changed = false
const next = new Map(s.runtimeStatusByEnvironmentId)
for (const id of next.keys()) {
if (!keep.has(id)) {
next.delete(id)
changed = true
}
}
return changed ? { runtimeStatusByEnvironmentId: next } : s
}),
hydrateRuntimeEnvironmentStatuses: async () => {
let environments: { id: string }[]
try {
environments = await window.api.runtimeEnvironments.list()
} catch (err) {
console.error('Failed to list runtime environments for status hydration:', err)
return
}
get().retainRuntimeEnvironmentStatuses(environments.map((environment) => environment.id))
// Why: fire-and-forget per env; one unreachable server must not block the
// others, and a failure records a null status rather than nothing.
await Promise.allSettled(
environments.map(async (environment) => {
try {
const response = await window.api.runtimeEnvironments.getStatus({
selector: environment.id,
timeoutMs: 10_000
})
const status = unwrapRuntimeRpcResult<RuntimeStatus>(response)
get().setRuntimeEnvironmentStatus(environment.id, { status, checkedAt: Date.now() })
} catch {
get().setRuntimeEnvironmentStatus(environment.id, {
status: null,
checkedAt: Date.now()
})
}
})
)
}
})
@@ -59,6 +59,10 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
try {
const settings = await window.api.settings.get()
set({ settings })
// Why: best-effort boot probe so sidebar host pickers show live runtime
// health before the settings pane is ever opened. Fire-and-forget to keep
// startup off the network round-trips.
void get().hydrateRuntimeEnvironmentStatuses()
} catch (err) {
console.error('Failed to fetch settings:', err)
}
@@ -37,6 +37,7 @@ import { createDetectedAgentsSlice } from './detected-agents'
import { createWorktreeNavHistorySlice } from './worktree-nav-history'
import { createDictationSlice } from './dictation'
import { createWorkspaceCleanupSlice } from './workspace-cleanup'
import { createRuntimeStatusSlice } from './runtime-status'
import { translate } from '@/i18n/i18n'
export const TEST_REPO = {
@@ -77,7 +78,8 @@ export function createTestStore() {
...createDetectedAgentsSlice(...a),
...createWorktreeNavHistorySlice(...a),
...createDictationSlice(...a),
...createWorkspaceCleanupSlice(...a)
...createWorkspaceCleanupSlice(...a),
...createRuntimeStatusSlice(...a)
}))
}
@@ -124,7 +126,7 @@ export function makeTab(
): TerminalTab {
return {
ptyId: null,
title: translate("auto.store.slices.store.test.helpers.b9a8117c33", "Terminal 1"),
title: translate('auto.store.slices.store.test.helpers.b9a8117c33', 'Terminal 1'),
customTitle: null,
color: null,
sortOrder: 0,
@@ -156,7 +158,7 @@ export function makeUnifiedTab(
return {
entityId: overrides.id,
contentType: 'terminal',
label: translate("auto.store.slices.store.test.helpers.b9a8117c33", "Terminal 1"),
label: translate('auto.store.slices.store.test.helpers.b9a8117c33', 'Terminal 1'),
customLabel: null,
color: null,
sortOrder: 0,
+3 -1
View File
@@ -27,6 +27,7 @@ import type { DetectedAgentsSlice } from './slices/detected-agents'
import type { WorktreeNavHistorySlice } from './slices/worktree-nav-history'
import type { DictationSlice } from './slices/dictation'
import type { WorkspaceCleanupSlice } from './slices/workspace-cleanup'
import type { RuntimeStatusSlice } from './slices/runtime-status'
export type AppState = RepoSlice &
SparsePresetsSlice &
@@ -56,4 +57,5 @@ export type AppState = RepoSlice &
DetectedAgentsSlice &
WorktreeNavHistorySlice &
DictationSlice &
WorkspaceCleanupSlice
WorkspaceCleanupSlice &
RuntimeStatusSlice