Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions ui/desktop/src/components/GroupedExtensionLoadingToast.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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();
});
});
40 changes: 20 additions & 20 deletions ui/desktop/src/components/settings/extensions/agent-api.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
});
Comment on lines +37 to +45
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The variable msg could have a more descriptive name like formattedMsg or displayMsg to better distinguish it from the raw errMsg and improve code clarity about which message is being used where.

Copilot uses AI. Check for mistakes.
}
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;
}
}
Expand Down Expand Up @@ -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;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
});
});

Expand Down
32 changes: 14 additions & 18 deletions ui/desktop/src/components/settings/extensions/extension-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading