feat: implement React Error Boundaries for improved error handling

- Created ErrorBoundary component with comprehensive fallback UI
  - Displays error details and component stack trace
  - Provides "Try Again" and "Reload App" recovery options
  - Clean, user-friendly error display with Lucide icons

- Wrapped entire app in ErrorBoundary at main.tsx entry point
- Added granular error boundaries around major sections:
  - SessionList (left sidebar)
  - Center workspace (TerminalOutput/InputArea)
  - RightPanel (right sidebar)

- Prevents component crashes from taking down entire application
- Each section can now fail independently without affecting others
- Build verified successfully

Related to housekeeping task #21
This commit is contained in:
Pedram Amini
2025-11-23 23:44:30 -06:00
parent c3ad2250e8
commit 4e79701cce
3 changed files with 230 additions and 72 deletions

View File

@@ -22,6 +22,7 @@ import { CreateGroupModal } from './components/CreateGroupModal';
import { RenameSessionModal } from './components/RenameSessionModal';
import { RenameGroupModal } from './components/RenameGroupModal';
import { ConfirmModal } from './components/ConfirmModal';
import { ErrorBoundary } from './components/ErrorBoundary';
// Import custom hooks
import { useSettings, useSessionManager, useFileExplorer } from './hooks';
@@ -1468,42 +1469,44 @@ export default function MaestroConsole() {
)}
{/* --- LEFT SIDEBAR --- */}
<SessionList
theme={theme}
sessions={sessions}
groups={groups}
sortedSessions={sortedSessions}
activeSessionId={activeSessionId}
leftSidebarOpen={leftSidebarOpen}
leftSidebarWidthState={leftSidebarWidthState}
activeFocus={activeFocus}
selectedSidebarIndex={selectedSidebarIndex}
editingGroupId={editingGroupId}
editingSessionId={editingSessionId}
draggingSessionId={draggingSessionId}
anyTunnelActive={anyTunnelActive}
shortcuts={shortcuts}
setActiveFocus={setActiveFocus}
setActiveSessionId={setActiveSessionId}
setLeftSidebarOpen={setLeftSidebarOpen}
setLeftSidebarWidthState={setLeftSidebarWidthState}
setShortcutsHelpOpen={setShortcutsHelpOpen}
setSettingsModalOpen={setSettingsModalOpen}
setSettingsTab={setSettingsTab}
toggleGroup={toggleGroup}
handleDragStart={handleDragStart}
handleDragOver={handleDragOver}
handleDropOnGroup={handleDropOnGroup}
handleDropOnUngrouped={handleDropOnUngrouped}
finishRenamingGroup={finishRenamingGroup}
finishRenamingSession={finishRenamingSession}
startRenamingGroup={startRenamingGroup}
startRenamingSession={startRenamingSession}
showConfirmation={showConfirmation}
setGroups={setGroups}
createNewGroup={createNewGroup}
addNewSession={addNewSession}
/>
<ErrorBoundary>
<SessionList
theme={theme}
sessions={sessions}
groups={groups}
sortedSessions={sortedSessions}
activeSessionId={activeSessionId}
leftSidebarOpen={leftSidebarOpen}
leftSidebarWidthState={leftSidebarWidthState}
activeFocus={activeFocus}
selectedSidebarIndex={selectedSidebarIndex}
editingGroupId={editingGroupId}
editingSessionId={editingSessionId}
draggingSessionId={draggingSessionId}
anyTunnelActive={anyTunnelActive}
shortcuts={shortcuts}
setActiveFocus={setActiveFocus}
setActiveSessionId={setActiveSessionId}
setLeftSidebarOpen={setLeftSidebarOpen}
setLeftSidebarWidthState={setLeftSidebarWidthState}
setShortcutsHelpOpen={setShortcutsHelpOpen}
setSettingsModalOpen={setSettingsModalOpen}
setSettingsTab={setSettingsTab}
toggleGroup={toggleGroup}
handleDragStart={handleDragStart}
handleDragOver={handleDragOver}
handleDropOnGroup={handleDropOnGroup}
handleDropOnUngrouped={handleDropOnUngrouped}
finishRenamingGroup={finishRenamingGroup}
finishRenamingSession={finishRenamingSession}
startRenamingGroup={startRenamingGroup}
startRenamingSession={startRenamingSession}
showConfirmation={showConfirmation}
setGroups={setGroups}
createNewGroup={createNewGroup}
addNewSession={addNewSession}
/>
</ErrorBoundary>
{/* --- CENTER WORKSPACE --- */}
{!activeSession ? (
@@ -1522,11 +1525,12 @@ export default function MaestroConsole() {
</>
) : (
<>
<div
className={`flex-1 flex flex-col min-w-0 relative ${activeFocus === 'main' ? 'ring-1 ring-inset z-10' : ''}`}
style={{ backgroundColor: theme.colors.bgMain, ringColor: theme.colors.accent }}
onClick={() => setActiveFocus('main')}
>
<ErrorBoundary>
<div
className={`flex-1 flex flex-col min-w-0 relative ${activeFocus === 'main' ? 'ring-1 ring-inset z-10' : ''}`}
style={{ backgroundColor: theme.colors.bgMain, ringColor: theme.colors.accent }}
onClick={() => setActiveFocus('main')}
>
{/* Top Bar */}
<div className="h-16 border-b flex items-center justify-between px-6 shrink-0" style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgSidebar }}>
<div className="flex items-center gap-4">
@@ -1643,38 +1647,41 @@ export default function MaestroConsole() {
/>
)}
</div>
</ErrorBoundary>
{/* --- RIGHT PANEL --- */}
<RightPanel
session={activeSession}
theme={theme}
shortcuts={shortcuts}
rightPanelOpen={rightPanelOpen}
setRightPanelOpen={setRightPanelOpen}
rightPanelWidth={rightPanelWidthState}
setRightPanelWidthState={setRightPanelWidthState}
activeRightTab={activeRightTab}
setActiveRightTab={setActiveRightTab}
activeFocus={activeFocus}
setActiveFocus={setActiveFocus}
fileTreeFilter={fileTreeFilter}
setFileTreeFilter={setFileTreeFilter}
fileTreeFilterOpen={fileTreeFilterOpen}
setFileTreeFilterOpen={setFileTreeFilterOpen}
filteredFileTree={filteredFileTree}
selectedFileIndex={selectedFileIndex}
setSelectedFileIndex={setSelectedFileIndex}
previewFile={previewFile}
fileTreeContainerRef={fileTreeContainerRef}
toggleFolder={toggleFolder}
handleFileClick={handleFileClick}
expandAllFolders={expandAllFolders}
collapseAllFolders={collapseAllFolders}
updateSessionWorkingDirectory={updateSessionWorkingDirectory}
setSessions={setSessions}
updateScratchPad={updateScratchPad}
updateScratchPadState={updateScratchPadState}
/>
<ErrorBoundary>
<RightPanel
session={activeSession}
theme={theme}
shortcuts={shortcuts}
rightPanelOpen={rightPanelOpen}
setRightPanelOpen={setRightPanelOpen}
rightPanelWidth={rightPanelWidthState}
setRightPanelWidthState={setRightPanelWidthState}
activeRightTab={activeRightTab}
setActiveRightTab={setActiveRightTab}
activeFocus={activeFocus}
setActiveFocus={setActiveFocus}
fileTreeFilter={fileTreeFilter}
setFileTreeFilter={setFileTreeFilter}
fileTreeFilterOpen={fileTreeFilterOpen}
setFileTreeFilterOpen={setFileTreeFilterOpen}
filteredFileTree={filteredFileTree}
selectedFileIndex={selectedFileIndex}
setSelectedFileIndex={setSelectedFileIndex}
previewFile={previewFile}
fileTreeContainerRef={fileTreeContainerRef}
toggleFolder={toggleFolder}
handleFileClick={handleFileClick}
expandAllFolders={expandAllFolders}
collapseAllFolders={collapseAllFolders}
updateSessionWorkingDirectory={updateSessionWorkingDirectory}
setSessions={setSessions}
updateScratchPad={updateScratchPad}
updateScratchPadState={updateScratchPadState}
/>
</ErrorBoundary>
</>
)}

View File

@@ -0,0 +1,148 @@
import React, { Component, ReactNode } from 'react';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
interface Props {
children: ReactNode;
fallbackComponent?: ReactNode;
onReset?: () => void;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: React.ErrorInfo | null;
}
/**
* ErrorBoundary component catches JavaScript errors anywhere in the child component tree,
* logs those errors, and displays a fallback UI instead of crashing the entire app.
*
* Usage:
* ```tsx
* <ErrorBoundary>
* <YourComponent />
* </ErrorBoundary>
* ```
*/
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): State {
// Update state so the next render will show the fallback UI
return {
hasError: true,
error,
errorInfo: null,
};
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Log error details to console for debugging
console.error('ErrorBoundary caught an error:', error, errorInfo);
// Update state with error info
this.setState({
error,
errorInfo,
});
}
handleReset = () => {
// Reset error state
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
// Call optional reset handler
if (this.props.onReset) {
this.props.onReset();
}
};
handleReload = () => {
// Reload the entire app
window.location.reload();
};
render() {
if (this.state.hasError) {
// If a custom fallback is provided, use it
if (this.props.fallbackComponent) {
return this.props.fallbackComponent;
}
// Default error UI
return (
<div className="flex items-center justify-center min-h-screen bg-gray-900 text-gray-100 p-6">
<div className="max-w-2xl w-full bg-gray-800 rounded-lg shadow-xl p-8">
<div className="flex items-start gap-4 mb-6">
<div className="flex-shrink-0 bg-red-500/10 p-3 rounded-lg">
<AlertTriangle className="w-8 h-8 text-red-500" />
</div>
<div className="flex-1">
<h1 className="text-2xl font-bold mb-2 text-red-400">
Something went wrong
</h1>
<p className="text-gray-300">
An unexpected error occurred in the application. You can try to recover or reload the app.
</p>
</div>
</div>
{this.state.error && (
<div className="mb-6">
<h2 className="text-sm font-semibold text-gray-400 mb-2">Error Details:</h2>
<div className="bg-gray-900 rounded p-4 overflow-auto max-h-40">
<pre className="text-xs text-red-300 font-mono whitespace-pre-wrap">
{this.state.error.toString()}
</pre>
</div>
</div>
)}
{this.state.errorInfo && (
<details className="mb-6">
<summary className="text-sm font-semibold text-gray-400 cursor-pointer hover:text-gray-300">
Component Stack Trace
</summary>
<div className="bg-gray-900 rounded p-4 overflow-auto max-h-60 mt-2">
<pre className="text-xs text-gray-400 font-mono whitespace-pre-wrap">
{this.state.errorInfo.componentStack}
</pre>
</div>
</details>
)}
<div className="flex gap-3">
<button
onClick={this.handleReset}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
<RefreshCw className="w-4 h-4" />
Try Again
</button>
<button
onClick={this.handleReload}
className="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
<Home className="w-4 h-4" />
Reload App
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -1,10 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import MaestroConsole from './App';
import { ErrorBoundary } from './components/ErrorBoundary';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<MaestroConsole />
<ErrorBoundary>
<MaestroConsole />
</ErrorBoundary>
</React.StrictMode>
);