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

861 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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(<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**
```tsx
// frontend/src/App.tsx
export function App() {
return <div className="p-4 text-lg font-semibold">ThothII</div>;
}
```
```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(<StrictMode><App /></StrictMode>);
```
```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<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**
```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<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 };
```
```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<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.
```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<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.
```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<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**
```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) => <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**
```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 (
<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>
);
}
```
```tsx
// 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.
```bash
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**
```tsx
// 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" });
});
```
```tsx
// 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**
```tsx
// 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>;
}
```
```tsx
// 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>
);
}
```
```tsx
// 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>;
}
```
```tsx
// 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>
);
}
```
```ts
// 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.
```bash
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)**
```tsx
// 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`:
```tsx
// 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.
```bash
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`.
```tsx
// 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"] });
});
```
```tsx
// 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.
```bash
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**
```bash
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/`resumeSession`s 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**
```ts
// 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.