diff --git a/ui/desktop/src/components/RecipeEditor.tsx b/ui/desktop/src/components/RecipeEditor.tsx
index e062357f..2dee8c6b 100644
--- a/ui/desktop/src/components/RecipeEditor.tsx
+++ b/ui/desktop/src/components/RecipeEditor.tsx
@@ -4,13 +4,15 @@ import { Buffer } from 'buffer';
import { FullExtensionConfig } from '../extensions';
import { Geese } from './icons/Geese';
import Copy from './icons/Copy';
-import { Check } from 'lucide-react';
+import { Check, Save, Calendar } from 'lucide-react';
import { useConfig } from './ConfigContext';
import { FixedExtensionEntry } from './ConfigContext';
import RecipeActivityEditor from './RecipeActivityEditor';
import RecipeInfoModal from './RecipeInfoModal';
import RecipeExpandableInfo from './RecipeExpandableInfo';
import { ScheduleFromRecipeModal } from './schedule/ScheduleFromRecipeModal';
+import { saveRecipe, generateRecipeFilename } from '../recipe/recipeStorage';
+import { toastSuccess, toastError } from '../toasts';
interface RecipeEditorProps {
config?: Recipe;
@@ -35,6 +37,10 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
const [copied, setCopied] = useState(false);
const [isRecipeInfoModalOpen, setRecipeInfoModalOpen] = useState(false);
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
+ const [showSaveDialog, setShowSaveDialog] = useState(false);
+ const [saveRecipeName, setSaveRecipeName] = useState('');
+ const [saveGlobal, setSaveGlobal] = useState(true);
+ const [saving, setSaving] = useState(false);
const [recipeInfoModelProps, setRecipeInfoModelProps] = useState<{
label: string;
value: string;
@@ -177,6 +183,57 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
});
};
+ const handleSaveRecipe = async () => {
+ if (!saveRecipeName.trim()) {
+ return;
+ }
+
+ setSaving(true);
+ try {
+ const currentRecipe = getCurrentConfig();
+
+ if (!currentRecipe.title || !currentRecipe.description || !currentRecipe.instructions) {
+ throw new Error('Invalid recipe configuration: missing required fields');
+ }
+
+ await saveRecipe(currentRecipe, {
+ name: saveRecipeName.trim(),
+ global: saveGlobal,
+ });
+
+ // Reset dialog state
+ setShowSaveDialog(false);
+ setSaveRecipeName('');
+
+ toastSuccess({
+ title: saveRecipeName.trim(),
+ msg: `Recipe saved successfully`,
+ });
+ } catch (error) {
+ console.error('Failed to save recipe:', error);
+
+ toastError({
+ title: 'Save Failed',
+ msg: `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ traceback: error instanceof Error ? error.message : String(error),
+ });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleSaveRecipeClick = () => {
+ if (!validateForm()) {
+ return;
+ }
+
+ const currentRecipe = getCurrentConfig();
+ // Generate a suggested name from the recipe title
+ const suggestedName = generateRecipeFilename(currentRecipe);
+ setSaveRecipeName(suggestedName);
+ setShowSaveDialog(true);
+ };
+
const onClickEditTextArea = ({
label,
value,
@@ -331,20 +388,31 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
)}
{/* Action Buttons */}
-
-
+
+
+
+
+
@@ -376,6 +444,87 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
localStorage.setItem('pendingScheduleDeepLink', deepLink);
}}
/>
+
+ {/* Save Recipe Dialog */}
+ {showSaveDialog && (
+
+
+
Save Recipe
+
+
+
+
+ setSaveRecipeName(e.target.value)}
+ className="w-full p-3 border border-borderSubtle rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
+ placeholder="Enter recipe name"
+ autoFocus
+ />
+
+
+
+
+
+
+
+
+
+
+
+ )}
);
}
diff --git a/ui/desktop/src/components/RecipesView.tsx b/ui/desktop/src/components/RecipesView.tsx
index 9c2e110f..bdc9c8b2 100644
--- a/ui/desktop/src/components/RecipesView.tsx
+++ b/ui/desktop/src/components/RecipesView.tsx
@@ -1,9 +1,18 @@
import { useState, useEffect } from 'react';
-import { listSavedRecipes, archiveRecipe, SavedRecipe } from '../recipe/recipeStorage';
-import { FileText, Trash2, Bot, Calendar, Globe, Folder } from 'lucide-react';
+import {
+ listSavedRecipes,
+ archiveRecipe,
+ SavedRecipe,
+ saveRecipe,
+ generateRecipeFilename,
+} from '../recipe/recipeStorage';
+import { FileText, Trash2, Bot, Calendar, Globe, Folder, Download } from 'lucide-react';
import { ScrollArea } from './ui/scroll-area';
import BackButton from './ui/BackButton';
import MoreMenuLayout from './more_menu/MoreMenuLayout';
+import { Recipe } from '../recipe';
+import { Buffer } from 'buffer';
+import { toastSuccess, toastError } from '../toasts';
interface RecipesViewProps {
onBack: () => void;
@@ -15,6 +24,11 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
const [error, setError] = useState
(null);
const [selectedRecipe, setSelectedRecipe] = useState(null);
const [showPreview, setShowPreview] = useState(false);
+ const [showImportDialog, setShowImportDialog] = useState(false);
+ const [importDeeplink, setImportDeeplink] = useState('');
+ const [importRecipeName, setImportRecipeName] = useState('');
+ const [importGlobal, setImportGlobal] = useState(true);
+ const [importing, setImporting] = useState(false);
useEffect(() => {
loadSavedRecipes();
@@ -81,6 +95,97 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
setShowPreview(true);
};
+ // Function to parse deeplink and extract recipe
+ const parseDeeplink = (deeplink: string): Recipe | null => {
+ try {
+ const cleanLink = deeplink.trim();
+
+ if (!cleanLink.startsWith('goose://recipe?config=')) {
+ throw new Error('Invalid deeplink format. Expected: goose://recipe?config=...');
+ }
+
+ // Extract and decode the base64 config
+ const configBase64 = cleanLink.replace('goose://recipe?config=', '');
+
+ if (!configBase64) {
+ throw new Error('No recipe configuration found in deeplink');
+ }
+ const configJson = Buffer.from(configBase64, 'base64').toString('utf-8');
+ const recipe = JSON.parse(configJson) as Recipe;
+
+ if (!recipe.title || !recipe.description || !recipe.instructions) {
+ throw new Error('Recipe is missing required fields (title, description, instructions)');
+ }
+
+ return recipe;
+ } catch (error) {
+ console.error('Failed to parse deeplink:', error);
+ return null;
+ }
+ };
+
+ const handleImportRecipe = async () => {
+ if (!importDeeplink.trim() || !importRecipeName.trim()) {
+ return;
+ }
+
+ setImporting(true);
+ try {
+ const recipe = parseDeeplink(importDeeplink.trim());
+
+ if (!recipe) {
+ throw new Error('Invalid deeplink or recipe format');
+ }
+
+ await saveRecipe(recipe, {
+ name: importRecipeName.trim(),
+ global: importGlobal,
+ });
+
+ // Reset dialog state
+ setShowImportDialog(false);
+ setImportDeeplink('');
+ setImportRecipeName('');
+
+ await loadSavedRecipes();
+
+ toastSuccess({
+ title: importRecipeName.trim(),
+ msg: 'Recipe imported successfully',
+ });
+ } catch (error) {
+ console.error('Failed to import recipe:', error);
+
+ toastError({
+ title: 'Import Failed',
+ msg: `Failed to import recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ traceback: error instanceof Error ? error.message : String(error),
+ });
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ const handleImportClick = () => {
+ setImportDeeplink('');
+ setImportRecipeName('');
+ setImportGlobal(true);
+ setShowImportDialog(true);
+ };
+
+ // Auto-generate recipe name when deeplink changes
+ const handleDeeplinkChange = (value: string) => {
+ setImportDeeplink(value);
+
+ if (value.trim()) {
+ const recipe = parseDeeplink(value.trim());
+ if (recipe && recipe.title) {
+ const suggestedName = generateRecipeFilename(recipe);
+ setImportRecipeName(suggestedName);
+ }
+ }
+ };
+
if (loading) {
return (
@@ -101,7 +206,7 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
{error}
@@ -118,7 +223,16 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
-
Saved Recipes
+
+
Saved Recipes
+
+
{/* Content Area */}
@@ -128,7 +242,10 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
No saved recipes
- Save a recipe from an active session to see it here.
+ Create and save recipes from the Recipe Editor to see them here.
+
+
+ You can also save recipes from active recipe sessions using the Settings menu.
) : (
@@ -168,7 +285,7 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
)}
+
+ {/* Import Recipe Dialog */}
+ {showImportDialog && (
+
+
+
Import Recipe
+
+
+
+
+
+
+ setImportRecipeName(e.target.value)}
+ className="w-full p-3 border border-borderSubtle rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
+ placeholder="Enter a name for the imported recipe"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ )}
);
}