diff --git a/services/platform/app/components/ui/forms/searchable-select.test.tsx b/services/platform/app/components/ui/forms/searchable-select.test.tsx index 5066f980c6..9c58cf0c80 100644 --- a/services/platform/app/components/ui/forms/searchable-select.test.tsx +++ b/services/platform/app/components/ui/forms/searchable-select.test.tsx @@ -69,6 +69,30 @@ describe('SearchableSelect', () => { expect(appleOption.getAttribute('aria-selected')).toBe('true'); }); + it('shows radio indicator when showRadio is enabled', async () => { + const { user } = renderSelect({ value: 'apple', showRadio: true }); + await user.click(screen.getByText('Open select')); + const appleOption = screen.getByRole('option', { name: /Apple/i }); + expect(appleOption.getAttribute('aria-selected')).toBe('true'); + const radioIndicators = appleOption.querySelectorAll( + 'span[aria-hidden="true"]', + ); + expect(radioIndicators.length).toBeGreaterThan(0); + }); + + it('renders option action when provided', async () => { + const { user } = renderSelect({ + optionAction: (option) => ( + + ), + }); + await user.click(screen.getByText('Open select')); + expect(screen.getByTestId('action-apple')).toBeInTheDocument(); + expect(screen.getByTestId('action-banana')).toBeInTheDocument(); + }); + it('renders empty state when no matches', async () => { const { user } = renderSelect(); await user.click(screen.getByText('Open select')); diff --git a/services/platform/app/components/ui/forms/searchable-select.tsx b/services/platform/app/components/ui/forms/searchable-select.tsx index e29a88f3a0..43f1d3c2c4 100644 --- a/services/platform/app/components/ui/forms/searchable-select.tsx +++ b/services/platform/app/components/ui/forms/searchable-select.tsx @@ -1,7 +1,7 @@ 'use client'; import * as PopoverPrimitive from '@radix-ui/react-popover'; -import { Check, Search } from 'lucide-react'; +import { Check, Circle, Search } from 'lucide-react'; import { type KeyboardEvent, type ReactNode, @@ -54,6 +54,10 @@ export interface SearchableSelectProps { 'aria-label'?: string; /** Custom filter function; defaults to case-insensitive match on label + description */ filterFn?: (option: SearchableSelectOption, query: string) => boolean; + /** Show a radio indicator instead of a check icon for the selected state */ + showRadio?: boolean; + /** Optional action element rendered on the right side of each option */ + optionAction?: (option: SearchableSelectOption) => ReactNode; } const CONTENT_CLASSES = @@ -99,6 +103,8 @@ export function SearchableSelect({ contentClassName, 'aria-label': ariaLabel, filterFn, + showRadio, + optionAction, }: SearchableSelectProps) { const instanceId = useId(); const listboxId = `${instanceId}-listbox`; @@ -277,6 +283,8 @@ export function SearchableSelect({ isHighlighted={highlightedIndex === index} onSelect={handleSelect} onMouseEnter={setHighlightedIndex} + showRadio={showRadio} + action={optionAction?.(option)} /> ))} @@ -307,6 +315,8 @@ function SearchableSelectOptionItem({ isHighlighted, onSelect, onMouseEnter, + showRadio, + action, }: { option: SearchableSelectOption; index: number; @@ -315,6 +325,8 @@ function SearchableSelectOptionItem({ isHighlighted: boolean; onSelect: (value: string) => void; onMouseEnter: (index: number) => void; + showRadio?: boolean; + action?: ReactNode; }) { return (
!option.disabled && onSelect(option.value)} onMouseEnter={() => onMouseEnter(index)} className={cn( - 'flex w-full cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors', + 'group/option flex w-full cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors', isHighlighted && 'bg-accent', option.disabled && 'pointer-events-none opacity-50', )} > + {showRadio && ( + + )}
{option.label} @@ -343,9 +371,10 @@ function SearchableSelectOptionItem({ )}
- {isSelected && ( + {!showRadio && isSelected && (
); } diff --git a/services/platform/app/features/chat/components/__tests__/agent-selector.test.tsx b/services/platform/app/features/chat/components/__tests__/agent-selector.test.tsx index 57a08b0344..57ac99de23 100644 --- a/services/platform/app/features/chat/components/__tests__/agent-selector.test.tsx +++ b/services/platform/app/features/chat/components/__tests__/agent-selector.test.tsx @@ -14,6 +14,7 @@ vi.mock('@/lib/i18n/client', () => ({ 'agentSelector.searchPlaceholder': 'Search agents...', 'agentSelector.noResults': 'No agents found', 'agentSelector.addAgent': 'Add agent', + 'agentSelector.configureAgent': 'Configure agent', }; return translations[key] ?? key; }, @@ -111,9 +112,10 @@ vi.mock( }), ); +const mockNavigate = vi.fn(); vi.mock('@tanstack/react-router', () => ({ useSearch: () => ({}), - useNavigate: () => vi.fn(), + useNavigate: () => mockNavigate, useLocation: () => ({ pathname: '/dashboard/org-1/chat' }), })); @@ -224,6 +226,44 @@ describe('AgentSelector', () => { }); }); + it('shows configure button for all agents when user has write permission', async () => { + const { user } = render(); + + const trigger = screen.getByLabelText('Select agent'); + await user.click(trigger); + + const configButtons = screen.getAllByLabelText('Configure agent'); + expect(configButtons).toHaveLength(2); + }); + + it('does not show configure button when user lacks write permission', async () => { + mockCanWrite = false; + + const { user } = render(); + + const trigger = screen.getByLabelText('Select agent'); + await user.click(trigger); + + expect(screen.queryByLabelText('Configure agent')).not.toBeInTheDocument(); + }); + + it('navigates to agent config when configure button is clicked', async () => { + const { user } = render(); + + const trigger = screen.getByLabelText('Select agent'); + await user.click(trigger); + + const configButtons = screen.getAllByLabelText('Configure agent'); + const customAgentConfig = configButtons[1]; + expect(customAgentConfig).toBeDefined(); + await user.click(customAgentConfig); + + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/dashboard/$id/custom-agents/$agentId', + params: { id: 'org-1', agentId: 'root-2' }, + }); + }); + it('only highlights one agent when multiple have isSystemDefault', async () => { mockAgents = [ { diff --git a/services/platform/app/features/chat/components/agent-selector.tsx b/services/platform/app/features/chat/components/agent-selector.tsx index 8dfeb8723e..87bfd708e4 100644 --- a/services/platform/app/features/chat/components/agent-selector.tsx +++ b/services/platform/app/features/chat/components/agent-selector.tsx @@ -1,10 +1,15 @@ 'use client'; -import { Bot, ChevronDown, Plus } from 'lucide-react'; -import { useCallback, useMemo, useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { Bot, ChevronDown, Plus, Settings } from 'lucide-react'; +import { type MouseEvent, useCallback, useMemo, useState } from 'react'; -import { SearchableSelect } from '@/app/components/ui/forms/searchable-select'; +import { + SearchableSelect, + type SearchableSelectOption, +} from '@/app/components/ui/forms/searchable-select'; import { Button } from '@/app/components/ui/primitives/button'; +import { IconButton } from '@/app/components/ui/primitives/icon-button'; import { CreateCustomAgentDialog } from '@/app/features/custom-agents/components/custom-agent-create-dialog'; import { useAbility } from '@/app/hooks/use-ability'; import { useDialogSearchParam } from '@/app/hooks/use-dialog-search-param'; @@ -21,6 +26,7 @@ interface AgentSelectorProps { export function AgentSelector({ organizationId }: AgentSelectorProps) { const { t } = useT('chat'); const ability = useAbility(); + const navigate = useNavigate(); const { setSelectedAgent } = useChatLayout(); const effectiveAgent = useEffectiveAgent(organizationId); const { agents: allAgents } = useChatAgents(organizationId); @@ -73,6 +79,35 @@ export function AgentSelector({ organizationId }: AgentSelectorProps) { createAgentDialog.open(); }, [createAgentDialog]); + const handleConfigClick = useCallback( + (option: SearchableSelectOption, e: MouseEvent) => { + e.stopPropagation(); + setOpen(false); + void navigate({ + to: '/dashboard/$id/custom-agents/$agentId', + params: { id: organizationId, agentId: option.value }, + }); + }, + [navigate, organizationId], + ); + + const renderOptionAction = useCallback( + (option: SearchableSelectOption) => { + if (!canManageAgents) return null; + return ( + handleConfigClick(option, e)} + /> + ); + }, + [canManageAgents, t, handleConfigClick], + ); + return ( <>