diff --git a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx new file mode 100644 index 000000000000..01a2887518a0 --- /dev/null +++ b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx @@ -0,0 +1,163 @@ +import { useState } from 'react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'; +import { ChevronDown, ChevronUp, Loader2 } from 'lucide-react'; +import { Button } from './ui/button'; +import { startNewSession } from '../sessions'; +import { useNavigation } from '../hooks/useNavigation'; +import { formatExtensionErrorMessage } from '../utils/extensionErrorUtils'; + +export interface ExtensionLoadingStatus { + name: string; + status: 'loading' | 'success' | 'error'; + error?: string; + recoverHints?: string; +} + +interface ExtensionLoadingToastProps { + extensions: ExtensionLoadingStatus[]; + totalCount: number; + isComplete: boolean; +} + +export function GroupedExtensionLoadingToast({ + extensions, + totalCount, + isComplete, +}: ExtensionLoadingToastProps) { + const [isOpen, setIsOpen] = useState(false); + const [copiedExtension, setCopiedExtension] = useState(null); + const setView = useNavigation(); + + const successCount = extensions.filter((ext) => ext.status === 'success').length; + const errorCount = extensions.filter((ext) => ext.status === 'error').length; + + const getStatusIcon = (status: 'loading' | 'success' | 'error') => { + switch (status) { + case 'loading': + return ; + case 'success': + return
; + case 'error': + return
; + } + }; + + const getSummaryText = () => { + if (!isComplete) { + return `Loading ${totalCount} extension${totalCount !== 1 ? 's' : ''}...`; + } + + if (errorCount === 0) { + return `Successfully loaded ${successCount} extension${successCount !== 1 ? 's' : ''}`; + } + + return `Loaded ${successCount}/${totalCount} extension${totalCount !== 1 ? 's' : ''}`; + }; + + const getSummaryIcon = () => { + if (!isComplete) { + return ; + } + + if (errorCount === 0) { + return
; + } + + return
; + }; + + return ( +
+ +
+ {/* Main summary section - clickable */} + +
+
+ {getSummaryIcon()} +
+
{getSummaryText()}
+ {errorCount > 0 && ( +
+ {errorCount} extension{errorCount !== 1 ? 's' : ''} failed to load +
+ )} +
+
+
+
+ + {/* Expanded details section */} + +
+
+ {extensions.map((ext) => ( +
+
+ {getStatusIcon(ext.status)} +
{ext.name}
+
+ {ext.status === 'error' && ext.error && ( +
+
+ {formatExtensionErrorMessage(ext.error, 'Failed to add extension')} +
+ {ext.recoverHints && setView ? ( + + ) : ( + + )} +
+ )} +
+ ))} +
+
+
+ + {/* Toggle button */} + {totalCount > 0 && ( + + + + )} +
+
+
+ ); +} diff --git a/ui/desktop/src/components/__tests__/GroupedExtensionLoadingToast.test.tsx b/ui/desktop/src/components/__tests__/GroupedExtensionLoadingToast.test.tsx new file mode 100644 index 000000000000..701e9e041391 --- /dev/null +++ b/ui/desktop/src/components/__tests__/GroupedExtensionLoadingToast.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { GroupedExtensionLoadingToast } from '../GroupedExtensionLoadingToast'; + +const renderWithRouter = (component: React.ReactElement) => { + return render({component}); +}; + +describe('GroupedExtensionLoadingToast', () => { + it('renders loading state correctly', () => { + const extensions = [ + { name: 'developer', status: 'loading' as const }, + { name: 'memory', status: 'loading' as const }, + ]; + + renderWithRouter( + + ); + + expect(screen.getByText('Loading 2 extensions...')).toBeInTheDocument(); + expect(screen.getByText('Show details')).toBeInTheDocument(); + }); + + it('renders success state correctly', () => { + const extensions = [ + { name: 'developer', status: 'success' as const }, + { name: 'memory', status: 'success' as const }, + ]; + + renderWithRouter( + + ); + + expect(screen.getByText('Successfully loaded 2 extensions')).toBeInTheDocument(); + expect(screen.getByText('Show details')).toBeInTheDocument(); + }); + + it('renders partial failure state correctly', () => { + const extensions = [ + { name: 'developer', status: 'success' as const }, + { name: 'memory', status: 'error' as const, error: 'Failed to connect' }, + ]; + + renderWithRouter( + + ); + + expect(screen.getByText('Loaded 1/2 extensions')).toBeInTheDocument(); + expect(screen.getByText('1 extension failed to load')).toBeInTheDocument(); + expect(screen.getByText('Show details')).toBeInTheDocument(); + }); + + it('renders single extension correctly', () => { + const extensions = [{ name: 'developer', status: 'success' as const }]; + + renderWithRouter( + + ); + + expect(screen.getByText('Successfully loaded 1 extension')).toBeInTheDocument(); + }); + + it('renders mixed status states correctly', () => { + const extensions = [ + { name: 'developer', status: 'success' as const }, + { name: 'memory', status: 'loading' as const }, + { name: 'Square MCP Server', status: 'error' as const, error: 'Connection failed' }, + ]; + + renderWithRouter( + + ); + + // Summary should show loading state with error count + expect(screen.getByText('Loading 3 extensions...')).toBeInTheDocument(); + expect(screen.getByText('1 extension failed to load')).toBeInTheDocument(); + expect(screen.getByText('Show details')).toBeInTheDocument(); + }); +}); diff --git a/ui/desktop/src/components/settings/extensions/agent-api.ts b/ui/desktop/src/components/settings/extensions/agent-api.ts index d051e289dec0..bd38284873c4 100644 --- a/ui/desktop/src/components/settings/extensions/agent-api.ts +++ b/ui/desktop/src/components/settings/extensions/agent-api.ts @@ -1,6 +1,10 @@ import { toastService } from '../../../toasts'; import { agentAddExtension, ExtensionConfig, agentRemoveExtension } from '../../../api'; import { errorMessage } from '../../../utils/conversionUtils'; +import { + createExtensionRecoverHints, + formatExtensionErrorMessage, +} from '../../../utils/extensionErrorUtils'; export async function addToAgent( extensionConfig: ExtensionConfig, @@ -30,20 +34,16 @@ export async function addToAgent( } catch (error) { if (showToast) { toastService.dismiss(toastId); + const errMsg = errorMessage(error); + const recoverHints = createExtensionRecoverHints(errMsg); + const msg = formatExtensionErrorMessage(errMsg, 'Failed to add extension'); + toastService.error({ + title: extensionName, + msg: msg, + traceback: errMsg, + recoverHints, + }); } - const errMsg = errorMessage(error); - const recoverHints = - `Explain the following error: ${errMsg}. ` + - 'This happened while trying to install an extension. Look out for issues that the ' + - "extension tried to run something faulty, didn't exist or there was trouble with " + - 'the network configuration - VPNs like WARP often cause issues.'; - const msg = errMsg.length < 70 ? errMsg : `Failed to add extension`; - toastService.error({ - title: extensionName, - msg: msg, - traceback: errMsg, - recoverHints, - }); throw error; } } @@ -75,14 +75,14 @@ export async function removeFromAgent( } catch (error) { if (showToast) { toastService.dismiss(toastId); + const errMsg = errorMessage(error); + const msg = formatExtensionErrorMessage(errMsg, 'Failed to remove extension'); + toastService.error({ + title: extensionName, + msg: msg, + traceback: errMsg, + }); } - const errorMessage = error instanceof Error ? error.message : String(error); - const msg = errorMessage.length < 70 ? errorMessage : `Failed to remove extension`; - toastService.error({ - title: extensionName, - msg: msg, - traceback: errorMessage, - }); throw error; } } diff --git a/ui/desktop/src/components/settings/extensions/extension-manager.test.ts b/ui/desktop/src/components/settings/extensions/extension-manager.test.ts index 785cea3ffdf9..0151ebca3658 100644 --- a/ui/desktop/src/components/settings/extensions/extension-manager.test.ts +++ b/ui/desktop/src/components/settings/extensions/extension-manager.test.ts @@ -70,23 +70,18 @@ describe('Extension Manager', () => { expect(mockAddToAgent).toHaveBeenCalledTimes(3); }); - it('should show error toast after max retries but keep extension enabled', async () => { + it('should throw error after max retries', async () => { const error428 = new Error('428 Precondition Required'); mockAddToAgent.mockRejectedValue(error428); - mockToastService.configure = vi.fn(); - mockToastService.error = vi.fn(); - await addToAgentOnStartup({ - sessionId: 'test-session', - extensionConfig: mockExtensionConfig, - }); + await expect( + addToAgentOnStartup({ + sessionId: 'test-session', + extensionConfig: mockExtensionConfig, + }) + ).rejects.toThrow('428 Precondition Required'); expect(mockAddToAgent).toHaveBeenCalledTimes(4); // Initial + 3 retries - expect(mockToastService.error).toHaveBeenCalledWith({ - title: 'test-extension', - msg: 'Extension failed to start and will retry on a new session.', - traceback: '428 Precondition Required', - }); }); }); diff --git a/ui/desktop/src/components/settings/extensions/extension-manager.ts b/ui/desktop/src/components/settings/extensions/extension-manager.ts index 6deb12960be0..3da183fdca58 100644 --- a/ui/desktop/src/components/settings/extensions/extension-manager.ts +++ b/ui/desktop/src/components/settings/extensions/extension-manager.ts @@ -96,25 +96,21 @@ interface AddToAgentOnStartupProps { export async function addToAgentOnStartup({ extensionConfig, sessionId, + toastOptions, }: AddToAgentOnStartupProps): Promise { - try { - await retryWithBackoff(() => addToAgent(extensionConfig, sessionId, true), { - retries: 3, - delayMs: 1000, - shouldRetry: (error: ExtensionError) => - !!error.message && - (error.message.includes('428') || - error.message.includes('Precondition Required') || - error.message.includes('Agent is not initialized')), - }); - } catch (finalError) { - toastService.configure({ silent: false }); - toastService.error({ - title: extensionConfig.name, - msg: 'Extension failed to start and will retry on a new session.', - traceback: finalError instanceof Error ? finalError.message : String(finalError), - }); - } + const showToast = !toastOptions?.silent; + + // Errors are caught by the grouped notification in providerUtils.ts + // Individual error toasts are suppressed during startup (showToast=false) + await retryWithBackoff(() => addToAgent(extensionConfig, sessionId, showToast), { + retries: 3, + delayMs: 1000, + shouldRetry: (error: ExtensionError) => + !!error.message && + (error.message.includes('428') || + error.message.includes('Precondition Required') || + error.message.includes('Agent is not initialized')), + }); } interface UpdateExtensionProps { diff --git a/ui/desktop/src/toasts.tsx b/ui/desktop/src/toasts.tsx index 2eb51dcb39c7..4f8849c828ae 100644 --- a/ui/desktop/src/toasts.tsx +++ b/ui/desktop/src/toasts.tsx @@ -2,6 +2,10 @@ import { toast, ToastOptions } from 'react-toastify'; import { Button } from './components/ui/button'; import { startNewSession } from './sessions'; import { useNavigation } from './hooks/useNavigation'; +import { + GroupedExtensionLoadingToast, + ExtensionLoadingStatus, +} from './components/GroupedExtensionLoadingToast'; export interface ToastServiceOptions { silent?: boolean; @@ -63,6 +67,56 @@ class ToastService { if (toastId) toast.dismiss(toastId); } + /** + * Create a grouped extension loading toast that can be updated as extensions load + */ + extensionLoading( + extensions: ExtensionLoadingStatus[], + totalCount: number, + isComplete: boolean = false + ): string | number { + if (this.silent) { + return 'silent'; + } + + const toastId = 'extension-loading'; + + // Check if toast already exists + if (toast.isActive(toastId)) { + // Update existing toast + toast.update(toastId, { + render: ( + + ), + autoClose: isComplete ? 5000 : false, + closeButton: true, + closeOnClick: false, + }); + } else { + // Create new toast + toast( + , + { + ...commonToastOptions, + toastId, + autoClose: false, + closeButton: true, + closeOnClick: false, // Prevent closing when clicking to expand/collapse + } + ); + } + + return toastId; + } + /** * Handle errors with consistent logging and toast notifications * Consolidates the functionality of the original handleError function @@ -80,6 +134,9 @@ class ToastService { // Export a singleton instance for use throughout the app export const toastService = ToastService.getInstance(); +// Re-export ExtensionLoadingStatus for convenience +export type { ExtensionLoadingStatus }; + const commonToastOptions: ToastOptions = { position: 'top-right', closeButton: true, diff --git a/ui/desktop/src/utils/extensionErrorUtils.ts b/ui/desktop/src/utils/extensionErrorUtils.ts new file mode 100644 index 000000000000..e707c6c0e757 --- /dev/null +++ b/ui/desktop/src/utils/extensionErrorUtils.ts @@ -0,0 +1,30 @@ +/** + * Shared constants and utilities for extension error handling + */ + +export const MAX_ERROR_MESSAGE_LENGTH = 70; + +/** + * Creates recovery hints for the "Ask goose" feature when extension loading fails + */ +export function createExtensionRecoverHints(errorMsg: string): string { + return ( + `Explain the following error: ${errorMsg}. ` + + 'This happened while trying to install an extension. Look out for issues where the ' + + "extension attempted to execute something incorrectly, didn't exist, or there was trouble with " + + 'the network configuration - VPNs like WARP often cause issues.' + ); +} + +/** + * Formats an error message for display, truncating long messages with a fallback + * @param errorMsg - The full error message + * @param fallback - The fallback message to show if the error is too long + * @returns The formatted error message + */ +export function formatExtensionErrorMessage( + errorMsg: string, + fallback: string = 'Failed to add extension' +): string { + return errorMsg.length < MAX_ERROR_MESSAGE_LENGTH ? errorMsg : fallback; +} diff --git a/ui/desktop/src/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts index 9c5f5f7c2d37..8862b5c12d69 100644 --- a/ui/desktop/src/utils/providerUtils.ts +++ b/ui/desktop/src/utils/providerUtils.ts @@ -5,6 +5,9 @@ import { } from '../components/settings/extensions'; import type { ExtensionConfig, FixedExtensionEntry } from '../components/ConfigContext'; import { Recipe, updateAgentProvider, updateFromSession } from '../api'; +import { toastService, ExtensionLoadingStatus } from '../toasts'; +import { errorMessage } from './conversionUtils'; +import { createExtensionRecoverHints } from './extensionErrorUtils'; // Helper function to substitute parameters in text export const substituteParameters = (text: string, params: Record): string => { @@ -77,23 +80,70 @@ export const initializeSystem = async ( // Add enabled extensions to agent in parallel const enabledExtensions = refreshedExtensions.filter((ext) => ext.enabled); + if (enabledExtensions.length === 0) { + return; + } + options?.setIsExtensionsLoading?.(true); + // Initialize extension status tracking + const extensionStatuses: Map = new Map( + enabledExtensions.map((ext) => [ext.name, { name: ext.name, status: 'loading' as const }]) + ); + + // Show initial loading toast + const updateToast = (isComplete: boolean = false) => { + toastService.extensionLoading( + Array.from(extensionStatuses.values()), + enabledExtensions.length, + isComplete + ); + }; + + updateToast(); + + // Load extensions in parallel and update status const extensionLoadingPromises = enabledExtensions.map(async (extensionConfig) => { const extensionName = extensionConfig.name; try { await addToAgentOnStartup({ extensionConfig, - toastOptions: { silent: false }, + toastOptions: { silent: true }, // Silent since we're using grouped notification sessionId, }); + + // Update status to success + extensionStatuses.set(extensionName, { + name: extensionName, + status: 'success', + }); + updateToast(); } catch (error) { console.error(`Failed to load extension ${extensionName}:`, error); + + // Extract error message using shared utility + const errMsg = errorMessage(error); + + // Create recovery hints for "Ask goose" button + const recoverHints = createExtensionRecoverHints(errMsg); + + // Update status to error + extensionStatuses.set(extensionName, { + name: extensionName, + status: 'error', + error: errMsg, + recoverHints, + }); + updateToast(); } }); await Promise.allSettled(extensionLoadingPromises); + + // Show final completion toast + updateToast(true); + options?.setIsExtensionsLoading?.(false); } catch (error) { console.error('Failed to initialize agent:', error);