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)}
+
+ );
+}
+```
+```tsx
+// frontend/src/widgets/registry.ts
+import type React from "react";
+import type { WidgetProps } from "./types";
+import { FallbackWidget } from "./FallbackWidget";
+const registry = new Map>();
+export function register(kind: string, comp: React.FC) { registry.set(kind, comp); }
+export function resolve(kind: string): React.FC { 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();
+ 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();
+ 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();
+ 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 = { 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 {reserved.map((c) => (
+
+ ))}
;
+}
+```
+```tsx
+// frontend/src/widgets/SelectWidget.tsx
+import type { WidgetProps } from "./types";
+import { ReservedControls } from "./ReservedControls";
+export function SelectWidget({ descriptor, onRespond }: WidgetProps) {
+ return (
+
+ {descriptor.title &&
{descriptor.title}
}
+ {descriptor.intro &&
{descriptor.intro}
}
+
+ {descriptor.options?.map((o) => (
+
+ ))}
+
+
onRespond({ id: descriptor.id, control: c })} />
+
+ );
+}
+```
+```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 {descriptor.text ?? descriptor.title}
;
+}
+```
+```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 (
+
+ {descriptor.title &&
{descriptor.title}
}
+
+ );
+}
+```
+```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();
+ 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 ;
+}
+```
+`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();
+ 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();
+ 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 ``; 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