From fd721b06ce689257ad737c9ab162ea65ca82bdf7 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 22 Oct 2026 03:29:21 -0700 Subject: [PATCH 1/3] fix(agents): apply paired host preferences to launch menus --- .../agent-launch-settings-transport.ts | 24 +- .../use-host-agent-launch-preferences.ts | 87 +++++++ ...w-tab-agent-menu-host-preferences.test.tsx | 240 ++++++++++++++++++ .../tab-bar/use-new-tab-agent-menu-rows.ts | 13 +- 4 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/settings/use-host-agent-launch-preferences.ts create mode 100644 src/renderer/src/components/tab-bar/use-new-tab-agent-menu-host-preferences.test.tsx 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..688a3d551c2 --- /dev/null +++ b/src/renderer/src/components/settings/use-host-agent-launch-preferences.ts @@ -0,0 +1,87 @@ +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 { + readHostAgentLaunchSettings, + subscribeHostAgentLaunchSettings +} from './agent-launch-settings-transport' + +type Preferences = Pick +type Observation = + | { key: string; state: 'pending' } + | { key: string; state: 'settled'; preferences: Preferences | null } + +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 = JSON.stringify([environmentId, pairingRevision, runtimeId]) + const [observation, setObservation] = useState({ key, state: 'pending' }) + useEffect(() => { + if (!environmentId || !active) { + return + } + const capturedOwner = { environmentId, pairingRevision, runtimeId: runtimeId ?? undefined } + let controller = new AbortController() + let live = true + const refresh = (): void => { + controller.abort() + const readController = new AbortController() + controller = readController + void readHostAgentLaunchSettings(capturedOwner, readController.signal).then( + (settings) => { + if (live && !readController.signal.aborted) { + setObservation({ key, state: 'settled', preferences: settings }) + } + }, + () => { + if (live && !readController.signal.aborted) { + setObservation((previous) => + previous.key === key && previous.state === 'settled' + ? previous + : { key, state: 'settled', preferences: null } + ) + } + } + ) + } + const unsubscribe = subscribeHostAgentLaunchSettings((changedOwner) => { + if ( + changedOwner.environmentId === environmentId && + changedOwner.pairingRevision === pairingRevision + ) { + refresh() + } + }) + refresh() + return () => { + live = false + controller.abort() + unsubscribe() + } + }, [environmentId, pairingRevision, runtimeId, key, contact, active, open]) + if (!environmentId) { + return { settings: desktopSettings, pending: false } + } + const current = observation.key === key ? observation : null + const preferences = current?.state === 'settled' ? current.preferences : null + return { + pending: current?.state !== 'settled', + settings: { + ...desktopSettings, + defaultTuiAgent: preferences?.defaultTuiAgent ?? null, + disabledTuiAgents: preferences?.disabledTuiAgents ?? [] + } + } +} 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..bfdce55768f --- /dev/null +++ b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-host-preferences.test.tsx @@ -0,0 +1,240 @@ +// @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 { 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() + 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.each(['unsupported', 'offline'])( + 'settles %s hosts without applying desktop visibility or default', + async (mode) => { + if (mode === 'unsupported') { + rpc.supports.mockResolvedValue(false) + } else { + 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(['claude', 'codex', 'opencode']) + if (mode === 'unsupported') { + expect(rpc.call).not.toHaveBeenCalled() + } + } +) 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..cb57e0f3727 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,6 +43,7 @@ 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 { AgentLaunchSurface } from '@/lib/agent-session-launch-plan' @@ -109,7 +110,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 +139,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 +233,8 @@ export function useAgentMenuObservation( const loading = !unresolvedTarget && !inventoryFailed && - ((detection.detectedIds === null && detectionTarget !== undefined) || + (preferencesPending || + (detection.detectedIds === null && detectionTarget !== undefined) || (!plain && (capabilityPending || (!local && localAcquiring) || @@ -279,6 +287,7 @@ export function useAgentMenuObservation( inventoryReady: target === null || (detectionTarget !== undefined && + !preferencesPending && (inventoryFailed || (detection.detectedIds !== null && (agent === null || detection.detectedIds.includes(agent))))), From f3cfd777fc62459809debb40d0b539f7b491123e Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 22 Oct 2026 12:57:03 -0700 Subject: [PATCH 2/3] 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 --- .../use-host-agent-launch-preferences.ts | 56 ++++++++++++---- ...w-tab-agent-menu-host-preferences.test.tsx | 66 +++++++++++++------ 2 files changed, 89 insertions(+), 33 deletions(-) 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 index 688a3d551c2..85b0d3dbb82 100644 --- a/src/renderer/src/components/settings/use-host-agent-launch-preferences.ts +++ b/src/renderer/src/components/settings/use-host-agent-launch-preferences.ts @@ -8,9 +8,27 @@ import { } from './agent-launch-settings-transport' type Preferences = Pick -type Observation = - | { key: string; state: 'pending' } - | { key: string; state: 'settled'; preferences: Preferences | null } +/** `desktop`: the host predates host launch settings, so this client's own choices still apply. */ +type Snapshot = Preferences | 'desktop' | 'failed' +type Observation = { key: string; snapshot: Snapshot | null } + +// 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: Exclude } +>() +subscribeHostAgentLaunchSettings((owner) => { + snapshotByEnvironment.delete(owner.environmentId) +}) + +export function clearHostAgentMenuSettingsForTests(): void { + snapshotByEnvironment.clear() +} + +function cachedSnapshot(environmentId: string | undefined, key: string): Snapshot | null { + const cached = environmentId ? snapshotByEnvironment.get(environmentId) : undefined + return cached?.key === key ? cached.snapshot : null +} export function useHostAgentMenuSettings( owner: { @@ -27,7 +45,10 @@ export function useHostAgentMenuSettings( const environmentId = host?.kind === 'runtime' ? host.environmentId : undefined const { pairingRevision, runtimeId, contact } = owner ?? {} const key = JSON.stringify([environmentId, pairingRevision, runtimeId]) - const [observation, setObservation] = useState({ key, state: 'pending' }) + const [observation, setObservation] = useState(() => ({ + key, + snapshot: cachedSnapshot(environmentId, key) + })) useEffect(() => { if (!environmentId || !active) { return @@ -42,16 +63,20 @@ export function useHostAgentMenuSettings( void readHostAgentLaunchSettings(capturedOwner, readController.signal).then( (settings) => { if (live && !readController.signal.aborted) { - setObservation({ key, state: 'settled', preferences: settings }) + const snapshot = settings + ? { + defaultTuiAgent: settings.defaultTuiAgent, + disabledTuiAgents: settings.disabledTuiAgents + } + : 'desktop' + snapshotByEnvironment.set(environmentId, { key, snapshot }) + setObservation({ key, snapshot }) } }, () => { if (live && !readController.signal.aborted) { - setObservation((previous) => - previous.key === key && previous.state === 'settled' - ? previous - : { key, state: 'settled', preferences: null } - ) + // A transient failure keeps this runtime's last answer. + setObservation({ key, snapshot: cachedSnapshot(environmentId, key) ?? 'failed' }) } } ) @@ -74,10 +99,15 @@ export function useHostAgentMenuSettings( if (!environmentId) { return { settings: desktopSettings, pending: false } } - const current = observation.key === key ? observation : null - const preferences = current?.state === 'settled' ? current.preferences : null + const snapshot = + (observation.key === key ? observation.snapshot : null) ?? cachedSnapshot(environmentId, key) + if (snapshot === 'desktop') { + return { settings: desktopSettings, pending: false } + } + // Why: a first-read failure must not borrow this client's preferences for another machine. + const preferences = snapshot === null || snapshot === 'failed' ? null : snapshot return { - pending: current?.state !== 'settled', + pending: snapshot === null, settings: { ...desktopSettings, defaultTuiAgent: preferences?.defaultTuiAgent ?? null, 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 index bfdce55768f..c4aede13538 100644 --- 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 @@ -17,6 +17,7 @@ import { TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures' import { mutateHostAgentLaunchSettings } from '../settings/agent-launch-settings-transport' +import { clearHostAgentMenuSettingsForTests } 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() })) @@ -72,6 +73,7 @@ beforeEach(() => { }) afterEach(() => { cleanup() + clearHostAgentMenuSettingsForTests() useAppStore.setState(initial, true) setLocalRuntimeCapabilitiesForTests(null) replaceRuntimeEnvironmentRevisions([]) @@ -218,23 +220,47 @@ it('keeps this runtime’s last preferences through a failed reconnect refresh', ) }) -it.each(['unsupported', 'offline'])( - 'settles %s hosts without applying desktop visibility or default', - async (mode) => { - if (mode === 'unsupported') { - rpc.supports.mockResolvedValue(false) - } else { - 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(['claude', 'codex', 'opencode']) - if (mode === 'unsupported') { - expect(rpc.call).not.toHaveBeenCalled() - } - } -) +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('settles an unreachable host without applying desktop visibility or default', 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(['claude', 'codex', 'opencode']) +}) From 8f48a76ab8ef7495fd8e8ffbaa703be948cc81d1 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 22 Oct 2026 20:38:34 -0700 Subject: [PATCH 3/3] 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 --- .../use-host-agent-launch-preferences.ts | 80 +++++++++++++++---- .../components/tab-bar/QuickLaunchButton.tsx | 3 +- ...w-tab-agent-menu-host-preferences.test.tsx | 49 +++++++++++- .../tab-bar/use-new-tab-agent-menu-rows.ts | 11 ++- .../components/terminal-agent-tab-shortcut.ts | 9 ++- src/renderer/src/lib/agent-tab-shortcuts.ts | 16 ++-- 6 files changed, 134 insertions(+), 34 deletions(-) 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 index 85b0d3dbb82..40766bfa77d 100644 --- a/src/renderer/src/components/settings/use-host-agent-launch-preferences.ts +++ b/src/renderer/src/components/settings/use-host-agent-launch-preferences.ts @@ -2,21 +2,28 @@ 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, so this client's own choices still apply. */ -type Snapshot = Preferences | 'desktop' | 'failed' +/** `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: Exclude } ->() +const snapshotByEnvironment = new Map() subscribeHostAgentLaunchSettings((owner) => { snapshotByEnvironment.delete(owner.environmentId) }) @@ -25,11 +32,45 @@ 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 @@ -44,7 +85,7 @@ export function useHostAgentMenuSettings( const host = parseExecutionHostId(owner?.executionHostId) const environmentId = host?.kind === 'runtime' ? host.environmentId : undefined const { pairingRevision, runtimeId, contact } = owner ?? {} - const key = JSON.stringify([environmentId, pairingRevision, runtimeId]) + const key = snapshotKey(environmentId, pairingRevision, runtimeId) const [observation, setObservation] = useState(() => ({ key, snapshot: cachedSnapshot(environmentId, key) @@ -56,6 +97,15 @@ export function useHostAgentMenuSettings( 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() @@ -75,8 +125,8 @@ export function useHostAgentMenuSettings( }, () => { if (live && !readController.signal.aborted) { - // A transient failure keeps this runtime's last answer. - setObservation({ key, snapshot: cachedSnapshot(environmentId, key) ?? 'failed' }) + // Why: a failed refresh keeps this runtime's last answer; with none, desktop choices apply as before. + setObservation({ key, snapshot: cachedSnapshot(environmentId, key) ?? 'desktop' }) } } ) @@ -92,6 +142,7 @@ export function useHostAgentMenuSettings( refresh() return () => { live = false + clearTimeout(deadline) controller.abort() unsubscribe() } @@ -101,17 +152,18 @@ export function useHostAgentMenuSettings( } 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 } } - // Why: a first-read failure must not borrow this client's preferences for another machine. - const preferences = snapshot === null || snapshot === 'failed' ? null : snapshot return { - pending: snapshot === null, + pending: false, settings: { ...desktopSettings, - defaultTuiAgent: preferences?.defaultTuiAgent ?? null, - disabledTuiAgents: preferences?.disabledTuiAgents ?? [] + 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 index c4aede13538..261b0e5160e 100644 --- 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 @@ -17,7 +17,10 @@ import { TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures' import { mutateHostAgentLaunchSettings } from '../settings/agent-launch-settings-transport' -import { clearHostAgentMenuSettingsForTests } from '../settings/use-host-agent-launch-preferences' +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() })) @@ -254,7 +257,7 @@ it('keeps desktop preferences for a host that predates host launch settings', as expect(rpc.call).not.toHaveBeenCalled() }) -it('settles an unreachable host without applying desktop visibility or default', async () => { +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' }) @@ -262,5 +265,45 @@ it('settles an unreachable host without applying desktop visibility or default', await act(async () => {}) expect( hook.result.current.state === 'settled' && hook.result.current.rows.map((row) => row.agent) - ).toEqual(['claude', 'codex', 'opencode']) + ).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 cb57e0f3727..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 @@ -45,11 +45,18 @@ import { readDetectedAgentsForTarget } from '@/lib/agent-detection-target-invent 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, @@ -273,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, 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 }) }