From d5f82b70a4638be6f7cf68afa6c06a250cb8bbaf Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 29 Jun 2026 12:50:24 +0200 Subject: [PATCH] feat(frontend): SessionMenu kebab + NavSessions prop-driven; AppShell owns sessions query Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/shell/AppShell.tsx | 11 +++- frontend/src/shell/NavSessions.test.tsx | 88 +++++-------------------- frontend/src/shell/NavSessions.tsx | 75 +++++++++------------ frontend/src/shell/SessionMenu.tsx | 60 +++++++++++++++++ 4 files changed, 116 insertions(+), 118 deletions(-) create mode 100644 frontend/src/shell/SessionMenu.tsx diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 695d623f..1b7b1eca 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -7,7 +7,9 @@ import { SteerInput, ComposerFooter } from "./SteerInput"; import { WorkflowBar } from "./WorkflowBar"; import { Button } from "../components/ui/button"; import { Toaster } from "../components/ui/sonner"; -import { closeSession } from "../api/sessions"; +import { closeSession, listSessions, resumeSession } from "../api/sessions"; +import type { SessionSummary } from "../api/types"; +import { useQuery } from "@tanstack/react-query"; import { useRef, useState } from "react"; /** @@ -18,6 +20,9 @@ import { useRef, useState } from "react"; */ export function AppShell() { const [activeSessionId, setActiveSessionId] = useState(null); + const { data: sessions = [] } = useQuery({ + queryKey: ["sessions"], queryFn: listSessions, refetchInterval: 10_000, + }); const composerRef = useRef(null); // The harness "holds the ball" whenever a session is live and no widget is @@ -121,8 +126,10 @@ export function AppShell() {
{ await resumeSession(id); setActiveSessionId(id); }} + menuFor={() => null} />
diff --git a/frontend/src/shell/NavSessions.test.tsx b/frontend/src/shell/NavSessions.test.tsx index 163208ac..80a2f954 100644 --- a/frontend/src/shell/NavSessions.test.tsx +++ b/frontend/src/shell/NavSessions.test.tsx @@ -1,81 +1,27 @@ -// frontend/src/shell/NavSessions.test.tsx -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen } 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"; +import type { SessionSummary } from "../api/types"; -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, - }, +const SESSIONS: SessionSummary[] = [ + { id: "s1", status: "open", question: "Come va?", summary: null, created_at: "2026-01-01T00:00:00Z", updated_at: null, author: null, name: null, group: null, archived: false }, + { id: "s2", status: "closed", question: "Seconda domanda", summary: "r", created_at: "2026-01-02T00:00:00Z", updated_at: null, author: null, name: "Etichetta", group: null, archived: false }, ]; -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 and shows name when present", () => { + render( null} />); + expect(screen.getByText("Come va?")).toBeInTheDocument(); + expect(screen.getByText("Etichetta")).toBeInTheDocument(); // name overrides question }); -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 row opens the panel (does not resume)", async () => { + const onOpenPanel = vi.fn(); + render( null} />); + await userEvent.click(screen.getByText("Come va?")); + expect(onOpenPanel).toHaveBeenCalledWith("s1"); }); -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"); +test("active session is highlighted", () => { + render( null} />); + expect(screen.getByTestId("session-item-s2")).toHaveAttribute("data-active", "true"); }); diff --git a/frontend/src/shell/NavSessions.tsx b/frontend/src/shell/NavSessions.tsx index 7300737e..8081824a 100644 --- a/frontend/src/shell/NavSessions.tsx +++ b/frontend/src/shell/NavSessions.tsx @@ -1,28 +1,15 @@ // frontend/src/shell/NavSessions.tsx -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { listSessions } from "../api/sessions"; -import { resumeSession } from "../api/sessions"; +import type { ReactNode } from "react"; import type { SessionSummary } from "../api/types"; interface Props { + sessions: SessionSummary[]; activeSessionId: string | null; - onSessionSelect: (id: string) => void; + onOpenPanel: (id: string) => void; + menuFor: (session: SessionSummary) => ReactNode; } -export function NavSessions({ activeSessionId, onSessionSelect }: Props) { - const queryClient = useQueryClient(); - const { data: sessions = [] } = useQuery({ - queryKey: ["sessions"], - queryFn: listSessions, - refetchInterval: 10_000, - }); - - async function handleClick(id: string) { - await resumeSession(id); - queryClient.invalidateQueries({ queryKey: ["sessions"] }); - onSessionSelect(id); - } - +export function NavSessions({ sessions, activeSessionId, onOpenPanel, menuFor }: Props) { if (sessions.length === 0) { return (

@@ -30,48 +17,46 @@ export function NavSessions({ activeSessionId, onSessionSelect }: Props) {

); } - return (
    {sessions.map((s) => { const active = s.id === activeSessionId; return (
  • - + + + {s.status} + + + {menuFor(s)} +
  • ); })} diff --git a/frontend/src/shell/SessionMenu.tsx b/frontend/src/shell/SessionMenu.tsx new file mode 100644 index 00000000..b1b90659 --- /dev/null +++ b/frontend/src/shell/SessionMenu.tsx @@ -0,0 +1,60 @@ +import { Menu } from "@base-ui/react/menu"; +import { MoreVertical } from "lucide-react"; +import type { SessionSummary } from "../api/types"; + +interface Props { + session: SessionSummary; + groups: string[]; + onView: () => void; + onRename: () => void; + onMove: (group: string) => void; + onNewGroup: () => void; + onArchiveToggle: () => void; + onDelete: () => void; +} + +const itemCls = + "flex w-full cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"; + +export function SessionMenu({ session, groups, onView, onRename, onMove, onNewGroup, onArchiveToggle, onDelete }: Props) { + return ( + + e.stopPropagation()} + > + + + + + + Vista divisa + Rinomina + + Sposta nel gruppo › + + + + {groups.filter((g) => g !== session.group).map((g) => ( + onMove(g)}>{g} + ))} + {session.group && ( + onMove("")}>Senza gruppo + )} + Nuovo gruppo… + + + + + + {session.archived ? "Ripristina" : "Archivia"} + + + Elimina + + + + + ); +}