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 */}
+
+
+ {/* 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"] } });
+});