From 6d53382f8cc0c34bff6777f6b488b5cd94806501 Mon Sep 17 00:00:00 2001 From: dave Date: Thu, 14 May 2026 19:04:29 +0000 Subject: [PATCH] huskies: merge 1059 --- frontend/src/components/ErrorBoundary.tsx | 73 +++++++++++++++++++ frontend/src/components/StagePanel.test.tsx | 36 +++++++++ frontend/src/components/StagePanel.tsx | 6 +- .../src/components/WorkItemDetailPanel.tsx | 2 +- .../components/workItemDetailPanelUtils.ts | 3 + frontend/src/main.tsx | 5 +- 6 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/ErrorBoundary.tsx diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..b9f05176 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,73 @@ +/** React error boundary that catches render-time exceptions and shows a + * recoverable error UI instead of a white screen. */ +import * as React from "react"; + +interface Props { + children: React.ReactNode; +} + +interface State { + error: Error | null; +} + +/** Catches uncaught render exceptions in its subtree and displays a message. */ +export class ErrorBoundary extends React.Component { + constructor(props: Props) { + super(props); + this.state = { error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + handleReset = () => { + this.setState({ error: null }); + }; + + render() { + if (this.state.error) { + return ( +
+
+
+ Something went wrong +
+
+ {this.state.error.message} +
+ +
+ ); + } + return this.props.children; + } +} diff --git a/frontend/src/components/StagePanel.test.tsx b/frontend/src/components/StagePanel.test.tsx index 9ae34929..e46489cd 100644 --- a/frontend/src/components/StagePanel.test.tsx +++ b/frontend/src/components/StagePanel.test.tsx @@ -490,3 +490,39 @@ describe("StagePanel", () => { expect(icon).toHaveTextContent("⏳"); }); }); + +describe("StagePanel - defensive rendering", () => { + it("renders without exception when a story is missing its name field", () => { + const items = [ + { + story_id: "60_story_no_name", + name: undefined as unknown as string, + error: null, + merge_failure: null, + agent: null, + review_hold: null, + qa: null, + depends_on: null, + }, + ]; + expect(() => render()).not.toThrow(); + expect(screen.getByTestId("card-60_story_no_name")).toBeInTheDocument(); + }); + + it("renders without exception when a story is missing its story_id field", () => { + const items = [ + { + story_id: undefined as unknown as string, + name: "Orphaned Story", + error: null, + merge_failure: null, + agent: null, + review_hold: null, + qa: null, + depends_on: null, + }, + ]; + expect(() => render()).not.toThrow(); + expect(screen.getByText("Orphaned Story")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/StagePanel.tsx b/frontend/src/components/StagePanel.tsx index 5bb08e29..e3845350 100644 --- a/frontend/src/components/StagePanel.tsx +++ b/frontend/src/components/StagePanel.tsx @@ -313,8 +313,10 @@ export function StagePanel({ }} > {items.map((item) => { - const itemNumber = item.story_id.match(/^(\d+)/)?.[1]; - const itemType = getWorkItemType(item.story_id); + const itemNumber = item.story_id?.match(/^(\d+)/)?.[1]; + const itemType = item.story_id + ? getWorkItemType(item.story_id) + : "unknown"; const borderColor = TYPE_COLORS[itemType]; const typeLabel = TYPE_LABELS[itemType]; const hasMergeFailure = Boolean(item.merge_failure); diff --git a/frontend/src/components/WorkItemDetailPanel.tsx b/frontend/src/components/WorkItemDetailPanel.tsx index cdf58e23..482a186f 100644 --- a/frontend/src/components/WorkItemDetailPanel.tsx +++ b/frontend/src/components/WorkItemDetailPanel.tsx @@ -258,7 +258,7 @@ export function WorkItemDetailPanel({ {error} )} - {!loading && !error && content !== null && ( + {!loading && !error && content != null && (
= { * them again inside the markdown body creates duplicate information. */ export function stripDisplayContent(content: string): string { + // Guard: content may be undefined/null at runtime if the server response is + // missing the field (e.g. a tombstoned story returns an error object). + if (!content) return ""; let text = content; // Strip YAML front matter (--- ... ---) if (text.startsWith("---")) { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a90798a0..0bb485a2 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,9 +1,12 @@ import * as React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; +import { ErrorBoundary } from "./components/ErrorBoundary"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + + + , );