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>
This commit is contained in:
@@ -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(<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.
|
||||
Reference in New Issue
Block a user