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:
Pedram Amini
2025-12-28 08:06:53 -06:00
parent 393c978a18
commit 3a1bfd5197
7 changed files with 360 additions and 3 deletions

View File

@@ -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', () => { describe('generateDocuments', () => {
it('should return error when agent type is missing', async () => { it('should return error when agent type is missing', async () => {
const { result } = renderHook(() => useInlineWizard()); const { result } = renderHook(() => useInlineWizard());

View File

@@ -4241,7 +4241,11 @@ You are taking over this conversation. Based on the context above, provide a bri
// Inline wizard hook for /wizard command // Inline wizard hook for /wizard command
// This manages the state for the inline wizard that creates/iterates on Auto Run documents // 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 // Handler for the built-in /history command
// Requests a synopsis from the current agent session and saves to history // 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 // Refresh the Auto Run panel to show newly generated documents
handleAutoRunRefresh(); handleAutoRunRefresh();
}} }}
// Inline wizard error handling callbacks
onWizardRetry={retryInlineWizardMessage}
onWizardClearError={clearInlineWizardError}
/> />
)} )}

View File

@@ -45,6 +45,12 @@ export interface WizardConversationViewProps {
ready?: boolean; ready?: boolean;
/** Callback when user clicks the "Let's Go" button to start document generation */ /** Callback when user clicks the "Let's Go" button to start document generation */
onLetsGo?: () => void; 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 * WizardConversationView - Scrollable conversation area for the inline wizard
*/ */
@@ -221,6 +395,9 @@ export function WizardConversationView({
confidence = 0, confidence = 0,
ready = false, ready = false,
onLetsGo, onLetsGo,
error = null,
onRetry,
onClearError,
}: WizardConversationViewProps): JSX.Element { }: WizardConversationViewProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -233,7 +410,7 @@ export function WizardConversationView({
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom();
}, [conversationHistory, isLoading, streamingText, scrollToBottom]); }, [conversationHistory, isLoading, streamingText, error, scrollToBottom]);
// Get a new filler phrase when requested by the TypingIndicator // Get a new filler phrase when requested by the TypingIndicator
const handleRequestNewPhrase = useCallback(() => { const handleRequestNewPhrase = useCallback(() => {
@@ -282,6 +459,7 @@ export function WizardConversationView({
{/* Streaming Response or Typing Indicator */} {/* Streaming Response or Typing Indicator */}
{isLoading && {isLoading &&
!error &&
(streamingText ? ( (streamingText ? (
<StreamingResponse <StreamingResponse
theme={theme} 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 */} {/* "Let's Go" Action Button - shown when ready and confidence threshold met */}
{ready && confidence >= READY_CONFIDENCE_THRESHOLD && !isLoading && onLetsGo && ( {ready && confidence >= READY_CONFIDENCE_THRESHOLD && !isLoading && onLetsGo && (
<div <div

View File

@@ -241,6 +241,10 @@ interface MainPanelProps {
onWizardContentChange?: (content: string, docIndex: number) => void; onWizardContentChange?: (content: string, docIndex: number) => void;
/** Called when user clicks "Let's Go" in wizard to start document generation */ /** Called when user clicks "Let's Go" in wizard to start document generation */
onWizardLetsGo?: () => void; 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 // 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} confidence={activeSession.wizardState.confidence}
ready={activeSession.wizardState.ready} ready={activeSession.wizardState.ready}
onLetsGo={props.onWizardLetsGo} onLetsGo={props.onWizardLetsGo}
error={activeSession.wizardState.error}
onRetry={props.onWizardRetry}
onClearError={props.onWizardClearError}
/> />
) : ( ) : (
<TerminalOutput <TerminalOutput

View File

@@ -104,9 +104,14 @@ export function InlineWizardProvider({ children }: InlineWizardProviderProps) {
wizardState.setGeneratedDocuments, wizardState.setGeneratedDocuments,
wizardState.setExistingDocuments, wizardState.setExistingDocuments,
wizardState.setError, wizardState.setError,
wizardState.clearError,
wizardState.retryLastMessage,
wizardState.addAssistantMessage, wizardState.addAssistantMessage,
wizardState.clearConversation, wizardState.clearConversation,
wizardState.reset, wizardState.reset,
wizardState.generateDocuments,
wizardState.streamingContent,
wizardState.generationProgress,
]); ]);
return ( return (

View File

@@ -115,6 +115,8 @@ export interface InlineWizardState {
previousUIState: PreviousUIState | null; previousUIState: PreviousUIState | null;
/** Error message if something goes wrong */ /** Error message if something goes wrong */
error: string | null; error: string | null;
/** Last user message content (for retry functionality) */
lastUserMessageContent: string | null;
/** Project path used for document detection */ /** Project path used for document detection */
projectPath: string | null; projectPath: string | null;
/** Agent type for the session */ /** Agent type for the session */
@@ -203,6 +205,14 @@ export interface UseInlineWizardReturn {
setExistingDocuments: (docs: ExistingDocument[]) => void; setExistingDocuments: (docs: ExistingDocument[]) => void;
/** Set error message */ /** Set error message */
setError: (error: string | null) => void; 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 */ /** Add an assistant response to the conversation */
addAssistantMessage: (content: string, confidence?: number, ready?: boolean) => void; addAssistantMessage: (content: string, confidence?: number, ready?: boolean) => void;
/** Clear conversation history */ /** Clear conversation history */
@@ -242,6 +252,7 @@ const initialState: InlineWizardState = {
existingDocuments: [], existingDocuments: [],
previousUIState: null, previousUIState: null,
error: null, error: null,
lastUserMessageContent: null,
projectPath: null, projectPath: null,
agentType: null, agentType: null,
sessionName: null, sessionName: null,
@@ -490,10 +501,11 @@ export function useInlineWizard(): UseInlineWizardReturn {
timestamp: Date.now(), 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) => ({ setState((prev) => ({
...prev, ...prev,
conversationHistory: [...prev.conversationHistory, userMessage], conversationHistory: [...prev.conversationHistory, userMessage],
lastUserMessageContent: content,
isWaiting: true, isWaiting: true,
error: null, 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. * Clear conversation history.
*/ */
@@ -891,6 +950,8 @@ export function useInlineWizard(): UseInlineWizardReturn {
setGeneratedDocuments, setGeneratedDocuments,
setExistingDocuments, setExistingDocuments,
setError, setError,
clearError,
retryLastMessage,
addAssistantMessage, addAssistantMessage,
clearConversation, clearConversation,
reset, reset,

View File

@@ -111,6 +111,10 @@ export interface SessionWizardState {
/** Previous UI state to restore when wizard ends */ /** Previous UI state to restore when wizard ends */
previousUIState: WizardPreviousUIState; previousUIState: WizardPreviousUIState;
// Error handling state
/** Error message if an error occurred during wizard conversation */
error?: string | null;
// Document generation state // Document generation state
/** Whether documents are currently being generated (triggers takeover view) */ /** Whether documents are currently being generated (triggers takeover view) */
isGeneratingDocs?: boolean; isGeneratingDocs?: boolean;