diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx index 90ba0d36..55713925 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx @@ -1,6 +1,9 @@ import { useMemo, useRef } from 'react'; +import { useLocation } from 'react-router'; import { useDock, MusicPlayerHost } from 'officerdev'; import { useCapabilities } from 'hooks/useCapabilities'; +import { ErrorBoundary } from '@/components/ErrorBoundary'; +import { ScreenErrorFallback } from './ScreenErrorFallback'; import { Background } from './Background'; import { Header } from './Header'; import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock'; @@ -19,6 +22,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { const permitted = useMemo(() => ALL_DOCK_ITEMS.filter((item) => canVisit(item.to)), [canVisit]); const { items: visibleItems } = useDock(permitted, DEFAULT_DOCK_PATHS); const isTouch = useIsTouch(); + const { pathname } = useLocation(); usePageTitleSync(); // The content region shrinks when the (in-flow) music dock takes its space; the nav dock measures its // reveal boundary from this element, so it always sits just above whatever's at the bottom. @@ -35,7 +39,16 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{!isTouch && } -
{children}
+
+ {/* Chrome stays outside: whatever broke, the dock and the header still navigate you off it. + Keyed on the pathname so leaving a broken screen is itself a recovery. */} + } + > + {children} + +
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/ScreenErrorFallback.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/ScreenErrorFallback.tsx new file mode 100644 index 00000000..80c46fd6 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Layout/ScreenErrorFallback.tsx @@ -0,0 +1,83 @@ +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>('/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 ( +
+
+ +
+

This screen failed to render

+

+ Nothing was lost — this is a display failure, not a data one. Agent transcripts, jobs and files are + untouched. +

+
+
+ +
+ {error.message || 'Unknown error'} +
{error.stack}
+
+ +
+ + + +
+ +
+

+ 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. Your dashboards are not touched. +

+ +
+
+ ); +}; diff --git a/src/workspaces/components/ErrorBoundary.tsx b/src/workspaces/components/ErrorBoundary.tsx new file mode 100644 index 00000000..088d02d0 --- /dev/null +++ b/src/workspaces/components/ErrorBoundary.tsx @@ -0,0 +1,55 @@ +import { Component } from 'react'; +import type { ErrorInfo, ReactNode } from 'react'; + +type FallbackArgs = { error: Error; reset: () => void }; + +type ErrorBoundaryProps = { + children: ReactNode; + /** Rendered in place of the children once something below has thrown. */ + fallback: (args: FallbackArgs) => ReactNode; + /** + * Clear the error whenever any of these changes — pass the pathname to recover on navigation, or the + * id of the thing being rendered. Without it a boundary latches: the screen stays broken until reload + * even after the user has navigated somewhere that would render fine. + */ + resetKeys?: readonly unknown[]; + onError?: (error: Error, info: ErrorInfo) => void; +}; + +type ErrorBoundaryState = { error: Error | null }; + +/** + * The repo had none of these. A single malformed value anywhere below the router took the whole app to a + * white screen, and the only recovery from a bad stored layout was SQL — which is the wrong thing to ask + * of someone who has just opened the dashboard to read what happened overnight. + * + * Deliberately a render-prop rather than a fixed panel: what "recover" means is different for a screen + * (go somewhere else) and for one panel inside a workspace (clear the app, keep the rest running). + */ +export class ErrorBoundary extends Component { + override state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + override componentDidCatch(error: Error, info: ErrorInfo): void { + console.error('[error-boundary]', error, info.componentStack); + this.props.onError?.(error, info); + } + + override componentDidUpdate(prev: ErrorBoundaryProps): void { + if (!this.state.error) return; + const a = prev.resetKeys ?? []; + const b = this.props.resetKeys ?? []; + if (a.length !== b.length || a.some((key, i) => !Object.is(key, b[i]))) this.setState({ error: null }); + } + + reset = (): void => this.setState({ error: null }); + + override render(): ReactNode { + const { error } = this.state; + if (error) return this.props.fallback({ error, reset: this.reset }); + return this.props.children; + } +} diff --git a/src/workspaces/officerdev/src/components/Workspace/PanelErrorFallback.tsx b/src/workspaces/officerdev/src/components/Workspace/PanelErrorFallback.tsx new file mode 100644 index 00000000..c1ab0160 --- /dev/null +++ b/src/workspaces/officerdev/src/components/Workspace/PanelErrorFallback.tsx @@ -0,0 +1,50 @@ +import { AlertTriangle } from 'lucide-react'; + +type PanelErrorFallbackProps = { + error: Error; + reset: () => void; + /** Null when the panel is not interactive — then there is nothing to clear it back to. */ + onClearApp: (() => void) | null; +}; + +/** + * One panel's app threw. The workspace around it keeps running — the other panels are still live, and + * that is the whole point: a dashboard left running overnight should not be taken to a white screen by + * whichever app happened to receive a malformed value. + * + * Compact on purpose. A panel can be 200px tall, so the stack lives behind a `
` and the two + * recoveries are the only things guaranteed to be visible. + */ +export const PanelErrorFallback = ({ error, reset, onClearApp }: PanelErrorFallbackProps) => ( +
+ +

This panel failed to render

+

+ {error.message || 'Unknown error'} +

+
+ + {onClearApp && ( + + )} +
+ {error.stack && ( +
+ Details +
{error.stack}
+
+ )} +
+); diff --git a/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx b/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx index f618040a..597ce9a3 100644 --- a/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx +++ b/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx @@ -5,7 +5,9 @@ import type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry import { useWorkspace } from './WorkspaceContext'; import { ZOOM_MIN, ZOOM_MAX, ZOOM_STEP } from './layout-utils'; import { Card } from '@/components/Card'; +import { ErrorBoundary } from '@/components/ErrorBoundary'; import { AppPicker } from './AppPicker'; +import { PanelErrorFallback } from './PanelErrorFallback'; import { ContextMenu, ContextMenuTrigger, @@ -382,6 +384,24 @@ export const PanelSlot = ({ ) : (content: React.ReactNode) => <>{content}; + // One app throwing must not take the workspace with it — the other panels are still live, and on a + // dashboard left running overnight they are the result. Keyed on the panel's identity, so swapping the + // app or clearing it is itself a recovery. A locked layout offers no "clear": it isn't the user's to edit. + const guardApp = (node: React.ReactNode) => ( + ( + onSetApp(panel.id, null) : null} + /> + )} + > + {node} + + ); + const overlays = interactive && !locked ? ( <> @@ -413,7 +433,7 @@ export const PanelSlot = ({ return contextMenu(
- + {guardApp()}
{overlays}
, @@ -518,7 +538,7 @@ export const PanelSlot = ({
- + {guardApp()}