From 2b55e966082cb0692fe99cccb8e1f472ee059efd Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 28 Jun 2026 12:30:35 +0200 Subject: [PATCH] feat(frontend): 4-zone shell + F1 loop end-to-end (MSW) Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/App.test.tsx | 4 +-- frontend/src/App.tsx | 10 ++++++- frontend/src/app/queryClient.ts | 10 +++++++ frontend/src/main.tsx | 1 + frontend/src/shell/AppShell.tsx | 46 +++++++++++++++++++++++++++++ frontend/src/shell/WidgetHost.tsx | 16 ++++++++++ frontend/src/shell/f1-loop.test.tsx | 43 +++++++++++++++++++++++++++ 7 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/queryClient.ts create mode 100644 frontend/src/shell/AppShell.tsx create mode 100644 frontend/src/shell/WidgetHost.tsx create mode 100644 frontend/src/shell/f1-loop.test.tsx diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7c9b81f6..d1e90fd9 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react"; import { App } from "./App"; -test("App renders the ThothII title", () => { +test("App renders the shell with create affordance", () => { render(); - expect(screen.getByText(/ThothII/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /nuova/i })).toBeInTheDocument(); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 899da7ba..97b8d367 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,11 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { queryClient } from "./app/queryClient"; +import { AppShell } from "./shell/AppShell"; + export function App() { - return
ThothII
; + return ( + + + + ); } diff --git a/frontend/src/app/queryClient.ts b/frontend/src/app/queryClient.ts new file mode 100644 index 00000000..5365c1a5 --- /dev/null +++ b/frontend/src/app/queryClient.ts @@ -0,0 +1,10 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + staleTime: 30_000, + }, + }, +}); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a93d054a..4bdfec2f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; import "./index.css"; +import "./widgets"; createRoot(document.getElementById("root")!).render( diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx new file mode 100644 index 00000000..02436de4 --- /dev/null +++ b/frontend/src/shell/AppShell.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import { createSession } from "../api/sessions"; +import { useSessionStream } from "../stream/useSessionStream"; +import { WidgetHost } from "./WidgetHost"; + +export function AppShell() { + const [activeSessionId, setActiveSessionId] = useState(null); + + useSessionStream(activeSessionId); + + async function handleNuovaDomanda() { + const { id } = await createSession({ workspace: "default", question: "" }); + setActiveSessionId(id); + } + + return ( +
+ {/* Left nav / session list */} + + +
+ {/* Top workflow bar */} +
+ Workflow +
+ + {/* Center: chat + input */} +
+ +
+
+ + {/* Right artifact sidebar */} + +
+ ); +} diff --git a/frontend/src/shell/WidgetHost.tsx b/frontend/src/shell/WidgetHost.tsx new file mode 100644 index 00000000..068b6d96 --- /dev/null +++ b/frontend/src/shell/WidgetHost.tsx @@ -0,0 +1,16 @@ +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 ; +} diff --git a/frontend/src/shell/f1-loop.test.tsx b/frontend/src/shell/f1-loop.test.tsx new file mode 100644 index 00000000..9309181d --- /dev/null +++ b/frontend/src/shell/f1-loop.test.tsx @@ -0,0 +1,43 @@ +// 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"] } }); +});