Convert recipe create and import forms to use tanstack form and zod schema validation (#4499)
This commit is contained in:
Generated
+81
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[700px] max-w-[90vw] max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-medium text-text-standard mb-4">Create New Recipe</h3>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
createRecipeForm.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<createRecipeForm.Field name="title">
|
||||
{(field) => (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="create-title"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Title <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="create-title"
|
||||
type="text"
|
||||
value={field.state.value}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
field.handleChange(value);
|
||||
// Auto-generate recipe name when title changes
|
||||
if (value.trim()) {
|
||||
const suggestedName = generateRecipeNameFromTitle(value);
|
||||
createRecipeForm.setFieldValue('recipeName', suggestedName);
|
||||
}
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
|
||||
}`}
|
||||
placeholder="Recipe title"
|
||||
autoFocus
|
||||
/>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{typeof field.state.meta.errors[0] === 'string'
|
||||
? field.state.meta.errors[0]
|
||||
: field.state.meta.errors[0]?.message || String(field.state.meta.errors[0])}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
|
||||
<createRecipeForm.Field name="description">
|
||||
{(field) => (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="create-description"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Description <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="create-description"
|
||||
type="text"
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
|
||||
}`}
|
||||
placeholder="Brief description of what this recipe does"
|
||||
/>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{typeof field.state.meta.errors[0] === 'string'
|
||||
? field.state.meta.errors[0]
|
||||
: field.state.meta.errors[0]?.message || String(field.state.meta.errors[0])}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
|
||||
<createRecipeForm.Field name="instructions">
|
||||
{(field) => (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="create-instructions"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Instructions <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="create-instructions"
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none font-mono text-sm ${
|
||||
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
|
||||
}`}
|
||||
placeholder="Detailed instructions for the AI agent..."
|
||||
rows={8}
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Use {`{{parameter_name}}`} to define parameters that users can fill in
|
||||
</p>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{typeof field.state.meta.errors[0] === 'string'
|
||||
? field.state.meta.errors[0]
|
||||
: field.state.meta.errors[0]?.message || String(field.state.meta.errors[0])}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
|
||||
<createRecipeForm.Field name="prompt">
|
||||
{(field) => (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="create-prompt"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Initial Prompt (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="create-prompt"
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
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 resize-none"
|
||||
placeholder="First message to send when the recipe starts..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
|
||||
<createRecipeForm.Field name="activities">
|
||||
{(field) => (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="create-activities"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Activities (Optional)
|
||||
</label>
|
||||
<input
|
||||
id="create-activities"
|
||||
type="text"
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
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)"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Comma-separated list of activities this recipe helps with
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
|
||||
<createRecipeForm.Field name="recipeName">
|
||||
{(field) => (
|
||||
<RecipeNameField
|
||||
id="create-recipe-name"
|
||||
value={field.state.value}
|
||||
onChange={field.handleChange}
|
||||
onBlur={field.handleBlur}
|
||||
errors={field.state.meta.errors.map((error) =>
|
||||
typeof error === 'string' ? error : error?.message || String(error)
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
|
||||
<createRecipeForm.Field name="global">
|
||||
{(field) => (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-standard mb-2">
|
||||
Save Location
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="create-save-location"
|
||||
checked={field.state.value === true}
|
||||
onChange={() => field.handleChange(true)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-text-standard">
|
||||
Global - Available across all Goose sessions
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="create-save-location"
|
||||
checked={field.state.value === false}
|
||||
onChange={() => field.handleChange(false)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-text-standard">
|
||||
Directory - Available in the working directory
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<Button type="button" onClick={handleClose} variant="ghost" disabled={creating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<createRecipeForm.Subscribe
|
||||
selector={(state) => [state.canSubmit, state.isSubmitting, state.isValid]}
|
||||
>
|
||||
{([canSubmit, isSubmitting, isValid]) => {
|
||||
// Debug logging to see what's happening
|
||||
console.log('Form state:', { canSubmit, isSubmitting, isValid });
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit || creating || isSubmitting}
|
||||
variant="default"
|
||||
>
|
||||
{creating || isSubmitting ? 'Creating...' : 'Create Recipe'}
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
</createRecipeForm.Subscribe>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Export the button component for easy access
|
||||
export function CreateRecipeButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<Button onClick={onClick} variant="outline" size="sm" className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
Create Recipe
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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<Recipe | null> => {
|
||||
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 (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[500px] max-w-[90vw]">
|
||||
<h3 className="text-lg font-medium text-text-standard mb-4">Import Recipe</h3>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
importRecipeForm.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<importRecipeForm.Field name="deeplink">
|
||||
{(field) => (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="import-deeplink"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Recipe Deeplink <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="import-deeplink"
|
||||
value={field.state.value}
|
||||
onChange={(e) => handleDeeplinkChange(e.target.value, field)}
|
||||
onBlur={field.handleBlur}
|
||||
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none ${
|
||||
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
|
||||
}`}
|
||||
placeholder="Paste your goose://recipe?config=... deeplink here"
|
||||
rows={3}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Paste a recipe deeplink starting with "goose://recipe?config="
|
||||
</p>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{typeof field.state.meta.errors[0] === 'string'
|
||||
? field.state.meta.errors[0]
|
||||
: field.state.meta.errors[0]?.message || String(field.state.meta.errors[0])}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</importRecipeForm.Field>
|
||||
|
||||
<importRecipeForm.Field name="recipeName">
|
||||
{(field) => {
|
||||
// Store reference to the field for programmatic updates
|
||||
recipeNameFieldRef = field;
|
||||
|
||||
return (
|
||||
<RecipeNameField
|
||||
id="import-recipe-name"
|
||||
value={field.state.value}
|
||||
onChange={field.handleChange}
|
||||
onBlur={field.handleBlur}
|
||||
errors={field.state.meta.errors.map((error) =>
|
||||
typeof error === 'string' ? error : error?.message || String(error)
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</importRecipeForm.Field>
|
||||
|
||||
<importRecipeForm.Field name="global">
|
||||
{(field) => (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-standard mb-2">
|
||||
Save Location
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="import-save-location"
|
||||
checked={field.state.value === true}
|
||||
onChange={() => field.handleChange(true)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-text-standard">
|
||||
Global - Available across all Goose sessions
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="import-save-location"
|
||||
checked={field.state.value === false}
|
||||
onChange={() => field.handleChange(false)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-text-standard">
|
||||
Directory - Available in the working directory
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</importRecipeForm.Field>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<Button type="button" onClick={handleClose} variant="ghost" disabled={importing}>
|
||||
Cancel
|
||||
</Button>
|
||||
<importRecipeForm.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}>
|
||||
{([canSubmit, isSubmitting]) => (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit || importing || isSubmitting}
|
||||
variant="default"
|
||||
>
|
||||
{importing || isSubmitting ? 'Importing...' : 'Import Recipe'}
|
||||
</Button>
|
||||
)}
|
||||
</importRecipeForm.Subscribe>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Export the button component for easy access
|
||||
export function ImportRecipeButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<Button onClick={onClick} variant="default" size="sm" className="flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Import Recipe
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
+3
-3
@@ -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();
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export default function RecipeActivityEditor({
|
||||
activities,
|
||||
+12
-12
@@ -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;
|
||||
+1
-1
@@ -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;
|
||||
+2
-2
@@ -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;
|
||||
+29
-544
@@ -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<RecipeManifestResponse[]>([]);
|
||||
@@ -24,44 +21,18 @@ export default function RecipesView() {
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<RecipeManifestResponse | null>(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<string>('');
|
||||
|
||||
// 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<Recipe | null> => {
|
||||
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:
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<h1 className="text-4xl font-light">Recipes</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleCreateClick}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
Create Recipe
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImportClick}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Import Recipe
|
||||
</Button>
|
||||
<CreateRecipeButton onClick={() => setShowCreateDialog(true)} />
|
||||
<ImportRecipeButton onClick={() => setShowImportDialog(true)} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mb-1">
|
||||
@@ -937,290 +694,18 @@ Parameters you can use:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Recipe Dialog */}
|
||||
{showImportDialog && (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[500px] max-w-[90vw]">
|
||||
<h3 className="text-lg font-medium text-text-standard mb-4">Import Recipe</h3>
|
||||
{/* Use the extracted form components */}
|
||||
<ImportRecipeForm
|
||||
isOpen={showImportDialog}
|
||||
onClose={() => setShowImportDialog(false)}
|
||||
onSuccess={loadSavedRecipes}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="import-deeplink"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Recipe Deeplink
|
||||
</label>
|
||||
<textarea
|
||||
id="import-deeplink"
|
||||
value={importDeeplink}
|
||||
onChange={(e) => handleDeeplinkChange(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 resize-none"
|
||||
placeholder="Paste your goose://recipe?config=... deeplink here"
|
||||
rows={3}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-text-muted 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-text-standard 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-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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-standard 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-text-standard">
|
||||
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-text-standard">
|
||||
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('');
|
||||
}}
|
||||
variant="ghost"
|
||||
disabled={importing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImportRecipe}
|
||||
disabled={!importDeeplink.trim() || !importRecipeName.trim() || importing}
|
||||
variant="default"
|
||||
>
|
||||
{importing ? 'Importing...' : 'Import Recipe'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Recipe Dialog */}
|
||||
{showCreateDialog && (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[700px] max-w-[90vw] max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-medium text-text-standard mb-4">Create New Recipe</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="create-title"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Title <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="create-title"
|
||||
type="text"
|
||||
value={createTitle}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="create-description"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Description <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="create-description"
|
||||
type="text"
|
||||
value={createDescription}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="create-instructions"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Instructions <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="create-instructions"
|
||||
value={createInstructions}
|
||||
onChange={(e) => setCreateInstructions(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 resize-none font-mono text-sm"
|
||||
placeholder="Detailed instructions for the AI agent..."
|
||||
rows={8}
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Use {`{{parameter_name}}`} to define parameters that users can fill in
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="create-prompt"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Initial Prompt (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="create-prompt"
|
||||
value={createPrompt}
|
||||
onChange={(e) => setCreatePrompt(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 resize-none"
|
||||
placeholder="First message to send when the recipe starts..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="create-activities"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Activities (Optional)
|
||||
</label>
|
||||
<input
|
||||
id="create-activities"
|
||||
type="text"
|
||||
value={createActivities}
|
||||
onChange={(e) => 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)"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Comma-separated list of activities this recipe helps with
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="create-recipe-name"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Recipe Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="create-recipe-name"
|
||||
type="text"
|
||||
value={createRecipeName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-standard mb-2">
|
||||
Save Location
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="create-save-location"
|
||||
checked={createGlobal}
|
||||
onChange={() => setCreateGlobal(true)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-text-standard">
|
||||
Global - Available across all Goose sessions
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="create-save-location"
|
||||
checked={!createGlobal}
|
||||
onChange={() => setCreateGlobal(false)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-text-standard">
|
||||
Directory - Available in the working directory
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowCreateDialog(false);
|
||||
setCreateTitle('');
|
||||
setCreateDescription('');
|
||||
setCreateInstructions('');
|
||||
setCreatePrompt('');
|
||||
setCreateActivities('');
|
||||
setCreateRecipeName('');
|
||||
}}
|
||||
variant="ghost"
|
||||
disabled={creating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateRecipe}
|
||||
disabled={
|
||||
!createTitle.trim() ||
|
||||
!createDescription.trim() ||
|
||||
!createInstructions.trim() ||
|
||||
!createRecipeName.trim() ||
|
||||
creating
|
||||
}
|
||||
variant="default"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create Recipe'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CreateRecipeForm
|
||||
isOpen={showCreateDialog}
|
||||
onClose={() => setShowCreateDialog(false)}
|
||||
onSuccess={loadSavedRecipes}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+11
-11
@@ -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;
|
||||
@@ -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 (
|
||||
<div>
|
||||
<label htmlFor={id} className="block text-sm font-medium text-text-standard mb-2">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
// 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}
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Will be automatically formatted (lowercase, dashes for spaces)
|
||||
</p>
|
||||
{errors.length > 0 && <p className="text-red-500 text-sm mt-1">{errors[0]}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { recipeNameSchema };
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user