mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
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:
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user