From cabff8e77f1a078a495f58c2f031437ecb8d71c4 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 28 Jun 2026 12:02:21 +0200 Subject: [PATCH] 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 --- .../2026-06-27-frontend-implementation.md | 860 ++++++++++++++++++ 1 file changed, 860 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-27-frontend-implementation.md diff --git a/docs/superpowers/plans/2026-06-27-frontend-implementation.md b/docs/superpowers/plans/2026-06-27-frontend-implementation.md new file mode 100644 index 00000000..8fc9bcc9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-27-frontend-implementation.md @@ -0,0 +1,860 @@ +# 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)}
+