feat: ability to manage sub recipes in desktop ui (#6360)

Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
Signed-off-by: Abhijay Jain <Abhijay007j@gmail.com>
This commit is contained in:
Abhijay Jain
2026-03-23 21:04:58 +05:30
committed by GitHub
parent 59c74e1b7d
commit 79f539f8af
15 changed files with 1079 additions and 16 deletions
+9 -1
View File
@@ -7562,9 +7562,17 @@
"SaveRecipeResponse": {
"type": "object",
"required": [
"id"
"id",
"file_name",
"file_path"
],
"properties": {
"file_name": {
"type": "string"
},
"file_path": {
"type": "string"
},
"id": {
"type": "string"
}
+2
View File
@@ -1173,6 +1173,8 @@ export type SaveRecipeRequest = {
};
export type SaveRecipeResponse = {
file_name: string;
file_path: string;
id: string;
};
@@ -20,6 +20,7 @@ interface CreateEditRecipeModalProps {
recipe?: Recipe;
isCreateMode?: boolean;
recipeId?: string | null;
onRecipeSaved?: (savedRecipeId: string) => void;
}
export default function CreateEditRecipeModal({
@@ -28,6 +29,7 @@ export default function CreateEditRecipeModal({
recipe,
isCreateMode = false,
recipeId,
onRecipeSaved,
}: CreateEditRecipeModalProps) {
const getInitialValues = React.useCallback((): RecipeFormData => {
if (recipe) {
@@ -44,6 +46,13 @@ export default function CreateEditRecipeModal({
model: recipe.settings?.goose_model ?? undefined,
provider: recipe.settings?.goose_provider ?? undefined,
extensions: recipe.extensions || undefined,
subRecipes: (recipe.sub_recipes || []).map((sr) => ({
name: sr.name,
path: sr.path,
description: sr.description || undefined,
values: sr.values || undefined,
sequential_when_repeated: sr.sequential_when_repeated ?? false,
})),
};
}
return {
@@ -57,6 +66,7 @@ export default function CreateEditRecipeModal({
model: undefined,
provider: undefined,
extensions: undefined,
subRecipes: [],
};
}, [recipe]);
@@ -75,6 +85,7 @@ export default function CreateEditRecipeModal({
const [model, setModel] = useState(form.state.values.model);
const [provider, setProvider] = useState(form.state.values.provider);
const [extensions, setExtensions] = useState(form.state.values.extensions);
const [subRecipes, setSubRecipes] = useState(form.state.values.subRecipes);
// Subscribe to form changes to update local state
useEffect(() => {
@@ -89,6 +100,7 @@ export default function CreateEditRecipeModal({
setModel(form.state.values.model);
setProvider(form.state.values.provider);
setExtensions(form.state.values.extensions);
setSubRecipes(form.state.values.subRecipes);
});
}, [form]);
const [copied, setCopied] = useState(false);
@@ -137,6 +149,21 @@ export default function CreateEditRecipeModal({
}
}
// Format subrecipes for API (convert from form data to API format)
const formattedSubRecipes =
subRecipes.length > 0
? subRecipes.map((subRecipe) => ({
name: subRecipe.name,
path: subRecipe.path,
description: subRecipe.description || undefined,
values:
subRecipe.values && Object.keys(subRecipe.values).length > 0
? subRecipe.values
: undefined,
sequential_when_repeated: subRecipe.sequential_when_repeated,
}))
: undefined;
const cleanedExtensions = extensions?.map(
(extension: ExtensionConfig & { envs?: unknown; enabled?: boolean }) => {
const { envs: _envs, enabled: _enabled, ...rest } = extension;
@@ -172,6 +199,7 @@ export default function CreateEditRecipeModal({
prompt: prompt || undefined,
parameters: formattedParameters,
response: responseConfig,
sub_recipes: formattedSubRecipes,
extensions: cleanedExtensions,
settings,
};
@@ -184,6 +212,7 @@ export default function CreateEditRecipeModal({
prompt,
parameters,
jsonSchema,
subRecipes,
model,
provider,
extensions,
@@ -258,6 +287,7 @@ export default function CreateEditRecipeModal({
activities,
parameters,
jsonSchema,
subRecipes,
model,
provider,
extensions,
@@ -293,7 +323,11 @@ export default function CreateEditRecipeModal({
try {
const recipe = getCurrentRecipe();
await saveRecipe(recipe, recipeId);
const { id: savedRecipeId } = await saveRecipe(recipe, recipeId);
if (onRecipeSaved) {
onRecipeSaved(savedRecipeId);
}
onClose(true);
@@ -327,9 +361,8 @@ export default function CreateEditRecipeModal({
try {
const recipe = getCurrentRecipe();
const savedId = await saveRecipe(recipe, recipeId);
const { id: savedId } = await saveRecipe(recipe, recipeId);
// Close modal first
onClose(true);
window.electron.createChatWindow({ recipeId: savedId });
@@ -40,6 +40,7 @@ export default function CreateRecipeFromSessionModal({
activities: [] as string[],
parameters: [] as RecipeParameter[],
jsonSchema: '',
subRecipes: [],
recipeName: '',
global: true,
} as RecipeFormData,
@@ -156,6 +157,20 @@ export default function CreateRecipeFromSessionModal({
setIsCreating(true);
try {
const formattedSubRecipes =
formData.subRecipes.length > 0
? formData.subRecipes.map((subRecipe) => ({
name: subRecipe.name,
path: subRecipe.path,
description: subRecipe.description || undefined,
values:
subRecipe.values && Object.keys(subRecipe.values).length > 0
? subRecipe.values
: undefined,
sequential_when_repeated: subRecipe.sequential_when_repeated,
}))
: undefined;
const recipe: Recipe = {
title: formData.title,
description: formData.description,
@@ -180,9 +195,10 @@ export default function CreateRecipeFromSessionModal({
json_schema: JSON.parse(formData.jsonSchema),
}
: undefined,
sub_recipes: formattedSubRecipes,
};
const recipeId = await saveRecipe(recipe, null);
const { id: recipeId } = await saveRecipe(recipe, null);
onRecipeCreated?.(recipe);
onClose();
@@ -14,7 +14,7 @@ vi.mock('../../../toasts', () => ({
}));
vi.mock('../../../recipe/recipe_management', () => ({
saveRecipe: vi.fn(),
saveRecipe: vi.fn().mockResolvedValue({ id: 'mock-recipe-id', fileName: 'mock-recipe.yaml' }),
}));
vi.mock('../../ConfigContext', () => ({
@@ -0,0 +1,309 @@
import { useState, useCallback } from 'react';
import { useForm } from '@tanstack/react-form';
import { X, Save, Loader2 } from 'lucide-react';
import { Button } from '../../ui/button';
import { toastSuccess, toastError } from '../../../toasts';
import { saveRecipe } from '../../../recipe/recipe_management';
import { Recipe } from '../../../recipe';
import { SubRecipeFormData } from './recipeFormSchema';
import { useEscapeKey } from '../../../hooks/useEscapeKey';
import KeyValueEditor from './KeyValueEditor';
interface CreateSubRecipeInlineProps {
isOpen: boolean;
onClose: () => void;
onSubRecipeSaved: (subRecipe: SubRecipeFormData) => void;
existingSubRecipes?: SubRecipeFormData[];
}
export default function CreateSubRecipeInline({
isOpen,
onClose,
onSubRecipeSaved,
existingSubRecipes = [],
}: CreateSubRecipeInlineProps) {
useEscapeKey(isOpen, onClose);
const form = useForm({
defaultValues: {
title: '',
description: '',
instructions: '',
prompt: '',
activities: [],
parameters: [],
jsonSchema: '',
subRecipes: [],
},
});
const [name, setName] = useState('');
const [toolDescription, setToolDescription] = useState('');
const [sequentialWhenRepeated, setSequentialWhenRepeated] = useState(false);
const [values, setValues] = useState<Record<string, string>>({});
const [isSaving, setIsSaving] = useState(false);
const handleSave = useCallback(async () => {
const formValues = form.state.values;
if (
!name.trim() ||
!formValues.title.trim() ||
!formValues.description.trim() ||
!formValues.instructions.trim()
) {
toastError({
title: 'Validation Failed',
msg: 'Name, title, recipe description, and instructions are required.',
});
return;
}
const trimmedName = name.trim();
if (existingSubRecipes.some((sr) => sr.name === trimmedName)) {
toastError({
title: 'Duplicate Name',
msg: `A subrecipe named "${trimmedName}" already exists. Please use a unique name.`,
});
return;
}
setIsSaving(true);
try {
const recipe: Recipe = {
version: '1.0.0',
title: formValues.title.trim(),
description: formValues.description.trim(),
instructions: formValues.instructions.trim(),
};
const { filePath } = await saveRecipe(recipe, null);
const subRecipe: SubRecipeFormData = {
name: trimmedName,
path: filePath,
description: toolDescription.trim() || undefined,
sequential_when_repeated: sequentialWhenRepeated,
values: Object.keys(values).length > 0 ? values : undefined,
};
toastSuccess({
title: formValues.title.trim(),
msg: 'Subrecipe created successfully',
});
onSubRecipeSaved(subRecipe);
onClose();
form.reset();
setName('');
setToolDescription('');
setSequentialWhenRepeated(false);
setValues({});
} catch (error) {
console.error('Failed to save subrecipe:', error);
toastError({
title: 'Save Failed',
msg: `Failed to save subrecipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
});
} finally {
setIsSaving(false);
}
}, [form, name, toolDescription, sequentialWhenRepeated, values, existingSubRecipes, onSubRecipeSaved, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[500] flex items-center justify-center bg-black/50">
<div className="bg-background-primary border border-borderSubtle rounded-lg w-[90vw] max-w-2xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-borderSubtle">
<div>
<h2 className="text-xl font-medium text-textProminent">Create New Subrecipe</h2>
<p className="text-textSubtle text-sm">
Create a simple recipe to use as a callable tool in your main recipe
</p>
</div>
<Button
onClick={onClose}
variant="ghost"
size="sm"
className="p-2 hover:bg-bgSubtle rounded-lg transition-colors"
aria-label="Close create subrecipe modal"
>
<X className="w-5 h-5" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* Name Field */}
<div>
<label
htmlFor="subrecipe-name"
className="block text-sm font-medium text-text-standard mb-2"
>
Name <span className="text-text-danger">*</span>
</label>
<input
id="subrecipe-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-primary text-text-standard focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="e.g., security_scan"
/>
<p className="text-xs text-text-muted mt-1">
Unique identifier used to generate the tool name
</p>
</div>
{/* Title Field */}
<form.Field name="title">
{(field) => (
<div>
<label
htmlFor="subrecipe-title"
className="block text-sm font-medium text-text-standard mb-2"
>
Recipe Title <span className="text-text-danger">*</span>
</label>
<input
id="subrecipe-title"
type="text"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-primary text-text-standard focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="e.g., Security Analysis Tool"
/>
</div>
)}
</form.Field>
{/* Recipe Description Field */}
<form.Field name="description">
{(field) => (
<div>
<label
htmlFor="recipe-description"
className="block text-sm font-medium text-text-standard mb-2"
>
Recipe Description <span className="text-text-danger">*</span>
</label>
<input
id="recipe-description"
type="text"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-primary text-text-standard focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="What this recipe does when executed"
/>
</div>
)}
</form.Field>
{/* Instructions Field */}
<form.Field name="instructions">
{(field) => (
<div>
<label
htmlFor="subrecipe-instructions"
className="block text-sm font-medium text-text-standard mb-2"
>
Instructions <span className="text-text-danger">*</span>
</label>
<textarea
id="subrecipe-instructions"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-primary text-text-standard focus:outline-none focus:ring-2 focus:ring-ring resize-none font-mono text-sm"
placeholder="Instructions for the AI when this subrecipe is called..."
rows={8}
/>
</div>
)}
</form.Field>
{/* Tool Description Field */}
<div>
<label
htmlFor="tool-description"
className="block text-sm font-medium text-text-standard mb-2"
>
Tool Description
</label>
<textarea
id="tool-description"
value={toolDescription}
onChange={(e) => setToolDescription(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-primary text-text-standard focus:outline-none focus:ring-2 focus:ring-ring resize-none"
placeholder="Optional description shown when this is called as a tool"
rows={2}
/>
</div>
{/* Sequential When Repeated */}
<div className="flex items-center gap-2">
<input
id="subrecipe-sequential"
type="checkbox"
checked={sequentialWhenRepeated}
onChange={(e) => setSequentialWhenRepeated(e.target.checked)}
className="w-4 h-4 border-border-subtle rounded focus:ring-2 focus:ring-ring"
/>
<label htmlFor="subrecipe-sequential" className="text-sm text-text-standard">
Sequential when repeated
</label>
<span className="text-xs text-text-muted">
(Forces sequential execution of multiple instances)
</span>
</div>
{/* Pre-configured Values */}
<div>
<label className="block text-sm font-medium text-text-standard mb-2">
Pre-configured Values
</label>
<p className="text-xs text-text-muted mb-3">
Optional parameter values that are always passed to the subrecipe
</p>
<KeyValueEditor values={values} onChange={setValues} />
</div>
</div>
{/* Footer */}
<div className="flex gap-3 p-6 border-t border-borderSubtle justify-end">
<Button onClick={onClose} variant="outline">
Cancel
</Button>
<Button
onClick={handleSave}
disabled={
!name.trim() ||
!form.state.values.title.trim() ||
!form.state.values.description.trim() ||
!form.state.values.instructions.trim() ||
isSaving
}
className="inline-flex items-center gap-2"
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Creating...
</>
) : (
<>
<Save className="w-4 h-4" />
Create & Add Subrecipe
</>
)}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,103 @@
import React, { useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '../../ui/button';
interface KeyValueEditorProps {
values: Record<string, string>;
onChange: (values: Record<string, string>) => void;
keyPlaceholder?: string;
valuePlaceholder?: string;
}
export default function KeyValueEditor({
values,
onChange,
keyPlaceholder = 'Parameter name...',
valuePlaceholder = 'Parameter value...',
}: KeyValueEditorProps) {
const [newKey, setNewKey] = useState('');
const [newValue, setNewValue] = useState('');
const handleAdd = () => {
if (newKey.trim() && newValue.trim()) {
onChange({ ...values, [newKey.trim()]: newValue.trim() });
setNewKey('');
setNewValue('');
}
};
const handleRemove = (key: string) => {
const updated = { ...values };
delete updated[key];
onChange(updated);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAdd();
}
};
return (
<div>
<div className="flex gap-2 mb-3">
<input
type="text"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={keyPlaceholder}
className="flex-1 px-3 py-2 border border-border-subtle rounded-lg bg-background-primary text-text-standard focus:outline-none focus:ring-2 focus:ring-ring text-sm"
/>
<input
type="text"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={valuePlaceholder}
className="flex-1 px-3 py-2 border border-border-subtle rounded-lg bg-background-primary text-text-standard focus:outline-none focus:ring-2 focus:ring-ring text-sm"
/>
<Button
type="button"
onClick={handleAdd}
disabled={!newKey.trim() || !newValue.trim()}
variant="outline"
size="sm"
className="px-3"
aria-label="Add pre-configured value"
>
<Plus className="w-4 h-4" />
</Button>
</div>
{Object.keys(values).length > 0 && (
<div className="space-y-2 border border-border-subtle rounded-lg p-3">
{Object.entries(values).map(([key, value]) => (
<div
key={key}
className="flex items-center justify-between p-2 bg-background-muted rounded"
>
<div className="flex-1">
<span className="text-sm font-medium text-text-standard">{key}</span>
<span className="text-sm text-text-muted mx-2">=</span>
<span className="text-sm text-text-standard">{value}</span>
</div>
<Button
type="button"
onClick={() => handleRemove(key)}
variant="ghost"
size="sm"
className="p-1 hover:bg-background-danger/10 hover:text-text-danger"
aria-label={`Remove pre-configured value ${key}`}
title={`Remove pre-configured value ${key}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
</div>
);
}
@@ -7,9 +7,10 @@ import ParameterInput from '../../parameter/ParameterInput';
import RecipeActivityEditor from '../RecipeActivityEditor';
import JsonSchemaEditor from './JsonSchemaEditor';
import InstructionsEditor from './InstructionsEditor';
import SubRecipeEditor from './SubRecipeEditor';
import { Button } from '../../ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../../ui/collapsible';
import { RecipeFormApi, RecipeFormData } from './recipeFormSchema';
import { RecipeFormApi, RecipeFormData, SubRecipeFormData } from './recipeFormSchema';
import { RecipeModelSelector } from './RecipeModelSelector';
import { RecipeExtensionSelector } from './RecipeExtensionSelector';
@@ -334,7 +335,7 @@ export function RecipeFormFields({
/>
<span className="text-sm font-medium text-textStandard">Advanced Options</span>
<span className="text-xs text-textSubtle">
Activities, parameters, model, extensions, response schema
Activities, parameters, model, extensions, response schema, subrecipes
</span>
</CollapsibleTrigger>
@@ -374,7 +375,7 @@ export function RecipeFormFields({
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddParameter();
@@ -422,7 +423,7 @@ export function RecipeFormFields({
type="text"
value={newParameterName}
onChange={(e) => setNewParameterName(e.target.value)}
onKeyPress={handleKeyPress}
onKeyDown={handleKeyDown}
placeholder="Enter parameter name..."
className="flex-1 px-3 py-2 border border-border-primary rounded-lg bg-background-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
@@ -554,6 +555,18 @@ export function RecipeFormFields({
</div>
)}
</form.Field>
{/* Subrecipes Field */}
<form.Field name="subRecipes">
{(field: FormFieldApi<SubRecipeFormData[]>) => (
<div>
<SubRecipeEditor
subRecipes={field.state.value}
onChange={(subRecipes) => field.handleChange(subRecipes)}
/>
</div>
)}
</form.Field>
</CollapsibleContent>
</Collapsible>
</div>
@@ -0,0 +1,194 @@
import { useState } from 'react';
import { Plus, Edit2, Trash2, FilePlus } from 'lucide-react';
import { Button } from '../../ui/button';
import { SubRecipeFormData } from './recipeFormSchema';
import SubRecipeModal from './SubRecipeModal';
import CreateSubRecipeInline from './CreateSubRecipeInline';
import { toastError } from '../../../toasts';
interface SubRecipeEditorProps {
subRecipes: SubRecipeFormData[];
onChange: (subRecipes: SubRecipeFormData[]) => void;
}
export default function SubRecipeEditor({ subRecipes, onChange }: SubRecipeEditorProps) {
const [showModal, setShowModal] = useState(false);
const [editingSubRecipe, setEditingSubRecipe] = useState<SubRecipeFormData | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [showCreateRecipeModal, setShowCreateRecipeModal] = useState(false);
const handleAddSubRecipe = () => {
setEditingSubRecipe(null);
setEditingIndex(null);
setShowModal(true);
};
const handleCreateNewRecipe = () => {
setShowCreateRecipeModal(true);
};
const handleEditSubRecipe = (subRecipe: SubRecipeFormData, index: number) => {
setEditingSubRecipe(subRecipe);
setEditingIndex(index);
setShowModal(true);
};
const handleDeleteSubRecipe = (index: number) => {
const newSubRecipes = subRecipes.filter((_, i) => i !== index);
onChange(newSubRecipes);
};
const handleSaveSubRecipe = (subRecipe: SubRecipeFormData): boolean => {
const isDuplicate = subRecipes.some(
(sr, i) => sr.name === subRecipe.name && i !== editingIndex
);
if (isDuplicate) {
toastError({
title: 'Duplicate Name',
msg: `A subrecipe named "${subRecipe.name}" already exists. Please use a unique name.`,
});
return false;
}
if (editingIndex !== null) {
const newSubRecipes = [...subRecipes];
newSubRecipes[editingIndex] = subRecipe;
onChange(newSubRecipes);
} else {
onChange([...subRecipes, subRecipe]);
}
return true;
};
const handleSubRecipeSaved = (subRecipe: SubRecipeFormData) => {
if (subRecipes.some((sr) => sr.name === subRecipe.name)) {
toastError({
title: 'Duplicate Name',
msg: `A subrecipe named "${subRecipe.name}" already exists. Please use a unique name.`,
});
return;
}
onChange([...subRecipes, subRecipe]);
};
return (
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-md text-textProminent font-bold">Subrecipes</label>
<div className="flex gap-2">
<Button
type="button"
onClick={handleCreateNewRecipe}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<FilePlus className="w-4 h-4" />
Create New Subrecipe
</Button>
<Button
type="button"
onClick={handleAddSubRecipe}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Add Existing
</Button>
</div>
</div>
<p className="text-textSubtle text-sm mb-4">
Subrecipes are recipes that can be called as tools during execution. They enable multi-step
workflows and reusable components.
</p>
{subRecipes.length > 0 && (
<div className="space-y-2">
{subRecipes.map((subRecipe, index) => (
<div
key={subRecipe.name}
className="border border-border-subtle rounded-lg p-4 bg-background-default hover:bg-background-muted transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-semibold text-textProminent">{subRecipe.name}</h4>
{subRecipe.sequential_when_repeated && (
<span className="text-xs px-2 py-0.5 bg-background-info/10 text-text-info rounded">
Sequential
</span>
)}
</div>
<p className="text-xs text-text-muted mb-2">{subRecipe.path}</p>
{subRecipe.description && (
<p className="text-sm text-text-standard mb-2">{subRecipe.description}</p>
)}
{subRecipe.values && Object.keys(subRecipe.values).length > 0 && (
<div className="mt-2">
<p className="text-xs text-text-muted mb-1">Pre-configured values:</p>
<div className="flex flex-wrap gap-1">
{Object.entries(subRecipe.values).map(([key, value]) => (
<span
key={key}
className="text-xs px-2 py-1 bg-background-muted border border-border-subtle rounded"
>
<span className="font-medium">{key}</span>
<span className="text-text-muted">: </span>
<span className="text-text-standard">{value}</span>
</span>
))}
</div>
</div>
)}
</div>
<div className="flex gap-1 ml-4">
<Button
type="button"
onClick={() => handleEditSubRecipe(subRecipe, index)}
variant="ghost"
size="sm"
className="p-2 hover:bg-background-secondary hover:text-text-primary"
aria-label={`Edit subrecipe ${subRecipe.name}`}
title={`Edit subrecipe ${subRecipe.name}`}
>
<Edit2 className="w-4 h-4" />
</Button>
<Button
type="button"
onClick={() => handleDeleteSubRecipe(index)}
variant="ghost"
size="sm"
className="p-2 hover:bg-background-danger/10 hover:text-text-danger"
aria-label={`Delete subrecipe ${subRecipe.name}`}
title={`Delete subrecipe ${subRecipe.name}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
<SubRecipeModal
isOpen={showModal}
onClose={() => {
setShowModal(false);
}}
onSave={handleSaveSubRecipe}
subRecipe={editingSubRecipe}
/>
<CreateSubRecipeInline
isOpen={showCreateRecipeModal}
onClose={() => {
setShowCreateRecipeModal(false);
}}
onSubRecipeSaved={handleSubRecipeSaved}
existingSubRecipes={subRecipes}
/>
</div>
);
}
@@ -0,0 +1,224 @@
import { useState, useEffect } from 'react';
import { X, FolderOpen } from 'lucide-react';
import { Button } from '../../ui/button';
import { SubRecipeFormData } from './recipeFormSchema';
import { useEscapeKey } from '../../../hooks/useEscapeKey';
import KeyValueEditor from './KeyValueEditor';
import { toastError } from '../../../toasts';
interface SubRecipeModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (subRecipe: SubRecipeFormData) => boolean;
subRecipe?: SubRecipeFormData | null;
}
export default function SubRecipeModal({
isOpen,
onClose,
onSave,
subRecipe,
}: SubRecipeModalProps) {
const [name, setName] = useState('');
const [path, setPath] = useState('');
const [description, setDescription] = useState('');
const [sequentialWhenRepeated, setSequentialWhenRepeated] = useState(false);
const [values, setValues] = useState<Record<string, string>>({});
useEscapeKey(isOpen, onClose);
useEffect(() => {
if (isOpen) {
if (subRecipe) {
setName(subRecipe.name);
setPath(subRecipe.path);
setDescription(subRecipe.description || '');
setSequentialWhenRepeated(subRecipe.sequential_when_repeated ?? false);
setValues(subRecipe.values || {});
} else {
setName('');
setPath('');
setDescription('');
setSequentialWhenRepeated(false);
setValues({});
}
}
}, [isOpen, subRecipe]);
const handleSave = () => {
if (!name.trim() || !path.trim()) {
return;
}
const subRecipeData: SubRecipeFormData = {
name: name.trim(),
path: path.trim(),
description: description.trim() || undefined,
sequential_when_repeated: sequentialWhenRepeated,
values: Object.keys(values).length > 0 ? values : undefined,
};
if (onSave(subRecipeData)) {
onClose();
}
};
const handleBrowseFile = async () => {
try {
const selectedPath = await window.electron.selectFileOrDirectory();
if (selectedPath) {
if (!selectedPath.endsWith('.yaml') && !selectedPath.endsWith('.yml')) {
toastError({
title: 'Invalid File',
msg: 'Please select a YAML file (.yaml or .yml).',
});
return;
}
setPath(selectedPath);
}
} catch (error) {
console.error('Failed to browse for file:', error);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[500] flex items-center justify-center bg-black/50">
<div className="bg-background-primary border border-borderSubtle rounded-lg w-[90vw] max-w-2xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-borderSubtle">
<div>
<h2 className="text-xl font-medium text-textProminent">
{subRecipe ? 'Configure Subrecipe' : 'Add Subrecipe'}
</h2>
<p className="text-textSubtle text-sm">
Configure a subrecipe that can be called as a tool during recipe execution
</p>
</div>
<Button
onClick={onClose}
variant="ghost"
size="sm"
className="p-2 hover:bg-bgSubtle rounded-lg transition-colors"
aria-label="Close subrecipe modal"
>
<X className="w-5 h-5" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* Name Field */}
<div>
<label
htmlFor="subrecipe-name"
className="block text-sm font-medium text-text-standard mb-2"
>
Name <span className="text-text-danger">*</span>
</label>
<input
id="subrecipe-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-primary text-text-standard focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="e.g., security_scan"
/>
<p className="text-xs text-text-muted mt-1">
Unique identifier used to generate the tool name
</p>
</div>
{/* Path Field */}
<div>
<label
htmlFor="subrecipe-path"
className="block text-sm font-medium text-text-standard mb-2"
>
Path <span className="text-text-danger">*</span>
</label>
<div className="flex gap-2">
<input
id="subrecipe-path"
type="text"
value={path}
onChange={(e) => setPath(e.target.value)}
className="flex-1 p-3 border border-border-subtle rounded-lg bg-background-primary text-text-standard focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="e.g., ./subrecipes/security-analysis.yaml"
/>
<Button
type="button"
onClick={handleBrowseFile}
variant="outline"
className="px-4 py-2 flex items-center gap-2"
>
<FolderOpen className="w-4 h-4" />
Browse
</Button>
</div>
<p className="text-xs text-text-muted mt-1">
Browse for an existing recipe file or enter a path manually
</p>
</div>
{/* Description Field */}
<div>
<label
htmlFor="subrecipe-description"
className="block text-sm font-medium text-text-standard mb-2"
>
Description
</label>
<textarea
id="subrecipe-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-primary text-text-standard focus:outline-none focus:ring-2 focus:ring-ring resize-none"
placeholder="Optional description of what this subrecipe does..."
rows={3}
/>
</div>
{/* Sequential When Repeated */}
<div className="flex items-center gap-2">
<input
id="subrecipe-sequential"
type="checkbox"
checked={sequentialWhenRepeated}
onChange={(e) => setSequentialWhenRepeated(e.target.checked)}
className="w-4 h-4 border-border-subtle rounded focus:ring-2 focus:ring-ring"
/>
<label htmlFor="subrecipe-sequential" className="text-sm text-text-standard">
Sequential when repeated
</label>
<span className="text-xs text-text-muted">
(Forces sequential execution of multiple subrecipe instances)
</span>
</div>
{/* Values Section */}
<div>
<label className="block text-sm font-medium text-text-standard mb-2">
Pre-configured Values
</label>
<p className="text-xs text-text-muted mb-3">
Optional parameter values that are always passed to the subrecipe
</p>
<KeyValueEditor values={values} onChange={setValues} />
</div>
</div>
{/* Footer */}
<div className="flex gap-2 p-6 border-t border-borderSubtle">
<Button onClick={onClose} variant="outline" className="flex-1">
Cancel
</Button>
<Button onClick={handleSave} disabled={!name.trim() || !path.trim()} className="flex-1">
{subRecipe ? 'Apply' : 'Add Subrecipe'}
</Button>
</div>
</div>
</div>
);
}
@@ -35,6 +35,7 @@ describe('RecipeFormFields', () => {
model: undefined,
provider: undefined,
extensions: undefined,
subRecipes: [],
...initialValues,
};
@@ -289,6 +290,7 @@ describe('RecipeFormFields', () => {
model: undefined,
provider: undefined,
extensions: undefined,
subRecipes: [],
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
@@ -385,6 +387,7 @@ describe('RecipeFormFields', () => {
model: undefined,
provider: undefined,
extensions: undefined,
subRecipes: [],
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
@@ -555,6 +558,7 @@ describe('RecipeFormFields', () => {
model: undefined,
provider: undefined,
extensions: undefined,
subRecipes: [],
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
@@ -631,6 +635,7 @@ describe('RecipeFormFields', () => {
model: undefined,
provider: undefined,
extensions: undefined,
subRecipes: [],
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
@@ -898,6 +903,7 @@ describe('RecipeFormFields', () => {
model: undefined,
provider: undefined,
extensions: undefined,
subRecipes: [],
} as RecipeFormData,
onSubmit: async ({ value }) => {
onSubmit(value);
@@ -929,6 +935,7 @@ describe('RecipeFormFields', () => {
model: undefined,
provider: undefined,
extensions: undefined,
subRecipes: [],
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
@@ -1000,4 +1007,126 @@ describe('RecipeFormFields', () => {
expect(screen.getByText('1 extension selected')).toBeInTheDocument();
});
});
describe('Subrecipes Field', () => {
it('renders the subrecipes section in advanced options', async () => {
const user = userEvent.setup();
render(<TestWrapper />);
await expandAdvancedSection(user);
expect(screen.getByText('Subrecipes')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /add existing/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /create new subrecipe/i })).toBeInTheDocument();
});
it('pre-fills subrecipes from initial values', async () => {
const user = userEvent.setup();
const initialValues: Partial<RecipeFormData> = {
subRecipes: [
{
name: 'data_fetcher',
path: '~/.config/goose/recipes/abc123.yaml',
description: 'Fetches data from an API',
sequential_when_repeated: false,
},
],
};
render(<TestWrapper initialValues={initialValues} />);
await expandAdvancedSection(user);
expect(screen.getByText('data_fetcher')).toBeInTheDocument();
expect(screen.getByText('~/.config/goose/recipes/abc123.yaml')).toBeInTheDocument();
expect(screen.getByText('Fetches data from an API')).toBeInTheDocument();
});
it('displays pre-configured values for a subrecipe', async () => {
const user = userEvent.setup();
const initialValues: Partial<RecipeFormData> = {
subRecipes: [
{
name: 'report_generator',
path: '~/.config/goose/recipes/def456.yaml',
sequential_when_repeated: false,
values: { output_format: 'pdf', language: 'en' },
},
],
};
render(<TestWrapper initialValues={initialValues} />);
await expandAdvancedSection(user);
expect(screen.getByText('report_generator')).toBeInTheDocument();
expect(screen.getByText('Pre-configured values:')).toBeInTheDocument();
expect(screen.getByText('output_format')).toBeInTheDocument();
expect(screen.getByText('pdf')).toBeInTheDocument();
expect(screen.getByText('language')).toBeInTheDocument();
expect(screen.getByText('en')).toBeInTheDocument();
});
it('shows sequential badge when subrecipe has sequential_when_repeated set', async () => {
const user = userEvent.setup();
const initialValues: Partial<RecipeFormData> = {
subRecipes: [
{
name: 'sequential_tool',
path: '~/.config/goose/recipes/ghi789.yaml',
sequential_when_repeated: true,
},
],
};
render(<TestWrapper initialValues={initialValues} />);
await expandAdvancedSection(user);
expect(screen.getByText('sequential_tool')).toBeInTheDocument();
expect(screen.getByText('Sequential')).toBeInTheDocument();
});
it('opens the add existing subrecipe modal on button click', async () => {
const user = userEvent.setup();
render(<TestWrapper />);
await expandAdvancedSection(user);
const addButton = screen.getByRole('button', { name: /add existing/i });
await user.click(addButton);
expect(screen.getByRole('heading', { name: /add subrecipe/i })).toBeInTheDocument();
expect(screen.getByLabelText(/^name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/^path/i)).toBeInTheDocument();
});
it('removes a subrecipe when its delete button is clicked', async () => {
const user = userEvent.setup();
const initialValues: Partial<RecipeFormData> = {
subRecipes: [
{
name: 'to_be_deleted',
path: '~/.config/goose/recipes/jkl012.yaml',
sequential_when_repeated: false,
},
],
};
render(<TestWrapper initialValues={initialValues} />);
await expandAdvancedSection(user);
expect(screen.getByText('to_be_deleted')).toBeInTheDocument();
// The delete button is the last icon button inside the subrecipe card
const subrecipeCard = screen.getByText('to_be_deleted').closest('.border');
const deleteButton = subrecipeCard?.querySelectorAll('button')[1];
expect(subrecipeCard).toBeTruthy();
expect(deleteButton).toBeTruthy();
await user.click(deleteButton!);
expect(screen.queryByText('to_be_deleted')).not.toBeInTheDocument();
});
});
});
@@ -17,6 +17,7 @@ describe('recipeFormSchema', () => {
},
],
jsonSchema: '{"type": "object"}',
subRecipes: [],
};
describe('Zod Schema Validation', () => {
@@ -14,6 +14,17 @@ const parameterSchema = z.object({
// Export the parameter type for use in components
export type RecipeParameter = z.infer<typeof parameterSchema>;
// Zod schema for SubRecipe - matching API SubRecipe type
const subRecipeSchema = z.object({
name: z.string().min(1, 'Subrecipe name is required'),
path: z.string().min(1, 'Subrecipe path is required'),
description: z.string().optional(),
values: z.record(z.string()).nullable().optional(),
sequential_when_repeated: z.boolean().default(false),
});
export type SubRecipeFormData = z.infer<typeof subRecipeSchema>;
// Main recipe form schema
export const recipeFormSchema = z.object({
title: z
@@ -46,6 +57,8 @@ export const recipeFormSchema = z.object({
provider: z.string().optional(),
extensions: z.array(z.custom<ExtensionConfig>()).optional(),
subRecipes: z.array(subRecipeSchema).default([]),
});
export type RecipeFormData = z.infer<typeof recipeFormSchema>;
+10 -3
View File
@@ -1,16 +1,23 @@
import { Recipe, saveRecipe as saveRecipeApi, listRecipes, RecipeManifest } from '../api';
import { stripEmptyExtensions } from '.';
export const saveRecipe = async (recipe: Recipe, recipeId?: string | null): Promise<string> => {
export const saveRecipe = async (
recipe: Recipe,
recipeId?: string | null
): Promise<{ id: string; fileName: string; filePath: string }> => {
try {
let response = await saveRecipeApi({
const response = await saveRecipeApi({
body: {
recipe: stripEmptyExtensions(recipe),
id: recipeId,
},
throwOnError: true,
});
return response.data.id;
return {
id: response.data.id,
fileName: response.data.file_name,
filePath: response.data.file_path,
};
} catch (error) {
let error_message = 'unknown error';
if (typeof error === 'object' && error !== null && 'message' in error) {