Archived
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user