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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user