feat(FN-1286): add ErrorBoundary component with page-level, modal, and root-level wrapping

- Create ErrorBoundary and ModalErrorBoundary components with recovery actions (retry, reload)
- Add error boundary CSS styles with fallback UI styling
- Wrap main dashboard pages (Board, List, Settings, Agents) with PageErrorBoundary in App.tsx
- Wrap all key modals (task detail, planning, subtask breakdown, mission) with ModalErrorBoundary in AppModals.tsx
- Wrap entire App with RootErrorBoundary in main.tsx as final safety net
- Add 11 comprehensive ErrorBoundary tests covering all rendering and error scenarios
This commit is contained in:
gsxdsm
2026-04-08 20:04:23 -07:00
parent 35abe67684
commit ed77561c44
7 changed files with 538 additions and 143 deletions

View File

@@ -7,6 +7,7 @@ import { ProjectOverview } from "./components/ProjectOverview";
import { AgentsView } from "./components/AgentsView";
import { MissionManager } from "./components/MissionManager";
import { NodesView } from "./components/NodesView";
import { PageErrorBoundary } from "./components/ErrorBoundary";
import { AppModals } from "./components/AppModals";
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
@@ -244,107 +245,121 @@ function AppInner() {
<div className="nodes-management-overlay__header">
<button className="btn btn-sm" onClick={() => setNodesOpen(false)}>Close Nodes</button>
</div>
<NodesView addToast={addToast} />
<PageErrorBoundary>
<NodesView addToast={addToast} />
</PageErrorBoundary>
</div>
);
}
if (viewMode === "overview") {
return (
<ProjectOverview
projects={projects}
loading={projectsLoading}
onSelectProject={handleSelectProject}
onAddProject={handleAddProject}
onPauseProject={handlePauseProject}
onResumeProject={handleResumeProject}
onRemoveProject={handleRemoveProject}
nodes={nodes}
/>
<PageErrorBoundary>
<ProjectOverview
projects={projects}
loading={projectsLoading}
onSelectProject={handleSelectProject}
onAddProject={handleAddProject}
onPauseProject={handlePauseProject}
onResumeProject={handleResumeProject}
onRemoveProject={handleRemoveProject}
nodes={nodes}
/>
</PageErrorBoundary>
);
}
// Project view
if (taskView === "missions") {
return (
<MissionManager
isInline={true}
isOpen={true}
onClose={() => {
setMissionTargetId(undefined);
setMissionResumeSessionId(undefined);
handleChangeTaskView("board");
}}
addToast={addToast}
projectId={currentProject?.id}
onSelectTask={(taskId) => {
const task = tasks.find((t) => t.id === taskId);
if (task) modalManager.openDetailTask(task as TaskDetail);
}}
availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))}
resumeSessionId={missionResumeSessionId}
targetMissionId={missionTargetId}
/>
<PageErrorBoundary>
<MissionManager
isInline={true}
isOpen={true}
onClose={() => {
setMissionTargetId(undefined);
setMissionResumeSessionId(undefined);
handleChangeTaskView("board");
}}
addToast={addToast}
projectId={currentProject?.id}
onSelectTask={(taskId) => {
const task = tasks.find((t) => t.id === taskId);
if (task) modalManager.openDetailTask(task as TaskDetail);
}}
availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))}
resumeSessionId={missionResumeSessionId}
targetMissionId={missionTargetId}
/>
</PageErrorBoundary>
);
}
if (taskView === "agents") {
return <AgentsView addToast={addToast} projectId={currentProject?.id} />;
return (
<PageErrorBoundary>
<AgentsView addToast={addToast} projectId={currentProject?.id} />
</PageErrorBoundary>
);
}
if (taskView === "board") {
return (
<Board
tasks={tasks}
projectId={currentProject?.id}
maxConcurrent={maxConcurrent}
onMoveTask={moveTask}
onOpenDetail={modalManager.openDetailTask}
addToast={addToast}
onQuickCreate={handleBoardQuickCreate}
onNewTask={modalManager.openNewTask}
onPlanningMode={modalManager.openPlanningWithInitialPlan}
onSubtaskBreakdown={modalManager.openSubtaskBreakdown}
autoMerge={autoMerge}
onToggleAutoMerge={toggleAutoMerge}
globalPaused={globalPaused}
onUpdateTask={updateTask}
onArchiveTask={archiveTask}
onUnarchiveTask={unarchiveTask}
onArchiveAllDone={archiveAllDone}
searchQuery={searchQuery}
availableModels={availableModels}
onOpenDetailWithTab={handleOpenDetailWithTab}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={handleOpenMission}
/>
<PageErrorBoundary>
<Board
tasks={tasks}
projectId={currentProject?.id}
maxConcurrent={maxConcurrent}
onMoveTask={moveTask}
onOpenDetail={modalManager.openDetailTask}
addToast={addToast}
onQuickCreate={handleBoardQuickCreate}
onNewTask={modalManager.openNewTask}
onPlanningMode={modalManager.openPlanningWithInitialPlan}
onSubtaskBreakdown={modalManager.openSubtaskBreakdown}
autoMerge={autoMerge}
onToggleAutoMerge={toggleAutoMerge}
globalPaused={globalPaused}
onUpdateTask={updateTask}
onArchiveTask={archiveTask}
onUnarchiveTask={unarchiveTask}
onArchiveAllDone={archiveAllDone}
searchQuery={searchQuery}
availableModels={availableModels}
onOpenDetailWithTab={handleOpenDetailWithTab}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={handleOpenMission}
/>
</PageErrorBoundary>
);
}
// List view
return (
<ListView
tasks={tasks}
projectId={currentProject?.id}
onMoveTask={moveTask}
onOpenDetail={modalManager.openDetailTask}
addToast={addToast}
globalPaused={globalPaused}
onNewTask={modalManager.openNewTask}
onQuickCreate={handleBoardQuickCreate}
onPlanningMode={modalManager.openPlanningWithInitialPlan}
onSubtaskBreakdown={modalManager.openSubtaskBreakdown}
availableModels={availableModels}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
/>
<PageErrorBoundary>
<ListView
tasks={tasks}
projectId={currentProject?.id}
onMoveTask={moveTask}
onOpenDetail={modalManager.openDetailTask}
addToast={addToast}
globalPaused={globalPaused}
onNewTask={modalManager.openNewTask}
onQuickCreate={handleBoardQuickCreate}
onPlanningMode={modalManager.openPlanningWithInitialPlan}
onSubtaskBreakdown={modalManager.openSubtaskBreakdown}
availableModels={availableModels}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
/>
</PageErrorBoundary>
);
};

View File

@@ -4,6 +4,7 @@ import type { UseProjectActionsResult } from "../hooks/useProjectActions";
import type { ModalManager } from "../hooks/useModalManager";
import type { UseTaskHandlersResult } from "../hooks/useTaskHandlers";
import type { Toast, ToastType } from "../hooks/useToast";
import { ModalErrorBoundary } from "./ErrorBoundary";
import { TaskDetailModal } from "./TaskDetailModal";
import { SettingsModal } from "./SettingsModal";
import { GitHubImportModal } from "./GitHubImportModal";
@@ -72,35 +73,39 @@ export function AppModals({
return (
<>
{modalManager.detailTask && (
<TaskDetailModal
task={modalManager.detailTask}
projectId={projectId}
tasks={tasks}
onClose={deepLink.handleDetailClose}
onOpenDetail={modalManager.openDetailTask}
onMoveTask={taskOperations.moveTask}
onDeleteTask={taskOperations.deleteTask}
onMergeTask={taskOperations.mergeTask}
onRetryTask={taskOperations.retryTask}
onDuplicateTask={taskOperations.duplicateTask}
onTaskUpdated={modalManager.updateDetailTask}
addToast={addToast}
githubTokenConfigured={settings.githubTokenConfigured}
initialTab={modalManager.detailTaskInitialTab}
/>
<ModalErrorBoundary>
<TaskDetailModal
task={modalManager.detailTask}
projectId={projectId}
tasks={tasks}
onClose={deepLink.handleDetailClose}
onOpenDetail={modalManager.openDetailTask}
onMoveTask={taskOperations.moveTask}
onDeleteTask={taskOperations.deleteTask}
onMergeTask={taskOperations.mergeTask}
onRetryTask={taskOperations.retryTask}
onDuplicateTask={taskOperations.duplicateTask}
onTaskUpdated={modalManager.updateDetailTask}
addToast={addToast}
githubTokenConfigured={settings.githubTokenConfigured}
initialTab={modalManager.detailTaskInitialTab}
/>
</ModalErrorBoundary>
)}
{modalManager.settingsOpen && (
<SettingsModal
onClose={modalManager.closeSettings}
addToast={addToast}
initialSection={modalManager.settingsInitialSection}
projectId={projectId}
themeMode={settings.themeMode}
colorTheme={settings.colorTheme}
onThemeModeChange={settings.setThemeMode}
onColorThemeChange={settings.setColorTheme}
/>
<ModalErrorBoundary>
<SettingsModal
onClose={modalManager.closeSettings}
addToast={addToast}
initialSection={modalManager.settingsInitialSection}
projectId={projectId}
themeMode={settings.themeMode}
colorTheme={settings.colorTheme}
onThemeModeChange={settings.setThemeMode}
onColorThemeChange={settings.setColorTheme}
/>
</ModalErrorBoundary>
)}
<GitHubImportModal
@@ -110,25 +115,29 @@ export function AppModals({
tasks={tasks}
/>
<PlanningModeModal
isOpen={modalManager.isPlanningOpen}
onClose={modalManager.closePlanning}
onTaskCreated={taskHandlers.handlePlanningTaskCreated}
onTasksCreated={taskHandlers.handlePlanningTasksCreated}
tasks={tasks}
initialPlan={modalManager.planningInitialPlan ?? undefined}
projectId={projectId}
resumeSessionId={modalManager.planningResumeSessionId}
/>
<ModalErrorBoundary>
<PlanningModeModal
isOpen={modalManager.isPlanningOpen}
onClose={modalManager.closePlanning}
onTaskCreated={taskHandlers.handlePlanningTaskCreated}
onTasksCreated={taskHandlers.handlePlanningTasksCreated}
tasks={tasks}
initialPlan={modalManager.planningInitialPlan ?? undefined}
projectId={projectId}
resumeSessionId={modalManager.planningResumeSessionId}
/>
</ModalErrorBoundary>
<SubtaskBreakdownModal
isOpen={modalManager.isSubtaskOpen}
onClose={modalManager.closeSubtask}
initialDescription={modalManager.subtaskInitialDescription ?? ""}
onTasksCreated={taskHandlers.handleSubtaskTasksCreated}
projectId={projectId}
resumeSessionId={modalManager.subtaskResumeSessionId}
/>
<ModalErrorBoundary>
<SubtaskBreakdownModal
isOpen={modalManager.isSubtaskOpen}
onClose={modalManager.closeSubtask}
initialDescription={modalManager.subtaskInitialDescription ?? ""}
onTasksCreated={taskHandlers.handleSubtaskTasksCreated}
projectId={projectId}
resumeSessionId={modalManager.subtaskResumeSessionId}
/>
</ModalErrorBoundary>
<TerminalModal
isOpen={modalManager.terminalOpen}
@@ -167,16 +176,18 @@ export function AppModals({
/>
)}
<NewTaskModal
isOpen={modalManager.newTaskModalOpen}
onClose={modalManager.closeNewTask}
tasks={tasks}
onCreateTask={taskHandlers.handleModalCreate}
addToast={addToast}
projectId={projectId}
onPlanningMode={modalManager.openPlanningWithInitialPlan}
onSubtaskBreakdown={modalManager.openSubtaskBreakdown}
/>
<ModalErrorBoundary>
<NewTaskModal
isOpen={modalManager.newTaskModalOpen}
onClose={modalManager.closeNewTask}
tasks={tasks}
onCreateTask={taskHandlers.handleModalCreate}
addToast={addToast}
projectId={projectId}
onPlanningMode={modalManager.openPlanningWithInitialPlan}
onSubtaskBreakdown={modalManager.openSubtaskBreakdown}
/>
</ModalErrorBoundary>
<ActivityLogModal
isOpen={modalManager.activityLogOpen}
@@ -193,19 +204,23 @@ export function AppModals({
}}
/>
<GitManagerModal
isOpen={modalManager.gitManagerOpen}
onClose={modalManager.closeGitManager}
tasks={tasks}
addToast={addToast}
/>
<ModalErrorBoundary>
<GitManagerModal
isOpen={modalManager.gitManagerOpen}
onClose={modalManager.closeGitManager}
tasks={tasks}
addToast={addToast}
/>
</ModalErrorBoundary>
<WorkflowStepManager
isOpen={modalManager.workflowStepsOpen}
onClose={modalManager.closeWorkflowSteps}
addToast={addToast}
projectId={projectId}
/>
<ModalErrorBoundary>
<WorkflowStepManager
isOpen={modalManager.workflowStepsOpen}
onClose={modalManager.closeWorkflowSteps}
addToast={addToast}
projectId={projectId}
/>
</ModalErrorBoundary>
<AgentListModal
isOpen={modalManager.agentsOpen}

View File

@@ -0,0 +1,94 @@
import { Component, type ReactNode, type ErrorInfo } from "react";
import { AlertTriangle } from "lucide-react";
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
level?: "page" | "modal" | "root";
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error("[ErrorBoundary]", error, errorInfo);
this.props.onError?.(error, errorInfo);
}
resetErrorBoundary = (): void => {
this.setState({ hasError: false, error: null });
};
render(): ReactNode {
if (!this.state.hasError) {
return this.props.children;
}
if (this.props.fallback) {
return this.props.fallback;
}
const level = this.props.level ?? "page";
const isModal = level === "modal";
const title = isModal
? "This section encountered an error"
: "Something went wrong";
return (
<div className={`error-boundary error-boundary--${level}`}>
<div className="error-boundary__icon">
<AlertTriangle size={40} />
</div>
<div className="error-boundary__title">{title}</div>
{this.state.error && (
<pre className="error-boundary__message">{this.state.error.message}</pre>
)}
<div className="error-boundary__actions">
<button className="btn btn-primary" onClick={this.resetErrorBoundary}>
Retry
</button>
<button className="btn" onClick={() => window.location.reload()}>
Reload page
</button>
</div>
</div>
);
}
}
export function PageErrorBoundary({ children, onError }: { children: ReactNode; onError?: (error: Error, errorInfo: ErrorInfo) => void }) {
return (
<ErrorBoundary level="page" onError={onError}>
{children}
</ErrorBoundary>
);
}
export function ModalErrorBoundary({ children, onError }: { children: ReactNode; onError?: (error: Error, errorInfo: ErrorInfo) => void }) {
return (
<ErrorBoundary level="modal" onError={onError}>
{children}
</ErrorBoundary>
);
}
export function RootErrorBoundary({ children, onError }: { children: ReactNode; onError?: (error: Error, errorInfo: ErrorInfo) => void }) {
return (
<ErrorBoundary level="root" onError={onError}>
{children}
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,191 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { act } from "react";
import {
ErrorBoundary,
PageErrorBoundary,
ModalErrorBoundary,
RootErrorBoundary,
} from "../ErrorBoundary";
// Suppress console.error noise from React error boundary logging
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
});
// ---- Throwing child helper ----
function ThrowingChild({ shouldThrow }: { shouldThrow?: boolean }) {
if (shouldThrow !== false) {
throw new Error("Test render error");
}
return <div data-testid="child-ok">OK</div>;
}
// ---- Tests ----
describe("ErrorBoundary", () => {
it("catches child render error and shows default fallback", () => {
render(
<ErrorBoundary>
<ThrowingChild />
</ErrorBoundary>,
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
expect(screen.getByText("Test render error")).toBeInTheDocument();
expect(screen.getByText("Retry")).toBeInTheDocument();
expect(screen.getByText("Reload page")).toBeInTheDocument();
});
it("calls onError callback", () => {
const onError = vi.fn();
render(
<ErrorBoundary onError={onError}>
<ThrowingChild />
</ErrorBoundary>,
);
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ componentStack: expect.any(String) }),
);
});
it("renders custom fallback when provided", () => {
render(
<ErrorBoundary fallback={<div data-testid="custom">Custom</div>}>
<ThrowingChild />
</ErrorBoundary>,
);
expect(screen.getByTestId("custom")).toBeInTheDocument();
expect(screen.getByText("Custom")).toBeInTheDocument();
// Default fallback should NOT appear
expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
});
it("resetErrorBoundary recovers from error", () => {
let shouldThrow = true;
function ConditionalChild() {
if (shouldThrow) {
throw new Error("Test render error");
}
return <div data-testid="child-ok">OK</div>;
}
render(
<ErrorBoundary>
<ConditionalChild />
</ErrorBoundary>,
);
// Error fallback should be visible
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
expect(screen.queryByTestId("child-ok")).not.toBeInTheDocument();
// Fix the error source before retrying
shouldThrow = false;
// Click Retry button
act(() => {
fireEvent.click(screen.getByText("Retry"));
});
// Child should now render successfully
expect(screen.getByTestId("child-ok")).toBeInTheDocument();
expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
});
it('level prop applies correct CSS class for "page"', () => {
render(
<ErrorBoundary level="page">
<ThrowingChild />
</ErrorBoundary>,
);
const container = screen.getByText("Something went wrong").closest(".error-boundary");
expect(container).toHaveClass("error-boundary--page");
});
it('level prop applies correct CSS class for "modal"', () => {
render(
<ErrorBoundary level="modal">
<ThrowingChild />
</ErrorBoundary>,
);
const container = screen.getByText("This section encountered an error").closest(".error-boundary");
expect(container).toHaveClass("error-boundary--modal");
});
it('level prop applies correct CSS class for "root"', () => {
render(
<ErrorBoundary level="root">
<ThrowingChild />
</ErrorBoundary>,
);
const container = screen.getByText("Something went wrong").closest(".error-boundary");
expect(container).toHaveClass("error-boundary--root");
});
it("renders children normally when no error occurs", () => {
render(
<ErrorBoundary>
<ThrowingChild shouldThrow={false} />
</ErrorBoundary>,
);
expect(screen.getByTestId("child-ok")).toBeInTheDocument();
expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
});
});
describe("PageErrorBoundary", () => {
it("renders with page level", () => {
render(
<PageErrorBoundary>
<ThrowingChild />
</PageErrorBoundary>,
);
const container = screen.getByText("Something went wrong").closest(".error-boundary");
expect(container).toHaveClass("error-boundary--page");
});
});
describe("ModalErrorBoundary", () => {
it("renders with modal level", () => {
render(
<ModalErrorBoundary>
<ThrowingChild />
</ModalErrorBoundary>,
);
const container = screen.getByText("This section encountered an error").closest(".error-boundary");
expect(container).toHaveClass("error-boundary--modal");
});
});
describe("RootErrorBoundary", () => {
it("renders with root level", () => {
render(
<RootErrorBoundary>
<ThrowingChild />
</RootErrorBoundary>,
);
const container = screen.getByText("Something went wrong").closest(".error-boundary");
expect(container).toHaveClass("error-boundary--root");
});
});

View File

@@ -1,11 +1,14 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RootErrorBoundary } from "./components/ErrorBoundary";
import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<RootErrorBoundary>
<App />
</RootErrorBoundary>
</StrictMode>,
);

View File

@@ -26690,3 +26690,75 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
min-height: 44px;
}
}
/* === Error Boundary === */
.error-boundary {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-xl);
background: var(--surface);
border-radius: var(--radius-lg);
border: 1px solid var(--color-error);
max-width: 480px;
margin: auto;
text-align: center;
color: var(--text-primary);
}
.error-boundary--root {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-boundary--page {
padding: var(--space-2xl) var(--space-xl);
}
.error-boundary--modal {
padding: var(--space-xl);
}
.error-boundary__icon {
color: var(--color-error);
margin-bottom: var(--space-md);
}
.error-boundary__icon svg {
width: 40px;
height: 40px;
}
.error-boundary__title {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: var(--space-sm);
}
.error-boundary__message {
font-size: 0.85rem;
color: var(--text-secondary);
background: var(--bg);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
margin: var(--space-md) 0;
overflow-x: auto;
text-align: left;
font-family: var(--font-mono);
white-space: pre-wrap;
word-break: break-word;
max-height: 120px;
overflow-y: auto;
width: 100%;
box-sizing: border-box;
}
.error-boundary__actions {
display: flex;
gap: var(--space-sm);
justify-content: center;
margin-top: var(--space-lg);
}