diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index ab32ba398f7..43c84abb456 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -154,7 +154,8 @@ function getComposerPrefetchRepoId( return resolveComposerGitRepoId({ eligibleRepos: getComposerEligibleRepos(state.repos), initialRepoId, - activeRepoId: state.activeRepoId + activeRepoId: state.activeRepoId, + focusedHostScope: state.workspaceHostScope }) } diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index 572f121b5c6..3e02eaaa642 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -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( diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index 9b20a5845ae..3e483a3dc9c 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -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(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 } diff --git a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx index 89056fa4ef2..5a1c5707b6b 100644 --- a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx +++ b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx @@ -129,7 +129,13 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS selector: parsed.environmentId, timeoutMs: 10_000 }) - unwrapRuntimeRpcResult(response) + const runtimeStatus = unwrapRuntimeRpcResult(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 diff --git a/src/renderer/src/components/sidebar/SidebarHostScopeStrip.tsx b/src/renderer/src/components/sidebar/SidebarHostScopeStrip.tsx index 2ebf7c0da5f..bdbe9500b41 100644 --- a/src/renderer/src/components/sidebar/SidebarHostScopeStrip.tsx +++ b/src/renderer/src/components/sidebar/SidebarHostScopeStrip.tsx @@ -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 diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index 8d54cc0b076..45080632394 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -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) => { diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 94e0db19bb4..facdd6afee4 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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 (
- {isBlocked - ? translate('auto.components.sidebar.WorktreeList.7a8b9c0d1e', 'Update required') - : row.detail} + {detail.text}
@@ -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( () => diff --git a/src/renderer/src/components/sidebar/host-section-rows.test.ts b/src/renderer/src/components/sidebar/host-section-rows.test.ts index 2d2d909e2fb..e8b0cd555d2 100644 --- a/src/renderer/src/components/sidebar/host-section-rows.test.ts +++ b/src/renderer/src/components/sidebar/host-section-rows.test.ts @@ -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)] diff --git a/src/renderer/src/components/sidebar/host-section-rows.ts b/src/renderer/src/components/sidebar/host-section-rows.ts index d6dcc3049f4..e26744cdee7 100644 --- a/src/renderer/src/components/sidebar/host-section-rows.ts +++ b/src/renderer/src/components/sidebar/host-section-rows.ts @@ -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) diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts index 5f7d124f778..011457cfa22 100644 --- a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts +++ b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts @@ -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' }], diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.ts b/src/renderer/src/components/sidebar/sidebar-host-options.ts index a070b86317a..b7fa4bc8a2b 100644 --- a/src/renderer/src/components/sidebar/sidebar-host-options.ts +++ b/src/renderer/src/components/sidebar/sidebar-host-options.ts @@ -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 sshConnectionStates?: ReadonlyMap settings: Pick | 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 }) } diff --git a/src/renderer/src/components/sidebar/use-sidebar-host-scope-options.ts b/src/renderer/src/components/sidebar/use-sidebar-host-scope-options.ts new file mode 100644 index 00000000000..354afa2fbd8 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-sidebar-host-scope-options.ts @@ -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 } +} diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 25885b9a70a..0228ef89938 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -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(resolvedInitialRepoId) diff --git a/src/renderer/src/lib/new-workspace-composer-repo.test.ts b/src/renderer/src/lib/new-workspace-composer-repo.test.ts index 48a49a0bd01..3f1fea13ea1 100644 --- a/src/renderer/src/lib/new-workspace-composer-repo.test.ts +++ b/src/renderer/src/lib/new-workspace-composer-repo.test.ts @@ -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' + ) + }) }) diff --git a/src/renderer/src/lib/new-workspace-composer-repo.ts b/src/renderer/src/lib/new-workspace-composer-repo.ts index 23869a646f5..1c4dd896e8b 100644 --- a/src/renderer/src/lib/new-workspace-composer-repo.ts +++ b/src/renderer/src/lib/new-workspace-composer-repo.ts @@ -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 diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index bdf9c6a32fb..29075289d77 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -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()((...a) => ({ ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), ...createDictationSlice(...a), - ...createWorkspaceCleanupSlice(...a) + ...createWorkspaceCleanupSlice(...a), + ...createRuntimeStatusSlice(...a) })) registerHttpLinkStoreAccessor(() => useAppStore.getState()) diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index f2b4d818c66..a558907b9a6 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -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()((...a) => ({ @@ -167,7 +168,8 @@ function createTestStore() { ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), ...createDictationSlice(...a), - ...createWorkspaceCleanupSlice(...a) + ...createWorkspaceCleanupSlice(...a), + ...createRuntimeStatusSlice(...a) })) } diff --git a/src/renderer/src/store/slices/runtime-status.test.ts b/src/renderer/src/store/slices/runtime-status.test.ts new file mode 100644 index 00000000000..c8b22d6658b --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status.test.ts @@ -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()((...a) => ({ + ...createRuntimeStatusSlice(...(a as unknown as Parameters)) + })) +} + +function makeStatus(overrides: Partial = {}): 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) + }) +}) diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts new file mode 100644 index 00000000000..4fa8d26eed0 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -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 + /** 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) => 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 +} + +export const createRuntimeStatusSlice: StateCreator = ( + 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(response) + get().setRuntimeEnvironmentStatus(environment.id, { status, checkedAt: Date.now() }) + } catch { + get().setRuntimeEnvironmentStatus(environment.id, { + status: null, + checkedAt: Date.now() + }) + } + }) + ) + } +}) diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 4812dcecfca..78e0a3a4f60 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -59,6 +59,10 @@ export const createSettingsSlice: StateCreator 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) } diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index 77b60c8f5e9..0a42bc3fd76 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -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, diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index 19b746a3446..bbb55a50e49 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -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