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

+ +
{ + e.preventDefault(); + e.stopPropagation(); + createRecipeForm.handleSubmit(); + }} + > +
+ + {(field) => ( +
+ + { + 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 && ( +

+ {typeof field.state.meta.errors[0] === 'string' + ? field.state.meta.errors[0] + : field.state.meta.errors[0]?.message || String(field.state.meta.errors[0])} +

+ )} +
+ )} +
+ + + {(field) => ( +
+ + 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 && ( +

+ {typeof field.state.meta.errors[0] === 'string' + ? field.state.meta.errors[0] + : field.state.meta.errors[0]?.message || String(field.state.meta.errors[0])} +

+ )} +
+ )} +
+ + + {(field) => ( +
+ +