fix(ui): open one session accordion panel at a time

This commit is contained in:
Codex
2026-09-14 01:09:45 +02:00
parent cf90c1bd51
commit bdcd8fcd28
8 changed files with 48 additions and 28 deletions
+4 -2
View File
@@ -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.
+7 -6
View File
@@ -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
+2 -1
View File
@@ -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.
+3 -2
View File
@@ -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");
@@ -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");
});
+2 -3
View File
@@ -1160,7 +1160,7 @@ export function AppShell({ canLogout }: AppShellProps) {
</p>
)}
<Accordion.Root multiple
<Accordion.Root multiple={false}
value={[...(activeOpen ? ["active"] : []), ...(archiveOpen ? ["archive"] : [])]}
onValueChange={value => { 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) {
</div>
))}
{/* 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) && (
<NavSessions
sessions={ungroupedActive}
+6
View File
@@ -11,6 +11,12 @@ const SESSIONS: SessionSummary[] = [
afterEach(() => 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(<NavSessions sessions={[]} activeSessionId={null} onOpenPanel={vi.fn()} menuFor={() => 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(<NavSessions sessions={SESSIONS} activeSessionId="s2" showOwner onOpenPanel={onOpenPanel} menuFor={() => null} />);
+1 -1
View File
@@ -29,7 +29,7 @@ export function NavSessions({
if (sessions.length === 0) {
return (
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
{translate("No sessions yet.")} <span className="font-medium text-foreground">{translate("Start with Session.")}</span>
{translate("No sessions yet.")}
</p>
);
}