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:
Neil
2026-10-22 21:22:59 -07:00
committed by GitHub
co-authored by Claude
parent 1ec8f079d8
commit f4a1b0586a
7 changed files with 533 additions and 21 deletions
@@ -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 }
}
+6 -10
View File
@@ -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
})
}