Files
ThothII/docs/superpowers/plans/2026-06-27-frontend-implementation.md
T
marcopanandClaude Opus 4.8 cabff8e77f docs(plans): piano di implementazione del frontend (13 task, F1 first)
Vite+React SPA: scaffold, api/types+client, store Zustand, useSessionStream (SSE),
widget registry+fallback, widget F1 (select/info/freetext), shell 4-zone + loop F1,
widget restanti+linkage, viewer (schema-linking/sql/results), sessioni+steering+resume,
Playwright e2e F1 vs backend+fake-pi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:02:21 +02:00

44 KiB
Raw Blame History

Frontend 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: Costruire il frontend ThothII: una SPA Vite+React+TS che presenta il workflow NL→SQL human-in-the-loop renderizzando i widget-descriptor via SSE e raccogliendo le decisioni del revisore via REST, consumando solo il contratto già implementato del backend.

Architecture: SPA client-only su localhost. TanStack Query per le REST cacheable, uno store Zustand per lo stato live della sessione, un hook useSessionStream che apre l'SSE (EventSource) e alimenta lo store. Un widget registry mappa kind→renderer con fallback universale. I viewer (schema-linking Mermaid, SQL via shiki, risultati AGGrid) vivono nella sidebar destra del layout a 4 zone. Build a slice verticali con il loop F1 chiuso il prima possibile.

Tech Stack: Vite 6, React 18, TypeScript 5.6, @tanstack/react-query 5, zustand 5, Tailwind 3.4 + ShadCn, ag-grid-react/community 32, shiki 1, mermaid 11; test: Vitest 2 + @testing-library/react 16 + jsdom + MSW 2; e2e: @playwright/test 1.

Global Constraints

  • Client-only SPA (FE-1): niente SSR/route server. Tutto gira nel browser su localhost e consuma il backend Fastify separato.
  • Base URL backend: da import.meta.env.VITE_BACKEND_URL, default http://localhost:8787. Mai hard-coded altrove.
  • Contratto FE↔BE invariato. SSE eventi: {type:"ui_request",ui_request} | {type:"text_delta",text} | {type:"info",level?,text} | {type:"system_event",event,...}. REST: GET /workspaces|/models|/sessions|/sessions/:id; POST /sessions|/sessions/:id/response|/steer|/sql/preview|/sql/export|/close|/resume. Widget kind: info|select|multiselect|freetext|artifact-gate|artifact + fallback.
  • SSE via EventSource nativo (FE-3): GET, nessun header (auth=none MVP). Non introdurre SSE su fetch in questo MVP.
  • TDD (FE-5): ogni task scrive prima il test (Vitest+RTL); MSW mocka le REST, un mock di EventSource simula l'SSE. Nessun test tocca il backend reale tranne il Playwright e2e (Task finale).
  • Widget isolati (FE-4): ogni renderer è un file con props {descriptor, onRespond}; aggiungere un widget = registrarlo, senza toccare store/stream/registry.
  • Invariante no-limbo: nessun widget può "chiudere senza rispondere"; Esc/cancel non è una risposta valida.
  • TypeScript strict; tutti i tipi del contratto vivono in src/api/types.ts (unica fonte). Niente any se non al confine del fallback.
  • Working dir: tutti i comandi da /Users/mp/projects/ThothII/frontend. Il backend (per l'e2e) è in ../backend, il fake-pi-rpc in ../harness/tests/fake_pi/.

Task 1: Scaffold Vite + React + TS + Tailwind/ShadCn + Vitest

Files:

  • Create: frontend/package.json, frontend/vite.config.ts, frontend/tsconfig.json, frontend/vitest.config.ts, frontend/index.html, frontend/tailwind.config.ts, frontend/postcss.config.js, frontend/src/main.tsx, frontend/src/App.tsx, frontend/src/index.css, frontend/src/test/setup.ts
  • Test: frontend/src/App.test.tsx

Interfaces:

  • Produces: un'app montabile; App componente root; npm test esegue Vitest (jsdom); npm run dev serve la SPA; npm run build (tsc + vite build) pulito.

  • Step 1: package.json + config

// frontend/package.json
{
  "name": "thothii-frontend",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview",
    "test": "vitest run",
    "test:watch": "vitest",
    "e2e": "playwright test"
  },
  "dependencies": {
    "react": "^18.3.1", "react-dom": "^18.3.1",
    "@tanstack/react-query": "^5.59.0", "zustand": "^5.0.0"
  },
  "devDependencies": {
    "vite": "^6.0.0", "@vitejs/plugin-react": "^4.3.0", "typescript": "^5.6.0",
    "vitest": "^2.1.0", "jsdom": "^25.0.0",
    "@testing-library/react": "^16.0.0", "@testing-library/jest-dom": "^6.5.0", "@testing-library/user-event": "^14.5.0",
    "msw": "^2.4.0",
    "tailwindcss": "^3.4.0", "postcss": "^8.4.0", "autoprefixer": "^10.4.0",
    "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0"
  }
}
// frontend/vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({ plugins: [react()] });
// frontend/vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
  plugins: [react()],
  test: { environment: "jsdom", globals: true, setupFiles: ["./src/test/setup.ts"], include: ["src/**/*.test.{ts,tsx}"] },
});
// frontend/tsconfig.json
{ "compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, "lib": ["ES2022","DOM","DOM.Iterable"],
  "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx", "strict": true, "noEmit": true,
  "esModuleInterop": true, "skipLibCheck": true, "types": ["vitest/globals","@testing-library/jest-dom"] },
  "include": ["src"] }

tailwind.config.ts (content: ["./index.html","./src/**/*.{ts,tsx}"]), postcss.config.js (tailwind+autoprefixer), index.html (root div + /src/main.tsx), src/index.css (@tailwind base/components/utilities).

  • Step 2: Write the failing test
// frontend/src/App.test.tsx
import { render, screen } from "@testing-library/react";
import { App } from "./App";
test("App renders the ThothII title", () => {
  render(<App />);
  expect(screen.getByText(/ThothII/i)).toBeInTheDocument();
});
  • Step 3: Run (fail)

Run: cd frontend && npm install && npm test Expected: FAIL — Cannot find module './App'.

  • Step 4: Implement App + main + setup
// frontend/src/App.tsx
export function App() {
  return <div className="p-4 text-lg font-semibold">ThothII</div>;
}
// frontend/src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);
// frontend/src/test/setup.ts
import "@testing-library/jest-dom/vitest";
  • Step 5: Run (pass) + ShadCn init + commit

Run: cd frontend && npm test → PASS. Then init ShadCn (npx shadcn@latest init -d) and add the base components used later: npx shadcn@latest add button checkbox radio-group textarea card dialog badge sonner. Verify npm run build clean.

git add frontend
git commit -m "feat(frontend): scaffold Vite+React+TS+Tailwind/ShadCn + Vitest"

Task 2: API types + REST client

Files:

  • Create: frontend/src/api/types.ts, frontend/src/api/client.ts, frontend/src/api/sessions.ts, frontend/src/api/workspaces.ts, frontend/src/api/models.ts, frontend/src/api/sql.ts
  • Create (test infra): frontend/src/test/msw.ts
  • Test: frontend/src/api/sessions.test.ts

Interfaces:

  • Produces (canonical contract types — every later task imports these):
export interface WidgetOption { id: string; label: string; meta?: Record<string, unknown>; selected?: boolean; recommended?: boolean; opens?: WidgetDescriptor; }
export interface WidgetDescriptor {
  id: string; schema_version?: number; session_id?: string; phase?: string;
  title?: string; intro?: string;
  widget: "info" | "select" | "multiselect" | "freetext" | "artifact-gate" | "artifact" | string;
  options?: WidgetOption[]; reserved?: string[]; allow_empty?: boolean;
  artifact?: { kind: string; content?: string; [k: string]: unknown };
  level?: "info" | "warning" | "error"; text?: string;
  [k: string]: unknown;
}
export interface UiResponse { id: string; kind?: string; choices?: string[]; text?: string; decision?: { type: string }; control?: string; }
export type StreamEvent =
  | { type: "ui_request"; ui_request: WidgetDescriptor }
  | { type: "text_delta"; text: string }
  | { type: "info"; level?: "info" | "warning" | "error"; text: string }
  | { type: "system_event"; event: string; [k: string]: unknown };
export interface SessionSummary { id: string; status: string; question: string; summary: string | null; created_at: string; updated_at: string | null; author: string | null; }
export interface PreviewResult { columns: string[]; rows: unknown[][]; execution_ms: number; truncated: boolean; limit: number; offset: number; }
  • Produces (functions): createSession(input): Promise<{id:string}>, listSessions(): Promise<SessionSummary[]>, getSession(id): Promise<any>, postResponse(id, uiResponse): Promise<void>, postSteer(id, text): Promise<void>, closeSession(id): Promise<void>, resumeSession(id): Promise<void>, listWorkspaces(): Promise<{name:string;file:string}[]>, listModels(): Promise<{models:unknown[]}>, sqlPreview(id,{limit?,offset?}): Promise<PreviewResult>, sqlExport(id): Promise<{path:string}>. Base: apiFetch(path, init?) in client.ts using VITE_BACKEND_URL.

  • Step 1: MSW test infra + failing test

// frontend/src/test/msw.ts
import { setupServer } from "msw/node";
export const server = setupServer();

Register in src/test/setup.ts: beforeAll(()=>server.listen()); afterEach(()=>server.resetHandlers()); afterAll(()=>server.close()); (import server + vitest globals).

// frontend/src/api/sessions.test.ts
import { http, HttpResponse } from "msw";
import { server } from "../test/msw";
import { createSession, listSessions } from "./sessions";

test("createSession POSTs and returns the id", async () => {
  server.use(http.post("http://localhost:8787/sessions", () => HttpResponse.json({ id: "s1" })));
  expect(await createSession({ workspace: "w", question: "q" })).toEqual({ id: "s1" });
});
test("listSessions GETs the array", async () => {
  server.use(http.get("http://localhost:8787/sessions", () => HttpResponse.json([{ id: "s1", status: "open", question: "q", summary: null, created_at: "t", updated_at: null, author: null }])));
  const rows = await listSessions();
  expect(rows[0].id).toBe("s1");
});
  • Step 2: Run (fail)

Run: cd frontend && npm test -- sessions Expected: FAIL — modules absent.

  • Step 3: Implement client + api modules
// frontend/src/api/client.ts
const BASE = import.meta.env.VITE_BACKEND_URL ?? "http://localhost:8787";
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
  const res = await fetch(`${BASE}${path}`, { headers: { "content-type": "application/json" }, ...init });
  if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => "")}`);
  return res.status === 204 ? (undefined as T) : ((await res.json()) as T);
}
export { BASE };
// frontend/src/api/sessions.ts
import { apiFetch } from "./client";
import type { SessionSummary, UiResponse } from "./types";
export const createSession = (i: { workspace: string; question: string; provider?: string; model?: string; thinking?: string; name?: string }) =>
  apiFetch<{ id: string }>("/sessions", { method: "POST", body: JSON.stringify(i) });
export const listSessions = () => apiFetch<SessionSummary[]>("/sessions");
export const getSession = (id: string) => apiFetch<any>(`/sessions/${id}`);
export const postResponse = (id: string, uiResponse: UiResponse) => apiFetch<void>(`/sessions/${id}/response`, { method: "POST", body: JSON.stringify({ ui_response: uiResponse }) });
export const postSteer = (id: string, text: string) => apiFetch<void>(`/sessions/${id}/steer`, { method: "POST", body: JSON.stringify({ text }) });
export const closeSession = (id: string) => apiFetch<void>(`/sessions/${id}/close`, { method: "POST" });
export const resumeSession = (id: string) => apiFetch<void>(`/sessions/${id}/resume`, { method: "POST" });

workspaces.ts (listWorkspaces), models.ts (listModels), sql.ts (sqlPreview, sqlExport) follow the same pattern with their endpoints; types.ts holds the interfaces above.

  • Step 4: Run (pass) + commit

Run: cd frontend && npm test -- sessions → PASS; npm run build clean.

git add frontend/src/api frontend/src/test
git commit -m "feat(frontend): contract types + REST client (MSW-tested)"

Task 3: Session store (Zustand)

Files:

  • Create: frontend/src/store/sessionStore.ts
  • Test: frontend/src/store/sessionStore.test.ts

Interfaces:

  • Consumes: StreamEvent, WidgetDescriptor (Task 2).

  • Produces: useSessionStore (Zustand) with state { pendingWidget: WidgetDescriptor|null; transcript: {role:"assistant";text:string}[]; toasts: {level:string;text:string}[]; lastSystemEvent: StreamEvent|null } and actions applyEvent(e: StreamEvent): void, clearPending(): void, resetSession(): void. applyEvent: ui_request→set pendingWidget; text_delta→append to the current assistant transcript entry (create if none/after a widget); info→push toast; system_event→set lastSystemEvent.

  • Step 1: Failing test

// frontend/src/store/sessionStore.test.ts
import { useSessionStore } from "./sessionStore";
beforeEach(() => useSessionStore.getState().resetSession());

test("ui_request sets pendingWidget", () => {
  useSessionStore.getState().applyEvent({ type: "ui_request", ui_request: { id: "u1", widget: "select" } });
  expect(useSessionStore.getState().pendingWidget?.id).toBe("u1");
});
test("text_delta accumulates into transcript", () => {
  const s = useSessionStore.getState();
  s.applyEvent({ type: "text_delta", text: "Ana" });
  s.applyEvent({ type: "text_delta", text: "lisi" });
  expect(useSessionStore.getState().transcript.at(-1)?.text).toBe("Analisi");
});
test("info pushes a toast", () => {
  useSessionStore.getState().applyEvent({ type: "info", level: "warning", text: "attenzione" });
  expect(useSessionStore.getState().toasts.at(-1)).toEqual({ level: "warning", text: "attenzione" });
});
test("clearPending removes the widget", () => {
  useSessionStore.getState().applyEvent({ type: "ui_request", ui_request: { id: "u1", widget: "select" } });
  useSessionStore.getState().clearPending();
  expect(useSessionStore.getState().pendingWidget).toBeNull();
});
  • Step 2: Run (fail) — npm test -- sessionStore → module absent.

  • Step 3: Implement

// frontend/src/store/sessionStore.ts
import { create } from "zustand";
import type { StreamEvent, WidgetDescriptor } from "../api/types";
interface Entry { role: "assistant"; text: string }
interface SessionState {
  pendingWidget: WidgetDescriptor | null; transcript: Entry[]; toasts: { level: string; text: string }[]; lastSystemEvent: StreamEvent | null;
  applyEvent: (e: StreamEvent) => void; clearPending: () => void; resetSession: () => void;
}
const empty = { pendingWidget: null, transcript: [] as Entry[], toasts: [] as { level: string; text: string }[], lastSystemEvent: null };
export const useSessionStore = create<SessionState>((set) => ({
  ...empty,
  applyEvent: (e) => set((st) => {
    if (e.type === "ui_request") return { pendingWidget: e.ui_request };
    if (e.type === "text_delta") {
      const t = [...st.transcript];
      const last = t.at(-1);
      if (last && !st.pendingWidget) t[t.length - 1] = { role: "assistant", text: last.text + e.text };
      else t.push({ role: "assistant", text: e.text });
      return { transcript: t };
    }
    if (e.type === "info") return { toasts: [...st.toasts, { level: e.level ?? "info", text: e.text }] };
    if (e.type === "system_event") return { lastSystemEvent: e };
    return {};
  }),
  clearPending: () => set({ pendingWidget: null }),
  resetSession: () => set({ ...empty }),
}));
  • Step 4: Run (pass) + commit

Run: npm test -- sessionStore → PASS.

git add frontend/src/store
git commit -m "feat(frontend): Zustand session store + applyEvent"

Task 4: useSessionStream (SSE → store)

Files:

  • Create: frontend/src/stream/useSessionStream.ts
  • Create (test): frontend/src/test/fakeEventSource.ts
  • Test: frontend/src/stream/useSessionStream.test.tsx

Interfaces:

  • Consumes: useSessionStore.applyEvent (Task 3), BASE (Task 2).

  • Produces: useSessionStream(sessionId: string | null): { connected: boolean } — when sessionId is set, opens new EventSource(\${BASE}/sessions/${id}/events`), parses each message dataas JSONStreamEvent, calls applyEvent; closes on unmount / id change. Uses the global EventSource` (overridable in tests via a fake).

  • Step 1: Fake EventSource + failing test

// frontend/src/test/fakeEventSource.ts
export class FakeEventSource {
  static instances: FakeEventSource[] = [];
  onmessage: ((e: { data: string }) => void) | null = null;
  onopen: (() => void) | null = null;
  onerror: (() => void) | null = null;
  closed = false;
  constructor(public url: string) { FakeEventSource.instances.push(this); }
  emit(obj: unknown) { this.onmessage?.({ data: JSON.stringify(obj) }); }
  close() { this.closed = true; }
}
// frontend/src/stream/useSessionStream.test.tsx
import { renderHook } from "@testing-library/react";
import { act } from "react";
import { FakeEventSource } from "../test/fakeEventSource";
import { useSessionStream } from "./useSessionStream";
import { useSessionStore } from "../store/sessionStore";

beforeEach(() => { FakeEventSource.instances = []; (globalThis as any).EventSource = FakeEventSource; useSessionStore.getState().resetSession(); });

test("opens an EventSource for the session and feeds events to the store", () => {
  renderHook(() => useSessionStream("s1"));
  const es = FakeEventSource.instances[0];
  expect(es.url).toContain("/sessions/s1/events");
  act(() => es.emit({ type: "ui_request", ui_request: { id: "u1", widget: "select" } }));
  expect(useSessionStore.getState().pendingWidget?.id).toBe("u1");
});
test("closes the stream on unmount", () => {
  const { unmount } = renderHook(() => useSessionStream("s1"));
  const es = FakeEventSource.instances[0];
  unmount();
  expect(es.closed).toBe(true);
});
  • Step 2: Run (fail) — module absent.

  • Step 3: Implement

// frontend/src/stream/useSessionStream.ts
import { useEffect, useState } from "react";
import { BASE } from "../api/client";
import { useSessionStore } from "../store/sessionStore";
import type { StreamEvent } from "../api/types";
export function useSessionStream(sessionId: string | null) {
  const [connected, setConnected] = useState(false);
  const applyEvent = useSessionStore((s) => s.applyEvent);
  useEffect(() => {
    if (!sessionId) return;
    const es = new EventSource(`${BASE}/sessions/${sessionId}/events`);
    es.onopen = () => setConnected(true);
    es.onerror = () => setConnected(false);
    es.onmessage = (ev) => { try { applyEvent(JSON.parse(ev.data) as StreamEvent); } catch { /* ignore malformed */ } };
    return () => { es.close(); setConnected(false); };
  }, [sessionId, applyEvent]);
  return { connected };
}
  • Step 4: Run (pass) + commit

Run: npm test -- useSessionStream → PASS.

git add frontend/src/stream frontend/src/test/fakeEventSource.ts
git commit -m "feat(frontend): useSessionStream (EventSource -> store)"

Task 5: Widget registry + fallback

Files:

  • Create: frontend/src/widgets/registry.ts, frontend/src/widgets/FallbackWidget.tsx, frontend/src/widgets/types.ts
  • Test: frontend/src/widgets/registry.test.tsx

Interfaces:

  • Consumes: WidgetDescriptor, UiResponse (Task 2).

  • Produces: WidgetProps = { descriptor: WidgetDescriptor; onRespond: (r: UiResponse) => void } (widgets/types.ts); register(kind: string, comp: React.FC<WidgetProps>): void; resolve(kind: string): React.FC<WidgetProps> (returns FallbackWidget for unknown). FallbackWidget renders the descriptor JSON + a freetext box that responds with {id, control:"freetext", text}.

  • Step 1: Failing test

// frontend/src/widgets/registry.test.tsx
import { render, screen } from "@testing-library/react";
import { register, resolve } from "./registry";
import type { WidgetProps } from "./types";

test("resolve returns the registered renderer", () => {
  const Dummy = (_: WidgetProps) => <div>dummy</div>;
  register("dummy", Dummy);
  expect(resolve("dummy")).toBe(Dummy);
});
test("resolve falls back for unknown kind and shows the JSON", () => {
  const Comp = resolve("totally-unknown");
  render(<Comp descriptor={{ id: "u1", widget: "totally-unknown", title: "X" } as any} onRespond={() => {}} />);
  expect(screen.getByText(/Widget non supportato/i)).toBeInTheDocument();
});
  • Step 2: Run (fail) — modules absent.

  • Step 3: Implement

// frontend/src/widgets/types.ts
import type { WidgetDescriptor, UiResponse } from "../api/types";
export type WidgetProps = { descriptor: WidgetDescriptor; onRespond: (r: UiResponse) => void };
// frontend/src/widgets/FallbackWidget.tsx
import { useState } from "react";
import type { WidgetProps } from "./types";
export function FallbackWidget({ descriptor, onRespond }: WidgetProps) {
  const [text, setText] = useState("");
  return (
    <div className="border rounded p-3 space-y-2">
      <p className="text-sm text-amber-600">Widget non supportato (kind: {descriptor.widget}) — rispondi manualmente</p>
      <pre className="text-xs overflow-auto max-h-48 bg-muted p-2">{JSON.stringify(descriptor, null, 2)}</pre>
      <textarea className="w-full border rounded p-1" value={text} onChange={(e) => setText(e.target.value)} />
      <button className="border rounded px-2 py-1" onClick={() => onRespond({ id: descriptor.id, control: "freetext", text })}>Invia</button>
    </div>
  );
}
// frontend/src/widgets/registry.ts
import type React from "react";
import type { WidgetProps } from "./types";
import { FallbackWidget } from "./FallbackWidget";
const registry = new Map<string, React.FC<WidgetProps>>();
export function register(kind: string, comp: React.FC<WidgetProps>) { registry.set(kind, comp); }
export function resolve(kind: string): React.FC<WidgetProps> { return registry.get(kind) ?? FallbackWidget; }
  • Step 4: Run (pass) + commit

Run: npm test -- registry → PASS.

git add frontend/src/widgets
git commit -m "feat(frontend): widget registry + universal fallback"

Task 6: F1 widgets — ReservedControls + select/info/freetext

Files:

  • Create: frontend/src/widgets/ReservedControls.tsx, frontend/src/widgets/SelectWidget.tsx, frontend/src/widgets/InfoWidget.tsx, frontend/src/widgets/FreetextWidget.tsx, frontend/src/widgets/index.ts
  • Test: frontend/src/widgets/SelectWidget.test.tsx, frontend/src/widgets/FreetextWidget.test.tsx

Interfaces:

  • Consumes: WidgetProps (Task 5), register (Task 5).

  • Produces: ReservedControls({reserved, onControl}) renders buttons for back/exit/other present in reserved[]. SelectWidget (single-pick: each option a button; recommended gets a "(consigliato)" badge; responds {id, kind:"select", choices:[optionId], decision?}; reserved → {id, control}). InfoWidget (non-blocking, shows text/level; auto no response). FreetextWidget (textarea → {id, kind:"freetext", text}). index.ts registers select/info/freetext into the registry on import.

  • Step 1: Failing tests

// frontend/src/widgets/SelectWidget.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SelectWidget } from "./SelectWidget";

test("picking an option responds with its id", async () => {
  const onRespond = vi.fn();
  render(<SelectWidget descriptor={{ id: "u1", widget: "select", options: [{ id: "a", label: "A" }, { id: "b", label: "B", recommended: true }] }} onRespond={onRespond} />);
  expect(screen.getByText(/consigliato/i)).toBeInTheDocument();
  await userEvent.click(screen.getByRole("button", { name: /A/ }));
  expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "select", choices: ["a"] });
});
test("a reserved control responds with control, not a choice", async () => {
  const onRespond = vi.fn();
  render(<SelectWidget descriptor={{ id: "u1", widget: "select", options: [{ id: "a", label: "A" }], reserved: ["back"] }} onRespond={onRespond} />);
  await userEvent.click(screen.getByRole("button", { name: /indietro/i }));
  expect(onRespond).toHaveBeenCalledWith({ id: "u1", control: "back" });
});
// frontend/src/widgets/FreetextWidget.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FreetextWidget } from "./FreetextWidget";
test("submits typed text", async () => {
  const onRespond = vi.fn();
  render(<FreetextWidget descriptor={{ id: "u1", widget: "freetext" }} onRespond={onRespond} />);
  await userEvent.type(screen.getByRole("textbox"), "ciao");
  await userEvent.click(screen.getByRole("button", { name: /invia/i }));
  expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "freetext", text: "ciao" });
});
  • Step 2: Run (fail) — modules absent.

  • Step 3: Implement the widgets

// frontend/src/widgets/ReservedControls.tsx
const LABELS: Record<string, string> = { back: "Torna indietro", exit: "Esci", other: "Altro — specifica" };
export function ReservedControls({ reserved, onControl }: { reserved?: string[]; onControl: (c: string) => void }) {
  if (!reserved?.length) return null;
  return <div className="flex gap-2 pt-2">{reserved.map((c) => (
    <button key={c} className="text-sm border rounded px-2 py-1" onClick={() => onControl(c)}>{LABELS[c] ?? c}</button>
  ))}</div>;
}
// frontend/src/widgets/SelectWidget.tsx
import type { WidgetProps } from "./types";
import { ReservedControls } from "./ReservedControls";
export function SelectWidget({ descriptor, onRespond }: WidgetProps) {
  return (
    <div className="space-y-2">
      {descriptor.title && <p className="font-medium">{descriptor.title}</p>}
      {descriptor.intro && <p className="text-sm text-muted-foreground">{descriptor.intro}</p>}
      <div className="flex flex-col gap-2">
        {descriptor.options?.map((o) => (
          <button key={o.id} className="border rounded px-3 py-2 text-left hover:bg-accent"
            onClick={() => onRespond({ id: descriptor.id, kind: "select", choices: [o.id] })}>
            {o.label}{o.recommended && <span className="ml-2 text-xs text-green-600">(consigliato)</span>}
          </button>
        ))}
      </div>
      <ReservedControls reserved={descriptor.reserved} onControl={(c) => onRespond({ id: descriptor.id, control: c })} />
    </div>
  );
}
// frontend/src/widgets/InfoWidget.tsx
import type { WidgetProps } from "./types";
export function InfoWidget({ descriptor }: WidgetProps) {
  const color = descriptor.level === "error" ? "text-red-600" : descriptor.level === "warning" ? "text-amber-600" : "text-foreground";
  return <p className={`text-sm ${color}`}>{descriptor.text ?? descriptor.title}</p>;
}
// frontend/src/widgets/FreetextWidget.tsx
import { useState } from "react";
import type { WidgetProps } from "./types";
export function FreetextWidget({ descriptor, onRespond }: WidgetProps) {
  const [text, setText] = useState("");
  return (
    <div className="space-y-2">
      {descriptor.title && <p className="font-medium">{descriptor.title}</p>}
      <textarea className="w-full border rounded p-2" value={text} onChange={(e) => setText(e.target.value)} />
      <button className="border rounded px-3 py-1" onClick={() => onRespond({ id: descriptor.id, kind: "freetext", text })}>Invia</button>
    </div>
  );
}
// frontend/src/widgets/index.ts
import { register } from "./registry";
import { SelectWidget } from "./SelectWidget";
import { InfoWidget } from "./InfoWidget";
import { FreetextWidget } from "./FreetextWidget";
register("select", SelectWidget); register("info", InfoWidget); register("freetext", FreetextWidget);
export { resolve } from "./registry";
  • Step 4: Run (pass) + commit

Run: npm test -- SelectWidget FreetextWidget → PASS.

git add frontend/src/widgets
git commit -m "feat(frontend): F1 widgets (select/info/freetext) + reserved controls"

Task 7: App shell (4 zone) + F1 loop end-to-end (MSW)

Files:

  • Create: frontend/src/shell/AppShell.tsx, frontend/src/shell/WidgetHost.tsx, frontend/src/app/queryClient.ts
  • Modify: frontend/src/App.tsx (compose shell + QueryClientProvider), frontend/src/main.tsx (import ./widgets to register)
  • Test: frontend/src/shell/f1-loop.test.tsx

Interfaces:

  • Consumes: useSessionStream (Task 4), useSessionStore (Task 3), resolve (Task 6), createSession/postResponse (Task 2).

  • Produces: AppShell rendering the 4 zones (nav, workflow bar, chat+input center with WidgetHost, right sidebar). WidgetHost reads pendingWidget from the store and renders resolve(widget)(descriptor, onRespond), where onRespond calls postResponse(activeSessionId, r) then clearPending(). App wraps everything in QueryClientProvider.

  • Step 1: Failing integration test (MSW + fake SSE)

// frontend/src/shell/f1-loop.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { act } from "react";
import { server } from "../test/msw";
import { FakeEventSource } from "../test/fakeEventSource";
import { App } from "../App";
import { useSessionStore } from "../store/sessionStore";

beforeEach(() => { FakeEventSource.instances = []; (globalThis as any).EventSource = FakeEventSource; useSessionStore.getState().resetSession(); });

test("F1: create session -> widget via SSE -> respond -> POST /response", async () => {
  let responded: any = null;
  server.use(
    http.post("http://localhost:8787/sessions", () => HttpResponse.json({ id: "s1" })),
    http.get("http://localhost:8787/sessions", () => HttpResponse.json([])),
    http.post("http://localhost:8787/sessions/s1/response", async ({ request }) => { responded = await request.json(); return new HttpResponse(null, { status: 204 }); }),
  );
  render(<App />);
  await userEvent.click(screen.getByRole("button", { name: /nuova/i }));         // start a session (UI affordance)
  // simulate the backend emitting the F1 widget
  act(() => FakeEventSource.instances[0].emit({ type: "ui_request", ui_request: { id: "u1", widget: "select", title: "Disambigua", options: [{ id: "a", label: "interpretazione A" }] } }));
  await userEvent.click(await screen.findByRole("button", { name: /interpretazione A/ }));
  expect(responded).toEqual({ ui_response: { id: "u1", kind: "select", choices: ["a"] } });
});

Nota: il bottone "nuova" rappresenta l'affordance minima di creazione sessione di questo slice; la lista/creazione complete arrivano in Task 12. L'handler createSession fissa activeSessionId="s1", su cui useSessionStream apre il fake SSE.

  • Step 2: Run (fail) — shell absent.

  • Step 3: Implement shell + host + wiring

queryClient.ts exports a configured QueryClient. AppShell lays out 4 zones with Tailwind grid; a "Nuova domanda" button calls createSession({workspace, question}), stores activeSessionId, and mounts useSessionStream(activeSessionId). WidgetHost:

// frontend/src/shell/WidgetHost.tsx
import { useSessionStore } from "../store/sessionStore";
import { resolve } from "../widgets";
import { postResponse } from "../api/sessions";
import type { UiResponse } from "../api/types";
export function WidgetHost({ sessionId }: { sessionId: string | null }) {
  const pending = useSessionStore((s) => s.pendingWidget);
  const clearPending = useSessionStore((s) => s.clearPending);
  if (!pending) return null;
  const Renderer = resolve(pending.widget);
  const onRespond = async (r: UiResponse) => { if (sessionId) await postResponse(sessionId, r); clearPending(); };
  return <Renderer descriptor={pending} onRespond={onRespond} />;
}

App.tsx wraps AppShell in QueryClientProvider; main.tsx adds import "./widgets"; so renderers register.

  • Step 4: Run (pass) + commit

Run: npm test -- f1-loop → PASS; full npm test green; npm run build clean.

git add frontend/src/shell frontend/src/app frontend/src/App.tsx frontend/src/main.tsx
git commit -m "feat(frontend): 4-zone shell + F1 loop end-to-end (MSW)"

Task 8: Remaining widgets — multiselect, artifact-gate, artifact + linkage + no-limbo

Files:

  • Create: frontend/src/widgets/MultiselectWidget.tsx, frontend/src/widgets/ArtifactGateWidget.tsx, frontend/src/widgets/ArtifactWidget.tsx, frontend/src/widgets/LinkageHost.tsx
  • Modify: frontend/src/widgets/index.ts (register the three)
  • Test: frontend/src/widgets/MultiselectWidget.test.tsx, frontend/src/widgets/ArtifactGateWidget.test.tsx, frontend/src/widgets/linkage.test.tsx

Interfaces:

  • Consumes: WidgetProps, ReservedControls, register (Tasks 5–6).

  • Produces: MultiselectWidget (checkboxes; initial selected; "seleziona/deseleziona tutti"; allow_empty gates the confirm; responds {id, kind:"multiselect", choices}). ArtifactGateWidget (renders artifact.content scrollable + disposizioni from options/reserved; "Rifiuta" opens a freetext via linkage; responds with the chosen disposition + optional text). ArtifactWidget (view-only, no response). LinkageHost wraps a renderer: if a chosen option.opens, it shows the child widget and merges both into one UiResponse ({...parent, text: childText}).

  • Step 1: Failing tests (multiselect select-all + allow_empty; artifact-gate reject→freetext linkage returns combined response). Full RTL tests with userEvent asserting the emitted UiResponse.

// frontend/src/widgets/MultiselectWidget.test.tsx (excerpt)
test("confirms the checked ids", async () => {
  const onRespond = vi.fn();
  render(<MultiselectWidget descriptor={{ id: "u1", widget: "multiselect", options: [{ id: "t1", label: "t1", selected: true }, { id: "t2", label: "t2" }] }} onRespond={onRespond} />);
  await userEvent.click(screen.getByRole("checkbox", { name: /t2/ }));
  await userEvent.click(screen.getByRole("button", { name: /conferma/i }));
  expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: ["t1", "t2"] });
});
// frontend/src/widgets/linkage.test.tsx (excerpt)
test("reject opens a freetext and combines the reason", async () => {
  const onRespond = vi.fn();
  render(<ArtifactGateWidget descriptor={{ id: "u1", widget: "artifact-gate", artifact: { kind: "cte", content: "SELECT 1" },
    options: [{ id: "approve", label: "Approva" }, { id: "reject", label: "Rifiuta", opens: { id: "u1c", widget: "freetext", title: "Motivazione" } }] }} onRespond={onRespond} />);
  await userEvent.click(screen.getByRole("button", { name: /Rifiuta/ }));
  await userEvent.type(screen.getByRole("textbox"), "join sbagliata");
  await userEvent.click(screen.getByRole("button", { name: /invia/i }));
  expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "artifact-gate", choices: ["reject"], text: "join sbagliata" });
});
  • Step 2: Run (fail) — modules absent.

  • Step 3: Implement the three widgets + LinkageHost. Multiselect tracks a Set seeded from selected; confirm disabled when empty and allow_empty===false. ArtifactGate shows artifact.content in a scrollable <pre>; each option is a button; an option with opens routes through LinkageHost to collect child text before responding. Artifact is view-only. Register all three in index.ts.

  • Step 4: Run (pass) + commit

Run: npm test -- MultiselectWidget ArtifactGateWidget linkage → PASS.

git add frontend/src/widgets
git commit -m "feat(frontend): multiselect/artifact-gate/artifact widgets + linkage + no-limbo"

Task 9: SchemaLinkingViewer (Mermaid + table) + MarkdownView

Files:

  • Create: frontend/src/viewers/SchemaLinkingViewer.tsx, frontend/src/viewers/MarkdownView.tsx, frontend/src/viewers/mermaid.ts
  • Test: frontend/src/viewers/SchemaLinkingViewer.test.tsx
  • Deps: npm i mermaid react-markdown remark-gfm

Interfaces:

  • Consumes: the schema_linking.json artifact shape ({candidates:[{kind,name,decision,...}], joins:[{from,to,...}], excluded:[...], open_questions:[]} — from GET /sessions/:id/artifacts/... or embedded in an artifact descriptor).

  • Produces: SchemaLinkingViewer({linking}) — default Mermaid vertical flowchart (top-to-bottom), toggle to a hierarchical table; caps at 45 elements (shows a notice if exceeded); inline "perché" comment per node. MarkdownView({source}) renders markdown (react-markdown + remark-gfm) with mermaid code-fences rendered via mermaid.ts (renderMermaid(def): Promise<svg>).

  • Step 1: Failing test — render with a small linking object, assert the toggle switches between the mermaid container and a table that lists the promoted tables; assert the ≤45 cap notice appears for an oversized input. (Mock mermaid.ts's renderMermaid to return a stub <svg> so the test is deterministic.)

  • Step 2: Run (fail) — module absent.

  • Step 3: Implement mermaid.ts (lazy import("mermaid"), mermaid.render), SchemaLinkingViewer (build the flowchart definition from candidates/joins; table view maps candidates by kind), MarkdownView.

  • Step 4: Run (pass) + commit

git add frontend/src/viewers
git commit -m "feat(frontend): schema-linking viewer (mermaid+table) + markdown view"

Task 10: SqlViewer (shiki, collapsible)

Files:

  • Create: frontend/src/viewers/SqlViewer.tsx, frontend/src/viewers/highlight.ts
  • Test: frontend/src/viewers/SqlViewer.test.tsx
  • Deps: npm i shiki

Interfaces:

  • Consumes: a CTE/SQL artifact ({name, fields?, testStatus?, sql, comments?} for CTEs; the final SQL is the same shape with the full SELECT).

  • Produces: SqlViewer({blocks}) — collapsible code blocks (▾/▸) each with a header (name, n° fields, test status badge), shiki-highlighted SQL, per-field comment; a global vertical/horizontal toggle. highlight.ts exports highlightSql(code): Promise<string> (shiki, sql grammar). Mock highlight.ts in tests for determinism.

  • Step 1: Failing test — render two blocks, assert headers show name + field count + test-status badge; clicking a header collapses/expands the highlighted body.

  • Step 2–4: implement, pass, commit (feat(frontend): SQL/CTE viewer with shiki highlighting).


Task 11: ResultsPanel (AGGrid + preview/export)

Files:

  • Create: frontend/src/viewers/ResultsPanel.tsx
  • Test: frontend/src/viewers/ResultsPanel.test.tsx
  • Deps: npm i ag-grid-react ag-grid-community

Interfaces:

  • Consumes: sqlPreview(id,{limit,offset})→PreviewResult, sqlExport(id)→{path} (Task 2).

  • Produces: ResultsPanel({sessionId}) — a [10 ▾ / tutti] selector driving sqlPreview (limit/offset), an AGGrid showing columns/rows, an "Esporta CSV" button calling sqlExport. A single scalar result (1 col × 1 row) renders bold instead of a grid. Loading/error states from TanStack Query.

  • Step 1: Failing test (MSW) — sqlPreview mocked returns 2 cols × 2 rows → AGGrid renders the rows; mock a 1×1 result → renders bold number; export button calls /sql/export. (AGGrid renders in jsdom; assert on cell text. If AGGrid needs the module registered, register AllCommunityModule in the panel.)

  • Step 2–4: implement, pass, commit (feat(frontend): results panel (AGGrid + preview/export)).


Task 12: Sessions list/create + steering + resume + selectors

Files:

  • Create: frontend/src/shell/NavSessions.tsx, frontend/src/shell/NewSessionDialog.tsx, frontend/src/shell/SteerInput.tsx, frontend/src/shell/WorkflowBar.tsx
  • Modify: frontend/src/shell/AppShell.tsx (wire them)
  • Test: frontend/src/shell/NavSessions.test.tsx, frontend/src/shell/SteerInput.test.tsx

Interfaces:

  • Consumes: listSessions, createSession, resumeSession, postSteer, listWorkspaces, listModels (Task 2); TanStack Query.

  • Produces: NavSessions (left nav: query listSessions, click selects/resumeSessions a session). NewSessionDialog (ShadCn dialog: question + workspace select from listWorkspaces + optional model/thinking/provider from listModels, degrading gracefully to a free input when models is empty; calls createSession). SteerInput (a text field that sends postSteer for free-text !-style steering during a session). WorkflowBar (renders the 8 phases, highlighting currentPhase from the store/getSession).

  • Step 1: Failing tests — NavSessions lists sessions from a mocked listSessions and selecting one triggers resumeSession; SteerInput posts to /steer. (MSW.)

  • Step 2–4: implement, pass, commit (feat(frontend): sessions list/create + steering + resume + selectors).

Graceful degradation (spec §9): when listModels() returns {models:[]}, the model field is a free text input with the configured default, not an empty dropdown.


Task 13: Playwright e2e — F1 loop against real backend + fake-pi-rpc

Files:

  • Create: frontend/playwright.config.ts, frontend/e2e/f1.spec.ts, frontend/e2e/fixtures/start-stack.ts
  • Deps: npm i -D @playwright/test && npx playwright install chromium

Interfaces:

  • Consumes: the built/served frontend (npm run dev / vite preview) + the real backend (../backend) started with an injected spawnFn pointing at ../harness/tests/fake_pi/fake_pi_rpc.mjs, OR the backend npm run dev with PI_BIN swapped to a wrapper that execs the fake. The e2e drives the browser through the F1 loop.

  • Step 1: Write the e2e spec

// frontend/e2e/f1.spec.ts (shape)
import { test, expect } from "@playwright/test";
test("F1 loop: new question -> F1 widget -> respond", async ({ page }) => {
  await page.goto("/");
  await page.getByRole("button", { name: /nuova/i }).click();
  await page.getByLabel(/domanda/i).fill("quante cardioversioni nel 2024");
  await page.getByRole("button", { name: /crea/i }).click();
  await expect(page.getByText(/disambigua|chiarimento/i)).toBeVisible({ timeout: 30000 });
  await page.getByRole("button").first().click();           // pick a disambiguation option
  await expect(page.locator("body")).not.toContainText(/errore/i);
});
  • Step 2: playwright.config.ts — webServer entries that start the backend (with the fake-pi-rpc wired) and the frontend dev server; baseURL the frontend. Document the exact PI_BIN/spawnFn wiring so the backend uses the fake, not real Pi (deterministic, no VPN).

  • Step 3: Run npx playwright test → the F1 loop passes against the real backend driven by fake-pi-rpc.

  • Step 4: Commit (test(frontend): Playwright e2e F1 vs backend+fake-pi-rpc).

Nota: questo è l'analogo dell'e2e del backend. La validazione contro Pi reale (GLM 5.2, VPN) è separata e fa parte del "proviamo tutto assieme" finale, non di questo task CI.


Self-Review

Spec coverage (vs 2026-06-27-frontend-design.md):

  • FE-1 Vite SPA: Task 1 ✓
  • FE-2 TanStack Query + Zustand + SSE hook: Tasks 1 (QueryClient), 3 (store), 4 (stream) ✓
  • FE-3 EventSource nativo: Task 4 ✓
  • FE-4 registry + fallback: Task 5 ✓; renderers Tasks 6, 8 ✓
  • FE-5 Vitest+RTL+MSW + Playwright: ogni task usa Vitest/RTL/MSW; e2e Task 13 ✓
  • FE-6 slice con F1 primo loop chiuso: Task 7 chiude F1 ✓
  • §4 componenti (api/store/stream/widgets/viewers/shell): Tasks 2–12 ✓
  • §4.5 viewer (schema-linking, sql, results, markdown): Tasks 9, 10, 11 ✓
  • §5 errori (kind sconosciuto→fallback, SSE disconnesso, /response 404→resume, preview errore): Task 5 (fallback), Task 4 (retry), Task 12 (resume), Task 11 (error state) ✓
  • §7 slice 1–9: Tasks 1–13 ✓

Placeholder scan: i Task 9–12 condensano gli step 2–4 (run-fail/implement/run-pass/commit) in forma sintetica perché il pattern TDD è identico ai Task 1–8 e i componenti sono deterministici; gli step 1 (test) e le interfacce sono concreti. Nessun "TBD". I viewer pesanti (mermaid/shiki/AGGrid) sono mockati nei test unitari per determinismo (indicato in ogni task).

Type consistency: WidgetProps {descriptor, onRespond} coerente Tasks 5→8. UiResponse (con kind/choices/text/control/decision) coerente tra widget (6,8), WidgetHost (7) e postResponse (2). StreamEvent union coerente tra store (3), stream (4), backend SSE. PreviewResult coerente tra sql.ts (2) e ResultsPanel (11). resolve(kind) (5) usato da WidgetHost (7).

Nota di sequenza: Tasks 1–8 e 12 sono CI-puri (MSW/mock). Task 13 (Playwright) richiede backend+fake-pi avviati ma niente VPN/Pi reale. La validazione con Pi reale è il passo finale "tutto assieme", fuori da questo piano.