From bdcd8fcd28f3011471d77224db9c3f5baf227995 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 14 Sep 2026 01:09:45 +0200 Subject: [PATCH] fix(ui): open one session accordion panel at a time --- DESIGN.md | 6 ++-- PROJECT_STATE.md | 13 +++---- docs/guida-utente.md | 3 +- frontend/e2e/ui-visual-review.spec.ts | 5 +-- .../src/shell/AppShell.session-mgmt.test.tsx | 36 ++++++++++++------- frontend/src/shell/AppShell.tsx | 5 ++- frontend/src/shell/NavSessions.test.tsx | 6 ++++ frontend/src/shell/NavSessions.tsx | 2 +- 8 files changed, 48 insertions(+), 28 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 8adea87d..bb07e807 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -313,14 +313,16 @@ default, hover, focus, active, disabled, loading, and error behavior where those preprocessing and no query error; all other states are red. The button's 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 +- **Session groups:** one accessible single-open accordion contains Active sessions 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 + Opening a section closes the other; either can be collapsed, including both. + Empty lists show only the translated "No sessions yet." message. + The open section uses the rail's remaining height; its list scrolls internally with a cap of `min(18rem, 35dvh)`, while its trigger remains outside that scroll area. The mobile navigation dialog supplies a bounded viewport-height container. Keyboard users can focus and scroll each labelled panel. diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index faf8fc32..4cd75b16 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,6 +1,6 @@ # ThothII — Project State -Last updated: 2026-09-13. +Last updated: 2026-09-14. This file is the short operational snapshot. Stable commands and the architecture mental model live in `AGENTS.md`; current design and runtime contracts live under `docs/architecture/`, @@ -50,18 +50,19 @@ The backend readiness gate is unchanged. 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 +bulk-delete controls, scoped to that list and preserving the other's selection. +As of 2026-09-14, only one section can be open at a time, and either can be +collapsed. Empty lists show only "No sessions yet." The open section uses the +remaining sidebar height, with 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: 766 frontend unit tests, 20 browser scenarios (including 80 mocked +Verification: 768 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-scoped-session-selection-20260913` for rollback. +`thothii-frontend:before-single-session-accordion-20260914` for rollback. ### Agreed Omics delivery route — 2026-09-13 diff --git a/docs/guida-utente.md b/docs/guida-utente.md index 18143eb9..c2714339 100644 --- a/docs/guida-utente.md +++ b/docs/guida-utente.md @@ -67,7 +67,8 @@ 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 +is preserved. Opening a section closes the other; clicking the open section closes +it too. Empty lists show only "No sessions yet." 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 f89c4403..853e36fe 100644 --- a/frontend/e2e/ui-visual-review.spec.ts +++ b/frontend/e2e/ui-visual-review.spec.ts @@ -133,9 +133,10 @@ for (const width of [390, 1280]) { 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)"]) { + await page.getByRole("button", { name, exact: true }).click(); + await expect(name === "Active sessions" ? archive : active).toHaveAttribute("aria-expanded", "false"); + await expect(page.locator(".thot-session-accordion__panel:visible")).toHaveCount(1); const panel = page.getByRole("region", { name, exact: true }); await expect(panel.getByRole("checkbox", { name: "Select all" })).toBeVisible(); await expect(panel).toHaveCSS("overflow-y", "auto"); diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index e57965d8..3819dc9a 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -422,11 +422,10 @@ test("Archive accordion expands to reveal archived sessions", async () => { expect(screen.queryByText("Archiviata due")).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: /archive/i })); expect(await screen.findByText("Archiviata due")).toBeInTheDocument(); - // Active sessions remain visible (it is a separate accordion, not a swap) - expect(screen.getByText("Attiva uno")).toBeInTheDocument(); + expect(screen.queryByText("Attiva uno")).not.toBeInTheDocument(); }); -test("session accordion exposes labelled panels and supports independent keyboard toggles", async () => { +test("session accordion permits only one open panel and supports keyboard toggles", async () => { wrap(regularUser, false); await screen.findByRole("button", { name: "Archive (1)" }); const active = screen.getByRole("button", { name: "Active sessions" }); @@ -443,12 +442,17 @@ test("session accordion exposes labelled panels and supports independent keyboar await userEvent.keyboard("{Enter}"); expect(archive).toHaveAttribute("aria-expanded", "true"); expect(screen.getByRole("region", { name: "Archive (1)" })).toHaveAttribute("tabindex", "0"); - expect(active).toHaveAttribute("aria-expanded", "true"); + expect(active).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByText("Attiva uno")).not.toBeInTheDocument(); active.focus(); await userEvent.keyboard(" "); + expect(active).toHaveAttribute("aria-expanded", "true"); + expect(archive).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByText("Archiviata due")).not.toBeInTheDocument(); + await userEvent.keyboard(" "); expect(active).toHaveAttribute("aria-expanded", "false"); expect(screen.queryByText("Attiva uno")).not.toBeInTheDocument(); - expect(screen.getByText("Archiviata due")).toBeInTheDocument(); + expect(screen.queryByRole("region", { name: "Archive (1)" })).not.toBeInTheDocument(); }); test("each list selects only its own sessions and preserves selection in the other list", async () => { @@ -457,21 +461,25 @@ test("each list selects only its own sessions and preserves selection in the oth ]))); 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(); + await userEvent.click(screen.getByRole("button", { name: "Archive (1)" })); + const archiveAll = within(screen.getByRole("region", { name: "Archive (1)" })).getByRole("checkbox", { name: "Select all" }); 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(); + await userEvent.click(screen.getByRole("button", { name: "Active sessions" })); + const reopenedActive = within(screen.getByRole("region", { name: "Active sessions" })); + expect(reopenedActive.getByRole("checkbox", { name: "Select all" })).toBeChecked(); + await userEvent.click(reopenedActive.getByRole("checkbox", { name: "Select all" })); + expect(reopenedActive.getByRole("checkbox", { name: "Select all" })).not.toBeChecked(); + expect(reopenedActive.queryByRole("button", { name: /Delete.*selected sessions/ })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Archive (1)" })); + const archive = within(screen.getByRole("region", { name: "Archive (1)" })); expect(archive.getByRole("checkbox", { name: "Select Archiviata due" })).toBeChecked(); expect(archive.getByRole("button", { name: "Delete 1 selected sessions" })).toBeInTheDocument(); }); @@ -482,10 +490,11 @@ test.each([false, true])("empty accordion list has no selection controls (archiv ]))); 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/ })); + await userEvent.click(screen.getByRole("button", { name: archived ? "Active sessions" : "Archive (0)" })); const emptyPanel = screen.getByRole("region", { name: archived ? "Active sessions" : "Archive (0)" }); expect(within(emptyPanel).queryByRole("checkbox")).not.toBeInTheDocument(); + expect(screen.queryByRole("checkbox", { name: "Select all" })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: archived ? "Archive (1)" : "Active sessions" })); expect(screen.getAllByRole("checkbox", { name: "Select all" })).toHaveLength(1); }); @@ -1056,6 +1065,7 @@ test("deleting another session does not invalidate a pending Resume", async () = await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1)); expect(FakeEventSource.instances[0].url).toContain("/sessions/s1/events"); + await userEvent.click(screen.getByRole("button", { name: "Active sessions" })); expect(screen.getByTestId("session-item-s1")).toHaveAttribute("data-active", "true"); }); diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index d550df26..442f9b81 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -1160,7 +1160,7 @@ export function AppShell({ canLogout }: AppShellProps) {

)} - { setActiveOpen(value.includes("active")); setArchiveOpen(value.includes("archive")); }} className="thot-session-accordion min-h-0 flex-1 overflow-y-auto px-2 pb-4"> @@ -1210,8 +1210,7 @@ export function AppShell({ canLogout }: AppShellProps) { ))} {/* Ungrouped sessions list directly after the groups — no "No group" label. - When there are no groups at all, still render it so its empty state can - teach first-time users. */} + When there are no groups at all, still render its empty state. */} {(ungroupedActive.length > 0 || groups.length === 0) && ( act(() => setLocale("en"))); +test.each(["en", "it"] as const)("empty list has only the empty-state message in %s", (locale) => { + act(() => setLocale(locale)); + const { container } = render( null} />); + expect(container.textContent).toBe(locale === "it" ? "Non ci sono ancora sessioni." : "No sessions yet."); +}); + test("translates session status and selection names while preserving authored names and IDs", async () => { const onOpenPanel = vi.fn(); render( null} />); diff --git a/frontend/src/shell/NavSessions.tsx b/frontend/src/shell/NavSessions.tsx index fd36e522..63c85a4c 100644 --- a/frontend/src/shell/NavSessions.tsx +++ b/frontend/src/shell/NavSessions.tsx @@ -29,7 +29,7 @@ export function NavSessions({ if (sessions.length === 0) { return (

- {translate("No sessions yet.")} {translate("Start with Session.")} + {translate("No sessions yet.")}

); }