feat: add grouped extension loading notification (#5529)

Signed-off-by: jom-sq <148157197+jom-sq@users.noreply.github.com>
This commit is contained in:
jom-sq
2025-11-03 20:43:20 -05:00
committed by GitHub
parent 7511a533d6
commit 86c3e42e43
8 changed files with 422 additions and 51 deletions
@@ -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<string | null>(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 <Loader2 className="w-4 h-4 animate-spin text-blue-500" />;
case 'success':
return <div className="w-4 h-4 rounded-full bg-green-500" />;
case 'error':
return <div className="w-4 h-4 rounded-full bg-red-500" />;
}
};
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 <Loader2 className="w-5 h-5 animate-spin text-blue-500" />;
}
if (errorCount === 0) {
return <div className="w-5 h-5 rounded-full bg-green-500" />;
}
return <div className="w-5 h-5 rounded-full bg-yellow-500" />;
};
return (
<div className="w-full">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="flex flex-col">
{/* Main summary section - clickable */}
<CollapsibleTrigger asChild>
<div className="flex items-start gap-3 pr-8 cursor-pointer hover:opacity-90 transition-opacity">
<div className="flex items-center gap-3 flex-1 min-w-0">
{getSummaryIcon()}
<div className="flex-1 min-w-0">
<div className="font-medium text-base">{getSummaryText()}</div>
{errorCount > 0 && (
<div className="text-sm opacity-90">
{errorCount} extension{errorCount !== 1 ? 's' : ''} failed to load
</div>
)}
</div>
</div>
</div>
</CollapsibleTrigger>
{/* Expanded details section */}
<CollapsibleContent className="overflow-hidden">
<div className="mt-3 pt-3 border-t border-white/20">
<div className="space-y-3 max-h-64 overflow-y-auto pr-2 pl-1">
{extensions.map((ext) => (
<div key={ext.name} className="flex flex-col gap-2">
<div className="flex items-center gap-3 text-sm">
{getStatusIcon(ext.status)}
<div className="flex-1 min-w-0 truncate">{ext.name}</div>
</div>
{ext.status === 'error' && ext.error && (
<div className="ml-7 flex flex-col gap-2">
<div className="text-xs opacity-75 break-words">
{formatExtensionErrorMessage(ext.error, 'Failed to add extension')}
</div>
{ext.recoverHints && setView ? (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
startNewSession(ext.recoverHints, null, setView);
}}
className="self-start"
>
Ask goose
</Button>
) : (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(ext.error!);
setCopiedExtension(ext.name);
setTimeout(() => setCopiedExtension(null), 2000);
}}
className="self-start"
>
{copiedExtension === ext.name ? 'Copied!' : 'Copy error'}
</Button>
)}
</div>
)}
</div>
))}
</div>
</div>
</CollapsibleContent>
{/* Toggle button */}
{totalCount > 0 && (
<CollapsibleTrigger asChild>
<button
className="flex items-center justify-center gap-1 text-xs opacity-60 hover:opacity-100 transition-opacity mt-2 py-1.5 w-full"
aria-label={isOpen ? 'Collapse details' : 'Expand details'}
>
{isOpen ? (
<>
<span>Show less</span>
<ChevronUp className="w-3 h-3" />
</>
) : (
<>
<span>Show details</span>
<ChevronDown className="w-3 h-3" />
</>
)}
</button>
</CollapsibleTrigger>
)}
</div>
</Collapsible>
</div>
);
}
@@ -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(<MemoryRouter>{component}</MemoryRouter>);
};
describe('GroupedExtensionLoadingToast', () => {
it('renders loading state correctly', () => {
const extensions = [
{ name: 'developer', status: 'loading' as const },
{ name: 'memory', status: 'loading' as const },
];
renderWithRouter(
<GroupedExtensionLoadingToast extensions={extensions} totalCount={2} isComplete={false} />
);
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(
<GroupedExtensionLoadingToast extensions={extensions} totalCount={2} isComplete={true} />
);
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(
<GroupedExtensionLoadingToast extensions={extensions} totalCount={2} isComplete={true} />
);
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(
<GroupedExtensionLoadingToast extensions={extensions} totalCount={1} isComplete={true} />
);
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(
<GroupedExtensionLoadingToast extensions={extensions} totalCount={3} isComplete={false} />
);
// 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();
});
});
@@ -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;
}
}
@@ -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',
});
});
});
@@ -96,25 +96,21 @@ interface AddToAgentOnStartupProps {
export async function addToAgentOnStartup({
extensionConfig,
sessionId,
toastOptions,
}: AddToAgentOnStartupProps): Promise<void> {
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 {
+57
View File
@@ -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: (
<GroupedExtensionLoadingToast
extensions={extensions}
totalCount={totalCount}
isComplete={isComplete}
/>
),
autoClose: isComplete ? 5000 : false,
closeButton: true,
closeOnClick: false,
});
} else {
// Create new toast
toast(
<GroupedExtensionLoadingToast
extensions={extensions}
totalCount={totalCount}
isComplete={isComplete}
/>,
{
...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,
@@ -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;
}
+51 -1
View File
@@ -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, string>): 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<string, ExtensionLoadingStatus> = 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);