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