diff --git a/src/renderer/src/components/settings/agent-launch-settings-transport.ts b/src/renderer/src/components/settings/agent-launch-settings-transport.ts index f6da14982d3..4aa84c08187 100644 --- a/src/renderer/src/components/settings/agent-launch-settings-transport.ts +++ b/src/renderer/src/components/settings/agent-launch-settings-transport.ts @@ -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() + +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 } diff --git a/src/renderer/src/components/settings/use-host-agent-launch-preferences.ts b/src/renderer/src/components/settings/use-host-agent-launch-preferences.ts new file mode 100644 index 00000000000..40766bfa77d --- /dev/null +++ b/src/renderer/src/components/settings/use-host-agent-launch-preferences.ts @@ -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 +/** `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() +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(() => ({ + 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 + } + } +} diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index f4466344e35..755c722f326 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -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 ? ( diff --git a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-host-preferences.test.tsx b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-host-preferences.test.tsx new file mode 100644 index 00000000000..261b0e5160e --- /dev/null +++ b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-host-preferences.test.tsx @@ -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'] + }) +}) diff --git a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts index a454d8229ab..f72e20a8e72 100644 --- a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts +++ b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts @@ -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))))), diff --git a/src/renderer/src/components/terminal-agent-tab-shortcut.ts b/src/renderer/src/components/terminal-agent-tab-shortcut.ts index c7cf91fe384..476e18c7a48 100644 --- a/src/renderer/src/components/terminal-agent-tab-shortcut.ts +++ b/src/renderer/src/components/terminal-agent-tab-shortcut.ts @@ -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 } } diff --git a/src/renderer/src/lib/agent-tab-shortcuts.ts b/src/renderer/src/lib/agent-tab-shortcuts.ts index d0925273b8a..d24918c4d41 100644 --- a/src/renderer/src/lib/agent-tab-shortcuts.ts +++ b/src/renderer/src/lib/agent-tab-shortcuts.ts @@ -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, + getState: () => AppState, worktreeId: string ): Promise { 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 }) }