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>
44 KiB
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, defaulthttp://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
EventSourcenativo (FE-3): GET, nessun header (auth=noneMVP). 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
EventSourcesimula 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). Nienteanyse 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;
Appcomponente root;npm testesegue Vitest (jsdom);npm run devserve 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?)inclient.tsusingVITE_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 actionsapplyEvent(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 }— whensessionIdis set, opensnew EventSource(\${BASE}/sessions/${id}/events`), parses eachmessagedataas JSONStreamEvent, callsapplyEvent; closes on unmount / id change. Uses the globalEventSource` (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>(returnsFallbackWidgetfor unknown).FallbackWidgetrenders 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 forback/exit/otherpresent inreserved[].SelectWidget(single-pick: eachoptiona button;recommendedgets a "(consigliato)" badge; responds{id, kind:"select", choices:[optionId], decision?}; reserved →{id, control}).InfoWidget(non-blocking, showstext/level; auto no response).FreetextWidget(textarea →{id, kind:"freetext", text}).index.tsregistersselect/info/freetextinto 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./widgetsto 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:
AppShellrendering the 4 zones (nav, workflow bar, chat+input center withWidgetHost, right sidebar).WidgetHostreadspendingWidgetfrom the store and rendersresolve(widget)(descriptor, onRespond), whereonRespondcallspostResponse(activeSessionId, r)thenclearPending().Appwraps everything inQueryClientProvider. -
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
createSessionfissaactiveSessionId="s1", su cuiuseSessionStreamapre 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; initialselected; "seleziona/deseleziona tutti";allow_emptygates the confirm; responds{id, kind:"multiselect", choices}).ArtifactGateWidget(rendersartifact.contentscrollable + disposizioni fromoptions/reserved; "Rifiuta" opens a freetext via linkage; responds with the chosen disposition + optional text).ArtifactWidget(view-only, no response).LinkageHostwraps a renderer: if a chosenoption.opens, it shows the child widget and merges both into oneUiResponse({...parent, text: childText}). -
Step 1: Failing tests (multiselect select-all + allow_empty; artifact-gate reject→freetext linkage returns combined response). Full RTL tests with
userEventasserting the emittedUiResponse.
// 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 aSetseeded fromselected; confirm disabled when empty andallow_empty===false. ArtifactGate showsartifact.contentin a scrollable<pre>; each option is a button; an option withopensroutes throughLinkageHostto collect child text before responding. Artifact is view-only. Register all three inindex.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.jsonartifact shape ({candidates:[{kind,name,decision,...}], joins:[{from,to,...}], excluded:[...], open_questions:[]}— fromGET /sessions/:id/artifacts/...or embedded in anartifactdescriptor). -
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 viamermaid.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'srenderMermaidto return a stub<svg>so the test is deterministic.) -
Step 2: Run (fail) — module absent.
-
Step 3: Implement
mermaid.ts(lazyimport("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.tsexportshighlightSql(code): Promise<string>(shiki, sql grammar). Mockhighlight.tsin 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 drivingsqlPreview(limit/offset), an AGGrid showingcolumns/rows, an "Esporta CSV" button callingsqlExport. 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) —
sqlPreviewmocked 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, registerAllCommunityModulein 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: querylistSessions, click selects/resumeSessions a session).NewSessionDialog(ShadCn dialog: question + workspace select fromlistWorkspaces+ optional model/thinking/provider fromlistModels, degrading gracefully to a free input whenmodelsis empty; callscreateSession).SteerInput(a text field that sendspostSteerfor free-text!-style steering during a session).WorkflowBar(renders the 8 phases, highlightingcurrentPhasefrom the store/getSession). -
Step 1: Failing tests — NavSessions lists sessions from a mocked
listSessionsand selecting one triggersresumeSession; 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 injectedspawnFnpointing at../harness/tests/fake_pi/fake_pi_rpc.mjs, OR the backendnpm run devwithPI_BINswapped 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 —
webServerentries that start the backend (with the fake-pi-rpc wired) and the frontend dev server;baseURLthe frontend. Document the exactPI_BIN/spawnFnwiring 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.