From cbf129fd28f0e356521ba27f1d7b7cf65c6635b5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:27:52 -0700 Subject: [PATCH] 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. --- config/focus-setting-read-baseline.txt | 8 +-- ...use-automation-source-host-availability.ts | 7 +- .../status-bar/ClaudeSwitcherMenu.tsx | 33 +++++----- .../status-bar/status-bar-claude-accounts.ts | 6 +- .../status-bar/status-bar-codex-accounts.ts | 6 +- .../status-bar-runtime-groups.test.ts | 20 +++--- .../use-codex-switcher-controller.ts | 33 +++++----- .../use-task-page-source-availability.ts | 5 +- .../use-task-page-source-summary.ts | 5 +- .../use-task-page-store-bindings.ts | 8 ++- .../src/lib/local-preflight-context.test.ts | 21 ++++++ .../src/lib/local-preflight-context.ts | 16 ++--- .../src/lib/provider-runtime-context.ts | 11 +++- .../resolved-worktree-execution-host.test.ts | 65 ++++++++++++++++++- .../lib/resolved-worktree-execution-host.ts | 19 ++++++ .../runtime/use-worktree-runtime-target.ts | 13 ++++ .../always-mounted-selector-scan-cost.test.ts | 28 ++++++-- .../src/store/slices/preflight.test.ts | 53 +++++++++++++++ src/renderer/src/store/slices/preflight.ts | 5 +- 19 files changed, 289 insertions(+), 73 deletions(-) diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 6f39a744c56..20f9e85dc52 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -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 diff --git a/src/renderer/src/components/automations/use-automation-source-host-availability.ts b/src/renderer/src/components/automations/use-automation-source-host-availability.ts index 890a8deb4aa..b3510b6c7d8 100644 --- a/src/renderer/src/components/automations/use-automation-source-host-availability.ts +++ b/src/renderer/src/components/automations/use-automation-source-host-availability.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 >(() => 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 ]) diff --git a/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx b/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx index 0f6b2a83329..3a39e20ca4d 100644 --- a/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx +++ b/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx @@ -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) => { diff --git a/src/renderer/src/components/status-bar/status-bar-claude-accounts.ts b/src/renderer/src/components/status-bar/status-bar-claude-accounts.ts index dc9afe66600..9e28323c1c5 100644 --- a/src/renderer/src/components/status-bar/status-bar-claude-accounts.ts +++ b/src/renderer/src/components/status-bar/status-bar-claude-accounts.ts @@ -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 diff --git a/src/renderer/src/components/status-bar/status-bar-codex-accounts.ts b/src/renderer/src/components/status-bar/status-bar-codex-accounts.ts index d72e4d1702d..19f0321f253 100644 --- a/src/renderer/src/components/status-bar/status-bar-codex-accounts.ts +++ b/src/renderer/src/components/status-bar/status-bar-codex-accounts.ts @@ -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 diff --git a/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts index fa37dc8c01b..502922fb9ed 100644 --- a/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts @@ -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']) }) }) diff --git a/src/renderer/src/components/status-bar/use-codex-switcher-controller.ts b/src/renderer/src/components/status-bar/use-codex-switcher-controller.ts index 61743ad5095..5bf55a98ff2 100644 --- a/src/renderer/src/components/status-bar/use-codex-switcher-controller.ts +++ b/src/renderer/src/components/status-bar/use-codex-switcher-controller.ts @@ -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 diff --git a/src/renderer/src/components/use-task-page-source-availability.ts b/src/renderer/src/components/use-task-page-source-availability.ts index 4fafe9d1a1e..bee09bdb1e5 100644 --- a/src/renderer/src/components/use-task-page-source-availability.ts +++ b/src/renderer/src/components/use-task-page-source-availability.ts @@ -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 diff --git a/src/renderer/src/components/use-task-page-source-summary.ts b/src/renderer/src/components/use-task-page-source-summary.ts index 650970b335d..7160b328fb0 100644 --- a/src/renderer/src/components/use-task-page-source-summary.ts +++ b/src/renderer/src/components/use-task-page-source-summary.ts @@ -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 diff --git a/src/renderer/src/components/use-task-page-store-bindings.ts b/src/renderer/src/components/use-task-page-store-bindings.ts index ab46452d3ba..9e66e9610b6 100644 --- a/src/renderer/src/components/use-task-page-store-bindings.ts +++ b/src/renderer/src/components/use-task-page-store-bindings.ts @@ -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, diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index cc5627de587..8a4df24577a 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -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` diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts index 2eb0d9133df..1d72186d991 100644 --- a/src/renderer/src/lib/local-preflight-context.ts +++ b/src/renderer/src/lib/local-preflight-context.ts @@ -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, diff --git a/src/renderer/src/lib/provider-runtime-context.ts b/src/renderer/src/lib/provider-runtime-context.ts index 772ca7ffb42..c9a6c2e3563 100644 --- a/src/renderer/src/lib/provider-runtime-context.ts +++ b/src/renderer/src/lib/provider-runtime-context.ts @@ -3,11 +3,20 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' export function getProviderRuntimeContextKey( settings: Pick | 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 | null | undefined ): boolean { diff --git a/src/renderer/src/lib/resolved-worktree-execution-host.test.ts b/src/renderer/src/lib/resolved-worktree-execution-host.test.ts index 12b15d63a51..fb39bb3bc81 100644 --- a/src/renderer/src/lib/resolved-worktree-execution-host.test.ts +++ b/src/renderer/src/lib/resolved-worktree-execution-host.test.ts @@ -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' }) + }) +}) diff --git a/src/renderer/src/lib/resolved-worktree-execution-host.ts b/src/renderer/src/lib/resolved-worktree-execution-host.ts index 8052ccec6e6..a70e8eb7d57 100644 --- a/src/renderer/src/lib/resolved-worktree-execution-host.ts +++ b/src/renderer/src/lib/resolved-worktree-execution-host.ts @@ -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 +} diff --git a/src/renderer/src/runtime/use-worktree-runtime-target.ts b/src/renderer/src/runtime/use-worktree-runtime-target.ts index 503f112d77b..07abb555f1f 100644 --- a/src/renderer/src/runtime/use-worktree-runtime-target.ts +++ b/src/renderer/src/runtime/use-worktree-runtime-target.ts @@ -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]) +} diff --git a/src/renderer/src/store/always-mounted-selector-scan-cost.test.ts b/src/renderer/src/store/always-mounted-selector-scan-cost.test.ts index 2baeb5958ef..60c85be7728 100644 --- a/src/renderer/src/store/always-mounted-selector-scan-cost.test.ts +++ b/src/renderer/src/store/always-mounted-selector-scan-cost.test.ts @@ -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', () => { diff --git a/src/renderer/src/store/slices/preflight.test.ts b/src/renderer/src/store/slices/preflight.test.ts index bd0cea9e05f..3f6de017adf 100644 --- a/src/renderer/src/store/slices/preflight.test.ts +++ b/src/renderer/src/store/slices/preflight.test.ts @@ -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() diff --git a/src/renderer/src/store/slices/preflight.ts b/src/renderer/src/store/slices/preflight.ts index e4473556f3e..a0cff01eb1f 100644 --- a/src/renderer/src/store/slices/preflight.ts +++ b/src/renderer/src/store/slices/preflight.ts @@ -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