From cac8159a1c6f74016cbdd1396bff0850984e6abb Mon Sep 17 00:00:00 2001 From: Angie Jones Date: Wed, 2 Jul 2025 10:43:12 -0500 Subject: [PATCH] feat: improve UX for saving recipes (#3214) --- ui/desktop/src/components/RecipeEditor.tsx | 169 ++++++++++++++- ui/desktop/src/components/RecipesView.tsx | 231 ++++++++++++++++++++- 2 files changed, 384 insertions(+), 16 deletions(-) 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

+ +
+
+ +