Files
ThothII/frontend/src/shell/SessionDocumentsPanel.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

128 lines
5.4 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import { X } from "lucide-react";
import { getSessionDocuments } from "../api/sessions";
import type { SessionDocument, SessionSummary } from "../api/types";
import { Button } from "../components/ui/button";
import { ErrorBoundary } from "../components/ErrorBoundary";
import { SqlViewer } from "../viewers/SqlViewer";
import { SchemaLinkingViewer } from "../viewers/SchemaLinkingViewer";
import { MarkdownView } from "../viewers/MarkdownView";
interface Props {
session: SessionSummary;
onClose: () => void;
onResume: (id: string) => void;
}
function statusLabel(s: SessionSummary): string {
if (s.archived) return "Archived";
if (s.status === "finalized") return "Completed";
return `In progress · ${s.status}`;
}
function DocBody({ doc }: { doc: SessionDocument }) {
if (doc.format === "sql") return <SqlViewer blocks={[{ name: doc.title, sql: doc.content }]} />;
if (doc.format === "markdown") return <MarkdownView source={doc.content} />;
if (doc.format === "schema-linking") {
try {
const linking = JSON.parse(doc.content);
return <SchemaLinkingViewer linking={linking} />;
} catch {
return <pre className="whitespace-pre-wrap text-xs">{doc.content}</pre>;
}
}
if (doc.format === "decisions") {
const items = doc.content
.split("\n")
.filter(Boolean)
.map((line) => {
try { return JSON.parse(line) as { type?: string; subject?: string; detail?: string }; }
catch { return null; }
})
.filter((d): d is { type?: string; subject?: string; detail?: string } => d !== null);
if (items.length === 0) return <p className="text-sm text-muted-foreground">No decisions recorded.</p>;
return (
<ul className="flex flex-col divide-y divide-border/50">
{items.map((d, i) => (
<li key={i} className="py-3 first:pt-0">
<span className={`inline-block rounded-md px-2 py-0.5 text-[0.62rem] font-semibold uppercase tracking-wide ${decisionChipClass(d.type ?? "")}`}>
{(d.type ?? "decision").replace(/_/g, " ")}
</span>
{d.subject && <p className="mt-1.5 text-sm font-medium text-foreground">{d.subject}</p>}
{d.detail && <p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">{d.detail}</p>}
</li>
))}
</ul>
);
}
return <p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">{doc.content}</p>;
}
/** Semantic tint for a decision-type chip: approvals/promotions read as progress
* (green), exclusions as neutral, everything else carries the brand accent. */
function decisionChipClass(type: string): string {
const t = type.toLowerCase();
if (t.includes("approved") || t.includes("promoted"))
return "bg-[oklch(var(--success)/0.15)] text-[oklch(0.47_0.12_165)]";
if (t.includes("excluded") || t.includes("rejected") || t.includes("reopened"))
return "bg-muted text-muted-foreground";
return "bg-[oklch(var(--primary)/0.1)] text-primary";
}
export function SessionDocumentsPanel({ session, onClose, onResume }: Props) {
const { data: docs = [], isLoading } = useQuery<SessionDocument[]>({
queryKey: ["session-documents", session.id],
queryFn: () => getSessionDocuments(session.id),
});
const resumable = session.status !== "finalized" && !session.archived;
return (
<aside className="flex w-[30vw] max-w-[30vw] shrink-0 flex-col border-r border-border bg-sidebar">
<div className="flex items-start justify-between gap-2 border-b border-border/60 px-3 py-3">
<div className="min-w-0">
<h2 className="truncate font-heading text-sm font-semibold text-foreground">
{session.name || session.question}
</h2>
<p className="thot-label mt-0.5">{statusLabel(session)}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{resumable && (
<Button size="sm" variant="outline" onClick={() => onResume(session.id)}>Resume</Button>
)}
<Button size="icon-sm" variant="ghost" aria-label="Close panel" onClick={onClose}>
<X className="size-4" />
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-3 py-5">
{isLoading ? (
<p className="text-xs text-muted-foreground">Loading…</p>
) : (
<div className="flex max-w-[76ch] flex-col gap-7">
{docs.map((doc, idx) => (
<section key={doc.key} className={idx > 0 ? "border-t border-border/50 pt-7" : ""}>
<div className="mb-3 flex items-center gap-2">
<h3 className="thot-label">
{doc.title}
</h3>
{doc.phase !== "—" && (
<span className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.6rem] font-medium text-muted-foreground">
{doc.phase}
</span>
)}
</div>
{/* Isolate each document: a viewer that crashes on malformed
content degrades to a fallback in place, leaving the rest of
the panel intact instead of blanking the whole tree. */}
<ErrorBoundary resetKeys={[doc.key, doc.content]} label="document">
<DocBody doc={doc} />
</ErrorBoundary>
</section>
))}
</div>
)}
</div>
</aside>
);
}