diff --git a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx index 8934a741ada8..6b68a1edc75c 100644 --- a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx +++ b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx @@ -9,8 +9,8 @@ import { saveRecipe } from '../../recipe/recipeStorage'; import * as yaml from 'yaml'; import { toastSuccess, toastError } from '../../toasts'; import { useEscapeKey } from '../../hooks/useEscapeKey'; -import { RecipeNameField, recipeNameSchema } from './shared/RecipeNameField'; -import { generateRecipeNameFromTitle } from './shared/recipeNameUtils'; +import { RecipeTitleField } from './shared/RecipeTitleField'; +import { listSavedRecipes } from '../../recipe/recipeStorage'; import { validateRecipe, getValidationErrorMessages, @@ -39,7 +39,15 @@ const importRecipeSchema = z if (!file) return true; return file.size <= 1024 * 1024; }, 'File is too large, max size is 1MB'), - recipeName: recipeNameSchema, + recipeTitle: z + .string() + .min(1, 'Recipe title is required') + .max(100, 'Recipe title must be 100 characters or less') + .refine((title) => title.trim().length > 0, 'Recipe title cannot be empty') + .refine( + (title) => /^[^<>:"/\\|?*]+$/.test(title.trim()), + 'Recipe title contains invalid characters (< > : " / \\ | ? *)' + ), global: z.boolean(), }) .refine((data) => (data.deeplink && data.deeplink.trim()) || data.recipeUploadFile, { @@ -111,11 +119,34 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR return recipe as Recipe; }; + const validateTitleUniqueness = async ( + title: string, + isGlobal: boolean + ): Promise => { + if (!title.trim()) return undefined; + + try { + const existingRecipes = await listSavedRecipes(); + const titleExists = existingRecipes.some( + (recipe) => + recipe.recipe.title?.toLowerCase() === title.toLowerCase() && recipe.isGlobal === isGlobal + ); + + if (titleExists) { + return `A recipe with the same title already exists`; + } + } catch (error) { + console.warn('Failed to validate title uniqueness:', error); + } + + return undefined; + }; + const importRecipeForm = useForm({ defaultValues: { deeplink: '', recipeUploadFile: null as File | null, - recipeName: '', + recipeTitle: '', global: true, }, validators: { @@ -138,6 +169,16 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR recipe = await parseRecipeUploadFile(fileContent, value.recipeUploadFile!.name); } + recipe.title = value.recipeTitle.trim(); + + const titleValidationError = await validateTitleUniqueness( + value.recipeTitle.trim(), + value.global + ); + if (titleValidationError) { + throw new Error(titleValidationError); + } + const validationResult = validateRecipe(recipe); if (!validationResult.success) { const errorMessages = getValidationErrorMessages(validationResult.errors); @@ -145,7 +186,8 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR } await saveRecipe(recipe, { - name: value.recipeName.trim(), + name: '', + title: value.recipeTitle.trim(), global: value.global, }); @@ -153,7 +195,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR importRecipeForm.reset({ deeplink: '', recipeUploadFile: null, - recipeName: '', + recipeTitle: '', global: true, }); onClose(); @@ -161,7 +203,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR onSuccess(); toastSuccess({ - title: value.recipeName.trim(), + title: value.recipeTitle.trim(), msg: 'Recipe imported successfully', }); } catch (error) { @@ -183,16 +225,16 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR importRecipeForm.reset({ deeplink: '', recipeUploadFile: null, - recipeName: '', + recipeTitle: '', global: true, }); onClose(); }; - // Store reference to recipe name field for programmatic updates - let recipeNameFieldRef: { handleChange: (value: string) => void } | null = null; + // Store reference to recipe title field for programmatic updates + let recipeTitleFieldRef: { handleChange: (value: string) => void } | null = null; - // Auto-generate recipe name when deeplink changes + // Auto-populate recipe title when deeplink changes const handleDeeplinkChange = async ( value: string, field: { handleChange: (value: string) => void } @@ -204,13 +246,11 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR try { const recipe = await parseDeeplink(value.trim()); if (recipe && recipe.title) { - const suggestedName = generateRecipeNameFromTitle(recipe.title); - - // Use the recipe name field's handleChange method if available - if (recipeNameFieldRef) { - recipeNameFieldRef.handleChange(suggestedName); + // Use the recipe title field's handleChange method if available + if (recipeTitleFieldRef) { + recipeTitleFieldRef.handleChange(recipe.title); } else { - importRecipeForm.setFieldValue('recipeName', suggestedName); + importRecipeForm.setFieldValue('recipeTitle', recipe.title); } } } catch (error) { @@ -218,11 +258,11 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR console.log('Could not parse deeplink for auto-suggest:', error); } } else { - // Clear the recipe name when deeplink is empty - if (recipeNameFieldRef) { - recipeNameFieldRef.handleChange(''); + // Clear the recipe title when deeplink is empty + if (recipeTitleFieldRef) { + recipeTitleFieldRef.handleChange(''); } else { - importRecipeForm.setFieldValue('recipeName', ''); + importRecipeForm.setFieldValue('recipeTitle', ''); } } }; @@ -235,13 +275,11 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR const fileContent = await file.text(); const recipe = await parseRecipeUploadFile(fileContent, file.name); if (recipe.title) { - const suggestedName = generateRecipeNameFromTitle(recipe.title); - - // Use the recipe name field's handleChange method if available - if (recipeNameFieldRef) { - recipeNameFieldRef.handleChange(suggestedName); + // Use the recipe title field's handleChange method if available + if (recipeTitleFieldRef) { + recipeTitleFieldRef.handleChange(recipe.title); } else { - importRecipeForm.setFieldValue('recipeName', suggestedName); + importRecipeForm.setFieldValue('recipeTitle', recipe.title); } } } catch (error) { @@ -249,11 +287,11 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR console.log('Could not parse recipe file for auto-suggest:', error); } } else { - // Clear the recipe name when file is removed - if (recipeNameFieldRef) { - recipeNameFieldRef.handleChange(''); + // Clear the recipe title when file is removed + if (recipeTitleFieldRef) { + recipeTitleFieldRef.handleChange(''); } else { - importRecipeForm.setFieldValue('recipeName', ''); + importRecipeForm.setFieldValue('recipeTitle', ''); } } }; @@ -397,14 +435,14 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR interface.

- + {(field) => { // Store reference to the field for programmatic updates - recipeNameFieldRef = field; + recipeTitleFieldRef = field; return ( -

Expected Recipe Structure:

-
+              
                 {JSON.stringify(getRecipeJsonSchema(), null, 2)}
               

diff --git a/ui/desktop/src/components/recipes/shared/RecipeTitleField.tsx b/ui/desktop/src/components/recipes/shared/RecipeTitleField.tsx new file mode 100644 index 000000000000..52fc68cef083 --- /dev/null +++ b/ui/desktop/src/components/recipes/shared/RecipeTitleField.tsx @@ -0,0 +1,45 @@ +interface RecipeTitleFieldProps { + id: string; + value: string; + onChange: (value: string) => void; + onBlur: () => void; + errors: string[]; + label?: string; + required?: boolean; + disabled?: boolean; +} + +export function RecipeTitleField({ + id, + value, + onChange, + onBlur, + errors, + label = 'Recipe Title', + required = true, + disabled = false, +}: RecipeTitleFieldProps) { + return ( +

+ + onChange(e.target.value)} + onBlur={onBlur} + disabled={disabled} + className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + errors.length > 0 ? 'border-red-500' : 'border-border-subtle' + } ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`} + placeholder="My Recipe Title" + /> +

+ This will be the display name shown in your recipe library +

+ {errors.length > 0 &&

{errors[0]}

} +
+ ); +} diff --git a/ui/desktop/src/recipe/recipeStorage.ts b/ui/desktop/src/recipe/recipeStorage.ts index 38ccd633945c..c012c5dc9bd1 100644 --- a/ui/desktop/src/recipe/recipeStorage.ts +++ b/ui/desktop/src/recipe/recipeStorage.ts @@ -5,6 +5,7 @@ import { validateRecipe, getValidationErrorMessages } from './validation'; export interface SaveRecipeOptions { name: string; + title?: string; global?: boolean; // true for global (~/.config/goose/recipes/), false for project-specific (.goose/recipes/) } @@ -70,12 +71,23 @@ async function saveRecipeToFile(recipe: SavedRecipe): Promise { * Save a recipe to a file using IPC. */ export async function saveRecipe(recipe: Recipe, options: SaveRecipeOptions): Promise { - const { name, global = true } = options; + const { name, title, global = true } = options; - // Sanitize name - const sanitizedName = sanitizeRecipeName(name); - if (!sanitizedName) { - throw new Error('Invalid recipe name'); + let sanitizedName: string; + + if (title) { + recipe.title = title.trim(); + sanitizedName = generateRecipeFilename(recipe); + if (!sanitizedName) { + throw new Error('Invalid recipe title - cannot generate filename'); + } + } else { + // This branch should now be considered deprecated and will be removed once the same functionality + // is incorporated in CreateRecipeForm + sanitizedName = sanitizeRecipeName(name); + if (!sanitizedName) { + throw new Error('Invalid recipe name'); + } } const validationResult = validateRecipe(recipe);