From 651456022cb0dc37a2cad64b153f77881a9a0ac5 Mon Sep 17 00:00:00 2001
From: Zane <75694352+zanesq@users.noreply.github.com>
Date: Wed, 3 Sep 2025 13:18:58 -0700
Subject: [PATCH] Convert recipe create and import forms to use tanstack form
and zod schema validation (#4499)
---
ui/desktop/package-lock.json | 82 ++-
ui/desktop/package.json | 4 +-
ui/desktop/src/App.tsx | 4 +-
ui/desktop/src/components/BaseChat.tsx | 2 +-
.../components/recipes/CreateRecipeForm.tsx | 408 +++++++++++++
.../components/recipes/ImportRecipeForm.tsx | 304 ++++++++++
.../{ => recipes}/RecipeActivities.tsx | 6 +-
.../{ => recipes}/RecipeActivityEditor.tsx | 2 +-
.../components/{ => recipes}/RecipeEditor.tsx | 24 +-
.../{ => recipes}/RecipeExpandableInfo.tsx | 2 +-
.../{ => recipes}/RecipeInfoModal.tsx | 4 +-
.../components/{ => recipes}/RecipesView.tsx | 573 +-----------------
.../{ => recipes}/ViewRecipeModal.tsx | 22 +-
.../recipes/shared/RecipeNameField.tsx | 66 ++
.../recipes/shared/recipeNameUtils.ts | 38 ++
.../models/bottom_bar/ModelsBottomBar.tsx | 2 +-
16 files changed, 963 insertions(+), 580 deletions(-)
create mode 100644 ui/desktop/src/components/recipes/CreateRecipeForm.tsx
create mode 100644 ui/desktop/src/components/recipes/ImportRecipeForm.tsx
rename ui/desktop/src/components/{ => recipes}/RecipeActivities.tsx (94%)
rename ui/desktop/src/components/{ => recipes}/RecipeActivityEditor.tsx (99%)
rename ui/desktop/src/components/{ => recipes}/RecipeEditor.tsx (97%)
rename ui/desktop/src/components/{ => recipes}/RecipeExpandableInfo.tsx (98%)
rename ui/desktop/src/components/{ => recipes}/RecipeInfoModal.tsx (96%)
rename ui/desktop/src/components/{ => recipes}/RecipesView.tsx (58%)
rename ui/desktop/src/components/{ => recipes}/ViewRecipeModal.tsx (97%)
create mode 100644 ui/desktop/src/components/recipes/shared/RecipeNameField.tsx
create mode 100644 ui/desktop/src/components/recipes/shared/recipeNameUtils.ts
diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json
index bdf4cd23..0bbe04a3 100644
--- a/ui/desktop/package-lock.json
+++ b/ui/desktop/package-lock.json
@@ -23,6 +23,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/themes": "^3.2.1",
+ "@tanstack/react-form": "^1.19.3",
"@types/react-router-dom": "^5.3.3",
"ai": "^5.0.14",
"class-variance-authority": "^0.7.1",
@@ -56,7 +57,8 @@
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.3.6",
"unist-util-visit": "^5.0.0",
- "uuid": "^11.1.0"
+ "uuid": "^11.1.0",
+ "zod": "^3.25.76"
},
"devDependencies": {
"@electron-forge/cli": "^7.8.3",
@@ -5409,6 +5411,72 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
+ "node_modules/@tanstack/form-core": {
+ "version": "1.19.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.19.3.tgz",
+ "integrity": "sha512-VUDKpR24kfKHh43nGtDExZtwpq1MMNmN0mrYbqNOOjOoOUm9yZ0fPxWjPBfJQOdvmyncV2Svg9I5klUPjXqWzA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/store": "^0.7.4"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-form": {
+ "version": "1.19.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.19.3.tgz",
+ "integrity": "sha512-e9m/aAemSIfM9Qesk6xeXiBdXLRhZPnQp4QVp/epcaaA6peBCYS0rBL9fuLKGrL4LRyDIA4jA68XR0Mxq0yO4A==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/form-core": "1.19.3",
+ "@tanstack/react-store": "^0.7.4",
+ "decode-formdata": "^0.9.0",
+ "devalue": "^5.3.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "@tanstack/react-start": "^1.130.10",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@tanstack/react-start": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@tanstack/react-store": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.4.tgz",
+ "integrity": "sha512-DyG1e5Qz/c1cNLt/NdFbCA7K1QGuFXQYT6EfUltYMJoQ4LzBOGnOl5IjuxepNcRtmIKkGpmdMzdFZEkevgU9bQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/store": "0.7.4",
+ "use-sync-external-store": "^1.5.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tanstack/store": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.4.tgz",
+ "integrity": "sha512-F1XqZQici1Aq6WigEfcxJSml92nW+85Om8ElBMokPNg5glCYVOmPkZGIQeieYFxcPiKTfwo0MTOQpUyJtwncrg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -8036,6 +8104,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/decode-formdata": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/decode-formdata/-/decode-formdata-0.9.0.tgz",
+ "integrity": "sha512-q5uwOjR3Um5YD+ZWPOF/1sGHVW9A5rCrRwITQChRXlmPkxDFBqCm4jNTIVdGHNH9OnR+V9MoZVgRhsFb+ARbUw==",
+ "license": "MIT"
+ },
"node_modules/decode-named-character-reference": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
@@ -8253,6 +8327,12 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
+ "node_modules/devalue": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.3.2.tgz",
+ "integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==",
+ "license": "MIT"
+ },
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
diff --git a/ui/desktop/package.json b/ui/desktop/package.json
index 93f45f1e..f9acaa75 100644
--- a/ui/desktop/package.json
+++ b/ui/desktop/package.json
@@ -53,6 +53,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/themes": "^3.2.1",
+ "@tanstack/react-form": "^1.19.3",
"@types/react-router-dom": "^5.3.3",
"ai": "^5.0.14",
"class-variance-authority": "^0.7.1",
@@ -86,7 +87,8 @@
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.3.6",
"unist-util-visit": "^5.0.0",
- "uuid": "^11.1.0"
+ "uuid": "^11.1.0",
+ "zod": "^3.25.76"
},
"devDependencies": {
"@electron-forge/cli": "^7.8.3",
diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx
index 7f7bf1a3..7b1cf2db 100644
--- a/ui/desktop/src/App.tsx
+++ b/ui/desktop/src/App.tsx
@@ -30,8 +30,8 @@ import PermissionSettingsView from './components/settings/permission/PermissionS
import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/ExtensionsView';
import { Recipe } from './recipe';
-import RecipesView from './components/RecipesView';
-import RecipeEditor from './components/RecipeEditor';
+import RecipesView from './components/recipes/RecipesView';
+import RecipeEditor from './components/recipes/RecipeEditor';
import { createNavigationHandler, View, ViewOptions } from './utils/navigationUtils';
import {
AgentState,
diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx
index 7ceea05d..85497bba 100644
--- a/ui/desktop/src/components/BaseChat.tsx
+++ b/ui/desktop/src/components/BaseChat.tsx
@@ -47,7 +47,7 @@ import { SearchView } from './conversation/SearchView';
import { AgentHeader } from './AgentHeader';
import LayingEggLoader from './LayingEggLoader';
import LoadingGoose from './LoadingGoose';
-import RecipeActivities from './RecipeActivities';
+import RecipeActivities from './recipes/RecipeActivities';
import PopularChatTopics from './PopularChatTopics';
import ProgressiveMessageList from './ProgressiveMessageList';
import { View, ViewOptions } from '../utils/navigationUtils';
diff --git a/ui/desktop/src/components/recipes/CreateRecipeForm.tsx b/ui/desktop/src/components/recipes/CreateRecipeForm.tsx
new file mode 100644
index 00000000..d67e82d4
--- /dev/null
+++ b/ui/desktop/src/components/recipes/CreateRecipeForm.tsx
@@ -0,0 +1,408 @@
+import { useState, useEffect } from 'react';
+import { useForm } from '@tanstack/react-form';
+import { z } from 'zod';
+import { FileText } from 'lucide-react';
+import { Button } from '../ui/button';
+import { Recipe } from '../../recipe';
+import { saveRecipe } from '../../recipe/recipeStorage';
+import { toastSuccess, toastError } from '../../toasts';
+import { useEscapeKey } from '../../hooks/useEscapeKey';
+import { RecipeNameField, recipeNameSchema } from './shared/RecipeNameField';
+import { generateRecipeNameFromTitle } from './shared/recipeNameUtils';
+
+interface CreateRecipeFormProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess: () => void;
+}
+
+// Define Zod schema for the entire form
+const createRecipeSchema = z.object({
+ title: z.string().min(3, 'Title must be at least 3 characters'),
+ description: z.string().min(10, 'Description must be at least 10 characters'),
+ instructions: z.string().min(20, 'Instructions must be at least 20 characters'),
+ prompt: z.string(),
+ activities: z.string(),
+ recipeName: recipeNameSchema,
+ global: z.boolean(),
+});
+
+export default function CreateRecipeForm({ isOpen, onClose, onSuccess }: CreateRecipeFormProps) {
+ const [creating, setCreating] = useState(false);
+
+ // Handle Esc key for modal
+ useEscapeKey(isOpen, onClose);
+
+ const createRecipeForm = useForm({
+ defaultValues: {
+ title: '',
+ description: '',
+ instructions: '',
+ prompt: '',
+ activities: '',
+ recipeName: '',
+ global: true,
+ },
+ validators: {
+ onChange: createRecipeSchema,
+ },
+ onSubmit: async ({ value }) => {
+ setCreating(true);
+ try {
+ // Parse activities from comma-separated string
+ const activities = value.activities
+ .split(',')
+ .map((activity) => activity.trim())
+ .filter((activity) => activity.length > 0);
+
+ // Create the recipe object
+ const recipe: Recipe = {
+ title: value.title.trim(),
+ description: value.description.trim(),
+ instructions: value.instructions.trim(),
+ prompt: value.prompt.trim() || undefined,
+ activities: activities.length > 0 ? activities : undefined,
+ };
+
+ await saveRecipe(recipe, {
+ name: value.recipeName.trim(),
+ global: value.global,
+ });
+
+ // Reset dialog state
+ createRecipeForm.reset({
+ title: '',
+ description: '',
+ instructions: '',
+ prompt: '',
+ activities: '',
+ recipeName: '',
+ global: true,
+ });
+ onClose();
+
+ onSuccess();
+
+ toastSuccess({
+ title: value.recipeName.trim(),
+ msg: 'Recipe created successfully',
+ });
+ } catch (error) {
+ console.error('Failed to create recipe:', error);
+
+ toastError({
+ title: 'Create Failed',
+ msg: `Failed to create recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ traceback: error instanceof Error ? error.message : String(error),
+ });
+ } finally {
+ setCreating(false);
+ }
+ },
+ });
+
+ // Set default example values when the modal opens
+ useEffect(() => {
+ if (isOpen) {
+ // Set example values like the original did
+ createRecipeForm.setFieldValue('title', 'Python Development Assistant');
+ createRecipeForm.setFieldValue(
+ 'description',
+ 'A helpful assistant for Python development tasks including coding, debugging, and code review.'
+ );
+ createRecipeForm.setFieldValue(
+ 'instructions',
+ `You are an expert Python developer assistant. Help users with:
+
+1. Writing clean, efficient Python code
+2. Debugging and troubleshooting issues
+3. Code review and optimization suggestions
+4. Best practices and design patterns
+5. Testing and documentation
+
+Always provide clear explanations and working code examples.
+
+Parameters you can use:
+- {{project_type}}: The type of Python project (web, data science, CLI, etc.)
+- {{python_version}}: Target Python version`
+ );
+ createRecipeForm.setFieldValue(
+ 'prompt',
+ 'What Python development task can I help you with today?'
+ );
+ createRecipeForm.setFieldValue('activities', 'coding, debugging, testing, documentation');
+ createRecipeForm.setFieldValue('recipeName', 'python-development-assistant');
+ createRecipeForm.setFieldValue('global', true);
+ }
+ }, [isOpen, createRecipeForm]);
+
+ const handleClose = () => {
+ // Reset form to default values
+ createRecipeForm.reset({
+ title: '',
+ description: '',
+ instructions: '',
+ prompt: '',
+ activities: '',
+ recipeName: '',
+ global: true,
+ });
+ onClose();
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
Create New Recipe
+
+
+
+
+ );
+}
+
+// Export the button component for easy access
+export function CreateRecipeButton({ onClick }: { onClick: () => void }) {
+ return (
+
+ );
+}
diff --git a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx
new file mode 100644
index 00000000..2e0a9a41
--- /dev/null
+++ b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx
@@ -0,0 +1,304 @@
+import { useState } from 'react';
+import { useForm } from '@tanstack/react-form';
+import { z } from 'zod';
+import { Download } from 'lucide-react';
+import { Button } from '../ui/button';
+import { Recipe, decodeRecipe } from '../../recipe';
+import { saveRecipe } from '../../recipe/recipeStorage';
+import { toastSuccess, toastError } from '../../toasts';
+import { useEscapeKey } from '../../hooks/useEscapeKey';
+import { RecipeNameField, recipeNameSchema } from './shared/RecipeNameField';
+import { generateRecipeNameFromTitle } from './shared/recipeNameUtils';
+
+interface ImportRecipeFormProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess: () => void;
+}
+
+// Define Zod schema for the import form
+const importRecipeSchema = z.object({
+ deeplink: z
+ .string()
+ .min(1, 'Deeplink is required')
+ .refine(
+ (value) => value.trim().startsWith('goose://recipe?config='),
+ 'Invalid deeplink format. Expected: goose://recipe?config=...'
+ ),
+ recipeName: recipeNameSchema,
+ global: z.boolean(),
+});
+
+export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportRecipeFormProps) {
+ const [importing, setImporting] = useState(false);
+
+ // Handle Esc key for modal
+ useEscapeKey(isOpen, onClose);
+
+ // Function to parse deeplink and extract recipe
+ const parseDeeplink = async (deeplink: string): Promise => {
+ try {
+ const cleanLink = deeplink.trim();
+
+ if (!cleanLink.startsWith('goose://recipe?config=')) {
+ throw new Error('Invalid deeplink format. Expected: goose://recipe?config=...');
+ }
+
+ const recipeEncoded = cleanLink.replace('goose://recipe?config=', '');
+
+ if (!recipeEncoded) {
+ throw new Error('No recipe configuration found in deeplink');
+ }
+ const recipe = await decodeRecipe(recipeEncoded);
+
+ if (!recipe.title || !recipe.description) {
+ throw new Error('Recipe is missing required fields (title, description)');
+ }
+
+ if (!recipe.instructions && !recipe.prompt) {
+ throw new Error('Recipe must have either instructions or prompt');
+ }
+
+ return recipe;
+ } catch (error) {
+ console.error('Failed to parse deeplink:', error);
+ return null;
+ }
+ };
+
+ const importRecipeForm = useForm({
+ defaultValues: {
+ deeplink: '',
+ recipeName: '',
+ global: true,
+ },
+ validators: {
+ onChange: importRecipeSchema,
+ },
+ onSubmit: async ({ value }) => {
+ setImporting(true);
+ try {
+ const recipe = await parseDeeplink(value.deeplink.trim());
+
+ if (!recipe) {
+ throw new Error('Invalid deeplink or recipe format');
+ }
+
+ await saveRecipe(recipe, {
+ name: value.recipeName.trim(),
+ global: value.global,
+ });
+
+ // Reset dialog state
+ importRecipeForm.reset({
+ deeplink: '',
+ recipeName: '',
+ global: true,
+ });
+ onClose();
+
+ onSuccess();
+
+ toastSuccess({
+ title: value.recipeName.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 handleClose = () => {
+ // Reset form to default values
+ importRecipeForm.reset({
+ deeplink: '',
+ recipeName: '',
+ global: true,
+ });
+ onClose();
+ };
+
+ // Store reference to recipe name field for programmatic updates
+ let recipeNameFieldRef: { handleChange: (value: string) => void } | null = null;
+
+ // Auto-generate recipe name when deeplink changes
+ const handleDeeplinkChange = async (
+ value: string,
+ field: { handleChange: (value: string) => void }
+ ) => {
+ // Use the proper field change handler to trigger validation
+ field.handleChange(value);
+
+ if (value.trim()) {
+ 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);
+ } else {
+ importRecipeForm.setFieldValue('recipeName', suggestedName);
+ }
+ }
+ } 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('');
+ } else {
+ importRecipeForm.setFieldValue('recipeName', '');
+ }
+ }
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
Import Recipe
+
+
+
+
+ );
+}
+
+// Export the button component for easy access
+export function ImportRecipeButton({ onClick }: { onClick: () => void }) {
+ return (
+
+ );
+}
diff --git a/ui/desktop/src/components/RecipeActivities.tsx b/ui/desktop/src/components/recipes/RecipeActivities.tsx
similarity index 94%
rename from ui/desktop/src/components/RecipeActivities.tsx
rename to ui/desktop/src/components/recipes/RecipeActivities.tsx
index 4bf90ce6..22873ba1 100644
--- a/ui/desktop/src/components/RecipeActivities.tsx
+++ b/ui/desktop/src/components/recipes/RecipeActivities.tsx
@@ -1,7 +1,7 @@
-import { Card } from './ui/card';
+import { Card } from '../ui/card';
import { gsap } from 'gsap';
-import GooseLogo from './GooseLogo';
-import MarkdownContent from './MarkdownContent';
+import GooseLogo from '../GooseLogo';
+import MarkdownContent from '../MarkdownContent';
// Register GSAP plugins
gsap.registerPlugin();
diff --git a/ui/desktop/src/components/RecipeActivityEditor.tsx b/ui/desktop/src/components/recipes/RecipeActivityEditor.tsx
similarity index 99%
rename from ui/desktop/src/components/RecipeActivityEditor.tsx
rename to ui/desktop/src/components/recipes/RecipeActivityEditor.tsx
index a5b8f207..4a384592 100644
--- a/ui/desktop/src/components/RecipeActivityEditor.tsx
+++ b/ui/desktop/src/components/recipes/RecipeActivityEditor.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
-import { Button } from './ui/button';
+import { Button } from '../ui/button';
export default function RecipeActivityEditor({
activities,
diff --git a/ui/desktop/src/components/RecipeEditor.tsx b/ui/desktop/src/components/recipes/RecipeEditor.tsx
similarity index 97%
rename from ui/desktop/src/components/RecipeEditor.tsx
rename to ui/desktop/src/components/recipes/RecipeEditor.tsx
index 9f949c94..e944882d 100644
--- a/ui/desktop/src/components/RecipeEditor.tsx
+++ b/ui/desktop/src/components/recipes/RecipeEditor.tsx
@@ -1,22 +1,22 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
-import { Recipe, generateDeepLink } from '../recipe';
-import { Parameter } from '../recipe/index';
+import { Recipe, generateDeepLink } from '../../recipe';
+import { Parameter } from '../../recipe/index';
-import { Geese } from './icons/Geese';
-import Copy from './icons/Copy';
+import { Geese } from '../icons/Geese';
+import Copy from '../icons/Copy';
import { Check, Save, Calendar } from 'lucide-react';
-import { ExtensionConfig, useConfig } from './ConfigContext';
-import { FixedExtensionEntry } from './ConfigContext';
+import { ExtensionConfig, 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 ParameterInput from './parameter/ParameterInput';
-import { saveRecipe, generateRecipeFilename } from '../recipe/recipeStorage';
-import { toastSuccess, toastError } from '../toasts';
-import { Button } from './ui/button';
-import { useEscapeKey } from '../hooks/useEscapeKey';
+import { ScheduleFromRecipeModal } from '../schedule/ScheduleFromRecipeModal';
+import ParameterInput from '../parameter/ParameterInput';
+import { saveRecipe, generateRecipeFilename } from '../../recipe/recipeStorage';
+import { toastSuccess, toastError } from '../../toasts';
+import { Button } from '../ui/button';
+import { useEscapeKey } from '../../hooks/useEscapeKey';
interface RecipeEditorProps {
config?: Recipe;
diff --git a/ui/desktop/src/components/RecipeExpandableInfo.tsx b/ui/desktop/src/components/recipes/RecipeExpandableInfo.tsx
similarity index 98%
rename from ui/desktop/src/components/RecipeExpandableInfo.tsx
rename to ui/desktop/src/components/recipes/RecipeExpandableInfo.tsx
index 14a65b0e..acaed66a 100644
--- a/ui/desktop/src/components/RecipeExpandableInfo.tsx
+++ b/ui/desktop/src/components/recipes/RecipeExpandableInfo.tsx
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
-import { Button } from './ui/button';
+import { Button } from '../ui/button';
interface RecipeExpandableInfoProps {
infoLabel: string;
diff --git a/ui/desktop/src/components/RecipeInfoModal.tsx b/ui/desktop/src/components/recipes/RecipeInfoModal.tsx
similarity index 96%
rename from ui/desktop/src/components/RecipeInfoModal.tsx
rename to ui/desktop/src/components/recipes/RecipeInfoModal.tsx
index 2edd70cc..80043a31 100644
--- a/ui/desktop/src/components/RecipeInfoModal.tsx
+++ b/ui/desktop/src/components/recipes/RecipeInfoModal.tsx
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
-import { Card } from './ui/card';
-import { Button } from './ui/button';
+import { Card } from '../ui/card';
+import { Button } from '../ui/button';
interface RecipeInfoModalProps {
infoLabel?: string;
diff --git a/ui/desktop/src/components/RecipesView.tsx b/ui/desktop/src/components/recipes/RecipesView.tsx
similarity index 58%
rename from ui/desktop/src/components/RecipesView.tsx
rename to ui/desktop/src/components/recipes/RecipesView.tsx
index abf60be7..f2bb4053 100644
--- a/ui/desktop/src/components/RecipesView.tsx
+++ b/ui/desktop/src/components/recipes/RecipesView.tsx
@@ -1,20 +1,17 @@
import { useState, useEffect } from 'react';
-import {
- listSavedRecipes,
- saveRecipe,
- generateRecipeFilename,
- convertToLocaleDateString,
-} from '../recipe/recipeStorage';
-import { FileText, Trash2, Bot, Calendar, AlertCircle, Download } from 'lucide-react';
-import { ScrollArea } from './ui/scroll-area';
-import { Card } from './ui/card';
-import { Button } from './ui/button';
-import { Skeleton } from './ui/skeleton';
-import { MainPanelLayout } from './Layout/MainPanelLayout';
-import { Recipe, decodeRecipe, generateDeepLink } from '../recipe';
-import { toastSuccess, toastError } from '../toasts';
-import { useEscapeKey } from '../hooks/useEscapeKey';
-import { deleteRecipe, RecipeManifestResponse } from '../api';
+import { listSavedRecipes, convertToLocaleDateString } from '../../recipe/recipeStorage';
+import { FileText, Trash2, Bot, Calendar, AlertCircle } from 'lucide-react';
+import { ScrollArea } from '../ui/scroll-area';
+import { Card } from '../ui/card';
+import { Button } from '../ui/button';
+import { Skeleton } from '../ui/skeleton';
+import { MainPanelLayout } from '../Layout/MainPanelLayout';
+import { Recipe, generateDeepLink } from '../../recipe';
+import { toastSuccess, toastError } from '../../toasts';
+import { useEscapeKey } from '../../hooks/useEscapeKey';
+import { deleteRecipe, RecipeManifestResponse } from '../../api';
+import CreateRecipeForm, { CreateRecipeButton } from './CreateRecipeForm';
+import ImportRecipeForm, { ImportRecipeButton } from './ImportRecipeForm';
export default function RecipesView() {
const [savedRecipes, setSavedRecipes] = useState([]);
@@ -24,44 +21,18 @@ export default function RecipesView() {
const [selectedRecipe, setSelectedRecipe] = useState(null);
const [showPreview, setShowPreview] = useState(false);
const [showContent, setShowContent] = 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);
const [previewDeeplink, setPreviewDeeplink] = useState('');
- // Create Recipe state
+ // Form dialog states
const [showCreateDialog, setShowCreateDialog] = useState(false);
- const [createTitle, setCreateTitle] = useState('');
- const [createDescription, setCreateDescription] = useState('');
- const [createInstructions, setCreateInstructions] = useState('');
- const [createPrompt, setCreatePrompt] = useState('');
- const [createActivities, setCreateActivities] = useState('');
- const [createRecipeName, setCreateRecipeName] = useState('');
- const [createGlobal, setCreateGlobal] = useState(true);
- const [creating, setCreating] = useState(false);
+ const [showImportDialog, setShowImportDialog] = useState(false);
useEffect(() => {
loadSavedRecipes();
}, []);
- // Handle Esc key for modals
+ // Handle Esc key for preview modal
useEscapeKey(showPreview, () => setShowPreview(false));
- useEscapeKey(showImportDialog, () => {
- setShowImportDialog(false);
- setImportDeeplink('');
- setImportRecipeName('');
- });
- useEscapeKey(showCreateDialog, () => {
- setShowCreateDialog(false);
- setCreateTitle('');
- setCreateDescription('');
- setCreateInstructions('');
- setCreatePrompt('');
- setCreateActivities('');
- setCreateRecipeName('');
- });
// Minimum loading time to prevent skeleton flash
useEffect(() => {
@@ -163,204 +134,6 @@ export default function RecipesView() {
}
};
- // Function to parse deeplink and extract recipe
- const parseDeeplink = async (deeplink: string): Promise => {
- try {
- const cleanLink = deeplink.trim();
-
- if (!cleanLink.startsWith('goose://recipe?config=')) {
- throw new Error('Invalid deeplink format. Expected: goose://recipe?config=...');
- }
-
- const recipeEncoded = cleanLink.replace('goose://recipe?config=', '');
-
- if (!recipeEncoded) {
- throw new Error('No recipe configuration found in deeplink');
- }
- const recipe = await decodeRecipe(recipeEncoded);
-
- if (!recipe.title || !recipe.description) {
- throw new Error('Recipe is missing required fields (title, description)');
- }
-
- if (!recipe.instructions && !recipe.prompt) {
- throw new Error('Recipe must have either instructions or prompt');
- }
-
- 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 = await 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 = async (value: string) => {
- setImportDeeplink(value);
-
- if (value.trim()) {
- try {
- const recipe = await parseDeeplink(value.trim());
- if (recipe && recipe.title) {
- const suggestedName = generateRecipeFilename(recipe);
- setImportRecipeName(suggestedName);
- }
- } catch (error) {
- // Silently handle parsing errors during auto-suggest
- console.log('Could not parse deeplink for auto-suggest:', error);
- }
- }
- };
-
- // Create Recipe handlers
- const handleCreateClick = () => {
- // Reset form with example values
- setCreateTitle('Python Development Assistant');
- setCreateDescription(
- 'A helpful assistant for Python development tasks including coding, debugging, and code review.'
- );
- setCreateInstructions(`You are an expert Python developer assistant. Help users with:
-
-1. Writing clean, efficient Python code
-2. Debugging and troubleshooting issues
-3. Code review and optimization suggestions
-4. Best practices and design patterns
-5. Testing and documentation
-
-Always provide clear explanations and working code examples.
-
-Parameters you can use:
-- {{project_type}}: The type of Python project (web, data science, CLI, etc.)
-- {{python_version}}: Target Python version`);
- setCreatePrompt('What Python development task can I help you with today?');
- setCreateActivities('coding, debugging, testing, documentation');
- setCreateRecipeName('');
- setCreateGlobal(true);
- setShowCreateDialog(true);
- };
-
- const handleCreateRecipe = async () => {
- if (
- !createTitle.trim() ||
- !createDescription.trim() ||
- !createInstructions.trim() ||
- !createRecipeName.trim()
- ) {
- return;
- }
-
- setCreating(true);
- try {
- // Parse activities from comma-separated string
- const activities = createActivities
- .split(',')
- .map((activity) => activity.trim())
- .filter((activity) => activity.length > 0);
-
- // Create the recipe object
- const recipe: Recipe = {
- title: createTitle.trim(),
- description: createDescription.trim(),
- instructions: createInstructions.trim(),
- prompt: createPrompt.trim() || undefined,
- activities: activities.length > 0 ? activities : undefined,
- };
-
- await saveRecipe(recipe, {
- name: createRecipeName.trim(),
- global: createGlobal,
- });
-
- // Reset dialog state
- setShowCreateDialog(false);
- setCreateTitle('');
- setCreateDescription('');
- setCreateInstructions('');
- setCreatePrompt('');
- setCreateActivities('');
- setCreateRecipeName('');
-
- await loadSavedRecipes();
-
- toastSuccess({
- title: createRecipeName.trim(),
- msg: 'Recipe created successfully',
- });
- } catch (error) {
- console.error('Failed to create recipe:', error);
-
- toastError({
- title: 'Create Failed',
- msg: `Failed to create recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
- traceback: error instanceof Error ? error.message : String(error),
- });
- } finally {
- setCreating(false);
- }
- };
-
- // Auto-generate recipe name when title changes
- const handleCreateTitleChange = (value: string) => {
- setCreateTitle(value);
- if (value.trim() && !createRecipeName.trim()) {
- const suggestedName = value
- .toLowerCase()
- .replace(/[^a-zA-Z0-9\s-]/g, '')
- .replace(/\s+/g, '-')
- .trim();
- setCreateRecipeName(suggestedName);
- }
- };
-
// Render a recipe item
const RecipeItem = ({
recipeManifestResponse,
@@ -498,24 +271,8 @@ Parameters you can use:
Recipes
-
-
+ setShowCreateDialog(true)} />
+ setShowImportDialog(true)} />
@@ -937,290 +694,18 @@ Parameters you can use:
)}
- {/* Import Recipe Dialog */}
- {showImportDialog && (
-
-
-
Import Recipe
+ {/* Use the extracted form components */}
+
setShowImportDialog(false)}
+ onSuccess={loadSavedRecipes}
+ />
-
-
-
-
-
-
-
- setImportRecipeName(e.target.value)}
- className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
- placeholder="Enter a name for the imported recipe"
- />
-
-
-
-
-
-
-
-
-
-
-
- )}
-
- {/* Create Recipe Dialog */}
- {showCreateDialog && (
-
-
-
Create New Recipe
-
-
-
-
- handleCreateTitleChange(e.target.value)}
- className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
- placeholder="Recipe title"
- autoFocus
- />
-
-
-
-
- setCreateDescription(e.target.value)}
- className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
- placeholder="Brief description of what this recipe does"
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
setCreateActivities(e.target.value)}
- className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
- placeholder="coding, debugging, testing, documentation (comma-separated)"
- />
-
- Comma-separated list of activities this recipe helps with
-
-
-
-
-
- setCreateRecipeName(e.target.value)}
- className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
- placeholder="File name for the recipe"
- />
-
-
-
-
-
-
-
-
-
-
-
- )}
+ setShowCreateDialog(false)}
+ onSuccess={loadSavedRecipes}
+ />
>
);
}
diff --git a/ui/desktop/src/components/ViewRecipeModal.tsx b/ui/desktop/src/components/recipes/ViewRecipeModal.tsx
similarity index 97%
rename from ui/desktop/src/components/ViewRecipeModal.tsx
rename to ui/desktop/src/components/recipes/ViewRecipeModal.tsx
index 478eb6a5..1ec74f0d 100644
--- a/ui/desktop/src/components/ViewRecipeModal.tsx
+++ b/ui/desktop/src/components/recipes/ViewRecipeModal.tsx
@@ -1,19 +1,19 @@
import { useState, useEffect, useCallback } from 'react';
-import { Recipe, generateDeepLink } from '../recipe';
-import { Parameter } from '../recipe/index';
-import { Geese } from './icons/Geese';
-import Copy from './icons/Copy';
+import { Recipe, generateDeepLink } from '../../recipe';
+import { Parameter } from '../../recipe/index';
+import { Geese } from '../icons/Geese';
+import Copy from '../icons/Copy';
import { Check, Save, Calendar, X } from 'lucide-react';
-import { ExtensionConfig, useConfig } from './ConfigContext';
-import { FixedExtensionEntry } from './ConfigContext';
+import { ExtensionConfig, 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 ParameterInput from './parameter/ParameterInput';
-import { saveRecipe, generateRecipeFilename } from '../recipe/recipeStorage';
-import { toastSuccess, toastError } from '../toasts';
-import { Button } from './ui/button';
+import { ScheduleFromRecipeModal } from '../schedule/ScheduleFromRecipeModal';
+import ParameterInput from '../parameter/ParameterInput';
+import { saveRecipe, generateRecipeFilename } from '../../recipe/recipeStorage';
+import { toastSuccess, toastError } from '../../toasts';
+import { Button } from '../ui/button';
interface ViewRecipeModalProps {
isOpen: boolean;
diff --git a/ui/desktop/src/components/recipes/shared/RecipeNameField.tsx b/ui/desktop/src/components/recipes/shared/RecipeNameField.tsx
new file mode 100644
index 00000000..a98be30a
--- /dev/null
+++ b/ui/desktop/src/components/recipes/shared/RecipeNameField.tsx
@@ -0,0 +1,66 @@
+import { recipeNameSchema, RECIPE_NAME_PLACEHOLDER } from './recipeNameUtils';
+
+interface RecipeNameFieldProps {
+ id: string;
+ value: string;
+ onChange: (value: string) => void;
+ onBlur: () => void;
+ errors: string[];
+ label?: string;
+ required?: boolean;
+ disabled?: boolean;
+}
+
+export function RecipeNameField({
+ id,
+ value,
+ onChange,
+ onBlur,
+ errors,
+ label = 'Recipe Name',
+ required = true,
+ disabled = false,
+}: RecipeNameFieldProps) {
+ return (
+
+
+
{
+ // Allow typing normally, only filter out invalid characters but keep spaces
+ const rawValue = e.target.value;
+ const filtered = rawValue.replace(/[^a-zA-Z0-9\s-]/g, '');
+ onChange(filtered);
+ }}
+ onBlur={(e) => {
+ // Transform on blur: convert to lowercase and replace spaces with dashes
+ const rawValue = e.target.value;
+ const transformed = rawValue
+ .toLowerCase()
+ .replace(/\s+/g, '-')
+ .replace(/[^a-z0-9-]/g, '')
+ .replace(/-+/g, '-')
+ .replace(/^-+|-+$/g, '');
+
+ onChange(transformed);
+ 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={RECIPE_NAME_PLACEHOLDER}
+ />
+
+ Will be automatically formatted (lowercase, dashes for spaces)
+
+ {errors.length > 0 &&
{errors[0]}
}
+
+ );
+}
+
+export { recipeNameSchema };
diff --git a/ui/desktop/src/components/recipes/shared/recipeNameUtils.ts b/ui/desktop/src/components/recipes/shared/recipeNameUtils.ts
new file mode 100644
index 00000000..5d8045f7
--- /dev/null
+++ b/ui/desktop/src/components/recipes/shared/recipeNameUtils.ts
@@ -0,0 +1,38 @@
+import { z } from 'zod';
+
+/**
+ * Validation schema for recipe names
+ */
+export const recipeNameSchema = z.string().min(3, 'Recipe name must be at least 3 characters');
+
+/**
+ * Transform a string to a valid recipe name format:
+ * - Convert to lowercase
+ * - Replace spaces with dashes
+ * - Remove invalid characters
+ * - Trim whitespace and dashes
+ */
+export function transformToRecipeName(input: string): string {
+ return input
+ .toLowerCase()
+ .replace(/[^a-zA-Z0-9\s-]/g, '') // Remove invalid characters
+ .replace(/\s+/g, '-') // Replace spaces with dashes
+ .replace(/--+/g, '-') // Replace multiple dashes with single dash
+ .replace(/^-+|-+$/g, '') // Remove leading/trailing dashes
+ .trim();
+}
+
+/**
+ * Generate a recipe name from a title
+ */
+export function generateRecipeNameFromTitle(title: string): string {
+ if (!title.trim()) {
+ return '';
+ }
+ return transformToRecipeName(title);
+}
+
+/**
+ * Common placeholder text for recipe name inputs
+ */
+export const RECIPE_NAME_PLACEHOLDER = 'my-awesome-recipe';
diff --git a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx
index 6da275d3..3dd8fc9e 100644
--- a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx
+++ b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx
@@ -19,7 +19,7 @@ import BottomMenuAlertPopover from '../../../bottom_menu/BottomMenuAlertPopover'
import { Recipe } from '../../../../recipe';
import { saveRecipe, generateRecipeFilename } from '../../../../recipe/recipeStorage';
import { toastSuccess, toastError } from '../../../../toasts';
-import ViewRecipeModal from '../../../ViewRecipeModal';
+import ViewRecipeModal from '../../../recipes/ViewRecipeModal';
interface ModelsBottomBarProps {
sessionId: string | null;