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