The repo had no error boundary anywhere, so a single malformed stored layout took the whole app down and the only recovery was a psql session. Two boundaries, because "recover" means different things: - around the routed screen in DashboardLayout, with the dock and header deliberately left outside so navigating away is itself a way out, plus a two-click reset of every `screens/*` layout for when it fails again in the same place. Dashboards are not touched — they are user-created and hold content. - around each panel app in PanelSlot, so one bad app leaves the rest of the workspace running. Its recovery is "clear this panel", offered only when the layout is the user's to edit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
84 lines
3.5 KiB
TypeScript
84 lines
3.5 KiB
TypeScript
import { useState } from 'react';
|
|
import { Link } from 'react-router';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { AlertTriangle } from 'lucide-react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
type ScreenErrorFallbackProps = { error: Error; reset: () => void };
|
|
|
|
/**
|
|
* What the owner sees instead of a white screen.
|
|
*
|
|
* The recovery that matters is the last one. Every screen in the app renders a Workspace whose layout is
|
|
* a jsonb blob written by the client and never validated on the way back in, so a malformed one throws
|
|
* during render — and until this existed, the only way out was a psql session. Dashboards are left alone:
|
|
* they are user-created and hold content, whereas a screen's layout is just where its panes sit.
|
|
*/
|
|
export const ScreenErrorFallback = ({ error, reset }: ScreenErrorFallbackProps) => {
|
|
const client = useClient();
|
|
const queryClient = useQueryClient();
|
|
const [confirming, setConfirming] = useState(false);
|
|
const [resetting, setResetting] = useState(false);
|
|
|
|
const resetScreenLayouts = async () => {
|
|
if (!confirming) return setConfirming(true);
|
|
setResetting(true);
|
|
try {
|
|
const state = await client.get<Record<string, unknown>>('/dashboards');
|
|
const patch = Object.fromEntries(
|
|
Object.keys(state)
|
|
.filter((key) => key.startsWith('screens/'))
|
|
.map((key) => [key, null]),
|
|
);
|
|
if (Object.keys(patch).length > 0) await client.patch('/dashboards', patch);
|
|
queryClient.removeQueries({ queryKey: ['DASHBOARD_STATE'] });
|
|
window.location.reload();
|
|
} catch (err) {
|
|
console.error('[screen-error] could not reset screen layouts', err);
|
|
setResetting(false);
|
|
setConfirming(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="mx-auto flex h-full max-w-2xl flex-col justify-center gap-6 p-6">
|
|
<div className="flex items-start gap-3">
|
|
<AlertTriangle className="mt-1 size-6 shrink-0 text-amber-500" />
|
|
<div className="space-y-1">
|
|
<h1 className="text-xl font-semibold">This screen failed to render</h1>
|
|
<p className="text-muted-foreground text-sm">
|
|
Nothing was lost — this is a display failure, not a data one. Agent transcripts, jobs and files are
|
|
untouched.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<details className="bg-muted/40 rounded-md border p-3 text-sm">
|
|
<summary className="cursor-pointer select-none font-medium">{error.message || 'Unknown error'}</summary>
|
|
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap text-xs opacity-70">{error.stack}</pre>
|
|
</details>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button onClick={reset}>Try again</Button>
|
|
<Button variant="secondary" onClick={() => window.location.reload()}>
|
|
Reload
|
|
</Button>
|
|
<Button variant="secondary" asChild>
|
|
<Link to="/">Go home</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-2 border-t pt-4">
|
|
<p className="text-muted-foreground text-sm">
|
|
If it fails again in the same place, a saved panel layout is the usual cause. Resetting puts every screen's
|
|
panes back to their defaults. <strong>Your dashboards are not touched.</strong>
|
|
</p>
|
|
<Button variant={confirming ? 'destructive' : 'outline'} disabled={resetting} onClick={resetScreenLayouts}>
|
|
{resetting ? 'Resetting…' : confirming ? 'Yes — reset every screen layout' : 'Reset screen layouts'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|