# 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** ```json // 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" } } ``` ```ts // frontend/vite.config.ts import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()] }); ``` ```ts // 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}"] }, }); ``` ```json // 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** ```tsx // frontend/src/App.test.tsx import { render, screen } from "@testing-library/react"; import { App } from "./App"; test("App renders the ThothII title", () => { render(); 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** ```tsx // frontend/src/App.tsx export function App() { return
ThothII
; } ``` ```tsx // 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(); ``` ```ts // 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. ```bash 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): ```ts export interface WidgetOption { id: string; label: string; meta?: Record; 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`, `getSession(id): Promise`, `postResponse(id, uiResponse): Promise`, `postSteer(id, text): Promise`, `closeSession(id): Promise`, `resumeSession(id): Promise`, `listWorkspaces(): Promise<{name:string;file:string}[]>`, `listModels(): Promise<{models:unknown[]}>`, `sqlPreview(id,{limit?,offset?}): Promise`, `sqlExport(id): Promise<{path:string}>`. Base: `apiFetch(path, init?)` in `client.ts` using `VITE_BACKEND_URL`. - [ ] **Step 1: MSW test infra + failing test** ```ts // 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). ```ts // 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** ```ts // frontend/src/api/client.ts const BASE = import.meta.env.VITE_BACKEND_URL ?? "http://localhost:8787"; export async function apiFetch(path: string, init?: RequestInit): Promise { 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 }; ``` ```ts // 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("/sessions"); export const getSession = (id: string) => apiFetch(`/sessions/${id}`); export const postResponse = (id: string, uiResponse: UiResponse) => apiFetch(`/sessions/${id}/response`, { method: "POST", body: JSON.stringify({ ui_response: uiResponse }) }); export const postSteer = (id: string, text: string) => apiFetch(`/sessions/${id}/steer`, { method: "POST", body: JSON.stringify({ text }) }); export const closeSession = (id: string) => apiFetch(`/sessions/${id}/close`, { method: "POST" }); export const resumeSession = (id: string) => apiFetch(`/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. ```bash 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** ```ts // 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** ```ts // 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((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. ```bash 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` `data` as JSON `StreamEvent`, calls `applyEvent`; closes on unmount / id change. Uses the global `EventSource` (overridable in tests via a fake). - [ ] **Step 1: Fake EventSource + failing test** ```ts // 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; } } ``` ```tsx // 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** ```ts // 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. ```bash 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): void`; `resolve(kind: string): React.FC` (returns `FallbackWidget` for unknown). `FallbackWidget` renders the descriptor JSON + a freetext box that responds with `{id, control:"freetext", text}`. - [ ] **Step 1: Failing test** ```tsx // 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) =>
dummy
; 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( {}} />); expect(screen.getByText(/Widget non supportato/i)).toBeInTheDocument(); }); ``` - [ ] **Step 2: Run (fail)** — modules absent. - [ ] **Step 3: Implement** ```tsx // frontend/src/widgets/types.ts import type { WidgetDescriptor, UiResponse } from "../api/types"; export type WidgetProps = { descriptor: WidgetDescriptor; onRespond: (r: UiResponse) => void }; ``` ```tsx // 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 (

Widget non supportato (kind: {descriptor.widget}) — rispondi manualmente

{JSON.stringify(descriptor, null, 2)}