feat: view/edit existing recipe in Desktop (#2670)

This commit is contained in:
Lifei Zhou
2025-05-28 14:19:48 +10:00
committed by GitHub
parent f080a41387
commit 4947591ac8
11 changed files with 461 additions and 324 deletions
+11
View File
@@ -67,6 +67,7 @@
"@hey-api/openapi-ts": "^0.64.4",
"@modelcontextprotocol/sdk": "^1.8.0",
"@playwright/test": "^1.51.1",
"@tailwindcss/line-clamp": "^0.4.4",
"@tailwindcss/typography": "^0.5.15",
"@types/cors": "^2.8.17",
"@types/electron": "^1.4.38",
@@ -4585,6 +4586,16 @@
"node": ">=10"
}
},
"node_modules/@tailwindcss/line-clamp": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz",
"integrity": "sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
+1
View File
@@ -47,6 +47,7 @@
"@hey-api/openapi-ts": "^0.64.4",
"@modelcontextprotocol/sdk": "^1.8.0",
"@playwright/test": "^1.51.1",
"@tailwindcss/line-clamp": "^0.4.4",
"@tailwindcss/typography": "^0.5.15",
"@types/cors": "^2.8.17",
"@types/electron": "^1.4.38",
-16
View File
@@ -527,23 +527,7 @@ export default function App() {
)}
{view === 'recipeEditor' && (
<RecipeEditor
key={viewOptions?.config ? 'with-config' : 'no-config'}
config={viewOptions?.config || window.electron.getConfig().recipeConfig}
onClose={() => setView('chat')}
setView={setView}
onSave={(config) => {
console.log('Saving recipe config:', config);
window.electron.createChatWindow(
undefined,
undefined,
undefined,
undefined,
config,
'recipeEditor',
{ config }
);
setView('chat');
}}
/>
)}
{view === 'permission' && (
@@ -0,0 +1,66 @@
import { useState } from 'react';
export default function RecipeActivityEditor({
activities,
setActivities,
}: {
activities: string[];
setActivities: (prev: string[]) => void;
}) {
const [newActivity, setNewActivity] = useState('');
const handleAddActivity = () => {
if (newActivity.trim()) {
setActivities([...activities, newActivity.trim()]);
setNewActivity('');
}
};
const handleRemoveActivity = (activity: string) => {
setActivities(activities.filter((a) => a !== activity));
};
return (
<div>
<label htmlFor="activities" className="block text-md text-textProminent mb-2 font-bold">
Activities
</label>
<p className="text-textSubtle space-y-2 pb-2">
The top-line prompts and activities that will display within your goose home page.
</p>
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
{activities.map((activity, index) => (
<div
key={index}
className="inline-flex items-center bg-bgApp border-2 border-borderSubtle rounded-full px-4 py-2 text-sm text-textStandard"
title={activity.length > 100 ? activity : undefined}
>
<span>{activity.length > 100 ? activity.slice(0, 100) + '...' : activity}</span>
<button
onClick={() => handleRemoveActivity(activity)}
className="ml-2 text-textStandard hover:text-textSubtle transition-colors"
>
×
</button>
</div>
))}
</div>
<div className="flex gap-3 mt-6">
<input
type="text"
value={newActivity}
onChange={(e) => setNewActivity(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleAddActivity()}
className="flex-1 px-4 py-3 border rounded-lg bg-bgApp text-textStandard placeholder-textPlaceholder focus:outline-none focus:ring-2 focus:ring-borderProminent"
placeholder="Add new activity..."
/>
<button
onClick={handleAddActivity}
className="px-5 py-1.5 text-sm bg-bgAppInverse text-textProminentInverse rounded-xl hover:bg-bgStandardInverse transition-colors"
>
Add activity
</button>
</div>
</div>
</div>
);
}
+188 -285
View File
@@ -1,15 +1,15 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { Recipe } from '../recipe';
import { Buffer } from 'buffer';
import { FullExtensionConfig } from '../extensions';
import { ChevronRight } from './icons/ChevronRight';
import Back from './icons/Back';
import { Bars } from './icons/Bars';
import { Geese } from './icons/Geese';
import Copy from './icons/Copy';
import { Check } from 'lucide-react';
import { useConfig } from './ConfigContext';
import { FixedExtensionEntry } from './ConfigContext';
import RecipeActivityEditor from './RecipeActivityEditor';
import RecipeInfoModal from './RecipeInfoModal';
import RecipeExpandableInfo from './RecipeExpandableInfo';
// import ExtensionList from './settings_v2/extensions/subcomponents/ExtensionList';
interface RecipeEditorProps {
@@ -28,10 +28,17 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
const [title, setTitle] = useState(config?.title || '');
const [description, setDescription] = useState(config?.description || '');
const [instructions, setInstructions] = useState(config?.instructions || '');
const [prompt, setPrompt] = useState(config?.prompt || '');
const [activities, setActivities] = useState<string[]>(config?.activities || []);
const [extensionOptions, setExtensionOptions] = useState<FixedExtensionEntry[]>([]);
const [extensionsLoaded, setExtensionsLoaded] = useState(false);
const [copied, setCopied] = useState(false);
const [isRecipeInfoModalOpen, setRecipeInfoModalOpen] = useState(false);
const [recipeInfoModelProps, setRecipeInfoModelProps] = useState<{
label: string;
value: string;
setValue: (value: string) => void;
} | null>(null);
// Initialize selected extensions for the recipe from config or localStorage
const [recipeExtensions] = useState<string[]>(() => {
@@ -50,12 +57,10 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
const exts = [];
return exts;
});
const [newActivity, setNewActivity] = useState('');
// Section visibility state
const [activeSection, setActiveSection] = useState<
'none' | 'activities' | 'instructions' | 'extensions'
>('none');
const [activeSection, _] = useState<'none' | 'activities' | 'instructions' | 'extensions'>(
'none'
);
// Load extensions when component mounts and when switching to extensions section
useEffect(() => {
@@ -95,32 +100,6 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recipeExtensions, extensionsLoaded]);
// const handleExtensionToggle = (extension: FixedExtensionEntry) => {
// console.log('Toggling extension:', extension.name);
// setRecipeExtensions((prev) => {
// const isSelected = prev.includes(extension.name);
// const newState = isSelected
// ? prev.filter((extName) => extName !== extension.name)
// : [...prev, extension.name];
// // Persist to localStorage
// localStorage.setItem('recipe_editor_extensions', JSON.stringify(newState));
// return newState;
// });
// };
const handleAddActivity = () => {
if (newActivity.trim()) {
setActivities((prev) => [...prev, newActivity.trim()]);
setNewActivity('');
}
};
const handleRemoveActivity = (activity: string) => {
setActivities((prev) => prev.filter((a) => a !== activity));
};
const getCurrentConfig = (): Recipe => {
console.log('Creating config with:', {
selectedExtensions: recipeExtensions,
@@ -134,6 +113,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
description,
instructions,
activities,
prompt,
extensions: recipeExtensions
.map((name) => {
const extension = extensionOptions.find((e) => e.name === name);
@@ -154,16 +134,27 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
return config;
};
const [errors, setErrors] = useState<{ title?: string; description?: string }>({});
const [errors, setErrors] = useState<{
title?: string;
description?: string;
instructions?: string;
}>({});
const requiredFieldsAreFilled = () => {
return title.trim() && description.trim() && instructions.trim();
};
const validateForm = () => {
const newErrors: { title?: string; description?: string } = {};
const newErrors: { title?: string; description?: string; instructions?: string } = {};
if (!title.trim()) {
newErrors.title = 'Title is required';
}
if (!description.trim()) {
newErrors.description = 'Description is required';
}
if (!instructions.trim()) {
newErrors.instructions = 'Instructions are required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
@@ -182,268 +173,180 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
});
};
const onClickEditTextArea = ({
label,
value,
setValue,
}: {
label: string;
value: string;
setValue: (value: string) => void;
}) => {
setRecipeInfoModalOpen(true);
setRecipeInfoModelProps({
label,
value,
setValue,
});
};
// Reset extensionsLoaded when section changes away from extensions
useEffect(() => {
if (activeSection !== 'extensions') {
setExtensionsLoaded(false);
}
}, [activeSection]);
// Render expanded section content
const renderSectionContent = () => {
switch (activeSection) {
case 'activities':
return (
<div className="p-6 pt-10">
<button onClick={() => setActiveSection('none')} className="mb-6">
<Back className="w-6 h-6 text-iconProminent" />
</button>
<div className="py-2">
<Bars className="w-6 h-6 text-iconSubtle" />
</div>
<div className="mb-8 mt-6">
<h2 className="text-2xl font-medium mb-2 text-textProminent">Activities</h2>
<p className="text-textSubtle">
The top-line prompts and activities that will display within your goose home page.
</p>
</div>
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
{activities.map((activity, index) => (
<div
key={index}
className="inline-flex items-center bg-bgApp border-2 border-borderSubtle rounded-full px-4 py-2 text-sm text-textStandard"
title={activity.length > 100 ? activity : undefined}
>
<span>{activity.length > 100 ? activity.slice(0, 100) + '...' : activity}</span>
<button
onClick={() => handleRemoveActivity(activity)}
className="ml-2 text-textStandard hover:text-textSubtle transition-colors"
>
×
</button>
</div>
))}
</div>
<div className="flex gap-3 mt-6">
<input
type="text"
value={newActivity}
onChange={(e) => setNewActivity(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleAddActivity()}
className="flex-1 px-4 py-3 bg-bgSubtle text-textStandard rounded-xl placeholder-textPlaceholder focus:outline-none focus:ring-2 focus:ring-borderProminent"
placeholder="Add new activity..."
/>
<button
onClick={handleAddActivity}
className="px-5 py-3 bg-bgAppInverse text-textProminentInverse rounded-xl hover:bg-bgStandardInverse transition-colors"
>
Add activity
</button>
</div>
</div>
</div>
);
case 'instructions':
return (
<div className="p-6 pt-10">
<button onClick={() => setActiveSection('none')} className="mb-6">
<Back className="w-6 h-6 text-iconProminent" />
</button>
<div className="py-2">
<Bars className="w-6 h-6 text-iconSubtle" />
</div>
<div className="mb-8 mt-6">
<h2 className="text-2xl font-medium mb-2 text-textProminent">Instructions</h2>
<p className="text-textSubtle">
Hidden instructions that will be passed to the provider to help direct and add
context to your responses.
</p>
</div>
<textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
className="w-full h-96 p-4 bg-bgSubtle text-textStandard rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-borderProminent"
placeholder="Enter instructions..."
/>
</div>
);
// case 'extensions':
// return (
// <div className="p-6 pt-10">
// <button onClick={() => setActiveSection('none')} className="mb-6">
// <Back className="w-6 h-6 text-iconProminent" />
// </button>
// <div className="py-2">
// <Bars className="w-6 h-6 text-iconSubtle" />
// </div>
// <div className="mb-8 mt-6">
// <h2 className="text-2xl font-medium mb-2 text-textProminent">Extensions</h2>
// <p className="text-textSubtle">Select extensions to bundle in the recipe</p>
// </div>
// {extensionsLoaded ? (
// <ExtensionList
// extensions={extensionOptions}
// onToggle={handleExtensionToggle}
// isStatic={true}
// />
// ) : (
// <div className="text-center py-8 text-textSubtle">Loading extensions...</div>
// )}
// </div>
// );
default:
return (
<div className="space-y-2 py-2">
<div>
<h2 className="text-lg font-medium mb-2 text-textProminent">Agent</h2>
<input
type="text"
value={title}
onChange={(e) => {
setTitle(e.target.value);
if (errors.title) {
setErrors({ ...errors, title: undefined });
}
}}
className={`w-full p-3 border rounded-lg bg-bgApp text-textStandard ${
errors.title ? 'border-red-500' : 'border-borderSubtle'
}`}
placeholder="Agent Recipe Name (required)"
/>
{errors.title && <div className="text-red-500 text-sm mt-1">{errors.title}</div>}
</div>
<div>
<input
type="text"
value={description}
onChange={(e) => {
setDescription(e.target.value);
if (errors.description) {
setErrors({ ...errors, description: undefined });
}
}}
className={`w-full p-3 border rounded-lg bg-bgApp text-textStandard ${
errors.description ? 'border-red-500' : 'border-borderSubtle'
}`}
placeholder="Description (required)"
/>
{errors.description && (
<div className="text-red-500 text-sm mt-1">{errors.description}</div>
)}
</div>
{/* Section buttons */}
<button
onClick={() => setActiveSection('activities')}
className="w-full flex items-start justify-between p-4 border border-borderSubtle rounded-lg bg-bgApp hover:bg-bgSubtle"
>
<div className="text-left">
<h3 className="font-medium text-textProminent">Activities</h3>
<p className="text-textSubtle text-sm">
Starting activities present in the home panel on a fresh session
</p>
</div>
<ChevronRight className="w-5 h-5 mt-1 text-iconSubtle" />
</button>
<button
onClick={() => setActiveSection('instructions')}
className="w-full flex items-start justify-between p-4 border border-borderSubtle rounded-lg bg-bgApp hover:bg-bgSubtle"
>
<div className="text-left">
<h3 className="font-medium text-textProminent">Instructions</h3>
<p className="text-textSubtle text-sm">Recipe instructions sent to the model</p>
</div>
<ChevronRight className="w-5 h-5 mt-1 text-iconSubtle" />
</button>
{/* <button
onClick={() => setActiveSection('extensions')}
className="w-full flex items-start justify-between p-4 border border-borderSubtle rounded-lg bg-bgApp hover:bg-bgSubtle"
>
<div className="text-left">
<h3 className="font-medium text-textProminent">Extensions</h3>
<p className="text-textSubtle text-sm">
Extensions to be enabled by default with this recipe
</p>
</div>
<ChevronRight className="w-5 h-5 mt-1 text-iconSubtle" />
</button> */}
{/* Deep Link Display */}
<div className="w-full p-4 bg-bgSubtle rounded-lg">
{!title.trim() || !description.trim() ? (
<div className="text-sm text-textSubtle text-xs text-textSubtle">
Fill in required fields to generate link
</div>
) : (
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-textSubtle text-xs text-textSubtle">
Copy this link to share with friends or paste directly in Chrome to open
</div>
<button
onClick={() => validateForm() && handleCopy()}
className="ml-4 p-2 hover:bg-bgApp rounded-lg transition-colors flex items-center disabled:opacity-50 disabled:hover:bg-transparent"
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4 text-iconSubtle" />
)}
<span className="ml-1 text-sm text-textSubtle">
{copied ? 'Copied!' : 'Copy'}
</span>
</button>
</div>
)}
{title.trim() && description.trim() && (
<div
onClick={() => validateForm() && handleCopy()}
className={`text-sm truncate dark:text-white font-mono ${!title.trim() || !description.trim() ? 'text-textDisabled' : 'text-textStandard'}`}
>
{deeplink}
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex flex-col space-y-2 pt-1">
<button
onClick={() => {
localStorage.removeItem('recipe_editor_extensions');
window.close();
}}
className="w-full p-3 text-textSubtle rounded-lg hover:bg-bgSubtle"
>
Close
</button>
</div>
</div>
);
}
};
const page_title = config?.title ? 'View/edit current recipe' : 'Create an agent recipe';
const subtitle = config?.title
? "You can edit the recipe below to change the agent's behavior in a new session."
: 'Your custom agent recipe can be shared with others. Fill in the sections below to create!';
return (
<div className="flex flex-col w-full h-screen bg-bgApp max-w-3xl mx-auto">
{activeSection === 'none' && (
<div className="flex flex-col items-center mb-6 px-6 pt-10">
<div className="flex flex-col items-center mb-2 px-6 pt-10">
<div className="w-16 h-16 bg-bgApp rounded-full flex items-center justify-center mb-4">
<Geese className="w-12 h-12 text-iconProminent" />
</div>
<h1 className="text-2xl font-medium text-center text-textProminent">
Create an agent recipe
</h1>
<p className="text-textSubtle text-center mt-2 text-sm">
Your custom agent recipe can be shared with others. Fill in the sections below to
create!
</p>
<h1 className="text-2xl font-medium text-center text-textProminent">{page_title}</h1>
<p className="text-textSubtle text-center mt-2 text-sm">{subtitle}</p>
</div>
)}
<div className="flex-1 overflow-y-auto px-6">{renderSectionContent()}</div>
<div className="flex-1 overflow-y-auto px-6">
<div className="flex flex-col">
<h2 className="text-lg font-medium mb-2 text-textProminent">Agent Recipe Details</h2>
</div>
<div className="space-y-2 py-2">
<div className="pb-6 border-b-2 border-borderSubtle">
<label htmlFor="title" className="block text-md text-textProminent mb-2 font-bold">
Title <span className="text-red-500">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => {
setTitle(e.target.value);
if (errors.title) {
setErrors({ ...errors, title: undefined });
}
}}
className={`w-full p-3 border rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent ${
errors.title ? 'border-red-500' : 'border-borderSubtle'
}`}
placeholder="Agent Recipe Title (required)"
/>
{errors.title && <div className="text-red-500 text-sm mt-1">{errors.title}</div>}
</div>
<div className="pt-3 pb-6 border-b-2 border-borderSubtle">
<label
htmlFor="description"
className="block text-md text-textProminent mb-2 font-bold"
>
Description <span className="text-red-500">*</span>
</label>
<input
type="text"
value={description}
onChange={(e) => {
setDescription(e.target.value);
if (errors.description) {
setErrors({ ...errors, description: undefined });
}
}}
className={`w-full p-3 border rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent ${
errors.description ? 'border-red-500' : 'border-borderSubtle'
}`}
placeholder="Description (required)"
/>
{errors.description && (
<div className="text-red-500 text-sm mt-1">{errors.description}</div>
)}
</div>
<div className="pt-3 pb-6 border-b-2 border-borderSubtle">
<RecipeExpandableInfo
infoLabel="Instructions"
infoValue={instructions}
required={true}
onClickEdit={() =>
onClickEditTextArea({
label: 'Instructions',
value: instructions,
setValue: setInstructions,
})
}
/>
{errors.instructions && (
<div className="text-red-500 text-sm mt-1">{errors.instructions}</div>
)}
</div>
<div className="pt-3 pb-6 border-b-2 border-borderSubtle">
<RecipeExpandableInfo
infoLabel="Initial Prompt"
infoValue={prompt}
required={false}
onClickEdit={() =>
onClickEditTextArea({ label: 'Initial Prompt', value: prompt, setValue: setPrompt })
}
/>
</div>
<div className="pt-3 pb-6">
<RecipeActivityEditor activities={activities} setActivities={setActivities} />
</div>
{/* Deep Link Display */}
<div className="w-full p-4 bg-bgSubtle rounded-lg">
{!requiredFieldsAreFilled() ? (
<div className="text-sm text-textSubtle text-xs text-textSubtle">
Fill in required fields to generate link
</div>
) : (
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-textSubtle text-xs text-textSubtle">
Copy this link to share with friends or paste directly in Chrome to open
</div>
<button
onClick={() => validateForm() && handleCopy()}
className="ml-4 p-2 hover:bg-bgApp rounded-lg transition-colors flex items-center disabled:opacity-50 disabled:hover:bg-transparent"
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4 text-iconSubtle" />
)}
<span className="ml-1 text-sm text-textSubtle">
{copied ? 'Copied!' : 'Copy'}
</span>
</button>
</div>
)}
{requiredFieldsAreFilled() && (
<div
onClick={() => validateForm() && handleCopy()}
className={`text-sm truncate dark:text-white font-mono ${!title.trim() || !description.trim() ? 'text-textDisabled' : 'text-textStandard'}`}
>
{deeplink}
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex flex-col space-y-2 pt-1">
<button
onClick={() => {
localStorage.removeItem('recipe_editor_extensions');
window.close();
}}
className="w-full p-3 text-textSubtle rounded-lg hover:bg-bgSubtle"
>
Close
</button>
</div>
</div>
</div>
<RecipeInfoModal
infoLabel={recipeInfoModelProps?.label}
originalValue={recipeInfoModelProps?.value}
isOpen={isRecipeInfoModalOpen}
onClose={() => setRecipeInfoModalOpen(false)}
onSaveValue={recipeInfoModelProps?.setValue}
/>
</div>
);
}
@@ -0,0 +1,95 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
interface RecipeExpandableInfoProps {
infoLabel: string;
infoValue: string;
required?: boolean;
onClickEdit: () => void;
}
export default function RecipeExpandableInfo({
infoValue,
infoLabel,
required = false,
onClickEdit,
}: RecipeExpandableInfoProps) {
const [isValueExpanded, setValueExpanded] = useState(false);
const [isClamped, setIsClamped] = useState(false);
// eslint-disable-next-line no-undef
const contentRef = useRef<HTMLParagraphElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = measureRef.current;
if (el) {
const lineHeight = parseFloat(window.getComputedStyle(el).lineHeight || '0');
const maxHeight = lineHeight * 3;
const actualHeight = el.scrollHeight;
setIsClamped(actualHeight > maxHeight);
}
}, [infoValue]);
return (
<>
<div className="flex justify-between items-center mb-2">
<label className="block text-md text-textProminent font-bold">
{infoLabel} {required && <span className="text-red-500">*</span>}
</label>
</div>
<div className="relative rounded-lg bg-white text-textStandard">
{infoValue && (
<>
<div
ref={measureRef}
className="invisible absolute whitespace-pre-wrap w-full pointer-events-none"
style={{ position: 'absolute', top: '-9999px' }}
>
{infoValue}
</div>
<p
ref={contentRef}
className={`whitespace-pre-wrap transition-all duration-300 ${
!isValueExpanded ? 'line-clamp-3' : ''
}`}
>
{infoValue}
</p>
</>
)}
<div className="mt-4 flex items-center justify-between">
<button
type="button"
onClick={(e) => {
e.preventDefault();
setValueExpanded(true);
onClickEdit();
}}
className="w-36 px-3 py-3 bg-bgAppInverse text-sm text-textProminentInverse rounded-xl hover:bg-bgStandardInverse transition-colors"
>
{infoValue ? 'Edit' : 'Add'} {infoLabel.toLowerCase()}
</button>
{infoValue && isClamped && (
<button
type="button"
onClick={() => setValueExpanded(!isValueExpanded)}
aria-label={isValueExpanded ? 'Collapse content' : 'Expand content'}
title={isValueExpanded ? 'Collapse' : 'Expand'}
className="bg-gray-100 hover:bg-gray-200 p-2 rounded text-black hover:text-blue-800 transition-colors"
>
<ChevronDown
className={`w-6 h-6 transition-transform duration-300 ${
isValueExpanded ? 'rotate-180' : ''
}`}
strokeWidth={2.5}
/>
</button>
)}
</div>
</div>
</>
);
}
@@ -0,0 +1,66 @@
import React, { useEffect, useRef, useState } from 'react';
import { Card } from './ui/card';
import { Button } from './ui/button';
interface RecipeInfoModalProps {
infoLabel?: string;
originalValue?: string;
isOpen: boolean;
onClose: () => void;
onSaveValue?: (val: string) => void;
}
export default function RecipeInfoModal({
infoLabel = '',
isOpen,
onClose,
originalValue = '',
onSaveValue = () => {},
}: RecipeInfoModalProps) {
const [value, setValue] = useState(originalValue);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (isOpen) {
setValue(originalValue);
textareaRef.current?.focus();
}
}, [isOpen, originalValue]);
const onSave = (event: React.FormEvent) => {
onSaveValue(value);
event.preventDefault();
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/20 dark:bg-white/20 backdrop-blur-sm transition-colors animate-[fadein_200ms_ease-in_forwards] z-[1000]">
<Card className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col min-w-[80%] min-h-[80%] bg-bgApp rounded-xl overflow-hidden shadow-lg px-8 pt-[24px] pb-0">
<div className="flex mb-6">
<h2 className="text-xl font-semibold text-textProminent">Edit {infoLabel}</h2>
</div>
<div className="flex flex-col flex-grow overflow-y-auto space-y-8">
<textarea
ref={textareaRef}
className="w-full flex-grow resize-none min-h-[300px] max-h-[calc(100vh-300px)] border border-borderSubtle rounded-lg p-3 text-textStandard focus:outline-none focus:ring-2 focus:border-borderSubtle"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={`Enter ${infoLabel.toLowerCase()}...`}
/>
</div>
<Button
onClick={onSave}
className="w-full h-[60px] rounded-none border-b border-borderSubtle bg-transparent hover:bg-bgSubtle text-textProminent font-medium text-md"
>
Save Changes
</Button>
<Button
onClick={onClose}
variant="ghost"
className="w-full h-[60px] rounded-none hover:bg-bgSubtle text-textSubtle hover:text-textStandard text-md font-regular"
>
Cancel
</Button>
</Card>
</div>
);
}
@@ -158,7 +158,7 @@ export default function MoreMenu({
const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => {
setThemeMode(newTheme);
};
const recipeConfig = window.appConfig.get('recipeConfig');
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@@ -235,31 +235,38 @@ export default function MoreMenu({
Configure .goosehints
</MenuButton>
{/* Make Agent from Chat - disabled if already in a recipe */}
<MenuButton
onClick={() => {
const recipeConfig = window.appConfig.get('recipeConfig');
if (!recipeConfig) {
{recipeConfig ? (
<MenuButton
onClick={() => {
setOpen(false);
window.electron.createChatWindow(
undefined, // query
undefined, // dir
undefined, // version
undefined, // resumeSessionId
recipeConfig, // recipe config
'recipeEditor' // view type
);
}}
subtitle="View the recipe you're using"
icon={<Send className="w-4 h-4" />}
>
View recipe
</MenuButton>
) : (
<MenuButton
onClick={() => {
setOpen(false);
// Signal to ChatView that we want to make an agent from the current chat
window.electron.logInfo('Make Agent button clicked');
window.dispatchEvent(new CustomEvent('make-agent-from-chat'));
}
}}
subtitle="Make a custom agent you can share or reuse with a link"
icon={<Send className="w-4 h-4" />}
className={
window.appConfig.get('recipeConfig') ? 'opacity-50 cursor-not-allowed' : ''
}
>
Make Agent from this session
{window.appConfig.get('recipeConfig') && (
<div className="text-xs text-textSubtle mt-1">
(Not available while using a recipe/botling)
</div>
)}
</MenuButton>
}}
subtitle="Make a custom agent you can share or reuse with a link"
icon={<Send className="w-4 h-4" />}
>
Make Agent from this session
</MenuButton>
)}
<MenuButton
onClick={() => {
setOpen(false);
@@ -0,0 +1,3 @@
export default function ViewRecipe() {
return <div>ViewRecipe</div>;
}
+1
View File
@@ -6,6 +6,7 @@ export interface Recipe {
title: string;
description: string;
instructions: string;
prompt?: string;
activities?: string[];
author?: {
contact?: string;
+1 -1
View File
@@ -2,7 +2,7 @@
export default {
darkMode: ['class'],
content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html'],
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography'), require('@tailwindcss/line-clamp') ],
theme: {
extend: {
fontFamily: {