diff --git a/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx b/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx index 2d92fc42ebd..0b76082dfb0 100644 --- a/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx +++ b/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx @@ -68,13 +68,39 @@ describe('useActiveProjectSkillRuntime', () => { expect(result.current.discoveryTarget).toBeUndefined() }) - it('does not adopt the global default once a project is active', () => { + it('lets an active local project override the global default', () => { setGlobalWslDefault('Ubuntu') - useAppStore.setState({ activeRepoId: 'repo-1' }) + useAppStore.setState({ + activeRepoId: 'repo-1', + repos: [{ id: 'repo-1', path: 'C:\\repo', displayName: 'r', badgeColor: 'b', addedAt: 1 }], + projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }] as never + }) const { result } = renderHook(() => useActiveProjectSkillRuntime()) - expect(result.current.agentRuntime).toBeUndefined() - useAppStore.setState({ activeRepoId: null }) + expect(result.current.agentRuntime).toEqual({ runtime: 'host', label: 'Windows' }) + useAppStore.setState({ activeRepoId: null, repos: [], projects: [] }) + }) + + it('keeps the global WSL default while an SSH project is active', () => { + setGlobalWslDefault('Ubuntu') + useAppStore.setState({ + activeRepoId: 'repo-ssh', + repos: [ + { + id: 'repo-ssh', + path: '/home/alice/repo', + displayName: 'r', + badgeColor: 'b', + addedAt: 1, + connectionId: 'builder', + executionHostId: 'ssh:builder' + } + ] + }) + const { result } = renderHook(() => useActiveProjectSkillRuntime()) + + expect(result.current.agentRuntime).toMatchObject({ runtime: 'wsl' }) + useAppStore.setState({ activeRepoId: null, repos: [] }) }) it('does not inject the local WSL runtime or shell into a remote environment', () => { diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index 3bce56d5340..336649e2b37 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -4,6 +4,7 @@ import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' import type { AppState } from '@/store/types' import { + getGlobalWindowsExecutionRuntimeContext, getLocalAgentPreflightContext, getLocalPreflightContext, getLocalProjectExecutionRuntimeContext, @@ -375,18 +376,54 @@ describe('local preflight context', () => { expect(localPreflightContextKey(context)).toBe('local-project:wsl:Ubuntu') }) - it('does not use the global runtime default for active SSH projects', () => { + it('keeps the global WSL runtime for local agent checks inside an active SSH project', () => { const state = { ...makeState({ repoPath: '/home/alice/repo', - repo: { connectionId: 'builder', executionHostId: 'ssh:builder' } + worktreePath: '/home/alice/repo', + repo: { connectionId: 'builder', executionHostId: 'ssh:builder' }, + worktree: { hostId: 'ssh:builder' } }), settings: { localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } } } as unknown as AppState - expect(getLocalAgentPreflightContext(state, 'win32')).toBeUndefined() + // Why: the SSH host owns remote execution, but the local CLIs this probe + // looks for still live in the configured Windows runtime, not the raw host. + expect(localPreflightContextKey(getLocalAgentPreflightContext(state, 'win32'))).toBe( + 'local-project:wsl:Ubuntu' + ) + }) + + it('keeps the global WSL runtime when the active repo id has no repo row', () => { + const state = { + ...makeState({ repoPath: undefined }), + activeRepoId: 'ghost', + activeWorktreeId: null, + settings: { + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + } + } as unknown as AppState + + expect(localPreflightContextKey(getLocalAgentPreflightContext(state, 'win32'))).toBe( + 'local-project:wsl:Ubuntu' + ) + }) + + it('lets a local project own the runtime instead of the global default', () => { + const state = { + ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), + projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }], + settings: { + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + } + } as unknown as AppState + + expect(getGlobalWindowsExecutionRuntimeContext(state, undefined, 'win32')).toBeUndefined() + expect(localPreflightContextKey(getLocalAgentPreflightContext(state, 'win32'))).toBe( + 'repo-1:windows-host' + ) }) it('uses the project override over legacy agent location for local agent checks', () => { diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts index 7988e4656a7..587bd565a39 100644 --- a/src/renderer/src/lib/local-preflight-context.ts +++ b/src/renderer/src/lib/local-preflight-context.ts @@ -41,6 +41,9 @@ type LocalProjectRuntimeState = Pick< // or `[]` fallback would miss the cache on every read. const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {} const EMPTY_REPOS: AppState['repos'] = [] +// Why: the global Windows default is one runtime regardless of which remote +// workspace is active, so every non-owned caller shares this cache key. +const GLOBAL_LOCAL_PROJECT_ID = 'local-project' type LocalProjectRuntimeWslContext = { wslAvailable?: boolean @@ -91,7 +94,7 @@ export function getLocalProjectExecutionRuntimeContext( }) } -/** Resolves the Windows default only when no project can own the runtime. */ +/** Resolves the Windows default only when no local project can own the runtime. */ export function getGlobalWindowsExecutionRuntimeContext( state: LocalProjectRuntimeState, worktreeId?: string | null, @@ -100,16 +103,22 @@ export function getGlobalWindowsExecutionRuntimeContext( ): ProjectExecutionRuntimeResolution | undefined { if ( appPlatform !== 'win32' || - worktreeId || - state.activeRepoId || - state.activeWorktreeId || + worktreeId === FLOATING_TERMINAL_WORKTREE_ID || !state.settings?.localWindowsRuntimeDefault ) { return undefined } + // Why: an SSH/runtime workspace (or a stale active id) cannot own the local + // Windows runtime, and the local CLIs still live where the default says. + // Only a local project owner may displace the global default. + const worktree = getLocalWorktree(state, worktreeId) + const repo = getLocalRuntimeRepoForWorktree(state, worktree) + if (isLocalRuntimeRepo(repo) && isLocalRuntimeWorktree(worktree)) { + return undefined + } return resolveProjectExecutionRuntime({ appPlatform: 'win32', - projectId: getLocalPreflightProjectId(state, worktreeId), + projectId: GLOBAL_LOCAL_PROJECT_ID, projectRuntimePreference: { kind: 'inherit-global' }, globalWindowsRuntimeDefault: state.settings.localWindowsRuntimeDefault, ...wslContext @@ -323,6 +332,9 @@ function getLocalPreflightProjectId( ): string { const activeWorktree = getLocalWorktree(state, worktreeId) return ( - activeWorktree?.projectId ?? activeWorktree?.repoId ?? state.activeRepoId ?? 'local-project' + activeWorktree?.projectId ?? + activeWorktree?.repoId ?? + state.activeRepoId ?? + GLOBAL_LOCAL_PROJECT_ID ) } diff --git a/src/renderer/src/store/slices/local-detected-agents-ssh-workspace.test.ts b/src/renderer/src/store/slices/local-detected-agents-ssh-workspace.test.ts new file mode 100644 index 00000000000..0a73cbb0994 --- /dev/null +++ b/src/renderer/src/store/slices/local-detected-agents-ssh-workspace.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import type { AppState } from '../types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { createDetectedAgentsSlice } from './detected-agents' + +const detectAgents = vi.fn() +const refreshAgents = vi.fn() + +globalThis.window = { + api: { + preflight: { detectAgents, refreshAgents }, + platform: { get: () => ({ platform: 'win32' }) } + } as unknown as Window['api'] +} as Window & typeof globalThis + +const sshRepo = { + id: 'repo-ssh', + path: '/home/alice/repo', + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0, + connectionId: 'builder', + executionHostId: 'ssh:builder' +} satisfies Repo + +const sshWorktree = { + id: 'repo-ssh::main', + repoId: 'repo-ssh', + path: '/home/alice/repo', + hostId: 'ssh:builder' +} as Worktree + +function createTestStore() { + const store = create()( + (...args) => createDetectedAgentsSlice(...args) as unknown as AppState + ) + store.setState({ + settings: { localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } }, + repos: [sshRepo], + projects: [], + worktreesByRepo: { 'repo-ssh': [sshWorktree] }, + activeRepoId: 'repo-ssh', + activeWorktreeId: 'repo-ssh::main' + } as Partial) + return store +} + +describe('local detected agents inside an SSH workspace', () => { + beforeEach(() => { + // Why: mirrors the reported machine -- CLIs live in WSL, the Windows host has none. + const agentsFor = (context: { projectRuntime?: { runtime?: { kind?: string } } }) => + context?.projectRuntime?.runtime?.kind === 'wsl' ? ['claude', 'codex'] : [] + detectAgents.mockReset().mockImplementation(async (context) => agentsFor(context)) + refreshAgents.mockReset().mockImplementation(async (context) => ({ + agents: agentsFor(context), + addedPathSegments: [], + shellHydrationOk: true, + pathSource: 'shell_hydrate', + pathFailureReason: 'none' + })) + }) + + it('refreshes in the global WSL runtime instead of the raw Windows host', async () => { + const store = createTestStore() + + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude', 'codex']) + await expect(store.getState().refreshDetectedAgents()).resolves.toEqual(['claude', 'codex']) + + const detectContext = detectAgents.mock.calls[0]?.[0] + expect(detectContext).toMatchObject({ + projectRuntime: { runtime: { kind: 'wsl', distro: 'Ubuntu', reason: 'global-default' } } + }) + // Why: Refresh must re-detect in the runtime the visible list came from (#18837). + expect(refreshAgents).toHaveBeenCalledWith(detectContext) + expect(store.getState().detectedAgentIds).toEqual(['claude', 'codex']) + }) +})