From 43d755534334cb86e232e21cc5bc4b0063fa17ab Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:27:44 -0800 Subject: [PATCH] Use the shared agent picker for terminal quick commands (#38213) Use the shared searchable agent picker in Quick Commands while preserving the full catalog, disabled-agent explanations, independent form drafts and saved commands. Keep dialog search focused through opening and host-preference reloads, and avoid setup-only cleanup for agent-only menus. --- .../agent/AgentLaunchPicker.focus.test.tsx | 127 ++++++++++ .../components/agent/AgentLaunchPicker.tsx | 17 ++ .../agent/AgentLaunchPickerMenu.tsx | 217 ++---------------- .../agent/AgentLaunchPickerMenuRow.tsx | 196 ++++++++++++++++ .../TerminalQuickCommandContentSection.tsx | 127 +++++----- .../TerminalQuickCommandDialog.test.tsx | 149 +++++++++++- .../src/components/ui/dropdown-menu.tsx | 5 +- .../picker/agent-launch-picker.ts | 1 + 8 files changed, 571 insertions(+), 268 deletions(-) create mode 100644 src/renderer/src/components/agent/AgentLaunchPicker.focus.test.tsx create mode 100644 src/renderer/src/components/agent/AgentLaunchPickerMenuRow.tsx diff --git a/src/renderer/src/components/agent/AgentLaunchPicker.focus.test.tsx b/src/renderer/src/components/agent/AgentLaunchPicker.focus.test.tsx new file mode 100644 index 00000000000..187f421cdb7 --- /dev/null +++ b/src/renderer/src/components/agent/AgentLaunchPicker.focus.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { fireEvent, getByRole, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, afterEach, expect, it, vi } from 'vitest' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { TooltipProvider } from '@/components/ui/tooltip' +import { AgentLaunchPicker } from './AgentLaunchPicker' +import type { NewTabAgentMenuModel } from '../tab-bar/use-new-tab-agent-menu-rows' +import * as inventoryModule from './use-agent-launch-picker-inventory' +import type { AgentPickerEntry } from '../../../../shared/agent-launch/picker/agent-launch-picker' +vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: () => null })) +const entry: AgentPickerEntry = { + agent: 'codex', + label: 'Codex', + rows: [{ agent: 'codex', kind: 'chat' }], + surfaces: ['chat', 'terminal'], + defaultSurface: 'chat', + terminal: {}, + structured: {}, + catalog: { + origin: 'probe', + fetchedAt: 1, + models: [ + { id: 'astra', label: 'Astra', isDefault: false, efforts: [{ value: 'low', label: 'Low' }] } + ] + } +} +let root: Root +let host: HTMLDivElement +let owner = 0 +const launch = vi.fn() +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + entry.ownerKey = `trigger-test-${owner++}` + launch.mockClear() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +it('keeps continuous trigger typing in the search inside a dialog', async () => { + const claude: AgentPickerEntry = { + ...entry, + agent: 'claude', + label: 'Claude', + rows: [{ agent: 'claude', kind: 'cli' }] + } + const entries = [entry, claude] + vi.spyOn(inventoryModule, 'useAgentLaunchPickerInventory').mockReturnValue([]) + const model = { + state: 'settled' as const, + rows: entries.flatMap((entry) => entry.rows), + detection: { + detectedIds: entries.map((entry) => entry.agent), + isLoading: false, + isRefreshing: false, + detectionFailed: false, + needsServerUpdate: false, + refresh: async () => entries.map((entry) => entry.agent) + } + } + const show = (currentModel: NewTabAgentMenuModel = model, currentEntries = entries) => + act(() => + root.render( + + + + Agent selection + + + + + ) + ) + show() + const trigger = getByRole(document.body, 'button', { name: 'Select agent' }) + const search = () => + document.querySelector('input[placeholder="Search agents and setups…"]') + fireEvent.keyDown(trigger, { key: 'c', metaKey: true }) + expect(search()).toBeNull() + const user = userEvent.setup() + act(() => trigger.focus()) + await user.keyboard('c') + expect(document.activeElement).toBe(search()) + expect(search()?.value).toBe('c') + expect(search()?.selectionStart).toBe(1) + await user.keyboard('o') + expect(document.activeElement).toBe(search()) + expect(search()?.value).toBe('co') + expect(search()?.selectionStart).toBe(2) + expect(document.querySelector('[aria-label="Configure Claude"]')).toBeNull() + expect(launch).not.toHaveBeenCalled() + await user.keyboard('{Enter}') + expect(launch).toHaveBeenCalledOnce() + expect(launch.mock.calls[0][0]).toBe('codex') + await waitFor(() => expect(document.activeElement).toBe(trigger)) + await user.click(trigger) + expect(document.activeElement).toBe(search()) + expect(search()?.value).toBe('') + await user.keyboard('{ArrowDown}') + expect(document.activeElement).toBe(getByRole(document.body, 'menuitem', { name: /^Codex/ })) + show(model, [...entries]) + expect(document.activeElement).toBe(getByRole(document.body, 'menuitem', { name: /^Codex/ })) + show({ state: 'loading', placeholderCount: 2 }) + expect(search()).toBeNull() + show() + await waitFor(() => expect(document.activeElement).toBe(search())) + await user.keyboard('co') + expect(search()?.value).toBe('co') + expect(search()?.selectionStart).toBe(2) + await user.keyboard('{Escape}') + await waitFor(() => expect(document.activeElement).toBe(trigger)) +}) diff --git a/src/renderer/src/components/agent/AgentLaunchPicker.tsx b/src/renderer/src/components/agent/AgentLaunchPicker.tsx index 1a1459dfd90..8c46c5622b8 100644 --- a/src/renderer/src/components/agent/AgentLaunchPicker.tsx +++ b/src/renderer/src/components/agent/AgentLaunchPicker.tsx @@ -89,12 +89,21 @@ export const AgentLaunchPicker = memo( const ownDraftScope = useAgentPickerDraftScope() const [settingsOpen, setSettingsOpen] = useState(false) const trigger = useRef(null) + const searchInput = useRef(null) + const popupFocusReady = useRef(false) + const setSearchInput = useCallback((input: HTMLInputElement | null) => { + searchInput.current = input + if (popupFocusReady.current) { + input?.focus({ preventScroll: true }) + } + }, []) const [open, setOpenState] = useState(false) // Why: typing on the closed trigger opens the menu with that key already in the search field. const [initialQuery, setInitialQuery] = useState('') const setOpen = useCallback( (next: boolean) => { if (!next) { + popupFocusReady.current = false setInitialQuery('') } setOpenState(next) @@ -209,6 +218,13 @@ export const AgentLaunchPicker = memo( { + popupFocusReady.current = true + if (searchInput.current) { + event.preventDefault() + searchInput.current.focus({ preventScroll: true }) + } + }} className={ matchTriggerWidth ? 'w-(--radix-dropdown-menu-trigger-width) min-w-(--radix-dropdown-menu-trigger-width)' @@ -222,6 +238,7 @@ export const AgentLaunchPicker = memo( model={model} entries={entries} initialQuery={initialQuery} + searchInputRef={setSearchInput} value={value} draftForEntry={draftForEntry} mode="select" diff --git a/src/renderer/src/components/agent/AgentLaunchPickerMenu.tsx b/src/renderer/src/components/agent/AgentLaunchPickerMenu.tsx index ab41d91406d..38933041e55 100644 --- a/src/renderer/src/components/agent/AgentLaunchPickerMenu.tsx +++ b/src/renderer/src/components/agent/AgentLaunchPickerMenu.tsx @@ -1,57 +1,23 @@ import { selectWorktreesByRepo, selectFolderWorkspaces } from '@/store/workspace-record-selectors' -import { memo, useEffect, useId, useRef, useState, type ComponentProps } from 'react' +import { useEffect, useRef, useState, type ComponentProps, type Ref } from 'react' import { useAppStore } from '@/store/index' import { parseExecutionHostId } from '../../../../shared/execution-host' import { Input } from '@/components/ui/input' -import { Button } from '@/components/ui/button' -import { - DropdownMenuItem, - DropdownMenuShortcut, - useDropdownMenuClose -} from '@/components/ui/dropdown-menu' -import { AgentIcon } from '@/lib/agent-catalog' +import { AgentPickerMenuRow, type AgentPickerMenuLaunch } from './AgentLaunchPickerMenuRow' +import { DropdownMenuItem } from '@/components/ui/dropdown-menu' import { translate } from '@/i18n/i18n' -import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision' -import { - AgentLaunchSetupButton, - retireAgentPickerDrafts, - type AgentPickerSetupIdentity -} from './AgentLaunchSetupButton' -import { applyAgentPickerEdit } from '../../../../shared/agent-launch/picker/agent-launch-picker-edits' +import { retireAgentPickerDrafts, type AgentPickerSetupIdentity } from './AgentLaunchSetupButton' import type { NewTabAgentMenuModel } from '../tab-bar/use-new-tab-agent-menu-rows' -import { chatUnavailableLabel } from '../tab-bar/tab-agent-row-presentation' -import type { NewTabAgentMenuRow } from '../../../../shared/agent-launch/picker/agent-launch-picker-rows' import { useAgentPickerInventoryEntries, type AgentPickerInventorySource } from './use-agent-launch-picker-inventory' import { searchAgentPickerSetups } from '../../../../shared/agent-launch/picker/agent-launch-picker-search' -import { - agentPickerDraft, - type AgentPickerDraft, - type AgentPickerEntry, - type AgentPickerSurface +import type { + AgentPickerDraft, + AgentPickerEntry } from '../../../../shared/agent-launch/picker/agent-launch-picker' -export function pickerLaunchRow( - entry: AgentPickerEntry, - surface: AgentPickerSurface -): NewTabAgentMenuRow { - return { - agent: entry.agent, - kind: surface === 'chat' ? 'chat' : 'cli', - executionHostId: entry.rows[0]?.executionHostId - } -} - -type Launch = ( - row: NewTabAgentMenuRow, - draft?: AgentPickerDraft, - entry?: AgentPickerEntry, - /** Select mode: the surface explicitly accepted by a named launch action. */ - surface?: AgentPickerSurface -) => boolean | void | Promise - export function AgentLaunchPickerMenu({ draftScope, shortcutLabels, @@ -64,6 +30,7 @@ export function AgentLaunchPickerMenu({ entries, hideSearch, initialQuery = '', + searchInputRef, onSelectAgent }: { model?: NewTabAgentMenuModel @@ -74,14 +41,19 @@ export function AgentLaunchPickerMenu({ mode?: 'launch' | 'select' value?: AgentPickerDraft draftForEntry?: (entry: AgentPickerEntry) => AgentPickerDraft - runLaunch: Launch + runLaunch: AgentPickerMenuLaunch entries: AgentPickerEntry[] hideSearch?: boolean /** Seeds the search field; read once when the menu mounts. */ initialQuery?: string + /** A supplied ref gives the owning popup control of initial focus. */ + searchInputRef?: Ref onSelectAgent?: (agent: AgentPickerEntry['agent']) => void }) { useEffect(() => { + if (setupDisabled) { + return + } const state = useAppStore.getState() const workspaces = new Set([ ...Object.values(selectWorktreesByRepo(state) ?? {}) @@ -105,7 +77,7 @@ export function AgentLaunchPickerMenu({ }, mode ) - }, [entries, draftScope, mode]) + }, [entries, draftScope, mode, setupDisabled]) const [query, setQuery] = useState(initialQuery) const [activeSetup, setActiveSetup] = useState<{ id: string @@ -156,9 +128,10 @@ export function AgentLaunchPickerMenu({ {!hideSearch ? (
{ // Why: a seeded query must keep the caret after the typed key. const end = event.currentTarget.value.length @@ -253,159 +226,3 @@ export function SubscribedAgentLaunchPickerMenu({ 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, - draftScope, - draftId, - initialModel, - initialEffort, - openSetup, - onSetupOpenChange, - label = entry.label, - source, - selected, - mode, - setupDisabled, - disabled, - runLaunch, - shortcut, - onSelectAgent -}: { - entry: AgentPickerEntry - draftScope?: object - draftId?: string - initialModel?: string - initialEffort?: string - openSetup: string | null - onSetupOpenChange: ( - setup: { - id: string - agent: AgentPickerEntry['agent'] - preset?: AgentPickerSetupIdentity - label: string - } | null - ) => void - label?: string - source?: AgentPickerDraft - selected?: AgentPickerDraft - mode?: 'launch' | 'select' - setupDisabled?: boolean - disabled?: boolean - runLaunch: Launch - shortcut?: string - onSelectAgent?: (agent: AgentPickerEntry['agent']) => void -}) { - useUiLanguageRevision() - const setupId = useId() - const preset = initialModel ? { model: initialModel, effort: initialEffort } : undefined - const initial = preset - ? applyAgentPickerEdit(entry, selected ?? source ?? agentPickerDraft(entry), { - kind: 'choice', - patch: { - model: preset.model, - ...(preset.effort ? { options: { effort: preset.effort } } : {}) - } - }) - : undefined - const closeMenu = useDropdownMenuClose() - const unavailable = entry.rows.find((row) => row.kind === 'chat-unavailable') - if (setupDisabled) { - return ( - { - onSelectAgent?.(entry.agent) - runLaunch(pickerLaunchRow(entry, selected?.surface ?? entry.defaultSurface)) - }} - > - - {label} - {shortcut ? {shortcut} : null} - - ) - } - return ( - { - if (next || openSetup === setupId) { - onSetupOpenChange(next ? { id: setupId, agent: entry.agent, preset, label } : null) - } - if (next && openSetup !== setupId) { - onSelectAgent?.(entry.agent) - } - }} - onUse={(draft) => { - const chosen = mode === 'select' ? draft.surface : undefined - const result = - chosen === undefined - ? runLaunch(pickerLaunchRow(entry, draft.surface), draft, entry) - : runLaunch(pickerLaunchRow(entry, draft.surface), draft, entry, chosen) - if (result !== false) { - closeMenu() - } - return result - }} - renderAnchor={(trigger) => ( -
{ - event.preventDefault() - if (disabled || !entry.surfaces.length) { - return - } - const draft = initial ?? (mode === 'select' ? selected : undefined) - const surface = draft?.surface ?? entry.defaultSurface - const row = draft - ? pickerLaunchRow(entry, surface) - : (entry.rows.find((row) => - surface === 'chat' - ? row.kind === 'chat' - : row.kind === 'terminal' || row.kind === 'cli' - ) ?? pickerLaunchRow(entry, surface)) - const result = draft ? runLaunch(row, draft, entry) : runLaunch(row, undefined) - if (result !== false) { - closeMenu() - } - }} - onKeyDown={(event) => { - if ( - event.target === event.currentTarget && - (event.key === 'Enter' || event.key === ' ') - ) { - event.preventDefault() - event.currentTarget.click() - } - }} - > - - {trigger} -
- )} - /> - ) -}) diff --git a/src/renderer/src/components/agent/AgentLaunchPickerMenuRow.tsx b/src/renderer/src/components/agent/AgentLaunchPickerMenuRow.tsx new file mode 100644 index 00000000000..8603aec070c --- /dev/null +++ b/src/renderer/src/components/agent/AgentLaunchPickerMenuRow.tsx @@ -0,0 +1,196 @@ +import { memo, useId } from 'react' +import { Button } from '@/components/ui/button' +import { + DropdownMenuItem, + DropdownMenuShortcut, + useDropdownMenuClose +} from '@/components/ui/dropdown-menu' +import { AgentIcon } from '@/lib/agent-catalog' +import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision' +import { AgentLaunchSetupButton, type AgentPickerSetupIdentity } from './AgentLaunchSetupButton' +import { applyAgentPickerEdit } from '../../../../shared/agent-launch/picker/agent-launch-picker-edits' +import { chatUnavailableLabel } from '../tab-bar/tab-agent-row-presentation' +import type { NewTabAgentMenuRow } from '../../../../shared/agent-launch/picker/agent-launch-picker-rows' +import { + agentPickerDraft, + type AgentPickerDraft, + type AgentPickerEntry, + type AgentPickerSurface +} from '../../../../shared/agent-launch/picker/agent-launch-picker' + +function pickerLaunchRow(entry: AgentPickerEntry, surface: AgentPickerSurface): NewTabAgentMenuRow { + return { + agent: entry.agent, + kind: surface === 'chat' ? 'chat' : 'cli', + executionHostId: entry.rows[0]?.executionHostId + } +} + +export type AgentPickerMenuLaunch = ( + row: NewTabAgentMenuRow, + draft?: AgentPickerDraft, + entry?: AgentPickerEntry, + /** Select mode: the surface explicitly accepted by a named launch action. */ + surface?: AgentPickerSurface +) => boolean | void | Promise + +// Why: catalog and seed reads land one agent at a time after the menu opens; only that row re-renders. +export const AgentPickerMenuRow = memo(function AgentPickerMenuRow({ + entry, + draftScope, + draftId, + initialModel, + initialEffort, + openSetup, + onSetupOpenChange, + label = entry.label, + source, + selected, + mode, + setupDisabled, + disabled, + runLaunch, + shortcut, + onSelectAgent +}: { + entry: AgentPickerEntry + draftScope?: object + draftId?: string + initialModel?: string + initialEffort?: string + openSetup: string | null + onSetupOpenChange: ( + setup: { + id: string + agent: AgentPickerEntry['agent'] + preset?: AgentPickerSetupIdentity + label: string + } | null + ) => void + label?: string + source?: AgentPickerDraft + selected?: AgentPickerDraft + mode?: 'launch' | 'select' + setupDisabled?: boolean + disabled?: boolean + runLaunch: AgentPickerMenuLaunch + shortcut?: string + onSelectAgent?: (agent: AgentPickerEntry['agent']) => void +}) { + useUiLanguageRevision() + const setupId = useId() + const preset = initialModel ? { model: initialModel, effort: initialEffort } : undefined + const initial = preset + ? applyAgentPickerEdit(entry, selected ?? source ?? agentPickerDraft(entry), { + kind: 'choice', + patch: { + model: preset.model, + ...(preset.effort ? { options: { effort: preset.effort } } : {}) + } + }) + : undefined + const closeMenu = useDropdownMenuClose() + const unavailable = entry.rows.find((row) => row.kind === 'chat-unavailable') + if (setupDisabled) { + return ( + { + onSelectAgent?.(entry.agent) + runLaunch(pickerLaunchRow(entry, selected?.surface ?? entry.defaultSurface)) + }} + > + + + {label} + {entry.description ? ( + {entry.description} + ) : null} + + {shortcut ? {shortcut} : null} + + ) + } + return ( + { + if (next || openSetup === setupId) { + onSetupOpenChange(next ? { id: setupId, agent: entry.agent, preset, label } : null) + } + if (next && openSetup !== setupId) { + onSelectAgent?.(entry.agent) + } + }} + onUse={(draft) => { + const chosen = mode === 'select' ? draft.surface : undefined + const result = + chosen === undefined + ? runLaunch(pickerLaunchRow(entry, draft.surface), draft, entry) + : runLaunch(pickerLaunchRow(entry, draft.surface), draft, entry, chosen) + if (result !== false) { + closeMenu() + } + return result + }} + renderAnchor={(trigger) => ( +
{ + event.preventDefault() + if (disabled || !entry.surfaces.length) { + return + } + const draft = initial ?? (mode === 'select' ? selected : undefined) + const surface = draft?.surface ?? entry.defaultSurface + const row = draft + ? pickerLaunchRow(entry, surface) + : (entry.rows.find((row) => + surface === 'chat' + ? row.kind === 'chat' + : row.kind === 'terminal' || row.kind === 'cli' + ) ?? pickerLaunchRow(entry, surface)) + const result = draft ? runLaunch(row, draft, entry) : runLaunch(row, undefined) + if (result !== false) { + closeMenu() + } + }} + onKeyDown={(event) => { + if ( + event.target === event.currentTarget && + (event.key === 'Enter' || event.key === ' ') + ) { + event.preventDefault() + event.currentTarget.click() + } + }} + > + + {trigger} +
+ )} + /> + ) +}) diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandContentSection.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandContentSection.tsx index 9d708cfa813..ec66016fd95 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandContentSection.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandContentSection.tsx @@ -1,4 +1,11 @@ -import { useId, type Dispatch, type MutableRefObject, type SetStateAction } from 'react' +import { + useCallback, + useId, + useMemo, + type Dispatch, + type MutableRefObject, + type SetStateAction +} from 'react' import type { TerminalQuickCommand } from '../../../../shared/terminal/quick-commands/terminal-quick-command-types' import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent' import { @@ -10,14 +17,10 @@ import { type TerminalQuickCommandBodySize } from '../../../../shared/terminal/quick-commands/terminal-quick-command-prompt-limit' import { Label } from '@/components/ui/label' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select' -import { AgentIcon } from '@/lib/agent-catalog' +import { AgentLaunchPicker } from '@/components/agent/AgentLaunchPicker' +import type { AgentPickerEntry } from '../../../../shared/agent-launch/picker/agent-launch-picker' +import type { NewTabAgentMenuModel } from '../tab-bar/new-tab-agent-menu-model' +import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision' import { cn } from '@/lib/utils' import { getIntlLocale, translate } from '@/i18n/i18n' import { getTerminalQuickCommandAgentOptions } from './terminal-quick-command-agent-options' @@ -25,6 +28,18 @@ import type { TerminalQuickCommandDialogDraftMemory } from './terminal-quick-com import { TerminalQuickCommandAppendEnterSwitch } from './TerminalQuickCommandAppendEnterSwitch' const QUICK_COMMAND_AGENT_OPTIONS = getTerminalQuickCommandAgentOptions() +const QUICK_COMMAND_PICKER_MODEL: NewTabAgentMenuModel = { + state: 'settled', + rows: QUICK_COMMAND_AGENT_OPTIONS.map(({ id }) => ({ agent: id, kind: 'cli' })), + detection: { + detectedIds: [], + isLoading: false, + isRefreshing: false, + detectionFailed: false, + needsServerUpdate: false, + refresh: async () => [] + } +} // Why: long pastes are the common way to hit the cap, so the count appears before Save disables. const BODY_LENGTH_COUNTER_THRESHOLD = 0.8 @@ -79,6 +94,38 @@ export function TerminalQuickCommandContentSection({ setDraft, toggleAppendEnter }: TerminalQuickCommandContentSectionProps): React.JSX.Element { + useUiLanguageRevision() + const unsupportedLabel = translate( + 'auto.components.terminal.quick.commands.TerminalQuickCommandDialog.026cfb232a', + 'Does not support prompt commands' + ) + const entries = useMemo( + () => + QUICK_COMMAND_AGENT_OPTIONS.map(({ id, label }) => { + const supported = supportsTerminalAgentQuickCommand(id) + return { + agent: id, + label, + description: supported ? undefined : unsupportedLabel, + rows: [{ agent: id, kind: 'cli' }], + surfaces: supported ? ['terminal'] : [], + defaultSurface: 'terminal', + terminal: {}, + structured: {}, + catalog: { origin: 'unknown' } + } + }), + [unsupportedLabel] + ) + const selectAgent = useCallback( + (nextAgent: TuiAgent) => { + draftMemoryRef.current = { ...draftMemoryRef.current, agent: nextAgent } + setDraft((current) => + isTerminalAgentQuickCommand(current) ? { ...current, agent: nextAgent } : current + ) + }, + [draftMemoryRef, setDraft] + ) const bodyTooLong = bodySize.used > bodySize.max const showBodyLength = bodySize.used >= bodySize.max * BODY_LENGTH_COUNTER_THRESHOLD const lengthId = useId() @@ -124,58 +171,18 @@ export function TerminalQuickCommandContentSection({ 'Agent' )} - + onPick={selectAgent} + />
diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx index 6f27d439ff7..61b1e119d3a 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx @@ -2,10 +2,18 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' +import { fireEvent, getByRole } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TerminalQuickCommand } from '../../../../shared/terminal/quick-commands/terminal-quick-command-types' import { LEGACY_MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH } from '../../../../shared/terminal/quick-commands/terminal-quick-command-prompt-limit' import { TerminalQuickCommandDialog } from './TerminalQuickCommandDialog' +import { useAppStore } from '@/store' +import { + makeFolderWorkspace, + makeWorktree, + TEST_REPO +} from '@/store/slices/worktrees-slice-test-fixtures' const mountedRoots: Root[] = [] @@ -192,13 +200,13 @@ describe('TerminalQuickCommandDialog animation structure', () => { }) } - const agentSelect = document.body.querySelector('[role="combobox"]')! + const agentSelect = getByRole(document.body, 'button', { + name: 'Select agent' + }) await submit(agentSelect) expect(agentSelect.getAttribute('data-state')).toBe('open') expect(onSave).not.toHaveBeenCalled() - const codex = Array.from(document.body.querySelectorAll('[role="option"]')).find( - (option) => option.textContent?.trim() === 'Codex' - )! + const codex = getByRole(document.body, 'menuitem', { name: 'Codex' }) expect(textarea.closest('fieldset')?.contains(codex)).toBe(false) await submit(codex) expect(onSave).not.toHaveBeenCalled() @@ -208,9 +216,12 @@ describe('TerminalQuickCommandDialog animation structure', () => { await submit() expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ agent: 'codex', prompt })) expect(agentSelect.disabled).toBe(true) - const options = Array.from(document.body.querySelectorAll('[role="option"]')) - expect(options.length).toBeGreaterThan(0) - expect(options.every((option) => option.getAttribute('aria-disabled') === 'true')).toBe(true) + const options = Array.from(document.body.querySelectorAll('[role="menuitem"]')) + expect( + options + .filter((option) => !option.textContent?.includes('Agent settings')) + .every((option) => option.getAttribute('aria-disabled') === 'true') + ).toBe(true) await act(async () => options.find((option) => option.textContent?.includes('Claude'))?.click()) expect(agentSelect.textContent).toContain('Codex') expect(textarea.closest('fieldset')?.disabled).toBe(true) @@ -246,4 +257,128 @@ describe('TerminalQuickCommandDialog animation structure', () => { expect(onSave).toHaveBeenLastCalledWith(expect.objectContaining({ prompt: updatedPrompt })) expect(onOpenChange).toHaveBeenCalledWith(false) }) + + it('keeps shell and agent drafts independent and saves only the selected agent and prompt', async () => { + const onSave = vi.fn(() => false) + await renderDialog( + { + id: 'qc-picker', + label: 'Review', + action: 'terminal-command', + command: 'git diff\n', + appendEnter: false, + scope: { type: 'global' } + }, + { onSave } + ) + + await act(async () => + fireEvent.click(getByRole(document.body, 'radio', { name: 'Agent Prompt' })) + ) + const prompt = getByRole(document.body, 'textbox', { name: 'Prompt' }) + await act(async () => + fireEvent.change(prompt, { target: { value: 'Review @src\n/goal careful' } }) + ) + const trigger = getByRole(document.body, 'button', { name: 'Select agent' }) + await act(async () => fireEvent.keyDown(trigger, { key: 'Enter' })) + const unsupported = getByRole(document.body, 'menuitem', { name: /^Aider/ }) + expect(unsupported.getAttribute('aria-disabled')).toBe('true') + await act(async () => fireEvent.click(unsupported)) + expect(onSave).not.toHaveBeenCalled() + await act(async () => fireEvent.keyDown(getByRole(document.body, 'menu'), { key: 'Escape' })) + const user = userEvent.setup() + act(() => trigger.focus()) + await user.keyboard('co') + const search = document.querySelector( + 'input[placeholder="Search agents and setups…"]' + )! + expect(document.activeElement).toBe(search) + expect(search.value).toBe('co') + expect(search.selectionStart).toBe(2) + await user.keyboard('{Enter}') + expect(onSave).not.toHaveBeenCalled() + + await act(async () => + fireEvent.click(getByRole(document.body, 'radio', { name: 'Terminal Command' })) + ) + expect( + getByRole(document.body, 'textbox', { name: 'Command' }).value + ).toBe('git diff\n') + expect( + getByRole(document.body, 'switch', { name: 'Toggle append Enter' }).getAttribute( + 'aria-checked' + ) + ).toBe('false') + await act(async () => + fireEvent.click(getByRole(document.body, 'radio', { name: 'Agent Prompt' })) + ) + await act(async () => fireEvent.click(getByRole(document.body, 'button', { name: /^Save/ }))) + expect(onSave).toHaveBeenCalledExactlyOnceWith({ + id: 'qc-picker', + label: 'Review', + action: 'agent-prompt', + agent: 'codex', + prompt: 'Review @src\n/goal careful', + scope: { type: 'global' } + }) + }) + + it.each([100, 1000])( + 'opens the agent-only picker without visiting %i unrelated records', + async (count) => { + const before = useAppStore.getState() + let visits = 0 + const tracked = (record: T): T => { + const id = record.id + return Object.defineProperty(record, 'id', { + get: () => { + visits++ + return id + } + }) + } + useAppStore.setState({ + worktreesByRepo: { + 'repo-1': Array.from({ length: count }, (_, i) => + tracked(makeWorktree({ id: `wt-${i}`, repoId: 'repo-1' })) + ) + }, + folderWorkspaces: Array.from({ length: count }, (_, i) => + tracked(makeFolderWorkspace({ id: `folder-${i}` })) + ), + repos: Array.from({ length: count }, (_, i) => tracked({ ...TEST_REPO, id: `repo-${i}` })) + }) + try { + await renderDialog({ + id: 'qc-perf', + label: 'Review', + action: 'agent-prompt', + agent: 'claude', + prompt: 'Review', + scope: { type: 'global' } + }) + visits = 0 + await act(async () => + fireEvent.keyDown(getByRole(document.body, 'button', { name: 'Select agent' }), { + key: 'Enter' + }) + ) + expect(visits).toBe(0) + await act(async () => + fireEvent.keyDown(getByRole(document.body, 'menuitem', { name: 'Codex' }), { + key: 'Enter' + }) + ) + expect(getByRole(document.body, 'button', { name: 'Select agent' }).textContent).toContain( + 'Codex' + ) + } finally { + useAppStore.setState({ + worktreesByRepo: before.worktreesByRepo, + folderWorkspaces: before.folderWorkspaces, + repos: before.repos + }) + } + } + ) }) diff --git a/src/renderer/src/components/ui/dropdown-menu.tsx b/src/renderer/src/components/ui/dropdown-menu.tsx index 4384742c162..5e8f210f747 100644 --- a/src/renderer/src/components/ui/dropdown-menu.tsx +++ b/src/renderer/src/components/ui/dropdown-menu.tsx @@ -59,7 +59,10 @@ function DropdownMenuContent({ sideOffset = 4, style, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + // Radix consumes this at its opening focus scope but omits it from the public Menu props. + onOpenAutoFocus?: (event: Event) => void +}) { const pointerHighlight = useMenuPointerHighlight(props) const { ref: focusScopeRef, scope } = useOverlayFocusScope(pointerHighlight.ref) return ( diff --git a/src/shared/agent-launch/picker/agent-launch-picker.ts b/src/shared/agent-launch/picker/agent-launch-picker.ts index df3b4847b61..7f60d8905d2 100644 --- a/src/shared/agent-launch/picker/agent-launch-picker.ts +++ b/src/shared/agent-launch/picker/agent-launch-picker.ts @@ -29,6 +29,7 @@ export type AgentPickerEntry = { textGeneration?: TextActionPickerCatalog agent: TuiAgent label: string + description?: string aliases?: string[] rows: NewTabAgentMenuRow[] surfaces: AgentPickerSurface[]