diff --git a/config/scripts/check-test-file-runners.mjs b/config/scripts/check-test-file-runners.mjs index 68f22769568..8e0a738743e 100644 --- a/config/scripts/check-test-file-runners.mjs +++ b/config/scripts/check-test-file-runners.mjs @@ -22,6 +22,12 @@ const NON_UNIT_TEST_RUNNERS = [ sources: ['tests/playwright.config.ts'], mirrors: ["testDir: './e2e'", "testMatch: '**/*.spec.ts'"] }, + { + name: 'component fixtures Playwright', + include: ['tests/component-fixtures/**/*.spec.ts'], + sources: ['tests/component-fixtures/playwright.config.ts'], + mirrors: ["testDir: '.'", "testMatch: '*.spec.ts'"] + }, { name: 'computer-use e2e', include: ['tests/e2e/computer-*.e2e.ts'], diff --git a/src/renderer/src/components/agent/AgentLaunchPickerMenu.language.test.tsx b/src/renderer/src/components/agent/AgentLaunchPickerMenu.language.test.tsx new file mode 100644 index 00000000000..0bc0da0d791 --- /dev/null +++ b/src/renderer/src/components/agent/AgentLaunchPickerMenu.language.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { expect, it, vi } from 'vitest' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { TooltipProvider } from '@/components/ui/tooltip' +import { i18n } from '@/i18n/i18n' +import es from '@/i18n/locales/es.json' +import { AgentLaunchPickerMenu } from './AgentLaunchPickerMenu' +import type { AgentPickerEntry } from '../../../../shared/agent-launch-picker' + +vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: () => null })) + +it('refreshes translated row labels on a language change with unchanged props', async () => { + const language = i18n.language + const host = document.createElement('div') + document.body.append(host) + const root = createRoot(host) + const entry: AgentPickerEntry = { + agent: 'codex', + label: 'Codex', + rows: [{ agent: 'codex', kind: 'chat-unavailable', reason: 'ssh' }], + surfaces: ['terminal'], + defaultSurface: 'terminal', + terminal: {}, + structured: {}, + catalog: { origin: 'probe', fetchedAt: 1, models: [] } + } + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + try { + i18n.addResourceBundle('es', 'translation', es) + await i18n.changeLanguage('en') + act(() => + root.render( + + + New tab + + + + + + ) + ) + const row = document.querySelector('[role="menuitem"]')! + expect(row.textContent).toContain('No chat over SSH') + await act(async () => { + await i18n.changeLanguage('es') + }) + expect(row.textContent).toContain(es.auto.components.tab.bar.QuickLaunchButton.chatNotOverSsh) + expect(row.textContent).not.toContain('No chat over SSH') + } finally { + act(() => root.unmount()) + host.remove() + await i18n.changeLanguage(language) + vi.unstubAllGlobals() + } +}) diff --git a/src/renderer/src/components/agent/AgentLaunchPickerMenu.search.test.tsx b/src/renderer/src/components/agent/AgentLaunchPickerMenu.search.test.tsx new file mode 100644 index 00000000000..ba3cd12d540 --- /dev/null +++ b/src/renderer/src/components/agent/AgentLaunchPickerMenu.search.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { ComponentProps } from 'react' +import { AgentLaunchSetupButton } from './AgentLaunchSetupButton' +import { AgentLaunchPickerMenu } from './AgentLaunchPickerMenu' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import type { AgentPickerEntry } from '../../../../shared/agent-launch-picker' + +vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: () => null })) +vi.mock('./AgentLaunchSetupButton', () => ({ + AgentLaunchSetupButton: vi.fn((props: ComponentProps) => + props.renderAnchor?.() + ) +})) +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +it('skips unchanged search rows on unrelated inventory updates and launches current choices', () => { + const codex: AgentPickerEntry = { + agent: 'codex', + label: 'Codex', + rows: [{ agent: 'codex', kind: 'cli' }], + surfaces: ['terminal'], + defaultSurface: 'terminal', + terminal: {}, + structured: {}, + catalog: { + origin: 'probe', + fetchedAt: 1, + models: [ + { + id: 'host-model', + label: 'Host model', + isDefault: true, + efforts: [{ value: 'low', label: 'Low' }] + } + ] + } + } + const claude: AgentPickerEntry = { + ...codex, + agent: 'claude', + label: 'Claude', + rows: [{ agent: 'claude', kind: 'cli' }], + catalog: { origin: 'probe', fetchedAt: 1, models: [] } + } + const launch = vi.fn() + const menu = (entries: AgentPickerEntry[]) => ( + + Open + + + + + ) + const view = render(menu([codex, claude])) + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'host low' } }) + expect(screen.getByRole('menuitem').textContent).toContain('Codex · Host model · Low') + expect(vi.mocked(AgentLaunchSetupButton).mock.lastCall?.[0].initial).toEqual({ + agent: 'codex', + surface: 'terminal', + choices: { model: 'host-model', options: { effort: 'low' } } + }) + vi.mocked(AgentLaunchSetupButton).mockClear() + view.rerender( + menu([codex, { ...claude, catalog: { origin: 'probe', fetchedAt: 2, models: [] } }]) + ) + expect(AgentLaunchSetupButton).not.toHaveBeenCalled() + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'host' } }) + expect(AgentLaunchSetupButton).toHaveBeenCalled() + fireEvent.click(screen.getByRole('menuitem')) + expect(launch).toHaveBeenCalledWith( + expect.objectContaining({ agent: 'codex', kind: 'cli' }), + { agent: 'codex', surface: 'terminal', choices: { model: 'host-model' } }, + codex + ) +}) diff --git a/src/renderer/src/components/agent/AgentLaunchPickerMenu.tsx b/src/renderer/src/components/agent/AgentLaunchPickerMenu.tsx index e375a1dfe46..e9558d180f1 100644 --- a/src/renderer/src/components/agent/AgentLaunchPickerMenu.tsx +++ b/src/renderer/src/components/agent/AgentLaunchPickerMenu.tsx @@ -1,11 +1,16 @@ -import { useId, useRef, useState, type PointerEvent } from 'react' +import { memo, useId, useRef, useState, type ComponentProps, type PointerEvent } from 'react' import { Input } from '@/components/ui/input' import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu' import { AgentIcon } from '@/lib/agent-catalog' import { translate } from '@/i18n/i18n' +import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision' import { AgentLaunchSetupButton } from './AgentLaunchSetupButton' import { chatUnavailableLabel } from '../tab-bar/tab-agent-row-presentation' import type { NewTabAgentMenuRow } from '../../../../shared/agent-launch-picker-rows' +import { + useAgentPickerInventoryEntries, + type AgentPickerInventorySource +} from './use-agent-launch-picker-inventory' import { searchAgentPickerSetups, type AgentPickerDraft, @@ -92,11 +97,8 @@ export function AgentLaunchPickerMenu({ openSetup={openSetup} onSetupOpenChange={setOpenSetup} label={setup.label} - initial={{ - agent: setup.entry.agent, - surface: setup.entry.defaultSurface, - choices: setup.choices - }} + initialModel={setup.choices.model} + initialEffort={setup.choices.options?.effort} onSelectAgent={onSelectAgent} disabled={disabled} runLaunch={runLaunch} @@ -126,12 +128,25 @@ export function AgentLaunchPickerMenu({ ) } -function AgentPickerMenuRow({ +// Subscribe here so inventory updates do not re-render the tab bar. +export function SubscribedAgentLaunchPickerMenu({ + inventory, + ...props +}: Omit, 'entries'> & { + inventory: AgentPickerInventorySource +}) { + const entries = useAgentPickerInventoryEntries(inventory) + return +} + +// Why: catalog and seed reads land one agent at a time after the menu opens; only that row re-renders. +const AgentPickerMenuRow = memo(function AgentPickerMenuRow({ entry, openSetup, onSetupOpenChange, label = entry.label, - initial, + initialModel, + initialEffort, disabled, runLaunch, shortcut, @@ -141,12 +156,24 @@ function AgentPickerMenuRow({ openSetup: string | null onSetupOpenChange: (id: string | null) => void label?: string - initial?: AgentPickerDraft + initialModel?: string + initialEffort?: string disabled?: boolean runLaunch: Launch shortcut?: string onSelectAgent?: (agent: AgentPickerEntry['agent']) => void }) { + useUiLanguageRevision() + const initial: AgentPickerDraft | undefined = initialModel + ? { + agent: entry.agent, + surface: entry.defaultSurface, + choices: { + model: initialModel, + ...(initialEffort ? { options: { effort: initialEffort } } : {}) + } + } + : undefined const setupId = useId() const item = useRef(null) const configured = useRef(undefined) @@ -212,4 +239,4 @@ function AgentPickerMenuRow({ )} /> ) -} +}) diff --git a/src/renderer/src/components/agent/agent-launch-picker-acquisition.ts b/src/renderer/src/components/agent/agent-launch-picker-acquisition.ts index bd6e08e8e13..fddc7ec72ec 100644 --- a/src/renderer/src/components/agent/agent-launch-picker-acquisition.ts +++ b/src/renderer/src/components/agent/agent-launch-picker-acquisition.ts @@ -1,3 +1,4 @@ +import { shallow } from 'zustand/shallow' import type { AgentPickerEntry } from '../../../../shared/agent-launch-picker' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -81,7 +82,7 @@ class AgentPickerAcquisition { return defaults.map((entry, i) => { const read = this.supports.get(i) const current = read && !(reopen && read.state === 'settled') - return { + const next = { ...entry, catalog: this.entries[i].catalog, ...(read @@ -94,6 +95,7 @@ class AgentPickerAcquisition { } : {}) } + return shallow(this.entries[i], next) ? this.entries[i] : next }) } diff --git a/src/renderer/src/components/agent/agent-launch-picker-freshness-test-fixture.tsx b/src/renderer/src/components/agent/agent-launch-picker-freshness-test-fixture.tsx index 76dccdd64e4..65186c01aef 100644 --- a/src/renderer/src/components/agent/agent-launch-picker-freshness-test-fixture.tsx +++ b/src/renderer/src/components/agent/agent-launch-picker-freshness-test-fixture.tsx @@ -3,7 +3,10 @@ import { act, fireEvent, render } from '@testing-library/react' import { expect, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import { AgentLaunchSetupPanel } from './AgentLaunchSetupPanel' -import { useAgentLaunchPickerInventory } from './use-agent-launch-picker-inventory' +import { + useAgentLaunchPickerInventorySource, + useAgentPickerInventoryEntries +} from './use-agent-launch-picker-inventory' import { agentPickerAcquisitionDiagnostics } from './agent-launch-picker-acquisition' import type { NewTabAgentMenuModel } from '../tab-bar/new-tab-agent-menu-model' import * as picker from '../../../../shared/agent-launch-picker' @@ -48,7 +51,9 @@ export async function exerciseAgentPickerFreshness({ const snapshots: picker.AgentPickerEntry[] = [] const current: { entry?: picker.AgentPickerEntry } = {} function Setup({ value }: { value: typeof menu }) { - const [entry] = useAgentLaunchPickerInventory('wt', value, true) + const [entry] = useAgentPickerInventoryEntries( + useAgentLaunchPickerInventorySource('wt', value, true) + ) current.entry = entry snapshots.push(entry) return createElement( diff --git a/src/renderer/src/components/agent/use-agent-launch-picker-inventory.test.tsx b/src/renderer/src/components/agent/use-agent-launch-picker-inventory.test.tsx index b7f1b8edc84..28f3d3a267e 100644 --- a/src/renderer/src/components/agent/use-agent-launch-picker-inventory.test.tsx +++ b/src/renderer/src/components/agent/use-agent-launch-picker-inventory.test.tsx @@ -18,7 +18,10 @@ import * as picker from '../../../../shared/agent-launch-picker' import { projectAgentLaunchSettings } from '../../../../shared/agent-launch-settings' import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' import type { NewTabAgentMenuModel } from '../tab-bar/new-tab-agent-menu-model' -import { useAgentLaunchPickerInventory } from './use-agent-launch-picker-inventory' +import { + useAgentLaunchPickerInventorySource, + useAgentPickerInventoryEntries +} from './use-agent-launch-picker-inventory' import { agentPickerAcquisitionDiagnostics } from './agent-launch-picker-acquisition' vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: () => null })) @@ -132,7 +135,9 @@ it('publishes only committed selection to physical catalog admission after a sus const committed = vi.fn() type Selection = { selected: picker.AgentPickerEntry['agent']; suspend: boolean } function Inventory({ selected, suspend }: Selection) { - const entries = useAgentLaunchPickerInventory('wt', menu, true, selected) + const entries = useAgentPickerInventoryEntries( + useAgentLaunchPickerInventorySource('wt', menu, true, selected) + ) rendered(selected, suspend) useLayoutEffect(() => { committed(selected) @@ -226,7 +231,9 @@ it('reads host Arguments and remembered chat choices without leaking desktop def const menu = model() const hook = renderHook( ({ selected }: { selected?: 'codex' }) => - useAgentLaunchPickerInventory('wt', menu, true, selected), + useAgentPickerInventoryEntries( + useAgentLaunchPickerInventorySource('wt', menu, true, selected) + ), { initialProps: {} } ) expect(hook.result.current[0]).toMatchObject({ @@ -265,7 +272,9 @@ it('uses existing legacy settings and a missing optional seed on an older paired mocks.session.mockImplementation(async (_target, method) => method === 'agentSession.modelCatalog' ? catalog : { supported: true } ) - const hook = renderHook(() => useAgentLaunchPickerInventory('wt', model(), true)) + const hook = renderHook(() => + useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', model(), true)) + ) await act(async () => {}) expect(hook.result.current[0]).toMatchObject({ defaultsPending: false, @@ -288,9 +297,13 @@ it('bounds open-time catalog hydration to two agents and discards responses from mocks.session.mockImplementation((_target, method) => method === 'agentSession.modelCatalog' ? old.promise : Promise.resolve({ supported: true }) ) - const hook = renderHook(({ menu }) => useAgentLaunchPickerInventory('wt', menu, true), { - initialProps: { menu: model('alpha', ['codex', 'claude', 'gemini']) } - }) + const hook = renderHook( + ({ menu }) => + useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', menu, true)), + { + initialProps: { menu: model('alpha', ['codex', 'claude', 'gemini']) } + } + ) await act(async () => {}) const catalogs = () => mocks.session.mock.calls.filter((call) => call[1] === 'agentSession.modelCatalog') @@ -308,6 +321,40 @@ it('bounds open-time catalog hydration to two agents and discards responses from expect(hook.result.current[0].rows[0].executionHostId).toBe('runtime:beta') }) +it('lands catalog reads in subscribers only, replacing just the entry whose catalog changed', async () => { + const reads = new Map>() + mocks.session.mockImplementation((_target, method, params: { agent: string }) => { + if (method !== 'agentSession.modelCatalog') { + return Promise.resolve({ supported: true }) + } + const read = Promise.withResolvers() + reads.set(params.agent, read) + return read.promise + }) + const menu = model('alpha', ['codex', 'claude']) + let ownerRenders = 0 + const owner = renderHook(() => { + ownerRenders += 1 + return useAgentLaunchPickerInventorySource('wt', menu, true) + }) + const subscriber = renderHook(() => useAgentPickerInventoryEntries(owner.result.current)) + await act(async () => {}) + const ownerRendersBeforeReads = ownerRenders + const before = subscriber.result.current + + await act(async () => reads.get('codex')!.resolve(catalog)) + const afterCodex = subscriber.result.current + expect(afterCodex.find((entry) => entry.agent === 'codex')?.catalog).toEqual(catalog) + const beforeClaude = before.find((entry) => entry.agent === 'claude') + expect(beforeClaude).toBeDefined() + expect(afterCodex.find((entry) => entry.agent === 'claude')).toBe(beforeClaude) + + // A failed read changes nothing, so it must not publish a new snapshot. + await act(async () => reads.get('claude')!.reject(new Error('listing unavailable'))) + expect(subscriber.result.current).toBe(afterCodex) + expect(ownerRenders).toBe(ownerRendersBeforeReads) +}) + it.each(['slow', 'failed'] as const)( 'publishes known terminal defaults independently of %s optional catalog/support reads', async (kind) => { @@ -319,7 +366,9 @@ it.each(['slow', 'failed'] as const)( ? Promise.reject(new Error('unavailable')) : Promise.resolve({ seedOptions: { model: 'host-model', permissionMode: 'ask' } }) ) - const hook = renderHook(() => useAgentLaunchPickerInventory('wt', model(), true)) + const hook = renderHook(() => + useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', model(), true)) + ) await act(async () => {}) expect(hook.result.current[0]).toMatchObject({ defaultsPending: false, @@ -350,7 +399,9 @@ it('retains legacy remembered values when createSupport does not supply optional mocks.session.mockImplementation(async (_target, method) => method === 'agentSession.modelCatalog' ? catalog : { supported: true } ) - const hook = renderHook(() => useAgentLaunchPickerInventory('wt', model(), true)) + const hook = renderHook(() => + useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', model(), true)) + ) await act(async () => {}) expect(hook.result.current[0]).toMatchObject({ defaultsPending: false, @@ -363,7 +414,9 @@ it('keeps Chat default controls pending until their independent seed read settle mocks.session.mockImplementation((_target, method) => method === 'agentSession.modelCatalog' ? Promise.resolve(catalog) : support.promise ) - const hook = renderHook(() => useAgentLaunchPickerInventory('wt', model(), true)) + const hook = renderHook(() => + useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', model(), true)) + ) await act(async () => {}) expect(hook.result.current[0]).toMatchObject({ defaultsPending: false, @@ -400,7 +453,10 @@ it('acquires each host agent once per open batch at N and 10N, independently of .map((agent) => ({ agent, kind: 'chat', executionHostId: 'runtime:alpha' })) menu.detection.detectedIds = agents.slice(0, count) const hook = renderHook( - ({ open, selected }) => useAgentLaunchPickerInventory('wt', menu, open, selected), + ({ open, selected }) => + useAgentPickerInventoryEntries( + useAgentLaunchPickerInventorySource('wt', menu, open, selected) + ), { initialProps: { open: true, selected: agents[0] } } ) await act(async () => {}) @@ -433,7 +489,7 @@ it('updates terminal availability when detection changes while the Chat rows sta } const hook = renderHook( ({ value }: { value: Extract }) => - useAgentLaunchPickerInventory('wt', value, true), + useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', value, true)), { initialProps } ) await act(async () => {}) @@ -447,7 +503,7 @@ it('reorders entries when the default agent changes while the rows stay the same const menu = model('default-order', ['codex', 'claude']) const hook = renderHook( ({ value }: { value: Extract }) => - useAgentLaunchPickerInventory('wt', value, true), + useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', value, true)), { initialProps: { value: { ...menu, defaultTuiAgent: 'claude' } } } ) await act(async () => {}) @@ -475,9 +531,13 @@ it.each([3, 30])( }) const menu = model(`shared-${count}`, ['claude', 'codex', 'gemini']) const mount = (open = true) => - renderHook(({ open }) => useAgentLaunchPickerInventory('wt', menu, open), { - initialProps: { open } - }) + renderHook( + ({ open }) => + useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', menu, open)), + { + initialProps: { open } + } + ) const one = mount(), two = mount() for (let i = 0; i < count; i++) { @@ -548,9 +608,13 @@ it.each([ mocks.settings.mockResolvedValue(null) mocks.rpc.mockResolvedValue({ settings: payload }) const menu = model(`invalid-${JSON.stringify(payload)}`) - const hook = renderHook(({ open }) => useAgentLaunchPickerInventory('wt', menu, open), { - initialProps: { open: true } - }) + const hook = renderHook( + ({ open }) => + useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', menu, open)), + { + initialProps: { open: true } + } + ) await act(async () => {}) expect(hook.result.current[0].defaultsPending).toBe(true) expect(mocks.rpc).toHaveBeenCalledTimes(1) @@ -568,9 +632,13 @@ it.each([ it('revokes same-key read authority synchronously, retains failure as unknown, then accepts a late valid current answer', async () => { const menu = model('authority') - const hook = renderHook(({ open }) => useAgentLaunchPickerInventory('wt', menu, open), { - initialProps: { open: true } - }) + const hook = renderHook( + ({ open }) => + useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', menu, open)), + { + initialProps: { open: true } + } + ) await act(async () => {}) expect(hook.result.current[0].defaultsPending).toBe(false) hook.rerender({ open: false }) @@ -603,9 +671,13 @@ it('lets a re-paired owner progress independently of held local-style calls and mocks.settings.mockReturnValueOnce(oldSettings.promise) mocks.session.mockReturnValueOnce(oldOptional.promise).mockReturnValueOnce(oldOptional.promise) const menu = model('re-pair') - const hook = renderHook(({ menu }) => useAgentLaunchPickerInventory('wt', menu, true), { - initialProps: { menu } - }) + const hook = renderHook( + ({ menu }) => + useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', menu, true)), + { + initialProps: { menu } + } + ) if (menu.state !== 'settled' || !menu.pickerContext) { throw new Error('fixture') } @@ -636,7 +708,10 @@ it.each([3, 30])( throw new Error('fixture') } menu.pickerContext.executionHostId = 'local' - const mount = () => renderHook(() => useAgentLaunchPickerInventory('wt', menu, true)) + const mount = () => + renderHook(() => + useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', menu, true)) + ) const surviving = mount() for (let i = 0; i < count; i++) { const transient = mount() @@ -684,7 +759,7 @@ it('updates a mounted setup from current host rows without making ordinary actio hostChatAnswer: () => answer, chatProgram: () => 'available' }).map((row) => ({ ...row, executionHostId: 'runtime:late-offer' as const })) - current = useAgentLaunchPickerInventory('wt', menu, true) + current = useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', menu, true)) return createElement( TooltipProvider, null, diff --git a/src/renderer/src/components/agent/use-agent-launch-picker-inventory.ts b/src/renderer/src/components/agent/use-agent-launch-picker-inventory.ts index 5bb02aeede7..f2ec14a528b 100644 --- a/src/renderer/src/components/agent/use-agent-launch-picker-inventory.ts +++ b/src/renderer/src/components/agent/use-agent-launch-picker-inventory.ts @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react' import { readHostAgentLaunchSettings } from '../settings/agent-launch-settings-transport' import type { NewTabAgentMenuModel } from '../tab-bar/use-new-tab-agent-menu-rows' import { useAppStore } from '@/store' @@ -25,12 +25,57 @@ import { type AgentPickerEntry } from '../../../../shared/agent-launch-picker' -export function useAgentLaunchPickerInventory( +/** + * Live picker entries for one (inventory key, open) generation. Catalog and seed reads land one + * at a time for several seconds after the menu opens; holding them here instead of in the + * owner's React state lets only the components that render entries re-render as they land. + */ +export type AgentPickerInventorySource = { + getSnapshot: () => AgentPickerEntry[] + subscribe: (listener: () => void) => () => void +} + +type WritableAgentPickerInventorySource = AgentPickerInventorySource & { + publish: (entries: readonly AgentPickerEntry[]) => void +} + +function createAgentPickerInventorySource( + initial: AgentPickerEntry[] +): WritableAgentPickerInventorySource { + let entries = initial + const listeners = new Set<() => void>() + return { + getSnapshot: () => entries, + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + publish: (next) => { + // Why: a read that changed nothing (e.g. an unavailable catalog) must not re-render the menu. + if (next.length === entries.length && next.every((entry, i) => entry === entries[i])) { + return + } + entries = [...next] + for (const listener of listeners) { + listener() + } + } + } +} + +export function useAgentPickerInventoryEntries( + source: AgentPickerInventorySource +): AgentPickerEntry[] { + return useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot) +} + +/** Owns acquisition for the menu without subscribing its caller to the entries it publishes. */ +export function useAgentLaunchPickerInventorySource( worktreeId: string | undefined, model: NewTabAgentMenuModel, open: boolean, selected?: AgentPickerEntry['agent'] -): AgentPickerEntry[] { +): AgentPickerInventorySource { const selectedRef = useRef(selected) useLayoutEffect(() => { selectedRef.current = selected @@ -70,11 +115,6 @@ export function useAgentLaunchPickerInventory( ]), [context, worktreeId, executionHostId, model, settings] ) - const generation = useMemo(() => ({ key, open }), [key, open]) - const [snapshot, setSnapshot] = useState<{ - generation: object - entries: AgentPickerEntry[] - } | null>(null) const build = (defaults: AgentPickerDefaultSettings | null | undefined) => model.state === 'settled' ? agentPickerEntriesFromRows( @@ -101,6 +141,14 @@ export function useAgentLaunchPickerInventory( // oxlint-disable-next-line react-hooks/exhaustive-deps -- The semantic key covers rows, detection, target and defaults without restarting for object identity. [key] ) + // A new generation starts from the shared acquisition (or the fallback) until it publishes. + const source = useMemo( + () => + createAgentPickerInventorySource( + (open ? currentAgentPickerAcquisition(key) : undefined) ?? fallback + ), + [key, open, fallback] + ) useEffect(() => { if (!open || !target || !worktreeId || model.state !== 'settled') { return @@ -212,12 +260,10 @@ export function useAgentLaunchPickerInventory( }, { selected: () => selectedRef.current, - publish: (entries) => setSnapshot({ generation, entries: [...entries] }) + publish: source.publish } ) // oxlint-disable-next-line react-hooks/exhaustive-deps -- Only semantic inputs and visibility own observation; selected priority publishes independently after commit. - }, [key, open]) - return snapshot?.generation === generation - ? snapshot.entries - : ((open ? currentAgentPickerAcquisition(key) : undefined) ?? fallback) + }, [key, open, source]) + return source } diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx index f20020b941f..43f7dc451fd 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx @@ -393,7 +393,7 @@ it('gives static/search no agent actions while loading, retaining settings and g onNewBrowserTab: vi.fn() }) ) - expect(controller.result.current.agentLaunchOptions).toEqual([]) + expect(controller.result.current.agentPickerInventory.getSnapshot()).toEqual([]) expect( controller.result.current.createMenuOptions.some((option) => option.kind === 'new-terminal') ).toBe(true) diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index b9465505ade..ae59d2b0f36 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -1,4 +1,4 @@ -import { AgentLaunchPickerMenu } from '@/components/agent/AgentLaunchPickerMenu' +import { SubscribedAgentLaunchPickerMenu } from '@/components/agent/AgentLaunchPickerMenu' import { agentPickerLaunchInputs, type AgentPickerEntry, @@ -22,6 +22,7 @@ import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog' import { useAppStore } from '@/store' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { useAgentMenuShortcutLabels } from './agent-menu-shortcut-labels' +import type { AgentPickerInventorySource } from '../agent/use-agent-launch-picker-inventory' import type { TuiAgent } from '../../../../shared/tui-agent' import type { LaunchSource } from '../../../../shared/telemetry-events' import { translate } from '@/i18n/i18n' @@ -174,14 +175,14 @@ export function ProjectedAgentMenuItems({ disabled = false, model, runLaunch, - pickerEntries, + pickerInventory, onSelectPickerAgent, hidePickerSearch }: { disabled?: boolean model: NewTabAgentMenuModel runLaunch: (row: NewTabAgentMenuRow, draft?: AgentPickerDraft) => void - pickerEntries?: AgentPickerEntry[] + pickerInventory?: AgentPickerInventorySource onSelectPickerAgent?: (agent: AgentPickerEntry['agent']) => void hidePickerSearch?: boolean }): React.JSX.Element { @@ -189,7 +190,7 @@ export function ProjectedAgentMenuItems({ const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent') - const shortcutLabels = useAgentMenuShortcutLabels(pickerEntries ? model : undefined) + const shortcutLabels = useAgentMenuShortcutLabels(pickerInventory ? model : undefined) const openAgentSettings = useCallback(() => { openSettingsTarget({ pane: 'agents', repoId: null }) openSettingsPage() @@ -250,10 +251,10 @@ export function ProjectedAgentMenuItems({ )} ) : null} - {pickerEntries ? ( - & { + inventory: AgentPickerInventorySource +}): React.JSX.Element { + const entries = useAgentPickerInventoryEntries(inventory) + const commandOverrides = useAppStore((state) => state.settings?.agentCmdOverrides) + const launchOptions = useMemo( + () => buildAgentPickerLaunchOptions(entries, commandOverrides), + [entries, commandOverrides] + ) + return +} diff --git a/src/renderer/src/components/tab-bar/host-agent-launch-preferences-source.test.tsx b/src/renderer/src/components/tab-bar/host-agent-launch-preferences-source.test.tsx index 591a7639c97..f2507dcc73c 100644 --- a/src/renderer/src/components/tab-bar/host-agent-launch-preferences-source.test.tsx +++ b/src/renderer/src/components/tab-bar/host-agent-launch-preferences-source.test.tsx @@ -27,7 +27,10 @@ import { ensureAgentLaunchPreferences, readAgentLaunchPreferencesForWorktree } from '../settings/use-host-agent-launch-preferences' -import { useAgentLaunchPickerInventory } from '../agent/use-agent-launch-picker-inventory' +import { + useAgentLaunchPickerInventorySource, + useAgentPickerInventoryEntries +} from '../agent/use-agent-launch-picker-inventory' import { useAgentMenuObservation, useNewTabAgentMenuRows } from './use-new-tab-agent-menu-rows' it('projects one capable source through cold, deadline, late, failed-refresh and empty answers', async () => { @@ -270,7 +273,9 @@ it.each([20, 200])( environmentId: 'alpha' }) renders++ - return useAgentLaunchPickerInventory('wt', menu, false) + return useAgentPickerInventoryEntries( + useAgentLaunchPickerInventorySource('wt', menu, false) + ) }) ) await act(async () => vi.advanceTimersByTimeAsync(3_000)) diff --git a/src/renderer/src/components/tab-bar/tab-bar-create-menu-contract.ts b/src/renderer/src/components/tab-bar/tab-bar-create-menu-contract.ts index 5dc52db91c4..b166f375cd5 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-create-menu-contract.ts +++ b/src/renderer/src/components/tab-bar/tab-bar-create-menu-contract.ts @@ -1,5 +1,6 @@ import type { NewTabAgentMenuModel } from './use-new-tab-agent-menu-rows' import type { AgentPickerEntry } from '../../../../shared/agent-launch-picker' +import type { AgentPickerInventorySource } from '../agent/use-agent-launch-picker-inventory' import type { TabAgentLaunchOption } from './tab-agent-launch-options' import type { useTabAgentMenuLaunch } from './QuickLaunchButton' import type { TabCreateMenuOption } from './tab-create-menu-options' @@ -7,8 +8,8 @@ import type { WindowsShellMenuEntry } from './tab-bar-windows-shell-options' export type TabBarCreateMenuController = { agentMenu: NewTabAgentMenuModel - agentLaunchOptions: TabAgentLaunchOption[] - agentPickerEntries: AgentPickerEntry[] + /** Read through `useAgentPickerInventoryEntries` in the component that renders the entries. */ + agentPickerInventory: AgentPickerInventorySource selectPickerAgent: (agent: AgentPickerEntry['agent']) => void launchAgentRow: ReturnType newTabMenuOpen: boolean diff --git a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx index dfb6d734ced..d73ba8238a3 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx @@ -11,7 +11,7 @@ import { } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ProjectedAgentMenuItems } from './QuickLaunchButton' -import TabBarCreateEntry from './TabBarCreateEntry' +import { TabBarAgentPickerEntry } from './TabBarAgentPickerEntry' import { TabStripScrollIndicator } from './TabStripScrollIndicator' import { TabStripTooltipProvider } from './TabStripTooltipProvider' import { getTabStripScrollMaskClassName } from './tab-strip-scroll-metrics' @@ -75,8 +75,7 @@ export function renderTabBarSurface({ } = runtime const { agentMenu, - agentLaunchOptions, - agentPickerEntries, + agentPickerInventory, selectPickerAgent, launchAgentRow, newTabMenuOpen, @@ -234,13 +233,12 @@ export function renderTabBarSurface({ > {!terminalOnly && onOpenEntry ? ( <> - { queueNewActiveTerminalFocusAfterNewTabMenuClose() @@ -283,7 +281,7 @@ export function renderTabBarSurface({ { const hook = renderHook(() => { const menu = useNewTabAgentMenuRows('wt', true, { kind: 'runtime', environmentId: 'alpha' }) - const entries = useAgentLaunchPickerInventory('wt', menu, true) + const entries = useAgentPickerInventoryEntries( + useAgentLaunchPickerInventorySource('wt', menu, true) + ) return { menu, entries } }) await act(async () => {}) @@ -407,7 +412,9 @@ it('updates actual inventory for native presentation and local permission change }) const local = renderHook(() => { const menu = useNewTabAgentMenuRows('folder:local', true, { kind: 'local' }) - return useAgentLaunchPickerInventory('folder:local', menu, true) + return useAgentPickerInventoryEntries( + useAgentLaunchPickerInventorySource('folder:local', menu, true) + ) }) await act(async () => {}) await act(async () => { @@ -465,10 +472,12 @@ it.each([30, 300])( const hook = renderHook( ({ supplied, query }) => { void query - return useAgentLaunchPickerInventory( - id, - supplied ? model : { ...model, pickerContext: undefined }, - false + return useAgentPickerInventoryEntries( + useAgentLaunchPickerInventorySource( + id, + supplied ? model : { ...model, pickerContext: undefined }, + false + ) ) }, { initialProps: { supplied: true, query: '' } } @@ -553,12 +562,14 @@ it('invalidates mounted picker seeds only for their effective authored-default s const local = renderHook(() => { const menu = useNewTabAgentMenuRows('folder:local', true, { kind: 'local' }) localRenders++ - return useAgentLaunchPickerInventory('folder:local', menu, true) + return useAgentPickerInventoryEntries( + useAgentLaunchPickerInventorySource('folder:local', menu, true) + ) }) const host = renderHook(() => { const menu = useNewTabAgentMenuRows('wt', true, { kind: 'runtime', environmentId: 'alpha' }) hostRenders++ - return useAgentLaunchPickerInventory('wt', menu, true) + return useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', menu, true)) }) await act(async () => {}) const settled = { local: localRenders, host: hostRenders } @@ -655,7 +666,10 @@ it('reprojects a supplied local authored map once without host acquisition', asy const before = compositions.mock.calls.length const profileBefore = profiles.mock.calls.length const hook = renderHook( - ({ supplied }) => useAgentLaunchPickerInventory(undefined, supplied, false), + ({ supplied }) => + useAgentPickerInventoryEntries( + useAgentLaunchPickerInventorySource(undefined, supplied, false) + ), { initialProps: { supplied: model } } ) await act(async () => {}) diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts index d3c3fd9ccd6..4be1917a4a0 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts @@ -1,6 +1,6 @@ import type { TabBarCreateMenuController } from './tab-bar-create-menu-contract' export type { TabBarCreateMenuController } from './tab-bar-create-menu-contract' -import { useAgentLaunchPickerInventory } from '../agent/use-agent-launch-picker-inventory' +import { useAgentLaunchPickerInventorySource } from '../agent/use-agent-launch-picker-inventory' import type { AgentPickerEntry } from '../../../../shared/agent-launch-picker' import { useEffect, useMemo, useRef, useState } from 'react' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' @@ -11,7 +11,6 @@ import { requestNativeChatComposerFocus } from '@/components/native-chat/use-nat import { useNewTabAgentMenuRows } from './use-new-tab-agent-menu-rows' import type { AgentDetectionTarget } from '@/hooks/useDetectedAgents' import type { TabAgentLaunchOption } from './tab-agent-launch-options' -import { buildAgentPickerLaunchOptions } from './tab-agent-launch-options' import { buildTabCreateMenuOptions, type TabCreateMenuOption } from './tab-create-menu-options' import { resolveWindowsShellLaunchTarget } from './windows-shell-launch' import { buildWindowsShellMenuEntries } from './tab-bar-windows-shell-options' @@ -76,14 +75,13 @@ export function useTabBarCreateMenuController({ { wakeOnOpen: true } ) const [selectedPickerAgent, selectPickerAgent] = useState() - const agentPickerEntries = useAgentLaunchPickerInventory( + // Why: subscribed inside the open menu only; catalog reads landing here would re-render the tab bar. + const agentPickerInventory = useAgentLaunchPickerInventorySource( worktreeId, agentMenu, newTabMenuOpen && showAgentLaunchItems, selectedPickerAgent ) - const commandOverrides = useAppStore((state) => state.settings?.agentCmdOverrides) - const agentLaunchOptions = buildAgentPickerLaunchOptions(agentPickerEntries, commandOverrides) const pendingNewTabMenuFocusRef = useRef<{ focus: () => void; synchronous?: true } | null>(null) const pendingNewTabMenuFocusAnimationRef = useRef(null) const pendingNewTabMenuFocusRetryRef = useRef(null) @@ -277,8 +275,7 @@ export function useTabBarCreateMenuController({ }, [newTabMenuOpen]) return { agentMenu, - agentLaunchOptions, - agentPickerEntries, + agentPickerInventory, selectPickerAgent, launchAgentRow, newTabMenuOpen, diff --git a/src/renderer/src/components/ui/context-menu.tsx b/src/renderer/src/components/ui/context-menu.tsx index 7f217eadaed..0ac22823057 100644 --- a/src/renderer/src/components/ui/context-menu.tsx +++ b/src/renderer/src/components/ui/context-menu.tsx @@ -6,6 +6,7 @@ import * as ContextMenuPrimitive from 'radix-ui/context-menu' import { cn } from '@/lib/utils' import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape' import { keepFocusWhileMenuCloses } from './menu-item-closing-focus' +import { useMenuPointerHighlight } from './menu-pointer-highlight' function ContextMenu({ ...props }: React.ComponentProps) { return @@ -70,6 +71,7 @@ function ContextMenuSubContent({ ...props }: React.ComponentProps & VariantProps) { + const pointerHighlight = useMenuPointerHighlight(props) return ( handleImeOverlayEscape(event, props.onEscapeKeyDown)} /> @@ -97,6 +100,7 @@ function ContextMenuContent({ ...props }: React.ComponentProps & VariantProps) { + const pointerHighlight = useMenuPointerHighlight(props) return ( handleImeOverlayEscape(event, props.onEscapeKeyDown)} /> diff --git a/src/renderer/src/components/ui/dropdown-menu.tsx b/src/renderer/src/components/ui/dropdown-menu.tsx index 7ab1989120a..db61f38d1ff 100644 --- a/src/renderer/src/components/ui/dropdown-menu.tsx +++ b/src/renderer/src/components/ui/dropdown-menu.tsx @@ -5,6 +5,7 @@ import * as DropdownMenuPrimitive from 'radix-ui/dropdown-menu' import { cn } from '@/lib/utils' import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape' import { keepFocusWhileMenuCloses } from './menu-item-closing-focus' +import { useMenuPointerHighlight } from './menu-pointer-highlight' function DropdownMenu({ ...props }: React.ComponentProps) { return @@ -28,6 +29,7 @@ function DropdownMenuContent({ style, ...props }: React.ComponentProps) { + const pointerHighlight = useMenuPointerHighlight(props) return ( handleImeOverlayEscape(event, props.onEscapeKeyDown)} /> @@ -215,6 +218,7 @@ function DropdownMenuSubContent({ alignOffset = -5, ...props }: React.ComponentProps) { + const pointerHighlight = useMenuPointerHighlight(props) return ( handleImeOverlayEscape(event, props.onEscapeKeyDown)} /> diff --git a/src/renderer/src/components/ui/menu-pointer-highlight.fixture.tsx b/src/renderer/src/components/ui/menu-pointer-highlight.fixture.tsx new file mode 100644 index 00000000000..e02c9a20be8 --- /dev/null +++ b/src/renderer/src/components/ui/menu-pointer-highlight.fixture.tsx @@ -0,0 +1,49 @@ +import { defineFixture } from '../../component-fixtures/component-fixture' +import * as Dropdown from './dropdown-menu' +import * as Context from './context-menu' + +function MenuPointerFixture({ kind }: { kind: 'dropdown' | 'context' }) { + const Menu = kind === 'dropdown' ? Dropdown.DropdownMenu : Context.ContextMenu + const Trigger = kind === 'dropdown' ? Dropdown.DropdownMenuTrigger : Context.ContextMenuTrigger + const Content = kind === 'dropdown' ? Dropdown.DropdownMenuContent : Context.ContextMenuContent + const Item = kind === 'dropdown' ? Dropdown.DropdownMenuItem : Context.ContextMenuItem + const Sub = kind === 'dropdown' ? Dropdown.DropdownMenuSub : Context.ContextMenuSub + const SubTrigger = + kind === 'dropdown' ? Dropdown.DropdownMenuSubTrigger : Context.ContextMenuSubTrigger + const SubContent = + kind === 'dropdown' ? Dropdown.DropdownMenuSubContent : Context.ContextMenuSubContent + + return ( + + Open menu + + {['First', 'Second'].map((name) => ( + + {name} + + {name} child + {Array.from({ length: 6 }, (_, index) => ( + + {name} option {index + 1} + + ))} + + + ))} + {Array.from({ length: 40 }, (_, index) => ( + Row {index + 1} + ))} + + + ) +} + +export const dropdown = defineFixture({ + width: 800, + render: () => +}) + +export const context = defineFixture({ + width: 800, + render: () => +}) diff --git a/src/renderer/src/components/ui/menu-pointer-highlight.test.tsx b/src/renderer/src/components/ui/menu-pointer-highlight.test.tsx new file mode 100644 index 00000000000..624e7b35348 --- /dev/null +++ b/src/renderer/src/components/ui/menu-pointer-highlight.test.tsx @@ -0,0 +1,386 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useMenuPointerHighlight } from './menu-pointer-highlight' + +let root: Root | null = null +let hit: Element | null = null +let frames: FrameRequestCallback[] = [] + +beforeEach(() => { + hit = null + frames = [] + document.elementFromPoint = vi.fn(() => hit) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => frames.push(cb)) +}) + +afterEach(() => { + act(() => root?.unmount()) + root = null + document.body.innerHTML = '' + vi.restoreAllMocks() +}) + +type MenuProps = Pick, 'onKeyDown' | 'onKeyDownCapture'> & { + open?: boolean + forceMount?: boolean +} + +function Menu({ open = true, forceMount = false, ...props }: MenuProps): React.JSX.Element | null { + const handlers = useMenuPointerHighlight(props) + if (!open && !forceMount) { + return null + } + return ( +