37 KiB
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 -bis 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— modifyisReservedto normalize + prefix-match.harness/.pi/extensions/gate/__tests__/reserved_labels.test.js— new unit test.
Frontend
frontend/src/viewers/SchemaLinkingViewer.tsx— replacebuildFlowchartwith exportedbuildErDiagram(MermaiderDiagram).frontend/src/viewers/SchemaLinkingViewer.test.tsx— add abuildErDiagramunit test.frontend/src/viewers/ArtifactView.tsx— new: routeartifact.kind→ viewer, defensive ondata.frontend/src/viewers/ArtifactView.test.tsx— new.frontend/src/widgets/ArtifactGateWidget.tsx— rewrite body to render a 90% modal usingArtifactView(keep the export name and filename).frontend/src/widgets/ArtifactGateWidget.test.tsx— update to the modal behaviour.frontend/src/shell/WorkflowBar.tsx—finalized/createdAt/updatedAtprops; all-green on finalized; renderElapsedTimer.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— setcurrentPhase="F1"on new-question creation.frontend/src/shell/AppShell.tsx— passfinalized/createdAt/updatedAttoWorkflowBar.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 withaltroorother, or equals the normalized quit/back canonical labels.stripReserved(labels: string[]): string[]unchanged in signature; inherits the new matcher. Used bytht-gate.jsreviewer_select/reviewer_decideto drop model-supplied free-text options. -
Step 1: Write the failing test
Create harness/.pi/extensions/gate/__tests__/reserved_labels.test.js:
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:
// 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
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(replacebuildFlowchart, ~lines 36-57 and its call ~line 92) - Test:
frontend/src/viewers/SchemaLinkingViewer.test.tsx
Interfaces:
-
Consumes:
Candidate,Join(already exported fromSchemaLinkingViewer.tsx). -
Produces:
export function buildErDiagram(promoted: Candidate[], joins: Join[]): string— a MermaiderDiagramstring: promoted tables as entities, their promotedtable.columncandidates as attributes,joins(resolved to owning tables) as relationships. Consumed byArtifactViewindirectly (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:
import { SchemaLinkingViewer, buildErDiagram } from "./SchemaLinkingViewer";
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
buildFlowchartwithbuildErDiagram
In frontend/src/viewers/SchemaLinkingViewer.tsx, delete the buildFlowchart function (lines ~36-57) and add:
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
const def = buildFlowchart(promoted, linking.joins);
to
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
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 onartifact.kind, defensive aboutdata(string or object), JSON fallback otherwise. Consumed byArtifactGateWidget(Task 4). -
Step 1: Write the failing test
Create frontend/src/viewers/ArtifactView.test.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:
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
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"inwidgets/index.ts). Renders a90vw × 90vhmodal: artifact on top viaArtifactView, action bar (options + reserved) at the bottom. SameonRespondpayloads as before:{ id, kind: "artifact-gate", choices: [id] }, linkage merges childtext, 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:
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.tsxas the modal
Replace the entire contents of frontend/src/widgets/ArtifactGateWidget.tsx with:
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
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:
useSessionStorecurrentPhase/phaseError/setPhase. -
Produces:
WorkflowBar({ finalized }: { finalized?: boolean })— whenfinalized, every dot isdata-state="done". Optimistic F1:SteerInputcallssetPhase("F1")right after a new-questioncreateSession. -
Step 1: Write the failing tests
Create frontend/src/shell/WorkflowBar.test.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));):
// 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:
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:
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):
const setLastUserEntry = useSessionStore((s) => s.setLastUserEntry);
const setPhase = useSessionStore((s) => s.setPhase);
} 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):
const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null;
const finalized = activeSession?.status === "finalized";
Then change the <WorkflowBar /> render (line ~172) to:
<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
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(passcreatedAt/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 fromstartedAtuntilstoppedAtis set (then frozen); rendersnullwhenstartedAtis null. FormatXm Ys.WorkflowBargainscreatedAt/updatedAtprops and renders<ElapsedTimer>after the phase dots. -
Step 1: Write the failing test
Create frontend/src/shell/ElapsedTimer.test.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
ElapsedTimerand render it inWorkflowBar
Create frontend/src/shell/ElapsedTimer.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:
import { ElapsedTimer } from "./ElapsedTimer";
Change the signature:
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:
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
AppShelland 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:
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):
import { useEffect, useMemo, useRef, useState } from "react";
Change the WorkflowBar render (from Task 5) to:
<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
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 (
ArtifactViewreadsdata) + 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).