From cf90c1bd5102e7adadc85ab92785b5b665716df7 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 18:12:27 +0200 Subject: [PATCH] fix(ui): collapse session lists and scope selection controls to panels --- DESIGN.md | 6 +- PROJECT_STATE.md | 9 ++- docs/guida-utente.md | 7 +- frontend/e2e/ui-visual-review.spec.ts | 10 ++- .../shell/AppShell.administration.test.tsx | 1 + frontend/src/shell/AppShell.auth.test.tsx | 1 + .../AppShell.database-management.test.tsx | 1 + .../src/shell/AppShell.session-mgmt.test.tsx | 56 +++++++++++++-- .../shell/AppShell.session-target.test.tsx | 2 + frontend/src/shell/AppShell.tsx | 72 ++++++++++--------- .../src/shell/host/AppShell.host.test.tsx | 1 + 11 files changed, 119 insertions(+), 47 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index a0136d69..8adea87d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -314,7 +314,11 @@ default, hover, focus, active, disabled, loading, and error behavior where those tooltip and accessible description retain the translated exact state. Do not add a separate readiness text row or change the backend readiness gate. - **Session groups:** one accessible multi-open accordion contains Active sessions - (initially open) and Archive (initially closed). Preserve the existing archived + and Archive, both initially closed. Below the scope tabs, show only their + adjacent section headers, without a redundant Sessions heading. Selection and + bulk-delete controls belong inside each panel and only appear for nonempty + lists. Select all affects that list only, preserves the other list's selection, + and exposes a mixed state for partial selection. Preserve the existing archived flag as the grouping rule, independent of whether a Pi process is running. Open sections share the rail's remaining height; each list scrolls independently with a cap of `min(18rem, 35dvh)`, while its trigger remains outside that scroll diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index f530c6a0..faf8fc32 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -47,18 +47,21 @@ successful, current `ready` response; checking, unavailable and other states are red, with the state exposed through the tooltip and accessible description. The backend readiness gate is unchanged. -Active sessions (non-archived) start expanded; Archive starts collapsed. Both +Active sessions (non-archived) and Archive both start collapsed. Their adjacent +headers are the only visible content below the scope tabs: no Sessions heading +or external selection toolbar. Each nonempty panel owns its Select all and +bulk-delete controls, scoped to that list and preserving the other's selection. Both accordion sections can remain open and share the remaining sidebar height, with independently scrolling content capped at `min(18rem, 35dvh)`. The mobile navigation dialog also provides a bounded height. Keyboard controls and labels are retained. See `DESIGN.md` and `docs/guida-utente.md` for the UI contract. -Verification: 763 frontend unit tests, 20 browser scenarios (including 80 mocked +Verification: 766 frontend unit tests, 20 browser scenarios (including 80 mocked sessions at 390/1280px), TypeScript and the production frontend build passed. Only the Mac frontend was recreated; it is healthy at `127.0.0.1:8080`. Core, catalog, Qdrant and embedding containers were not changed. The prior frontend image is retained as -`thothii-frontend:before-navigation-accordions-20260913` for rollback. +`thothii-frontend:before-scoped-session-selection-20260913` for rollback. ### Agreed Omics delivery route — 2026-09-13 diff --git a/docs/guida-utente.md b/docs/guida-utente.md index 11909aa4..18143eb9 100644 --- a/docs/guida-utente.md +++ b/docs/guida-utente.md @@ -63,8 +63,11 @@ In Administration, the dot beside **Workspace** is green when readiness is confirmed and red otherwise. Hover the button for the exact state; assistive technology receives the same description. Select Workspace to inspect preparation. -The session sidebar has two accordion sections: **Active sessions** starts open, -**Archive** starts closed. Both can be open together; long lists scroll inside +The session sidebar has two accordion sections: **Active sessions** and +**Archive**, both initially closed. Only their headers appear below the scope tabs. +Inside each nonempty list, **Select all** selects only that list; its delete action +also applies only to the selected sessions in that list. The other list's selection +is preserved. Both sections can be open together; long lists scroll inside their own panels. Here active means not archived, not necessarily a running model process. Existing groups and session actions remain inside those sections. diff --git a/frontend/e2e/ui-visual-review.spec.ts b/frontend/e2e/ui-visual-review.spec.ts index 97fb7045..f89c4403 100644 --- a/frontend/e2e/ui-visual-review.spec.ts +++ b/frontend/e2e/ui-visual-review.spec.ts @@ -125,11 +125,19 @@ for (const width of [390, 1280]) { const green = await workspaceButton.getByRole("img").evaluate(el => getComputedStyle(el).backgroundColor); const active = page.getByRole("button", { name: "Active sessions", exact: true }); const archive = page.getByRole("button", { name: "Archive (40)", exact: true }); - await expect(active).toHaveAttribute("aria-expanded", "true"); + await expect(active).toHaveAttribute("aria-expanded", "false"); await expect(archive).toHaveAttribute("aria-expanded", "false"); + await expect(page.getByRole("checkbox", { name: "Select all" })).toHaveCount(0); + await expect(page.getByText("Sessions", { exact: true })).toHaveCount(0); + const activeBox = await active.boundingBox(); + const archiveBox = await archive.boundingBox(); + expect(archiveBox!.y - activeBox!.y - activeBox!.height).toBeLessThan(8); + await page.screenshot({ path: testInfo.outputPath("session-accordions-closed.png"), animations: "disabled" }); + await active.click(); await archive.click(); for (const name of ["Active sessions", "Archive (40)"]) { const panel = page.getByRole("region", { name, exact: true }); + await expect(panel.getByRole("checkbox", { name: "Select all" })).toBeVisible(); await expect(panel).toHaveCSS("overflow-y", "auto"); const dimensions = await panel.evaluate(el => ({ height: el.getBoundingClientRect().height, scroll: el.scrollHeight, client: el.clientHeight })); expect(dimensions.height).toBeLessThanOrEqual(288); diff --git a/frontend/src/shell/AppShell.administration.test.tsx b/frontend/src/shell/AppShell.administration.test.tsx index 56e25d18..3e858075 100644 --- a/frontend/src/shell/AppShell.administration.test.tsx +++ b/frontend/src/shell/AppShell.administration.test.tsx @@ -222,6 +222,7 @@ test("Resume binds the pinned workspace immediately and leaves the global model ); renderShell(); await screen.findByRole("textbox", { name: /new question/i }); + await userEvent.click(screen.getByRole("button", { name: "Active sessions" })); await userEvent.click(await screen.findByTestId("session-item-in-beta")); await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1)); expect(workspacePreferences.load()).toMatchObject({ workspaceId: "beta", provider: "test", model: "test" }); diff --git a/frontend/src/shell/AppShell.auth.test.tsx b/frontend/src/shell/AppShell.auth.test.tsx index d82fc4b1..7fde70dc 100644 --- a/frontend/src/shell/AppShell.auth.test.tsx +++ b/frontend/src/shell/AppShell.auth.test.tsx @@ -320,6 +320,7 @@ describe("authenticated shell permissions", () => { } renderUserShell(); + await userEvent.click(await screen.findByRole("button", { name: "Active sessions" })); await userEvent.click(await screen.findByTestId("session-item-panel-a")); expect(await screen.findByText("A-private-document")).toBeInTheDocument(); act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: "A-private-transcript" })); diff --git a/frontend/src/shell/AppShell.database-management.test.tsx b/frontend/src/shell/AppShell.database-management.test.tsx index 8239d35a..01f52a13 100644 --- a/frontend/src/shell/AppShell.database-management.test.tsx +++ b/frontend/src/shell/AppShell.database-management.test.tsx @@ -174,6 +174,7 @@ test("keeps a live core session connected while returning from database manageme renderShell(); await expandAdministration(); + await userEvent.click(screen.getByRole("button", { name: "Active sessions" })); const session = await screen.findByTestId("session-item-s1"); await userEvent.click(session); await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1)); diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index d6598144..e57965d8 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -20,10 +20,13 @@ const adminUser: AuthenticatedUser = { permissions: ["session.use", "session.read_all", "workspace.manage", "workspace.secrets.manage", "database.manage", "pi.manage"] as const, isAdmin: true, }; -function wrap(user: AuthenticatedUser = regularUser) { +function wrap(user: AuthenticatedUser = regularUser, openActive = true) { if (user !== regularUser) setAuthState(user); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render(); + const view = render(); + // Session-operation tests explicitly open the list before using its rows. + if (openActive) fireEvent.click(screen.getByRole("button", { name: "Active sessions" })); + return view; } const LIST = [ @@ -424,12 +427,17 @@ test("Archive accordion expands to reveal archived sessions", async () => { }); test("session accordion exposes labelled panels and supports independent keyboard toggles", async () => { - wrap(); - await screen.findByText("Attiva uno"); + wrap(regularUser, false); + await screen.findByRole("button", { name: "Archive (1)" }); const active = screen.getByRole("button", { name: "Active sessions" }); const archive = screen.getByRole("button", { name: "Archive (1)" }); - expect(active).toHaveAttribute("aria-expanded", "true"); + expect(active).toHaveAttribute("aria-expanded", "false"); expect(archive).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByText("Sessions", { exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole("checkbox", { name: "Select all" })).not.toBeInTheDocument(); + expect(screen.queryByRole("region", { name: "Active sessions" })).not.toBeInTheDocument(); + active.focus(); + await userEvent.keyboard("{Enter}"); expect(screen.getByRole("region", { name: "Active sessions" })).toHaveClass("thot-session-accordion__panel"); archive.focus(); await userEvent.keyboard("{Enter}"); @@ -443,6 +451,44 @@ test("session accordion exposes labelled panels and supports independent keyboar expect(screen.getByText("Archiviata due")).toBeInTheDocument(); }); +test("each list selects only its own sessions and preserves selection in the other list", async () => { + server.use(http.get("/api/sessions", () => HttpResponse.json([ + ...LIST, { ...LIST[0], id: "s3", question: "Attiva tre" }, + ]))); + wrap(); + await screen.findByText("Attiva tre"); + await userEvent.click(screen.getByRole("button", { name: "Archive (1)" })); + const active = within(screen.getByRole("region", { name: "Active sessions" })); + const archive = within(screen.getByRole("region", { name: "Archive (1)" })); + const activeAll = active.getByRole("checkbox", { name: "Select all" }); + const archiveAll = archive.getByRole("checkbox", { name: "Select all" }); + await userEvent.click(active.getByRole("checkbox", { name: "Select Attiva uno" })); + expect(activeAll).toBePartiallyChecked(); + await userEvent.click(activeAll); + expect(activeAll).toBeChecked(); + expect(active.getByRole("checkbox", { name: "Select Attiva tre" })).toBeChecked(); + expect(archiveAll).not.toBeChecked(); + await userEvent.click(archiveAll); + await userEvent.click(activeAll); + expect(activeAll).not.toBeChecked(); + expect(active.queryByRole("button", { name: /Delete.*selected sessions/ })).not.toBeInTheDocument(); + expect(archive.getByRole("checkbox", { name: "Select Archiviata due" })).toBeChecked(); + expect(archive.getByRole("button", { name: "Delete 1 selected sessions" })).toBeInTheDocument(); +}); + +test.each([false, true])("empty accordion list has no selection controls (archived=%s)", async (archived) => { + server.use(http.get("/api/sessions", () => HttpResponse.json([ + { ...LIST[0], archived, group: null }, + ]))); + wrap(regularUser, false); + await screen.findByRole("button", { name: `Archive (${archived ? 1 : 0})` }); + await userEvent.click(screen.getByRole("button", { name: "Active sessions" })); + await userEvent.click(screen.getByRole("button", { name: /^Archive/ })); + const emptyPanel = screen.getByRole("region", { name: archived ? "Active sessions" : "Archive (0)" }); + expect(within(emptyPanel).queryByRole("checkbox")).not.toBeInTheDocument(); + expect(screen.getAllByRole("checkbox", { name: "Select all" })).toHaveLength(1); +}); + test("Resume from the panel activates the session and closes the panel", async () => { let resumed: string | null = null; useSessionStore.setState({ diff --git a/frontend/src/shell/AppShell.session-target.test.tsx b/frontend/src/shell/AppShell.session-target.test.tsx index 1a87e625..cad0629d 100644 --- a/frontend/src/shell/AppShell.session-target.test.tsx +++ b/frontend/src/shell/AppShell.session-target.test.tsx @@ -57,6 +57,7 @@ test("a held stop for s1 cannot reset the newer active s2 session", async () => }), ); renderShell(); + await userEvent.click(await screen.findByRole("button", { name: "Active sessions" })); await userEvent.click(await screen.findByTestId("session-item-s1")); await waitFor(() => expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s1/events")); await userEvent.click(screen.getByRole("button", { name: /stop and save session/i })); @@ -97,6 +98,7 @@ test("a held new-session completion cannot replace the newer active s2 target", await userEvent.click(screen.getByRole("button", { name: /send/i })); await createStarted.promise; + await userEvent.click(screen.getByRole("button", { name: "Active sessions" })); await userEvent.click(screen.getByTestId("session-item-s2")); await waitFor(() => expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s2/events")); act(() => useSessionStore.setState({ currentPhase: "F2" })); diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 95a632fa..d550df26 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -256,7 +256,7 @@ export function AppShell({ canLogout }: AppShellProps) { const contextLocked = interactionBusy || creatingSession || administrationBusy || preprocessingQuery.data?.state === "running"; const [adminNavigationValue, setAdminNavigationValue] = useState([]); - const [activeOpen, setActiveOpen] = useState(true); + const [activeOpen, setActiveOpen] = useState(false); const [archiveOpen, setArchiveOpen] = useState(false); const [renameTarget, setRenameTarget] = useState(null); const [deleteTargets, setDeleteTargets] = useState([]); @@ -326,8 +326,39 @@ export function AppShell({ canLogout }: AppShellProps) { }); } - function toggleAllSessions(selected: boolean) { - setSelectedSessionIds(selected ? new Set(sessions.map((session) => session.id)) : new Set()); + function sessionSelectionControls(list: SessionSummary[]) { + if (list.length === 0) return null; + const selected = list.filter((session) => selectedSessionIds.has(session.id)); + return ( +
+ + {selected.length > 0 && ( + + )} +
+ ); } function handleSessionScopeTabKeyDown( @@ -1124,42 +1155,11 @@ export function AppShell({ canLogout }: AppShellProps) { className="flex min-h-0 flex-1 flex-col" > {canReadAllSessions && showingAllSessions && ( -

+

{t("Administrator view: all sessions")}

)} - {/* L1 — rail title */} -
- - {t("Sessions")} - -
- -
- - {selectedSessions.length > 0 && ( - - )} -
{ setActiveOpen(value.includes("active")); setArchiveOpen(value.includes("archive")); }} @@ -1172,6 +1172,7 @@ export function AppShell({ canLogout }: AppShellProps) { + {sessionSelectionControls(activeList)}
{groups.map((g) => (
@@ -1233,6 +1234,7 @@ export function AppShell({ canLogout }: AppShellProps) { + {sessionSelectionControls(archivedList)} ); await userEvent.click(await screen.findByRole("tab", { name: "All sessions" })); + await userEvent.click(screen.getByRole("button", { name: "Active sessions" })); await userEvent.click(await screen.findByText("Another analyst's work")); expect(await screen.findByRole("button", { name: "Resume" })).toBeInTheDocument(); expect(readRememberedSession(admin)).toBe("foreign");