Files
ThothII/docs/superpowers/plans/2026-07-03-workflow-ui-fixes.md
T

37 KiB
Raw Blame History

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:

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 (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:

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 buildFlowchart with buildErDiagram

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 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:

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" 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:

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:

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: 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:

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 (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:

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:

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 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:

  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 (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).