From b2ab9e0e21480f2afc1987d24005218dae289d3a Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Mon, 3 Mar 2025 20:53:03 -0500 Subject: [PATCH] feat: extension add & edit in settings v2 (#1480) --- .../ProviderSetupOverlay.tsx => Modal.tsx} | 12 +- .../settings_v2/ExtensionsSection.tsx | 464 +++++++++++++++++- .../modal/ProviderConfiguationModal.tsx | 7 +- 3 files changed, 467 insertions(+), 16 deletions(-) rename ui/desktop/src/components/{settings_v2/providers/modal/subcomponents/ProviderSetupOverlay.tsx => Modal.tsx} (53%) diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/ProviderSetupOverlay.tsx b/ui/desktop/src/components/Modal.tsx similarity index 53% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/ProviderSetupOverlay.tsx rename to ui/desktop/src/components/Modal.tsx index a2c74f5f..2f9cfb51 100644 --- a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/ProviderSetupOverlay.tsx +++ b/ui/desktop/src/components/Modal.tsx @@ -1,18 +1,18 @@ import React from 'react'; -import { Card } from '../../../../ui/card'; +import { Card } from './ui/card'; -interface ProviderSetupOverlayProps { +interface ModalProps { children: React.ReactNode; } /** - * Renders the semi-transparent backdrop + blur for the modal. + * A reusable modal component that renders content with a semi-transparent backdrop and blur effect. */ -export default function ProviderSetupOverlay({ children }: ProviderSetupOverlayProps) { +export default function Modal({ children }: ModalProps) { return (
- -
{children}
+ +
{children}
); diff --git a/ui/desktop/src/components/settings_v2/ExtensionsSection.tsx b/ui/desktop/src/components/settings_v2/ExtensionsSection.tsx index 7aaa74aa..4c777ec6 100644 --- a/ui/desktop/src/components/settings_v2/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings_v2/ExtensionsSection.tsx @@ -1,10 +1,14 @@ import React, { useEffect, useState } from 'react'; import { Button } from '../ui/button'; import { Switch } from '../ui/switch'; -import { Plus } from 'lucide-react'; +import { Plus, X } from 'lucide-react'; import { Gear } from '../icons/Gear'; import { GPSIcon } from '../ui/icons'; import { useConfig } from '../ConfigContext'; +import Modal from '../Modal'; +import { Input } from '../ui/input'; +import Select from 'react-select'; +import { createDarkSelectStyles, darkSelectTheme } from '../ui/select-styles'; interface ExtensionConfig { args?: string[]; @@ -12,7 +16,7 @@ interface ExtensionConfig { enabled: boolean; envs?: Record; name: string; - type: 'stdio' | 'builtin'; + type: 'stdio' | 'sse'; } interface ExtensionItem { @@ -24,6 +28,11 @@ interface ExtensionItem { config: ExtensionConfig; } +interface EnvVar { + key: string; + value: string; +} + // Helper function to get a friendly title from extension name const getFriendlyTitle = (name: string): string => { return name @@ -41,8 +50,28 @@ const getSubtitle = (config: ExtensionConfig): string => { }; export default function ExtensionsSection() { - const { config, updateExtension } = useConfig(); + const { config, updateExtension, addExtension } = useConfig(); const [extensions, setExtensions] = useState([]); + const [selectedExtension, setSelectedExtension] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [formData, setFormData] = useState<{ + name: string; + type: 'stdio' | 'sse'; + cmd?: string; + args?: string[]; + endpoint?: string; + enabled: boolean; + envVars: EnvVar[]; + }>({ + name: '', + type: 'stdio', + cmd: '', + args: [], + endpoint: '', + enabled: true, + envVars: [], + }); useEffect(() => { if (config.extensions) { @@ -63,6 +92,28 @@ export default function ExtensionsSection() { } }, [config.extensions]); + useEffect(() => { + if (selectedExtension) { + const envVars = selectedExtension.config.envs + ? Object.entries(selectedExtension.config.envs).map(([key, value]) => ({ + key, + value: value as string, + })) + : []; + + setFormData({ + name: selectedExtension.config.name, + type: selectedExtension.config.type as 'stdio' | 'sse', + cmd: selectedExtension.config.type === 'stdio' ? selectedExtension.config.cmd : undefined, + args: selectedExtension.config.args || [], + endpoint: + selectedExtension.config.type === 'sse' ? selectedExtension.config.cmd : undefined, + enabled: selectedExtension.config.enabled, + envVars, + }); + } + }, [selectedExtension]); + const handleExtensionToggle = async (id: string) => { const extension = extensions.find((ext) => ext.id === id); if (extension) { @@ -75,11 +126,125 @@ export default function ExtensionsSection() { await updateExtension(id, updatedConfig); } catch (error) { console.error('Failed to update extension:', error); - // Here you might want to add a toast notification for error feedback } } }; + const handleConfigureClick = (extension: ExtensionItem) => { + setSelectedExtension(extension); + setIsModalOpen(true); + }; + + const handleAddExtension = async () => { + const envs = formData.envVars.reduce( + (acc, { key, value }) => { + if (key) { + acc[key] = value; + } + return acc; + }, + {} as Record + ); + + const extensionConfig = { + name: formData.name, + type: formData.type, + enabled: formData.enabled, + envs, + ...(formData.type === 'stdio' + ? { + cmd: formData.cmd, + args: formData.args, + } + : { + cmd: formData.endpoint, + }), + }; + + try { + await addExtension(formData.name, extensionConfig); + handleModalClose(); + } catch (error) { + console.error('Failed to add extension:', error); + } + }; + + const handleModalClose = () => { + setIsModalOpen(false); + setIsAddModalOpen(false); + setSelectedExtension(null); + setFormData({ + name: '', + type: 'stdio', + cmd: '', + args: [], + endpoint: '', + enabled: true, + envVars: [], + }); + }; + + const handleAddEnvVar = () => { + setFormData({ + ...formData, + envVars: [...formData.envVars, { key: '', value: '' }], + }); + }; + + const handleRemoveEnvVar = (index: number) => { + const newEnvVars = [...formData.envVars]; + newEnvVars.splice(index, 1); + setFormData({ + ...formData, + envVars: newEnvVars, + }); + }; + + const handleEnvVarChange = (index: number, field: 'key' | 'value', value: string) => { + const newEnvVars = [...formData.envVars]; + newEnvVars[index][field] = value; + setFormData({ + ...formData, + envVars: newEnvVars, + }); + }; + + const handleSaveConfig = async () => { + if (!selectedExtension) return; + + const envs = formData.envVars.reduce( + (acc, { key, value }) => { + if (key) { + acc[key] = value; + } + return acc; + }, + {} as Record + ); + + const updatedConfig = { + name: formData.name, + type: formData.type, + enabled: formData.enabled, + envs, + ...(formData.type === 'stdio' + ? { + cmd: formData.cmd, + args: formData.args, + } + : { + cmd: formData.endpoint, + }), + }; + + try { + await updateExtension(selectedExtension.id, updatedConfig); + handleModalClose(); + } catch (error) { + console.error('Failed to update extension configuration:', error); + } + }; + return (
@@ -100,7 +265,10 @@ export default function ExtensionsSection() {
{extension.canConfigure && ( - )} @@ -116,7 +284,21 @@ export default function ExtensionsSection() { ))}
- @@ -129,6 +311,276 @@ export default function ExtensionsSection() {
+ + {isModalOpen && selectedExtension && ( + +
+

Update Extension

+ +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="Enter extension name..." + /> +
+
+ + + setFormData({ ...formData, cmd: e.target.value, endpoint: undefined }) + } + placeholder="Enter command..." + className="w-full" + /> +
+
+ + + setFormData({ + ...formData, + args: e.target.value.split(' ').filter((arg) => arg.length > 0), + }) + } + placeholder="Enter arguments..." + className="w-full" + /> +
+
+ ) : ( +
+ + + setFormData({ + ...formData, + endpoint: e.target.value, + cmd: undefined, + args: [], + }) + } + placeholder="Enter endpoint URL..." + className="w-full" + /> +
+ )} +
+ +
+
+ + +
+ +
+ {formData.envVars.map((envVar, index) => ( +
+ handleEnvVarChange(index, 'key', e.target.value)} + placeholder="Key" + className="flex-1" + /> + handleEnvVarChange(index, 'value', e.target.value)} + placeholder="Value" + className="flex-1" + /> + +
+ ))} +
+
+ +
+ + +
+ +
+ )} + + {isAddModalOpen && ( + +
+

Add New Extension

+ +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="Enter extension name..." + /> +
+
+ + + setFormData({ ...formData, cmd: e.target.value, endpoint: undefined }) + } + placeholder="Enter command..." + className="w-full" + /> +
+
+ + + setFormData({ + ...formData, + args: e.target.value.split(' ').filter((arg) => arg.length > 0), + }) + } + placeholder="Enter arguments..." + className="w-full" + /> +
+
+ ) : ( +
+ + + setFormData({ + ...formData, + endpoint: e.target.value, + cmd: undefined, + args: [], + }) + } + placeholder="Enter endpoint URL..." + className="w-full" + /> +
+ )} +
+ +
+
+ + +
+ +
+ {formData.envVars.map((envVar, index) => ( +
+ handleEnvVarChange(index, 'key', e.target.value)} + placeholder="Key" + className="flex-1" + /> + handleEnvVarChange(index, 'value', e.target.value)} + placeholder="Value" + className="flex-1" + /> + +
+ ))} +
+
+ +
+ + +
+ +
+ )}
); } 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 693a0cb1..18469fe9 100644 --- a/ui/desktop/src/components/settings_v2/providers/modal/ProviderConfiguationModal.tsx +++ b/ui/desktop/src/components/settings_v2/providers/modal/ProviderConfiguationModal.tsx @@ -1,10 +1,9 @@ import React, { useEffect, useState } from 'react'; -import ProviderSetupOverlay from './subcomponents/ProviderSetupOverlay'; +import Modal from '../../../../components/Modal'; import ProviderSetupHeader from './subcomponents/ProviderSetupHeader'; import DefaultProviderSetupForm from './subcomponents/forms/DefaultProviderSetupForm'; import ProviderSetupActions from './subcomponents/ProviderSetupActions'; import ProviderLogo from './subcomponents/ProviderLogo'; -import ProviderConfiguationModalProps from './interfaces/ProviderConfigurationModalProps'; import { useProviderModal } from './ProviderModalProvider'; import { toast } from 'react-toastify'; @@ -61,7 +60,7 @@ export default function ProviderConfigurationModal() { }; return ( - +
{/* Logo area - centered above title */} @@ -79,6 +78,6 @@ export default function ProviderConfigurationModal() { /> - + ); }