Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<button type="button" data-testid={`action-${option.value}`}>
Config
</button>
),
});
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'));
Expand Down
35 changes: 32 additions & 3 deletions services/platform/app/components/ui/forms/searchable-select.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -99,6 +103,8 @@ export function SearchableSelect({
contentClassName,
'aria-label': ariaLabel,
filterFn,
showRadio,
optionAction,
}: SearchableSelectProps) {
const instanceId = useId();
const listboxId = `${instanceId}-listbox`;
Expand Down Expand Up @@ -277,6 +283,8 @@ export function SearchableSelect({
isHighlighted={highlightedIndex === index}
onSelect={handleSelect}
onMouseEnter={setHighlightedIndex}
showRadio={showRadio}
action={optionAction?.(option)}
/>
))}

Expand Down Expand Up @@ -307,6 +315,8 @@ function SearchableSelectOptionItem({
isHighlighted,
onSelect,
onMouseEnter,
showRadio,
action,
}: {
option: SearchableSelectOption;
index: number;
Expand All @@ -315,6 +325,8 @@ function SearchableSelectOptionItem({
isHighlighted: boolean;
onSelect: (value: string) => void;
onMouseEnter: (index: number) => void;
showRadio?: boolean;
action?: ReactNode;
}) {
return (
<div
Expand All @@ -328,11 +340,27 @@ function SearchableSelectOptionItem({
onClick={() => !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 && (
<span
aria-hidden="true"
className={cn(
'border-border bg-background pointer-events-none flex size-4 shrink-0 items-center justify-center rounded-full border transition-colors duration-150',
isSelected && 'border-blue-600',
)}
>
{isSelected && (
<Circle
className="size-2.5 fill-blue-600 text-blue-600"
aria-hidden="true"
/>
)}
</span>
)}
<div className="min-w-0 flex-1">
<Text as="div" variant="label">
{option.label}
Expand All @@ -343,9 +371,10 @@ function SearchableSelectOptionItem({
</Text>
)}
</div>
{isSelected && (
{!showRadio && isSelected && (
<Check className="text-primary size-4 shrink-0" aria-hidden="true" />
)}
{action}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Expand Down Expand Up @@ -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' }),
}));

Expand Down Expand Up @@ -224,6 +226,44 @@ describe('AgentSelector', () => {
});
});

it('shows configure button for all agents when user has write permission', async () => {
const { user } = render(<AgentSelector organizationId="org-1" />);

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(<AgentSelector organizationId="org-1" />);

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(<AgentSelector organizationId="org-1" />);

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' },
});
Comment thread
Israeltheminer marked this conversation as resolved.
});

it('only highlights one agent when multiple have isSystemDefault', async () => {
mockAgents = [
{
Expand Down
43 changes: 40 additions & 3 deletions services/platform/app/features/chat/components/agent-selector.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -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 (
<IconButton
icon={Settings}
aria-label={t('agentSelector.configureAgent')}
variant="ghost"
iconSize={4}
className="size-8 shrink-0 rounded-md"
onClick={(e) => handleConfigClick(option, e)}
/>
);
},
[canManageAgents, t, handleConfigClick],
);

return (
<>
<SearchableSelect
Expand All @@ -88,6 +123,8 @@ export function AgentSelector({ organizationId }: AgentSelectorProps) {
searchPlaceholder={t('agentSelector.searchPlaceholder')}
emptyText={t('agentSelector.noResults')}
aria-label={t('agentSelector.label')}
showRadio
optionAction={renderOptionAction}
trigger={
<button
type="button"
Expand Down
1 change: 1 addition & 0 deletions services/platform/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2171,6 +2171,7 @@
"searchPlaceholder": "Search agents",
"noResults": "No agents found",
"addAgent": "Add agent",
"configureAgent": "Configure agent",
"builtinAgents": {
"chat": {
"name": "Assistant",
Expand Down
Loading