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:
parent
fc05769edf
commit
ee6319ebe4
|
|
@ -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('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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
'Couldn’t 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "次へのアイコン",
|
||||
|
|
|
|||
|
|
@ -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": "다음 아이콘",
|
||||
|
|
|
|||
|
|
@ -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": "下一个图标",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 &
|
||||
|
|
|
|||
Loading…
Reference in New Issue