fix(ui): open one session accordion panel at a time
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user