diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 02436de4..dd03f5a8 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -1,46 +1,62 @@ import { useState } from "react"; -import { createSession } from "../api/sessions"; import { useSessionStream } from "../stream/useSessionStream"; import { WidgetHost } from "./WidgetHost"; +import { NavSessions } from "./NavSessions"; +import { NewSessionDialog } from "./NewSessionDialog"; +import { SteerInput } from "./SteerInput"; +import { WorkflowBar } from "./WorkflowBar"; export function AppShell() { const [activeSessionId, setActiveSessionId] = useState(null); + const [sidebarOpen, setSidebarOpen] = useState(true); useSessionStream(activeSessionId); - async function handleNuovaDomanda() { - const { id } = await createSession({ workspace: "default", question: "" }); + function handleSessionSelect(id: string) { + setActiveSessionId(id); + } + + function handleCreated(id: string) { setActiveSessionId(id); } return (
- {/* Left nav / session list */} + {/* Left nav: new session button + sessions list */} -
+
{/* Top workflow bar */} -
- Workflow +
+ +
- {/* Center: chat + input */} -
+ {/* Center: chat + steer input */} +
+ {activeSessionId && }
- {/* Right artifact sidebar */} - + {/* Right artifact sidebar — collapsible */} + {sidebarOpen && ( + + )}
); } diff --git a/frontend/src/shell/NavSessions.test.tsx b/frontend/src/shell/NavSessions.test.tsx new file mode 100644 index 00000000..163208ac --- /dev/null +++ b/frontend/src/shell/NavSessions.test.tsx @@ -0,0 +1,81 @@ +// frontend/src/shell/NavSessions.test.tsx +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { server } from "../test/msw"; +import { NavSessions } from "./NavSessions"; + +function makeClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +function renderWithClient(ui: React.ReactElement) { + const client = makeClient(); + return render({ui}); +} + +const SESSION_LIST = [ + { + id: "s1", + status: "open", + question: "Come va?", + summary: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: null, + author: null, + }, + { + id: "s2", + status: "closed", + question: "Seconda domanda", + summary: "riassunto", + created_at: "2026-01-02T00:00:00Z", + updated_at: null, + author: null, + }, +]; + +beforeEach(() => { + server.use( + http.get("http://localhost:8787/sessions", () => + HttpResponse.json(SESSION_LIST), + ), + http.post("http://localhost:8787/sessions/:id/resume", () => + new HttpResponse(null, { status: 204 }), + ), + ); +}); + +test("lists sessions loaded from listSessions", async () => { + const onSelect = vi.fn(); + renderWithClient(); + await screen.findByText(/Come va\?/); + expect(screen.getByText(/Seconda domanda/)).toBeInTheDocument(); +}); + +test("clicking a session calls resumeSession and onSessionSelect", async () => { + const onSelect = vi.fn(); + let resumeCalledWith: string | null = null; + server.use( + http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => { + resumeCalledWith = params.id as string; + return new HttpResponse(null, { status: 204 }); + }), + ); + renderWithClient(); + await screen.findByText(/Come va\?/); + await userEvent.click(screen.getByText(/Come va\?/)); + await waitFor(() => { + expect(resumeCalledWith).toBe("s1"); + expect(onSelect).toHaveBeenCalledWith("s1"); + }); +}); + +test("active session is visually highlighted", async () => { + const onSelect = vi.fn(); + renderWithClient(); + await screen.findByText(/Seconda domanda/); + const activeItem = screen.getByTestId("session-item-s2"); + expect(activeItem).toHaveAttribute("data-active", "true"); +}); diff --git a/frontend/src/shell/NavSessions.tsx b/frontend/src/shell/NavSessions.tsx new file mode 100644 index 00000000..40c18075 --- /dev/null +++ b/frontend/src/shell/NavSessions.tsx @@ -0,0 +1,44 @@ +// frontend/src/shell/NavSessions.tsx +import { useQuery } from "@tanstack/react-query"; +import { listSessions } from "../api/sessions"; +import { resumeSession } from "../api/sessions"; +import type { SessionSummary } from "../api/types"; + +interface Props { + activeSessionId: string | null; + onSessionSelect: (id: string) => void; +} + +export function NavSessions({ activeSessionId, onSessionSelect }: Props) { + const { data: sessions = [] } = useQuery({ + queryKey: ["sessions"], + queryFn: listSessions, + refetchInterval: 10_000, + }); + + async function handleClick(id: string) { + await resumeSession(id); + onSessionSelect(id); + } + + return ( +
    + {sessions.map((s) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/frontend/src/shell/NewSessionDialog.tsx b/frontend/src/shell/NewSessionDialog.tsx new file mode 100644 index 00000000..79e7d822 --- /dev/null +++ b/frontend/src/shell/NewSessionDialog.tsx @@ -0,0 +1,212 @@ +// frontend/src/shell/NewSessionDialog.tsx +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "../components/ui/dialog"; +import { Button } from "../components/ui/button"; +import { createSession } from "../api/sessions"; +import { listWorkspaces } from "../api/workspaces"; +import { listModels } from "../api/models"; + +interface Props { + onCreated: (id: string) => void; +} + +export function NewSessionDialog({ onCreated }: Props) { + const [open, setOpen] = useState(false); + const [question, setQuestion] = useState(""); + const [workspace, setWorkspace] = useState(""); + const [model, setModel] = useState(""); + const [thinking, setThinking] = useState(""); + const [provider, setProvider] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const { data: workspaces = [] } = useQuery({ + queryKey: ["workspaces"], + queryFn: listWorkspaces, + enabled: open, + }); + + const { data: modelsData } = useQuery({ + queryKey: ["models"], + queryFn: listModels, + enabled: open, + }); + + const modelList: string[] = Array.isArray(modelsData?.models) + ? (modelsData.models as string[]) + : []; + + // When workspace list loads, pre-select the first one if nothing chosen yet + const effectiveWorkspace = + workspace || (workspaces.length > 0 ? workspaces[0].name : "default"); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + if (!question.trim()) { + setError("La domanda non può essere vuota."); + return; + } + setBusy(true); + try { + const { id } = await createSession({ + workspace: effectiveWorkspace, + question: question.trim(), + ...(model ? { model } : {}), + ...(thinking ? { thinking } : {}), + ...(provider ? { provider } : {}), + }); + setOpen(false); + setQuestion(""); + setModel(""); + setThinking(""); + setProvider(""); + onCreated(id); + } catch (err) { + setError(err instanceof Error ? err.message : "Errore nella creazione della sessione."); + } finally { + setBusy(false); + } + } + + return ( + + + + + Nuova sessione + +
+
+ +