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(