feat(frontend): AppShell session management — grouping, archive view, docs panel, mutations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
import { render, screen, waitFor, within } 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 { AppShell } from "./AppShell";
|
||||||
|
|
||||||
|
function wrap() {
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(<QueryClientProvider client={client}><AppShell /></QueryClientProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIST = [
|
||||||
|
{ id: "s1", status: "open", question: "Attiva uno", summary: null, created_at: "2026-01-02T00:00:00Z", updated_at: null, author: null, name: null, group: "Aritmologia", archived: false },
|
||||||
|
{ id: "s2", status: "finalized", question: "Archiviata due", summary: null, created_at: "2026-01-01T00:00:00Z", updated_at: null, author: null, name: null, group: null, archived: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
server.use(
|
||||||
|
http.get("http://localhost:8787/sessions", () => HttpResponse.json(LIST)),
|
||||||
|
http.get("http://localhost:8787/sessions/:id/documents", () => HttpResponse.json([
|
||||||
|
{ phase: "—", key: "question", title: "Domanda originale", format: "text", content: "Attiva uno" },
|
||||||
|
])),
|
||||||
|
http.post("http://localhost:8787/sessions/:id/archive", () => new HttpResponse(null, { status: 204 })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("active list shows group header and hides archived sessions", async () => {
|
||||||
|
wrap();
|
||||||
|
expect(await screen.findByText("Attiva uno")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Aritmologia")).toBeInTheDocument(); // group header
|
||||||
|
expect(screen.queryByText("Archiviata due")).not.toBeInTheDocument(); // archived hidden
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opening the panel shows the session documents", async () => {
|
||||||
|
wrap();
|
||||||
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||||
|
expect(await screen.findByText("Domanda originale")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Archive toggle reveals archived sessions", async () => {
|
||||||
|
wrap();
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: /archive/i }));
|
||||||
|
expect(await screen.findByText("Archiviata due")).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -3,14 +3,21 @@ import { useSessionStore } from "../store/sessionStore";
|
|||||||
import { WidgetHost } from "./WidgetHost";
|
import { WidgetHost } from "./WidgetHost";
|
||||||
import { Transcript } from "./Transcript";
|
import { Transcript } from "./Transcript";
|
||||||
import { NavSessions } from "./NavSessions";
|
import { NavSessions } from "./NavSessions";
|
||||||
|
import { SessionMenu } from "./SessionMenu";
|
||||||
|
import { SessionDocumentsPanel } from "./SessionDocumentsPanel";
|
||||||
|
import { RenameDialog } from "./RenameDialog";
|
||||||
|
import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
|
||||||
import { SteerInput, ComposerFooter } from "./SteerInput";
|
import { SteerInput, ComposerFooter } from "./SteerInput";
|
||||||
import { WorkflowBar } from "./WorkflowBar";
|
import { WorkflowBar } from "./WorkflowBar";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Toaster } from "../components/ui/sonner";
|
import { Toaster } from "../components/ui/sonner";
|
||||||
import { closeSession, listSessions, resumeSession } from "../api/sessions";
|
import {
|
||||||
|
closeSession, listSessions, resumeSession,
|
||||||
|
renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession,
|
||||||
|
} from "../api/sessions";
|
||||||
import type { SessionSummary } from "../api/types";
|
import type { SessionSummary } from "../api/types";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The page is designed to live INSIDE the Omics Portal chrome (its left sidebar
|
* The page is designed to live INSIDE the Omics Portal chrome (its left sidebar
|
||||||
@@ -25,6 +32,55 @@ export function AppShell() {
|
|||||||
});
|
});
|
||||||
const composerRef = useRef<HTMLInputElement>(null);
|
const composerRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [panelSession, setPanelSession] = useState<SessionSummary | null>(null);
|
||||||
|
const [showArchive, setShowArchive] = useState(false);
|
||||||
|
const [renameTarget, setRenameTarget] = useState<SessionSummary | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<SessionSummary | null>(null);
|
||||||
|
|
||||||
|
const groups = useMemo(
|
||||||
|
() => [...new Set(sessions.map((s) => s.group).filter((g): g is string => !!g))].sort(),
|
||||||
|
[sessions],
|
||||||
|
);
|
||||||
|
const activeList = sessions.filter((s) => !s.archived);
|
||||||
|
const archivedList = sessions.filter((s) => s.archived);
|
||||||
|
const refresh = () => queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
||||||
|
|
||||||
|
function openPanel(id: string) {
|
||||||
|
const s = sessions.find((x) => x.id === id);
|
||||||
|
if (s) setPanelSession(s);
|
||||||
|
}
|
||||||
|
async function doResume(id: string) {
|
||||||
|
await resumeSession(id);
|
||||||
|
setPanelSession(null);
|
||||||
|
setActiveSessionId(id);
|
||||||
|
}
|
||||||
|
async function move(s: SessionSummary, group: string) { await setSessionGroup(s.id, group); refresh(); }
|
||||||
|
async function newGroup(s: SessionSummary) {
|
||||||
|
const name = window.prompt("New group:");
|
||||||
|
if (name && name.trim()) { await setSessionGroup(s.id, name.trim()); refresh(); }
|
||||||
|
}
|
||||||
|
async function toggleArchive(s: SessionSummary) {
|
||||||
|
await (s.archived ? unarchiveSession(s.id) : archiveSession(s.id));
|
||||||
|
if (panelSession?.id === s.id) setPanelSession(null);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function menuFor(s: SessionSummary) {
|
||||||
|
return (
|
||||||
|
<SessionMenu
|
||||||
|
session={s}
|
||||||
|
groups={groups}
|
||||||
|
onView={() => setPanelSession(s)}
|
||||||
|
onRename={() => setRenameTarget(s)}
|
||||||
|
onMove={(g) => move(s, g)}
|
||||||
|
onNewGroup={() => newGroup(s)}
|
||||||
|
onArchiveToggle={() => toggleArchive(s)}
|
||||||
|
onDelete={() => setDeleteTarget(s)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// The harness "holds the ball" whenever a session is live and no widget is
|
// The harness "holds the ball" whenever a session is live and no widget is
|
||||||
// waiting on the human; once a widget appears, input is back in the user's court.
|
// waiting on the human; once a widget appears, input is back in the user's court.
|
||||||
const pendingWidget = useSessionStore((s) => s.pendingWidget);
|
const pendingWidget = useSessionStore((s) => s.pendingWidget);
|
||||||
@@ -51,6 +107,14 @@ export function AppShell() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background text-foreground">
|
<div className="flex h-screen bg-background text-foreground">
|
||||||
|
{panelSession && (
|
||||||
|
<SessionDocumentsPanel
|
||||||
|
session={panelSession}
|
||||||
|
onClose={() => setPanelSession(null)}
|
||||||
|
onResume={doResume}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Conversation column */}
|
{/* Conversation column */}
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
{activeSessionId && (
|
{activeSessionId && (
|
||||||
@@ -125,16 +189,66 @@ export function AppShell() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto px-2 pb-4">
|
<div className="flex-1 overflow-y-auto px-2 pb-4">
|
||||||
|
{showArchive ? (
|
||||||
|
<NavSessions sessions={archivedList} activeSessionId={activeSessionId} onOpenPanel={openPanel} menuFor={menuFor} />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<div key={g}>
|
||||||
|
<p className="px-1 pb-1 text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">{g}</p>
|
||||||
<NavSessions
|
<NavSessions
|
||||||
sessions={sessions}
|
sessions={activeList.filter((s) => s.group === g)}
|
||||||
activeSessionId={activeSessionId}
|
activeSessionId={activeSessionId}
|
||||||
onOpenPanel={async (id) => { await resumeSession(id); setActiveSessionId(id); }}
|
onOpenPanel={openPanel}
|
||||||
menuFor={() => null}
|
menuFor={menuFor}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
<div>
|
||||||
|
{groups.length > 0 && (
|
||||||
|
<p className="px-1 pb-1 text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">No group</p>
|
||||||
|
)}
|
||||||
|
<NavSessions
|
||||||
|
sessions={activeList.filter((s) => !s.group)}
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
|
onOpenPanel={openPanel}
|
||||||
|
menuFor={menuFor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowArchive((v) => !v)}
|
||||||
|
className="mt-3 w-full rounded-md px-2 py-1.5 text-left text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground hover:bg-accent"
|
||||||
|
>
|
||||||
|
{showArchive ? "← Active sessions" : `Archive (${archivedList.length})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|
||||||
|
{renameTarget && (
|
||||||
|
<RenameDialog
|
||||||
|
open
|
||||||
|
initial={renameTarget.name ?? ""}
|
||||||
|
onOpenChange={(o) => { if (!o) setRenameTarget(null); }}
|
||||||
|
onSubmit={async (name) => { await renameSession(renameTarget.id, name); setRenameTarget(null); refresh(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{deleteTarget && (
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open
|
||||||
|
label={deleteTarget.name || deleteTarget.question}
|
||||||
|
onOpenChange={(o) => { if (!o) setDeleteTarget(null); }}
|
||||||
|
onConfirm={async () => {
|
||||||
|
await deleteSession(deleteTarget.id);
|
||||||
|
if (panelSession?.id === deleteTarget.id) setPanelSession(null);
|
||||||
|
if (activeSessionId === deleteTarget.id) { resetSession(); setActiveSessionId(null); }
|
||||||
|
setDeleteTarget(null); refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user