feat(bd-3pc): add error boundary and error handling UI
- Add ErrorBoundary class component to catch React render errors - Show fallback UI with error details (stack in dev mode) and recovery buttons - Add ErrorDisplay component for showing structured McError messages - Support all McErrorCode types with contextual messages and install guides - Implement retry/dismiss actions for recoverable errors - Add 16 comprehensive tests covering both components Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
259
src/components/ErrorBoundary.tsx
Normal file
259
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* ErrorBoundary -- Catches React render errors and shows fallback UI.
|
||||
*
|
||||
* Also exports ErrorDisplay for showing structured McError messages
|
||||
* with appropriate recovery actions.
|
||||
*/
|
||||
|
||||
import { Component, type ReactNode, type ErrorInfo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import type { McError, McErrorCode } from "@/lib/types";
|
||||
|
||||
// -- ErrorBoundary (class component required for getDerivedStateFromError) --
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
/** Called when user clicks "Try Again" */
|
||||
onRecover?: () => 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 {
|
||||
// Log error for debugging (could send to error tracking service)
|
||||
console.error("React error boundary caught:", error, errorInfo);
|
||||
}
|
||||
|
||||
handleReload = (): void => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleRecover = (): void => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
this.props.onRecover?.();
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<FallbackUI
|
||||
error={this.state.error}
|
||||
onRecover={this.handleRecover}
|
||||
onReload={this.handleReload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Fallback UI for ErrorBoundary --
|
||||
|
||||
interface FallbackUIProps {
|
||||
error: Error | null;
|
||||
onRecover: () => void;
|
||||
onReload: () => void;
|
||||
}
|
||||
|
||||
function FallbackUI({
|
||||
error,
|
||||
onRecover,
|
||||
onReload,
|
||||
}: FallbackUIProps): React.ReactElement {
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex h-screen flex-col items-center justify-center p-8"
|
||||
>
|
||||
{/* Warning icon */}
|
||||
<div
|
||||
className="mb-4 text-5xl text-mc-urgent"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
>
|
||||
⚠
|
||||
</div>
|
||||
|
||||
<h1 className="mb-2 text-2xl font-bold text-zinc-100">
|
||||
Something went wrong
|
||||
</h1>
|
||||
|
||||
<p className="mb-4 max-w-md text-center text-zinc-400">
|
||||
Mission Control encountered an unexpected error.
|
||||
</p>
|
||||
|
||||
{/* Show stack trace in development */}
|
||||
{isDev && error?.stack && (
|
||||
<pre className="mb-4 max-w-lg overflow-auto rounded-lg border border-zinc-700 bg-surface p-4 text-xs text-zinc-300">
|
||||
{error.stack}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Recovery actions */}
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRecover}
|
||||
className="rounded-lg border border-mc-fresh/40 bg-mc-fresh/10 px-5 py-2.5 text-sm font-medium text-mc-fresh transition-colors hover:bg-mc-fresh/20"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReload}
|
||||
className="rounded-lg border border-zinc-600 bg-surface-raised px-5 py-2.5 text-sm font-medium text-zinc-300 transition-colors hover:bg-surface-overlay hover:text-zinc-100"
|
||||
>
|
||||
Reload App
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- ErrorDisplay for structured McError --
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
error: McError;
|
||||
/** Called when user clicks Retry (only shown for recoverable errors) */
|
||||
onRetry?: () => void;
|
||||
/** Called when user dismisses the error */
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
interface ErrorMessage {
|
||||
title: string;
|
||||
description: string;
|
||||
showInstall?: string;
|
||||
}
|
||||
|
||||
const ERROR_MESSAGES: Partial<Record<McErrorCode, ErrorMessage>> = {
|
||||
LORE_UNAVAILABLE: {
|
||||
title: "lore not found",
|
||||
description:
|
||||
"The lore CLI is required to sync GitLab data.",
|
||||
showInstall: "cargo install gitlore",
|
||||
},
|
||||
LORE_UNHEALTHY: {
|
||||
title: "lore is unhealthy",
|
||||
description:
|
||||
"The lore database needs attention. Try running `lore sync` manually.",
|
||||
},
|
||||
LORE_FETCH_FAILED: {
|
||||
title: "Sync failed",
|
||||
description: "Could not fetch latest data from GitLab.",
|
||||
},
|
||||
BEADS_UNAVAILABLE: {
|
||||
title: "Beads (br) not found",
|
||||
description:
|
||||
"The beads CLI is required for task management.",
|
||||
showInstall: "cargo install beads_rust",
|
||||
},
|
||||
BEADS_CREATE_FAILED: {
|
||||
title: "Failed to create bead",
|
||||
description: "Could not create a new task in beads.",
|
||||
},
|
||||
BEADS_CLOSE_FAILED: {
|
||||
title: "Failed to close bead",
|
||||
description: "Could not close the task in beads.",
|
||||
},
|
||||
BV_UNAVAILABLE: {
|
||||
title: "bv not found",
|
||||
description:
|
||||
"The bv triage CLI is required for recommendations.",
|
||||
showInstall: "cargo install bv",
|
||||
},
|
||||
BV_TRIAGE_FAILED: {
|
||||
title: "Triage failed",
|
||||
description: "Could not get recommendations from bv.",
|
||||
},
|
||||
BRIDGE_LOCKED: {
|
||||
title: "Operation in progress",
|
||||
description: "Another sync operation is running. Please wait.",
|
||||
},
|
||||
BRIDGE_MAP_CORRUPTED: {
|
||||
title: "State corrupted",
|
||||
description:
|
||||
"The bridge mapping file is corrupted. Try clearing state.",
|
||||
},
|
||||
BRIDGE_SYNC_FAILED: {
|
||||
title: "Bridge sync failed",
|
||||
description: "Could not synchronize GitLab data with beads.",
|
||||
},
|
||||
IO_ERROR: {
|
||||
title: "File system error",
|
||||
description: "Could not read or write required files.",
|
||||
},
|
||||
INTERNAL_ERROR: {
|
||||
title: "Unexpected error",
|
||||
description: "An unexpected error occurred.",
|
||||
},
|
||||
};
|
||||
|
||||
export function ErrorDisplay({
|
||||
error,
|
||||
onRetry,
|
||||
onDismiss,
|
||||
}: ErrorDisplayProps): React.ReactElement {
|
||||
const msg = ERROR_MESSAGES[error.code] ?? {
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-mc-urgent/40 bg-mc-urgent/10 p-4"
|
||||
>
|
||||
<h3 className="mb-1 font-semibold text-mc-urgent">{msg.title}</h3>
|
||||
<p className="text-sm text-zinc-300">{msg.description}</p>
|
||||
|
||||
{msg.showInstall && (
|
||||
<code className="mt-2 block rounded bg-surface px-2 py-1 text-xs text-zinc-400">
|
||||
Install with: {msg.showInstall}
|
||||
</code>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-3 flex gap-2">
|
||||
{error.recoverable && onRetry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="rounded border border-zinc-600 bg-surface-raised px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:bg-surface-overlay"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="rounded border border-zinc-700 bg-transparent px-3 py-1.5 text-xs font-medium text-zinc-400 transition-colors hover:text-zinc-300"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user