Merge pull request #49 from cyxer000/feat/error-boundary

Error Boundary for crash isolation. Minor fixes (i18n, icon, h-5 conflict) will follow.
This commit is contained in:
歸藏 authored and GitHub committed 2026-02-26 10:23:24 +08:00
commit cd807ef175
2 files changed
+155 -10

No files matched your search

+21 -10
View File
@@ -11,6 +11,7 @@ import { UpdateDialog } from "./UpdateDialog";
import { DocPreview } from "./DocPreview";
import { PanelContext, type PanelContent, type PreviewViewMode } from "@/hooks/usePanel";
import { UpdateContext, type UpdateInfo } from "@/hooks/useUpdate";
import { ErrorBoundary } from "./ErrorBoundary";
const CHATLIST_MIN = 180;
const CHATLIST_MAX = 400;
@@ -253,7 +254,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
hasUpdate={updateInfo?.updateAvailable ?? false}
skipPermissionsActive={skipPermissionsActive}
/>
<ChatListPanel open={chatListOpen} width={chatListWidth} />
<ErrorBoundary>
<ChatListPanel open={chatListOpen} width={chatListWidth} />
</ErrorBoundary>
{chatListOpen && (
<ResizeHandle side="left" onResize={handleChatListResize} onResizeEnd={handleChatListResizeEnd} />
)}
@@ -263,24 +266,32 @@ export function AppShell({ children }: { children: React.ReactNode }) {
className="h-5 w-full shrink-0"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
/>
<main className="relative flex-1 overflow-hidden">{children}</main>
<main className="relative flex-1 overflow-hidden">
<ErrorBoundary>{children}</ErrorBoundary>
</main>
</div>
{isChatDetailRoute && previewFile && (
<ResizeHandle side="right" onResize={handleDocPreviewResize} onResizeEnd={handleDocPreviewResizeEnd} />
)}
{isChatDetailRoute && previewFile && (
<DocPreview
filePath={previewFile}
viewMode={previewViewMode}
onViewModeChange={setPreviewViewMode}
onClose={() => setPreviewFile(null)}
width={docPreviewWidth}
/>
<ErrorBoundary>
<DocPreview
filePath={previewFile}
viewMode={previewViewMode}
onViewModeChange={setPreviewViewMode}
onClose={() => setPreviewFile(null)}
width={docPreviewWidth}
/>
</ErrorBoundary>
)}
{isChatDetailRoute && panelOpen && (
<ResizeHandle side="right" onResize={handleRightPanelResize} onResizeEnd={handleRightPanelResizeEnd} />
)}
{isChatDetailRoute && <RightPanel width={rightPanelWidth} />}
{isChatDetailRoute && (
<ErrorBoundary>
<RightPanel width={rightPanelWidth} />
</ErrorBoundary>
)}
</div>
<UpdateDialog />
</TooltipProvider>
+134
View File
@@ -0,0 +1,134 @@
"use client";
import React from "react";
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
showDetails: boolean;
}
export class ErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null, showDetails: false };
}
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("[ErrorBoundary] Uncaught error:", error);
console.error("[ErrorBoundary] Component stack:", errorInfo.componentStack);
}
handleReset = () => {
this.setState({ hasError: false, error: null, showDetails: false });
};
handleReload = () => {
window.location.reload();
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex h-full w-full items-center justify-center bg-background p-8">
<div className="flex max-w-md flex-col items-center gap-4 text-center">
{/* Error icon */}
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<h2 className="text-lg font-semibold text-foreground">
Something went wrong
</h2>
<p className="text-sm text-muted-foreground">
An unexpected error occurred. You can try again or reload the app.
</p>
{/* Expandable error details */}
{this.state.error && (
<button
onClick={() =>
this.setState((s) => ({ showDetails: !s.showDetails }))
}
className="text-xs text-muted-foreground underline hover:text-foreground"
>
{this.state.showDetails ? "Hide details" : "Show details"}
</button>
)}
{this.state.showDetails && this.state.error && (
<pre className="max-h-40 w-full overflow-auto rounded-md border border-border/50 bg-muted/30 p-3 text-left text-xs text-muted-foreground">
{this.state.error.message}
{this.state.error.stack && `\n\n${this.state.error.stack}`}
</pre>
)}
{/* Action buttons */}
<div className="flex gap-2">
<button
onClick={this.handleReset}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Try Again
</button>
<button
onClick={this.handleReload}
className="rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
>
Reload App
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
/** Convenience HOC to wrap any component with an ErrorBoundary */
export function withErrorBoundary<P extends object>(
Component: React.ComponentType<P>,
fallback?: React.ReactNode
) {
const displayName = Component.displayName || Component.name || "Component";
const Wrapped = (props: P) => (
<ErrorBoundary fallback={fallback}>
<Component {...props} />
</ErrorBoundary>
);
Wrapped.displayName = `withErrorBoundary(${displayName})`;
return Wrapped;
}