From bc40b696d332283d9309df06677f839f5335ef1d Mon Sep 17 00:00:00 2001 From: AaronGoldsmith Date: Sun, 15 Jun 2025 17:52:58 -0700 Subject: [PATCH 1/4] ag/wip-feat: save and load recipes --- ui/desktop/src/App.tsx | 3 + ui/desktop/src/components/ChatView.tsx | 3 +- ui/desktop/src/components/RecipesView.tsx | 277 ++++++++++++++++++ .../src/components/more_menu/MoreMenu.tsx | 219 ++++++++++++-- .../src/components/ui/DeepLinkModal.tsx | 1 + ui/desktop/src/main.ts | 6 + ui/desktop/src/preload.ts | 27 +- ui/desktop/src/recipe/index.ts | 1 + ui/desktop/src/recipe/recipeStorage.ts | 142 +++++++++ 9 files changed, 644 insertions(+), 35 deletions(-) create mode 100644 ui/desktop/src/components/RecipesView.tsx create mode 100644 ui/desktop/src/recipe/recipeStorage.ts diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index ace1228668a6..2a21b8266304 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -20,6 +20,7 @@ import SharedSessionView from './components/sessions/SharedSessionView'; import SchedulesView from './components/schedule/SchedulesView'; import ProviderSettings from './components/settings/providers/ProviderSettingsPage'; import RecipeEditor from './components/RecipeEditor'; +import RecipesView from './components/RecipesView'; import { useChat } from './hooks/useChat'; import 'react-toastify/dist/ReactToastify.css'; @@ -45,6 +46,7 @@ export type View = | 'sharedSession' | 'loading' | 'recipeEditor' + | 'recipes' | 'permission'; export type ViewOptions = { @@ -557,6 +559,7 @@ export default function App() { config={(viewOptions?.config as Recipe) || window.electron.getConfig().recipeConfig} /> )} + {view === 'recipes' && setView('chat')} />} {view === 'permission' && ( setView((viewOptions as { parentView: View }).parentView)} diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index 222cb994eb8f..2044dab9e6d8 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -246,7 +246,8 @@ function ChatContent({ console.log('Opening recipe editor with config:', response.recipe); const recipeConfig = { id: response.recipe.title || 'untitled', - name: response.recipe.title || 'Untitled Recipe', + name: response.recipe.title || 'Untitled Recipe', // Does not exist on recipe type + title: response.recipe.title || 'Untitled Recipe', description: response.recipe.description || '', instructions: response.recipe.instructions || '', activities: response.recipe.activities || [], diff --git a/ui/desktop/src/components/RecipesView.tsx b/ui/desktop/src/components/RecipesView.tsx new file mode 100644 index 000000000000..e35699747e85 --- /dev/null +++ b/ui/desktop/src/components/RecipesView.tsx @@ -0,0 +1,277 @@ +import { useState, useEffect } from 'react'; +import { listSavedRecipes, deleteRecipe, SavedRecipe } from '../recipe/recipeStorage'; +import { FileText, Trash2, Download, Calendar, Globe, Folder } from 'lucide-react'; +import { ScrollArea } from './ui/scroll-area'; +import BackButton from './ui/BackButton'; +import MoreMenuLayout from './more_menu/MoreMenuLayout'; + +interface RecipesViewProps { + onBack: () => void; +} + +export default function RecipesView({ onBack }: RecipesViewProps) { + const [savedRecipes, setSavedRecipes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedRecipe, setSelectedRecipe] = useState(null); + const [showPreview, setShowPreview] = useState(false); + + useEffect(() => { + loadSavedRecipes(); + }, []); + + const loadSavedRecipes = async () => { + try { + setLoading(true); + setError(null); + const recipes = await listSavedRecipes(); + setSavedRecipes(recipes); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load recipes'); + console.error('Failed to load saved recipes:', err); + } finally { + setLoading(false); + } + }; + + const handleLoadRecipe = async (savedRecipe: SavedRecipe) => { + try { + // Use the recipe directly - no need for manual mapping + window.electron.createChatWindow( + undefined, // query + undefined, // dir + undefined, // version + undefined, // resumeSessionId + savedRecipe.recipe, // recipe config + undefined // view type + ); + } catch (err) { + console.error('Failed to load recipe:', err); + setError(err instanceof Error ? err.message : 'Failed to load recipe'); + } + }; + + const handleDeleteRecipe = async (savedRecipe: SavedRecipe) => { + // TODO: Use Electron's dialog API for confirmation + // const result = await window.electron.showMessageBox({ + // type: 'warning', + // buttons: ['Cancel', 'Delete'], + // defaultId: 0, + // title: 'Delete Recipe', + // message: `Are you sure you want to delete "${savedRecipe.name}"?`, + // detail: 'This action cannot be undone.', + // }); + + // if (result.response !== 1) { + // return; + // } + + try { + await deleteRecipe(savedRecipe.path); + // Reload the recipes list + await loadSavedRecipes(); + } catch (err) { + console.error('Failed to delete recipe:', err); + setError(err instanceof Error ? err.message : 'Failed to delete recipe'); + } + }; + + const handlePreviewRecipe = (savedRecipe: SavedRecipe) => { + setSelectedRecipe(savedRecipe); + setShowPreview(true); + }; + + if (loading) { + return ( +
+ +
+
+

Loading recipes...

+
+
+ ); + } + + if (error) { + return ( +
+ +
+

{error}

+ +
+
+ ); + } + + return ( +
+ + + +
+
+ +

Saved Recipes

+
+ + {/* Content Area */} +
+ {savedRecipes.length === 0 ? ( +
+ +

No saved recipes

+

+ Save a recipe from an active session to see it here. +

+
+ ) : ( +
+ {savedRecipes.map((savedRecipe) => ( +
+
+
+
+

+ {savedRecipe.recipe.title} +

+ {savedRecipe.isGlobal ? ( + + ) : ( + + )} +
+

{savedRecipe.recipe.description}

+
+ + {savedRecipe.lastModified.toLocaleDateString()} +
+
+
+ +
+ + + +
+
+ ))} +
+ )} +
+
+
+ + {/* Preview Modal */} + {showPreview && selectedRecipe && ( +
+
+
+
+

+ {selectedRecipe.recipe.title} +

+

+ {selectedRecipe.isGlobal ? 'Global recipe' : 'Project recipe'} +

+
+ +
+ +
+
+

Description

+

{selectedRecipe.recipe.description}

+
+ + {selectedRecipe.recipe.instructions && ( +
+

Instructions

+
+
+                      {selectedRecipe.recipe.instructions}
+                    
+
+
+ )} + + {selectedRecipe.recipe.prompt && ( +
+

Initial Prompt

+
+
+                      {selectedRecipe.recipe.prompt}
+                    
+
+
+ )} + + {selectedRecipe.recipe.activities && selectedRecipe.recipe.activities.length > 0 && ( +
+

Activities

+
+ {selectedRecipe.recipe.activities.map((activity, index) => ( + + {activity} + + ))} +
+
+ )} +
+ +
+ + +
+
+
+ )} +
+ ); +} diff --git a/ui/desktop/src/components/more_menu/MoreMenu.tsx b/ui/desktop/src/components/more_menu/MoreMenu.tsx index a54ed55238f8..3239a656fae9 100644 --- a/ui/desktop/src/components/more_menu/MoreMenu.tsx +++ b/ui/desktop/src/components/more_menu/MoreMenu.tsx @@ -1,18 +1,14 @@ import { Popover, PopoverContent, PopoverPortal, PopoverTrigger } from '../ui/popover'; import React, { useEffect, useState } from 'react'; import { ChatSmart, Idea, Refresh, Time, Send, Settings } from '../icons'; -import { FolderOpen, Moon, Sliders, Sun } from 'lucide-react'; +import { FolderOpen, Moon, Sliders, Sun, Save, FileText } from 'lucide-react'; import { useConfig } from '../ConfigContext'; import { ViewOptions, View } from '../../App'; +import { saveRecipe, generateRecipeFilename } from '../../recipe/recipeStorage'; +import { Recipe } from '../../recipe'; -interface RecipeConfig { - id: string; - name: string; - description: string; - instructions?: string; - activities?: string[]; - [key: string]: unknown; -} +// RecipeConfig is used for window creation and should match Recipe interface +type RecipeConfig = Recipe; interface MenuButtonProps { onClick: () => void; @@ -113,6 +109,10 @@ export default function MoreMenu({ setIsGoosehintsModalOpen: (isOpen: boolean) => void; }) { const [open, setOpen] = useState(false); + const [showSaveDialog, setShowSaveDialog] = useState(false); + const [saveRecipeName, setSaveRecipeName] = useState(''); + const [saveGlobal, setSaveGlobal] = useState(true); + const [saving, setSaving] = useState(false); const { remove } = useConfig(); const [themeMode, setThemeMode] = useState<'light' | 'dark' | 'system'>(() => { const savedUseSystemTheme = localStorage.getItem('use_system_theme') === 'true'; @@ -167,6 +167,72 @@ export default function MoreMenu({ const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => { setThemeMode(newTheme); }; + + const handleSaveRecipe = async () => { + if (!saveRecipeName.trim()) { + return; + } + + setSaving(true); + try { + // Get the current recipe config from the window with proper validation + const currentRecipeConfig = window.appConfig.get('recipeConfig'); + + if (!currentRecipeConfig || typeof currentRecipeConfig !== 'object') { + throw new Error('No recipe configuration found'); + } + + // Validate that it has the required Recipe properties + const recipe = currentRecipeConfig as Recipe; + if (!recipe.title || !recipe.description || !recipe.instructions) { + throw new Error('Invalid recipe configuration: missing required fields'); + } + + // Save the recipe + const filePath = await saveRecipe(recipe, { + name: saveRecipeName.trim(), + global: saveGlobal, + }); + + // Show success message (you might want to use a toast notification instead) + console.log(`Recipe saved to: ${filePath}`); + + // Reset dialog state + setShowSaveDialog(false); + setSaveRecipeName(''); + setOpen(false); + + // Optional: Show a success notification + window.electron.showNotification({ + title: 'Recipe Saved', + body: `Recipe "${saveRecipeName}" has been saved successfully.`, + }); + } catch (error) { + console.error('Failed to save recipe:', error); + + // Show error notification + window.electron.showNotification({ + title: 'Save Failed', + body: `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}`, + }); + } finally { + setSaving(false); + } + }; + + const handleSaveRecipeClick = () => { + const currentRecipeConfig = window.appConfig.get('recipeConfig'); + + if (currentRecipeConfig && typeof currentRecipeConfig === 'object') { + const recipe = currentRecipeConfig as Recipe; + // Generate a suggested name from the recipe title + const suggestedName = generateRecipeFilename(recipe); + setSaveRecipeName(suggestedName); + setShowSaveDialog(true); + setOpen(false); + } + }; + const recipeConfig = window.appConfig.get('recipeConfig'); return ( @@ -245,23 +311,33 @@ export default function MoreMenu({ {recipeConfig ? ( - { - setOpen(false); - window.electron.createChatWindow( - undefined, // query - undefined, // dir - undefined, // version - undefined, // resumeSessionId - recipeConfig as RecipeConfig, // recipe config - 'recipeEditor' // view type - ); - }} - subtitle="View the recipe you're using" - icon={} - > - View recipe - + <> + { + setOpen(false); + window.electron.createChatWindow( + undefined, // query + undefined, // dir + undefined, // version + undefined, // resumeSessionId + recipeConfig as RecipeConfig, // recipe config + 'recipeEditor' // view type + ); + }} + subtitle="View the recipe you're using" + icon={} + > + View recipe + + + } + > + Save recipe + + ) : ( { @@ -276,6 +352,16 @@ export default function MoreMenu({ Make Agent from this session )} + { + setOpen(false); + setView('recipes'); + }} + subtitle="Browse and load saved recipes" + icon={} + > + Load Recipes + { setOpen(false); @@ -310,6 +396,87 @@ export default function MoreMenu({ + + {/* 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/ui/DeepLinkModal.tsx b/ui/desktop/src/components/ui/DeepLinkModal.tsx index 0bf2063c9674..1c112febe47a 100644 --- a/ui/desktop/src/components/ui/DeepLinkModal.tsx +++ b/ui/desktop/src/components/ui/DeepLinkModal.tsx @@ -138,6 +138,7 @@ export function DeepLinkModal({ recipeConfig: initialRecipeConfig, onClose }: De const currentConfig = { id: 'deeplink-recipe', name: 'DeepLink Recipe', + title: 'DeepLink Recipe', description: 'Recipe from deep link', ...recipeConfig, instructions, diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 630f208b7cc6..1c73f1672c78 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -1112,6 +1112,12 @@ ipcMain.handle('write-file', (_event, filePath, content) => { }); }); +// Handle message box dialogs +ipcMain.handle('show-message-box', async (_event, options) => { + const result = await dialog.showMessageBox(options); + return result; +}); + // Handle allowed extensions list fetching ipcMain.handle('get-allowed-extensions', async () => { try { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index d79f68a5d6fc..43617a06e0e6 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -1,19 +1,28 @@ import Electron, { contextBridge, ipcRenderer, webUtils } from 'electron'; +import { Recipe } from './recipe'; -interface RecipeConfig { - id: string; - name: string; - description: string; - instructions?: string; - activities?: string[]; - [key: string]: unknown; -} +// RecipeConfig is used for window creation and should match Recipe interface +type RecipeConfig = Recipe; interface NotificationData { title: string; body: string; } +interface MessageBoxOptions { + type?: 'none' | 'info' | 'error' | 'question' | 'warning'; + buttons?: string[]; + defaultId?: number; + title?: string; + message: string; + detail?: string; +} + +interface MessageBoxResponse { + response: number; + checkboxChecked?: boolean; +} + interface FileResponse { file: string; filePath: string; @@ -51,6 +60,7 @@ type ElectronAPI = { ) => void; logInfo: (txt: string) => void; showNotification: (data: NotificationData) => void; + showMessageBox: (options: MessageBoxOptions) => Promise; openInChrome: (url: string) => void; fetchMetadata: (url: string) => Promise; reloadApp: () => void; @@ -121,6 +131,7 @@ const electronAPI: ElectronAPI = { ), logInfo: (txt: string) => ipcRenderer.send('logInfo', txt), showNotification: (data: NotificationData) => ipcRenderer.send('notify', data), + showMessageBox: (options: MessageBoxOptions) => ipcRenderer.invoke('show-message-box', options), openInChrome: (url: string) => ipcRenderer.send('open-in-chrome', url), fetchMetadata: (url: string) => ipcRenderer.invoke('fetch-metadata', url), reloadApp: () => ipcRenderer.send('reload-app'), diff --git a/ui/desktop/src/recipe/index.ts b/ui/desktop/src/recipe/index.ts index aa185c611aa1..305a03e6872f 100644 --- a/ui/desktop/src/recipe/index.ts +++ b/ui/desktop/src/recipe/index.ts @@ -2,6 +2,7 @@ import { Message } from '../types/message'; import { getApiUrl } from '../config'; import { FullExtensionConfig } from '../extensions'; +// Should Recipe have a name and id? export interface Recipe { title: string; description: string; diff --git a/ui/desktop/src/recipe/recipeStorage.ts b/ui/desktop/src/recipe/recipeStorage.ts new file mode 100644 index 000000000000..186e32ddbf0b --- /dev/null +++ b/ui/desktop/src/recipe/recipeStorage.ts @@ -0,0 +1,142 @@ +import { Recipe } from './index'; + +export interface SaveRecipeOptions { + name: string; + global?: boolean; // true for global (~/.config/goose/recipes/), false for project-specific (.goose/recipes/) +} + +export interface SavedRecipe { + name: string; + path: string; + recipe: Recipe; + isGlobal: boolean; + lastModified: Date; +} + +/** + * Save a recipe to local storage. + * + * IMPORTANT: This is a temporary implementation using localStorage. + * In a production system, this should save actual YAML files to the filesystem. + * The returned path is a mock path for UI purposes only. + * + * TODO: Integrate with the Rust backend recipe system (or local filesystem) to save actual files. + */ +export async function saveRecipe(recipe: Recipe, options: SaveRecipeOptions): Promise { + const { name, global = true } = options; + + // Sanitize filename + const sanitizedName = name.replace(/[^a-zA-Z0-9-_\s]/g, '').trim(); + if (!sanitizedName) { + throw new Error('Invalid recipe name'); + } + + // Validate recipe has required fields + if (!recipe.title || !recipe.description || !recipe.instructions) { + throw new Error('Recipe is missing required fields (title, description, instructions)'); + } + + const recipeData = { + name: sanitizedName, + recipe: recipe, + savedAt: new Date().toISOString(), + isGlobal: global, + }; + + try { + // Store in localStorage with a unique key + const storageKey = `goose_saved_recipe_${sanitizedName}_${global ? 'global' : 'local'}`; + localStorage.setItem(storageKey, JSON.stringify(recipeData)); + + // Return a mock path that represents where the file would be saved + const mockPath = global + ? `~/.config/goose/recipes/${sanitizedName}.yaml` + : `.goose/recipes/${sanitizedName}.yaml`; + + return mockPath; + } catch (error) { + throw new Error( + `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Load a recipe from storage (not yet implemented for file system) + */ +export async function loadRecipe(_filePath: string): Promise { + // TODO: Implement file system loading when integrated with Rust backend + throw new Error('Recipe loading from files not yet implemented'); +} + +/** + * List all saved recipes from localStorage. + * + * IMPORTANT: This reads from localStorage, not actual files. + * TODO: Integrate with filesystem when Rust backend is available. + */ +export async function listSavedRecipes(): Promise { + const recipes: SavedRecipe[] = []; + + try { + // Get all saved recipes from localStorage + const keys = Object.keys(localStorage).filter((key) => key.startsWith('goose_saved_recipe_')); + + for (const key of keys) { + try { + const data = JSON.parse(localStorage.getItem(key) || '{}'); + if (data.recipe && data.name && data.savedAt) { + recipes.push({ + name: data.name, + path: key, // Use the localStorage key as identifier + recipe: data.recipe, + isGlobal: data.isGlobal, + lastModified: new Date(data.savedAt), + }); + } + } catch (error) { + console.warn(`Failed to parse saved recipe ${key}:`, error); + // Continue processing other recipes instead of failing completely + } + } + } catch (error) { + console.warn('Failed to load saved recipes:', error); + } + + // Sort by last modified (newest first) + return recipes.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); +} + +/** + * Delete a saved recipe from localStorage. + * + * @param storageKey The localStorage key (not a file path) + */ +export async function deleteRecipe(storageKey: string): Promise { + try { + if (!storageKey.startsWith('goose_saved_recipe_')) { + throw new Error('Invalid storage key'); + } + localStorage.removeItem(storageKey); + } catch (error) { + throw new Error( + `Failed to delete recipe: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Generate a suggested filename for a recipe based on its title. + * + * @param recipe The recipe to generate a filename for + * @returns A sanitized filename suitable for use as a recipe name + */ +export function generateRecipeFilename(recipe: Recipe): string { + const baseName = recipe.title + .toLowerCase() + .replace(/[^a-zA-Z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .trim(); + + return baseName || 'untitled-recipe'; +} From 402d17910e3cb337e4ca60edc570c684281b57ba Mon Sep 17 00:00:00 2001 From: AaronGoldsmith Date: Sun, 15 Jun 2025 20:57:23 -0700 Subject: [PATCH 2/4] add recipe storage remove comment --- ui/desktop/src/components/RecipesView.tsx | 35 +-- ui/desktop/src/main.ts | 54 +++- ui/desktop/src/preload.ts | 5 + ui/desktop/src/recipe/index.ts | 1 - ui/desktop/src/recipe/recipeStorage.ts | 317 ++++++++++++++++++---- 5 files changed, 330 insertions(+), 82 deletions(-) diff --git a/ui/desktop/src/components/RecipesView.tsx b/ui/desktop/src/components/RecipesView.tsx index e35699747e85..4b0c5470b659 100644 --- a/ui/desktop/src/components/RecipesView.tsx +++ b/ui/desktop/src/components/RecipesView.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { listSavedRecipes, deleteRecipe, SavedRecipe } from '../recipe/recipeStorage'; +import { listSavedRecipes, archiveRecipe, SavedRecipe } from '../recipe/recipeStorage'; import { FileText, Trash2, Download, Calendar, Globe, Folder } from 'lucide-react'; import { ScrollArea } from './ui/scroll-area'; import BackButton from './ui/BackButton'; @@ -53,26 +53,26 @@ export default function RecipesView({ onBack }: RecipesViewProps) { const handleDeleteRecipe = async (savedRecipe: SavedRecipe) => { // TODO: Use Electron's dialog API for confirmation - // const result = await window.electron.showMessageBox({ - // type: 'warning', - // buttons: ['Cancel', 'Delete'], - // defaultId: 0, - // title: 'Delete Recipe', - // message: `Are you sure you want to delete "${savedRecipe.name}"?`, - // detail: 'This action cannot be undone.', - // }); + const result = await window.electron.showMessageBox({ + type: 'warning', + buttons: ['Cancel', 'Delete'], + defaultId: 0, + title: 'Delete Recipe', + message: `Are you sure you want to delete "${savedRecipe.name}"?`, + detail: 'Deleted recipes can be restored later.', + }); - // if (result.response !== 1) { - // return; - // } + if (result.response !== 1) { + return; + } try { - await deleteRecipe(savedRecipe.path); + await archiveRecipe(savedRecipe.name, savedRecipe.isGlobal); // Reload the recipes list await loadSavedRecipes(); } catch (err) { - console.error('Failed to delete recipe:', err); - setError(err instanceof Error ? err.message : 'Failed to delete recipe'); + console.error('Failed to archive recipe:', err); + setError(err instanceof Error ? err.message : 'Failed to archive recipe'); } }; @@ -134,7 +134,10 @@ export default function RecipesView({ onBack }: RecipesViewProps) { ) : (
{savedRecipes.map((savedRecipe) => ( -
+
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 1c73f1672c78..df1565864857 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -1069,7 +1069,12 @@ ipcMain.handle('get-binary-path', (_event, binaryName) => { ipcMain.handle('read-file', (_event, filePath) => { return new Promise((resolve) => { - const cat = spawn('cat', [filePath]); + // Expand tilde to home directory + const expandedPath = filePath.startsWith('~') + ? path.join(app.getPath('home'), filePath.slice(1)) + : filePath; + + const cat = spawn('cat', [expandedPath]); let output = ''; let errorOutput = ''; @@ -1084,26 +1089,31 @@ ipcMain.handle('read-file', (_event, filePath) => { cat.on('close', (code) => { if (code !== 0) { // File not found or error - resolve({ file: '', filePath, error: errorOutput || null, found: false }); + resolve({ file: '', filePath: expandedPath, error: errorOutput || null, found: false }); return; } - resolve({ file: output, filePath, error: null, found: true }); + resolve({ file: output, filePath: expandedPath, error: null, found: true }); }); cat.on('error', (error) => { console.error('Error reading file:', error); - resolve({ file: '', filePath, error, found: false }); + resolve({ file: '', filePath: expandedPath, error, found: false }); }); }); }); ipcMain.handle('write-file', (_event, filePath, content) => { return new Promise((resolve) => { + // Expand tilde to home directory + const expandedPath = filePath.startsWith('~') + ? path.join(app.getPath('home'), filePath.slice(1)) + : filePath; + // Create a write stream to the file // eslint-disable-next-line @typescript-eslint/no-var-requires const fsNode = require('fs'); // Using require for fs in this specific handler from original try { - fsNode.writeFileSync(filePath, content, { encoding: 'utf8' }); + fsNode.writeFileSync(expandedPath, content, { encoding: 'utf8' }); resolve(true); } catch (error) { console.error('Error writing to file:', error); @@ -1112,6 +1122,40 @@ ipcMain.handle('write-file', (_event, filePath, content) => { }); }); +// Enhanced file operations +ipcMain.handle('ensure-directory', async (_event, dirPath) => { + try { + // Expand tilde to home directory + const expandedPath = dirPath.startsWith('~') + ? path.join(app.getPath('home'), dirPath.slice(1)) + : dirPath; + + await fs.mkdir(expandedPath, { recursive: true }); + return true; + } catch (error) { + console.error('Error creating directory:', error); + return false; + } +}); + +ipcMain.handle('list-files', async (_event, dirPath, extension) => { + try { + // Expand tilde to home directory + const expandedPath = dirPath.startsWith('~') + ? path.join(app.getPath('home'), dirPath.slice(1)) + : dirPath; + + const files = await fs.readdir(expandedPath); + if (extension) { + return files.filter((file) => file.endsWith(extension)); + } + return files; + } catch (error) { + console.error('Error listing files:', error); + return []; + } +}); + // Handle message box dialogs ipcMain.handle('show-message-box', async (_event, options) => { const result = await dialog.showMessageBox(options); diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 43617a06e0e6..89276409c8c2 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -71,6 +71,8 @@ type ElectronAPI = { getBinaryPath: (binaryName: string) => Promise; readFile: (directory: string) => Promise; writeFile: (directory: string, content: string) => Promise; + ensureDirectory: (dirPath: string) => Promise; + listFiles: (dirPath: string, extension?: string) => Promise; getAllowedExtensions: () => Promise; getPathForFile: (file: File) => string; setMenuBarIcon: (show: boolean) => Promise; @@ -143,6 +145,9 @@ const electronAPI: ElectronAPI = { readFile: (filePath: string) => ipcRenderer.invoke('read-file', filePath), writeFile: (filePath: string, content: string) => ipcRenderer.invoke('write-file', filePath, content), + ensureDirectory: (dirPath: string) => ipcRenderer.invoke('ensure-directory', dirPath), + listFiles: (dirPath: string, extension?: string) => + ipcRenderer.invoke('list-files', dirPath, extension), getPathForFile: (file: File) => webUtils.getPathForFile(file), getAllowedExtensions: () => ipcRenderer.invoke('get-allowed-extensions'), setMenuBarIcon: (show: boolean) => ipcRenderer.invoke('set-menu-bar-icon', show), diff --git a/ui/desktop/src/recipe/index.ts b/ui/desktop/src/recipe/index.ts index 305a03e6872f..aa185c611aa1 100644 --- a/ui/desktop/src/recipe/index.ts +++ b/ui/desktop/src/recipe/index.ts @@ -2,7 +2,6 @@ import { Message } from '../types/message'; import { getApiUrl } from '../config'; import { FullExtensionConfig } from '../extensions'; -// Should Recipe have a name and id? export interface Recipe { title: string; description: string; diff --git a/ui/desktop/src/recipe/recipeStorage.ts b/ui/desktop/src/recipe/recipeStorage.ts index 186e32ddbf0b..0d92c6808982 100644 --- a/ui/desktop/src/recipe/recipeStorage.ts +++ b/ui/desktop/src/recipe/recipeStorage.ts @@ -1,4 +1,5 @@ import { Recipe } from './index'; +import * as yaml from 'yaml'; export interface SaveRecipeOptions { name: string; @@ -7,26 +8,93 @@ export interface SaveRecipeOptions { export interface SavedRecipe { name: string; - path: string; recipe: Recipe; isGlobal: boolean; lastModified: Date; + isArchived?: boolean; } /** - * Save a recipe to local storage. - * - * IMPORTANT: This is a temporary implementation using localStorage. - * In a production system, this should save actual YAML files to the filesystem. - * The returned path is a mock path for UI purposes only. - * - * TODO: Integrate with the Rust backend recipe system (or local filesystem) to save actual files. + * Sanitize a recipe name to be safe for use as a filename + */ +function sanitizeRecipeName(name: string): string { + return name.replace(/[^a-zA-Z0-9-_\s]/g, '').trim(); +} + +/** + * Parse a lastModified value that could be a string or Date + */ +function parseLastModified(val: string | Date): Date { + return val instanceof Date ? val : new Date(val); +} + +/** + * Get the storage directory path for recipes + */ +function getStorageDirectory(isGlobal: boolean): string { + return isGlobal ? '~/.config/goose/recipes' : '.goose/recipes'; +} + +/** + * Get the file path for a recipe based on its name + */ +function getRecipeFilePath(recipeName: string, isGlobal: boolean): string { + const dir = getStorageDirectory(isGlobal); + return `${dir}/${recipeName}.yaml`; +} + +/** + * Load recipe from file + */ +async function loadRecipeFromFile( + recipeName: string, + isGlobal: boolean +): Promise { + const filePath = getRecipeFilePath(recipeName, isGlobal); + + try { + const result = await window.electron.readFile(filePath); + if (!result.found || result.error) { + return null; + } + + const recipeData = yaml.parse(result.file) as SavedRecipe; + + // Convert lastModified string to Date if needed + recipeData.lastModified = parseLastModified(recipeData.lastModified); + + return { + ...recipeData, + isGlobal: isGlobal, + }; + } catch (error) { + console.warn(`Failed to load recipe from ${filePath}:`, error); + return null; + } +} + +/** + * Save recipe to file + */ +async function saveRecipeToFile(recipe: SavedRecipe): Promise { + const filePath = getRecipeFilePath(recipe.name, recipe.isGlobal); + + // Ensure directory exists + const dirPath = getStorageDirectory(recipe.isGlobal); + await window.electron.ensureDirectory(dirPath); + + // Convert to YAML and save + const yamlContent = yaml.stringify(recipe); + return await window.electron.writeFile(filePath, yamlContent); +} +/** + * Save a recipe to a file using IPC. */ export async function saveRecipe(recipe: Recipe, options: SaveRecipeOptions): Promise { const { name, global = true } = options; - // Sanitize filename - const sanitizedName = name.replace(/[^a-zA-Z0-9-_\s]/g, '').trim(); + // Sanitize name + const sanitizedName = sanitizeRecipeName(name); if (!sanitizedName) { throw new Error('Invalid recipe name'); } @@ -36,24 +104,25 @@ export async function saveRecipe(recipe: Recipe, options: SaveRecipeOptions): Pr throw new Error('Recipe is missing required fields (title, description, instructions)'); } - const recipeData = { - name: sanitizedName, - recipe: recipe, - savedAt: new Date().toISOString(), - isGlobal: global, - }; - try { - // Store in localStorage with a unique key - const storageKey = `goose_saved_recipe_${sanitizedName}_${global ? 'global' : 'local'}`; - localStorage.setItem(storageKey, JSON.stringify(recipeData)); + // Create saved recipe object + const savedRecipe: SavedRecipe = { + name: sanitizedName, + recipe: recipe, + isGlobal: global, + lastModified: new Date(), + isArchived: false, + }; + + // Save to file + const success = await saveRecipeToFile(savedRecipe); - // Return a mock path that represents where the file would be saved - const mockPath = global - ? `~/.config/goose/recipes/${sanitizedName}.yaml` - : `.goose/recipes/${sanitizedName}.yaml`; + if (!success) { + throw new Error('Failed to save recipe file'); + } - return mockPath; + // Return identifier for the saved recipe + return `${global ? 'global' : 'local'}:${sanitizedName}`; } catch (error) { throw new Error( `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}` @@ -62,62 +131,179 @@ export async function saveRecipe(recipe: Recipe, options: SaveRecipeOptions): Pr } /** - * Load a recipe from storage (not yet implemented for file system) + * Load a recipe by name from file. */ -export async function loadRecipe(_filePath: string): Promise { - // TODO: Implement file system loading when integrated with Rust backend - throw new Error('Recipe loading from files not yet implemented'); +export async function loadRecipe(recipeName: string, isGlobal: boolean): Promise { + try { + const savedRecipe = await loadRecipeFromFile(recipeName, isGlobal); + + if (!savedRecipe) { + throw new Error('Recipe not found'); + } + + // Validate the loaded recipe has required fields + if ( + !savedRecipe.recipe.title || + !savedRecipe.recipe.description || + !savedRecipe.recipe.instructions + ) { + throw new Error('Loaded recipe is missing required fields'); + } + + return savedRecipe.recipe; + } catch (error) { + throw new Error( + `Failed to load recipe: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } } /** - * List all saved recipes from localStorage. + * List all saved recipes from the recipes directories. * - * IMPORTANT: This reads from localStorage, not actual files. - * TODO: Integrate with filesystem when Rust backend is available. + * Uses the listFiles API to find available recipe files. */ -export async function listSavedRecipes(): Promise { +export async function listSavedRecipes(includeArchived: boolean = false): Promise { const recipes: SavedRecipe[] = []; try { - // Get all saved recipes from localStorage - const keys = Object.keys(localStorage).filter((key) => key.startsWith('goose_saved_recipe_')); - - for (const key of keys) { - try { - const data = JSON.parse(localStorage.getItem(key) || '{}'); - if (data.recipe && data.name && data.savedAt) { - recipes.push({ - name: data.name, - path: key, // Use the localStorage key as identifier - recipe: data.recipe, - isGlobal: data.isGlobal, - lastModified: new Date(data.savedAt), - }); - } - } catch (error) { - console.warn(`Failed to parse saved recipe ${key}:`, error); - // Continue processing other recipes instead of failing completely + // Check for global and local recipe directories + const globalDir = getStorageDirectory(true); + const localDir = getStorageDirectory(false); + + // Ensure directories exist + await window.electron.ensureDirectory(globalDir); + await window.electron.ensureDirectory(localDir); + + // Get list of recipe files with .yaml extension + const globalFiles = await window.electron.listFiles(globalDir, 'yaml'); + const localFiles = await window.electron.listFiles(localDir, 'yaml'); + + // Process global recipes + for (const file of globalFiles) { + const recipeName = file.replace(/\.yaml$/, ''); + const recipe = await loadRecipeFromFile(recipeName, true); + if (recipe && (includeArchived || !recipe.isArchived)) { + recipes.push(recipe); + } + } + + // Process local recipes + for (const file of localFiles) { + const recipeName = file.replace(/\.yaml$/, ''); + const recipe = await loadRecipeFromFile(recipeName, false); + if (recipe && (includeArchived || !recipe.isArchived)) { + recipes.push(recipe); } } + + // Sort by last modified (newest first) + return recipes.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); } catch (error) { - console.warn('Failed to load saved recipes:', error); + console.warn('Failed to list saved recipes:', error); + return []; } +} + +/** + * Restore an archived recipe. + * + * @param recipeName The name of the recipe to restore + * @param isGlobal Whether the recipe is in global or local storage + */ +export async function restoreRecipe(recipeName: string, isGlobal: boolean): Promise { + try { + const savedRecipe = await loadRecipeFromFile(recipeName, isGlobal); + + if (!savedRecipe) { + throw new Error('Archived recipe not found'); + } + + if (!savedRecipe.isArchived) { + throw new Error('Recipe is not archived'); + } + + // Mark as not archived + savedRecipe.isArchived = false; + savedRecipe.lastModified = new Date(); + + // Save back to file + const success = await saveRecipeToFile(savedRecipe); + + if (!success) { + throw new Error('Failed to save updated recipe'); + } + } catch (error) { + throw new Error( + `Failed to restore recipe: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Archive a recipe. + * + * @param recipeName The name of the recipe to archive + * @param isGlobal Whether the recipe is in global or local storage + */ +export async function archiveRecipe(recipeName: string, isGlobal: boolean): Promise { + try { + const savedRecipe = await loadRecipeFromFile(recipeName, isGlobal); - // Sort by last modified (newest first) - return recipes.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + if (!savedRecipe) { + throw new Error('Recipe not found'); + } + + if (savedRecipe.isArchived) { + throw new Error('Recipe is already archived'); + } + + // Mark as archived + savedRecipe.isArchived = true; + savedRecipe.lastModified = new Date(); + + // Save back to file + const success = await saveRecipeToFile(savedRecipe); + + if (!success) { + throw new Error('Failed to save updated recipe'); + } + } catch (error) { + throw new Error( + `Failed to archive recipe: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } } /** - * Delete a saved recipe from localStorage. + * Permanently delete a recipe file. * - * @param storageKey The localStorage key (not a file path) + * @param recipeName The name of the recipe to permanently delete + * @param isGlobal Whether the recipe is in global or local storage */ -export async function deleteRecipe(storageKey: string): Promise { +export async function permanentlyDeleteRecipe( + recipeName: string, + isGlobal: boolean +): Promise { try { - if (!storageKey.startsWith('goose_saved_recipe_')) { - throw new Error('Invalid storage key'); + // TODO: Implement file deletion when available in the API + // For now, we'll just mark it as archived as a fallback + const savedRecipe = await loadRecipeFromFile(recipeName, isGlobal); + + if (!savedRecipe) { + throw new Error('Recipe not found'); + } + + // Mark as archived with special flag + savedRecipe.isArchived = true; + savedRecipe.lastModified = new Date(); + + // Save back to file + const success = await saveRecipeToFile(savedRecipe); + + if (!success) { + throw new Error('Failed to mark recipe as deleted'); } - localStorage.removeItem(storageKey); } catch (error) { throw new Error( `Failed to delete recipe: ${error instanceof Error ? error.message : 'Unknown error'}` @@ -125,6 +311,17 @@ export async function deleteRecipe(storageKey: string): Promise { } } +/** + * Delete a recipe (archives it by default for backward compatibility). + * + * @deprecated Use archiveRecipe instead + * @param recipeName The name of the recipe to delete/archive + * @param isGlobal Whether the recipe is in global or local storage + */ +export async function deleteRecipe(recipeName: string, isGlobal: boolean): Promise { + return archiveRecipe(recipeName, isGlobal); +} + /** * Generate a suggested filename for a recipe based on its title. * From 58d89601834c7342843aac9a008f86fe266306b1 Mon Sep 17 00:00:00 2001 From: AaronGoldsmith Date: Mon, 16 Jun 2025 08:07:47 -0700 Subject: [PATCH 3/4] refactor: update button labels and subtitles for clarity in RecipesView and MoreMenu --- ui/desktop/src/components/RecipesView.tsx | 6 +++--- ui/desktop/src/components/more_menu/MoreMenu.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/desktop/src/components/RecipesView.tsx b/ui/desktop/src/components/RecipesView.tsx index 4b0c5470b659..9c2e110f6779 100644 --- a/ui/desktop/src/components/RecipesView.tsx +++ b/ui/desktop/src/components/RecipesView.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { listSavedRecipes, archiveRecipe, SavedRecipe } from '../recipe/recipeStorage'; -import { FileText, Trash2, Download, Calendar, Globe, Folder } from 'lucide-react'; +import { FileText, Trash2, Bot, Calendar, Globe, Folder } from 'lucide-react'; import { ScrollArea } from './ui/scroll-area'; import BackButton from './ui/BackButton'; import MoreMenuLayout from './more_menu/MoreMenuLayout'; @@ -163,8 +163,8 @@ export default function RecipesView({ onBack }: RecipesViewProps) { onClick={() => handleLoadRecipe(savedRecipe)} className="flex items-center gap-2 px-4 py-2 bg-black dark:bg-white text-white dark:text-black rounded-lg hover:bg-opacity-90 transition-colors text-sm font-medium" > - - Load Recipe + + Use Recipe