From 443db14d1cc408ff0434a991263bc00b93ab802e Mon Sep 17 00:00:00 2001
From: Aldon Smith
Date: Fri, 22 May 2026 10:46:52 -0400
Subject: [PATCH] Tighten Workbench empty and error states
---
apps/web/README.md | 4 +-
apps/web/app/components/demo-state.tsx | 28 +++++++++--
.../web/app/detections/[detectionId]/page.tsx | 32 ++++++++-----
apps/web/app/detections/page.tsx | 3 +-
apps/web/app/globals.css | 14 ++++++
apps/web/app/page.tsx | 5 +-
apps/web/app/rca-capa-draft/page.tsx | 23 ++++++---
apps/web/app/recommendations/page.tsx | 4 +-
.../web/e2e/operations-workbench-demo.spec.ts | 6 +++
apps/web/tests/app-shell.test.mjs | 30 ++++++++++++
docs/LEARNING_LOG.md | 48 +++++++++++++++++++
docs/demo/TROUBLESHOOTING.md | 28 ++++++++++-
12 files changed, 198 insertions(+), 27 deletions(-)
diff --git a/apps/web/README.md b/apps/web/README.md
index e8fc77f..39de616 100644
--- a/apps/web/README.md
+++ b/apps/web/README.md
@@ -94,7 +94,9 @@ is running on a non-default port.
The shell labels all demo content as simulator-backed data and states that the
scenario is synthetic local data, not real plant data. The pages include loading
-states, empty states, and simple user-readable API error states.
+states, empty states, and simple user-readable API error states. Empty and
+missing demo-data states show a short `Next step:` line so a presenter can
+recover without treating local setup drift as a product failure.
## Governance Decision Feedback
diff --git a/apps/web/app/components/demo-state.tsx b/apps/web/app/components/demo-state.tsx
index 598e657..61dbb3c 100644
--- a/apps/web/app/components/demo-state.tsx
+++ b/apps/web/app/components/demo-state.tsx
@@ -1,3 +1,5 @@
+import type { ReactNode } from "react";
+
import type { HealthResponse } from "../../lib/api-client";
import { getApiBaseUrl } from "../../lib/api-client";
@@ -16,6 +18,7 @@ type DemoDataBadgeProps = {
};
type EmptyStateProps = {
+ nextStep?: string;
title: string;
text: string;
};
@@ -24,6 +27,12 @@ type LoadingStateProps = {
title?: string;
};
+type MissingDataPanelProps = {
+ nextStep: string;
+ title: string;
+ text: ReactNode;
+};
+
type StatusBadgeProps = {
label?: string;
tone?:
@@ -106,20 +115,31 @@ export function DemoDataBadge({
);
}
-export function EmptyState({ title, text }: EmptyStateProps) {
+export function EmptyState({ nextStep, title, text }: EmptyStateProps) {
+ return (
+
+ {title}
+ {text}
+ {nextStep ? Next step: {nextStep} : null}
+
+ );
+}
+
+export function MissingDataPanel({ nextStep, text, title }: MissingDataPanelProps) {
return (
-
+
{title}
{text}
+ Next step: {nextStep}
);
}
export function LoadingState({ title = "Loading simulator-backed demo data" }: LoadingStateProps) {
return (
-
+
-
+
{title}
Connecting to the local FastAPI demo backend.
diff --git a/apps/web/app/detections/[detectionId]/page.tsx b/apps/web/app/detections/[detectionId]/page.tsx
index 5e49fc9..c8f171d 100644
--- a/apps/web/app/detections/[detectionId]/page.tsx
+++ b/apps/web/app/detections/[detectionId]/page.tsx
@@ -1,6 +1,10 @@
import Link from "next/link";
-import { ApiErrorPanel, StatusBadge } from "../../components/demo-state";
+import {
+ ApiErrorPanel,
+ MissingDataPanel,
+ StatusBadge,
+} from "../../components/demo-state";
import {
ApiClientError,
type Detection,
@@ -35,14 +39,17 @@ export default async function DetectionDetailPage({ params }: DetectionDetailPag
{!result.ok && result.notFound ? (
-
- Detection not found
-
+
The simulator-backed demo API did not return a detection for{" "}
{detectionId}. Open the detection list and choose a
current demo detection.
-
-
+ >
+ }
+ title="Detection not found"
+ />
) : null}
{!result.ok && !result.notFound ? : null}
{result.ok ? (
@@ -198,13 +205,16 @@ function EvidenceTimeline({ evidenceItems }: { evidenceItems: EvidenceItem[] })
{buildTimelineMeaning(evidenceItems)}
{evidenceItems.length === 0 ? (
-
- No evidence available
-
+
This detection exists, but the simulator-backed API did not return
evidence items for it yet.
-
-
+ >
+ }
+ title="No evidence available"
+ />
) : (
{evidenceItems.map((item) => (
diff --git a/apps/web/app/detections/page.tsx b/apps/web/app/detections/page.tsx
index c759179..41c669a 100644
--- a/apps/web/app/detections/page.tsx
+++ b/apps/web/app/detections/page.tsx
@@ -26,7 +26,8 @@ export default async function DetectionsPage() {
{!result.ok ? : null}
{result.ok && result.detections.length === 0 ? (
) : null}
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index b905754..f32a0f0 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -556,6 +556,11 @@ h3 {
padding: 28px;
}
+.loading-panel {
+ min-height: 260px;
+ align-content: start;
+}
+
.placeholder-list {
display: grid;
gap: 12px;
@@ -868,6 +873,15 @@ h3 {
color: #8a2b2b;
}
+.missing-data-panel {
+ border-color: #d9c18c;
+ background: #fffaf0;
+}
+
+.missing-data-panel strong {
+ color: var(--warning);
+}
+
.decision-note {
border-left: 4px solid var(--accent);
background: #edf6f1;
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 5d6f785..5bf5ecf 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -151,7 +151,10 @@ export default async function OverviewPage() {
) : (
<>
No active detections
- Run the demo simulator and Process Sentinel to populate this panel.
+
+ Run make demo, start the local API, and refresh this
+ page to populate this panel.
+
>
)}
diff --git a/apps/web/app/rca-capa-draft/page.tsx b/apps/web/app/rca-capa-draft/page.tsx
index 50a5b99..c0111af 100644
--- a/apps/web/app/rca-capa-draft/page.tsx
+++ b/apps/web/app/rca-capa-draft/page.tsx
@@ -1,6 +1,11 @@
import Link from "next/link";
-import { ApiErrorPanel, EmptyState, StatusBadge } from "../components/demo-state";
+import {
+ ApiErrorPanel,
+ EmptyState,
+ MissingDataPanel,
+ StatusBadge,
+} from "../components/demo-state";
import {
ApiClientError,
type RcaCapaDraft,
@@ -36,19 +41,23 @@ export default async function RcaCapaDraftPage({
{!result.ok && result.notFound ? (
-
- Draft not found
-
+
The simulator-backed API did not return an RCA/CAPA draft for{" "}
{result.detectionId}. Open a current demo detection and
use its RCA/CAPA draft link.
-
-
+ >
+ }
+ title="Draft not found"
+ />
) : null}
{!result.ok && !result.notFound ? : null}
{result.ok && !result.draft ? (
) : null}
diff --git a/apps/web/app/recommendations/page.tsx b/apps/web/app/recommendations/page.tsx
index 0563162..243a25e 100644
--- a/apps/web/app/recommendations/page.tsx
+++ b/apps/web/app/recommendations/page.tsx
@@ -36,12 +36,14 @@ export default async function RecommendationsPage({
{!result.ok ? : null}
{result.ok && result.recommendations.length === 0 ? (
) : null}
{result.ok && result.recommendations.length > 0 && !result.selected ? (
diff --git a/apps/web/e2e/operations-workbench-demo.spec.ts b/apps/web/e2e/operations-workbench-demo.spec.ts
index f0c65d1..010e49c 100644
--- a/apps/web/e2e/operations-workbench-demo.spec.ts
+++ b/apps/web/e2e/operations-workbench-demo.spec.ts
@@ -63,6 +63,12 @@ test("walks the simulator-backed Operations Workbench demo path", async ({ page
await expect(page.getByRole("heading", { name: "CAPA placeholder" })).toBeVisible();
await expect(page.getByText("Human review", { exact: true })).toBeVisible();
await expect(page.getByText("Required", { exact: true })).toBeVisible();
+
+ await page.goto("/detections/not-a-current-demo-detection");
+ await expect(page.getByRole("heading", { name: "Detection detail" })).toBeVisible();
+ await expect(page.getByRole("status")).toContainText("Detection not found");
+ await expect(page.getByRole("status")).toContainText("Next step:");
+ await expect(page.getByRole("status")).toContainText("rerun make demo");
});
async function recordDecision(
diff --git a/apps/web/tests/app-shell.test.mjs b/apps/web/tests/app-shell.test.mjs
index b39865b..0989b81 100644
--- a/apps/web/tests/app-shell.test.mjs
+++ b/apps/web/tests/app-shell.test.mjs
@@ -189,6 +189,36 @@ test("app shell documents configurable API base URL", () => {
assert.match(readme, /NEXT_PUBLIC_API_BASE_URL/);
});
+test("workbench state panels distinguish local demo data gaps from API failures", () => {
+ const demoState = readFileSync(join(root, "app/components/demo-state.tsx"), "utf8");
+ const overview = readFileSync(join(root, "app/page.tsx"), "utf8");
+ const detections = readFileSync(join(root, "app/detections/page.tsx"), "utf8");
+ const detail = readFileSync(join(root, "app/detections/[detectionId]/page.tsx"), "utf8");
+ const recommendations = readFileSync(join(root, "app/recommendations/page.tsx"), "utf8");
+ const draft = readFileSync(join(root, "app/rca-capa-draft/page.tsx"), "utf8");
+ const styles = readFileSync(join(root, "app/globals.css"), "utf8");
+
+ assert.match(demoState, /role="alert"/);
+ assert.match(demoState, /role="status"/);
+ assert.match(demoState, /aria-busy="true"/);
+ assert.match(demoState, /MissingDataPanel/);
+ assert.match(demoState, /loading-panel/);
+ assert.match(overview, /Run make demo<\/code>/);
+ assert.match(detections, /The local API is reachable, but it did not return any Process Sentinel detections/);
+ assert.match(detections, /Run make demo from the repository root/);
+ assert.match(detail, /Detection not found/);
+ assert.match(detail, /No evidence available/);
+ assert.match(detail, /rerun make demo if local state was reset/);
+ assert.match(recommendations, /No recommendations returned/);
+ assert.match(recommendations, /No linked recommendation found/);
+ assert.match(recommendations, /Run make demo so Process Sentinel can create the demo recommendation/);
+ assert.match(draft, /Draft not found/);
+ assert.match(draft, /No detection available for draft preview/);
+ assert.match(draft, /Run make demo, then open the draft/);
+ assert.match(styles, /missing-data-panel/);
+ assert.match(styles, /min-height: 260px/);
+});
+
test("operations workbench docs link to the demo runbook", async () => {
const rootReadme = readFileSync(join(root, "..", "..", "README.md"), "utf8");
const appReadme = readFileSync(join(root, "README.md"), "utf8");
diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md
index e4c8290..222665b 100644
--- a/docs/LEARNING_LOG.md
+++ b/docs/LEARNING_LOG.md
@@ -3370,3 +3370,51 @@ npm run typecheck
Practice the demo with the API intentionally stopped once, then confirm the
presenter can recover using only the visible target and recovery guidance.
+
+## 2026-05-22 - Workbench empty and error state audit
+
+### What changed
+
+Tightened the Operations Workbench loading, empty, missing-data, and API-error
+states for issue #177. Empty and missing demo-data panels now use status
+semantics and short `Next step:` recovery copy, while API connection failures
+remain alert panels with local API guidance.
+
+### Why it was built that way
+
+The Workbench already had the required routes and most state panels, so the
+smallest useful change was to refine existing components and route copy instead
+of adding a global error boundary or production monitoring feature. Missing demo
+data is separated from API connectivity to keep local rehearsal issues from
+looking like product failures.
+
+### How data flows through it
+
+Each route still calls the existing local FastAPI endpoints. Empty arrays and
+404-style missing IDs render local demo recovery panels. Network/API failures
+flow through `ApiErrorPanel`, preserving `role="alert"` and the configured API
+target guidance.
+
+### How to run it
+
+```bash
+make demo
+make api
+cd apps/web
+npm run dev
+```
+
+### How to test it
+
+```bash
+cd apps/web
+npm test
+npm run lint
+npm run typecheck
+make test-e2e
+```
+
+### What to learn next
+
+During rehearsal, intentionally visit a stale detection URL and confirm the
+presenter can recover from the visible `Next step:` text without opening docs.
diff --git a/docs/demo/TROUBLESHOOTING.md b/docs/demo/TROUBLESHOOTING.md
index 70691d3..85271a4 100644
--- a/docs/demo/TROUBLESHOOTING.md
+++ b/docs/demo/TROUBLESHOOTING.md
@@ -104,7 +104,7 @@ make demo
- `make demo-sentinel-run` prints `detections=0`.
- `/sentinel/detections` returns an empty list.
-- The Workbench detection list has no Process Sentinel case.
+- The Workbench detection list shows `No detections returned`.
- The recommendation queue is empty because no detection was created.
### Common causes
@@ -149,6 +149,9 @@ Confirm:
sentinel complete: detections=1 evidence=2 recommendations=1
```
+Refresh the Workbench after the API is started. Empty-state panels indicate the
+local API is reachable but the expected simulator-backed demo state is missing.
+
## API Not Running
### Symptoms
@@ -319,6 +322,29 @@ Expected fields include:
The draft is advisory decision support only. It does not create, close, or
submit a CAPA.
+## Workbench Missing-State Panels
+
+### Symptoms
+
+- A page shows `Detection not found`, `No evidence available`,
+ `No linked recommendation found`, or `No detection available for draft
+ preview`.
+- The API target is reachable, but the selected ID or generated local state does
+ not match the current demo run.
+
+### Recovery
+
+Use the visible `Next step:` line in the Workbench panel first. For a clean
+manufacturer demo reset, rebuild deterministic state:
+
+```bash
+make demo
+```
+
+Restart the API if it was already running, then refresh the Workbench. These
+missing-state panels are local demo recovery states; they are not production
+incident handling or real plant failure modes.
+
## Recommendation Decision Not Updating
### Symptoms