From 1dfbd470c286a48c4640be6f15d0906a7c32e978 Mon Sep 17 00:00:00 2001 From: Lily Delalande <119957291+lily-de@users.noreply.github.com> Date: Tue, 15 Apr 2025 18:43:23 -0400 Subject: [PATCH] Mini agent extension config (#2209) Co-authored-by: Zaki Ali --- ui/desktop/src/components/RecipeEditor.tsx | 184 ++++++++++-------- .../components/settings_v2/SettingsView.tsx | 1 - .../extensions/ExtensionsSection.tsx | 122 ++++++++---- .../subcomponents/ExtensionItem.tsx | 18 +- .../subcomponents/ExtensionList.tsx | 13 +- .../modal/ProviderConfiguationModal.tsx | 1 - 6 files changed, 210 insertions(+), 129 deletions(-) diff --git a/ui/desktop/src/components/RecipeEditor.tsx b/ui/desktop/src/components/RecipeEditor.tsx index 70a7a897..44d5777c 100644 --- a/ui/desktop/src/components/RecipeEditor.tsx +++ b/ui/desktop/src/components/RecipeEditor.tsx @@ -7,9 +7,10 @@ import Back from './icons/Back'; import { Bars } from './icons/Bars'; import { Geese } from './icons/Geese'; import Copy from './icons/Copy'; -import Check from './icons/Check'; -import { useConfig } from '../components/ConfigContext'; -import { settingsV2Enabled } from '../flags'; +import { useConfig } from './ConfigContext'; +import { FixedExtensionEntry } from './ConfigContext'; +import ExtensionList from './settings_v2/extensions/subcomponents/ExtensionList'; +import { Check } from 'lucide-react'; interface RecipeEditorProps { config?: Recipe; @@ -28,47 +29,83 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { const [description, setDescription] = useState(config?.description || ''); const [instructions, setInstructions] = useState(config?.instructions || ''); const [activities, setActivities] = useState(config?.activities || []); - const [availableExtensions, setAvailableExtensions] = useState([]); - const [selectedExtensions, setSelectedExtensions] = useState( - config?.extensions?.map((e) => e.id) || [] - ); - const [newActivity, setNewActivity] = useState(''); + const [extensionOptions, setExtensionOptions] = useState([]); const [copied, setCopied] = useState(false); + const [extensionsLoaded, setExtensionsLoaded] = useState(false); + + // Initialize selected extensions for the recipe from config or localStorage + const [recipeExtensions, setRecipeExtensions] = useState(() => { + // First try to get from localStorage + const stored = localStorage.getItem('recipe_editor_extensions'); + if (stored) { + try { + const parsed = JSON.parse(stored); + return Array.isArray(parsed) ? parsed : []; + } catch (e) { + console.error('Failed to parse localStorage recipe extensions:', e); + return []; + } + } + // Fall back to config if available, using extension names + const exts = []; + return exts; + }); + const [newActivity, setNewActivity] = useState(''); // Section visibility state const [activeSection, setActiveSection] = useState< 'none' | 'activities' | 'instructions' | 'extensions' >('none'); - // Load extensions + // Load extensions when component mounts and when switching to extensions section useEffect(() => { - const loadExtensions = async () => { - if (settingsV2Enabled) { + if (activeSection === 'extensions' && !extensionsLoaded) { + const loadExtensions = async () => { try { const extensions = await getExtensions(false); // force refresh to get latest - console.log('extensions {}', extensions); - setAvailableExtensions(extensions || []); + console.log('Loading extensions for recipe editor'); + + if (extensions && extensions.length > 0) { + // Map the extensions with the current selection state from recipeExtensions + const initializedExtensions = extensions.map((ext) => ({ + ...ext, + enabled: recipeExtensions.includes(ext.name), + })); + + setExtensionOptions(initializedExtensions); + setExtensionsLoaded(true); + } } catch (error) { console.error('Failed to load extensions:', error); } - } else { - const userSettingsStr = localStorage.getItem('user_settings'); - if (userSettingsStr) { - const userSettings = JSON.parse(userSettingsStr); - setAvailableExtensions(userSettings.extensions || []); - } - } - }; - loadExtensions(); - // Intentionally omitting getExtensions from deps to avoid refresh loops - // eslint-disable-next-line - }, []); + }; + loadExtensions(); + } + }, [activeSection, getExtensions, recipeExtensions, extensionsLoaded]); + + // Effect for updating extension options when recipeExtensions change + useEffect(() => { + if (extensionsLoaded && extensionOptions.length > 0) { + const updatedOptions = extensionOptions.map((ext) => ({ + ...ext, + enabled: recipeExtensions.includes(ext.name), + })); + setExtensionOptions(updatedOptions); + } + // 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)); - const handleExtensionToggle = (id: string) => { - console.log('Toggling extension:', id); - setSelectedExtensions((prev) => { - const isSelected = prev.includes(id); - const newState = isSelected ? prev.filter((extId) => extId !== id) : [...prev, id]; return newState; }); }; @@ -86,8 +123,8 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { const getCurrentConfig = (): Recipe => { console.log('Creating config with:', { - selectedExtensions, - availableExtensions, + selectedExtensions: recipeExtensions, + availableExtensions: extensionOptions, recipeConfig, }); @@ -97,9 +134,9 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { description, instructions, activities, - extensions: selectedExtensions + extensions: recipeExtensions .map((name) => { - const extension = availableExtensions.find((e) => e.name === name); + const extension = extensionOptions.find((e) => e.name === name); console.log('Looking for extension:', name, 'Found:', extension); if (!extension) return null; @@ -139,6 +176,8 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { const handleOpenAgent = () => { if (validateForm()) { const updatedConfig = getCurrentConfig(); + // Clear stored extensions when submitting + localStorage.removeItem('recipe_editor_extensions'); window.electron.createChatWindow( undefined, undefined, @@ -166,6 +205,13 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { }); }; + // Reset extensionsLoaded when section changes away from extensions + useEffect(() => { + if (activeSection !== 'extensions') { + setExtensionsLoaded(false); + } + }, [activeSection]); + // Render expanded section content const renderSectionContent = () => { switch (activeSection) { @@ -258,49 +304,23 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {

Extensions

-

- Choose which extensions will be available to your agent. -

-
-
- {availableExtensions.map((extension) => ( - - ))} +

Select extensions to bundle in the recipe

+ {extensionsLoaded ? ( + + ) : ( +
Loading extensions...
+ )} ); default: return ( -
+

Agent

{errors.title &&
{errors.title}
}
@@ -348,7 +368,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {

Activities

- Starting activities present in the home panel on a fresh goose session + Starting activities present in the home panel on a fresh session

@@ -360,9 +380,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { >

Instructions

-

- Starting activities present in the home panel on a fresh goose session -

+

Recipe instructions sent to the model

@@ -374,7 +392,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {

Extensions

- Starting activities present in the home panel on a fresh goose session + Extensions to be enabled by default with this recipe

@@ -397,7 +415,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
{/* Action Buttons */} -
+

- Create custom agent + Create an agent recipe

- Your custom agent can be shared with others + Your custom agent recipe can be shared with others. Fill in the sections below to + create!

)} diff --git a/ui/desktop/src/components/settings_v2/SettingsView.tsx b/ui/desktop/src/components/settings_v2/SettingsView.tsx index 37496658..62ba4f6e 100644 --- a/ui/desktop/src/components/settings_v2/SettingsView.tsx +++ b/ui/desktop/src/components/settings_v2/SettingsView.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { ScrollArea } from '../ui/scroll-area'; import BackButton from '../ui/BackButton'; import type { View } from '../../App'; diff --git a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx index c8083e4d..c3b38e4e 100644 --- a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx @@ -19,9 +19,22 @@ import { ExtensionConfig } from '../../../api/types.gen'; interface ExtensionSectionProps { deepLinkConfig?: ExtensionConfig; showEnvVars?: boolean; + hideButtons?: boolean; + hideHeader?: boolean; + disableConfiguration?: boolean; + customToggle?: (extension: FixedExtensionEntry) => Promise; + selectedExtensions?: string[]; // Add controlled state } -export default function ExtensionsSection({ deepLinkConfig, showEnvVars }: ExtensionSectionProps) { +export default function ExtensionsSection({ + deepLinkConfig, + showEnvVars, + hideButtons, + hideHeader, + disableConfiguration, + customToggle, + selectedExtensions = [], +}: ExtensionSectionProps) { const { getExtensions, addExtension, removeExtension } = useConfig(); const [extensions, setExtensions] = useState([]); const [selectedExtension, setSelectedExtension] = useState(null); @@ -37,22 +50,35 @@ export default function ExtensionsSection({ deepLinkConfig, showEnvVars }: Exten const fetchExtensions = useCallback(async () => { const extensionsList = await getExtensions(true); // Force refresh // Sort extensions by name to maintain consistent order - const sortedExtensions = [...extensionsList].sort((a, b) => { - // First sort by builtin - if (a.type === 'builtin' && b.type !== 'builtin') return -1; - if (a.type !== 'builtin' && b.type === 'builtin') return 1; + const sortedExtensions = [...extensionsList] + .sort((a, b) => { + // First sort by builtin + if (a.type === 'builtin' && b.type !== 'builtin') return -1; + if (a.type !== 'builtin' && b.type === 'builtin') return 1; - // Then sort by bundled (handle null/undefined cases) - const aBundled = a.bundled === true; - const bBundled = b.bundled === true; - if (aBundled && !bBundled) return -1; - if (!aBundled && bBundled) return 1; + // Then sort by bundled (handle null/undefined cases) + const aBundled = a.bundled === true; + const bBundled = b.bundled === true; + if (aBundled && !bBundled) return -1; + if (!aBundled && bBundled) return 1; - // Finally sort alphabetically within each group - return a.name.localeCompare(b.name); - }); + // Finally sort alphabetically within each group + return a.name.localeCompare(b.name); + }) + .map((ext) => ({ + ...ext, + // Use selectedExtensions to determine enabled state in recipe editor + enabled: disableConfiguration ? selectedExtensions.includes(ext.name) : ext.enabled, + })); + + console.log( + 'Setting extensions with selectedExtensions:', + selectedExtensions, + 'Extensions:', + sortedExtensions + ); setExtensions(sortedExtensions); - }, [getExtensions]); + }, [getExtensions, disableConfiguration, selectedExtensions]); useEffect(() => { fetchExtensions(); @@ -60,6 +86,17 @@ export default function ExtensionsSection({ deepLinkConfig, showEnvVars }: Exten }, []); const handleExtensionToggle = async (extension: FixedExtensionEntry) => { + if (customToggle) { + await customToggle(extension); + // After custom toggle, update the local state to reflect the change + setExtensions((prevExtensions) => + prevExtensions.map((ext) => + ext.name === extension.name ? { ...ext, enabled: !ext.enabled } : ext + ) + ); + return true; + } + // If extension is enabled, we are trying to toggle if off, otherwise on const toggleDirection = extension.enabled ? 'toggleOff' : 'toggleOn'; const extensionConfig = extractExtensionConfig(extension); @@ -149,37 +186,46 @@ export default function ExtensionsSection({ deepLinkConfig, showEnvVars }: Exten return (
-
-

Extensions

-
-
-

- These extensions use the Model Context Protocol (MCP). They can expand Goose's - capabilities using three main components: Prompts, Resources, and Tools. -

+ {!hideHeader && ( + <> +
+

Extensions

+
+
+

+ These extensions use the Model Context Protocol (MCP). They can expand Goose's + capabilities using three main components: Prompts, Resources, and Tools. +

+
+ + )} +
-
- - -
+ {!hideButtons && ( +
+ + +
+ )} {/* Modal for updating an existing extension */} {isModalOpen && selectedExtension && ( diff --git a/ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionItem.tsx b/ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionItem.tsx index 9f99be11..97ad0dca 100644 --- a/ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionItem.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionItem.tsx @@ -6,11 +6,17 @@ import { getSubtitle, getFriendlyTitle } from './ExtensionList'; interface ExtensionItemProps { extension: FixedExtensionEntry; - onToggle: (extension: FixedExtensionEntry) => Promise; - onConfigure: (extension: FixedExtensionEntry) => void; + onToggle: (extension: FixedExtensionEntry) => Promise | void; + onConfigure?: (extension: FixedExtensionEntry) => void; + isStatic?: boolean; // to not allow users to edit configuration } -export default function ExtensionItem({ extension, onToggle, onConfigure }: ExtensionItemProps) { +export default function ExtensionItem({ + extension, + onToggle, + onConfigure, + isStatic, +}: ExtensionItemProps) { // Add local state to track the visual toggle state const [visuallyEnabled, setVisuallyEnabled] = useState(extension.enabled); // Track if we're in the process of toggling @@ -59,7 +65,9 @@ export default function ExtensionItem({ extension, onToggle, onConfigure }: Exte // Bundled extensions and builtins are not editable // Over time we can take the first part of the conditional away as people have bundled: true in their config.yaml entries - const editable = !(extension.type === 'builtin' || extension.bundled); + + // allow configuration editing if extension is not a builtin/bundled extension AND isStatic = false + const editable = !(extension.type === 'builtin' || extension.bundled) && !isStatic; return (
onConfigure(extension)} + onClick={() => (onConfigure ? onConfigure(extension) : () => {})} > diff --git a/ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionList.tsx b/ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionList.tsx index 060f0df1..4cb89983 100644 --- a/ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionList.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionList.tsx @@ -7,11 +7,17 @@ import { combineCmdAndArgs, removeShims } from '../utils'; interface ExtensionListProps { extensions: FixedExtensionEntry[]; - onToggle: (extension: FixedExtensionEntry) => Promise; - onConfigure: (extension: FixedExtensionEntry) => void; + onToggle: (extension: FixedExtensionEntry) => Promise | void; + onConfigure?: (extension: FixedExtensionEntry) => void; + isStatic?: boolean; } -export default function ExtensionList({ extensions, onToggle, onConfigure }: ExtensionListProps) { +export default function ExtensionList({ + extensions, + onToggle, + onConfigure, + isStatic, +}: ExtensionListProps) { return (
{extensions.map((extension) => ( @@ -20,6 +26,7 @@ export default function ExtensionList({ extensions, onToggle, onConfigure }: Ext extension={extension} onToggle={onToggle} onConfigure={onConfigure} + isStatic={isStatic} /> ))}
diff --git a/ui/desktop/src/components/settings_v2/providers/modal/ProviderConfiguationModal.tsx b/ui/desktop/src/components/settings_v2/providers/modal/ProviderConfiguationModal.tsx index 0fc1b61e..79718ca1 100644 --- a/ui/desktop/src/components/settings_v2/providers/modal/ProviderConfiguationModal.tsx +++ b/ui/desktop/src/components/settings_v2/providers/modal/ProviderConfiguationModal.tsx @@ -150,7 +150,6 @@ export default function ProviderConfigurationModal() { // go through the keys are remove them for (const param of params) { - console.log('param', param.name, 'secret', param.secret); await remove(param.name, param.secret); }