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 && (
+
+ {isSelected && (
+
+ )}
+
+ )}
{option.label}
@@ -343,9 +371,10 @@ function SearchableSelectOptionItem({
)}
- {isSelected && (
+ {!showRadio && isSelected && (
)}
+ {action}
);
}
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 (
<>