fix(ui): collapse session lists and scope selection controls to panels

This commit is contained in:
Codex
2026-09-13 18:12:27 +02:00
parent 571a4bcaa2
commit cf90c1bd51
11 changed files with 119 additions and 47 deletions
+5 -1
View File
@@ -314,7 +314,11 @@ default, hover, focus, active, disabled, loading, and error behavior where those
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
(initially open) and Archive (initially closed). Preserve the existing archived
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
with a cap of `min(18rem, 35dvh)`, while its trigger remains outside that scroll
+6 -3
View File
@@ -47,18 +47,21 @@ successful, current `ready` response; checking, unavailable and other states are
red, with the state exposed through the tooltip and accessible description.
The backend readiness gate is unchanged.
Active sessions (non-archived) start expanded; Archive starts collapsed. Both
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
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: 763 frontend unit tests, 20 browser scenarios (including 80 mocked
Verification: 766 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-navigation-accordions-20260913` for rollback.
`thothii-frontend:before-scoped-session-selection-20260913` for rollback.
### Agreed Omics delivery route — 2026-09-13
+5 -2
View File
@@ -63,8 +63,11 @@ In Administration, the dot beside **Workspace** is green when readiness is
confirmed and red otherwise. Hover the button for the exact state; assistive
technology receives the same description. Select Workspace to inspect preparation.
The session sidebar has two accordion sections: **Active sessions** starts open,
**Archive** starts closed. Both can be open together; long lists scroll inside
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
their own panels. Here active means not archived, not necessarily a running model
process. Existing groups and session actions remain inside those sections.
+9 -1
View File
@@ -125,11 +125,19 @@ for (const width of [390, 1280]) {
const green = await workspaceButton.getByRole("img").evaluate(el => getComputedStyle(el).backgroundColor);
const active = page.getByRole("button", { name: "Active sessions", exact: true });
const archive = page.getByRole("button", { name: "Archive (40)", exact: true });
await expect(active).toHaveAttribute("aria-expanded", "true");
await expect(active).toHaveAttribute("aria-expanded", "false");
await expect(archive).toHaveAttribute("aria-expanded", "false");
await expect(page.getByRole("checkbox", { name: "Select all" })).toHaveCount(0);
await expect(page.getByText("Sessions", { exact: true })).toHaveCount(0);
const activeBox = await active.boundingBox();
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)"]) {
const panel = page.getByRole("region", { name, exact: true });
await expect(panel.getByRole("checkbox", { name: "Select all" })).toBeVisible();
await expect(panel).toHaveCSS("overflow-y", "auto");
const dimensions = await panel.evaluate(el => ({ height: el.getBoundingClientRect().height, scroll: el.scrollHeight, client: el.clientHeight }));
expect(dimensions.height).toBeLessThanOrEqual(288);
@@ -222,6 +222,7 @@ test("Resume binds the pinned workspace immediately and leaves the global model
);
renderShell();
await screen.findByRole("textbox", { name: /new question/i });
await userEvent.click(screen.getByRole("button", { name: "Active sessions" }));
await userEvent.click(await screen.findByTestId("session-item-in-beta"));
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
expect(workspacePreferences.load()).toMatchObject({ workspaceId: "beta", provider: "test", model: "test" });
@@ -320,6 +320,7 @@ describe("authenticated shell permissions", () => {
}
renderUserShell();
await userEvent.click(await screen.findByRole("button", { name: "Active sessions" }));
await userEvent.click(await screen.findByTestId("session-item-panel-a"));
expect(await screen.findByText("A-private-document")).toBeInTheDocument();
act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: "A-private-transcript" }));
@@ -174,6 +174,7 @@ test("keeps a live core session connected while returning from database manageme
renderShell();
await expandAdministration();
await userEvent.click(screen.getByRole("button", { name: "Active sessions" }));
const session = await screen.findByTestId("session-item-s1");
await userEvent.click(session);
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
@@ -20,10 +20,13 @@ const adminUser: AuthenticatedUser = {
permissions: ["session.use", "session.read_all", "workspace.manage", "workspace.secrets.manage", "database.manage", "pi.manage"] as const, isAdmin: true,
};
function wrap(user: AuthenticatedUser = regularUser) {
function wrap(user: AuthenticatedUser = regularUser, openActive = true) {
if (user !== regularUser) setAuthState(user);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}><AppShell canLogout={false} /></QueryClientProvider>);
const view = render(<QueryClientProvider client={client}><AppShell canLogout={false} /></QueryClientProvider>);
// Session-operation tests explicitly open the list before using its rows.
if (openActive) fireEvent.click(screen.getByRole("button", { name: "Active sessions" }));
return view;
}
const LIST = [
@@ -424,12 +427,17 @@ test("Archive accordion expands to reveal archived sessions", async () => {
});
test("session accordion exposes labelled panels and supports independent keyboard toggles", async () => {
wrap();
await screen.findByText("Attiva uno");
wrap(regularUser, false);
await screen.findByRole("button", { name: "Archive (1)" });
const active = screen.getByRole("button", { name: "Active sessions" });
const archive = screen.getByRole("button", { name: "Archive (1)" });
expect(active).toHaveAttribute("aria-expanded", "true");
expect(active).toHaveAttribute("aria-expanded", "false");
expect(archive).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("Sessions", { exact: true })).not.toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: "Select all" })).not.toBeInTheDocument();
expect(screen.queryByRole("region", { name: "Active sessions" })).not.toBeInTheDocument();
active.focus();
await userEvent.keyboard("{Enter}");
expect(screen.getByRole("region", { name: "Active sessions" })).toHaveClass("thot-session-accordion__panel");
archive.focus();
await userEvent.keyboard("{Enter}");
@@ -443,6 +451,44 @@ test("session accordion exposes labelled panels and supports independent keyboar
expect(screen.getByText("Archiviata due")).toBeInTheDocument();
});
test("each list selects only its own sessions and preserves selection in the other list", async () => {
server.use(http.get("/api/sessions", () => HttpResponse.json([
...LIST, { ...LIST[0], id: "s3", question: "Attiva tre" },
])));
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();
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();
expect(archive.getByRole("checkbox", { name: "Select Archiviata due" })).toBeChecked();
expect(archive.getByRole("button", { name: "Delete 1 selected sessions" })).toBeInTheDocument();
});
test.each([false, true])("empty accordion list has no selection controls (archived=%s)", async (archived) => {
server.use(http.get("/api/sessions", () => HttpResponse.json([
{ ...LIST[0], archived, group: null },
])));
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/ }));
const emptyPanel = screen.getByRole("region", { name: archived ? "Active sessions" : "Archive (0)" });
expect(within(emptyPanel).queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.getAllByRole("checkbox", { name: "Select all" })).toHaveLength(1);
});
test("Resume from the panel activates the session and closes the panel", async () => {
let resumed: string | null = null;
useSessionStore.setState({
@@ -57,6 +57,7 @@ test("a held stop for s1 cannot reset the newer active s2 session", async () =>
}),
);
renderShell();
await userEvent.click(await screen.findByRole("button", { name: "Active sessions" }));
await userEvent.click(await screen.findByTestId("session-item-s1"));
await waitFor(() => expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s1/events"));
await userEvent.click(screen.getByRole("button", { name: /stop and save session/i }));
@@ -97,6 +98,7 @@ test("a held new-session completion cannot replace the newer active s2 target",
await userEvent.click(screen.getByRole("button", { name: /send/i }));
await createStarted.promise;
await userEvent.click(screen.getByRole("button", { name: "Active sessions" }));
await userEvent.click(screen.getByTestId("session-item-s2"));
await waitFor(() => expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s2/events"));
act(() => useSessionStore.setState({ currentPhase: "F2" }));
+37 -35
View File
@@ -256,7 +256,7 @@ export function AppShell({ canLogout }: AppShellProps) {
const contextLocked = interactionBusy || creatingSession || administrationBusy
|| preprocessingQuery.data?.state === "running";
const [adminNavigationValue, setAdminNavigationValue] = useState<string[]>([]);
const [activeOpen, setActiveOpen] = useState(true);
const [activeOpen, setActiveOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const [renameTarget, setRenameTarget] = useState<SessionSummary | null>(null);
const [deleteTargets, setDeleteTargets] = useState<SessionSummary[]>([]);
@@ -326,8 +326,39 @@ export function AppShell({ canLogout }: AppShellProps) {
});
}
function toggleAllSessions(selected: boolean) {
setSelectedSessionIds(selected ? new Set(sessions.map((session) => session.id)) : new Set());
function sessionSelectionControls(list: SessionSummary[]) {
if (list.length === 0) return null;
const selected = list.filter((session) => selectedSessionIds.has(session.id));
return (
<div className="flex items-center justify-between gap-2 px-2 pb-2">
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-muted-foreground hover:text-foreground">
<Checkbox
checked={selected.length === list.length}
indeterminate={selected.length > 0 && selected.length < list.length}
onCheckedChange={(checked) => setSelectedSessionIds((current) => {
const next = new Set(current);
for (const session of list) {
if (checked) next.add(session.id);
else next.delete(session.id);
}
return next;
})}
/>
<span>{t("Select all")}</span>
</label>
{selected.length > 0 && (
<Button
variant="destructive"
size="xs"
aria-label={t("Delete {count} selected sessions", { count: selected.length })}
onClick={() => requestDelete(selected)}
>
<Trash2 />
{t("Delete ({count})", { count: selected.length })}
</Button>
)}
</div>
);
}
function handleSessionScopeTabKeyDown(
@@ -1124,42 +1155,11 @@ export function AppShell({ canLogout }: AppShellProps) {
className="flex min-h-0 flex-1 flex-col"
>
{canReadAllSessions && showingAllSessions && (
<p className="mx-4 mb-2 mt-2 text-xs font-medium text-muted-foreground">
<p className="sr-only">
{t("Administrator view: all sessions")}
</p>
)}
{/* L1 — rail title */}
<div className="px-4 pb-1.5 pt-1">
<span className="text-sm font-semibold text-foreground">
{t("Sessions")}
</span>
</div>
<div className="flex items-center justify-between px-4 pb-2">
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-muted-foreground hover:text-foreground">
<Checkbox
checked={allSessionsSelected}
aria-label={t("Select all sessions")}
disabled={sessions.length === 0}
onCheckedChange={(selected) => toggleAllSessions(selected === true)}
/>
<span>{t("Select all")}</span>
</label>
{selectedSessions.length > 0 && (
<Button
variant="destructive"
size="xs"
aria-label={t("Delete {count} selected sessions", { count: selectedSessions.length })}
onClick={() => {
requestDelete(selectedSessions);
}}
>
<Trash2 />
{t("Delete ({count})", { count: selectedSessions.length })}
</Button>
)}
</div>
<Accordion.Root multiple
value={[...(activeOpen ? ["active"] : []), ...(archiveOpen ? ["archive"] : [])]}
onValueChange={value => { setActiveOpen(value.includes("active")); setArchiveOpen(value.includes("archive")); }}
@@ -1172,6 +1172,7 @@ export function AppShell({ canLogout }: AppShellProps) {
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel className="thot-session-accordion__panel" tabIndex={0}>
{sessionSelectionControls(activeList)}
<div className="flex flex-col gap-3 pb-2">
{groups.map((g) => (
<div key={g}>
@@ -1233,6 +1234,7 @@ export function AppShell({ canLogout }: AppShellProps) {
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel className="thot-session-accordion__panel" tabIndex={0}>
{sessionSelectionControls(archivedList)}
<NavSessions
sessions={archivedList}
activeSessionId={activeSessionId}
@@ -195,6 +195,7 @@ test("reload restores another user's selected session from the authorized All se
);
const first = render(<App />);
await userEvent.click(await screen.findByRole("tab", { name: "All sessions" }));
await userEvent.click(screen.getByRole("button", { name: "Active sessions" }));
await userEvent.click(await screen.findByText("Another analyst's work"));
expect(await screen.findByRole("button", { name: "Resume" })).toBeInTheDocument();
expect(readRememberedSession(admin)).toBe("foreign");