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.")}
);
}