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