+
{/* Header */}
@@ -381,12 +381,12 @@ export default function CreateEditRecipeModal({
- {isCreateMode ? 'Create Recipe' : 'View/edit recipe'}
+ {isCreateMode ? 'Create Command' : 'View/edit command'}
{isCreateMode
- ? 'Create a new recipe to define agent behavior and capabilities.'
- : "You can edit the recipe below to change the agent's behavior in a new session."}
+ ? 'Create a new command to define agent behavior and capabilities.'
+ : "You can edit the command below to change the agent's behavior in a new session."}
@@ -401,17 +401,15 @@ export default function CreateEditRecipeModal({
{/* Content */}
-
-
+
+
+
- {/* Schedule Configuration Section */}
-
-
-
{/* Deep Link Display */}
{requiredFieldsAreFilled() && (
@@ -480,7 +478,7 @@ export default function CreateEditRecipeModal({
className="inline-flex items-center justify-center gap-2 px-4 py-2"
>
- {isSaving ? 'Saving...' : 'Save Recipe'}
+ {isSaving ? 'Saving...' : 'Save Command'}
- {isSaving ? 'Saving...' : 'Save & Run Recipe'}
+ {isSaving ? 'Saving...' : 'Save & Run Command'}
diff --git a/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx b/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx
index 7754c3186d..564a56b74f 100644
--- a/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx
+++ b/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx
@@ -1,27 +1,29 @@
import React, { useState } from 'react';
import { Parameter } from '../../../recipe';
+import { ChevronDown, ChevronUp } from 'lucide-react';
import ParameterInput from '../../parameter/ParameterInput';
-import RecipeActivityEditor from '../RecipeActivityEditor';
import JsonSchemaEditor from './JsonSchemaEditor';
import InstructionsEditor from './InstructionsEditor';
import { Button } from '../../ui/button';
import { RecipeFormApi } from './recipeFormSchema';
+import { ScheduleConfigSection, ScheduleConfig } from '../../shared/ScheduleConfigSection';
-// Type for field API to avoid linting issues - use any to bypass complex type constraints
+// Type for field API to avoid linting issues
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type FormFieldApi<_T = any> = any;
interface RecipeFormFieldsProps {
- // Form instance from parent
form: RecipeFormApi;
-
- // Event handlers
onTitleChange?: (value: string) => void;
onDescriptionChange?: (value: string) => void;
onInstructionsChange?: (value: string) => void;
onPromptChange?: (value: string) => void;
onJsonSchemaChange?: (value: string) => void;
+ // Schedule configuration props
+ recipeTitle?: string;
+ scheduleConfig?: ScheduleConfig;
+ onScheduleConfigChange?: (config: ScheduleConfig | null) => void;
}
export const extractTemplateVariables = (content: string): string[] => {
@@ -31,17 +33,13 @@ export const extractTemplateVariables = (content: string): string[] => {
while ((match = templateVarRegex.exec(content)) !== null) {
const variable = match[1].trim();
-
if (variable && !variables.includes(variable)) {
- // Filter out complex variables that aren't valid parameter names
- // This matches the backend logic in filter_complex_variables()
const validVarRegex = /^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*$/;
if (validVarRegex.test(variable)) {
variables.push(variable);
}
}
}
-
return variables;
};
@@ -52,32 +50,30 @@ export function RecipeFormFields({
onInstructionsChange,
onPromptChange,
onJsonSchemaChange,
+ recipeTitle,
+ scheduleConfig,
+ onScheduleConfigChange,
}: RecipeFormFieldsProps) {
- const [showJsonSchemaEditor, setShowJsonSchemaEditor] = useState(false);
+ // Advanced configuration state
+ const [showAdvanced, setShowAdvanced] = useState(false);
+
+ // Other states
const [showInstructionsEditor, setShowInstructionsEditor] = useState(false);
const [newParameterName, setNewParameterName] = useState('');
const [expandedParameters, setExpandedParameters] = useState
>(new Set());
-
- // Force re-render when instructions, prompt, or activities change
const [_forceRender, setForceRender] = useState(0);
React.useEffect(() => {
return form.store.subscribe(() => {
- // Force re-render when any form field changes to update parameter usage indicators
setForceRender((prev) => prev + 1);
});
}, [form.store]);
const parseParametersFromInstructions = React.useCallback(
- (instructions: string, prompt?: string, activities?: string[]): Parameter[] => {
+ (instructions: string, prompt?: string): Parameter[] => {
const instructionVars = extractTemplateVariables(instructions);
const promptVars = prompt ? extractTemplateVariables(prompt) : [];
- const activityVars = activities
- ? activities.flatMap((activity) => extractTemplateVariables(activity))
- : [];
-
- // Combine and deduplicate
- const allVars = [...new Set([...instructionVars, ...promptVars, ...activityVars])];
+ const allVars = [...new Set([...instructionVars, ...promptVars])];
return allVars.map((key: string) => ({
key,
@@ -89,76 +85,36 @@ export function RecipeFormFields({
[]
);
- // Function to update parameters based on current field values
const updateParametersFromFields = React.useCallback(() => {
const currentValues = form.state.values;
- const { instructions, prompt, activities, parameters: currentParams } = currentValues;
+ const { instructions, prompt, parameters: currentParams } = currentValues;
- const newParams = parseParametersFromInstructions(instructions, prompt, activities);
-
- // Separate manually added parameters (those not found in instructions/prompt/activities)
+ const newParams = parseParametersFromInstructions(instructions, prompt);
const manualParams = currentParams.filter((param: Parameter) => {
- // Only keep manual params that have a valid key and are not found in the parsed params
- return (
- param.key && param.key.trim() && !newParams.some((newParam) => newParam.key === param.key)
- );
+ return !newParams.some((np) => np.key === param.key);
});
- // Combine parsed parameters with manually added ones, filtering out empty ones
- const combinedParams = [
- ...newParams.map((newParam) => {
- const existing = currentParams.find((cp: Parameter) => cp.key === newParam.key);
- return existing ? { ...existing } : newParam;
- }),
- ...manualParams,
- ].filter((param: Parameter) => param.key && param.key.trim()) as Parameter[];
-
- // Only update if parameters actually changed
- const currentParamKeys = currentParams.map((p: Parameter) => p.key).sort();
- const newParamKeys = combinedParams.map((p) => p.key).sort();
-
- if (JSON.stringify(currentParamKeys) !== JSON.stringify(newParamKeys)) {
- form.setFieldValue('parameters', combinedParams);
- }
+ const allParams = [...newParams, ...manualParams];
+ form.setFieldValue('parameters', allParams);
}, [form, parseParametersFromInstructions]);
- const isParameterUsed = (
- paramKey: string,
- instructions: string,
- prompt?: string,
- activities?: string[]
- ): boolean => {
- const regex = new RegExp(
- `\\{\\{\\s*${paramKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\}\\}`,
- 'g'
- );
- const usedInInstructions = regex.test(instructions);
- const usedInPrompt = prompt ? regex.test(prompt) : false;
- const usedInActivities = activities
- ? activities.some((activity) => {
- // For activities, we need to check the full activity string, including message: prefixes
- return regex.test(activity);
- })
- : false;
- return usedInInstructions || usedInPrompt || usedInActivities;
- };
+ React.useEffect(() => {
+ updateParametersFromFields();
+ }, [updateParametersFromFields]);
return (
-
- {/* Title Field */}
+
+ {/* REQUIRED: Title Field */}
{(field: FormFieldApi) => (
-
+
Title *
{
field.handleChange(e.target.value);
onTitleChange?.(e.target.value);
@@ -167,7 +123,7 @@ export function RecipeFormFields({
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 ${
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
}`}
- placeholder="Recipe title"
+ placeholder="Give your command a descriptive name"
data-testid="title-input"
/>
{field.state.meta.errors.length > 0 && (
@@ -177,314 +133,264 @@ export function RecipeFormFields({
)}
- {/* Description Field */}
-
- {(field: FormFieldApi) => (
-
-
- Description *
-
-
{
- field.handleChange(e.target.value);
- onDescriptionChange?.(e.target.value);
- }}
- onBlur={field.handleBlur}
- className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 ${
- field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
- }`}
- placeholder="Brief description of what this recipe does"
- data-testid="description-input"
- />
- {field.state.meta.errors.length > 0 && (
-
{field.state.meta.errors[0]}
- )}
-
- )}
-
-
- {/* Instructions Field */}
+ {/* REQUIRED: Instructions Field */}
{(field: FormFieldApi) => (
-
+
Instructions *
setShowInstructionsEditor(true)}
- variant="outline"
+ onClick={() => setShowInstructionsEditor(!showInstructionsEditor)}
+ variant="ghost"
size="sm"
className="text-xs"
>
- Open Editor
-
-
-
- )}
-
-
- {/* Initial Prompt Field */}
-
- {(field: FormFieldApi) => (
-
-
- Initial Prompt
-
-
- (Optional - Instructions or Prompt are required)
-
-
- )}
-
-
- {/* Activities Field */}
-
- {(field: FormFieldApi) => (
-
- field.handleChange(activities)}
- onBlur={updateParametersFromFields}
- />
-
- )}
-
-
- {/* Parameters Field */}
-
- {(field: FormFieldApi) => {
- const handleAddParameter = () => {
- if (newParameterName.trim()) {
- const newParam: Parameter = {
- key: newParameterName.trim(),
- description: `Enter value for ${newParameterName.trim()}`,
- input_type: 'string',
- requirement: 'required',
- };
- field.handleChange([...field.state.value, newParam]);
- setNewParameterName('');
- // Expand the newly added parameter by default
- setExpandedParameters((prev) => {
- const newSet = new Set(prev);
- newSet.add(newParam.key);
- return newSet;
- });
- }
- };
-
- const handleKeyPress = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- handleAddParameter();
- }
- };
-
- const handleDeleteParameter = (parameterKey: string) => {
- const updatedParams = field.state.value.filter(
- (param: Parameter) => param.key !== parameterKey
- );
- field.handleChange(updatedParams);
- // Remove from expanded set if it was expanded
- setExpandedParameters((prev) => {
- const newSet = new Set(prev);
- newSet.delete(parameterKey);
- return newSet;
- });
- };
-
- const handleToggleExpanded = (parameterKey: string) => {
- setExpandedParameters((prev) => {
- const newSet = new Set(prev);
- if (newSet.has(parameterKey)) {
- newSet.delete(parameterKey);
- } else {
- newSet.add(parameterKey);
- }
- return newSet;
- });
- };
-
- return (
-
-
Parameters
-
- Parameters will be automatically detected from {`{{parameter_name}}`} syntax in
- instructions/prompt/activities or you can manually add them below.
-
-
- {/* Add Parameter Input - Always Visible */}
-
- setNewParameterName(e.target.value)}
- onKeyPress={handleKeyPress}
- placeholder="Enter parameter name..."
- className="flex-1 px-3 py-2 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
- />
-
- Add parameter
-
-
-
- {field.state.value.length > 0 &&
- field.state.value
- .filter((parameter: Parameter) => parameter.key && parameter.key.trim()) // Filter out empty parameters
- .map((parameter: Parameter) => {
- const currentValues = form.state.values;
- const isUnused = !isParameterUsed(
- parameter.key,
- currentValues.instructions,
- currentValues.prompt,
- currentValues.activities
- );
-
- return (
-
{
- const updatedParams = field.state.value.map((param: Parameter) =>
- param.key === name ? { ...param, ...value } : param
- );
- field.handleChange(updatedParams);
- }}
- />
- );
- })}
-
- );
- }}
-
-
- {/* JSON Schema Field */}
-
- {(field: FormFieldApi) => (
-
-
- Response JSON Schema
-
-
- Define the expected structure of the AI's response using JSON Schema format
-
-
- setShowJsonSchemaEditor(true)}
- variant="outline"
- size="sm"
- className="text-xs"
- >
- Open Editor
+ {showInstructionsEditor ? 'Hide' : 'Show'} Editor
- {field.state.value && field.state.value.trim() && (
-
{
+ field.handleChange(value);
+ onInstructionsChange?.(value);
+ }}
+ />
+ ) : (
+
+ placeholder="Detailed instructions for the AI, hidden from the user..."
+ rows={8}
+ data-testid="instructions-input"
+ />
)}
-
{field.state.meta.errors.length > 0 && (
{field.state.meta.errors[0]}
)}
-
- {/* JSON Schema Editor Modal */}
-
setShowJsonSchemaEditor(false)}
- value={field.state.value || ''}
- onChange={(value) => {
- field.handleChange(value);
- onJsonSchemaChange?.(value);
- }}
- error={field.state.meta.errors.length > 0 ? field.state.meta.errors[0] : undefined}
- />
+
+ Use {`{{variable_name}}`} syntax to create parameters
+
)}
+
+ {/* SCHEDULE CONFIGURATION - Moved above Advanced Configuration */}
+ {onScheduleConfigChange && (
+
+ )}
+
+ {/* ADVANCED CONFIGURATION - Collapsible Section */}
+
+
setShowAdvanced(!showAdvanced)}
+ className="w-full flex items-center justify-between"
+ >
+
+
+ Advanced Configuration
+
+ (Optional)
+
+ {showAdvanced ? (
+
+ ) : (
+
+ )}
+
+
+ {showAdvanced && (
+
+ {/* Description Field */}
+
+ {(field: FormFieldApi) => (
+
+ )}
+
+
+ {/* Initial Prompt Field */}
+
+ {(field: FormFieldApi) => (
+
+ )}
+
+
+ {/* Parameters Field */}
+
+ {(field: FormFieldApi) => {
+ const handleAddParameter = () => {
+ if (newParameterName.trim()) {
+ const newParam: Parameter = {
+ key: newParameterName.trim(),
+ description: `Enter value for ${newParameterName.trim()}`,
+ requirement: 'required',
+ input_type: 'string',
+ };
+ field.handleChange([...field.state.value, newParam]);
+ setNewParameterName('');
+ }
+ };
+
+ const handleRemoveParameter = (index: number) => {
+ const updated = field.state.value.filter((_: Parameter, i: number) => i !== index);
+ field.handleChange(updated);
+ };
+
+ const handleUpdateParameter = (index: number, updated: Parameter) => {
+ const newParams = [...field.state.value];
+ newParams[index] = updated;
+ field.handleChange(newParams);
+ };
+
+ const toggleExpanded = (key: string) => {
+ const newExpanded = new Set(expandedParameters);
+ if (newExpanded.has(key)) {
+ newExpanded.delete(key);
+ } else {
+ newExpanded.add(key);
+ }
+ setExpandedParameters(newExpanded);
+ };
+
+ const handleKeyPress = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleAddParameter();
+ }
+ };
+
+ return (
+
+
+ Parameters
+
+
+ Parameters will be automatically detected from {`{{parameter_name}}`} syntax in
+ instructions/prompt or you can manually add them below.
+
+
+ {/* Add parameter input */}
+
+ setNewParameterName(e.target.value)}
+ onKeyPress={handleKeyPress}
+ placeholder="Enter parameter name..."
+ className="flex-1 px-3 py-2 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
+ />
+
+ Add
+
+
+
+ {/* Parameters list */}
+ {field.state.value.length > 0 && (
+
+ {field.state.value.map((param: Parameter, index: number) => (
+
toggleExpanded(param.key)}
+ onUpdate={(updated) => handleUpdateParameter(index, updated)}
+ onRemove={() => handleRemoveParameter(index)}
+ />
+ ))}
+
+ )}
+
+ );
+ }}
+
+
+ {/* JSON Schema Field */}
+
+ {(field: FormFieldApi) => (
+
+
+ Response JSON Schema
+
+
+ Define a JSON schema to structure the AI's response format.
+
+
{
+ field.handleChange(value);
+ onJsonSchemaChange?.(value);
+ }}
+ />
+
+ )}
+
+
+ )}
+
);
}
diff --git a/ui/desktop/src/components/recipes/shared/recipeFormSchema.ts b/ui/desktop/src/components/recipes/shared/recipeFormSchema.ts
index 3eda6f8ded..52b12938c1 100644
--- a/ui/desktop/src/components/recipes/shared/recipeFormSchema.ts
+++ b/ui/desktop/src/components/recipes/shared/recipeFormSchema.ts
@@ -15,26 +15,30 @@ export type RecipeParameter = z.infer;
// Main recipe form schema
export const recipeFormSchema = z.object({
+ // REQUIRED FIELDS
title: z
.string()
.min(1, 'Title is required')
.min(3, 'Title must be at least 3 characters')
.max(100, 'Title must be 100 characters or less'),
- description: z
- .string()
- .min(1, 'Description is required')
- .min(10, 'Description must be at least 10 characters')
- .max(500, 'Description must be 500 characters or less'),
-
instructions: z
.string()
.min(1, 'Instructions are required')
.min(20, 'Instructions must be at least 20 characters'),
- prompt: z.string().optional(),
+ // OPTIONAL FIELDS
+ description: z
+ .string()
+ .optional()
+ .refine((val) => !val || val.length >= 10, {
+ message: 'Description must be at least 10 characters if provided',
+ })
+ .refine((val) => !val || val.length <= 500, {
+ message: 'Description must be 500 characters or less',
+ }),
- activities: z.array(z.string()).default([]),
+ prompt: z.string().optional(),
parameters: z.array(parameterSchema).default([]),
diff --git a/ui/desktop/src/components/shared/ScheduleConfigSection.tsx b/ui/desktop/src/components/shared/ScheduleConfigSection.tsx
index 5942fb2d42..f563a4baeb 100644
--- a/ui/desktop/src/components/shared/ScheduleConfigSection.tsx
+++ b/ui/desktop/src/components/shared/ScheduleConfigSection.tsx
@@ -34,6 +34,9 @@ export const ScheduleConfigSection: React.FC = ({
}) => {
// Generate schedule ID from recipe title
const generateScheduleId = (title: string): string => {
+ if (!title || title.trim() === '') {
+ return 'untitled-recipe';
+ }
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')