feat: improve UX for saving recipes (#3214)

This commit is contained in:
Angie Jones
2025-07-02 10:43:12 -05:00
committed by GitHub
parent ecbd71920d
commit cac8159a1c
2 changed files with 384 additions and 16 deletions

View File

@@ -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) {
)}
</div>
{/* Action Buttons */}
<div className="flex flex-col space-y-2 pt-1">
<button
onClick={() => setIsScheduleModalOpen(true)}
disabled={!requiredFieldsAreFilled()}
className="w-full h-[60px] rounded-none border-t text-gray-900 dark:text-white hover:bg-gray-50 dark:border-gray-600 text-lg font-medium"
>
Create Schedule from Recipe
</button>
<div className="flex flex-col space-y-3 pt-4">
<div className="flex gap-3">
<button
onClick={handleSaveRecipeClick}
disabled={!requiredFieldsAreFilled() || saving}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-3 bg-bgStandard text-textStandard border border-borderStandard rounded-lg hover:bg-bgSubtle transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4" />
{saving ? 'Saving...' : 'Save Recipe'}
</button>
<button
onClick={() => setIsScheduleModalOpen(true)}
disabled={!requiredFieldsAreFilled()}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-3 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Calendar className="w-4 h-4" />
Create Schedule
</button>
</div>
<button
onClick={() => {
localStorage.removeItem('recipe_editor_extensions');
window.close();
}}
className="w-full p-3 text-textSubtle rounded-lg hover:bg-bgSubtle"
className="w-full p-3 text-textSubtle rounded-lg hover:bg-bgSubtle transition-colors"
>
Close
</button>
@@ -376,6 +444,87 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
localStorage.setItem('pendingScheduleDeepLink', deepLink);
}}
/>
{/* Save Recipe Dialog */}
{showSaveDialog && (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black bg-opacity-50">
<div className="bg-bgApp border border-borderSubtle rounded-lg p-6 w-96 max-w-[90vw]">
<h3 className="text-lg font-medium text-textProminent mb-4">Save Recipe</h3>
<div className="space-y-4">
<div>
<label
htmlFor="recipe-name"
className="block text-sm font-medium text-textStandard mb-2"
>
Recipe Name
</label>
<input
id="recipe-name"
type="text"
value={saveRecipeName}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-sm font-medium text-textStandard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={saveGlobal}
onChange={() => setSaveGlobal(true)}
className="mr-2"
/>
<span className="text-sm text-textStandard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={!saveGlobal}
onChange={() => setSaveGlobal(false)}
className="mr-2"
/>
<span className="text-sm text-textStandard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={() => {
setShowSaveDialog(false);
setSaveRecipeName('');
}}
className="px-4 py-2 text-textSubtle hover:text-textStandard transition-colors"
disabled={saving}
>
Cancel
</button>
<button
onClick={handleSaveRecipe}
disabled={!saveRecipeName.trim() || saving}
className="px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Recipe'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -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<string | null>(null);
const [selectedRecipe, setSelectedRecipe] = useState<SavedRecipe | null>(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 (
<div className="h-screen w-full animate-[fadein_200ms_ease-in_forwards]">
@@ -101,7 +206,7 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
<p className="text-red-500 mb-4">{error}</p>
<button
onClick={loadSavedRecipes}
className="px-4 py-2 bg-borderProminent text-white rounded-lg hover:bg-opacity-90"
className="px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90"
>
Retry
</button>
@@ -118,7 +223,16 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
<div className="flex flex-col pb-24">
<div className="px-8 pt-6 pb-4">
<BackButton onClick={onBack} />
<h1 className="text-3xl font-medium text-textStandard mt-1">Saved Recipes</h1>
<div className="flex items-center justify-between mt-1">
<h1 className="text-3xl font-medium text-textStandard">Saved Recipes</h1>
<button
onClick={handleImportClick}
className="flex items-center gap-2 px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors text-sm font-medium"
>
<Download className="w-4 h-4" />
Import Recipe
</button>
</div>
</div>
{/* Content Area */}
@@ -128,7 +242,10 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
<FileText className="w-16 h-16 text-textSubtle mb-4" />
<h3 className="text-lg font-medium text-textStandard mb-2">No saved recipes</h3>
<p className="text-textSubtle">
Save a recipe from an active session to see it here.
Create and save recipes from the Recipe Editor to see them here.
</p>
<p className="text-textSubtle text-sm mt-2">
You can also save recipes from active recipe sessions using the Settings menu.
</p>
</div>
) : (
@@ -168,7 +285,7 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
</button>
<button
onClick={() => handlePreviewRecipe(savedRecipe)}
className="flex items-center gap-2 px-4 py-2 border border-borderSubtle rounded-lg hover:border-borderStandard transition-colors text-sm"
className="flex items-center gap-2 px-4 py-2 bg-bgStandard text-textStandard border border-borderStandard rounded-lg hover:bg-bgSubtle transition-colors text-sm"
>
<FileText className="w-4 h-4" />
Preview
@@ -275,6 +392,108 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
</div>
</div>
)}
{/* Import Recipe Dialog */}
{showImportDialog && (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black bg-opacity-50">
<div className="bg-bgApp border border-borderSubtle rounded-lg p-6 w-[500px] max-w-[90vw]">
<h3 className="text-lg font-medium text-textProminent mb-4">Import Recipe</h3>
<div className="space-y-4">
<div>
<label
htmlFor="import-deeplink"
className="block text-sm font-medium text-textStandard mb-2"
>
Recipe Deeplink
</label>
<textarea
id="import-deeplink"
value={importDeeplink}
onChange={(e) => handleDeeplinkChange(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 resize-none"
placeholder="Paste your goose://recipe?config=... deeplink here"
rows={3}
autoFocus
/>
<p className="text-xs text-textSubtle mt-1">
Paste a recipe deeplink starting with "goose://recipe?config="
</p>
</div>
<div>
<label
htmlFor="import-recipe-name"
className="block text-sm font-medium text-textStandard mb-2"
>
Recipe Name
</label>
<input
id="import-recipe-name"
type="text"
value={importRecipeName}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-textStandard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="import-save-location"
checked={importGlobal}
onChange={() => setImportGlobal(true)}
className="mr-2"
/>
<span className="text-sm text-textStandard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="import-save-location"
checked={!importGlobal}
onChange={() => setImportGlobal(false)}
className="mr-2"
/>
<span className="text-sm text-textStandard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={() => {
setShowImportDialog(false);
setImportDeeplink('');
setImportRecipeName('');
}}
className="px-4 py-2 text-textSubtle hover:text-textStandard transition-colors"
disabled={importing}
>
Cancel
</button>
<button
onClick={handleImportRecipe}
disabled={!importDeeplink.trim() || !importRecipeName.trim() || importing}
className="px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{importing ? 'Importing...' : 'Import Recipe'}
</button>
</div>
</div>
</div>
)}
</div>
);
}