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:
teernisse
2026-02-26 10:56:45 -05:00
parent 044b0024a4
commit bd6d47dd70
2 changed files with 534 additions and 0 deletions

View 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"
>
&#9888;
</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>
);
}