docs(plan): implementation plan for workflow UI fixes (6 tasks, TDD)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 13:01:31 +02:00
co-authored by Claude Opus 4.8
parent 3c198ec775
commit 3de1a784ce
@@ -0,0 +1,944 @@
# Workflow UI fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix five UI/contract defects found while testing: phase-circle lifecycle colors, a total elapsed timer, the empty reviewer artifact gate, a 90% artifact modal with a Mermaid schema diagram, and the duplicate "Altro"/"other" option.
**Architecture:** All fixes are in the frontend except one root-cause fix in the harness gate. The reviewer artifact gate renders `artifact.data` (harness contract) — not the never-populated `artifact.content` — through a new `ArtifactView` router inside a full-screen modal. Phase lifecycle stays frontend-optimistic (no new Pi RPC events). The duplicate-option bug is fixed by making the harness reserved-label matcher robust to model-produced variants.
**Tech Stack:** React 18 + Vite + vitest + Testing Library (frontend, no network — MSW); Node `node --test` for the harness gate JS; Mermaid (already a dependency); Base UI dialog (`components/ui/dialog.tsx`).
## Global Constraints
- Frontend has no ESLint; `npx tsc -b` is the gate. Run it before every commit that touches TS.
- Frontend tests use vitest + MSW, no real network.
- Harness gate JS tests run via `cd harness && npm test` (`node --test .pi/extensions/gate/__tests__/*.test.js`). New harness tests MUST live in `.pi/extensions/gate/__tests__/` and be named `*.test.js`.
- UI strings/chrome/labels stay **English** (project convention). Document *content* stays the workspace language.
- Harness JS line style: keep the surrounding tab-indented style of the file you edit; do not reformat neighbours.
- Every changed line must trace to one of the five defects — no unrelated refactoring.
---
## File Structure
**Harness**
- `harness/.pi/extensions/reserved-labels.mjs` — modify `isReserved` to normalize + prefix-match.
- `harness/.pi/extensions/gate/__tests__/reserved_labels.test.js` — new unit test.
**Frontend**
- `frontend/src/viewers/SchemaLinkingViewer.tsx` — replace `buildFlowchart` with exported `buildErDiagram` (Mermaid `erDiagram`).
- `frontend/src/viewers/SchemaLinkingViewer.test.tsx` — add a `buildErDiagram` unit test.
- `frontend/src/viewers/ArtifactView.tsx` — new: route `artifact.kind` → viewer, defensive on `data`.
- `frontend/src/viewers/ArtifactView.test.tsx` — new.
- `frontend/src/widgets/ArtifactGateWidget.tsx` — rewrite body to render a 90% modal using `ArtifactView` (keep the export name and filename).
- `frontend/src/widgets/ArtifactGateWidget.test.tsx` — update to the modal behaviour.
- `frontend/src/shell/WorkflowBar.tsx` — `finalized`/`createdAt`/`updatedAt` props; all-green on finalized; render `ElapsedTimer`.
- `frontend/src/shell/WorkflowBar.test.tsx` — new.
- `frontend/src/shell/ElapsedTimer.tsx` — new.
- `frontend/src/shell/ElapsedTimer.test.tsx` — new.
- `frontend/src/shell/SteerInput.tsx` — set `currentPhase="F1"` on new-question creation.
- `frontend/src/shell/AppShell.tsx` — pass `finalized`/`createdAt`/`updatedAt` to `WorkflowBar`.
- `frontend/src/shell/f1-loop.test.tsx` — assert optimistic F1 after a new question.
---
## Task 1: Harness — robust `isReserved` (fix duplicate Altro/other)
**Files:**
- Modify: `harness/.pi/extensions/reserved-labels.mjs`
- Test: `harness/.pi/extensions/gate/__tests__/reserved_labels.test.js`
**Interfaces:**
- Consumes: nothing new.
- Produces: `isReserved(label: string): boolean` — true for any label whose normalized form starts with `altro` or `other`, or equals the normalized quit/back canonical labels. `stripReserved(labels: string[]): string[]` unchanged in signature; inherits the new matcher. Used by `tht-gate.js` `reviewer_select`/`reviewer_decide` to drop model-supplied free-text options.
- [ ] **Step 1: Write the failing test**
Create `harness/.pi/extensions/gate/__tests__/reserved_labels.test.js`:
```javascript
const test = require("node:test");
const assert = require("node:assert");
const { isReserved, stripReserved } = require("../../reserved-labels.mjs");
test("Altro variants (any punctuation/case) are reserved", () => {
assert.equal(isReserved("Altro — specifica…"), true);
assert.equal(isReserved("Altro - specificare"), true);
assert.equal(isReserved("altro"), true);
assert.equal(isReserved("ALTRO (specificare)"), true);
});
test("English Other variant is reserved", () => {
assert.equal(isReserved("Other — specify"), true);
assert.equal(isReserved("other"), true);
});
test("canonical quit/back labels stay reserved", () => {
assert.equal(isReserved("Esci da Pi (/quit)"), true);
assert.equal(isReserved("Torna indietro (fase precedente)"), true);
});
test("normal merit options are NOT reserved", () => {
assert.equal(isReserved("procedura"), false);
assert.equal(isReserved("patologia"), false);
assert.equal(isReserved("altrove"), false); // starts with "altro"? no — "altrove" -> normalized "altrove" starts with "altro" -> guard below
});
test("stripReserved drops every Altro/other variant, keeps merit order", () => {
assert.deepEqual(
stripReserved(["procedura", "Altro - specificare", "patologia", "other"]),
["procedura", "patologia"],
);
});
```
Note: `"altrove"` normalizes to `"altrove"`, which *does* start with `"altro"`. To avoid stripping a legitimate option, match the whole first token equals `altro`/`other` rather than a raw `startsWith`. The implementation below tokenizes, so `"altrove"` (single token `altrove`) is NOT reserved. Keep this test line as the guard.
- [ ] **Step 2: Run test to verify it fails**
Run: `cd harness && node --test .pi/extensions/gate/__tests__/reserved_labels.test.js`
Expected: FAIL — `isReserved("Altro - specificare")` returns `false` (exact-match implementation).
- [ ] **Step 3: Implement the normalized matcher**
Replace the body of `harness/.pi/extensions/reserved-labels.mjs` from the `const RESERVED` line through `stripReserved` with:
```javascript
// Normalize a label to lowercase ASCII tokens: strip diacritics, turn every run
// of punctuation/space into a single space, trim. "Altro — specifica…" -> "altro specifica".
function normalize(label) {
return String(label)
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
const NORM_QUIT = normalize(QUIT_LABEL);
const NORM_BACK = normalize(BACK_LABEL);
// true if the label is one the gate adds itself. Robust to the variants models
// emit ("Altro - specificare", "altro", English "Other — specify"): reserved when
// the FIRST normalized token is exactly "altro"/"other", or the whole normalized
// label equals the canonical quit/back labels. First-token match keeps real
// options like "altrove" out of the reserved set.
export function isReserved(label) {
const n = normalize(label);
if (!n) return false;
const first = n.split(" ")[0];
return first === "altro" || first === "other" || n === NORM_QUIT || n === NORM_BACK;
}
// removes every reserved entry from a list of labels, preserving order and normal
// entries. Idempotent.
export function stripReserved(labels) {
return labels.filter((label) => !isReserved(label));
}
```
Leave the `export const ALTRO / QUIT_LABEL / BACK_LABEL / CONTROL_LABELS` lines at the top of the file unchanged (still imported elsewhere). Remove the now-unused `const RESERVED = new Set([...])` line.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd harness && node --test .pi/extensions/gate/__tests__/reserved_labels.test.js`
Expected: PASS (all 5 tests).
Run the whole gate suite to confirm no regression: `cd harness && npm test`
Expected: PASS (existing `builders.test.js` assertion "select no longer injects an Altro option" and the rest stay green).
- [ ] **Step 5: Commit**
```bash
git add harness/.pi/extensions/reserved-labels.mjs harness/.pi/extensions/gate/__tests__/reserved_labels.test.js
git commit -m "fix(gate): robust isReserved strips model Altro/other variants (no duplicate free-text option)"
```
---
## Task 2: Frontend — Mermaid `erDiagram` for schema linking
**Files:**
- Modify: `frontend/src/viewers/SchemaLinkingViewer.tsx` (replace `buildFlowchart`, ~lines 36-57 and its call ~line 92)
- Test: `frontend/src/viewers/SchemaLinkingViewer.test.tsx`
**Interfaces:**
- Consumes: `Candidate`, `Join` (already exported from `SchemaLinkingViewer.tsx`).
- Produces: `export function buildErDiagram(promoted: Candidate[], joins: Join[]): string` — a Mermaid `erDiagram` string: promoted tables as entities, their promoted `table.column` candidates as attributes, `joins` (resolved to owning tables) as relationships. Consumed by `ArtifactView` indirectly (via the component) and unit-tested here.
- [ ] **Step 1: Write the failing test**
Add to `frontend/src/viewers/SchemaLinkingViewer.test.tsx` — update the import on line 3 and append the test:
```tsx
import { SchemaLinkingViewer, buildErDiagram } from "./SchemaLinkingViewer";
```
```tsx
test("(e) buildErDiagram emits entities, attributes and a relation", () => {
const def = buildErDiagram(
[
{ kind: "table", name: "orders", decision: "promoted" },
{ kind: "column", name: "orders.id", decision: "promoted" },
{ kind: "table", name: "customers", decision: "promoted" },
],
[{ from: "orders.customer_id", to: "customers.id" }],
);
expect(def.startsWith("erDiagram")).toBe(true);
expect(def).toContain("orders {");
expect(def).toContain("col id");
expect(def).toContain("orders }o--o{ customers : join");
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd frontend && npx vitest run src/viewers/SchemaLinkingViewer.test.tsx -t buildErDiagram`
Expected: FAIL — `buildErDiagram` is not exported (`buildErDiagram is not a function`).
- [ ] **Step 3: Replace `buildFlowchart` with `buildErDiagram`**
In `frontend/src/viewers/SchemaLinkingViewer.tsx`, delete the `buildFlowchart` function (lines ~36-57) and add:
```tsx
export function buildErDiagram(promoted: Candidate[], joins: Join[]): string {
const sanitize = (name: string) => name.replace(/[^a-zA-Z0-9]/g, "_");
const tables = promoted.filter((c) => c.kind === "table");
const tableNames = new Set(tables.map((t) => t.name));
const columns = promoted.filter((c) => c.kind === "column");
const lines: string[] = ["erDiagram"];
for (const t of tables) {
const id = sanitize(t.name);
const cols = columns.filter((col) => col.name.startsWith(t.name + "."));
lines.push(` ${id} {`);
for (const col of cols) {
lines.push(` col ${sanitize(col.name.slice(t.name.length + 1))}`);
}
lines.push(` }`);
}
// A join endpoint may be "table" or "table.column"; resolve to its owning table.
// Draw a relationship only between two DISTINCT promoted tables, once per pair.
const owningTable = (ref: string) =>
ref.includes(".") ? ref.slice(0, ref.indexOf(".")) : ref;
const seen = new Set<string>();
for (const j of joins) {
const a = owningTable(j.from);
const b = owningTable(j.to);
if (a === b || !tableNames.has(a) || !tableNames.has(b)) continue;
const key = [a, b].sort().join("::");
if (seen.has(key)) continue;
seen.add(key);
lines.push(` ${sanitize(a)} }o--o{ ${sanitize(b)} : join`);
}
return lines.join("\n");
}
```
Then update the effect that builds the diagram (~line 92): change
```tsx
const def = buildFlowchart(promoted, linking.joins);
```
to
```tsx
const def = buildErDiagram(promoted, linking.joins);
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd frontend && npx vitest run src/viewers/SchemaLinkingViewer.test.tsx`
Expected: PASS — the new `(e)` test plus the existing `(a)-(d)` tests (they mock `renderMermaid`, so the diagram string change doesn't affect them).
Run: `cd frontend && npx tsc -b`
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/viewers/SchemaLinkingViewer.tsx frontend/src/viewers/SchemaLinkingViewer.test.tsx
git commit -m "feat(viewer): schema linking renders a Mermaid erDiagram (tables + relations)"
```
---
## Task 3: Frontend — `ArtifactView` (render artifact.data by kind)
**Files:**
- Create: `frontend/src/viewers/ArtifactView.tsx`
- Test: `frontend/src/viewers/ArtifactView.test.tsx`
**Interfaces:**
- Consumes: `SqlViewer` + `SqlBlock` (`./SqlViewer`), `SchemaLinkingViewer` + `SchemaLinking` (`./SchemaLinkingViewer`), `MarkdownView` (`./MarkdownView`).
- Produces: `export function ArtifactView({ artifact }: { artifact: { kind: string; data?: unknown; content?: unknown; [k: string]: unknown } }): ReactElement`. Routes on `artifact.kind`, defensive about `data` (string or object), JSON fallback otherwise. Consumed by `ArtifactGateWidget` (Task 4).
- [ ] **Step 1: Write the failing test**
Create `frontend/src/viewers/ArtifactView.test.tsx`:
```tsx
import { render, screen } from "@testing-library/react";
import { ArtifactView } from "./ArtifactView";
vi.mock("./mermaid", () => ({
renderMermaid: vi.fn().mockResolvedValue('<svg data-testid="mm"></svg>'),
}));
test("sql artifact renders the SQL text", () => {
render(<ArtifactView artifact={{ kind: "sql", data: "SELECT 1" }} />);
expect(screen.getByText("SELECT 1")).toBeInTheDocument();
});
test("cte_plan renders an ordered list of names", () => {
render(<ArtifactView artifact={{ kind: "cte_plan", data: { names: ["a_cte", "b_cte"] } }} />);
expect(screen.getByText("a_cte")).toBeInTheDocument();
expect(screen.getByText("b_cte")).toBeInTheDocument();
});
test("question renders markdown headings", () => {
render(<ArtifactView artifact={{ kind: "question", data: "## Domanda\nrevised" }} />);
expect(screen.getByText("Domanda")).toBeInTheDocument();
});
test("unknown kind falls back to formatted JSON", () => {
render(<ArtifactView artifact={{ kind: "mystery", data: { a: 1 } }} />);
expect(screen.getByText(/"a": 1/)).toBeInTheDocument();
});
test("schema_linking renders the schema viewer", async () => {
render(
<ArtifactView
artifact={{
kind: "schema_linking",
data: { candidates: [{ kind: "table", name: "orders", decision: "promoted" }], joins: [] },
}}
/>,
);
expect(await screen.findByTestId("mm")).toBeInTheDocument();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd frontend && npx vitest run src/viewers/ArtifactView.test.tsx`
Expected: FAIL — cannot resolve `./ArtifactView`.
- [ ] **Step 3: Implement `ArtifactView`**
Create `frontend/src/viewers/ArtifactView.tsx`:
```tsx
import type { ReactElement } from "react";
import { SqlViewer, type SqlBlock } from "./SqlViewer";
import { SchemaLinkingViewer, type SchemaLinking } from "./SchemaLinkingViewer";
import { MarkdownView } from "./MarkdownView";
type ArtifactData = { kind: string; data?: unknown; content?: unknown; [k: string]: unknown };
function asRecord(v: unknown): Record<string, unknown> | null {
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
}
// The payload lives in `data` (harness contract); fall back to `content`, then the
// artifact object itself, so older/other producers still render something.
function payload(artifact: ArtifactData): unknown {
if (artifact.data !== undefined) return artifact.data;
if (artifact.content !== undefined) return artifact.content;
return artifact;
}
function toSchemaLinking(raw: unknown): SchemaLinking | null {
let obj: unknown = raw;
if (typeof raw === "string") {
try { obj = JSON.parse(raw); } catch { return null; }
}
const rec = asRecord(obj);
if (!rec || !Array.isArray(rec.candidates)) return null;
return {
candidates: rec.candidates as SchemaLinking["candidates"],
joins: Array.isArray(rec.joins) ? (rec.joins as SchemaLinking["joins"]) : [],
excluded: Array.isArray(rec.excluded) ? (rec.excluded as SchemaLinking["excluded"]) : [],
open_questions: Array.isArray(rec.open_questions) ? (rec.open_questions as string[]) : [],
question: typeof rec.question === "string" ? rec.question : undefined,
};
}
function toSqlBlocks(raw: unknown): SqlBlock[] | null {
if (typeof raw === "string") return [{ name: "SQL", sql: raw }];
const rec = asRecord(raw);
if (!rec) return null;
if (typeof rec.sql === "string") return [{ name: "SQL", sql: rec.sql }];
if (Array.isArray(rec.ctes)) {
const blocks = rec.ctes
.map((c) => asRecord(c))
.filter((c): c is Record<string, unknown> => !!c && typeof c.sql === "string")
.map((c) => ({ name: typeof c.name === "string" ? c.name : "cte", sql: c.sql as string }));
if (typeof rec.final === "string") blocks.push({ name: "final", sql: rec.final });
return blocks.length ? blocks : null;
}
return null;
}
function toMarkdown(raw: unknown): string | null {
if (typeof raw === "string") return raw;
const rec = asRecord(raw);
if (!rec) return null;
for (const k of ["markdown", "question", "text"]) {
if (typeof rec[k] === "string") return rec[k] as string;
}
return null;
}
function cteNames(raw: unknown): string[] | null {
if (Array.isArray(raw) && raw.every((x) => typeof x === "string")) return raw as string[];
const rec = asRecord(raw);
if (rec && Array.isArray(rec.names) && rec.names.every((x) => typeof x === "string")) {
return rec.names as string[];
}
return null;
}
function JsonFallback({ value }: { value: unknown }): ReactElement {
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
return (
<pre className="overflow-auto whitespace-pre-wrap rounded bg-muted p-3 text-sm">{text}</pre>
);
}
export function ArtifactView({ artifact }: { artifact: ArtifactData }): ReactElement {
const kind = artifact.kind ?? "";
const data = payload(artifact);
if (kind === "schema_linking") {
const linking = toSchemaLinking(data);
if (linking) return <SchemaLinkingViewer linking={linking} />;
}
if (kind === "sql" || kind === "cte_result") {
const blocks = toSqlBlocks(data);
if (blocks) return <SqlViewer blocks={blocks} />;
}
if (kind === "cte_plan") {
const names = cteNames(data);
if (names) {
return (
<ol className="list-decimal pl-6 text-sm">
{names.map((n, i) => (
<li key={`${n}-${i}`} className="font-mono">{n}</li>
))}
</ol>
);
}
}
if (kind === "question" || kind === "phase") {
const md = toMarkdown(data);
if (md !== null) return <MarkdownView source={md} />;
}
return <JsonFallback value={data} />;
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd frontend && npx vitest run src/viewers/ArtifactView.test.tsx`
Expected: PASS (5 tests). (The `sql` test reads the un-highlighted `<pre>` fallback that `SqlViewer` shows before async highlight resolves.)
Run: `cd frontend && npx tsc -b`
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/viewers/ArtifactView.tsx frontend/src/viewers/ArtifactView.test.tsx
git commit -m "feat(viewer): ArtifactView routes artifact.data by kind (schema/sql/cte/question)"
```
---
## Task 4: Frontend — artifact gate as a 90% modal
**Files:**
- Modify (rewrite body, keep export name): `frontend/src/widgets/ArtifactGateWidget.tsx`
- Test: `frontend/src/widgets/ArtifactGateWidget.test.tsx`
**Interfaces:**
- Consumes: `ArtifactView` (`../viewers/ArtifactView`), `Dialog`/`DialogContent`/`DialogTitle` (`../components/ui/dialog`), `ReservedControls`, `LinkageHost`, `WidgetProps`.
- Produces: `ArtifactGateWidget` (unchanged name; still registered for `"artifact-gate"` in `widgets/index.ts`). Renders a `90vw × 90vh` modal: artifact on top via `ArtifactView`, action bar (options + reserved) at the bottom. Same `onRespond` payloads as before: `{ id, kind: "artifact-gate", choices: [id] }`, linkage merges child `text`, reserved → `{ id, control, text? }`.
- [ ] **Step 1: Write the failing test**
Replace the first test in `frontend/src/widgets/ArtifactGateWidget.test.tsx` (the "renders artifact content in a pre block" test, lines ~5-19) with two tests, and add a `vi.mock` for mermaid at the top. Final file top + first tests:
```tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ArtifactGateWidget } from "./ArtifactGateWidget";
vi.mock("../viewers/mermaid", () => ({
renderMermaid: vi.fn().mockResolvedValue('<svg data-testid="mm"></svg>'),
}));
test("renders inside a dialog and shows the artifact via ArtifactView (data path)", () => {
const onRespond = vi.fn();
render(
<ArtifactGateWidget
descriptor={{
id: "u1",
widget: "artifact-gate",
title: "SQL finale",
artifact: { kind: "sql", data: "SELECT 42" },
options: [{ id: "approve", label: "Approva" }],
}}
onRespond={onRespond}
/>
);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("SELECT 42")).toBeInTheDocument();
});
```
Keep the existing "clicking an option without opens responds immediately" and "reserved control responds with control field" tests as-is (lines ~21-54) — they still describe the modal's behaviour. The reserved test's descriptor `artifact: { kind: "cte", content: "SELECT 1" }` now renders through `ArtifactView`'s JSON fallback (kind `cte` is unknown; `content` string → `JsonFallback` prints `SELECT 1`), which does not affect the button assertions.
- [ ] **Step 2: Run test to verify it fails**
Run: `cd frontend && npx vitest run src/widgets/ArtifactGateWidget.test.tsx -t "inside a dialog"`
Expected: FAIL — current widget renders a `<pre>`, no `role="dialog"`.
- [ ] **Step 3: Rewrite `ArtifactGateWidget.tsx` as the modal**
Replace the entire contents of `frontend/src/widgets/ArtifactGateWidget.tsx` with:
```tsx
import { useState } from "react";
import type { WidgetProps } from "./types";
import type { UiResponse, WidgetDescriptor } from "../api/types";
import { Dialog, DialogContent, DialogTitle } from "../components/ui/dialog";
import { ArtifactView } from "../viewers/ArtifactView";
import { ReservedControls } from "./ReservedControls";
import { LinkageHost } from "./LinkageHost";
/**
* Artifact review gate rendered as a full-screen (90%) modal: the artifact fills
* the top (scrollable) area via ArtifactView; the action bar (options + reserved
* controls) sits at the bottom. The gate contract forbids silent dismissal, so the
* dialog has no close button and is not closeable by Esc/backdrop — the only way
* out is an action or a reserved control, both of which call onRespond.
*/
export function ArtifactGateWidget({ descriptor, onRespond }: WidgetProps) {
const [pendingLinkage, setPendingLinkage] = useState<{
parentResponse: UiResponse;
childDescriptor: WidgetDescriptor;
} | null>(null);
function handleOption(optionId: string) {
const option = descriptor.options?.find((o) => o.id === optionId);
const parentResponse: UiResponse = { id: descriptor.id, kind: "artifact-gate", choices: [optionId] };
if (option?.opens) setPendingLinkage({ parentResponse, childDescriptor: option.opens });
else onRespond(parentResponse);
}
return (
<Dialog open>
<DialogContent
showCloseButton={false}
className="grid h-[90vh] w-[90vw] max-w-[90vw] grid-rows-[auto_1fr_auto] gap-3 sm:max-w-[90vw]"
>
<DialogTitle>{descriptor.title ?? "Artifact review"}</DialogTitle>
<div className="min-h-0 overflow-auto rounded border bg-background p-3">
{descriptor.artifact ? (
<ArtifactView artifact={descriptor.artifact} />
) : (
<p className="text-sm text-muted-foreground">No artifact.</p>
)}
</div>
<div className="flex flex-col gap-2 border-t pt-3">
{pendingLinkage ? (
<LinkageHost
parentResponse={pendingLinkage.parentResponse}
childDescriptor={pendingLinkage.childDescriptor}
onRespond={onRespond}
/>
) : (
<>
<div className="flex flex-wrap gap-2">
{descriptor.options?.map((o) => (
<button
key={o.id}
className="rounded border px-3 py-2 text-left hover:bg-accent"
onClick={() => handleOption(o.id)}
>
{o.label}
</button>
))}
</div>
<ReservedControls
reserved={descriptor.reserved}
onControl={(c, t) =>
onRespond({ id: descriptor.id, control: c, ...(t !== undefined ? { text: t } : {}) })
}
/>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd frontend && npx vitest run src/widgets/ArtifactGateWidget.test.tsx src/widgets/linkage.test.tsx`
Expected: PASS — the new dialog test, the two kept option/reserved tests, and both `linkage.test.tsx` tests (linkage flow is unchanged).
Run: `cd frontend && npx tsc -b`
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/widgets/ArtifactGateWidget.tsx frontend/src/widgets/ArtifactGateWidget.test.tsx
git commit -m "feat(gate): render reviewer artifacts in a 90% modal (fix empty artifact.data gate)"
```
---
## Task 5: Frontend — phase-circle lifecycle (optimistic F1 + finalized all-green)
**Files:**
- Modify: `frontend/src/shell/WorkflowBar.tsx`
- Modify: `frontend/src/shell/SteerInput.tsx` (submit path, ~lines 29-45)
- Modify: `frontend/src/shell/AppShell.tsx` (WorkflowBar render, ~line 172)
- Modify: `frontend/src/shell/f1-loop.test.tsx` (add optimistic-F1 assertion)
- Test: `frontend/src/shell/WorkflowBar.test.tsx`
**Interfaces:**
- Consumes: `useSessionStore` `currentPhase`/`phaseError`/`setPhase`.
- Produces: `WorkflowBar({ finalized }: { finalized?: boolean })` — when `finalized`, every dot is `data-state="done"`. Optimistic F1: `SteerInput` calls `setPhase("F1")` right after a new-question `createSession`.
- [ ] **Step 1: Write the failing tests**
Create `frontend/src/shell/WorkflowBar.test.tsx`:
```tsx
import { render, screen } from "@testing-library/react";
import { WorkflowBar } from "./WorkflowBar";
import { useSessionStore } from "../store/sessionStore";
beforeEach(() => useSessionStore.getState().resetSession());
test("currentPhase F1 renders F1 as running (yellow)", () => {
useSessionStore.getState().setPhase("F1");
render(<WorkflowBar />);
expect(screen.getByTestId("phase-F1")).toHaveAttribute("data-state", "running");
});
test("a phase before the active one is done (green)", () => {
useSessionStore.getState().setPhase("F3");
render(<WorkflowBar />);
expect(screen.getByTestId("phase-F1")).toHaveAttribute("data-state", "done");
expect(screen.getByTestId("phase-F3")).toHaveAttribute("data-state", "running");
expect(screen.getByTestId("phase-F5")).toHaveAttribute("data-state", "pending");
});
test("finalized marks all phases done (green)", () => {
useSessionStore.getState().setPhase("F8");
render(<WorkflowBar finalized />);
for (const id of ["F1", "F4", "F8"]) {
expect(screen.getByTestId(`phase-${id}`)).toHaveAttribute("data-state", "done");
}
});
```
Also add, in `frontend/src/shell/f1-loop.test.tsx`, an optimistic-F1 assertion right after the SSE connects (after line 39 `await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));`):
```tsx
// Optimistic lifecycle: a brand-new question paints F1 immediately (before any gate).
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F1"));
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd frontend && npx vitest run src/shell/WorkflowBar.test.tsx src/shell/f1-loop.test.tsx`
Expected: FAIL — `finalized` prop has no effect yet; `currentPhase` stays `null` after a new question.
- [ ] **Step 3: Implement the lifecycle changes**
In `frontend/src/shell/WorkflowBar.tsx`, change the signature and the state computation:
```tsx
export function WorkflowBar({ finalized = false }: { finalized?: boolean }) {
const currentPhase = useSessionStore((s) => s.currentPhase);
const phaseError = useSessionStore((s) => s.phaseError);
const activeIdx = PHASES.findIndex((p) => p.id === currentPhase);
```
Inside the `PHASES.map`, replace the `state` and `connectorDone` computations with:
```tsx
const isActive = currentPhase === p.id;
const isDone = activeIdx > -1 && i < activeIdx;
const state: DotState = finalized
? "done"
: isActive
? phaseError === p.id
? "error"
: "running"
: isDone
? "done"
: "pending";
const connectorDone = finalized || (activeIdx > -1 && i <= activeIdx);
```
In `frontend/src/shell/SteerInput.tsx`, add `setPhase` from the store and call it on new-question creation. Change the store hook (line ~27) and the `else` branch of `submit` (lines ~36-39):
```tsx
const setLastUserEntry = useSessionStore((s) => s.setLastUserEntry);
const setPhase = useSessionStore((s) => s.setPhase);
```
```tsx
} else {
const { id } = await createSession({ question: trimmed });
setPhase("F1"); // optimistic: paint F1 yellow during the cold start, before the first gate
onSessionCreated?.(id);
}
```
In `frontend/src/shell/AppShell.tsx`, derive `finalized` and pass it. Add near the other derived values (after line ~54 `const refresh = ...` or beside the `working` computation ~line 134):
```tsx
const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null;
const finalized = activeSession?.status === "finalized";
```
Then change the `<WorkflowBar />` render (line ~172) to:
```tsx
<WorkflowBar finalized={finalized} />
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd frontend && npx vitest run src/shell/WorkflowBar.test.tsx src/shell/f1-loop.test.tsx`
Expected: PASS.
Run: `cd frontend && npx tsc -b`
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/shell/WorkflowBar.tsx frontend/src/shell/SteerInput.tsx frontend/src/shell/AppShell.tsx frontend/src/shell/WorkflowBar.test.tsx frontend/src/shell/f1-loop.test.tsx
git commit -m "feat(workflow-bar): optimistic F1 at start; all-green when session finalized"
```
---
## Task 6: Frontend — total elapsed timer
**Files:**
- Create: `frontend/src/shell/ElapsedTimer.tsx`
- Test: `frontend/src/shell/ElapsedTimer.test.tsx`
- Modify: `frontend/src/shell/WorkflowBar.tsx` (render the timer after the dots)
- Modify: `frontend/src/shell/AppShell.tsx` (pass `createdAt`/`updatedAt`, with a local fallback)
**Interfaces:**
- Consumes: nothing new.
- Produces: `export function ElapsedTimer({ startedAt, stoppedAt }: { startedAt: string | null; stoppedAt?: string | null }): ReactElement | null` — ticks every second from `startedAt` until `stoppedAt` is set (then frozen); renders `null` when `startedAt` is null. Format `Xm Ys`. `WorkflowBar` gains `createdAt`/`updatedAt` props and renders `<ElapsedTimer>` after the phase dots.
- [ ] **Step 1: Write the failing test**
Create `frontend/src/shell/ElapsedTimer.test.tsx`:
```tsx
import { render, screen } from "@testing-library/react";
import { ElapsedTimer } from "./ElapsedTimer";
test("renders nothing without a start time", () => {
const { container } = render(<ElapsedTimer startedAt={null} />);
expect(container).toBeEmptyDOMElement();
});
test("frozen elapsed when stoppedAt is set (2m 5s)", () => {
render(
<ElapsedTimer
startedAt="2026-07-03T10:00:00.000Z"
stoppedAt="2026-07-03T10:02:05.000Z"
/>,
);
expect(screen.getByText("2m 5s")).toBeInTheDocument();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd frontend && npx vitest run src/shell/ElapsedTimer.test.tsx`
Expected: FAIL — cannot resolve `./ElapsedTimer`.
- [ ] **Step 3: Implement `ElapsedTimer` and render it in `WorkflowBar`**
Create `frontend/src/shell/ElapsedTimer.tsx`:
```tsx
import { useEffect, useState } from "react";
function fmt(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
return `${Math.floor(total / 60)}m ${total % 60}s`;
}
/**
* Total process time, anchored on the session's created_at (robust to reload and
* resume). Ticks every second while running; freezes at stoppedAt once the session
* is finalized. Renders nothing until a start time is known.
*/
export function ElapsedTimer({
startedAt,
stoppedAt,
}: {
startedAt: string | null;
stoppedAt?: string | null;
}) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!startedAt || stoppedAt) return;
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, [startedAt, stoppedAt]);
if (!startedAt) return null;
const start = new Date(startedAt).getTime();
const end = stoppedAt ? new Date(stoppedAt).getTime() : now;
return (
<span
className="shrink-0 tabular-nums text-xs text-muted-foreground"
aria-label="Elapsed time"
title="Total elapsed time"
>
{fmt(end - start)}
</span>
);
}
```
In `frontend/src/shell/WorkflowBar.tsx`: import the timer and extend the props, then wrap the returned `<nav>` so the timer sits after the dots.
Add the import at the top:
```tsx
import { ElapsedTimer } from "./ElapsedTimer";
```
Change the signature:
```tsx
export function WorkflowBar({
finalized = false,
createdAt = null,
updatedAt = null,
}: {
finalized?: boolean;
createdAt?: string | null;
updatedAt?: string | null;
}) {
```
Wrap the return: replace `return (\n <nav ...>` … `</nav>\n );` so the `<nav>` is nested inside a flex row with the timer:
```tsx
return (
<div className="flex items-center justify-center gap-3">
<nav
aria-label="Workflow progress"
className="flex items-center gap-0 overflow-x-auto py-0.5"
>
{/* …existing PHASES.map(...) unchanged… */}
</nav>
<ElapsedTimer startedAt={createdAt} stoppedAt={finalized ? updatedAt : null} />
</div>
);
```
(Only the wrapper and the removed `justify-center` on the `<nav>` change; the `PHASES.map` body is untouched.)
- [ ] **Step 4: Wire `AppShell` and run tests**
In `frontend/src/shell/AppShell.tsx`, add a local start fallback and pass the props. Near the `activeSession`/`finalized` lines from Task 5, add:
```tsx
const [localStart, setLocalStart] = useState<string | null>(null);
useEffect(() => {
if (activeSessionId && !activeSession) setLocalStart((prev) => prev ?? new Date().toISOString());
else if (!activeSessionId) setLocalStart(null);
}, [activeSessionId, activeSession]);
const startedAt = activeSession?.created_at ?? localStart;
```
Ensure `useEffect` is imported (AppShell already imports from `react`; change line 23 to include it):
```tsx
import { useEffect, useMemo, useRef, useState } from "react";
```
Change the WorkflowBar render (from Task 5) to:
```tsx
<WorkflowBar
finalized={finalized}
createdAt={startedAt}
updatedAt={activeSession?.updated_at ?? null}
/>
```
Run: `cd frontend && npx vitest run src/shell/ElapsedTimer.test.tsx src/shell/WorkflowBar.test.tsx`
Expected: PASS (timer unit tests; WorkflowBar tests still pass — no `createdAt` → timer renders null).
Run: `cd frontend && npx tsc -b`
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/shell/ElapsedTimer.tsx frontend/src/shell/ElapsedTimer.test.tsx frontend/src/shell/WorkflowBar.tsx frontend/src/shell/AppShell.tsx
git commit -m "feat(workflow-bar): total elapsed timer after the phase circles"
```
---
## Final verification
- [ ] **Frontend full suite + typecheck**
Run: `cd frontend && npx vitest run && npx tsc -b`
Expected: all tests PASS, no type errors.
- [ ] **Harness gate suite**
Run: `cd harness && npm test`
Expected: all `node --test` gate tests PASS.
---
## Self-Review
**Spec coverage:**
- P1 (phase lifecycle) → Task 5 (optimistic F1 + finalized all-green; error red retained via existing `phaseError`).
- P2 (timer) → Task 6.
- P3 (empty artifact gate) → Task 3 (`ArtifactView` reads `data`) + Task 4 (rendered in the gate).
- P4 (90% modal + erDiagram) → Task 4 (modal) + Task 2 (erDiagram) + Task 3 (routing).
- P5 (duplicate Altro/other) → Task 1.
All five covered.
**Placeholder scan:** No TBD/TODO; every code step shows full code and exact commands.
**Type consistency:** `buildErDiagram(promoted, joins)` name matches its call site (Task 2). `ArtifactView({ artifact })` prop shape matches `descriptor.artifact` (Task 4). `WorkflowBar` prop set grows monotonically: `{ finalized }` (Task 5) → `{ finalized, createdAt, updatedAt }` (Task 6). `ElapsedTimer` prop names (`startedAt`/`stoppedAt`) match both its test and the `WorkflowBar` call site. `setPhase` exists on the store (used by SteerInput). `isReserved`/`stripReserved` signatures unchanged.
**Note (out of scope, per spec):** the erDiagram draws relations only when `artifact.data.joins` is present; no harness/model change forces joins. Reserved control label stays English (`Other — specify`).