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 preprocessing and no query error; all other states are red. The button's
tooltip and accessible description retain the translated exact state. Do not tooltip and accessible description retain the translated exact state. Do not
add a separate readiness text row or change the backend readiness gate. 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 and Archive, both initially closed. Below the scope tabs, show only their
adjacent section headers, without a redundant Sessions heading. Selection and adjacent section headers, without a redundant Sessions heading. Selection and
bulk-delete controls belong inside each panel and only appear for nonempty 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, 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 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. 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 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. area. The mobile navigation dialog supplies a bounded viewport-height container.
Keyboard users can focus and scroll each labelled panel. Keyboard users can focus and scroll each labelled panel.
+7 -6
View File
@@ -1,6 +1,6 @@
# ThothII — Project State # 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 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/`, 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 Active sessions (non-archived) and Archive both start collapsed. Their adjacent
headers are the only visible content below the scope tabs: no Sessions heading 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 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 bulk-delete controls, scoped to that list and preserving the other's selection.
accordion sections can remain open and share the remaining sidebar height, As of 2026-09-14, only one section can be open at a time, and either can be
with independently scrolling content capped at `min(18rem, 35dvh)`. The mobile 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 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. 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. 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`. 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 Core, catalog, Qdrant and embedding containers were not changed. The prior
frontend image is retained as 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 ### 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. **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 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 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 their own panels. Here active means not archived, not necessarily a running model
process. Existing groups and session actions remain inside those sections. 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(); const archiveBox = await archive.boundingBox();
expect(archiveBox!.y - activeBox!.y - activeBox!.height).toBeLessThan(8); expect(archiveBox!.y - activeBox!.y - activeBox!.height).toBeLessThan(8);
await page.screenshot({ path: testInfo.outputPath("session-accordions-closed.png"), animations: "disabled" }); 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)"]) { 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 }); const panel = page.getByRole("region", { name, exact: true });
await expect(panel.getByRole("checkbox", { name: "Select all" })).toBeVisible(); await expect(panel.getByRole("checkbox", { name: "Select all" })).toBeVisible();
await expect(panel).toHaveCSS("overflow-y", "auto"); 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(); expect(screen.queryByText("Archiviata due")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /archive/i })); await userEvent.click(screen.getByRole("button", { name: /archive/i }));
expect(await screen.findByText("Archiviata due")).toBeInTheDocument(); expect(await screen.findByText("Archiviata due")).toBeInTheDocument();
// Active sessions remain visible (it is a separate accordion, not a swap) expect(screen.queryByText("Attiva uno")).not.toBeInTheDocument();
expect(screen.getByText("Attiva uno")).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); wrap(regularUser, false);
await screen.findByRole("button", { name: "Archive (1)" }); await screen.findByRole("button", { name: "Archive (1)" });
const active = screen.getByRole("button", { name: "Active sessions" }); 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}"); await userEvent.keyboard("{Enter}");
expect(archive).toHaveAttribute("aria-expanded", "true"); expect(archive).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("region", { name: "Archive (1)" })).toHaveAttribute("tabindex", "0"); 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(); active.focus();
await userEvent.keyboard(" "); 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(active).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("Attiva uno")).not.toBeInTheDocument(); 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 () => { 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(); wrap();
await screen.findByText("Attiva tre"); await screen.findByText("Attiva tre");
await userEvent.click(screen.getByRole("button", { name: "Archive (1)" }));
const active = within(screen.getByRole("region", { name: "Active sessions" })); 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 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" })); await userEvent.click(active.getByRole("checkbox", { name: "Select Attiva uno" }));
expect(activeAll).toBePartiallyChecked(); expect(activeAll).toBePartiallyChecked();
await userEvent.click(activeAll); await userEvent.click(activeAll);
expect(activeAll).toBeChecked(); expect(activeAll).toBeChecked();
expect(active.getByRole("checkbox", { name: "Select Attiva tre" })).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(); expect(archiveAll).not.toBeChecked();
await userEvent.click(archiveAll); await userEvent.click(archiveAll);
await userEvent.click(activeAll); await userEvent.click(screen.getByRole("button", { name: "Active sessions" }));
expect(activeAll).not.toBeChecked(); const reopenedActive = within(screen.getByRole("region", { name: "Active sessions" }));
expect(active.queryByRole("button", { name: /Delete.*selected sessions/ })).not.toBeInTheDocument(); 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("checkbox", { name: "Select Archiviata due" })).toBeChecked();
expect(archive.getByRole("button", { name: "Delete 1 selected sessions" })).toBeInTheDocument(); 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); wrap(regularUser, false);
await screen.findByRole("button", { name: `Archive (${archived ? 1 : 0})` }); 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: archived ? "Active sessions" : "Archive (0)" }));
await userEvent.click(screen.getByRole("button", { name: /^Archive/ }));
const emptyPanel = screen.getByRole("region", { 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(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); 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)); await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
expect(FakeEventSource.instances[0].url).toContain("/sessions/s1/events"); 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"); expect(screen.getByTestId("session-item-s1")).toHaveAttribute("data-active", "true");
}); });
+2 -3
View File
@@ -1160,7 +1160,7 @@ export function AppShell({ canLogout }: AppShellProps) {
</p> </p>
)} )}
<Accordion.Root multiple <Accordion.Root multiple={false}
value={[...(activeOpen ? ["active"] : []), ...(archiveOpen ? ["archive"] : [])]} value={[...(activeOpen ? ["active"] : []), ...(archiveOpen ? ["archive"] : [])]}
onValueChange={value => { setActiveOpen(value.includes("active")); setArchiveOpen(value.includes("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"> 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> </div>
))} ))}
{/* Ungrouped sessions list directly after the groups — no "No group" label. {/* 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 When there are no groups at all, still render its empty state. */}
teach first-time users. */}
{(ungroupedActive.length > 0 || groups.length === 0) && ( {(ungroupedActive.length > 0 || groups.length === 0) && (
<NavSessions <NavSessions
sessions={ungroupedActive} sessions={ungroupedActive}
+6
View File
@@ -11,6 +11,12 @@ const SESSIONS: SessionSummary[] = [
afterEach(() => act(() => setLocale("en"))); 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 () => { test("translates session status and selection names while preserving authored names and IDs", async () => {
const onOpenPanel = vi.fn(); const onOpenPanel = vi.fn();
render(<NavSessions sessions={SESSIONS} activeSessionId="s2" showOwner onOpenPanel={onOpenPanel} menuFor={() => null} />); 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) { if (sessions.length === 0) {
return ( return (
<p className="px-1 py-6 text-center text-xs text-muted-foreground"> <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> </p>
); );
} }