feat(frontend): SessionMenu kebab + NavSessions prop-driven; AppShell owns sessions query
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string | null>(null);
|
||||
const { data: sessions = [] } = useQuery<SessionSummary[]>({
|
||||
queryKey: ["sessions"], queryFn: listSessions, refetchInterval: 10_000,
|
||||
});
|
||||
const composerRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// The harness "holds the ball" whenever a session is live and no widget is
|
||||
@@ -121,8 +126,10 @@ export function AppShell() {
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-2 pb-4">
|
||||
<NavSessions
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSessionSelect={setActiveSessionId}
|
||||
onOpenPanel={async (id) => { await resumeSession(id); setActiveSessionId(id); }}
|
||||
menuFor={() => null}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -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(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
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(<NavSessions sessions={SESSIONS} activeSessionId={null} onOpenPanel={vi.fn()} menuFor={() => 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(<NavSessions onSessionSelect={onSelect} activeSessionId={null} />);
|
||||
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(<NavSessions sessions={SESSIONS} activeSessionId={null} onOpenPanel={onOpenPanel} menuFor={() => 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(<NavSessions onSessionSelect={onSelect} activeSessionId={null} />);
|
||||
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(<NavSessions onSessionSelect={onSelect} activeSessionId="s2" />);
|
||||
await screen.findByText(/Seconda domanda/);
|
||||
const activeItem = screen.getByTestId("session-item-s2");
|
||||
expect(activeItem).toHaveAttribute("data-active", "true");
|
||||
test("active session is highlighted", () => {
|
||||
render(<NavSessions sessions={SESSIONS} activeSessionId="s2" onOpenPanel={vi.fn()} menuFor={() => null} />);
|
||||
expect(screen.getByTestId("session-item-s2")).toHaveAttribute("data-active", "true");
|
||||
});
|
||||
|
||||
@@ -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<SessionSummary[]>({
|
||||
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 (
|
||||
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
|
||||
@@ -30,48 +17,46 @@ export function NavSessions({ activeSessionId, onSessionSelect }: Props) {
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{sessions.map((s) => {
|
||||
const active = s.id === activeSessionId;
|
||||
return (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
<div
|
||||
data-testid={`session-item-${s.id}`}
|
||||
data-active={active ? "true" : "false"}
|
||||
onClick={() => handleClick(s.id)}
|
||||
onClick={() => onOpenPanel(s.id)}
|
||||
className={[
|
||||
"group w-full rounded-lg px-2.5 py-2 text-left transition-colors",
|
||||
active
|
||||
? "bg-[oklch(var(--primary)/0.12)]"
|
||||
: "hover:bg-accent",
|
||||
"group flex w-full cursor-pointer items-start gap-1 rounded-lg px-2.5 py-2 text-left transition-colors",
|
||||
active ? "bg-[oklch(var(--primary)/0.12)]" : "hover:bg-accent",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"block truncate text-[0.8rem] leading-snug",
|
||||
active ? "font-bold text-primary" : "text-foreground/90",
|
||||
].join(" ")}
|
||||
>
|
||||
{s.question || s.id}
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span
|
||||
className={[
|
||||
"size-1.5 rounded-full",
|
||||
s.status === "open"
|
||||
? "bg-[oklch(var(--success))]"
|
||||
: s.status === "finalized"
|
||||
? "bg-primary"
|
||||
: "bg-muted-foreground/50",
|
||||
"block truncate text-[0.8rem] leading-snug",
|
||||
active ? "font-bold text-primary" : "text-foreground/90",
|
||||
].join(" ")}
|
||||
/>
|
||||
<span className="text-[0.65rem] uppercase tracking-wide text-muted-foreground">
|
||||
{s.status}
|
||||
>
|
||||
{s.name || s.question || s.id}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<span className="mt-1 flex items-center gap-1.5">
|
||||
<span
|
||||
className={[
|
||||
"size-1.5 rounded-full",
|
||||
s.status === "open"
|
||||
? "bg-[oklch(var(--success))]"
|
||||
: s.status === "finalized"
|
||||
? "bg-primary"
|
||||
: "bg-muted-foreground/50",
|
||||
].join(" ")}
|
||||
/>
|
||||
<span className="text-[0.65rem] uppercase tracking-wide text-muted-foreground">{s.status}</span>
|
||||
</span>
|
||||
</div>
|
||||
{menuFor(s)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
aria-label="Session actions"
|
||||
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 data-popup-open:opacity-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner side="bottom" align="end" sideOffset={4}>
|
||||
<Menu.Popup className="z-50 min-w-44 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
||||
<Menu.Item className={itemCls} onClick={onView}>Vista divisa</Menu.Item>
|
||||
<Menu.Item className={itemCls} onClick={onRename}>Rinomina</Menu.Item>
|
||||
<Menu.SubmenuRoot>
|
||||
<Menu.SubmenuTrigger className={itemCls}>Sposta nel gruppo ›</Menu.SubmenuTrigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner side="right" align="start">
|
||||
<Menu.Popup className="z-50 min-w-44 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
||||
{groups.filter((g) => g !== session.group).map((g) => (
|
||||
<Menu.Item key={g} className={itemCls} onClick={() => onMove(g)}>{g}</Menu.Item>
|
||||
))}
|
||||
{session.group && (
|
||||
<Menu.Item className={itemCls} onClick={() => onMove("")}>Senza gruppo</Menu.Item>
|
||||
)}
|
||||
<Menu.Item className={itemCls} onClick={onNewGroup}>Nuovo gruppo…</Menu.Item>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.SubmenuRoot>
|
||||
<Menu.Item className={itemCls} onClick={onArchiveToggle}>
|
||||
{session.archived ? "Ripristina" : "Archivia"}
|
||||
</Menu.Item>
|
||||
<Menu.Separator className="my-1 h-px bg-border" />
|
||||
<Menu.Item className={`${itemCls} text-destructive`} onClick={onDelete}>Elimina</Menu.Item>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user