mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat: add grouped extension loading notification (#5529)
Signed-off-by: jom-sq <148157197+jom-sq@users.noreply.github.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user