diff --git a/src/renderer/src/components/settings/AgentsPane.test.tsx b/src/renderer/src/components/settings/AgentsPane.test.tsx index 2d69afaff..4a8428ca8 100644 --- a/src/renderer/src/components/settings/AgentsPane.test.tsx +++ b/src/renderer/src/components/settings/AgentsPane.test.tsx @@ -10,6 +10,7 @@ import { getAgentStatusHooksTitle } from './agent-status-hooks-copy' import { getAgentAwakeDescription, getAgentAwakeTitle } from './agent-awake-copy' import { AgentAwakeSetting } from './AgentAwakeSetting' import { AgentRuntimeSetting } from './AgentRuntimeSetting' +import type * as AgentRuntimeSettingModule from './AgentRuntimeSetting' import { AgentAvailabilityControl, AgentPermissionsSetting, @@ -25,18 +26,39 @@ import { TooltipProvider } from '../ui/tooltip' const detectedAgentsMock = vi.hoisted(() => ({ detectedIds: ['claude'] as TuiAgent[] | null, - refresh: vi.fn() + isLoading: false, + detectionFailed: false, + refresh: vi.fn(), + lastTarget: undefined as unknown +})) +const agentRuntimeSettingMock = vi.hoisted(() => ({ + lastRefresh: null as (() => Promise) | null })) vi.mock('@/hooks/useDetectedAgents', () => ({ - useDetectedAgents: () => ({ - detectedIds: detectedAgentsMock.detectedIds, - isLoading: detectedAgentsMock.detectedIds === null, - isRefreshing: false, - refresh: detectedAgentsMock.refresh - }) + useDetectedAgents: (target: unknown) => { + detectedAgentsMock.lastTarget = target + return { + detectedIds: detectedAgentsMock.detectedIds, + isLoading: detectedAgentsMock.isLoading, + detectionFailed: detectedAgentsMock.detectionFailed, + isRefreshing: false, + refresh: detectedAgentsMock.refresh + } + } })) +vi.mock('./AgentRuntimeSetting', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + AgentRuntimeSetting: (props: React.ComponentProps) => { + agentRuntimeSettingMock.lastRefresh = props.refresh + return actual.AgentRuntimeSetting(props) + } + } +}) + type ReactElementLike = { type: unknown props: Record @@ -141,13 +163,90 @@ function findSegmentedControl(node: unknown, ariaLabel: string): ReactElementLik describe('AgentsPane', () => { beforeEach(() => { detectedAgentsMock.detectedIds = ['claude'] + detectedAgentsMock.isLoading = false + detectedAgentsMock.detectionFailed = false detectedAgentsMock.refresh.mockReset() + detectedAgentsMock.lastTarget = undefined + agentRuntimeSettingMock.lastRefresh = null useAppStore.setState({ settingsSearchQuery: '', detectedAgentIds: ['claude'], isDetectingAgents: false, - isRefreshingAgents: false + isRefreshingAgents: false, + runtimeEnvironments: [] + } as never) + }) + + it('detects agents locally when no active remote server is set', () => { + renderPane(getDefaultSettings('/tmp')) + + expect(detectedAgentsMock.lastTarget).toEqual({ kind: 'local' }) + }) + + it('scopes agent detection to the active remote server', () => { + // Repro for the "Remote Server lists local agents" bug: with an Active + // Server selected, the Installed list must probe that server's PATH. + // Why the mutation: renderToStaticMarkup makes useSyncExternalStore read + // the zustand SERVER snapshot (getInitialState), so setState is invisible + // here — patch the initial-state object itself and restore it after. + const initialState = useAppStore.getInitialState() as unknown as { + runtimeEnvironments: unknown + } + const priorRuntimeEnvironments = initialState.runtimeEnvironments + initialState.runtimeEnvironments = [{ id: 'env-1', name: 'Coder' }] + + try { + const markup = renderPane({ + ...getDefaultSettings('/tmp'), + activeRuntimeEnvironmentId: 'env-1' + }) + + expect(detectedAgentsMock.lastTarget).toEqual({ kind: 'runtime', environmentId: 'env-1' }) + expect(markup).toContain('on Coder') + } finally { + initialState.runtimeEnvironments = priorRuntimeEnvironments + } + }) + + it('shows a retryable error when initial remote detection fails', () => { + detectedAgentsMock.detectedIds = null + detectedAgentsMock.isLoading = false + detectedAgentsMock.detectionFailed = true + + const markup = renderPane({ + ...getDefaultSettings('/tmp'), + activeRuntimeEnvironmentId: 'env-1' }) + + expect(markup).toContain('Couldn’t detect installed agents') + expect(markup).toContain('Retry') + expect(markup).not.toContain('Detecting installed agents…') + }) + + it('does not flash a failure before the initial detection effect starts', () => { + detectedAgentsMock.detectedIds = null + detectedAgentsMock.isLoading = false + detectedAgentsMock.detectionFailed = false + + const markup = renderPane(getDefaultSettings('/tmp')) + + expect(markup).toContain('Detecting installed agents…') + expect(markup).not.toContain('Couldn’t detect installed agents') + }) + + it('keeps Windows runtime changes scoped to the local agent refresh', () => { + renderPane( + { + ...getDefaultSettings('/tmp'), + activeRuntimeEnvironmentId: 'env-1' + }, + { wslSupportedPlatform: true, wslAvailable: true, wslDistros: ['Ubuntu'] } + ) + + expect(agentRuntimeSettingMock.lastRefresh).toBe( + useAppStore.getInitialState().refreshDetectedAgents + ) + expect(agentRuntimeSettingMock.lastRefresh).not.toBe(detectedAgentsMock.refresh) }) it('renders the keep-awake toggle from settings', () => { diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 99718de46..21f50bede 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -2,10 +2,18 @@ selection, per-agent controls, and runtime location together so settings reconciliation stays visible in one file. */ import { useId, useMemo, useState } from 'react' -import { Check, ChevronDown, ExternalLink, Info, RefreshCw, Terminal } from 'lucide-react' +import { + AlertTriangle, + Check, + ChevronDown, + ExternalLink, + Info, + RefreshCw, + Terminal +} from 'lucide-react' import type { GlobalSettings, TuiAgent } from '../../../../shared/types' import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog' -import { useDetectedAgents } from '@/hooks/useDetectedAgents' +import { useDetectedAgents, type AgentDetectionTarget } from '@/hooks/useDetectedAgents' import { useAppStore } from '@/store' import { Button } from '../ui/button' import { Input } from '../ui/input' @@ -679,12 +687,36 @@ export function AgentsPane({ wslDistros, wslCapabilitiesLoading }: AgentsPaneProps): React.JSX.Element { - const { detectedIds: detectedList, isRefreshing, refresh } = useDetectedAgents() - // Why: refresh re-spawns the user's login shell to re-capture PATH - // (preflight:refreshAgents on the main side). This handles the - // "installed a new CLI, Orca doesn't see it yet" case without a restart. + // Why: the Active Server routes agent launches and provider checks through + // that server, so this pane must list what THAT host can launch — detecting + // on the client showed a Windows machine's agents while paired to a Linux + // server (the enable/disable/default toggles below stay client settings). + const activeServerEnvironmentId = settings.activeRuntimeEnvironmentId?.trim() || null + const agentDetectionTarget = useMemo( + () => + activeServerEnvironmentId + ? { kind: 'runtime', environmentId: activeServerEnvironmentId } + : { kind: 'local' }, + [activeServerEnvironmentId] + ) + const { + detectedIds: detectedList, + detectionFailed, + isRefreshing, + refresh: refreshTargetAgents + } = useDetectedAgents(agentDetectionTarget) + const refreshLocalAgents = useAppStore((s) => s.refreshDetectedAgents) + const activeServerName = useAppStore((s) => + activeServerEnvironmentId + ? (s.runtimeEnvironments.find((environment) => environment.id === activeServerEnvironmentId) + ?.name ?? null) + : null + ) + // Why: refresh re-spawns the target host's login shell to re-capture PATH + // (preflight:refreshAgents). This handles the "installed a new CLI, Orca + // doesn't see it yet" case without a restart. const handleRefresh = (): void => { - void refresh() + void refreshTargetAgents() } const detectedIds = useMemo | null>( () => (detectedList ? new Set(detectedList) : null), @@ -822,7 +854,9 @@ export function AgentsPane({ + {activeServerName ? ( + + {translate('auto.components.settings.AgentsPane.03e1a5081a', 'on {{value0}}', { + value0: activeServerName + })} + + ) : null} } action={ @@ -858,10 +899,17 @@ export function AgentsPane({ size="xs" onClick={handleRefresh} disabled={isRefreshing} - title={translate( - 'auto.components.settings.AgentsPane.13647f9f80', - 'Re-read your shell PATH and re-detect installed agents' - )} + title={ + activeServerEnvironmentId + ? translate( + 'auto.components.settings.AgentsPane.25a41a9aad', + 'Re-detect agents installed on the active server' + ) + : translate( + 'auto.components.settings.AgentsPane.13647f9f80', + 'Re-read your shell PATH and re-detect installed agents' + ) + } className="h-7 gap-1.5 text-xs text-muted-foreground hover:text-foreground" > @@ -948,7 +996,7 @@ export function AgentsPane({ )} - {detectedIds === null && ( + {detectedIds === null && !detectionFailed && (
{translate( 'auto.components.settings.AgentsPane.d83834f5e6', @@ -956,6 +1004,28 @@ export function AgentsPane({ )}
)} + + {detectionFailed && ( +
+ + + {translate( + 'auto.components.settings.AgentsPane.remoteDetectionFailed', + 'Couldn’t detect installed agents. Check the host connection and try again.' + )} + + +
+ )} ) } diff --git a/src/renderer/src/components/settings/OrchestrationSkillAgentCoverage.tsx b/src/renderer/src/components/settings/OrchestrationSkillAgentCoverage.tsx index 43c05a68f..036af3795 100644 --- a/src/renderer/src/components/settings/OrchestrationSkillAgentCoverage.tsx +++ b/src/renderer/src/components/settings/OrchestrationSkillAgentCoverage.tsx @@ -73,7 +73,7 @@ export function OrchestrationSkillAgentCoverage(props: { className?: string }): React.JSX.Element { const { skills, loading: skillsLoading, embedded = false, className } = props - const { detectedIds, isLoading: agentsLoading } = useDetectedAgents() + const { detectedIds, isLoading: agentsLoading } = useDetectedAgents({ kind: 'local' }) const loading = skillsLoading || agentsLoading || detectedIds === null const agentStatuses = getOrchestrationSkillAgentStatuses(skills, detectedIds ?? []) const installedCount = agentStatuses.filter((status) => status.installed).length diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts index 3c32fdd7c..bb2f1cf02 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts @@ -3,26 +3,31 @@ import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { QuickLaunchAgentMenuItems, shouldShowLaunchWatchdogTimeout } from './QuickLaunchButton' -const { shortcutLabelMock, storeState, openSettingsPageMock, openSettingsTargetMock } = vi.hoisted( - () => ({ - shortcutLabelMock: vi.fn<() => string | null>(), - storeState: { - settings: { - defaultTuiAgent: 'codex' as 'claude' | 'codex' | 'gemini' | 'blank' | null, - disabledTuiAgents: [] as string[] - }, - worktreesByRepo: {}, - repos: [], - openSettingsPage: vi.fn(), - openSettingsTarget: vi.fn() +const { + shortcutLabelMock, + storeState, + openSettingsPageMock, + openSettingsTargetMock, + useDetectedAgentsMock +} = vi.hoisted(() => ({ + shortcutLabelMock: vi.fn<() => string | null>(), + storeState: { + settings: { + defaultTuiAgent: 'codex' as 'claude' | 'codex' | 'gemini' | 'blank' | null, + disabledTuiAgents: [] as string[] }, - openSettingsPageMock: vi.fn(), - openSettingsTargetMock: vi.fn() - }) -) + worktreesByRepo: {} as Record, + repos: [] as unknown[], + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + }, + openSettingsPageMock: vi.fn(), + openSettingsTargetMock: vi.fn(), + useDetectedAgentsMock: vi.fn(() => ({ detectedIds: ['claude', 'codex', 'gemini'] })) +})) vi.mock('@/hooks/useDetectedAgents', () => ({ - useDetectedAgents: () => ({ detectedIds: ['claude', 'codex', 'gemini'] }) + useDetectedAgents: useDetectedAgentsMock })) vi.mock('@/hooks/useShortcutLabel', () => ({ @@ -111,6 +116,7 @@ function rowMarkup(html: string, label: string): string { beforeEach(() => { shortcutLabelMock.mockReset() shortcutLabelMock.mockReturnValue(null) + useDetectedAgentsMock.mockClear() openSettingsPageMock.mockReset() openSettingsTargetMock.mockReset() storeState.settings.defaultTuiAgent = 'codex' @@ -141,6 +147,56 @@ describe('QuickLaunchAgentMenuItems', () => { expect(html).not.toContain('data-dropdown-shortcut="true"') }) + it('routes agent detection to the worktree-owning runtime host, not the local client', () => { + // Repro for the "Remote Server lists local agents" bug: a worktree owned by + // a paired runtime must probe that runtime, never the client's PATH. + storeState.worktreesByRepo = { + 'repo-1': [{ id: 'worktree-1', repoId: 'repo-1', hostId: 'runtime:env-1' }] + } + storeState.repos = [{ id: 'repo-1' }] + + renderAgentMenuItems() + + expect(useDetectedAgentsMock).toHaveBeenLastCalledWith({ + kind: 'runtime', + environmentId: 'env-1' + }) + }) + + it('prefers the paired runtime owner over its server-side SSH connection', () => { + storeState.worktreesByRepo = { + 'repo-1': [{ id: 'worktree-1', repoId: 'repo-1' }] + } + storeState.repos = [ + { + id: 'repo-1', + connectionId: 'server-only-ssh-target', + executionHostId: 'runtime:env-1' + } + ] + + renderAgentMenuItems() + + expect(useDetectedAgentsMock).toHaveBeenLastCalledWith({ + kind: 'runtime', + environmentId: 'env-1' + }) + }) + + it('routes agent detection to the owning SSH host', () => { + storeState.worktreesByRepo = { + 'repo-1': [{ id: 'worktree-1', repoId: 'repo-1' }] + } + storeState.repos = [{ id: 'repo-1', connectionId: 'ssh-target-1' }] + + renderAgentMenuItems() + + expect(useDetectedAgentsMock).toHaveBeenLastCalledWith({ + kind: 'ssh', + connectionId: 'ssh-target-1' + }) + }) + it('does not label an auto-picked or blank default as configured', () => { shortcutLabelMock.mockReturnValue('⌘⌥T') diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index a439a8b67..87d12c712 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -4,7 +4,7 @@ import { toast } from 'sonner' import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu' import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog' import { useAppStore } from '@/store' -import { getConnectionIdFromState } from '@/lib/connection-context' +import { useAgentDetectionTargetForWorktree } from '@/hooks/useAgentDetectionTarget' import { useDetectedAgents } from '@/hooks/useDetectedAgents' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' @@ -100,12 +100,12 @@ function QuickLaunchAgentMenuItemsInner({ launchSource, onPromptDelivered }: QuickLaunchAgentMenuItemsProps): React.JSX.Element | null { - // Why: must be a reactive selector (not getConnectionId() which reads a - // snapshot via getState()). This ensures the component re-renders when the - // SSH connection state changes. Returns undefined when the worktree isn't - // found (store not hydrated), null for local repos, string for remote. - const connectionId = useAppStore((s) => getConnectionIdFromState(s, worktreeId)) - const { detectedIds } = useDetectedAgents(connectionId) + // Why: resolving only the SSH connectionId here made paired-runtime + // worktrees fall back to LOCAL detection, listing the client's agents + // instead of the remote server's. Use the same ssh/runtime/local owner + // resolution as the rest of the tab bar. + const agentDetectionTarget = useAgentDetectionTargetForWorktree(worktreeId) + const { detectedIds } = useDetectedAgents(agentDetectionTarget) const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent) const disabledAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) const openSettingsPage = useAppStore((s) => s.openSettingsPage) diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index efc170890..a1e01b50c 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -38,7 +38,8 @@ import TabBarCreateEntry from './TabBarCreateEntry' import { ShellIcon } from './shell-icons' import { resolveWindowsShellLaunchTarget } from './windows-shell-launch' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' -import { type AgentDetectionTarget, useDetectedAgents } from '@/hooks/useDetectedAgents' +import { useDetectedAgents } from '@/hooks/useDetectedAgents' +import { useAgentDetectionTargetForWorktree } from '@/hooks/useAgentDetectionTarget' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { normalizeRelativePath } from '@/lib/path' import { @@ -91,7 +92,6 @@ type GitStatusEntries = ReturnType['gitStatusByWork const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntries = [] const EMPTY_AGENT_CMD_OVERRIDES: Partial> = {} const EMPTY_UNIFIED_TABS: readonly Tab[] = [] -const AGENT_DETECTION_LOCAL_TARGET_KEY = 'local' function getProjectRuntimeShellMenuMode( projectRuntime: ProjectExecutionRuntimeResolution | undefined @@ -325,36 +325,7 @@ function TabBarInner({ const agentCmdOverrides = useAppStore( (s) => s.settings?.agentCmdOverrides ?? EMPTY_AGENT_CMD_OVERRIDES ) - const agentDetectionTargetKey = useAppStore((s): string | undefined => { - const connectionId = getConnectionIdFromState(s, worktreeId) - if (connectionId === undefined) { - return undefined - } - const normalizedConnectionId = connectionId?.trim() - if (normalizedConnectionId) { - return `ssh:${normalizedConnectionId}` - } - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(s, worktreeId)?.trim() - if (runtimeEnvironmentId) { - return `runtime:${runtimeEnvironmentId}` - } - return AGENT_DETECTION_LOCAL_TARGET_KEY - }) - const agentDetectionTarget = useMemo(() => { - if (agentDetectionTargetKey === undefined) { - return undefined - } - if (agentDetectionTargetKey === AGENT_DETECTION_LOCAL_TARGET_KEY) { - return { kind: 'local' } - } - if (agentDetectionTargetKey.startsWith('ssh:')) { - return { kind: 'ssh', connectionId: agentDetectionTargetKey.slice('ssh:'.length) } - } - if (agentDetectionTargetKey.startsWith('runtime:')) { - return { kind: 'runtime', environmentId: agentDetectionTargetKey.slice('runtime:'.length) } - } - return { kind: 'local' } - }, [agentDetectionTargetKey]) + const agentDetectionTarget = useAgentDetectionTargetForWorktree(worktreeId) const { detectedIds } = useDetectedAgents(agentDetectionTarget) const agentLaunchOptions = useMemo( () => diff --git a/src/renderer/src/hooks/useAgentDetectionTarget.test.ts b/src/renderer/src/hooks/useAgentDetectionTarget.test.ts new file mode 100644 index 000000000..8d0cc61f7 --- /dev/null +++ b/src/renderer/src/hooks/useAgentDetectionTarget.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { getAgentDetectionTargetKeyForWorktree } from './useAgentDetectionTarget' + +describe('getAgentDetectionTargetKeyForWorktree', () => { + it('uses an explicit runtime owner without scanning ambiguous child SSH repos', () => { + let projectGroupReads = 0 + const repos = Array.from({ length: 100 }, (_, index) => { + const repo = { + id: `repo-${index}`, + connectionId: `ssh-${index}`, + executionHostId: `ssh:ssh-${index}`, + path: `/workspace/repo-${index}` + } + Object.defineProperty(repo, 'projectGroupId', { + enumerable: true, + get: () => { + projectGroupReads += 1 + return 'runtime-group' + } + }) + return repo + }) + const state = { + settings: { activeRuntimeEnvironmentId: 'focused-env' }, + folderWorkspaces: [ + { + id: 'runtime-folder', + projectGroupId: 'runtime-group', + folderPath: '/workspace' + } + ], + projectGroups: [ + { + id: 'runtime-group', + connectionId: null, + executionHostId: 'runtime:owner-env' + } + ], + repos, + worktreesByRepo: {} + } as Parameters[0] + + expect(getAgentDetectionTargetKeyForWorktree(state, folderWorkspaceKey('runtime-folder'))).toBe( + 'runtime:owner-env' + ) + expect(projectGroupReads).toBe(0) + }) + + it('stays unresolved when ownership records have not hydrated', () => { + const state = { + settings: { activeRuntimeEnvironmentId: 'focused-env' }, + folderWorkspaces: [], + projectGroups: [], + repos: [], + worktreesByRepo: {} + } as Parameters[0] + + expect(getAgentDetectionTargetKeyForWorktree(state, 'missing-worktree')).toBeUndefined() + }) + + it('does not trust a repo owner before the requested worktree hydrates', () => { + const state = { + settings: { activeRuntimeEnvironmentId: null }, + folderWorkspaces: [], + projectGroups: [], + repos: [ + { + id: 'repo-1', + connectionId: null, + executionHostId: 'local' + } + ], + worktreesByRepo: {} + } as unknown as Parameters[0] + + expect(getAgentDetectionTargetKeyForWorktree(state, 'repo-1::/remote/worktree')).toBeUndefined() + }) + + it('keeps the active runtime fallback for hydrated legacy worktrees', () => { + const state = { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + folderWorkspaces: [], + projectGroups: [], + repos: [{ id: 'repo-1', connectionId: null, executionHostId: null }], + worktreesByRepo: { + 'repo-1': [{ id: 'repo-1::worktree-1', repoId: 'repo-1' }] + } + } as unknown as Parameters[0] + + expect(getAgentDetectionTargetKeyForWorktree(state, 'repo-1::worktree-1')).toBe('runtime:env-1') + }) + + it('builds one owner index per cold worktree and repo snapshot', () => { + let worktreeIdReads = 0 + let repoIdReads = 0 + const repos = Array.from({ length: 100 }, (_, index) => { + const repo = { + connectionId: null, + executionHostId: 'local' + } + Object.defineProperty(repo, 'id', { + enumerable: true, + get: () => { + repoIdReads += 1 + return `repo-${index}` + } + }) + return repo + }) + const worktrees = Array.from({ length: 100 }, (_, index) => { + const worktree = { + repoId: `repo-${index}`, + hostId: undefined + } + Object.defineProperty(worktree, 'id', { + enumerable: true, + get: () => { + worktreeIdReads += 1 + return `worktree-${index}` + } + }) + return worktree + }) + const state = { + settings: { activeRuntimeEnvironmentId: null }, + folderWorkspaces: [], + projectGroups: [], + repos, + worktreesByRepo: { all: worktrees } + } as unknown as Parameters[0] + + expect(getAgentDetectionTargetKeyForWorktree(state, 'worktree-99')).toBe('local') + expect(worktreeIdReads).toBe(100) + expect(repoIdReads).toBe(100) + }) +}) diff --git a/src/renderer/src/hooks/useAgentDetectionTarget.ts b/src/renderer/src/hooks/useAgentDetectionTarget.ts new file mode 100644 index 000000000..5f00d3100 --- /dev/null +++ b/src/renderer/src/hooks/useAgentDetectionTarget.ts @@ -0,0 +1,86 @@ +import { useMemo } from 'react' +import { useAppStore } from '@/store' +import { getConnectionIdFromState } from '@/lib/connection-owner-resolution' +import { + getExplicitRuntimeEnvironmentIdForWorktree, + getExecutionHostIdForWorktree, + type WorktreeRuntimeOwnerState +} from '@/lib/worktree-runtime-owner' +import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host' +import { parseExecutionHostId } from '../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import type { AgentDetectionTarget } from './useDetectedAgents' + +export const AGENT_DETECTION_LOCAL_TARGET_KEY = 'local' + +type AgentDetectionOwnerState = Parameters[0] & + WorktreeRuntimeOwnerState + +/** + * Resolve which host's agent detection a worktree's launch surfaces must use: + * the owning SSH host, the owning paired-runtime host, or the local machine. + * Returns undefined while the store has not hydrated the owning repo yet. + * + * Why a string key: selectors must return a stable primitive; building the + * target object inside the selector would re-render subscribers on every + * store write. + */ +export function getAgentDetectionTargetKeyForWorktree( + state: AgentDetectionOwnerState, + worktreeId: string | null +): string | undefined { + if (worktreeId === null) { + return AGENT_DETECTION_LOCAL_TARGET_KEY + } + if (parseWorkspaceKey(worktreeId)?.type === 'folder') { + const explicitRuntimeEnvironmentId = getExplicitRuntimeEnvironmentIdForWorktree( + state, + worktreeId + ) + if (explicitRuntimeEnvironmentId) { + return `runtime:${explicitRuntimeEnvironmentId}` + } + // Why: a hostless folder can span local and SSH children, so keep the + // ambiguity gate before applying its focused-runtime fallback. + if (getConnectionIdFromState(state, worktreeId) === undefined) { + return undefined + } + } else if (getResolvedExecutionHostIdForWorktree(state, worktreeId) === null) { + // Why: repo rows can hydrate before a restored remote worktree; that gap + // must stay unresolved instead of probing the repo row's local owner. + return undefined + } + const executionHost = parseExecutionHostId(getExecutionHostIdForWorktree(state, worktreeId)) + if (executionHost?.kind === 'ssh') { + return `ssh:${executionHost.targetId}` + } + if (executionHost?.kind === 'runtime') { + return `runtime:${executionHost.environmentId}` + } + return AGENT_DETECTION_LOCAL_TARGET_KEY +} + +export function parseAgentDetectionTargetKey( + key: string | undefined +): AgentDetectionTarget | undefined { + if (key === undefined) { + return undefined + } + if (key === AGENT_DETECTION_LOCAL_TARGET_KEY) { + return { kind: 'local' } + } + if (key.startsWith('ssh:')) { + return { kind: 'ssh', connectionId: key.slice('ssh:'.length) } + } + if (key.startsWith('runtime:')) { + return { kind: 'runtime', environmentId: key.slice('runtime:'.length) } + } + return { kind: 'local' } +} + +export function useAgentDetectionTargetForWorktree( + worktreeId: string | null +): AgentDetectionTarget | undefined { + const key = useAppStore((s) => getAgentDetectionTargetKeyForWorktree(s, worktreeId)) + return useMemo(() => parseAgentDetectionTargetKey(key), [key]) +} diff --git a/src/renderer/src/hooks/useDetectedAgents.test.tsx b/src/renderer/src/hooks/useDetectedAgents.test.tsx index 7e9febda3..a4f0d7f3f 100644 --- a/src/renderer/src/hooks/useDetectedAgents.test.tsx +++ b/src/renderer/src/hooks/useDetectedAgents.test.tsx @@ -4,19 +4,26 @@ import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' -import { useDetectedAgents, type AgentDetectionTarget } from './useDetectedAgents' +import { + useDetectedAgents, + type AgentDetectionTarget, + type UseDetectedAgentsResult +} from './useDetectedAgents' import { MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_PROTOCOL_VERSION } from '../../../shared/protocol-version' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' const detectRemoteAgents = vi.fn() +const refreshLocalAgents = vi.fn() const runtimeEnvironmentCall = vi.fn() const initialAppState = useAppStore.getInitialState() const roots: Root[] = [] +let latestHookResult: UseDetectedAgentsResult | null = null -function HookProbe({ target }: { target: AgentDetectionTarget }): null { - useDetectedAgents(target) +function HookProbe({ target }: { target: AgentDetectionTarget | undefined }): null { + latestHookResult = useDetectedAgents(target) return null } @@ -27,7 +34,7 @@ async function flushEffects(): Promise { }) } -async function renderProbe(target: AgentDetectionTarget): Promise { +async function renderProbe(target: AgentDetectionTarget | undefined): Promise { const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) @@ -40,8 +47,17 @@ async function renderProbe(target: AgentDetectionTarget): Promise { } beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() useAppStore.setState(initialAppState, true) + latestHookResult = null detectRemoteAgents.mockReset().mockResolvedValue([]) + refreshLocalAgents.mockReset().mockResolvedValue({ + agents: [], + addedPathSegments: [], + shellHydrationOk: true, + pathSource: 'process_env', + pathFailureReason: 'none' + }) runtimeEnvironmentCall.mockReset().mockImplementation(({ method }: { method: string }) => { const result = method === 'status.get' @@ -64,7 +80,7 @@ beforeEach(() => { }) }) globalThis.window.api = { - preflight: { detectRemoteAgents }, + preflight: { detectRemoteAgents, refreshAgents: refreshLocalAgents }, runtimeEnvironments: { call: runtimeEnvironmentCall } } as unknown as Window['api'] }) @@ -115,7 +131,103 @@ describe('useDetectedAgents (ssh call site)', () => { }) }) +describe('useDetectedAgents (unresolved target)', () => { + it('does not fall back to detecting or refreshing the local client', async () => { + await renderProbe(undefined) + + expect(latestHookResult?.detectedIds).toBeNull() + expect(latestHookResult?.isLoading).toBe(true) + await expect(latestHookResult?.refresh()).resolves.toEqual([]) + + expect(refreshLocalAgents).not.toHaveBeenCalled() + expect(detectRemoteAgents).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) +}) + describe('useDetectedAgents (runtime call site)', () => { + it('distinguishes an initial remote failure from the pre-effect loading state', async () => { + runtimeEnvironmentCall.mockRejectedValue(new Error('runtime disconnected')) + + await renderProbe({ kind: 'runtime', environmentId: 'env-1' }) + + expect(latestHookResult?.detectedIds).toBeNull() + expect(latestHookResult?.isLoading).toBe(false) + expect(latestHookResult?.detectionFailed).toBe(true) + }) + + it('probes each empty runtime target at most once per mounted surface', async () => { + const root = await renderProbe({ kind: 'runtime', environmentId: 'env-1' }) + + await act(async () => { + root.render(createElement(HookProbe, { target: { kind: 'runtime', environmentId: 'env-2' } })) + }) + await flushEffects() + await act(async () => { + root.render(createElement(HookProbe, { target: { kind: 'runtime', environmentId: 'env-1' } })) + }) + await flushEffects() + + expect( + runtimeEnvironmentCall.mock.calls.filter( + ([{ method }]) => method === 'preflight.detectAgents' + ) + ).toHaveLength(2) + }) + + it('does not re-probe after an explicit refresh finds no agents', async () => { + useAppStore.setState({ + runtimeDetectedAgentIds: { 'env-1': ['claude'] }, + isDetectingRuntimeAgents: { 'env-1': false } + }) + let detectCalls = 0 + let refreshCalls = 0 + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + let result: unknown + if (method === 'status.get') { + result = { + runtimeId: 'remote-runtime', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + } else if (method === 'preflight.refreshAgents') { + refreshCalls += 1 + result = { + agents: [], + addedPathSegments: [], + shellHydrationOk: true, + pathSource: 'shell_hydrate', + pathFailureReason: 'none' + } + } else { + detectCalls += 1 + result = ['claude'] + } + return Promise.resolve({ + id: method, + ok: true, + result, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + await renderProbe({ kind: 'runtime', environmentId: 'env-1' }) + await renderProbe({ kind: 'runtime', environmentId: 'env-1' }) + await act(async () => { + await latestHookResult?.refresh() + }) + await flushEffects() + + expect(refreshCalls).toBe(1) + expect(detectCalls).toBe(0) + expect(useAppStore.getState().runtimeDetectedAgentIds['env-1']).toEqual([]) + }) + it('retries a cached empty runtime result when the launch surface is reopened', async () => { let detectCalls = 0 runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { diff --git a/src/renderer/src/hooks/useDetectedAgents.ts b/src/renderer/src/hooks/useDetectedAgents.ts index d4be36d0e..2776f2b69 100644 --- a/src/renderer/src/hooks/useDetectedAgents.ts +++ b/src/renderer/src/hooks/useDetectedAgents.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { useAppStore } from '@/store' import type { TuiAgent } from '../../../shared/types' @@ -6,10 +6,13 @@ export type UseDetectedAgentsResult = { /** Null while detection is in flight on first load. */ detectedIds: TuiAgent[] | null isLoading: boolean + /** True when the first probe for this mounted remote target finished without a result. */ + detectionFailed: boolean isRefreshing: boolean - /** Re-runs `preflight.refreshAgents` and updates every subscribed surface in - * the same tick. Idempotent while in flight: concurrent callers receive the - * same pending promise. */ + /** Forces a re-detect on the target host (`preflight.refreshAgents` for + * local/runtime targets, a fresh probe for SSH) and updates every + * subscribed surface in the same tick. Idempotent while in flight: + * concurrent callers receive the same pending promise. */ refresh: () => Promise } @@ -47,10 +50,10 @@ function normalizeAgentDetectionTarget( * (store not hydrated) — returns loading state. */ export function useDetectedAgents( - connectionId: AgentDetectionTarget | string | null | undefined = null + connectionId: AgentDetectionTarget | string | null | undefined ): UseDetectedAgentsResult { const target = normalizeAgentDetectionTarget(connectionId) - const retriedEmptyTargetRef = useRef(null) + const observedRemoteTargetKeysRef = useRef>(new Set()) // Why: undefined means "store not yet hydrated" — we don't know if the // worktree is local or remote yet. This prevents flashing local agents for // remote worktrees during hydration. @@ -62,6 +65,12 @@ export function useDetectedAgents( : target?.kind === 'runtime' ? target.environmentId : null + const remoteTargetKey = + targetKind === 'ssh' && targetId + ? `ssh:${targetId}` + : targetKind === 'runtime' && targetId + ? `runtime:${targetId}` + : null const detectedIds = useAppStore((s) => { if (isUnknown) { @@ -87,48 +96,73 @@ export function useDetectedAgents( } return s.isDetectingAgents }) - const isRefreshing = useAppStore((s) => (targetKind === 'local' ? s.isRefreshingAgents : false)) - const ensureLocal = useAppStore((s) => s.ensureDetectedAgents) - const ensureRemote = useAppStore((s) => s.ensureRemoteDetectedAgents) - const ensureRuntime = useAppStore((s) => s.ensureRuntimeDetectedAgents) - const refresh = useAppStore((s) => s.refreshDetectedAgents) + const isRefreshing = useAppStore((s) => { + if (targetKind === 'runtime' && targetId) { + return s.isRefreshingRuntimeAgents[targetId] ?? false + } + if (targetKind === 'ssh' && targetId) { + return s.isDetectingRemoteAgents[targetId] ?? false + } + return targetKind === 'local' ? s.isRefreshingAgents : false + }) + const detectionFailed = + detectedIds === null && + !isLoading && + !isRefreshing && + remoteTargetKey !== null && + observedRemoteTargetKeysRef.current.has(remoteTargetKey) + // Why: refresh must hit the same host the list came from — refreshing the + // local PATH while showing a remote server's agents is a silent no-op. + const refresh = useCallback((): Promise => { + if (isUnknown) { + return Promise.resolve([]) + } + // Why: retained tab bars stay mounted; imperative action reads avoid six + // no-op Zustand subscriptions per hook during unrelated store churn. + const state = useAppStore.getState() + if (targetKind === 'runtime' && targetId) { + return state.refreshRuntimeDetectedAgents(targetId) + } + if (targetKind === 'ssh' && targetId) { + return state.refreshRemoteDetectedAgents(targetId) + } + return state.refreshDetectedAgents() + }, [isUnknown, targetKind, targetId]) useEffect(() => { if (isUnknown) { return } - const emptyRetryKey = - targetKind === 'ssh' && targetId - ? `ssh:${targetId}` - : targetKind === 'runtime' && targetId - ? `runtime:${targetId}` - : null + const isNewRemoteTarget = + remoteTargetKey !== null && !observedRemoteTargetKeysRef.current.has(remoteTargetKey) + // Why: switching A → B → A is still one mounted surface; remember every + // target so empty hosts don't respawn all detection subprocesses on each switch. + if (remoteTargetKey !== null) { + observedRemoteTargetKeysRef.current.add(remoteTargetKey) + } + const state = useAppStore.getState() if (targetKind === 'ssh' && targetId) { if (detectedIds === null) { - retriedEmptyTargetRef.current = emptyRetryKey - void ensureRemote(targetId) - } else if (detectedIds.length === 0 && retriedEmptyTargetRef.current !== emptyRetryKey) { + void state.ensureRemoteDetectedAgents(targetId) + } else if (detectedIds.length === 0 && isNewRemoteTarget) { // Why: a newly opened remote launch surface should get one fresh probe // after a prior empty result, but must not spin while the host has no agents. - retriedEmptyTargetRef.current = emptyRetryKey - void ensureRemote(targetId) + void state.ensureRemoteDetectedAgents(targetId) } } else if (targetKind === 'runtime' && targetId) { if (detectedIds === null) { - retriedEmptyTargetRef.current = emptyRetryKey - void ensureRuntime(targetId) - } else if (detectedIds.length === 0 && retriedEmptyTargetRef.current !== emptyRetryKey) { + void state.ensureRuntimeDetectedAgents(targetId) + } else if (detectedIds.length === 0 && isNewRemoteTarget) { // Why: remote `orca serve` users can install/fix PATH without reconnecting; // retry once per mounted surface so the menu can pick that up. - retriedEmptyTargetRef.current = emptyRetryKey - void ensureRuntime(targetId) + void state.ensureRuntimeDetectedAgents(targetId) } } else { if (detectedIds === null) { - void ensureLocal() + void state.ensureDetectedAgents() } } - }, [isUnknown, targetKind, targetId, detectedIds, ensureLocal, ensureRemote, ensureRuntime]) + }, [isUnknown, targetKind, targetId, remoteTargetKey, detectedIds]) - return { detectedIds, isLoading, isRefreshing, refresh } + return { detectedIds, isLoading, detectionFailed, isRefreshing, refresh } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3471bb8ef..6c586a405 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5090,6 +5090,10 @@ "024bd95089": "agents", "e8da2af684": "Available to install", "ed3e110e61": "detected", + "03e1a5081a": "on {{value0}}", + "25a41a9aad": "Re-detect agents installed on the active server", + "remoteDetectionFailed": "Couldn’t detect installed agents. Check the host connection and try again.", + "retryDetection": "Retry", "02e0143be5": "Installed", "110b74b022": "No agent (blank terminal)", "92033495ff": "Auto", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b708cbcbb..3d05eff9f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5089,7 +5089,11 @@ "3f1bdf3cb4": "El texto de entorno es demasiado grande para analizarlo de forma segura.", "codexSessionSource": "Directorio Codex a importar", "codexSessionSourceInfo": "Sobre la importación del historial de Codex", - "codexSessionSourceTooltip": "Orca ejecuta Codex en un directorio aislado. Señala aquí a tu directorio Codex existente para importar el historial de sesiones. Dejar vacío usa ~/.codex." + "codexSessionSourceTooltip": "Orca ejecuta Codex en un directorio aislado. Señala aquí a tu directorio Codex existente para importar el historial de sesiones. Dejar vacío usa ~/.codex.", + "03e1a5081a": "on {{value0}}", + "25a41a9aad": "Re-detect agents installed on the active server", + "remoteDetectionFailed": "Couldn’t detect installed agents. Check the host connection and try again.", + "retryDetection": "Retry" }, "AppIconSelector": { "d5a112dc9b": "Icono siguiente", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index fd224c1e7..2ad43b89f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5074,7 +5074,11 @@ "3f1bdf3cb4": "環境テキストが大きすぎるため安全に解析できません。", "codexSessionSource": "インポートするCodexホーム", "codexSessionSourceInfo": "Codex履歴のインポートについて", - "codexSessionSourceTooltip": "Orcaは分離されたホームでCodexを実行します。既存のCodexホームを指定してセッション履歴をインポートします。空の場合、~/.codexを使用します。" + "codexSessionSourceTooltip": "Orcaは分離されたホームでCodexを実行します。既存のCodexホームを指定してセッション履歴をインポートします。空の場合、~/.codexを使用します。", + "03e1a5081a": "on {{value0}}", + "25a41a9aad": "Re-detect agents installed on the active server", + "remoteDetectionFailed": "Couldn’t detect installed agents. Check the host connection and try again.", + "retryDetection": "Retry" }, "AppIconSelector": { "d5a112dc9b": "次へのアイコン", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f4d7e8181..7c53f7439 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5074,7 +5074,11 @@ "3f1bdf3cb4": "환경 텍스트가 너무 커서 안전하게 파싱할 수 없습니다.", "codexSessionSource": "가져올 Codex 홈", "codexSessionSourceInfo": "Codex 기록 가져오기 정보", - "codexSessionSourceTooltip": "Orca는 격리된 홈에서 Codex를 실행합니다. 기존 Codex 홈을 지정하여 세션 기록을 가져옵니다. 비워두면 ~/.codex를 사용합니다." + "codexSessionSourceTooltip": "Orca는 격리된 홈에서 Codex를 실행합니다. 기존 Codex 홈을 지정하여 세션 기록을 가져옵니다. 비워두면 ~/.codex를 사용합니다.", + "03e1a5081a": "on {{value0}}", + "25a41a9aad": "Re-detect agents installed on the active server", + "remoteDetectionFailed": "Couldn’t detect installed agents. Check the host connection and try again.", + "retryDetection": "Retry" }, "AppIconSelector": { "d5a112dc9b": "다음 아이콘", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2645aa755..5c8e57872 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5074,7 +5074,11 @@ "3f1bdf3cb4": "环境文本过长,无法安全解析。", "codexSessionSource": "要导入的 Codex 主目录", "codexSessionSourceInfo": "关于导入 Codex 历史记录", - "codexSessionSourceTooltip": "Orca 在隔离的主目录中运行 Codex。将此项指向您现有的 Codex 主目录以导入会话历史记录。留空则使用 ~/.codex。" + "codexSessionSourceTooltip": "Orca 在隔离的主目录中运行 Codex。将此项指向您现有的 Codex 主目录以导入会话历史记录。留空则使用 ~/.codex。", + "03e1a5081a": "on {{value0}}", + "25a41a9aad": "Re-detect agents installed on the active server", + "remoteDetectionFailed": "Couldn’t detect installed agents. Check the host connection and try again.", + "retryDetection": "Retry" }, "AppIconSelector": { "d5a112dc9b": "下一个图标", diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index b98340fe6..8764d8127 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -28,6 +28,7 @@ import { createAgentStatusSlice } from './slices/agent-status' import { createPaneForegroundAgentSlice } from './slices/pane-foreground-agent' import { createDiffCommentsSlice } from './slices/diffComments' import { createDetectedAgentsSlice } from './slices/detected-agents' +import { createRuntimeDetectedAgentsSlice } from './slices/runtime-detected-agents' import { createWorktreeNavHistorySlice } from './slices/worktree-nav-history' import { createDictationSlice } from './slices/dictation' import { createWorkspaceCleanupSlice } from './slices/workspace-cleanup' @@ -71,6 +72,7 @@ export const useAppStore = create()((...a) => ({ ...createPaneForegroundAgentSlice(...a), ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), + ...createRuntimeDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), ...createDictationSlice(...a), ...createWorkspaceCleanupSlice(...a), diff --git a/src/renderer/src/store/slices/detected-agents.test.ts b/src/renderer/src/store/slices/detected-agents.test.ts index 0d9575eee..1755f3550 100644 --- a/src/renderer/src/store/slices/detected-agents.test.ts +++ b/src/renderer/src/store/slices/detected-agents.test.ts @@ -2,11 +2,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' import type { Repo, Worktree } from '../../../../shared/types' +import { _getRemoteDetectPromiseCountForTest, createDetectedAgentsSlice } from './detected-agents' import { - _getRemoteDetectPromiseCountForTest, _getRuntimeDetectPromiseCountForTest, - createDetectedAgentsSlice -} from './detected-agents' + createRuntimeDetectedAgentsSlice +} from './runtime-detected-agents' import { MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_PROTOCOL_VERSION @@ -38,7 +38,8 @@ function createTestStore(initial?: Partial) { const store = create()( (...a) => ({ - ...createDetectedAgentsSlice(...a) + ...createDetectedAgentsSlice(...a), + ...createRuntimeDetectedAgentsSlice(...a) }) as AppState ) store.setState({ @@ -478,6 +479,46 @@ describe('createDetectedAgentsSlice remote detection', () => { expect(detectRemoteAgents).toHaveBeenCalledTimes(1) }) + it('deduplicates concurrent SSH refreshes', async () => { + const store = createTestStore() + store.setState({ remoteDetectedAgentIds: { 'ssh-1': ['claude'] } } as Partial) + let resolveRemote: (ids: string[]) => void = () => {} + detectRemoteAgents.mockReturnValueOnce( + new Promise((resolve) => { + resolveRemote = resolve + }) + ) + + const first = store.getState().refreshRemoteDetectedAgents('ssh-1') + const second = store.getState().refreshRemoteDetectedAgents('ssh-1') + + expect(second).toBe(first) + expect(detectRemoteAgents).toHaveBeenCalledTimes(1) + expect(store.getState().remoteDetectedAgentIds['ssh-1']).toEqual(['claude']) + + resolveRemote(['codex']) + await expect(first).resolves.toEqual(['codex']) + expect(store.getState().remoteDetectedAgentIds['ssh-1']).toEqual(['codex']) + }) + + it('does not restore an SSH cache entry after it is cleared mid-detection', async () => { + const store = createTestStore() + let resolveRemote: (ids: string[]) => void = () => {} + detectRemoteAgents.mockReturnValueOnce( + new Promise((resolve) => { + resolveRemote = resolve + }) + ) + + const pending = store.getState().ensureRemoteDetectedAgents('ssh-1') + store.getState().clearRemoteDetectedAgents('ssh-1') + resolveRemote(['claude']) + + await expect(pending).resolves.toEqual(['claude']) + expect(store.getState().remoteDetectedAgentIds).not.toHaveProperty('ssh-1') + expect(store.getState().isDetectingRemoteAgents).not.toHaveProperty('ssh-1') + }) + it('re-runs remote detection after an empty result instead of pinning it', async () => { const store = createTestStore() // An empty [] is truthy, so a prior "no agents found" must not be cached: @@ -561,4 +602,220 @@ describe('createDetectedAgentsSlice remote detection', () => { expect(store.getState().runtimeDetectedAgentIds['env-1']).toEqual(['kilo']) expect(detectCalls).toBe(2) }) + + it('refreshes runtime agents through preflight.refreshAgents on the owning runtime', async () => { + const store = createTestStore() + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + let result: unknown + if (method === 'status.get') { + result = { + runtimeId: 'remote-runtime', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + } else if (method === 'preflight.refreshAgents') { + result = { + agents: ['claude', 'gemini'], + addedPathSegments: [], + shellHydrationOk: true, + pathSource: 'shell_hydrate', + pathFailureReason: 'none' + } + } else { + result = ['codex'] + } + return Promise.resolve({ + id: method, + ok: true, + result, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + const first = store.getState().refreshRuntimeDetectedAgents('env-1') + const second = store.getState().refreshRuntimeDetectedAgents('env-1') + + expect(store.getState().isRefreshingRuntimeAgents['env-1']).toBe(true) + await expect(first).resolves.toEqual(['claude', 'gemini']) + await expect(second).resolves.toEqual(['claude', 'gemini']) + expect(store.getState().runtimeDetectedAgentIds['env-1']).toEqual(['claude', 'gemini']) + expect(store.getState().isRefreshingRuntimeAgents['env-1']).toBe(false) + expect( + runtimeEnvironmentCall.mock.calls.filter( + ([{ method }]) => method === 'preflight.refreshAgents' + ) + ).toHaveLength(1) + }) + + it('keeps a late initial detect from overwriting a runtime refresh', async () => { + const store = createTestStore() + let resolveDetect: (value: unknown) => void = () => {} + let resolveRefresh: (value: unknown) => void = () => {} + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + if (method === 'status.get') { + return Promise.resolve({ + id: method, + ok: true, + result: { + runtimeId: 'remote-runtime', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return new Promise((resolve) => { + if (method === 'preflight.detectAgents') { + resolveDetect = resolve + } else { + resolveRefresh = resolve + } + }) + }) + + const detect = store.getState().ensureRuntimeDetectedAgents('env-1') + await vi.waitFor(() => { + expect( + runtimeEnvironmentCall.mock.calls.filter( + ([{ method }]) => method === 'preflight.detectAgents' + ) + ).toHaveLength(1) + }) + + const refresh = store.getState().refreshRuntimeDetectedAgents('env-1') + expect(store.getState().ensureRuntimeDetectedAgents('env-1')).toBe(refresh) + expect(store.getState().isDetectingRuntimeAgents['env-1']).toBe(true) + expect(store.getState().isRefreshingRuntimeAgents['env-1']).toBe(true) + await vi.waitFor(() => { + expect( + runtimeEnvironmentCall.mock.calls.filter( + ([{ method }]) => method === 'preflight.refreshAgents' + ) + ).toHaveLength(1) + }) + resolveRefresh({ + id: 'preflight.refreshAgents', + ok: true, + result: { + agents: ['kilo'], + addedPathSegments: [], + shellHydrationOk: true, + pathSource: 'shell_hydrate', + pathFailureReason: 'none' + }, + _meta: { runtimeId: 'remote-runtime' } + }) + await expect(refresh).resolves.toEqual(['kilo']) + + resolveDetect({ + id: 'preflight.detectAgents', + ok: true, + result: ['claude'], + _meta: { runtimeId: 'remote-runtime' } + }) + await expect(detect).resolves.toEqual(['claude']) + + expect(store.getState().runtimeDetectedAgentIds['env-1']).toEqual(['kilo']) + expect(store.getState().isDetectingRuntimeAgents['env-1']).toBe(false) + expect(store.getState().isRefreshingRuntimeAgents['env-1']).toBe(false) + expect( + runtimeEnvironmentCall.mock.calls.filter( + ([{ method }]) => method === 'preflight.refreshAgents' + ) + ).toHaveLength(1) + }) + + it('falls back to plain runtime re-detection when the server lacks preflight.refreshAgents', async () => { + const store = createTestStore() + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + if (method === 'preflight.refreshAgents') { + return Promise.resolve({ + id: method, + ok: false, + error: { code: 'method_not_found', message: `Unknown method: ${method}` }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + const result = + method === 'status.get' + ? { + runtimeId: 'remote-runtime', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + : ['kilo'] + return Promise.resolve({ + id: method, + ok: true, + result, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + await expect(store.getState().refreshRuntimeDetectedAgents('env-1')).resolves.toEqual(['kilo']) + expect(store.getState().runtimeDetectedAgentIds['env-1']).toEqual(['kilo']) + expect(store.getState().isRefreshingRuntimeAgents['env-1']).toBe(false) + }) + + it('does not retry ordinary runtime refresh failures with a second RPC', async () => { + const store = createTestStore() + store.setState({ runtimeDetectedAgentIds: { 'env-1': ['claude'] } } as Partial) + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + if (method === 'status.get') { + return Promise.resolve({ + id: method, + ok: true, + result: { + runtimeId: 'remote-runtime', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + if (method === 'preflight.refreshAgents') { + return Promise.resolve({ + id: method, + ok: false, + error: { code: 'runtime_error', message: 'runtime disconnected' }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return Promise.resolve({ + id: method, + ok: true, + result: ['codex'], + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + await expect(store.getState().refreshRuntimeDetectedAgents('env-1')).resolves.toEqual([ + 'claude' + ]) + expect(store.getState().runtimeDetectedAgentIds['env-1']).toEqual(['claude']) + expect(store.getState().isRefreshingRuntimeAgents['env-1']).toBe(false) + expect( + runtimeEnvironmentCall.mock.calls.filter(([{ method }]) => method.startsWith('preflight.')) + ).toHaveLength(1) + }) }) diff --git a/src/renderer/src/store/slices/detected-agents.ts b/src/renderer/src/store/slices/detected-agents.ts index 449bc81b1..e3a4f4947 100644 --- a/src/renderer/src/store/slices/detected-agents.ts +++ b/src/renderer/src/store/slices/detected-agents.ts @@ -5,7 +5,6 @@ import { getLocalAgentPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' -import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' export type DetectedAgentsSlice = { detectedAgentIds: TuiAgent[] | null @@ -31,19 +30,13 @@ export type DetectedAgentsSlice = { // separate map keyed by SSH connectionId. remoteDetectedAgentIds: Record isDetectingRemoteAgents: Record - ensureRemoteDetectedAgents: (connectionId: string) => Promise + ensureRemoteDetectedAgents: ( + connectionId: string, + options?: { force?: boolean } + ) => Promise + /** Forces one fresh SSH probe per connection while preserving the cached list. */ + refreshRemoteDetectedAgents: (connectionId: string) => Promise clearRemoteDetectedAgents: (connectionId: string) => void - - // Why: remote runtime hosts are not SSH connections, but their tab-bar - // launch menu still has to probe the host where the workspace actually runs. - runtimeDetectedAgentIds: Record - isDetectingRuntimeAgents: Record - ensureRuntimeDetectedAgents: (environmentId: string) => Promise - clearRuntimeDetectedAgents: (environmentId: string) => void - /** Drops runtime detected-agent caches for environments not in the kept set. - * Wired into setRuntimeEnvironments so removed environments don't leak their - * detected-agent entries for the renderer session. */ - retainRuntimeDetectedAgents: (environmentIds: Iterable) => void } // Why: these are module-scoped (not in the store) so we can deduplicate @@ -53,16 +46,12 @@ let refreshPromise: { key: string; promise: Promise } | null = null let detectedContextKey: string | null = null let localDetectionGeneration = 0 const remoteDetectPromises = new Map>() -const runtimeDetectPromises = new Map>() +const remoteRefreshPromises = new Map>() export function _getRemoteDetectPromiseCountForTest(): number { return remoteDetectPromises.size } -export function _getRuntimeDetectPromiseCountForTest(): number { - return runtimeDetectPromises.size -} - export const createDetectedAgentsSlice: StateCreator = ( set, get @@ -180,15 +169,13 @@ export const createDetectedAgentsSlice: StateCreator { + ensureRemoteDetectedAgents: (connectionId: string, options?: { force?: boolean }) => { const existing = get().remoteDetectedAgentIds[connectionId] // Why: an empty result ([]) is truthy, so a prior "no agents found" detection // must not be treated as cached — re-detect so a later install / PATH fix is // picked up without a reconnect. Non-empty results still short-circuit. - if (existing?.length) { + if (existing?.length && options?.force !== true) { return Promise.resolve(existing) } const inflight = remoteDetectPromises.get(connectionId) @@ -204,17 +191,21 @@ export const createDetectedAgentsSlice: StateCreator { const typed = ids as TuiAgent[] - set((s) => ({ - remoteDetectedAgentIds: { ...s.remoteDetectedAgentIds, [connectionId]: typed }, - isDetectingRemoteAgents: { ...s.isDetectingRemoteAgents, [connectionId]: false } - })) + if (remoteDetectPromises.get(connectionId) === pending) { + set((s) => ({ + remoteDetectedAgentIds: { ...s.remoteDetectedAgentIds, [connectionId]: typed }, + isDetectingRemoteAgents: { ...s.isDetectingRemoteAgents, [connectionId]: false } + })) + } return typed }) .catch(() => { // Why: allow retry on next call (SSH may reconnect). Do not cache failure. - set((s) => ({ - isDetectingRemoteAgents: { ...s.isDetectingRemoteAgents, [connectionId]: false } - })) + if (remoteDetectPromises.get(connectionId) === pending) { + set((s) => ({ + isDetectingRemoteAgents: { ...s.isDetectingRemoteAgents, [connectionId]: false } + })) + } return [] as TuiAgent[] }) .finally(() => { @@ -230,112 +221,38 @@ export const createDetectedAgentsSlice: StateCreator { + const inflightRefresh = remoteRefreshPromises.get(connectionId) + if (inflightRefresh) { + return inflightRefresh + } + const inflightDetect = remoteDetectPromises.get(connectionId) + if (inflightDetect) { + return inflightDetect + } + + const pending = get() + .ensureRemoteDetectedAgents(connectionId, { force: true }) + .finally(() => { + if (remoteRefreshPromises.get(connectionId) === pending) { + remoteRefreshPromises.delete(connectionId) + } + }) + remoteRefreshPromises.set(connectionId, pending) + return pending + }, + // Why: the remote agent list is tied to a live SSH connection. On disconnect // the relay is gone, so clear both the cached result and the deduplication // promise. When the user reconnects and opens the quick-launch menu, // ensureRemoteDetectedAgents will re-detect against the new relay. clearRemoteDetectedAgents: (connectionId: string) => { remoteDetectPromises.delete(connectionId) + remoteRefreshPromises.delete(connectionId) set((s) => { const { [connectionId]: _, ...restAgents } = s.remoteDetectedAgentIds const { [connectionId]: __, ...restLoading } = s.isDetectingRemoteAgents return { remoteDetectedAgentIds: restAgents, isDetectingRemoteAgents: restLoading } }) - }, - - ensureRuntimeDetectedAgents: (environmentId: string) => { - const existing = get().runtimeDetectedAgentIds[environmentId] - // Why: an empty result ([]) is truthy, so a prior "no agents found" detection - // must not be treated as cached — re-detect so a later install / PATH fix is - // picked up without a reconnect. Non-empty results still short-circuit. - if (existing?.length) { - return Promise.resolve(existing) - } - const inflight = runtimeDetectPromises.get(environmentId) - if (inflight) { - return inflight - } - - set((s) => ({ - isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [environmentId]: true } - })) - - const pending = callRuntimeRpc( - { kind: 'environment', environmentId }, - 'preflight.detectAgents' - ) - .then((ids) => { - const typed = ids as TuiAgent[] - // Why: skip committing if the environment was removed (retained out) - // while the detect was in flight — otherwise it re-adds a stale entry - // that retainRuntimeDetectedAgents just pruned. - if (runtimeDetectPromises.get(environmentId) === pending) { - set((s) => ({ - runtimeDetectedAgentIds: { ...s.runtimeDetectedAgentIds, [environmentId]: typed }, - isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [environmentId]: false } - })) - } - return typed - }) - .catch(() => { - // Why: a remote runtime may be disconnected or version-incompatible. - // Keep the menu retryable instead of pinning a failed probe forever. - // Same in-flight guard as the .then() above: if the environment was - // retained out mid-detect, don't re-add the isDetecting entry that - // retainRuntimeDetectedAgents just pruned (and don't clobber a freshly - // started detect's spinner). - if (runtimeDetectPromises.get(environmentId) === pending) { - set((s) => ({ - isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [environmentId]: false } - })) - } - return [] as TuiAgent[] - }) - .finally(() => { - if (runtimeDetectPromises.get(environmentId) === pending) { - runtimeDetectPromises.delete(environmentId) - } - }) - - runtimeDetectPromises.set(environmentId, pending) - return pending - }, - - clearRuntimeDetectedAgents: (environmentId: string) => { - runtimeDetectPromises.delete(environmentId) - set((s) => { - const { [environmentId]: _, ...restAgents } = s.runtimeDetectedAgentIds - const { [environmentId]: __, ...restLoading } = s.isDetectingRuntimeAgents - return { runtimeDetectedAgentIds: restAgents, isDetectingRuntimeAgents: restLoading } - }) - }, - - retainRuntimeDetectedAgents: (environmentIds: Iterable) => { - const keep = new Set(environmentIds) - for (const id of runtimeDetectPromises.keys()) { - if (!keep.has(id)) { - runtimeDetectPromises.delete(id) - } - } - set((s) => { - let changed = false - const nextAgents = { ...s.runtimeDetectedAgentIds } - const nextLoading = { ...s.isDetectingRuntimeAgents } - for (const id of Object.keys(nextAgents)) { - if (!keep.has(id)) { - delete nextAgents[id] - changed = true - } - } - for (const id of Object.keys(nextLoading)) { - if (!keep.has(id)) { - delete nextLoading[id] - changed = true - } - } - return changed - ? { runtimeDetectedAgentIds: nextAgents, isDetectingRuntimeAgents: nextLoading } - : s - }) } }) diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index 274924773..feb98289c 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -133,6 +133,7 @@ import { createAgentStatusSlice } from './agent-status' import { createPaneForegroundAgentSlice } from './pane-foreground-agent' import { createDiffCommentsSlice } from './diffComments' import { createDetectedAgentsSlice } from './detected-agents' +import { createRuntimeDetectedAgentsSlice } from './runtime-detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' import { createDictationSlice } from './dictation' import { createWorkspaceCleanupSlice } from './workspace-cleanup' @@ -175,6 +176,7 @@ function createTestStore() { ...createPaneForegroundAgentSlice(...a), ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), + ...createRuntimeDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), ...createDictationSlice(...a), ...createWorkspaceCleanupSlice(...a), diff --git a/src/renderer/src/store/slices/runtime-detected-agents.ts b/src/renderer/src/store/slices/runtime-detected-agents.ts new file mode 100644 index 000000000..7da5bef6d --- /dev/null +++ b/src/renderer/src/store/slices/runtime-detected-agents.ts @@ -0,0 +1,242 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { TuiAgent } from '../../../../shared/types' +import { callRuntimeRpc, RuntimeRpcCallError } from '@/runtime/runtime-rpc-client' + +// Why: remote runtime hosts are not SSH connections, but their launch surfaces +// (tab bar, quick launch, Settings → Agents under an Active Server) still have +// to probe the host where the workspace actually runs. +export type RuntimeDetectedAgentsSlice = { + runtimeDetectedAgentIds: Record + isDetectingRuntimeAgents: Record + isRefreshingRuntimeAgents: Record + ensureRuntimeDetectedAgents: (environmentId: string) => Promise + /** Forces a re-detect on the runtime host via `preflight.refreshAgents` + * (login-shell PATH re-read), falling back to `preflight.detectAgents` for + * servers that predate the refresh RPC. */ + refreshRuntimeDetectedAgents: (environmentId: string) => Promise + clearRuntimeDetectedAgents: (environmentId: string) => void + /** Drops runtime detected-agent caches for environments not in the kept set. + * Wired into setRuntimeEnvironments so removed environments don't leak their + * detected-agent entries for the renderer session. */ + retainRuntimeDetectedAgents: (environmentIds: Iterable) => void +} + +// Why: these are module-scoped (not in the store) so we can deduplicate +// concurrent callers without storing a Promise in Zustand state. +const runtimeDetectPromises = new Map>() +const runtimeRefreshPromises = new Map>() + +function isRuntimeMethodNotFoundError(error: unknown): boolean { + return error instanceof RuntimeRpcCallError && error.code === 'method_not_found' +} + +export function _getRuntimeDetectPromiseCountForTest(): number { + return runtimeDetectPromises.size +} + +export function _getRuntimeRefreshPromiseCountForTest(): number { + return runtimeRefreshPromises.size +} + +export const createRuntimeDetectedAgentsSlice: StateCreator< + AppState, + [], + [], + RuntimeDetectedAgentsSlice +> = (set, get) => ({ + runtimeDetectedAgentIds: {}, + isDetectingRuntimeAgents: {}, + isRefreshingRuntimeAgents: {}, + + ensureRuntimeDetectedAgents: (environmentId: string) => { + const inflightRefresh = runtimeRefreshPromises.get(environmentId) + if (inflightRefresh) { + return inflightRefresh + } + const existing = get().runtimeDetectedAgentIds[environmentId] + // Why: an empty result ([]) is truthy, so a prior "no agents found" detection + // must not be treated as cached — re-detect so a later install / PATH fix is + // picked up without a reconnect. Non-empty results still short-circuit. + if (existing?.length) { + return Promise.resolve(existing) + } + const inflight = runtimeDetectPromises.get(environmentId) + if (inflight) { + return inflight + } + + set((s) => ({ + isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [environmentId]: true } + })) + + const pending = callRuntimeRpc( + { kind: 'environment', environmentId }, + 'preflight.detectAgents' + ) + .then((ids) => { + const typed = ids as TuiAgent[] + // Why: skip committing if the environment was removed (retained out) + // while the detect was in flight — otherwise it re-adds a stale entry + // that retainRuntimeDetectedAgents just pruned. + if (runtimeDetectPromises.get(environmentId) === pending) { + set((s) => ({ + runtimeDetectedAgentIds: { ...s.runtimeDetectedAgentIds, [environmentId]: typed }, + isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [environmentId]: false } + })) + } + return typed + }) + .catch(() => { + // Why: a remote runtime may be disconnected or version-incompatible. + // Keep the menu retryable instead of pinning a failed probe forever. + // Same in-flight guard as the .then() above: if the environment was + // retained out mid-detect, don't re-add the isDetecting entry that + // retainRuntimeDetectedAgents just pruned (and don't clobber a freshly + // started detect's spinner). + if (runtimeDetectPromises.get(environmentId) === pending) { + set((s) => ({ + isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [environmentId]: false } + })) + } + return [] as TuiAgent[] + }) + .finally(() => { + if (runtimeDetectPromises.get(environmentId) === pending) { + runtimeDetectPromises.delete(environmentId) + } + }) + + runtimeDetectPromises.set(environmentId, pending) + return pending + }, + + refreshRuntimeDetectedAgents: (environmentId: string) => { + const inflight = runtimeRefreshPromises.get(environmentId) + if (inflight) { + return inflight + } + + // Why: a refresh is newer and authoritative; detach an older detect so its + // late result cannot overwrite the freshly hydrated PATH result. + runtimeDetectPromises.delete(environmentId) + set((s) => ({ + isRefreshingRuntimeAgents: { ...s.isRefreshingRuntimeAgents, [environmentId]: true } + })) + + const pending = callRuntimeRpc<{ agents: TuiAgent[] }>( + { kind: 'environment', environmentId }, + 'preflight.refreshAgents' + ) + .then((result) => result.agents) + .catch((error) => { + if (!isRuntimeMethodNotFoundError(error)) { + throw error + } + // Why: only older servers need the fallback; retrying disconnects and + // runtime failures doubles remote work without any chance of recovery. + return callRuntimeRpc( + { kind: 'environment', environmentId }, + 'preflight.detectAgents' + ) + }) + .then((ids) => { + const typed = ids as TuiAgent[] + // Why: same guard as ensureRuntimeDetectedAgents — if the environment + // was retained out mid-refresh, don't re-add a pruned entry. + if (runtimeRefreshPromises.get(environmentId) === pending) { + set((s) => ({ + runtimeDetectedAgentIds: { ...s.runtimeDetectedAgentIds, [environmentId]: typed }, + isDetectingRuntimeAgents: { + ...s.isDetectingRuntimeAgents, + [environmentId]: false + }, + isRefreshingRuntimeAgents: { ...s.isRefreshingRuntimeAgents, [environmentId]: false } + })) + } + return typed + }) + .catch(() => { + // Why: a disconnected runtime must keep Refresh retryable and must not + // wipe the last known agent list. + if (runtimeRefreshPromises.get(environmentId) === pending) { + set((s) => ({ + isDetectingRuntimeAgents: { + ...s.isDetectingRuntimeAgents, + [environmentId]: false + }, + isRefreshingRuntimeAgents: { ...s.isRefreshingRuntimeAgents, [environmentId]: false } + })) + } + return get().runtimeDetectedAgentIds[environmentId] ?? [] + }) + .finally(() => { + if (runtimeRefreshPromises.get(environmentId) === pending) { + runtimeRefreshPromises.delete(environmentId) + } + }) + + runtimeRefreshPromises.set(environmentId, pending) + return pending + }, + + clearRuntimeDetectedAgents: (environmentId: string) => { + runtimeDetectPromises.delete(environmentId) + runtimeRefreshPromises.delete(environmentId) + set((s) => { + const { [environmentId]: _, ...restAgents } = s.runtimeDetectedAgentIds + const { [environmentId]: __, ...restLoading } = s.isDetectingRuntimeAgents + const { [environmentId]: ___, ...restRefreshing } = s.isRefreshingRuntimeAgents + return { + runtimeDetectedAgentIds: restAgents, + isDetectingRuntimeAgents: restLoading, + isRefreshingRuntimeAgents: restRefreshing + } + }) + }, + + retainRuntimeDetectedAgents: (environmentIds: Iterable) => { + const keep = new Set(environmentIds) + for (const id of runtimeDetectPromises.keys()) { + if (!keep.has(id)) { + runtimeDetectPromises.delete(id) + } + } + for (const id of runtimeRefreshPromises.keys()) { + if (!keep.has(id)) { + runtimeRefreshPromises.delete(id) + } + } + set((s) => { + let changed = false + const nextAgents = { ...s.runtimeDetectedAgentIds } + const nextLoading = { ...s.isDetectingRuntimeAgents } + const nextRefreshing = { ...s.isRefreshingRuntimeAgents } + for (const id of Object.keys(nextAgents)) { + if (!keep.has(id)) { + delete nextAgents[id] + changed = true + } + } + for (const id of Object.keys(nextLoading)) { + if (!keep.has(id)) { + delete nextLoading[id] + changed = true + } + } + for (const id of Object.keys(nextRefreshing)) { + if (!keep.has(id)) { + delete nextRefreshing[id] + changed = true + } + } + return changed + ? { + runtimeDetectedAgentIds: nextAgents, + isDetectingRuntimeAgents: nextLoading, + isRefreshingRuntimeAgents: nextRefreshing + } + : s + }) + } +}) diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index d9342fe62..6ad9d61bb 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -36,6 +36,7 @@ import { createAgentStatusSlice } from './agent-status' import { createPaneForegroundAgentSlice } from './pane-foreground-agent' import { createDiffCommentsSlice } from './diffComments' import { createDetectedAgentsSlice } from './detected-agents' +import { createRuntimeDetectedAgentsSlice } from './runtime-detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' import { createDictationSlice } from './dictation' import { createWorkspaceCleanupSlice } from './workspace-cleanup' @@ -87,6 +88,7 @@ export function createTestStore() { ...createPaneForegroundAgentSlice(...a), ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), + ...createRuntimeDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), ...createDictationSlice(...a), ...createWorkspaceCleanupSlice(...a), diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index 3841eab88..8d64bead5 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -26,6 +26,7 @@ import type { AgentStatusSlice } from './slices/agent-status' import type { PaneForegroundAgentSlice } from './slices/pane-foreground-agent' import type { DiffCommentsSlice } from './slices/diffComments' import type { DetectedAgentsSlice } from './slices/detected-agents' +import type { RuntimeDetectedAgentsSlice } from './slices/runtime-detected-agents' import type { WorktreeNavHistorySlice } from './slices/worktree-nav-history' import type { DictationSlice } from './slices/dictation' import type { WorkspaceCleanupSlice } from './slices/workspace-cleanup' @@ -66,6 +67,7 @@ export type AppState = RepoSlice & PaneForegroundAgentSlice & DiffCommentsSlice & DetectedAgentsSlice & + RuntimeDetectedAgentsSlice & WorktreeNavHistorySlice & DictationSlice & WorkspaceCleanupSlice &