mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(agents): apply paired host preferences to launch menus (#31971)
* fix(agents): apply paired host preferences to launch menus * fix(agents): keep desktop preferences for older hosts and reopen menus instantly Hosts without settings.agent-launch.v1 (before 2026-10-11) cannot store launch preferences, so their menus keep the desktop's visibility and default instead of losing both. Cache the last host answer per environment so freshly mounted menus render at once and revalidate; host-settings saves invalidate the cache. Co-Authored-By: Claude <noreply@anthropic.com> * fix(agents): align new-agent shortcut with host menu and bound first read The tab.newAgent shortcut, its menu badge, and bound per-agent shortcuts now use the same per-host preference snapshot as the menu. A first read that fails or stalls past 3s falls back to desktop preferences (pre-PR behavior) instead of a neutral order or a long placeholder; a late answer still applies. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
@@ -21,7 +32,8 @@ export async function readHostAgentLaunchSettings(
|
||||
if (
|
||||
!(await runtimeEnvironmentSupportsCapability(
|
||||
owner.environmentId,
|
||||
AGENT_LAUNCH_SETTINGS_CAPABILITY
|
||||
AGENT_LAUNCH_SETTINGS_CAPABILITY,
|
||||
15_000
|
||||
))
|
||||
) {
|
||||
return null
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
|
||||
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { projectAgentLaunchSettings } from '../../../../shared/agent-launch-settings'
|
||||
import type { RuntimeEnvironmentStatus } from '../../../../shared/runtime-host-status'
|
||||
import {
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY as CHAT,
|
||||
STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY as CLIENT
|
||||
} from '../../../../shared/protocol-version'
|
||||
import {
|
||||
makeFolderWorkspace,
|
||||
makeWorktree,
|
||||
TEST_REPO
|
||||
} from '@/store/slices/worktrees-slice-test-fixtures'
|
||||
import { mutateHostAgentLaunchSettings } from '../settings/agent-launch-settings-transport'
|
||||
import {
|
||||
clearHostAgentMenuSettingsForTests,
|
||||
readAgentLaunchPreferencesForWorktree
|
||||
} from '../settings/use-host-agent-launch-preferences'
|
||||
import { useNewTabAgentMenuRows } from './use-new-tab-agent-menu-rows'
|
||||
|
||||
const rpc = vi.hoisted(() => ({ call: vi.fn(), supports: vi.fn() }))
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
callRuntimeRpc: rpc.call,
|
||||
runtimeEnvironmentSupportsCapability: rpc.supports
|
||||
}))
|
||||
vi.mock('@/hooks/useDetectedAgents', () => ({
|
||||
useDetectedAgents: () => ({ detectedIds: ['claude', 'codex', 'opencode'] })
|
||||
}))
|
||||
const initial = useAppStore.getState()
|
||||
const server = projectAgentLaunchSettings({
|
||||
defaultTuiAgent: 'codex',
|
||||
disabledTuiAgents: ['opencode']
|
||||
})
|
||||
function status(runtimeId = 'runtime-1'): RuntimeEnvironmentStatus {
|
||||
return {
|
||||
status: {
|
||||
runtimeId,
|
||||
capabilities: [CHAT, CLIENT],
|
||||
rendererGraphEpoch: 0,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: null,
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0
|
||||
},
|
||||
checkedAt: 1
|
||||
}
|
||||
}
|
||||
beforeEach(() => {
|
||||
rpc.call.mockReset().mockResolvedValue({ settings: server })
|
||||
rpc.supports.mockReset().mockResolvedValue(true)
|
||||
replaceRuntimeEnvironmentRevisions([{ id: 'alpha', createdAt: 7 }])
|
||||
setLocalRuntimeCapabilitiesForTests([CHAT, CLIENT])
|
||||
useAppStore.setState({
|
||||
...initial,
|
||||
settings: {
|
||||
...getDefaultSettings('/home/me'),
|
||||
defaultTuiAgent: 'opencode',
|
||||
disabledTuiAgents: ['codex'],
|
||||
experimentalNativeChat: false
|
||||
},
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
['alpha', status()],
|
||||
['beta', status('runtime-2')]
|
||||
]),
|
||||
repos: [TEST_REPO],
|
||||
worktreesByRepo: {
|
||||
[TEST_REPO.id]: [makeWorktree({ id: 'wt', repoId: TEST_REPO.id, hostId: 'runtime:alpha' })]
|
||||
},
|
||||
folderWorkspaces: [makeFolderWorkspace({ id: 'f1', executionHostId: 'runtime:alpha' })]
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearHostAgentMenuSettingsForTests()
|
||||
useAppStore.setState(initial, true)
|
||||
setLocalRuntimeCapabilitiesForTests(null)
|
||||
replaceRuntimeEnvironmentRevisions([])
|
||||
})
|
||||
|
||||
it.each(['wt', 'folder:f1'])(
|
||||
'uses the host list and default for %s without changing desktop settings',
|
||||
async (id) => {
|
||||
const hook = renderHook(() =>
|
||||
useNewTabAgentMenuRows(id, true, { kind: 'runtime', environmentId: 'alpha' })
|
||||
)
|
||||
expect(hook.result.current.state).toBe('loading')
|
||||
await act(async () => {})
|
||||
expect(hook.result.current.state === 'settled' && hook.result.current.rows).toEqual([
|
||||
{ kind: 'terminal', agent: 'codex', executionHostId: 'runtime:alpha' },
|
||||
{ kind: 'terminal', agent: 'claude', executionHostId: 'runtime:alpha' }
|
||||
])
|
||||
expect(rpc.call.mock.calls[0]?.[0]).toEqual({ kind: 'environment', environmentId: 'alpha' })
|
||||
expect(rpc.call.mock.calls[0]?.[3]).toMatchObject({
|
||||
expectedEnvironmentPairingRevision: 7,
|
||||
expectedEnvironmentRuntimeId: 'runtime-1'
|
||||
})
|
||||
expect(useAppStore.getState().settings?.disabledTuiAgents).toEqual(['codex'])
|
||||
}
|
||||
)
|
||||
|
||||
it('filters chat and CLI rows with host preferences while keeping the desktop chat presentation', async () => {
|
||||
useAppStore.setState((state) => ({
|
||||
settings: { ...state.settings!, experimentalNativeChat: true }
|
||||
}))
|
||||
const hook = renderHook(() =>
|
||||
useNewTabAgentMenuRows('folder:f1', true, { kind: 'runtime', environmentId: 'alpha' })
|
||||
)
|
||||
await act(async () => {})
|
||||
expect(hook.result.current.state === 'settled' && hook.result.current.rows[0]).toEqual({
|
||||
kind: 'chat',
|
||||
agent: 'codex',
|
||||
executionHostId: 'runtime:alpha'
|
||||
})
|
||||
expect(
|
||||
hook.result.current.state === 'settled' &&
|
||||
hook.result.current.rows.some((row) => row.agent === 'opencode')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores old reads across host switches, restarts, and re-pairing', async () => {
|
||||
const old = Promise.withResolvers<{ settings: typeof server }>()
|
||||
rpc.call.mockReturnValueOnce(old.promise)
|
||||
const hook = renderHook(
|
||||
({ environmentId }) =>
|
||||
useNewTabAgentMenuRows(
|
||||
{ workspace: { kind: 'folder', runtimeEnvironmentId: environmentId } },
|
||||
true,
|
||||
{ kind: 'runtime', environmentId }
|
||||
),
|
||||
{ initialProps: { environmentId: 'alpha' } }
|
||||
)
|
||||
await act(async () => {})
|
||||
hook.rerender({ environmentId: 'beta' })
|
||||
await act(async () => {})
|
||||
expect(hook.result.current.state === 'settled' && hook.result.current.rows[0]?.agent).toBe(
|
||||
'codex'
|
||||
)
|
||||
await act(async () =>
|
||||
old.resolve({ settings: projectAgentLaunchSettings({ disabledTuiAgents: ['codex'] }) })
|
||||
)
|
||||
expect(hook.result.current.state === 'settled' && hook.result.current.rows[0]?.agent).toBe(
|
||||
'codex'
|
||||
)
|
||||
const restarted = Promise.withResolvers<{ settings: typeof server }>()
|
||||
rpc.call.mockReturnValueOnce(restarted.promise)
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
runtimeStatusByEnvironmentId: new Map([['beta', status('replacement')]])
|
||||
})
|
||||
)
|
||||
expect(hook.result.current.state).toBe('loading')
|
||||
await act(async () => {})
|
||||
replaceRuntimeEnvironmentRevisions([{ id: 'beta', createdAt: 9 }])
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
runtimeStatusByEnvironmentId: new Map([['beta', status('replacement')]])
|
||||
})
|
||||
)
|
||||
await act(async () => {})
|
||||
expect(rpc.call.mock.calls.at(-1)?.[3]).toMatchObject({ expectedEnvironmentPairingRevision: 9 })
|
||||
await act(async () =>
|
||||
restarted.resolve({ settings: projectAgentLaunchSettings({ disabledTuiAgents: ['codex'] }) })
|
||||
)
|
||||
expect(hook.result.current.state === 'settled' && hook.result.current.rows[0]?.agent).toBe(
|
||||
'codex'
|
||||
)
|
||||
})
|
||||
|
||||
it('refreshes after a save, fences the earlier read, and refreshes on reopening', async () => {
|
||||
const old = Promise.withResolvers<{ settings: typeof server }>()
|
||||
rpc.call.mockReturnValueOnce(old.promise)
|
||||
const hook = renderHook(
|
||||
({ open }) => useNewTabAgentMenuRows('wt', open, { kind: 'runtime', environmentId: 'alpha' }),
|
||||
{ initialProps: { open: true } }
|
||||
)
|
||||
await act(async () => {})
|
||||
await act(async () => {
|
||||
await mutateHostAgentLaunchSettings(
|
||||
{ environmentId: 'alpha', pairingRevision: 7 },
|
||||
{ type: 'availability', agent: 'opencode', enabled: false }
|
||||
)
|
||||
})
|
||||
await act(async () =>
|
||||
old.resolve({ settings: projectAgentLaunchSettings({ disabledTuiAgents: ['codex'] }) })
|
||||
)
|
||||
expect(hook.result.current.state === 'settled' && hook.result.current.rows[0]?.agent).toBe(
|
||||
'codex'
|
||||
)
|
||||
hook.rerender({ open: false })
|
||||
rpc.call.mockResolvedValue({
|
||||
settings: projectAgentLaunchSettings({
|
||||
defaultTuiAgent: 'claude',
|
||||
disabledTuiAgents: ['codex']
|
||||
})
|
||||
})
|
||||
hook.rerender({ open: true })
|
||||
await act(async () => {})
|
||||
expect(hook.result.current.state === 'settled' && hook.result.current.rows[0]?.agent).toBe(
|
||||
'claude'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps this runtime’s last preferences through a failed reconnect refresh', async () => {
|
||||
const hook = renderHook(() =>
|
||||
useNewTabAgentMenuRows('wt', true, { kind: 'runtime', environmentId: 'alpha' })
|
||||
)
|
||||
await act(async () => {})
|
||||
rpc.call.mockRejectedValueOnce(new Error('offline'))
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
runtimeStatusByEnvironmentId: new Map([['alpha', { ...status(), hostContactEpoch: 2 }]])
|
||||
})
|
||||
)
|
||||
await act(async () => {})
|
||||
expect(rpc.call).toHaveBeenCalledTimes(2)
|
||||
expect(hook.result.current.state === 'settled' && hook.result.current.rows[0]?.agent).toBe(
|
||||
'codex'
|
||||
)
|
||||
})
|
||||
|
||||
it('reopens a freshly mounted menu with the last host answer, then revalidates', async () => {
|
||||
const first = renderHook(() =>
|
||||
useNewTabAgentMenuRows('wt', true, { kind: 'runtime', environmentId: 'alpha' })
|
||||
)
|
||||
await act(async () => {})
|
||||
first.unmount()
|
||||
const pending = Promise.withResolvers<{ settings: typeof server }>()
|
||||
rpc.call.mockReturnValueOnce(pending.promise)
|
||||
const second = renderHook(() =>
|
||||
useNewTabAgentMenuRows('wt', true, { kind: 'runtime', environmentId: 'alpha' })
|
||||
)
|
||||
expect(second.result.current.state === 'settled' && second.result.current.rows[0]?.agent).toBe(
|
||||
'codex'
|
||||
)
|
||||
await act(async () =>
|
||||
pending.resolve({ settings: projectAgentLaunchSettings({ defaultTuiAgent: 'claude' }) })
|
||||
)
|
||||
expect(second.result.current.state === 'settled' && second.result.current.rows[0]?.agent).toBe(
|
||||
'claude'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps desktop preferences for a host that predates host launch settings', async () => {
|
||||
rpc.supports.mockResolvedValue(false)
|
||||
const hook = renderHook(() =>
|
||||
useNewTabAgentMenuRows('wt', true, { kind: 'runtime', environmentId: 'alpha' })
|
||||
)
|
||||
await act(async () => {})
|
||||
expect(
|
||||
hook.result.current.state === 'settled' && hook.result.current.rows.map((row) => row.agent)
|
||||
).toEqual(['opencode', 'claude'])
|
||||
expect(rpc.call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to desktop preferences when the first read fails', async () => {
|
||||
rpc.call.mockRejectedValue(new Error('offline'))
|
||||
const hook = renderHook(() =>
|
||||
useNewTabAgentMenuRows('wt', true, { kind: 'runtime', environmentId: 'alpha' })
|
||||
)
|
||||
await act(async () => {})
|
||||
expect(
|
||||
hook.result.current.state === 'settled' && hook.result.current.rows.map((row) => row.agent)
|
||||
).toEqual(['opencode', 'claude'])
|
||||
})
|
||||
|
||||
it('stops waiting on a stalled host, then applies its late answer', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const stalled = Promise.withResolvers<{ settings: typeof server }>()
|
||||
rpc.call.mockReturnValueOnce(stalled.promise)
|
||||
const hook = renderHook(() =>
|
||||
useNewTabAgentMenuRows('wt', true, { kind: 'runtime', environmentId: 'alpha' })
|
||||
)
|
||||
expect(hook.result.current.state).toBe('loading')
|
||||
await act(async () => vi.advanceTimersByTime(3_000))
|
||||
expect(hook.result.current.state === 'settled' && hook.result.current.rows[0]?.agent).toBe(
|
||||
'opencode'
|
||||
)
|
||||
await act(async () => stalled.resolve({ settings: server }))
|
||||
expect(hook.result.current.state === 'settled' && hook.result.current.rows[0]?.agent).toBe(
|
||||
'codex'
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('gives the new-agent shortcut and badge the default the host menu applied', async () => {
|
||||
expect(readAgentLaunchPreferencesForWorktree(useAppStore.getState(), 'wt')).toEqual({
|
||||
defaultTuiAgent: 'opencode',
|
||||
disabledTuiAgents: ['codex']
|
||||
})
|
||||
const hook = renderHook(() =>
|
||||
useNewTabAgentMenuRows('wt', true, { kind: 'runtime', environmentId: 'alpha' })
|
||||
)
|
||||
await act(async () => {})
|
||||
expect(hook.result.current.state === 'settled' && hook.result.current.defaultTuiAgent).toBe(
|
||||
'codex'
|
||||
)
|
||||
expect(readAgentLaunchPreferencesForWorktree(useAppStore.getState(), 'wt')).toEqual({
|
||||
defaultTuiAgent: 'codex',
|
||||
disabledTuiAgents: ['opencode']
|
||||
})
|
||||
})
|
||||
@@ -43,12 +43,20 @@ import {
|
||||
} from '@/hooks/useDetectedAgents'
|
||||
import { readDetectedAgentsForTarget } from '@/lib/agent-detection-target-inventory'
|
||||
import { resolveAgentMenuSelection } from '@/lib/agent-menu-selection'
|
||||
import { useHostAgentMenuSettings } from '@/components/settings/use-host-agent-launch-preferences'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type { AgentLaunchSurface } from '@/lib/agent-session-launch-plan'
|
||||
|
||||
export type NewTabAgentMenuModel =
|
||||
| { state: 'loading'; placeholderCount: number }
|
||||
| { state: 'settled'; rows: NewTabAgentMenuRow[]; detection: UseDetectedAgentsResult }
|
||||
| {
|
||||
state: 'settled'
|
||||
rows: NewTabAgentMenuRow[]
|
||||
detection: UseDetectedAgentsResult
|
||||
/** The default these rows were ordered by, so the new-agent badge marks the same agent. */
|
||||
defaultTuiAgent?: GlobalSettings['defaultTuiAgent']
|
||||
}
|
||||
|
||||
function catalogUnlearned(
|
||||
host: string,
|
||||
@@ -109,7 +117,7 @@ export function useAgentMenuObservation(
|
||||
readLocalRuntimeCapabilityObservation,
|
||||
readLocalRuntimeCapabilityObservation
|
||||
)
|
||||
const settings = useAppStore((state) => state.settings)
|
||||
const desktopSettings = useAppStore((state) => state.settings)
|
||||
const workspace =
|
||||
typeof target === 'string'
|
||||
? { kind: workspaceKindForWorktreeId(target), worktreeId: target }
|
||||
@@ -138,6 +146,12 @@ export function useAgentMenuObservation(
|
||||
)
|
||||
const state = useAppStore.getState()
|
||||
const input = buildAgentLaunchRouteInput(state, { agent: 'claude', workspace })
|
||||
const { settings, pending: preferencesPending } = useHostAgentMenuSettings(
|
||||
unresolvedTarget ? null : facts,
|
||||
desktopSettings,
|
||||
open || worktreeShown,
|
||||
open
|
||||
)
|
||||
const chatEnabled = isNativeChatEnabled(settings)
|
||||
const plain = !chatEnabled || hostChatAnswer(input) === 'no-chats'
|
||||
const local = facts.executionHostId === LOCAL_EXECUTION_HOST_ID
|
||||
@@ -226,7 +240,8 @@ export function useAgentMenuObservation(
|
||||
const loading =
|
||||
!unresolvedTarget &&
|
||||
!inventoryFailed &&
|
||||
((detection.detectedIds === null && detectionTarget !== undefined) ||
|
||||
(preferencesPending ||
|
||||
(detection.detectedIds === null && detectionTarget !== undefined) ||
|
||||
(!plain &&
|
||||
(capabilityPending ||
|
||||
(!local && localAcquiring) ||
|
||||
@@ -265,7 +280,7 @@ export function useAgentMenuObservation(
|
||||
state: 'loading',
|
||||
placeholderCount: Math.max(chatAgents.length, detection.detectedIds?.length ?? 0, 2)
|
||||
}
|
||||
: { state: 'settled', rows, detection }
|
||||
: { state: 'settled', rows, detection, defaultTuiAgent: settings?.defaultTuiAgent }
|
||||
return {
|
||||
model,
|
||||
detection,
|
||||
@@ -279,6 +294,7 @@ export function useAgentMenuObservation(
|
||||
inventoryReady:
|
||||
target === null ||
|
||||
(detectionTarget !== undefined &&
|
||||
!preferencesPending &&
|
||||
(inventoryFailed ||
|
||||
(detection.detectedIds !== null &&
|
||||
(agent === null || detection.detectedIds.includes(agent))))),
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { KeybindingActionId, KeybindingOverrides } from '../../../shared/ke
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
import { useAppStore } from '../store'
|
||||
import { readDetectedAgentsForWorktree } from '@/lib/agent-detection-target-inventory'
|
||||
import { readAgentLaunchPreferencesForWorktree } from '@/components/settings/use-host-agent-launch-preferences'
|
||||
import {
|
||||
listBoundAgentTabActions,
|
||||
resolveDefaultAgentForNewTab,
|
||||
@@ -25,11 +26,11 @@ export function resolveTerminalAgentTabShortcut({
|
||||
matchShortcut: (actionId: KeybindingActionId) => boolean
|
||||
}): TerminalAgentTabShortcut {
|
||||
const state = useAppStore.getState()
|
||||
const preferences = readAgentLaunchPreferencesForWorktree(state, activeWorktreeId)
|
||||
if (matchShortcut('tab.newAgent')) {
|
||||
const agent = resolveDefaultAgentForNewTab({
|
||||
defaultTuiAgent: state.settings?.defaultTuiAgent,
|
||||
detectedAgentIds: readDetectedAgentsForWorktree(state, activeWorktreeId),
|
||||
disabledTuiAgents: state.settings?.disabledTuiAgents
|
||||
...preferences,
|
||||
detectedAgentIds: readDetectedAgentsForWorktree(state, activeWorktreeId)
|
||||
})
|
||||
// Why: the cached list is empty until the host's probe lands (e.g. a paired server's
|
||||
// workspace right after it opens); ask that host instead of reporting no agent.
|
||||
@@ -41,7 +42,7 @@ export function resolveTerminalAgentTabShortcut({
|
||||
pendingAgent: resolveDefaultAgentForWorktreeNewTab(useAppStore.getState, activeWorktreeId)
|
||||
}
|
||||
}
|
||||
for (const bound of listBoundAgentTabActions(keybindings, state.settings?.disabledTuiAgents)) {
|
||||
for (const bound of listBoundAgentTabActions(keybindings, preferences.disabledTuiAgents)) {
|
||||
if (matchShortcut(bound.actionId)) {
|
||||
return { actionId: bound.actionId, agent: bound.agent }
|
||||
}
|
||||
|
||||
@@ -7,10 +7,8 @@ import { ALL_TUI_AGENTS } from '../../../shared/tui-agent-display-names'
|
||||
import { normalizeDisabledTuiAgents, pickTuiAgent } from '../../../shared/tui-agent-selection'
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
loadDetectedAgentsForWorktree,
|
||||
type WorktreeAgentInventoryState
|
||||
} from '@/lib/agent-detection-target-inventory'
|
||||
import { loadDetectedAgentsForWorktree } from '@/lib/agent-detection-target-inventory'
|
||||
import { readAgentLaunchPreferencesForWorktree } from '@/components/settings/use-host-agent-launch-preferences'
|
||||
|
||||
export type BoundAgentTabAction = {
|
||||
agent: TuiAgent
|
||||
@@ -62,15 +60,13 @@ export function resolveDefaultAgentForNewTab(args: {
|
||||
|
||||
/** The new-tab default agent for a workspace, from the agents its own host reports. */
|
||||
export async function resolveDefaultAgentForWorktreeNewTab(
|
||||
getState: () => WorktreeAgentInventoryState & Pick<AppState, 'settings'>,
|
||||
getState: () => AppState,
|
||||
worktreeId: string
|
||||
): Promise<TuiAgent | null> {
|
||||
const detectedAgentIds = await loadDetectedAgentsForWorktree(getState(), worktreeId)
|
||||
// Why: settings may change while the host answers; pick with the current ones.
|
||||
const state = getState()
|
||||
// Why: preferences may change while the host answers; pick with the current ones.
|
||||
return resolveDefaultAgentForNewTab({
|
||||
defaultTuiAgent: state.settings?.defaultTuiAgent,
|
||||
detectedAgentIds,
|
||||
disabledTuiAgents: state.settings?.disabledTuiAgents
|
||||
...readAgentLaunchPreferencesForWorktree(getState(), worktreeId),
|
||||
detectedAgentIds
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user