mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 00:21:21 +00:00
MAESTRO: Add error handling throughout inline wizard
- Create ErrorDisplay component in WizardConversationView with user-friendly error messages, retry button, and dismiss button - Add getUserFriendlyErrorMessage() for mapping technical errors to helpful titles and descriptions (timeout, agent unavailable, session errors, etc.) - Add error props to WizardConversationViewProps interface - Add lastUserMessageContent state field for retry functionality - Create clearError() function in useInlineWizard hook - Create retryLastMessage() that removes failed message and re-sends - Update sendMessage() to track last user message content - Add error field to SessionWizardState interface - Wire onWizardRetry and onWizardClearError props through MainPanel - Connect callbacks in App.tsx to hook functions - Add collapsible "Technical details" section for debugging - Add 4 new tests for clearError and retryLastMessage (41 total tests)
This commit is contained in:
@@ -477,6 +477,91 @@ describe('useInlineWizard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearError', () => {
|
||||
it('should clear the current error', async () => {
|
||||
const { result } = renderHook(() => useInlineWizard());
|
||||
|
||||
// Set an error manually
|
||||
act(() => {
|
||||
result.current.setError('Something went wrong');
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Something went wrong');
|
||||
|
||||
// Clear the error
|
||||
act(() => {
|
||||
result.current.clearError();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should not affect other state when clearing error', async () => {
|
||||
const { result } = renderHook(() => useInlineWizard());
|
||||
|
||||
// Start wizard and set some state
|
||||
await act(async () => {
|
||||
await result.current.startWizard('test', undefined, '/test/project');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setConfidence(50);
|
||||
result.current.setError('Test error');
|
||||
});
|
||||
|
||||
expect(result.current.isWizardActive).toBe(true);
|
||||
expect(result.current.confidence).toBe(50);
|
||||
expect(result.current.error).toBe('Test error');
|
||||
|
||||
// Clear error
|
||||
act(() => {
|
||||
result.current.clearError();
|
||||
});
|
||||
|
||||
// Other state should be preserved
|
||||
expect(result.current.isWizardActive).toBe(true);
|
||||
expect(result.current.confidence).toBe(50);
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retryLastMessage', () => {
|
||||
it('should not retry when there is no error', async () => {
|
||||
const { result } = renderHook(() => useInlineWizard());
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.retryLastMessage();
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[useInlineWizard] Cannot retry: no last message or no error'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not retry when there is no last message', async () => {
|
||||
const { result } = renderHook(() => useInlineWizard());
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
// Set an error but don't send a message
|
||||
act(() => {
|
||||
result.current.setError('Some error');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.retryLastMessage();
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[useInlineWizard] Cannot retry: no last message or no error'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateDocuments', () => {
|
||||
it('should return error when agent type is missing', async () => {
|
||||
const { result } = renderHook(() => useInlineWizard());
|
||||
|
||||
@@ -4241,7 +4241,11 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
|
||||
// Inline wizard hook for /wizard command
|
||||
// This manages the state for the inline wizard that creates/iterates on Auto Run documents
|
||||
const { startWizard: startInlineWizard } = useInlineWizard();
|
||||
const {
|
||||
startWizard: startInlineWizard,
|
||||
clearError: clearInlineWizardError,
|
||||
retryLastMessage: retryInlineWizardMessage,
|
||||
} = useInlineWizard();
|
||||
|
||||
// Handler for the built-in /history command
|
||||
// Requests a synopsis from the current agent session and saves to history
|
||||
@@ -10044,6 +10048,9 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
// Refresh the Auto Run panel to show newly generated documents
|
||||
handleAutoRunRefresh();
|
||||
}}
|
||||
// Inline wizard error handling callbacks
|
||||
onWizardRetry={retryInlineWizardMessage}
|
||||
onWizardClearError={clearInlineWizardError}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ export interface WizardConversationViewProps {
|
||||
ready?: boolean;
|
||||
/** Callback when user clicks the "Let's Go" button to start document generation */
|
||||
onLetsGo?: () => void;
|
||||
/** Error message to display (if any) */
|
||||
error?: string | null;
|
||||
/** Callback when user clicks the retry button */
|
||||
onRetry?: () => void;
|
||||
/** Callback to clear the error */
|
||||
onClearError?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,6 +213,174 @@ function StreamingResponse({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly error message from a raw error string.
|
||||
* Maps technical errors to helpful messages.
|
||||
*/
|
||||
function getUserFriendlyErrorMessage(error: string): { title: string; description: string } {
|
||||
const lowerError = error.toLowerCase();
|
||||
|
||||
// Network/timeout errors
|
||||
if (lowerError.includes('timeout') || lowerError.includes('timed out')) {
|
||||
return {
|
||||
title: 'Response Timeout',
|
||||
description: 'The AI agent took too long to respond. This can happen with complex requests or network issues.',
|
||||
};
|
||||
}
|
||||
|
||||
// Agent not available errors
|
||||
if (lowerError.includes('not available') || lowerError.includes('not found')) {
|
||||
return {
|
||||
title: 'Agent Not Available',
|
||||
description: 'The AI agent could not be started. Please check that it is properly installed and configured.',
|
||||
};
|
||||
}
|
||||
|
||||
// Session errors
|
||||
if (lowerError.includes('session') && (lowerError.includes('not active') || lowerError.includes('no active'))) {
|
||||
return {
|
||||
title: 'Session Error',
|
||||
description: 'The wizard session is no longer active. Please restart the wizard.',
|
||||
};
|
||||
}
|
||||
|
||||
// Failed to spawn errors
|
||||
if (lowerError.includes('failed to spawn')) {
|
||||
return {
|
||||
title: 'Failed to Start Agent',
|
||||
description: 'Could not start the AI agent. Please check your configuration and try again.',
|
||||
};
|
||||
}
|
||||
|
||||
// Exit code errors
|
||||
if (lowerError.includes('exited with code')) {
|
||||
return {
|
||||
title: 'Agent Error',
|
||||
description: 'The AI agent encountered an error and stopped unexpectedly.',
|
||||
};
|
||||
}
|
||||
|
||||
// Parse errors
|
||||
if (lowerError.includes('parse') || lowerError.includes('failed to parse')) {
|
||||
return {
|
||||
title: 'Response Error',
|
||||
description: 'Could not understand the response from the AI. Please try rephrasing your message.',
|
||||
};
|
||||
}
|
||||
|
||||
// Default generic error
|
||||
return {
|
||||
title: 'Something Went Wrong',
|
||||
description: error || 'An unexpected error occurred. Please try again.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorDisplay - Shows error messages with a retry button
|
||||
*/
|
||||
function ErrorDisplay({
|
||||
theme,
|
||||
error,
|
||||
onRetry,
|
||||
onDismiss,
|
||||
}: {
|
||||
theme: Theme;
|
||||
error: string;
|
||||
onRetry?: () => void;
|
||||
onDismiss?: () => void;
|
||||
}): JSX.Element {
|
||||
const { title, description } = getUserFriendlyErrorMessage(error);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center mb-4" data-testid="wizard-error-display">
|
||||
<div
|
||||
className="max-w-md w-full rounded-lg px-4 py-4"
|
||||
style={{
|
||||
backgroundColor: `${theme.colors.error}15`,
|
||||
border: `1px solid ${theme.colors.error}40`,
|
||||
}}
|
||||
>
|
||||
{/* Error header with icon */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center"
|
||||
style={{ backgroundColor: `${theme.colors.error}20` }}
|
||||
>
|
||||
<span style={{ color: theme.colors.error, fontSize: '16px' }}>⚠️</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4
|
||||
className="text-sm font-semibold mb-1"
|
||||
style={{ color: theme.colors.error }}
|
||||
data-testid="error-title"
|
||||
>
|
||||
{title}
|
||||
</h4>
|
||||
<p
|
||||
className="text-xs mb-3"
|
||||
style={{ color: theme.colors.textMain, opacity: 0.9 }}
|
||||
data-testid="error-description"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="px-3 py-1.5 rounded text-xs font-medium transition-all hover:scale-105"
|
||||
style={{
|
||||
backgroundColor: theme.colors.error,
|
||||
color: 'white',
|
||||
}}
|
||||
data-testid="error-retry-button"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="px-3 py-1.5 rounded text-xs font-medium transition-colors hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
color: theme.colors.textDim,
|
||||
border: `1px solid ${theme.colors.border}`,
|
||||
}}
|
||||
data-testid="error-dismiss-button"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical details (collapsed by default, can be expanded for debugging) */}
|
||||
<details className="mt-3">
|
||||
<summary
|
||||
className="text-[10px] cursor-pointer select-none"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
>
|
||||
Technical details
|
||||
</summary>
|
||||
<pre
|
||||
className="mt-2 text-[10px] p-2 rounded overflow-x-auto whitespace-pre-wrap"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgActivity,
|
||||
color: theme.colors.textDim,
|
||||
}}
|
||||
data-testid="error-technical-details"
|
||||
>
|
||||
{error}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WizardConversationView - Scrollable conversation area for the inline wizard
|
||||
*/
|
||||
@@ -221,6 +395,9 @@ export function WizardConversationView({
|
||||
confidence = 0,
|
||||
ready = false,
|
||||
onLetsGo,
|
||||
error = null,
|
||||
onRetry,
|
||||
onClearError,
|
||||
}: WizardConversationViewProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
@@ -233,7 +410,7 @@ export function WizardConversationView({
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [conversationHistory, isLoading, streamingText, scrollToBottom]);
|
||||
}, [conversationHistory, isLoading, streamingText, error, scrollToBottom]);
|
||||
|
||||
// Get a new filler phrase when requested by the TypingIndicator
|
||||
const handleRequestNewPhrase = useCallback(() => {
|
||||
@@ -282,6 +459,7 @@ export function WizardConversationView({
|
||||
|
||||
{/* Streaming Response or Typing Indicator */}
|
||||
{isLoading &&
|
||||
!error &&
|
||||
(streamingText ? (
|
||||
<StreamingResponse
|
||||
theme={theme}
|
||||
@@ -297,6 +475,16 @@ export function WizardConversationView({
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Error Display - shown when there's an error */}
|
||||
{error && !isLoading && (
|
||||
<ErrorDisplay
|
||||
theme={theme}
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
onDismiss={onClearError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* "Let's Go" Action Button - shown when ready and confidence threshold met */}
|
||||
{ready && confidence >= READY_CONFIDENCE_THRESHOLD && !isLoading && onLetsGo && (
|
||||
<div
|
||||
|
||||
@@ -241,6 +241,10 @@ interface MainPanelProps {
|
||||
onWizardContentChange?: (content: string, docIndex: number) => void;
|
||||
/** Called when user clicks "Let's Go" in wizard to start document generation */
|
||||
onWizardLetsGo?: () => void;
|
||||
/** Called when user clicks "Retry" in wizard after an error */
|
||||
onWizardRetry?: () => void;
|
||||
/** Called when user dismisses an error in the wizard */
|
||||
onWizardClearError?: () => void;
|
||||
}
|
||||
|
||||
// PERFORMANCE: Wrap with React.memo to prevent re-renders when parent (App.tsx) re-renders
|
||||
@@ -1140,6 +1144,9 @@ export const MainPanel = React.memo(forwardRef<MainPanelHandle, MainPanelProps>(
|
||||
confidence={activeSession.wizardState.confidence}
|
||||
ready={activeSession.wizardState.ready}
|
||||
onLetsGo={props.onWizardLetsGo}
|
||||
error={activeSession.wizardState.error}
|
||||
onRetry={props.onWizardRetry}
|
||||
onClearError={props.onWizardClearError}
|
||||
/>
|
||||
) : (
|
||||
<TerminalOutput
|
||||
|
||||
@@ -104,9 +104,14 @@ export function InlineWizardProvider({ children }: InlineWizardProviderProps) {
|
||||
wizardState.setGeneratedDocuments,
|
||||
wizardState.setExistingDocuments,
|
||||
wizardState.setError,
|
||||
wizardState.clearError,
|
||||
wizardState.retryLastMessage,
|
||||
wizardState.addAssistantMessage,
|
||||
wizardState.clearConversation,
|
||||
wizardState.reset,
|
||||
wizardState.generateDocuments,
|
||||
wizardState.streamingContent,
|
||||
wizardState.generationProgress,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -115,6 +115,8 @@ export interface InlineWizardState {
|
||||
previousUIState: PreviousUIState | null;
|
||||
/** Error message if something goes wrong */
|
||||
error: string | null;
|
||||
/** Last user message content (for retry functionality) */
|
||||
lastUserMessageContent: string | null;
|
||||
/** Project path used for document detection */
|
||||
projectPath: string | null;
|
||||
/** Agent type for the session */
|
||||
@@ -203,6 +205,14 @@ export interface UseInlineWizardReturn {
|
||||
setExistingDocuments: (docs: ExistingDocument[]) => void;
|
||||
/** Set error message */
|
||||
setError: (error: string | null) => void;
|
||||
/** Clear the current error */
|
||||
clearError: () => void;
|
||||
/**
|
||||
* Retry sending the last user message that failed.
|
||||
* Only works if there was a previous user message and an error occurred.
|
||||
* @param callbacks - Optional callbacks for streaming progress
|
||||
*/
|
||||
retryLastMessage: (callbacks?: ConversationCallbacks) => Promise<void>;
|
||||
/** Add an assistant response to the conversation */
|
||||
addAssistantMessage: (content: string, confidence?: number, ready?: boolean) => void;
|
||||
/** Clear conversation history */
|
||||
@@ -242,6 +252,7 @@ const initialState: InlineWizardState = {
|
||||
existingDocuments: [],
|
||||
previousUIState: null,
|
||||
error: null,
|
||||
lastUserMessageContent: null,
|
||||
projectPath: null,
|
||||
agentType: null,
|
||||
sessionName: null,
|
||||
@@ -490,10 +501,11 @@ export function useInlineWizard(): UseInlineWizardReturn {
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Add user message to history and set waiting state
|
||||
// Add user message to history, track it for retry, and set waiting state
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
conversationHistory: [...prev.conversationHistory, userMessage],
|
||||
lastUserMessageContent: content,
|
||||
isWaiting: true,
|
||||
error: null,
|
||||
}));
|
||||
@@ -672,6 +684,53 @@ export function useInlineWizard(): UseInlineWizardReturn {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear the current error.
|
||||
*/
|
||||
const clearError = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: null,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Retry sending the last user message that failed.
|
||||
* Removes the failed user message from history and re-sends it.
|
||||
*/
|
||||
const retryLastMessage = useCallback(
|
||||
async (callbacks?: ConversationCallbacks): Promise<void> => {
|
||||
const lastContent = state.lastUserMessageContent;
|
||||
|
||||
// Only retry if we have a last message and there's an error
|
||||
if (!lastContent || !state.error) {
|
||||
console.warn('[useInlineWizard] Cannot retry: no last message or no error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the last user message from history (it failed, so we'll re-add it)
|
||||
// Find the last user message in history
|
||||
const historyWithoutLastUser = [...state.conversationHistory];
|
||||
for (let i = historyWithoutLastUser.length - 1; i >= 0; i--) {
|
||||
if (historyWithoutLastUser[i].role === 'user') {
|
||||
historyWithoutLastUser.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear error and update history
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
conversationHistory: historyWithoutLastUser,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
// Re-send the message
|
||||
await sendMessage(lastContent, callbacks);
|
||||
},
|
||||
[state.lastUserMessageContent, state.error, state.conversationHistory, sendMessage]
|
||||
);
|
||||
|
||||
/**
|
||||
* Clear conversation history.
|
||||
*/
|
||||
@@ -891,6 +950,8 @@ export function useInlineWizard(): UseInlineWizardReturn {
|
||||
setGeneratedDocuments,
|
||||
setExistingDocuments,
|
||||
setError,
|
||||
clearError,
|
||||
retryLastMessage,
|
||||
addAssistantMessage,
|
||||
clearConversation,
|
||||
reset,
|
||||
|
||||
@@ -111,6 +111,10 @@ export interface SessionWizardState {
|
||||
/** Previous UI state to restore when wizard ends */
|
||||
previousUIState: WizardPreviousUIState;
|
||||
|
||||
// Error handling state
|
||||
/** Error message if an error occurred during wizard conversation */
|
||||
error?: string | null;
|
||||
|
||||
// Document generation state
|
||||
/** Whether documents are currently being generated (triggers takeover view) */
|
||||
isGeneratingDocs?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user