fix(ui): collapse session lists and scope selection controls to panels
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user