fix(agents): scope Settings agent list and quick-launch menu to the remote-server host (#9790)

* fix(agents): scope Settings agent list and quick-launch menu to the remote-server host

With a paired Remote Server as the Active Server, Settings → Agents and the
tab-bar + quick-launch items always ran agent detection on the local client's
PATH, so a Windows client showed its own agents while worktree-create
correctly listed the server's.

- Extract TabBar's ssh/runtime/local owner resolution into a shared
  useAgentDetectionTargetForWorktree hook and use it in QuickLaunch, which
  previously resolved only SSH connections and fell back to local for
  paired-runtime worktrees.
- Scope AgentsPane detection (and its Refresh button) to the Active Server,
  with an "on <server>" badge showing which host the list came from.
  Enable/disable/default toggles remain client-side settings.
- Split runtime detection into store/slices/runtime-detected-agents.ts and add
  refreshRuntimeDetectedAgents: preflight.refreshAgents over the relay
  (login-shell PATH re-read), falling back to preflight.detectAgents on
  servers that predate the refresh RPC, keeping the last known list when the
  runtime is unreachable.

* fix(agents): avoid redundant runtime refresh fallback

* fix(agents): dedupe SSH agent refreshes

* fix(agents): preserve remote host boundaries

* fix(agents): prevent remote detection refresh races

* fix(agents): harden remote detection failures

* fix(agents): keep unresolved detection off local host

* fix(agents): keep cold remote ownership unresolved
This commit is contained in:
Brennan Benson 2026-07-22 21:22:18 -07:00 committed by GitHub
parent fc05769edf
commit ee6319ebe4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1255 additions and 246 deletions

View File

@ -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<unknown>) | 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<typeof AgentRuntimeSettingModule>()
return {
...actual,
AgentRuntimeSetting: (props: React.ComponentProps<typeof actual.AgentRuntimeSetting>) => {
agentRuntimeSettingMock.lastRefresh = props.refresh
return actual.AgentRuntimeSetting(props)
}
}
})
type ReactElementLike = {
type: unknown
props: Record<string, unknown>
@ -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('Couldnt 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('Couldnt 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', () => {

View File

@ -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<AgentDetectionTarget>(
() =>
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<Set<string> | null>(
() => (detectedList ? new Set(detectedList) : null),
@ -822,7 +854,9 @@ export function AgentsPane({
<AgentRuntimeSetting
settings={settings}
updateSettings={updateSettings}
refresh={refresh}
// Why: this control changes the client-local Windows/WSL runtime even
// while the Installed list is scoped to an active remote server.
refresh={refreshLocalAgents}
wslSupportedPlatform={wslSupportedPlatform}
wslAvailable={wslAvailable}
wslDistros={wslDistros}
@ -849,6 +883,13 @@ export function AgentsPane({
{detectedAgents.length}{' '}
{translate('auto.components.settings.AgentsPane.ed3e110e61', 'detected')}
</SettingsBadge>
{activeServerName ? (
<SettingsBadge tone="muted">
{translate('auto.components.settings.AgentsPane.03e1a5081a', 'on {{value0}}', {
value0: activeServerName
})}
</SettingsBadge>
) : null}
</span>
}
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"
>
<RefreshCw className={cn('size-3', isRefreshing && 'animate-spin')} />
@ -948,7 +996,7 @@ export function AgentsPane({
</section>
)}
{detectedIds === null && (
{detectedIds === null && !detectionFailed && (
<div className="flex items-center justify-center rounded-md border border-dashed border-border/50 py-6 text-sm text-muted-foreground">
{translate(
'auto.components.settings.AgentsPane.d83834f5e6',
@ -956,6 +1004,28 @@ export function AgentsPane({
)}
</div>
)}
{detectionFailed && (
<div className="flex items-start justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<span className="flex min-w-0 items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
{translate(
'auto.components.settings.AgentsPane.remoteDetectionFailed',
'Couldnt detect installed agents. Check the host connection and try again.'
)}
</span>
<Button
type="button"
variant="ghost"
size="xs"
onClick={handleRefresh}
className="h-6 shrink-0 gap-1.5 px-2 text-destructive hover:text-destructive"
>
<RefreshCw className="size-3" />
{translate('auto.components.settings.AgentsPane.retryDetection', 'Retry')}
</Button>
</div>
)}
</div>
)
}

View File

@ -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

View File

@ -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<string, unknown[]>,
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')

View File

@ -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)

View File

@ -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<typeof useAppStore.getState>['gitStatusByWork
const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntries = []
const EMPTY_AGENT_CMD_OVERRIDES: Partial<Record<TuiAgent, string>> = {}
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<AgentDetectionTarget | undefined>(() => {
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(
() =>

View File

@ -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<typeof getAgentDetectionTargetKeyForWorktree>[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<typeof getAgentDetectionTargetKeyForWorktree>[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<typeof getAgentDetectionTargetKeyForWorktree>[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<typeof getAgentDetectionTargetKeyForWorktree>[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<typeof getAgentDetectionTargetKeyForWorktree>[0]
expect(getAgentDetectionTargetKeyForWorktree(state, 'worktree-99')).toBe('local')
expect(worktreeIdReads).toBe(100)
expect(repoIdReads).toBe(100)
})
})

View File

@ -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<typeof getConnectionIdFromState>[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])
}

View File

@ -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<void> {
})
}
async function renderProbe(target: AgentDetectionTarget): Promise<Root> {
async function renderProbe(target: AgentDetectionTarget | undefined): Promise<Root> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
@ -40,8 +47,17 @@ async function renderProbe(target: AgentDetectionTarget): Promise<Root> {
}
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 }) => {

View File

@ -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<TuiAgent[]>
}
@ -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<string | null>(null)
const observedRemoteTargetKeysRef = useRef<Set<string>>(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<TuiAgent[]> => {
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 }
}

View File

@ -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": "Couldnt detect installed agents. Check the host connection and try again.",
"retryDetection": "Retry",
"02e0143be5": "Installed",
"110b74b022": "No agent (blank terminal)",
"92033495ff": "Auto",

View File

@ -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": "Couldnt detect installed agents. Check the host connection and try again.",
"retryDetection": "Retry"
},
"AppIconSelector": {
"d5a112dc9b": "Icono siguiente",

View File

@ -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": "Couldnt detect installed agents. Check the host connection and try again.",
"retryDetection": "Retry"
},
"AppIconSelector": {
"d5a112dc9b": "次へのアイコン",

View File

@ -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": "Couldnt detect installed agents. Check the host connection and try again.",
"retryDetection": "Retry"
},
"AppIconSelector": {
"d5a112dc9b": "다음 아이콘",

View File

@ -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": "Couldnt detect installed agents. Check the host connection and try again.",
"retryDetection": "Retry"
},
"AppIconSelector": {
"d5a112dc9b": "下一个图标",

View File

@ -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<AppState>()((...a) => ({
...createPaneForegroundAgentSlice(...a),
...createDiffCommentsSlice(...a),
...createDetectedAgentsSlice(...a),
...createRuntimeDetectedAgentsSlice(...a),
...createWorktreeNavHistorySlice(...a),
...createDictationSlice(...a),
...createWorkspaceCleanupSlice(...a),

View File

@ -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<AppState>) {
const store = create<AppState>()(
(...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<AppState>)
let resolveRemote: (ids: string[]) => void = () => {}
detectRemoteAgents.mockReturnValueOnce(
new Promise<string[]>((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<string[]>((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<AppState>)
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)
})
})

View File

@ -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<string, TuiAgent[] | null>
isDetectingRemoteAgents: Record<string, boolean>
ensureRemoteDetectedAgents: (connectionId: string) => Promise<TuiAgent[]>
ensureRemoteDetectedAgents: (
connectionId: string,
options?: { force?: boolean }
) => Promise<TuiAgent[]>
/** Forces one fresh SSH probe per connection while preserving the cached list. */
refreshRemoteDetectedAgents: (connectionId: string) => Promise<TuiAgent[]>
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<string, TuiAgent[] | null>
isDetectingRuntimeAgents: Record<string, boolean>
ensureRuntimeDetectedAgents: (environmentId: string) => Promise<TuiAgent[]>
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<string>) => void
}
// Why: these are module-scoped (not in the store) so we can deduplicate
@ -53,16 +46,12 @@ let refreshPromise: { key: string; promise: Promise<TuiAgent[]> } | null = null
let detectedContextKey: string | null = null
let localDetectionGeneration = 0
const remoteDetectPromises = new Map<string, Promise<TuiAgent[]>>()
const runtimeDetectPromises = new Map<string, Promise<TuiAgent[]>>()
const remoteRefreshPromises = new Map<string, Promise<TuiAgent[]>>()
export function _getRemoteDetectPromiseCountForTest(): number {
return remoteDetectPromises.size
}
export function _getRuntimeDetectPromiseCountForTest(): number {
return runtimeDetectPromises.size
}
export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedAgentsSlice> = (
set,
get
@ -180,15 +169,13 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
remoteDetectedAgentIds: {},
isDetectingRemoteAgents: {},
runtimeDetectedAgentIds: {},
isDetectingRuntimeAgents: {},
ensureRemoteDetectedAgents: (connectionId: string) => {
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<AppState, [], [], DetectedA
.detectRemoteAgents({ connectionId })
.then((ids) => {
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<AppState, [], [], DetectedA
return pending
},
refreshRemoteDetectedAgents: (connectionId: string) => {
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<TuiAgent[]>(
{ 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<string>) => {
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
})
}
})

View File

@ -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),

View File

@ -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<string, TuiAgent[] | null>
isDetectingRuntimeAgents: Record<string, boolean>
isRefreshingRuntimeAgents: Record<string, boolean>
ensureRuntimeDetectedAgents: (environmentId: string) => Promise<TuiAgent[]>
/** 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<TuiAgent[]>
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<string>) => 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<string, Promise<TuiAgent[]>>()
const runtimeRefreshPromises = new Map<string, Promise<TuiAgent[]>>()
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<TuiAgent[]>(
{ 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<TuiAgent[]>(
{ 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<string>) => {
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
})
}
})

View File

@ -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),

View File

@ -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 &