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.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-30 00:21:53 +02:00
parent 5f4d81352f
commit 7103bfcb4c
3 changed files with 186 additions and 21 deletions
@@ -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.
@@ -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<SessionList> {
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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("h2.subheading .section-toggle") ?? null;
}
function toolbarButton(list: SessionList, text: string): HTMLButtonElement | null {
const buttons = list.shadowRoot?.querySelectorAll<HTMLButtonElement>(".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<HTMLElement>(".bulk-row.selecting small") ?? null;
}
function checkboxes(list: SessionList): HTMLInputElement[] {
return [...(list.shadowRoot?.querySelectorAll<HTMLInputElement>("input.session-checkbox") ?? [])];
}
function checkedBoxes(list: SessionList): HTMLInputElement[] {
return checkboxes(list).filter((checkbox) => checkbox.checked);
}
function session(id: string, overrides: Partial<SessionInfo> = {}): 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,
};
}
+24 -21
View File
@@ -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`
<div class="bulk-row selecting">
<button ?disabled=${visibleSessions.length === 0} @click=${() => { this.toggleVisibleSelection(visibleSessions, !allVisibleSelected); }}>${allVisibleSelected ? "Clear visible" : "Select visible"}</button>
<small>${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null}</small>
<button ?disabled=${archivableSessions.length === 0} @click=${() => { this.archiveSelectedCurrent(); }}>Archive selected</button>
${this.renderSelectionControls("current", visibleSessions)}
<button ?disabled=${archivableSessions.length === 0} @click=${() => { this.archiveSelectedCurrent(); }}>Archive</button>
<button ?disabled=${unreadSelectedSessions.length === 0} @click=${() => { this.markSelectedCurrentRead(); }}>Mark read</button>
<button @click=${() => { this.clearSelection("current"); }}>Clear</button>
<button @click=${() => { this.closeSelection("current"); }}>Done</button>
</div>
`;
}
@@ -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`
<div class="bulk-row selecting">
<button ?disabled=${visibleSessions.length === 0} @click=${() => { this.toggleVisibleSelection(visibleSessions, !allVisibleSelected); }}>${allVisibleSelected ? "Clear visible" : "Select visible"}</button>
<small>${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null}</small>
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete selected archived sessions" : this.archivedDeleteUnavailableMessage} ?disabled=${selectedSessions.length === 0 || !this.canDeleteArchived} @click=${() => { this.confirmDeleteSelectedArchived(); }}>Delete selected</button>
<button @click=${() => { this.clearSelection("archived"); }}>Clear</button>
<button @click=${() => { this.closeSelection("archived"); }}>Done</button>
${this.renderSelectionControls("archived", visibleSessions)}
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete selected archived sessions" : this.archivedDeleteUnavailableMessage} ?disabled=${selectedSessions.length === 0 || !this.canDeleteArchived} @click=${() => { this.confirmDeleteSelectedArchived(); }}>Delete</button>
${this.canDeleteArchived ? null : html`<small class="capability-hint">${this.archivedDeleteUnavailableMessage}</small>`}
</div>
`;
}
/**
* 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`<button @click=${() => { this.selectVisibleSessions(visibleSessions); }}>Select visible</button>`
: html`<button @click=${() => { this.clearSelection(scope); }}>Clear selected</button>`}
<small>${selectedCount} selected${visibleSelectedCount !== selectedCount ? html` · ${visibleSelectedCount} visible` : null}</small>
`;
}
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[] {