Files
ThothII/frontend/src/shell/WidgetHost.tsx
T
marcopanandClaude Fable 5 2f68b0d109 fix(frontend): contain viewer/widget crashes with error boundaries
A render error in one viewer or gate widget unmounted the whole React root
(white screen). Add a reusable ErrorBoundary (class component, no new dep) with
resetKeys + an on-brand fallback, and wire it at two surfaces:
- WidgetHost: isolates the gate widget (reset on descriptor id) so a malformed
  gate payload no longer blanks the conversation
- SessionDocumentsPanel: wraps each document (reset on doc key/content) so one
  crashing viewer degrades only its section; siblings and the panel survive

The observed crash: a schema-linking doc that parses but lacks `candidates`
makes SchemaLinkingViewer throw. Verified live via Playwright against the mock
backend. TDD throughout; tsc clean, 123/123 vitest (+8 new tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 13:18:51 +02:00

44 lines
1.6 KiB
TypeScript

import { useSessionStore } from "../store/sessionStore";
import { resolve } from "../widgets";
import { postResponse } from "../api/sessions";
import type { UiResponse } from "../api/types";
import { ErrorBoundary } from "../components/ErrorBoundary";
export function WidgetHost({ sessionId }: { sessionId: string | null }) {
const pending = useSessionStore((s) => s.pendingWidget);
const clearPending = useSessionStore((s) => s.clearPending);
const pushToast = useSessionStore((s) => s.pushToast);
const setLastUserEntry = useSessionStore((s) => s.setLastUserEntry);
if (!pending) return null;
const Renderer = resolve(pending.widget);
const onRespond = async (r: UiResponse) => {
if (sessionId) {
try {
await postResponse(sessionId, r);
} catch (err) {
// Surface feedback and keep the widget pending so the user can retry.
pushToast({
level: "error",
text:
err instanceof Error
? `Failed to send response: ${err.message}`
: "Failed to send response.",
});
return;
}
}
setLastUserEntry({
kind: "choice",
text: r.text ?? r.choices?.join(", ") ?? r.decision?.type ?? r.control ?? "(choice)",
});
clearPending();
};
// A malformed gate payload must not white-screen the whole app: isolate the
// widget render. resetKeys on the descriptor id so the next gate starts clean.
return (
<ErrorBoundary resetKeys={[pending.id]} label="step">
<Renderer descriptor={pending} onRespond={onRespond} />
</ErrorBoundary>
);
}