From 07abfa36818b0cff23981dadc40113e176c711fc Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Fri, 24 Oct 2025 09:11:03 -0700 Subject: [PATCH 1/3] add bottom menu extension selection for ALPHA --- ui/desktop/src/components/ChatInput.tsx | 8 +- .../BottomMenuExtensionSelection.tsx | 159 ++++++++++++++++++ .../components/bottom_menu/ExtensionItem.tsx | 45 +++++ 3 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx create mode 100644 ui/desktop/src/components/bottom_menu/ExtensionItem.tsx diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 62c446b62645..9a284f112a33 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -11,6 +11,7 @@ import { LocalMessageStorage } from '../utils/localMessageStorage'; import { DirSwitcher } from './bottom_menu/DirSwitcher'; import ModelsBottomBar from './settings/models/bottom_bar/ModelsBottomBar'; import { BottomMenuModeSelection } from './bottom_menu/BottomMenuModeSelection'; +import { BottomMenuExtensionSelection } from './bottom_menu/BottomMenuExtensionSelection'; import { AlertType, useAlerts } from './alerts'; import { useConfig } from './ConfigContext'; import { useModelAndProvider } from './ModelAndProviderContext'; @@ -1590,7 +1591,12 @@ export default function ChatInput({
-
+ {process.env.ALPHA && sessionId && ( + <> +
+ + + )}
diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx new file mode 100644 index 000000000000..a51949337c5f --- /dev/null +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx @@ -0,0 +1,159 @@ +import { useCallback, useMemo, useState } from 'react'; +import { Puzzle } from 'lucide-react'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../ui/dropdown-menu'; +import { Input } from '../ui/input'; +import { Switch } from '../ui/switch'; +import { FixedExtensionEntry, useConfig } from '../ConfigContext'; +import { toggleExtension } from '../settings/extensions/extension-manager'; +import { toastService } from '../../toasts'; +import { getFriendlyTitle, getSubtitle } from '../settings/extensions/subcomponents/ExtensionList'; + +interface BottomMenuExtensionSelectionProps { + sessionId: string; +} + +export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionSelectionProps) => { + const [searchQuery, setSearchQuery] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const { extensionsList, addExtension, getExtensions } = useConfig(); + + const extensions = useMemo(() => { + if (extensionsList.length === 0) { + return []; + } + + return [...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 = 'bundled' in a && a.bundled === true; + const bBundled = 'bundled' in b && 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); + }); + }, [extensionsList]); + + const fetchExtensions = useCallback(async () => { + await getExtensions(true); + }, [getExtensions]); + + const handleToggle = useCallback( + async (extensionConfig: FixedExtensionEntry) => { + if (!sessionId) { + toastService.error({ + title: 'Extension Toggle Error', + msg: 'No active session found. Please start a chat session first.', + traceback: 'No session ID available', + }); + return; + } + + try { + const toggleDirection = extensionConfig.enabled ? 'toggleOff' : 'toggleOn'; + + await toggleExtension({ + toggle: toggleDirection, + extensionConfig: extensionConfig, + addToConfig: addExtension, + toastOptions: { silent: false }, + sessionId: sessionId, + }); + + await fetchExtensions(); + } catch (error) { + toastService.error({ + title: 'Extension Error', + msg: `Failed to ${extensionConfig.enabled ? 'disable' : 'enable'} ${extensionConfig.name}`, + traceback: error instanceof Error ? error.message : String(error), + }); + await fetchExtensions(); + } + }, + [sessionId, addExtension, fetchExtensions] + ); + + const filteredExtensions = useMemo(() => { + return extensions.filter((ext) => { + const query = searchQuery.toLowerCase(); + return ( + ext.name.toLowerCase().includes(query) || + (ext.description && ext.description.toLowerCase().includes(query)) + ); + }); + }, [extensions, searchQuery]); + + const sortedExtensions = useMemo(() => { + return [...filteredExtensions].sort((a, b) => { + if (a.enabled === b.enabled) { + return a.name.localeCompare(b.name); + } + return a.enabled ? -1 : 1; + }); + }, [filteredExtensions]); + + const activeCount = useMemo(() => { + return extensions.filter((ext) => ext.enabled).length; + }, [extensions]); + + return ( + + + + + +
+ setSearchQuery(e.target.value)} + className="h-8 text-sm" + /> +
+
+ {sortedExtensions.length === 0 ? ( +
+ {searchQuery ? 'no extensions found' : 'no extensions available'} +
+ ) : ( + sortedExtensions.map((ext) => ( +
handleToggle(ext)} + title={ext.description || ext.name} + > +
+
+ {getFriendlyTitle(ext)} +
+
+ {getSubtitle(ext).description || 'No description available'} +
+
+
e.stopPropagation()}> + handleToggle(ext)} + variant="mono" + /> +
+
+ )) + )} +
+
+
+ ); +}; diff --git a/ui/desktop/src/components/bottom_menu/ExtensionItem.tsx b/ui/desktop/src/components/bottom_menu/ExtensionItem.tsx new file mode 100644 index 000000000000..e6b132054253 --- /dev/null +++ b/ui/desktop/src/components/bottom_menu/ExtensionItem.tsx @@ -0,0 +1,45 @@ +import { forwardRef } from 'react'; +import { Switch } from '../ui/switch'; + +export interface Extension { + name: string; + title: string; + description: string; +} + +interface ExtensionItemProps { + extension: Extension; + isEnabled: boolean; + onToggle: (enabled: boolean) => void; +} + +export const ExtensionItem = forwardRef( + ({ extension, isEnabled, onToggle }, ref) => { + const handleToggleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + return ( +
+
+
+
+

{extension.title}

+

{extension.description}

+
+
+ +
+ +
+
+
+ ); + } +); + +ExtensionItem.displayName = 'ExtensionItem'; From 37dd3269420c464058fd27d5b7b6ea585d369891 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Fri, 24 Oct 2025 12:59:30 -0700 Subject: [PATCH 2/3] - Remove unused ExtensionItem component - Add error handling for extension fetch failures - Add autofocus to search input for better UX - Reset search query when dropdown closes --- .../BottomMenuExtensionSelection.tsx | 21 ++++++++- .../components/bottom_menu/ExtensionItem.tsx | 45 ------------------- 2 files changed, 19 insertions(+), 47 deletions(-) delete mode 100644 ui/desktop/src/components/bottom_menu/ExtensionItem.tsx diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx index a51949337c5f..9709fc68a731 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx @@ -39,7 +39,15 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS }, [extensionsList]); const fetchExtensions = useCallback(async () => { - await getExtensions(true); + try { + await getExtensions(true); + } catch (error) { + toastService.error({ + title: 'Extension Fetch Error', + msg: 'Failed to refresh extensions list', + traceback: error instanceof Error ? error.message : String(error), + }); + } }, [getExtensions]); const handleToggle = useCallback( @@ -101,7 +109,15 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS }, [extensions]); return ( - + { + setIsOpen(open); + if (!open) { + setSearchQuery(''); // Reset search when closing + } + }} + >
diff --git a/ui/desktop/src/components/bottom_menu/ExtensionItem.tsx b/ui/desktop/src/components/bottom_menu/ExtensionItem.tsx deleted file mode 100644 index e6b132054253..000000000000 --- a/ui/desktop/src/components/bottom_menu/ExtensionItem.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { forwardRef } from 'react'; -import { Switch } from '../ui/switch'; - -export interface Extension { - name: string; - title: string; - description: string; -} - -interface ExtensionItemProps { - extension: Extension; - isEnabled: boolean; - onToggle: (enabled: boolean) => void; -} - -export const ExtensionItem = forwardRef( - ({ extension, isEnabled, onToggle }, ref) => { - const handleToggleClick = (e: React.MouseEvent) => { - e.stopPropagation(); - }; - - return ( -
-
-
-
-

{extension.title}

-

{extension.description}

-
-
- -
- -
-
-
- ); - } -); - -ExtensionItem.displayName = 'ExtensionItem'; From 592dce6a45a91ccc5f115d91dc7c40f1774f2c71 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Fri, 24 Oct 2025 13:20:34 -0700 Subject: [PATCH 3/3] - Remove fetchExtensions callback wrapper and unnecessary fetch calls - Remove empty array check in extensions memo - Combine sorting logic and prioritize by type (builtin/platform/frontend) - Flatten extension item view and remove descriptions for compact display - Remove unused imports (getSubtitle, getExtensions) --- .../BottomMenuExtensionSelection.tsx | 83 ++++++------------- 1 file changed, 27 insertions(+), 56 deletions(-) diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx index 9709fc68a731..05b0b11b782f 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx @@ -6,7 +6,7 @@ import { Switch } from '../ui/switch'; import { FixedExtensionEntry, useConfig } from '../ConfigContext'; import { toggleExtension } from '../settings/extensions/extension-manager'; import { toastService } from '../../toasts'; -import { getFriendlyTitle, getSubtitle } from '../settings/extensions/subcomponents/ExtensionList'; +import { getFriendlyTitle } from '../settings/extensions/subcomponents/ExtensionList'; interface BottomMenuExtensionSelectionProps { sessionId: string; @@ -15,40 +15,7 @@ interface BottomMenuExtensionSelectionProps { export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionSelectionProps) => { const [searchQuery, setSearchQuery] = useState(''); const [isOpen, setIsOpen] = useState(false); - const { extensionsList, addExtension, getExtensions } = useConfig(); - - const extensions = useMemo(() => { - if (extensionsList.length === 0) { - return []; - } - - return [...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 = 'bundled' in a && a.bundled === true; - const bBundled = 'bundled' in b && 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); - }); - }, [extensionsList]); - - const fetchExtensions = useCallback(async () => { - try { - await getExtensions(true); - } catch (error) { - toastService.error({ - title: 'Extension Fetch Error', - msg: 'Failed to refresh extensions list', - traceback: error instanceof Error ? error.message : String(error), - }); - } - }, [getExtensions]); + const { extensionsList, addExtension } = useConfig(); const handleToggle = useCallback( async (extensionConfig: FixedExtensionEntry) => { @@ -71,42 +38,53 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS toastOptions: { silent: false }, sessionId: sessionId, }); - - await fetchExtensions(); } catch (error) { toastService.error({ title: 'Extension Error', msg: `Failed to ${extensionConfig.enabled ? 'disable' : 'enable'} ${extensionConfig.name}`, traceback: error instanceof Error ? error.message : String(error), }); - await fetchExtensions(); } }, - [sessionId, addExtension, fetchExtensions] + [sessionId, addExtension] ); const filteredExtensions = useMemo(() => { - return extensions.filter((ext) => { + return extensionsList.filter((ext) => { const query = searchQuery.toLowerCase(); return ( ext.name.toLowerCase().includes(query) || (ext.description && ext.description.toLowerCase().includes(query)) ); }); - }, [extensions, searchQuery]); + }, [extensionsList, searchQuery]); const sortedExtensions = useMemo(() => { + const getTypePriority = (type: string): number => { + const priorities: Record = { + builtin: 0, + platform: 1, + frontend: 2, + }; + return priorities[type] ?? Number.MAX_SAFE_INTEGER; + }; + return [...filteredExtensions].sort((a, b) => { - if (a.enabled === b.enabled) { - return a.name.localeCompare(b.name); - } - return a.enabled ? -1 : 1; + // First sort by priority type + const typeDiff = getTypePriority(a.type) - getTypePriority(b.type); + if (typeDiff !== 0) return typeDiff; + + // Then sort by enabled status (enabled first) + if (a.enabled !== b.enabled) return a.enabled ? -1 : 1; + + // Finally sort alphabetically + return a.name.localeCompare(b.name); }); }, [filteredExtensions]); const activeCount = useMemo(() => { - return extensions.filter((ext) => ext.enabled).length; - }, [extensions]); + return extensionsList.filter((ext) => ext.enabled).length; + }, [extensionsList]); return ( (
handleToggle(ext)} title={ext.description || ext.name} > -
-
- {getFriendlyTitle(ext)} -
-
- {getSubtitle(ext).description || 'No description available'} -
-
+
{getFriendlyTitle(ext)}
e.stopPropagation()}>