Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 74 additions & 36 deletions ui/desktop/src/components/recipes/ImportRecipeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -111,11 +119,34 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
return recipe as Recipe;
};

const validateTitleUniqueness = async (
title: string,
isGlobal: boolean
): Promise<string | undefined> => {
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: {
Expand All @@ -138,30 +169,41 @@ 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);
throw new Error(`Recipe validation failed: ${errorMessages.join(', ')}`);
}

await saveRecipe(recipe, {
name: value.recipeName.trim(),
name: '',
Copy link
Collaborator Author

@amed-xyz amed-xyz Sep 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

name should now be considered deprecated and will be removed once the same functionality is incorporated in recipe creation form

Copy link
Collaborator

@zanesq zanesq Sep 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a drawback to continuing to set name for now for backwards compatibility purposes? Also we might want to indicate that its deprecated in the api/types and check that its actually ok to deprecate from a backend perspective before we deprecate it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a drawback to continuing to set name for now for backwards compatibility purposes?

No drawback, but value.recipeName used to come from an input field that's been replaced by value.recipeTitle. We're still setting and using recipe's name in recipeStorage.saveRecipe though, it's just derived from the name now.

we might want to indicate that its deprecated in the api/types and check that its actually ok to deprecate from a backend perspective before we deprecate it.

Although we're not fully deprecating it. The only thing changing is that we no longer set it directly from this form, it's rather derived from the recipe title, which the user can now control

title: value.recipeTitle.trim(),
global: value.global,
});

// Reset dialog state
importRecipeForm.reset({
deeplink: '',
recipeUploadFile: null,
recipeName: '',
recipeTitle: '',
global: true,
});
onClose();

onSuccess();

toastSuccess({
title: value.recipeName.trim(),
title: value.recipeTitle.trim(),
msg: 'Recipe imported successfully',
});
} catch (error) {
Expand All @@ -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 }
Expand All @@ -204,25 +246,23 @@ 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) {
// Silently handle parsing errors during auto-suggest
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', '');
}
}
};
Expand All @@ -235,25 +275,23 @@ 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) {
// Silently handle parsing errors during auto-suggest
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', '');
}
}
};
Expand Down Expand Up @@ -397,14 +435,14 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
interface.
</p>

<importRecipeForm.Field name="recipeName">
<importRecipeForm.Field name="recipeTitle">
{(field) => {
// Store reference to the field for programmatic updates
recipeNameFieldRef = field;
recipeTitleFieldRef = field;

return (
<RecipeNameField
id="import-recipe-name"
<RecipeTitleField
id="import-recipe-title"
value={field.state.value}
onChange={field.handleChange}
onBlur={field.handleBlur}
Expand Down Expand Up @@ -491,7 +529,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
</div>
<div className="flex-1 overflow-auto">
<p className="font-medium mb-3 text-text-standard">Expected Recipe Structure:</p>
<pre className="text-xs bg-gray-100 p-4 rounded overflow-auto whitespace-pre font-mono">
<pre className="text-xs bg-gray-800 p-4 rounded overflow-auto whitespace-pre font-mono">
{JSON.stringify(getRecipeJsonSchema(), null, 2)}
</pre>
<p className="mt-4 text-blue-700 text-sm">
Expand Down
45 changes: 45 additions & 0 deletions ui/desktop/src/components/recipes/shared/RecipeTitleField.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<label htmlFor={id} className="block text-sm font-medium text-text-standard mb-2">
{label} {required && <span className="text-red-500">*</span>}
</label>
<input
id={id}
type="text"
value={value}
onChange={(e) => 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"
/>
<p className="text-xs text-text-muted mt-1">
This will be the display name shown in your recipe library
</p>
{errors.length > 0 && <p className="text-red-500 text-sm mt-1">{errors[0]}</p>}
</div>
);
}
22 changes: 17 additions & 5 deletions ui/desktop/src/recipe/recipeStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
}

Expand Down Expand Up @@ -70,12 +71,23 @@ async function saveRecipeToFile(recipe: SavedRecipe): Promise<boolean> {
* Save a recipe to a file using IPC.
*/
export async function saveRecipe(recipe: Recipe, options: SaveRecipeOptions): Promise<string> {
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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we call it title instead of name here now?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is actually for setting the recipe's name derived from the title


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);
Expand Down
Loading