feat(frontend): 4-zone shell + F1 loop end-to-end (MSW)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
|
|
||||||
test("App renders the ThothII title", () => {
|
test("App renders the shell with create affordance", () => {
|
||||||
render(<App />);
|
render(<App />);
|
||||||
expect(screen.getByText(/ThothII/i)).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /nuova/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { queryClient } from "./app/queryClient";
|
||||||
|
import { AppShell } from "./shell/AppShell";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return <div className="p-4 text-lg font-semibold">ThothII</div>;
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AppShell />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
staleTime: 30_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
import "./widgets";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -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<string | null>(null);
|
||||||
|
|
||||||
|
useSessionStream(activeSessionId);
|
||||||
|
|
||||||
|
async function handleNuovaDomanda() {
|
||||||
|
const { id } = await createSession({ workspace: "default", question: "" });
|
||||||
|
setActiveSessionId(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen">
|
||||||
|
{/* Left nav / session list */}
|
||||||
|
<aside className="w-48 border-r p-2 flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
className="border rounded px-2 py-1 text-sm hover:bg-accent"
|
||||||
|
onClick={handleNuovaDomanda}
|
||||||
|
>
|
||||||
|
Nuova domanda
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
{/* Top workflow bar */}
|
||||||
|
<header className="border-b px-4 py-2 text-sm text-muted-foreground">
|
||||||
|
Workflow
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Center: chat + input */}
|
||||||
|
<main className="flex-1 overflow-auto p-4">
|
||||||
|
<WidgetHost sessionId={activeSessionId} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right artifact sidebar */}
|
||||||
|
<aside className="w-64 border-l p-2">
|
||||||
|
<p className="text-xs text-muted-foreground">Artefatti</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <Renderer descriptor={pending} onRespond={onRespond} />;
|
||||||
|
}
|
||||||
@@ -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(<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"] } });
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user