Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ import { callRuntimeRpc, runtimeEnvironmentSupportsCapability } from '@/runtime/
export type AgentLaunchSettingsOwner = {
environmentId: string
pairingRevision?: number
runtimeId?: string
}

type SettingsListener = (owner: AgentLaunchSettingsOwner) => void
const settingsListeners = new Set<SettingsListener>()

export function subscribeHostAgentLaunchSettings(listener: SettingsListener): () => void {
settingsListeners.add(listener)
return () => {
settingsListeners.delete(listener)
}
}

export async function readHostAgentLaunchSettings(
Expand All @@ -21,7 +32,8 @@ export async function readHostAgentLaunchSettings(
if (
!(await runtimeEnvironmentSupportsCapability(
owner.environmentId,
AGENT_LAUNCH_SETTINGS_CAPABILITY
AGENT_LAUNCH_SETTINGS_CAPABILITY,
15_000
Comment on lines +35 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '1,145p' src/renderer/src/components/settings/agent-launch-settings-transport.ts
sed -n '1,115p' src/renderer/src/components/settings/use-host-agent-launch-preferences.ts
rg -n 'function runtimeEnvironmentSupportsCapability|const runtimeEnvironmentSupportsCapability|preferencesPending|inventoryReady' src/renderer/src/components

Repository: stablyai/orca

Length of output: 7199


Bound the combined host-settings read deadline.

When the paired host responds slowly, readHostAgentLaunchSettings can spend 15 seconds in the capability check and then another 15 seconds in settings.getAgentLaunch. useHostAgentMenuSettings keeps pending true until both operations settle, so the new-tab menu can remain in its loading state for about 30 seconds while inventoryReady remains false.

Use one 15-second deadline for both operations and pass the remaining time to the RPC.

♻️ Suggested fix
 export async function readHostAgentLaunchSettings(
   owner: AgentLaunchSettingsOwner,
   signal?: AbortSignal
 ): Promise<AgentLaunchSettings | null> {
+  const deadline = Date.now() + 15_000
   if (
     !(await runtimeEnvironmentSupportsCapability(
       owner.environmentId,
       AGENT_LAUNCH_SETTINGS_CAPABILITY,
-      15_000
+      Math.max(1, deadline - Date.now())
     ))
   ) {
     return null
   }
...
-      timeoutMs: 15_000
+      timeoutMs: Math.max(1, deadline - Date.now())

))
) {
return null
Expand All @@ -30,7 +42,12 @@ export async function readHostAgentLaunchSettings(
{ kind: 'environment', environmentId: owner.environmentId },
'settings.getAgentLaunch',
undefined,
{ expectedEnvironmentPairingRevision: owner.pairingRevision, signal, timeoutMs: 15_000 }
{
expectedEnvironmentPairingRevision: owner.pairingRevision,
...(owner.runtimeId ? { expectedEnvironmentRuntimeId: owner.runtimeId } : {}),
signal,
timeoutMs: 15_000
}
)
return result.settings
}
Expand Down Expand Up @@ -71,6 +88,9 @@ export async function mutateHostAgentLaunchSettings(
mutation,
{ expectedEnvironmentPairingRevision: owner.pairingRevision, signal, timeoutMs: 15_000 }
)
if (!signal?.aborted) {
settingsListeners.forEach((listener) => listener(owner))
}
return result.settings
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { useEffect, useState } from 'react'
import type { AgentLaunchSettings } from '../../../../shared/agent-launch-settings'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { lastVerifiedRuntimeStatus } from '../../../../shared/runtime-host-status'
import type { AppState } from '@/store'
import {
buildAgentLaunchRouteInput,
workspaceKindForWorktreeId
} from '@/lib/agent-launch-route-input'
import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision'
import {
readHostAgentLaunchSettings,
subscribeHostAgentLaunchSettings
} from './agent-launch-settings-transport'

type Preferences = Pick<AgentLaunchSettings, 'defaultTuiAgent' | 'disabledTuiAgents'>
/** `desktop`: the host predates host launch settings or has not answered, so this client's own choices apply. */
type Snapshot = Preferences | 'desktop'
type Observation = { key: string; snapshot: Snapshot | null }

// Why: a stalled host must not hold the menu on placeholders for the full RPC timeouts.
const FIRST_READ_DEADLINE_MS = 3_000

// Why: menus mount on open; the last answer per host lets them render at once while revalidating.
const snapshotByEnvironment = new Map<string, { key: string; snapshot: Snapshot }>()
subscribeHostAgentLaunchSettings((owner) => {
snapshotByEnvironment.delete(owner.environmentId)
})

export function clearHostAgentMenuSettingsForTests(): void {
snapshotByEnvironment.clear()
}

function snapshotKey(
environmentId: string | undefined,
pairingRevision: number | undefined,
runtimeId: string | null | undefined
): string {
return JSON.stringify([environmentId, pairingRevision, runtimeId])
}

function cachedSnapshot(environmentId: string | undefined, key: string): Snapshot | null {
const cached = environmentId ? snapshotByEnvironment.get(environmentId) : undefined
return cached?.key === key ? cached.snapshot : null
}

/** The launch preferences the worktree's menu last applied, so shortcuts launch what the menu shows. */
export function readAgentLaunchPreferencesForWorktree(
state: AppState,
worktreeId: string
): { [K in keyof Preferences]: Preferences[K] | undefined } {
const desktop = {
defaultTuiAgent: state.settings?.defaultTuiAgent,
disabledTuiAgents: state.settings?.disabledTuiAgents
}
const workspace = { kind: workspaceKindForWorktreeId(worktreeId), worktreeId }
const host = parseExecutionHostId(
buildAgentLaunchRouteInput(state, { agent: 'claude', workspace }).executionHostId
)
if (host?.kind !== 'runtime') {
return desktop
}
const status = state.runtimeStatusByEnvironmentId.get(host.environmentId)
const key = snapshotKey(
host.environmentId,
getRuntimeEnvironmentRevision(host.environmentId),
lastVerifiedRuntimeStatus(status)?.runtimeId
)
const snapshot = cachedSnapshot(host.environmentId, key)
return snapshot && snapshot !== 'desktop' ? snapshot : desktop
}

export function useHostAgentMenuSettings(
owner: {
executionHostId: string
pairingRevision: number | undefined
runtimeId: string | null | undefined
contact: string
} | null,
desktopSettings: GlobalSettings | null,
active: boolean,
open: boolean
) {
const host = parseExecutionHostId(owner?.executionHostId)
const environmentId = host?.kind === 'runtime' ? host.environmentId : undefined
const { pairingRevision, runtimeId, contact } = owner ?? {}
const key = snapshotKey(environmentId, pairingRevision, runtimeId)
const [observation, setObservation] = useState<Observation>(() => ({
key,
snapshot: cachedSnapshot(environmentId, key)
}))
useEffect(() => {
if (!environmentId || !active) {
return
}
const capturedOwner = { environmentId, pairingRevision, runtimeId: runtimeId ?? undefined }
let controller = new AbortController()
let live = true
const deadline = setTimeout(() => {
if (live) {
setObservation((prev) =>
prev.key === key && prev.snapshot
? prev
: { key, snapshot: cachedSnapshot(environmentId, key) ?? 'desktop' }
)
}
}, FIRST_READ_DEADLINE_MS)
const refresh = (): void => {
controller.abort()
const readController = new AbortController()
controller = readController
void readHostAgentLaunchSettings(capturedOwner, readController.signal).then(
(settings) => {
if (live && !readController.signal.aborted) {
const snapshot = settings
? {
defaultTuiAgent: settings.defaultTuiAgent,
disabledTuiAgents: settings.disabledTuiAgents
}
: 'desktop'
snapshotByEnvironment.set(environmentId, { key, snapshot })
setObservation({ key, snapshot })
}
},
() => {
if (live && !readController.signal.aborted) {
// Why: a failed refresh keeps this runtime's last answer; with none, desktop choices apply as before.
setObservation({ key, snapshot: cachedSnapshot(environmentId, key) ?? 'desktop' })
}
}
)
}
const unsubscribe = subscribeHostAgentLaunchSettings((changedOwner) => {
if (
changedOwner.environmentId === environmentId &&
changedOwner.pairingRevision === pairingRevision
) {
refresh()
}
})
refresh()
return () => {
live = false
clearTimeout(deadline)
controller.abort()
unsubscribe()
}
}, [environmentId, pairingRevision, runtimeId, key, contact, active, open])
if (!environmentId) {
return { settings: desktopSettings, pending: false }
}
const snapshot =
(observation.key === key ? observation.snapshot : null) ?? cachedSnapshot(environmentId, key)
if (snapshot === null) {
return { settings: desktopSettings, pending: true }
}
if (snapshot === 'desktop') {
return { settings: desktopSettings, pending: false }
}
return {
pending: false,
settings: {
...desktopSettings,
defaultTuiAgent: snapshot.defaultTuiAgent,
disabledTuiAgents: snapshot.disabledTuiAgents
}
}
}
3 changes: 2 additions & 1 deletion src/renderer/src/components/tab-bar/QuickLaunchButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export function ProjectedAgentMenuItems({
model: NewTabAgentMenuModel
runLaunch: (row: NewTabAgentMenuRow) => void
}): React.JSX.Element {
const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent)
const desktopDefaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent')
Expand Down Expand Up @@ -210,6 +210,7 @@ export function ProjectedAgentMenuItems({
rows,
detection: { detectedIds, detectionFailed, needsServerUpdate }
} = model
const defaultAgent = 'defaultTuiAgent' in model ? model.defaultTuiAgent : desktopDefaultAgent
return (
<>
{(detectedIds === null && (detectionFailed || needsServerUpdate)) || rows.length === 0 ? (
Expand Down
Loading
Loading