From 7103bfcb4c13565a79d248f124fdb3118f778928 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 30 Jul 2026 00:21:53 +0200 Subject: [PATCH] feat(sessions): streamline bulk selection toolbar Merge Select visible / Clear visible / Clear into one binary toggle (Select visible when empty, Clear selected otherwise) and drop the redundant Done button; selection mode closes from the same heading toggle that opened it. Shorten Archive/Delete labels so the toolbar fits one line on narrow screens. --- .../streamline-bulk-selection-toolbar.md | 5 + .../components/SessionList.selection.test.ts | 157 ++++++++++++++++++ src/client/src/components/SessionList.ts | 45 ++--- 3 files changed, 186 insertions(+), 21 deletions(-) create mode 100644 .changeset/streamline-bulk-selection-toolbar.md create mode 100644 src/client/src/components/SessionList.selection.test.ts diff --git a/.changeset/streamline-bulk-selection-toolbar.md b/.changeset/streamline-bulk-selection-toolbar.md new file mode 100644 index 0000000..da2c9d5 --- /dev/null +++ b/.changeset/streamline-bulk-selection-toolbar.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Streamline the session list bulk-selection toolbar: the Select visible / Clear visible / Clear buttons are now a single toggle that offers "Select visible" when nothing is selected and "Clear selected" otherwise, and the redundant Done button is gone — selection mode closes from the same ☑ heading button that opened it. and "Archive selected" / "Delete selected" are shortened to "Archive" / "Delete". The slimmer toolbar no longer wraps to two lines on narrow sidebars. diff --git a/src/client/src/components/SessionList.selection.test.ts b/src/client/src/components/SessionList.selection.test.ts new file mode 100644 index 0000000..89344c7 --- /dev/null +++ b/src/client/src/components/SessionList.selection.test.ts @@ -0,0 +1,157 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it } from "vitest"; +import type { SessionInfo } from "../api"; +import { SessionList } from "./SessionList"; + +afterEach(() => { + document.body.replaceChildren(); + localStorage.clear(); +}); + +describe("SessionList bulk selection toolbar", () => { + it("offers Select visible with an empty selection and no Clear or Done buttons", async () => { + const list = await renderSessionList([session("a"), session("b"), session("c")]); + + currentSelectionToggle(list).click(); + await list.updateComplete; + + expect(toolbarButton(list, "Select visible")).not.toBeNull(); + expect(toolbarButton(list, "Clear selected")).toBeNull(); + expect(toolbarButton(list, "Clear")).toBeNull(); + expect(toolbarButton(list, "Done")).toBeNull(); + expect(selectionCount(list)?.textContent.trim()).toBe("0 selected"); + }); + + it("selects every visible session via Select visible, then clears them via Clear selected", async () => { + const list = await renderSessionList([session("a"), session("b"), session("c")]); + currentSelectionToggle(list).click(); + await list.updateComplete; + + toolbarButton(list, "Select visible")?.click(); + await list.updateComplete; + + expect(checkedBoxes(list)).toHaveLength(3); + expect(selectionCount(list)?.textContent.trim()).toBe("3 selected"); + expect(toolbarButton(list, "Select visible")).toBeNull(); + + toolbarButton(list, "Clear selected")?.click(); + await list.updateComplete; + + expect(checkedBoxes(list)).toHaveLength(0); + expect(selectionCount(list)?.textContent.trim()).toBe("0 selected"); + // Clearing keeps selection mode open so the visible set can be re-selected. + expect(toolbarButton(list, "Select visible")).not.toBeNull(); + }); + + it("clears a partial manual selection via Clear selected", async () => { + const list = await renderSessionList([session("a"), session("b"), session("c")]); + currentSelectionToggle(list).click(); + await list.updateComplete; + + checkboxes(list)[0]?.click(); + await list.updateComplete; + + expect(selectionCount(list)?.textContent.trim()).toBe("1 selected"); + expect(toolbarButton(list, "Select visible")).toBeNull(); + + toolbarButton(list, "Clear selected")?.click(); + await list.updateComplete; + + expect(checkedBoxes(list)).toHaveLength(0); + expect(toolbarButton(list, "Select visible")).not.toBeNull(); + }); + + it("closes selection mode, discarding the selection, from the same heading toggle that opened it", async () => { + const list = await renderSessionList([session("a"), session("b"), session("c")]); + currentSelectionToggle(list).click(); + await list.updateComplete; + toolbarButton(list, "Select visible")?.click(); + await list.updateComplete; + expect(checkedBoxes(list)).toHaveLength(3); + + currentSelectionToggle(list).click(); + await list.updateComplete; + + expect(list.shadowRoot?.querySelector(".bulk-row.selecting")).toBeNull(); + expect(checkboxes(list)).toHaveLength(0); + }); + + it("offers the same toggle in the archived scope", async () => { + const archivedA = session("archived-a", { archived: true, archivedAt: "2026-06-09T00:00:00.000Z" }); + const archivedB = session("archived-b", { archived: true, archivedAt: "2026-06-09T00:00:00.000Z" }); + const list = await renderSessionList([session("current"), archivedA, archivedB]); + + archivedSectionToggle(list)?.click(); + await list.updateComplete; + archivedSelectionToggle(list).click(); + await list.updateComplete; + + toolbarButton(list, "Select visible")?.click(); + await list.updateComplete; + expect(checkedBoxes(list)).toHaveLength(2); + expect(selectionCount(list)?.textContent.trim()).toBe("2 selected"); + + toolbarButton(list, "Clear selected")?.click(); + await list.updateComplete; + expect(checkedBoxes(list)).toHaveLength(0); + expect(toolbarButton(list, "Select visible")).not.toBeNull(); + }); +}); + +async function renderSessionList(sessions: SessionInfo[]): Promise { + const list = new SessionList(); + list.sessions = sessions; + document.body.append(list); + await list.updateComplete; + return list; +} + +function currentSelectionToggle(list: SessionList): HTMLButtonElement { + const button = list.shadowRoot?.querySelector("h2:not(.subheading) .bulk-select-entry"); + if (button === null || button === undefined) throw new Error("Expected the current selection toggle"); + return button; +} + +function archivedSelectionToggle(list: SessionList): HTMLButtonElement { + const button = list.shadowRoot?.querySelector("h2.subheading .bulk-select-entry"); + if (button === null || button === undefined) throw new Error("Expected the archived selection toggle"); + return button; +} + +function archivedSectionToggle(list: SessionList): HTMLButtonElement | null { + return list.shadowRoot?.querySelector("h2.subheading .section-toggle") ?? null; +} + +function toolbarButton(list: SessionList, text: string): HTMLButtonElement | null { + const buttons = list.shadowRoot?.querySelectorAll(".bulk-row.selecting button") ?? []; + for (const button of buttons) { + if (button.textContent.trim() === text) return button; + } + return null; +} + +function selectionCount(list: SessionList): HTMLElement | null { + return list.shadowRoot?.querySelector(".bulk-row.selecting small") ?? null; +} + +function checkboxes(list: SessionList): HTMLInputElement[] { + return [...(list.shadowRoot?.querySelectorAll("input.session-checkbox") ?? [])]; +} + +function checkedBoxes(list: SessionList): HTMLInputElement[] { + return checkboxes(list).filter((checkbox) => checkbox.checked); +} + +function session(id: string, overrides: Partial = {}): SessionInfo { + return { + id, + path: `/sessions/${id}.jsonl`, + cwd: "/workspace", + created: "2026-06-09T00:00:00.000Z", + modified: "2026-06-09T00:00:00.000Z", + messageCount: 1, + firstMessage: id, + ...overrides, + }; +} diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index ce243c8..9550657 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -230,16 +230,11 @@ export class SessionList extends LitElement implements KeyboardNavigableSection const selectedSessions = this.selectedSessions("current"); const archivableSessions = selectedSessions.filter((session) => isArchivableSessionInfo(session, this.statuses[session.id], this.sessionPersistenceOptions())); const unreadSelectedSessions = selectedSessions.filter((session) => this.unreadSessionIds.has(session.id)); - const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => this.selectedSessionIds.has(session.id)); - const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length; return html`
- - ${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null} - + ${this.renderSelectionControls("current", visibleSessions)} + - -
`; } @@ -248,20 +243,33 @@ export class SessionList extends LitElement implements KeyboardNavigableSection if (visibleSessions.length === 0 || !this.selectionScopes.has("archived")) return null; const selectedSessions = this.selectedSessions("archived"); - const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => this.selectedSessionIds.has(session.id)); - const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length; return html`
- - ${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null} - - - + ${this.renderSelectionControls("archived", visibleSessions)} + ${this.canDeleteArchived ? null : html`${this.archivedDeleteUnavailableMessage}`}
`; } + /** + * Shared selection toggle and count for both scopes. The toggle is binary: + * an empty selection offers to select every visible session, and any + * existing selection offers to clear the whole scope. Selection mode itself + * is exited from the same ☑ heading button that opened it, so the toolbar + * carries no separate Done or Clear buttons. + */ + private renderSelectionControls(scope: SessionSelectionScope, visibleSessions: SessionInfo[]) { + const selectedCount = this.selectedSessions(scope).length; + const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length; + return html` + ${selectedCount === 0 + ? html`` + : html``} + ${selectedCount} selected${visibleSelectedCount !== selectedCount ? html` · ${visibleSelectedCount} visible` : null} + `; + } + private renderSession(row: SessionRow, descendantCount: number, scope: SessionSelectionScope) { const { session } = row; const cappedDepth = Math.min(row.depth, 2); @@ -445,13 +453,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection this.selectedSessionIds = next; } - private toggleVisibleSelection(sessions: SessionInfo[], selected: boolean): void { - const next = new Set(this.selectedSessionIds); - for (const session of sessions) { - if (selected) next.add(session.id); - else next.delete(session.id); - } - this.selectedSessionIds = next; + private selectVisibleSessions(sessions: SessionInfo[]): void { + this.selectedSessionIds = new Set([...this.selectedSessionIds, ...sessions.map((session) => session.id)]); } private selectedSessions(scope: SessionSelectionScope): SessionInfo[] {