diff --git a/ui/desktop/src/components/bottom_menu/BottomMenu.tsx b/ui/desktop/src/components/bottom_menu/BottomMenu.tsx index 587670ec07f7..6cca95eb6044 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenu.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenu.tsx @@ -4,6 +4,7 @@ import { useToolCount } from '../alerts/useToolCount'; import BottomMenuAlertPopover from './BottomMenuAlertPopover'; import type { View, ViewOptions } from '../../App'; import { BottomMenuModeSelection } from './BottomMenuModeSelection'; +import { BottomMenuExtensions } from './BottomMenuExtensions'; import ModelsBottomBar from '../settings/models/bottom_bar/ModelsBottomBar'; import { useConfig } from '../ConfigContext'; import { useModelAndProvider } from '../ModelAndProviderContext'; @@ -207,6 +208,12 @@ export default function BottomMenu({ {/* Tool and Token count */} {} + {/* Extensions Menu */} + + + {/* Separator */} +
+ {/* Model Selector Dropdown */} diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensions.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensions.tsx new file mode 100644 index 000000000000..ef6cc05880cd --- /dev/null +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensions.tsx @@ -0,0 +1,193 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { useConfig, FixedExtensionEntry } from '../ConfigContext'; +import { View, ViewOptions } from '../../App'; +import { Puzzle } from 'lucide-react'; +import { Switch } from '../ui/switch'; +import { toggleExtension } from '../settings/extensions'; + +interface BottomMenuExtensionsProps { + setView: (view: View, viewOptions?: ViewOptions) => void; +} + +// Helper function to get display name from extension +function getDisplayName(extension: FixedExtensionEntry): string { + if (extension.type === 'builtin' && extension.display_name) { + return extension.display_name; + } + + // Format the name to be more readable + return extension.name + .split(/[-_]/) // Split on hyphens and underscores + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +// Helper function to get description from extension +function getDescription(extension: FixedExtensionEntry): string | null { + if (extension.type === 'sse' || extension.type === 'stdio') { + return extension.description || null; + } + return null; +} + +export const BottomMenuExtensions = ({ setView }: BottomMenuExtensionsProps) => { + const { getExtensions, addExtension } = useConfig(); + const [extensions, setExtensions] = useState([]); + const [isExtensionsMenuOpen, setIsExtensionsMenuOpen] = useState(false); + const [isToggling, setIsToggling] = useState(null); + const extensionsDropdownRef = useRef(null); + + const fetchExtensions = useCallback(async () => { + try { + const extensionsList = await getExtensions(true); + // 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; + + // 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); + }); + setExtensions(sortedExtensions); + } catch (error) { + console.error('Failed to fetch extensions:', error); + } + }, [getExtensions]); + + useEffect(() => { + fetchExtensions(); + }, [fetchExtensions]); + + // Add click outside handler + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + extensionsDropdownRef.current && + !extensionsDropdownRef.current.contains(event.target as Node) + ) { + setIsExtensionsMenuOpen(false); + } + } + + // Add the event listener when the menu is open + if (isExtensionsMenuOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + // Clean up the event listener + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isExtensionsMenuOpen]); + + // Add effect to handle Escape key + useEffect(() => { + const handleEsc = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsExtensionsMenuOpen(false); + } + }; + + if (isExtensionsMenuOpen) { + window.addEventListener('keydown', handleEsc); + } + + return () => { + window.removeEventListener('keydown', handleEsc); + }; + }, [isExtensionsMenuOpen]); + + const handleExtensionToggle = async (extension: FixedExtensionEntry) => { + if (isToggling === extension.name) return; + + setIsToggling(extension.name); + try { + await toggleExtension({ + toggle: extension.enabled ? 'toggleOff' : 'toggleOn', + extensionConfig: extension, + addToConfig: addExtension, + toastOptions: { silent: false }, // Show toast notifications + }); + await fetchExtensions(); // Refresh the list after successful toggle + } catch (error) { + console.error('Failed to toggle extension:', error); + } finally { + setIsToggling(null); + } + }; + + const enabledCount = extensions.filter((ext) => ext.enabled).length; + + return ( +
+
+
setIsExtensionsMenuOpen(!isExtensionsMenuOpen)} + > + + {enabledCount} extension{enabledCount !== 1 ? 's' : ''} enabled + + +
+ + {/* Dropdown Menu */} + {isExtensionsMenuOpen && ( +
+
+
Extensions
+
+
+ {extensions.map((extension) => ( +
+
+ + {getDisplayName(extension)} + + {getDescription(extension) && ( + + {getDescription(extension)} + + )} +
+
+ handleExtensionToggle(extension)} + disabled={isToggling === extension.name} + variant="mono" + /> +
+
+ ))} + {extensions.length === 0 && ( +
No extensions configured
+ )} +
+
+ +
+
+ )} +
+
+ ); +}; diff --git a/ui/desktop/src/components/settings/SettingsView.tsx b/ui/desktop/src/components/settings/SettingsView.tsx index 0a3ad2a0d7a8..b3c1da05f40e 100644 --- a/ui/desktop/src/components/settings/SettingsView.tsx +++ b/ui/desktop/src/components/settings/SettingsView.tsx @@ -11,6 +11,7 @@ import AppSettingsSection from './app/AppSettingsSection'; import SchedulerSection from './scheduler/SchedulerSection'; import { ExtensionConfig } from '../../api'; import MoreMenuLayout from '../more_menu/MoreMenuLayout'; +import { useEffect, useRef } from 'react'; export type SettingsViewOptions = { deepLinkConfig?: ExtensionConfig; @@ -27,6 +28,42 @@ export default function SettingsView({ setView: (view: View, viewOptions?: ViewOptions) => void; viewOptions: SettingsViewOptions; }) { + const extensionsSectionRef = useRef(null); + + // Handle scrolling to extensions section + useEffect(() => { + if (viewOptions.section === 'extensions' && extensionsSectionRef.current) { + // Use requestAnimationFrame for better timing and DOM readiness + requestAnimationFrame(() => { + setTimeout(() => { + if (extensionsSectionRef.current) { + const element = extensionsSectionRef.current; + const scrollContainer = element.closest('[data-radix-scroll-area-viewport]'); + + if (scrollContainer) { + // Scroll within the ScrollArea component + const elementTop = element.offsetTop; + + // Calculate the target scroll position with a small offset for the header + const targetScroll = elementTop - 20; // 20px offset from top + + scrollContainer.scrollTo({ + top: targetScroll, + behavior: 'smooth', + }); + } else { + // Fallback to scrollIntoView if ScrollArea not found + element.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + } + } + }, 50); + }); + } + }, [viewOptions.section]); + return (
@@ -44,10 +81,12 @@ export default function SettingsView({ {/* Models Section */} {/* Extensions Section */} - +
+ +
{/* Scheduler Section */} {/* Goose Modes */}