fix(hosts): route status-bar account switchers and forge preflight by the focused workspace's owner (#27687)

The switchers and the shared gh/glab preflight resolve the focused workspace's
owner row (getActiveWorkspaceOwnerTarget, which has no focus fallback) and fall
back to the default scope host only when nothing proves an owner. The task page
and Automations ignore a preflight whose context key names a paired server.
This commit is contained in:
Neil
2026-10-11 19:27:52 -07:00
committed by GitHub
parent d9a3c84b00
commit cbf129fd28
19 changed files with 289 additions and 73 deletions
+3 -5
View File
@@ -194,17 +194,15 @@
1 src/renderer/src/components/sidebar/worktree-list/rows/ProjectGroupDialogs.tsx
1 src/renderer/src/components/sidebar/worktree-visibility-host-target.ts
1 src/renderer/src/components/skills/SkillsPage.tsx
7 src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx
2 src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx
1 src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx
1 src/renderer/src/components/status-bar/PortsStatusSegment.tsx
1 src/renderer/src/components/status-bar/SshStatusSegment.tsx
1 src/renderer/src/components/status-bar/StatusBarSurface.tsx
1 src/renderer/src/components/status-bar/ports-status-popover-rows.tsx
2 src/renderer/src/components/status-bar/provider-account-sync-key.ts
1 src/renderer/src/components/status-bar/status-bar-claude-accounts.ts
1 src/renderer/src/components/status-bar/status-bar-codex-accounts.ts
1 src/renderer/src/components/status-bar/usage-provider-settings-target.ts
7 src/renderer/src/components/status-bar/use-codex-switcher-controller.ts
2 src/renderer/src/components/status-bar/use-codex-switcher-controller.ts
1 src/renderer/src/components/status-bar/use-status-bar-controller.ts
1 src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts
1 src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts
@@ -337,7 +335,7 @@
1 src/renderer/src/lib/launch-structured-agent-session.ts
1 src/renderer/src/lib/launch-worktree-background-terminals.ts
1 src/renderer/src/lib/linear-issue-url-lookup.ts
2 src/renderer/src/lib/local-preflight-context.ts
1 src/renderer/src/lib/local-preflight-context.ts
3 src/renderer/src/lib/open-agent-message-sender.ts
2 src/renderer/src/lib/provider-runtime-context.ts
2 src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context'
import { isEnvironmentRuntimeContextKey } from '@/lib/provider-runtime-context'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import type { TaskSourceContext } from '../../../../shared/task-source-context'
import type { PreflightStatus } from '../../../../preload/api-types'
@@ -49,6 +50,8 @@ export function useAutomationSourceHostAvailability(
ReadonlyMap<TaskSourceContext['hostId'], RuntimeProviderPreflightStatus>
>(() => new Map())
const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey
// Why: the shared preflight follows the focused host; only a local one answers for desktop-owned repos.
const preflightDescribesDesktop = !isEnvironmentRuntimeContextKey(expectedPreflightContextKey)
const sourceContexts = useMemo(
() =>
@@ -146,7 +149,8 @@ export function useAutomationSourceHostAvailability(
provider: context.provider,
contexts: [context],
preflightStatus,
preflightReady: preflightStatusCurrent && preflightStatusChecked,
preflightReady:
preflightStatusCurrent && preflightStatusChecked && preflightDescribesDesktop,
runtimePreflightStatusByHostId: statusByHostId
})
]
@@ -160,6 +164,7 @@ export function useAutomationSourceHostAvailability(
preflightStatus,
preflightStatusChecked,
preflightStatusCurrent,
preflightDescribesDesktop,
runtimeStatusByEnvironmentId,
statusByHostId
])
@@ -1,5 +1,5 @@
import { ChevronDown, ChevronRight } from 'lucide-react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import {
DropdownMenuItem,
DropdownMenuLabel,
@@ -9,6 +9,8 @@ import { useAppStore } from '../../store'
import type { ClaudeRateLimitAccountsState } from '../../../../shared/managed-account-types'
import type { ProviderRateLimits } from '../../../../shared/rate-limit-types'
import { defaultScopeHost } from '@/lib/default-creation-host'
import { useActiveWorkspaceOwnerTarget } from '@/runtime/use-worktree-runtime-target'
import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target'
import {
fetchProviderAccountsSnapshot,
selectClaudeProviderAccount
@@ -80,23 +82,27 @@ export function ClaudeSwitcherMenu({
const claudeTarget = useAppStore((s) => s.rateLimits.claudeTarget)
const settings = useAppStore((s) => s.settings)
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const hasActiveRuntimeEnvironment = Boolean(settings?.activeRuntimeEnvironmentId?.trim())
// Why: the status bar has no host picker; it shows the default scope host's accounts.
const runtimeTarget = useMemo(() => defaultScopeHost(settings), [settings])
// Why: no host picker; the focused workspace's owner, else the default scope host.
const runtimeTarget = useActiveWorkspaceOwnerTarget(defaultScopeHost(settings))
const activeRuntimeEnvironmentId = runtimeTargetEnvironmentId(runtimeTarget)
const hasActiveRuntimeEnvironment = activeRuntimeEnvironmentId !== null
const providerAccountHostLabel = hasActiveRuntimeEnvironment
? (runtimeEnvironments.find(
(environment) => environment.id === settings?.activeRuntimeEnvironmentId?.trim()
)?.name ??
? (runtimeEnvironments.find((environment) => environment.id === activeRuntimeEnvironmentId)
?.name ??
translate('auto.components.status.bar.StatusBar.remoteServerLabel', 'Remote server'))
: undefined
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
navigator.userAgent.includes('Windows') || hasActiveRuntimeEnvironment,
false,
getWindowsTerminalCapabilityOwnerKey(settings?.activeRuntimeEnvironmentId),
getWindowsTerminalCapabilityOwnerKey(activeRuntimeEnvironmentId),
runtimeTarget
)
const claudeAccountSyncKey = useAppStore((s) => getClaudeAccountSyncKey(s.settings))
const accountState = resolveClaudeStatusAccountState(settings, accounts)
const accountState = resolveClaudeStatusAccountState(
settings,
accounts,
activeRuntimeEnvironmentId
)
useEffect(() => {
mountedRef.current = true
@@ -105,14 +111,9 @@ export function ClaudeSwitcherMenu({
}
}, [])
const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null
// Why: keyed on owner id, not settings identity, so routine settings mutations don't re-run the remote snapshot fetch.
const loadAccounts = useCallback(async () => {
const snapshot = await fetchProviderAccountsSnapshot(
activeRuntimeEnvironmentId
? { kind: 'environment', environmentId: activeRuntimeEnvironmentId }
: { kind: 'local' }
)
const snapshot = await fetchProviderAccountsSnapshot(runtimeTarget)
// Why: a failed Claude half is a substituted empty roster; keep prior state.
if (snapshot.failedProviders?.includes('claude')) {
console.error('Claude account list failed; keeping previous status bar state.')
@@ -121,7 +122,7 @@ export function ClaudeSwitcherMenu({
if (mountedRef.current) {
setAccounts(snapshot.claude)
}
}, [activeRuntimeEnvironmentId])
}, [runtimeTarget])
useEffect(() => {
void loadAccounts().catch((error) => {
@@ -187,11 +187,13 @@ function getClaudeStatusAccountsFromSettings(
// Why: with a Remote Orca Server, local GlobalSettings describe this desktop, not the owner — the server snapshot wins (#7973).
/** `ownerEnvironmentId` is the server owning the shown accounts; `null` is this desktop. */
export function resolveClaudeStatusAccountState(
settings: GlobalSettings | null | undefined,
runtimeState: ClaudeRateLimitAccountsState
runtimeState: ClaudeRateLimitAccountsState,
ownerEnvironmentId: string | null
): ClaudeRateLimitAccountsState {
if (settings?.activeRuntimeEnvironmentId?.trim()) {
if (ownerEnvironmentId) {
return runtimeState
}
return getClaudeStatusAccountsFromSettings(settings, runtimeState) ?? runtimeState
@@ -172,11 +172,13 @@ function getCodexStatusAccountsFromSettings(
}
}
/** `ownerEnvironmentId` is the server owning the shown accounts; `null` is this desktop. */
export function resolveCodexStatusAccountState(
settings: GlobalSettings | null | undefined,
runtimeState: CodexRateLimitAccountsState
runtimeState: CodexRateLimitAccountsState,
ownerEnvironmentId: string | null
): CodexRateLimitAccountsState {
if (settings?.activeRuntimeEnvironmentId?.trim()) {
if (ownerEnvironmentId) {
return runtimeState
}
return getCodexStatusAccountsFromSettings(settings) ?? runtimeState
@@ -197,7 +197,7 @@ describe('status bar runtime switch groups', () => {
activeClaudeManagedAccountId: null,
activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} }
}
const state = resolveClaudeStatusAccountState(settings, runtimeState)
const state = resolveClaudeStatusAccountState(settings, runtimeState, null)
const [host] = buildClaudeStatusSwitchGroups(state, { runtime: 'host', wslDistro: null })
expect(
host.targets.map((target) => [target.label, target.disabled ?? false, target.hint ?? null])
@@ -324,13 +324,12 @@ describe('status bar runtime switch groups', () => {
]
} as GlobalSettings
expect(resolveCodexStatusAccountState(settings, remoteState)).toBe(remoteState)
expect(resolveCodexStatusAccountState(settings, remoteState, 'env-1')).toBe(remoteState)
// Without a remote owner, settings-derived accounts win as before.
expect(
resolveCodexStatusAccountState(
{ ...settings, activeRuntimeEnvironmentId: null },
remoteState
).accounts.map((account) => account.id)
resolveCodexStatusAccountState(settings, remoteState, null).accounts.map(
(account) => account.id
)
).toEqual(['desktop-codex-1'])
})
@@ -357,12 +356,11 @@ describe('status bar runtime switch groups', () => {
]
} as GlobalSettings
expect(resolveClaudeStatusAccountState(settings, remoteState)).toBe(remoteState)
expect(resolveClaudeStatusAccountState(settings, remoteState, 'env-1')).toBe(remoteState)
expect(
resolveClaudeStatusAccountState(
{ ...settings, activeRuntimeEnvironmentId: ' ' },
remoteState
).accounts.map((account) => account.id)
resolveClaudeStatusAccountState(settings, remoteState, null).accounts.map(
(account) => account.id
)
).toEqual(['desktop-claude-1'])
})
})
@@ -1,8 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { CodexRateLimitAccountsState } from '../../../../shared/managed-account-types'
import type { ProviderRateLimits } from '../../../../shared/rate-limit-types'
import { useAppStore } from '../../store'
import { defaultScopeHost } from '@/lib/default-creation-host'
import { useActiveWorkspaceOwnerTarget } from '@/runtime/use-worktree-runtime-target'
import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target'
import {
fetchProviderAccountsSnapshot,
selectCodexProviderAccount
@@ -67,32 +69,31 @@ export function useCodexSwitcherController(codex: ProviderRateLimits) {
const codexTarget = useAppStore((s) => s.rateLimits.codexTarget)
const settings = useAppStore((s) => s.settings)
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const hasActiveRuntimeEnvironment = Boolean(settings?.activeRuntimeEnvironmentId?.trim())
// Why: the status bar has no host picker; it shows the default scope host's accounts.
const runtimeTarget = useMemo(() => defaultScopeHost(settings), [settings])
// Why: no host picker; the focused workspace's owner, else the default scope host.
const runtimeTarget = useActiveWorkspaceOwnerTarget(defaultScopeHost(settings))
const activeRuntimeEnvironmentId = runtimeTargetEnvironmentId(runtimeTarget)
const hasActiveRuntimeEnvironment = activeRuntimeEnvironmentId !== null
const providerAccountHostLabel = hasActiveRuntimeEnvironment
? (runtimeEnvironments.find(
(environment) => environment.id === settings?.activeRuntimeEnvironmentId?.trim()
)?.name ??
? (runtimeEnvironments.find((environment) => environment.id === activeRuntimeEnvironmentId)
?.name ??
translate('auto.components.status.bar.StatusBar.remoteServerLabel', 'Remote server'))
: undefined
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
navigator.userAgent.includes('Windows') || hasActiveRuntimeEnvironment,
false,
getWindowsTerminalCapabilityOwnerKey(settings?.activeRuntimeEnvironmentId),
getWindowsTerminalCapabilityOwnerKey(activeRuntimeEnvironmentId),
runtimeTarget
)
const codexAccountSyncKey = useAppStore((s) => getCodexAccountSyncKey(s.settings))
const accountState = resolveCodexStatusAccountState(settings, accounts)
const accountState = resolveCodexStatusAccountState(
settings,
accounts,
activeRuntimeEnvironmentId
)
const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null
// Why: keyed on owner id, not settings identity, so routine settings mutations don't re-run the remote snapshot fetch.
const loadAccounts = useCallback(async () => {
const snapshot = await fetchProviderAccountsSnapshot(
activeRuntimeEnvironmentId
? { kind: 'environment', environmentId: activeRuntimeEnvironmentId }
: { kind: 'local' }
)
const snapshot = await fetchProviderAccountsSnapshot(runtimeTarget)
// Why: a failed Codex half is a substituted empty roster; keep prior state.
if (snapshot.failedProviders?.includes('codex')) {
console.error('Codex account list failed; keeping previous status bar state.')
@@ -101,7 +102,7 @@ export function useCodexSwitcherController(codex: ProviderRateLimits) {
if (mountedRef.current) {
setAccounts(snapshot.codex)
}
}, [activeRuntimeEnvironmentId])
}, [runtimeTarget])
useEffect(() => {
mountedRef.current = true
@@ -25,6 +25,7 @@ export function useTaskPageSourceAvailabilityPrelude(model: TaskPageRuntimeHosts
linearListInvalidationToken,
providerRuntimeContextKey,
preflightStatusCurrent,
preflightDescribesDesktop,
selectedRepos,
selectedLinearWorkspaceId,
selectedLinearWorkspace,
@@ -58,7 +59,8 @@ export function useTaskPageSourceAvailabilityPrelude(model: TaskPageRuntimeHosts
provider: taskSource,
contexts: taskSourceRepoContexts,
preflightStatus,
preflightReady: preflightStatusCurrent && preflightStatusChecked,
preflightReady:
preflightStatusCurrent && preflightStatusChecked && preflightDescribesDesktop,
runtimePreflightStatusByHostId
})
]
@@ -67,6 +69,7 @@ export function useTaskPageSourceAvailabilityPrelude(model: TaskPageRuntimeHosts
preflightStatus,
preflightStatusChecked,
preflightStatusCurrent,
preflightDescribesDesktop,
runtimePreflightStatusByHostId,
taskSource,
taskSourceRepoContexts
@@ -21,6 +21,7 @@ export function useTaskPageSourceSummary(model: TaskPageSourceAvailabilityPrelud
preflightStatus,
preflightStatusChecked,
preflightStatusCurrent,
preflightDescribesDesktop,
selectedRepos,
selectedLinearWorkspace,
selectedJiraSite,
@@ -50,7 +51,8 @@ export function useTaskPageSourceSummary(model: TaskPageSourceAvailabilityPrelud
provider,
contexts,
preflightStatus,
preflightReady: preflightStatusCurrent && preflightStatusChecked,
preflightReady:
preflightStatusCurrent && preflightStatusChecked && preflightDescribesDesktop,
runtimePreflightStatusByHostId
})
]
@@ -109,6 +111,7 @@ export function useTaskPageSourceSummary(model: TaskPageSourceAvailabilityPrelud
preflightStatus,
preflightStatusChecked,
preflightStatusCurrent,
preflightDescribesDesktop,
runtimePreflightStatusByHostId,
selectedRepos,
sourceOptions
@@ -2,7 +2,10 @@ import { useTranslation } from 'react-i18next'
import { useAppStore } from '@/store'
import { useRepoMap, useAllWorktrees } from '@/store/selectors'
import { localPreflightContextKey, getLocalPreflightContext } from '@/lib/local-preflight-context'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import {
getProviderRuntimeContextKey,
isEnvironmentRuntimeContextKey
} from '@/lib/provider-runtime-context'
import { useRef } from 'react'
import { getScreenSubmitShortcutLabel } from '@/lib/screen-submit-shortcut'
export function useTaskPageStoreBindings() {
@@ -77,6 +80,8 @@ export function useTaskPageStoreBindings() {
const linearStatusCurrent = linearStatusContextKey === providerRuntimeContextKey
const jiraStatusCurrent = jiraStatusContextKey === providerRuntimeContextKey
const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey
// Why: the shared preflight follows the focused host; only a local one answers for desktop-owned repos.
const preflightDescribesDesktop = !isEnvironmentRuntimeContextKey(expectedPreflightContextKey)
const linearStatusReady = linearStatusCurrent && linearStatusChecked
const jiraStatusReady = jiraStatusCurrent && jiraStatusChecked
const linearConnected = linearStatusCurrent && linearStatus.connected
@@ -147,6 +152,7 @@ export function useTaskPageStoreBindings() {
linearStatusCurrent,
jiraStatusCurrent,
preflightStatusCurrent,
preflightDescribesDesktop,
linearStatusReady,
jiraStatusReady,
linearConnected,
@@ -124,6 +124,27 @@ describe('local preflight context', () => {
expect(localPreflightContextKey(context)).toBe(context?.runtimeContextKey)
})
it('keys preflight by the focused worktree host over the default host', () => {
const localFocused: AppState = {
...makeState({
repoPath: '/Users/alice/repo',
worktreePath: '/Users/alice/repo',
repo: { executionHostId: 'local' }
}),
settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }
}
expect(localPreflightContextKey(getLocalPreflightContext(localFocused))).toBe('host')
const remoteFocused = makeState({
repoPath: '/srv/repo',
worktreePath: '/srv/repo',
repo: { executionHostId: 'runtime:runtime-2' }
})
expect(getLocalPreflightContext(remoteFocused)?.runtimeContextKey).toMatch(
/^runtime:runtime-2#\d+$/
)
})
it('uses the project runtime for Windows local preflight WSL paths', () => {
const state = makeState({
repoPath: String.raw`\\wsl.localhost\Ubuntu\home\alice\repo`
@@ -12,8 +12,10 @@ import type { Repo } from '../../../shared/repo-types'
import type { Worktree } from '../../../shared/worktree/types'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import { getIndexedRepoMap } from '@/store/worktree-repo-index'
import { defaultScopeHost } from './default-creation-host'
import { getLocalProjectRuntimeWorkspace } from './local-project-runtime-workspace'
import { getProviderRuntimeContextKey } from './provider-runtime-context'
import { getActiveWorkspaceOwnerTarget } from './resolved-worktree-execution-host'
import { getEnvironmentRuntimeContextKey } from './provider-runtime-context'
import { getRendererAppPlatform } from './renderer-app-platform'
import {
getCachedWindowsTerminalCapabilities,
@@ -62,11 +64,7 @@ export function getLocalProjectExecutionRuntimeContext(
appPlatform: NodeJS.Platform = getRendererAppPlatform(),
wslContext: LocalProjectRuntimeWslContext = {}
): ProjectExecutionRuntimeResolution | undefined {
if (appPlatform !== 'win32') {
return undefined
}
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
if (appPlatform !== 'win32' || worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
return undefined
}
const worktree = getLocalProjectRuntimeWorkspace(state, worktreeId)
@@ -162,8 +160,10 @@ export function getLocalPreflightContext(
appPlatform: NodeJS.Platform = getRendererAppPlatform(),
wslContext: LocalProjectRuntimeWslContext = getCachedLocalProjectRuntimeWslContext()
): LocalPreflightContext {
if (state.settings?.activeRuntimeEnvironmentId?.trim()) {
return { runtimeContextKey: getProviderRuntimeContextKey(state.settings) }
// Why: forge CLIs are checked on the focused workspace's owner, else the default scope host.
const host = getActiveWorkspaceOwnerTarget(state) ?? defaultScopeHost(state.settings)
if (host.kind === 'environment') {
return { runtimeContextKey: getEnvironmentRuntimeContextKey(host.environmentId) }
}
const projectRuntime = getLocalProjectExecutionRuntimeContext(
state,
@@ -3,11 +3,20 @@ import type { GlobalSettings } from '../../../shared/global-settings-types'
export function getProviderRuntimeContextKey(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
): string {
const environmentId = settings?.activeRuntimeEnvironmentId?.trim()
return getEnvironmentRuntimeContextKey(settings?.activeRuntimeEnvironmentId?.trim() || null)
}
/** Context key for an explicit owner; `null` is this app. */
export function getEnvironmentRuntimeContextKey(environmentId: string | null): string {
const baseKey = environmentId ? `runtime:${environmentId}` : 'local'
return `${baseKey}#${providerRuntimeSessionGeneration}`
}
/** Whether a context key from {@link getEnvironmentRuntimeContextKey} names a paired server. */
export function isEnvironmentRuntimeContextKey(key: string | null | undefined): boolean {
return key?.startsWith('runtime:') ?? false
}
export function hasRemoteProviderRuntime(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
): boolean {
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { getResolvedExecutionHostIdForWorktree } from './resolved-worktree-execution-host'
import type { ExecutionHostId } from '../../../shared/execution-host'
import {
getActiveWorkspaceOwnerTarget,
getResolvedExecutionHostIdForWorktree
} from './resolved-worktree-execution-host'
import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner'
describe('getResolvedExecutionHostIdForWorktree', () => {
@@ -78,3 +82,62 @@ describe('getResolvedExecutionHostIdForWorktree', () => {
).toBe('runtime:runtime-1')
})
})
function stateFocusing(
repo: { executionHostId: ExecutionHostId; connectionId?: string },
worktreeHostId?: ExecutionHostId
): WorktreeRuntimeOwnerState {
return {
// The default host must never leak into the owner answer.
settings: { activeRuntimeEnvironmentId: 'env-default' },
repos: [{ id: 'repo-1', ...repo }],
worktreesByRepo: {
'repo-1': [{ id: 'repo-1::/w', repoId: 'repo-1', hostId: worktreeHostId }]
},
activeWorktreeId: 'repo-1::/w'
}
}
describe('getActiveWorkspaceOwnerTarget', () => {
it('has no answer when nothing is focused or the focused row has not landed', () => {
expect(getActiveWorkspaceOwnerTarget({ activeWorktreeId: null })).toBeNull()
expect(
getActiveWorkspaceOwnerTarget({
...stateFocusing({ executionHostId: 'local' }),
worktreesByRepo: {}
})
).toBeNull()
})
it('follows the focused row: a local repo, a server repo, and a worktree host over its repo', () => {
expect(getActiveWorkspaceOwnerTarget(stateFocusing({ executionHostId: 'local' }))).toEqual({
kind: 'local'
})
expect(
getActiveWorkspaceOwnerTarget(stateFocusing({ executionHostId: 'runtime:env-b' }))
).toEqual({ kind: 'environment', environmentId: 'env-b' })
expect(
getActiveWorkspaceOwnerTarget(stateFocusing({ executionHostId: 'local' }, 'runtime:env-c'))
).toEqual({ kind: 'environment', environmentId: 'env-c' })
})
it('has no answer for a direct SSH owner, which has no RPC transport', () => {
expect(
getActiveWorkspaceOwnerTarget(
stateFocusing({ executionHostId: 'ssh:box', connectionId: 'box' })
)
).toBeNull()
})
it('skips owner resolution once the catalog has hydrated with no paired server', () => {
const state = {
...stateFocusing({ executionHostId: 'runtime:env-b' }),
runtimeEnvironments: [],
runtimeEnvironmentCatalogHydrated: true
}
expect(getActiveWorkspaceOwnerTarget(state)).toBeNull()
expect(
getActiveWorkspaceOwnerTarget({ ...state, runtimeEnvironmentCatalogHydrated: false })
).toEqual({ kind: 'environment', environmentId: 'env-b' })
})
})
@@ -15,6 +15,10 @@ import {
findIndexedWorktreeOwnerForHost
} from './worktree-runtime-owner-index'
import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner'
import {
runtimeTargetForExecutionHostId,
type RuntimeClientTarget
} from '@/runtime/runtime-client-target'
function getResolvedFolderHost(
state: WorktreeRuntimeOwnerState,
@@ -95,3 +99,18 @@ export function getResolvedExecutionHostIdForWorktree(
? toSshExecutionHostId(repo.connectionId)
: LOCAL_EXECUTION_HOST_ID
}
/**
* RPC target of the focused workspace's owner row; `null` when nothing is focused, ownership is
* unproven, or the owner has no RPC route (direct SSH). Callers pick their own fallback.
*/
export function getActiveWorkspaceOwnerTarget(
state: WorktreeRuntimeOwnerState
): RuntimeClientTarget | null {
// Why: with no paired server every RPC owner is this app; skip the owner-index build.
if (state.runtimeEnvironmentCatalogHydrated && state.runtimeEnvironments?.length === 0) {
return null
}
const hostId = getResolvedExecutionHostIdForWorktree(state, state.activeWorktreeId)
return hostId ? runtimeTargetForExecutionHostId(hostId) : null
}
@@ -2,8 +2,10 @@ import { useMemo } from 'react'
import { useAppStore } from '@/store'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { getRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target'
import { getActiveWorkspaceOwnerTarget } from '@/lib/resolved-worktree-execution-host'
import {
runtimeTargetForExecutionHostId,
runtimeTargetEnvironmentId,
runtimeTargetForOwnerEnvironment,
type RuntimeClientTarget
} from './runtime-client-target'
@@ -38,3 +40,14 @@ export function useFileOwnerRuntimeTarget(
[ownerKey]
)
}
/** {@link getActiveWorkspaceOwnerTarget}, else `fallback`; stable across renders. */
export function useActiveWorkspaceOwnerTarget(fallback: RuntimeClientTarget): RuntimeClientTarget {
// Why a string: a fresh target object per store write would re-render on every change.
const ownerKey = useAppStore((state) => {
const target = getActiveWorkspaceOwnerTarget(state)
return target ? (runtimeTargetEnvironmentId(target) ?? '') : null
})
const environmentId = ownerKey === null ? runtimeTargetEnvironmentId(fallback) : ownerKey || null
return useMemo(() => runtimeTargetForOwnerEnvironment(environmentId), [environmentId])
}
@@ -66,19 +66,24 @@ function lastWorktreeId(worktreesByRepo: AppState['worktreesByRepo']): string {
}
describe('local preflight context worktree lookup', () => {
it('builds the worktree index once instead of rescanning per store write', () => {
function countPreflightReads(runtimeEnvironments: readonly { id: string }[]): {
worktreeReads: number
repoReads: number
} {
const worktreeReads: ReadCounter = { count: 0 }
const repoReads: ReadCounter = { count: 0 }
const worktreesByRepo = makeWorktreesByRepo(worktreeReads)
const activeWorktreeId = lastWorktreeId(worktreesByRepo)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the preflight lookup reads only these AppState fields.
const state = {
activeRepoId: `repo-${REPO_COUNT - 1}`,
activeWorktreeId,
repos: makeRepoRows(repoReads),
worktreesByRepo,
projects: []
projects: [],
runtimeEnvironments,
runtimeEnvironmentCatalogHydrated: true
} as unknown as AppState
const rowCount = REPO_COUNT * WORKTREES_PER_REPO
worktreeReads.count = 0
repoReads.count = 0
@@ -87,10 +92,23 @@ describe('local preflight context worktree lookup', () => {
wslDistro: 'Ubuntu'
})
}
return { worktreeReads: worktreeReads.count, repoReads: repoReads.count }
}
it('builds the worktree index once instead of rescanning per store write', () => {
const reads = countPreflightReads([])
// One index build per snapshot, not one scan per store write.
expect(worktreeReads.count).toBeLessThanOrEqual(rowCount)
expect(repoReads.count).toBeLessThanOrEqual(REPO_COUNT)
expect(reads.worktreeReads).toBeLessThanOrEqual(REPO_COUNT * WORKTREES_PER_REPO)
expect(reads.repoReads).toBeLessThanOrEqual(REPO_COUNT)
})
it('builds each index once per snapshot in a window with a paired server', () => {
const reads = countPreflightReads([{ id: 'env-1' }])
// Focused-host owner index plus the local runtime index; each is shared with other selectors.
expect(reads.worktreeReads).toBeLessThanOrEqual(2 * REPO_COUNT * WORKTREES_PER_REPO)
expect(reads.repoReads).toBeLessThanOrEqual(2 * REPO_COUNT)
})
it('rebuilds against a replacement snapshot', () => {
@@ -3,6 +3,7 @@ import { create } from 'zustand'
import type { PreflightStatus } from '../../../../preload/api-types'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { getDefaultSettings } from '../../../../shared/constants'
import type { AppState } from '../types'
import { createPreflightSlice } from './preflight'
import { createRuntimeStatusSlice } from './runtime-status'
@@ -358,6 +359,58 @@ describe('createPreflightSlice', () => {
expect(store.getState().preflightStatus?.glab?.installed).toBe(true)
})
it('checks integrations on the focused worktree host, not the default host', async () => {
resetPreflightMocks()
callRuntimeRpc.mockResolvedValue(makeStatus(true))
preflightCheck.mockResolvedValue(makeStatus(false))
const store = createTestStore()
const remoteRepo = makeRepo({
id: 'repo-r',
path: '/srv/repo',
executionHostId: 'runtime:runtime-9'
})
const remoteWorktree = makeWorktree({
id: 'repo-r::/srv/repo',
repoId: 'repo-r',
path: '/srv/repo'
})
store.setState({
repos: [remoteRepo],
worktreesByRepo: { 'repo-r': [remoteWorktree] },
activeRepoId: 'repo-r',
activeWorktreeId: remoteWorktree.id
})
await store.getState().refreshPreflightStatus()
expect(preflightCheck).not.toHaveBeenCalled()
expect(callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'runtime-9' },
'preflight.check',
{}
)
expect(store.getState().preflightStatusContextKey).toMatch(/^runtime:runtime-9#\d+$/)
const localRepo = makeRepo({ id: 'repo-l', path: '/Users/a/repo', executionHostId: 'local' })
const localWorktree = makeWorktree({
id: 'repo-l::/Users/a/repo',
repoId: 'repo-l',
path: '/Users/a/repo'
})
store.setState({
settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-9' },
repos: [remoteRepo, localRepo],
worktreesByRepo: { 'repo-r': [remoteWorktree], 'repo-l': [localWorktree] },
activeRepoId: 'repo-l',
activeWorktreeId: localWorktree.id
})
await store.getState().refreshPreflightStatus()
expect(preflightCheck).toHaveBeenCalledTimes(1)
expect(store.getState().preflightStatusContextKey).toBe('host')
})
it('keeps a paired preflight result bound to the active runtime session', async () => {
resetPreflightMocks()
const runtimeA = deferred<PreflightStatus>()
+3 -2
View File
@@ -3,6 +3,7 @@ import type { PreflightRuntimeContext, PreflightStatus } from '../../../../prelo
import type { AppState } from '../types'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { defaultScopeHost } from '@/lib/default-creation-host'
import { getActiveWorkspaceOwnerTarget } from '@/lib/resolved-worktree-execution-host'
import {
getLocalPreflightContext,
localPreflightContextKey,
@@ -82,8 +83,8 @@ export const createPreflightSlice: StateCreator<AppState, [], [], PreflightSlice
const requestId = ++latestPreflightRequestId
const contextChanged = get().preflightStatusContextKey !== contextKey
// Why: a row-less readiness check of the default scope host (D4-12 initial default).
const runtimeTarget = defaultScopeHost(get().settings)
// Why: the host the context key names: the focused workspace's owner, else the default scope host.
const runtimeTarget = getActiveWorkspaceOwnerTarget(get()) ?? defaultScopeHost(get().settings)
const preflightArgs = buildPreflightArgs(force, context)
set({
preflightStatus: contextChanged ? null : get().preflightStatus,