diff --git a/src/__tests__/renderer/hooks/useInlineWizard.test.ts b/src/__tests__/renderer/hooks/useInlineWizard.test.ts index b8c4976a..87dcf55a 100644 --- a/src/__tests__/renderer/hooks/useInlineWizard.test.ts +++ b/src/__tests__/renderer/hooks/useInlineWizard.test.ts @@ -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()); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0dc7d59f..52ffd3e9 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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} /> )} diff --git a/src/renderer/components/InlineWizard/WizardConversationView.tsx b/src/renderer/components/InlineWizard/WizardConversationView.tsx index c6b4cb7e..bdbdf67d 100644 --- a/src/renderer/components/InlineWizard/WizardConversationView.tsx +++ b/src/renderer/components/InlineWizard/WizardConversationView.tsx @@ -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 ( +
+
+ {/* Error header with icon */} +
+
+ ⚠️ +
+
+

+ {title} +

+

+ {description} +

+ + {/* Action buttons */} +
+ {onRetry && ( + + )} + {onDismiss && ( + + )} +
+
+
+ + {/* Technical details (collapsed by default, can be expanded for debugging) */} +
+ + Technical details + +
+            {error}
+          
+
+
+
+ ); +} + /** * 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(null); const messagesEndRef = useRef(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 ? ( ))} + {/* Error Display - shown when there's an error */} + {error && !isLoading && ( + + )} + {/* "Let's Go" Action Button - shown when ready and confidence threshold met */} {ready && confidence >= READY_CONFIDENCE_THRESHOLD && !isLoading && onLetsGo && (
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( confidence={activeSession.wizardState.confidence} ready={activeSession.wizardState.ready} onLetsGo={props.onWizardLetsGo} + error={activeSession.wizardState.error} + onRetry={props.onWizardRetry} + onClearError={props.onWizardClearError} /> ) : ( 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; /** 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 => { + 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, diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index 724ab1c3..3bea42bb 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -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;