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 @@ -4,6 +4,7 @@ import type {
ControlActionDecision,
ControlExecution,
ControlStage,
StepSchema,
} from '@/core/api/types';
import {
labelPropsInline,
Expand All @@ -13,12 +14,21 @@ import {
import { StepNameInput } from './step-name-input';
import type { ControlDefinitionFormProps } from './types';

export const ControlDefinitionForm = ({ form }: ControlDefinitionFormProps) => {
export type ControlDefinitionFormWithStepsProps = ControlDefinitionFormProps & {
/** Available steps from the agent */
steps?: StepSchema[];
};

export const ControlDefinitionForm = ({
form,
steps,
}: ControlDefinitionFormWithStepsProps) => {
return (
<Stack gap="md">
<Switch
size="sm"
color="green.5"
style={{ width: 'fit-content' }}
label={
<LabelWithTooltip
label="Enabled"
Expand All @@ -28,7 +38,7 @@ export const ControlDefinitionForm = ({ form }: ControlDefinitionFormProps) => {
{...form.getInputProps('enabled', { type: 'checkbox' })}
/>

<StepNameInput form={form} />
<StepNameInput form={form} steps={steps} />

<MultiSelect
label={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { isApiError } from '@/core/api/errors';
import type { Control, ProblemDetail } from '@/core/api/types';
import { getEvaluator } from '@/core/evaluators';
import { useAddControlToAgent } from '@/core/hooks/query-hooks/use-add-control-to-agent';
import { useAgent } from '@/core/hooks/query-hooks/use-agent';
import { useUpdateControl } from '@/core/hooks/query-hooks/use-update-control';
import { useValidateControlData } from '@/core/hooks/query-hooks/use-validate-control-data';

Expand Down Expand Up @@ -40,6 +41,9 @@ export const EditControlContent = ({
onClose,
onSuccess,
}: EditControlContentProps) => {
// Fetch agent data to get steps - React Query will dedupe requests
const { data: agentResponse } = useAgent(agentId);
const steps = agentResponse?.steps ?? [];
// API error state
const [apiError, setApiError] = useState<ProblemDetail | null>(null);
// Errors that couldn't be mapped to form fields (shown in Alert)
Expand Down Expand Up @@ -352,7 +356,7 @@ export const EditControlContent = ({

<Grid gutter="xl">
<Grid.Col span={4}>
<ControlDefinitionForm form={definitionForm} />
<ControlDefinitionForm form={definitionForm} steps={steps} />
</Grid.Col>

<Grid.Col span={8}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,111 @@
import {
Box,
Group,
MultiSelect,
Stack,
Switch,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import {
IconAlertCircle,
IconCircleCheck,
IconCircleX,
IconInfoCircle,
} from '@tabler/icons-react';
import { useMemo, useState } from 'react';

import type { StepSchema } from '@/core/api/types';

import type { ControlDefinitionFormProps } from './types';

export function StepNameInput({ form }: ControlDefinitionFormProps) {
export type StepNameInputProps = ControlDefinitionFormProps & {
/** Available steps from the agent */
steps?: StepSchema[];
};

export function StepNameInput({ form, steps = [] }: StepNameInputProps) {
const isRegexMode = form.values.step_name_mode === 'regex';
const [searchValue, setSearchValue] = useState('');

const handleRegexToggle = (enabled: boolean) => {
form.setFieldValue('step_name_mode', enabled ? 'regex' : 'names');
};

// Convert comma-separated string to array for MultiSelect
const selectedStepNames = useMemo(() => {
if (isRegexMode || !form.values.step_names) return [];
return form.values.step_names
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}, [form.values.step_names, isRegexMode]);

// Step options for dropdown - ensure steps is always an array
const stepOptions = useMemo(() => {
if (!steps || !Array.isArray(steps)) return [];
return steps.map((step) => ({
value: step.name,
label: step.name,
}));
}, [steps]);

const handleStepNamesChange = (values: string[]) => {
// Empty means "all steps" on the server
if (values.length === 0) {
form.setFieldValue('step_names', '');
return;
}

form.setFieldValue('step_names', values.join(', '));
};

const selectedSummary = useMemo(() => {
if (selectedStepNames.length === 0) {
// When no steps exist, avoid showing "All steps" alongside "No steps available"
return steps.length > 0 ? 'All steps' : '';
}
if (selectedStepNames.length === 1) return selectedStepNames[0];
return `${selectedStepNames[0]} +${selectedStepNames.length - 1}`;
}, [selectedStepNames, steps.length]);

const regexMatchInfo = useMemo(() => {
const pattern = form.values.step_name_regex.trim();
if (!pattern) {
return {
hasPattern: false,
isValid: true,
matchCount: 0,
matchedStepNames: [] as string[],
errorMessage: '',
};
}

try {
const regex = new RegExp(pattern);
const matchedStepNames = steps
.map((step) => step.name)
.filter((stepName) => regex.test(stepName));

return {
hasPattern: true,
isValid: true,
matchCount: matchedStepNames.length,
matchedStepNames,
errorMessage: '',
};
} catch (error) {
return {
hasPattern: true,
isValid: false,
matchCount: 0,
matchedStepNames: [] as string[],
errorMessage: error instanceof Error ? error.message : 'Invalid regex',
};
}
}, [form.values.step_name_regex, steps]);

return (
<Box>
<Group gap="xs" mb={4} wrap="nowrap">
Expand All @@ -31,11 +119,11 @@ export function StepNameInput({ form }: ControlDefinitionFormProps) {
<Text size="xs">
{isRegexMode
? 'Optional RE2 pattern to match step names.'
: 'Comma-separated step names to scope this control.'}
: 'Select step names to scope this control.'}
</Text>
<Text size="xs">
{isRegexMode
? 'Toggle off to use comma-separated step names.'
? 'Toggle off to select step names from dropdown.'
: 'Toggle on to use a regex pattern instead.'}
</Text>
</Stack>
Expand All @@ -55,14 +143,101 @@ export function StepNameInput({ form }: ControlDefinitionFormProps) {
<TextInput
size="sm"
placeholder="^db_.*"
rightSection={
regexMatchInfo.hasPattern ? (
<Tooltip
multiline
label={
regexMatchInfo.isValid ? (
<Stack gap={4}>
<Text size="xs">
{regexMatchInfo.matchCount} matching step
{regexMatchInfo.matchCount === 1 ? '' : 's'}
</Text>
{regexMatchInfo.matchCount > 0 ? (
<Text size="xs">
{regexMatchInfo.matchedStepNames.join(', ')}
</Text>
) : (
<Text size="xs">No step names matched.</Text>
)}
</Stack>
) : (
<Text size="xs">
Invalid regex: {regexMatchInfo.errorMessage}
</Text>
)
}
>
<Group gap={4} wrap="nowrap">
{regexMatchInfo.isValid ? (
regexMatchInfo.matchCount > 0 ? (
<IconCircleCheck
size={14}
color="var(--mantine-color-green-6)"
/>
) : (
<IconCircleX
size={14}
color="var(--mantine-color-gray-6)"
/>
)
) : (
<IconAlertCircle
size={14}
color="var(--mantine-color-red-6)"
/>
)}
<Text size="xs" c={regexMatchInfo.isValid ? 'dimmed' : 'red'}>
{regexMatchInfo.isValid ? regexMatchInfo.matchCount : '!'}
</Text>
</Group>
</Tooltip>
) : undefined
}
rightSectionPointerEvents="all"
{...form.getInputProps('step_name_regex')}
/>
) : (
<TextInput
size="sm"
placeholder="search_db, fetch_user"
{...form.getInputProps('step_names')}
/>
<Box pos="relative">
<MultiSelect
data-testid="step-name-select"
size="sm"
placeholder={steps.length > 0 ? '' : 'No steps available'}
data={stepOptions}
value={selectedStepNames}
onChange={handleStepNamesChange}
clearable
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}
checkIconPosition="right"
styles={{
// Keep pillsList visible because it contains the focusable input field.
// Hiding pillsList breaks keyboard interaction for MultiSelect.
pill: { display: 'none' },
}}
/>
{searchValue.trim() === '' ? (
<Text
size="sm"
c={selectedStepNames.length === 0 ? 'dimmed' : undefined}
style={{
position: 'absolute',
left: 12,
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'none',
maxWidth: 'calc(100% - 72px)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{selectedSummary}
</Text>
) : null}
</Box>
)}
</Box>
);
Expand Down
8 changes: 4 additions & 4 deletions ui/tests/agent-detail.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,10 +427,10 @@ test.describe('Agent Detail Page', () => {
await expect(
modal.getByText('Pre (before execution)', { exact: true })
).toBeVisible();
// Step name: mock has both step_names and step_name_regex; form shows one (names mode when both set)
await expect(modal.getByPlaceholder('search_db, fetch_user')).toHaveValue(
'database_query'
);
// Step name: mock has both step_names and step_name_regex; form shows names mode by default.
await expect(
modal.locator('p', { hasText: 'database_query' })
).toBeVisible();
// Execution environment is a Select; assert label is visible (selected value may be in closed dropdown)
const executionLabel = modal.getByText('Execution environment', {
exact: true,
Expand Down
12 changes: 12 additions & 0 deletions ui/tests/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,17 @@ const agentResponse: GetAgentResponse = {
evaluators: [],
};

/** Agent with populated steps for step dropdown tests */
const agentWithStepsResponse: GetAgentResponse = {
...agentResponse,
steps: [
{ type: 'tool', name: 'search_db' },
{ type: 'tool', name: 'fetch_user' },
{ type: 'tool', name: 'database_query' },
{ type: 'llm', name: 'support-answer' },
],
};

const controlsList: Control[] = [
{
id: 1,
Expand Down Expand Up @@ -337,6 +348,7 @@ const emptyStatsResponse: StatsResponse = {
export const mockData = {
agents: agentsResponse,
agent: agentResponse,
agentWithSteps: agentWithStepsResponse,
controls: controlsResponse,
listControls: listControlsResponse,
evaluators: evaluatorsResponse,
Expand Down
Loading
Loading