mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 02:17:31 +00:00
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.
This commit is contained in:
@@ -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(
|
||||
<TooltipProvider>
|
||||
<Dialog open>
|
||||
<DialogContent>
|
||||
<DialogTitle>Agent selection</DialogTitle>
|
||||
<AgentLaunchPicker
|
||||
model={currentModel}
|
||||
entries={currentEntries}
|
||||
agent="codex"
|
||||
onPick={launch}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TooltipProvider>
|
||||
)
|
||||
)
|
||||
show()
|
||||
const trigger = getByRole(document.body, 'button', { name: 'Select agent' })
|
||||
const search = () =>
|
||||
document.querySelector<HTMLInputElement>('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))
|
||||
})
|
||||
@@ -89,12 +89,21 @@ export const AgentLaunchPicker = memo(
|
||||
const ownDraftScope = useAgentPickerDraftScope()
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const trigger = useRef<HTMLButtonElement>(null)
|
||||
const searchInput = useRef<HTMLInputElement>(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(
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
onOpenAutoFocus={(event) => {
|
||||
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"
|
||||
|
||||
@@ -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<boolean>
|
||||
|
||||
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<HTMLInputElement>
|
||||
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 ? (
|
||||
<div className="px-1 py-0.5">
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
density="compact"
|
||||
autoFocus
|
||||
autoFocus={!searchInputRef}
|
||||
onFocus={(event) => {
|
||||
// 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 <AgentLaunchPickerMenu {...props} entries={entries} />
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<DropdownMenuItem
|
||||
disabled={disabled || !entry.surfaces.length}
|
||||
onSelect={() => {
|
||||
onSelectAgent?.(entry.agent)
|
||||
runLaunch(pickerLaunchRow(entry, selected?.surface ?? entry.defaultSurface))
|
||||
}}
|
||||
>
|
||||
<AgentIcon agent={entry.agent} size={14} />
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
{shortcut ? <DropdownMenuShortcut>{shortcut}</DropdownMenuShortcut> : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<AgentLaunchSetupButton
|
||||
submenu
|
||||
draftScope={draftScope}
|
||||
draftId={draftId}
|
||||
preset={preset}
|
||||
entry={entry}
|
||||
initial={source}
|
||||
seed={initial ?? selected ?? source}
|
||||
surface={selected?.surface}
|
||||
mode={mode}
|
||||
open={openSetup === setupId}
|
||||
replacedByAnotherSetup={openSetup !== null && openSetup !== setupId}
|
||||
disabled={disabled || setupDisabled || !entry.surfaces.length}
|
||||
onOpenChange={(next) => {
|
||||
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) => (
|
||||
<div
|
||||
data-agent-launch-row
|
||||
onClick={(event) => {
|
||||
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()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button asChild variant="menu-launch" size="menu">
|
||||
<span>
|
||||
<AgentIcon agent={entry.agent} size={14} />
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
{shortcut ? <DropdownMenuShortcut>{shortcut}</DropdownMenuShortcut> : null}
|
||||
{unavailable?.kind === 'chat-unavailable' && !entry.surfaces.includes('chat') ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{chatUnavailableLabel(unavailable.reason)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</Button>
|
||||
{trigger}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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<boolean>
|
||||
|
||||
// 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 (
|
||||
<DropdownMenuItem
|
||||
disabled={disabled || !entry.surfaces.length}
|
||||
onSelect={() => {
|
||||
onSelectAgent?.(entry.agent)
|
||||
runLaunch(pickerLaunchRow(entry, selected?.surface ?? entry.defaultSurface))
|
||||
}}
|
||||
>
|
||||
<AgentIcon agent={entry.agent} size={14} />
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">{label}</span>
|
||||
{entry.description ? (
|
||||
<span className="truncate text-xs text-muted-foreground">{entry.description}</span>
|
||||
) : null}
|
||||
</span>
|
||||
{shortcut ? <DropdownMenuShortcut>{shortcut}</DropdownMenuShortcut> : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<AgentLaunchSetupButton
|
||||
submenu
|
||||
draftScope={draftScope}
|
||||
draftId={draftId}
|
||||
preset={preset}
|
||||
entry={entry}
|
||||
initial={source}
|
||||
seed={initial ?? selected ?? source}
|
||||
surface={selected?.surface}
|
||||
mode={mode}
|
||||
open={openSetup === setupId}
|
||||
replacedByAnotherSetup={openSetup !== null && openSetup !== setupId}
|
||||
disabled={disabled || setupDisabled || !entry.surfaces.length}
|
||||
onOpenChange={(next) => {
|
||||
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) => (
|
||||
<div
|
||||
data-agent-launch-row
|
||||
onClick={(event) => {
|
||||
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()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button asChild variant="menu-launch" size="menu">
|
||||
<span>
|
||||
<AgentIcon agent={entry.agent} size={14} />
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
{shortcut ? <DropdownMenuShortcut>{shortcut}</DropdownMenuShortcut> : null}
|
||||
{unavailable?.kind === 'chat-unavailable' && !entry.surfaces.includes('chat') ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{chatUnavailableLabel(unavailable.reason)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</Button>
|
||||
{trigger}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
+67
-60
@@ -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<AgentPickerEntry[]>(
|
||||
() =>
|
||||
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'
|
||||
)}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedAgent}
|
||||
<AgentLaunchPicker
|
||||
model={QUICK_COMMAND_PICKER_MODEL}
|
||||
entries={entries}
|
||||
agent={selectedAgent}
|
||||
emptyLabel={translate(
|
||||
'auto.components.terminal.quick.commands.TerminalQuickCommandDialog.346d409ab2',
|
||||
'Choose agent'
|
||||
)}
|
||||
setupDisabled
|
||||
disabled={disabled || !isAgentAction}
|
||||
onValueChange={(agent) => {
|
||||
const nextAgent = agent as TuiAgent
|
||||
draftMemoryRef.current = {
|
||||
...draftMemoryRef.current,
|
||||
agent: nextAgent
|
||||
}
|
||||
setDraft((current) =>
|
||||
isTerminalAgentQuickCommand(current) ? { ...current, agent: nextAgent } : current
|
||||
)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={translate(
|
||||
'auto.components.terminal.quick.commands.TerminalQuickCommandDialog.346d409ab2',
|
||||
'Choose agent'
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className="max-h-[min(20rem,var(--radix-select-content-available-height))] w-[--radix-select-trigger-width]"
|
||||
>
|
||||
{QUICK_COMMAND_AGENT_OPTIONS.map((entry) => {
|
||||
const supported = supportsTerminalAgentQuickCommand(entry.id)
|
||||
return (
|
||||
<SelectItem key={entry.id} value={entry.id} disabled={disabled || !supported}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<AgentIcon agent={entry.id} size={16} />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{entry.label}</span>
|
||||
{!supported ? (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.terminal.quick.commands.TerminalQuickCommandDialog.026cfb232a',
|
||||
'Does not support prompt commands'
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
onPick={selectAgent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+142
-7
@@ -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<HTMLButtonElement>('[role="combobox"]')!
|
||||
const agentSelect = getByRole<HTMLButtonElement>(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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLInputElement>(
|
||||
'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<HTMLTextAreaElement>(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 = <T extends { id: string }>(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
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -59,7 +59,10 @@ function DropdownMenuContent({
|
||||
sideOffset = 4,
|
||||
style,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content> & {
|
||||
// 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 (
|
||||
|
||||
@@ -29,6 +29,7 @@ export type AgentPickerEntry = {
|
||||
textGeneration?: TextActionPickerCatalog
|
||||
agent: TuiAgent
|
||||
label: string
|
||||
description?: string
|
||||
aliases?: string[]
|
||||
rows: NewTabAgentMenuRow[]
|
||||
surfaces: AgentPickerSurface[]
|
||||
|
||||
Reference in New Issue
Block a user