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
@@ -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>
);
}