;
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