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 selectedSessions = this.selectedSessions("current");
|
||||||
const archivableSessions = selectedSessions.filter((session) => isArchivableSessionInfo(session, this.statuses[session.id], this.sessionPersistenceOptions()));
|
const archivableSessions = selectedSessions.filter((session) => isArchivableSessionInfo(session, this.statuses[session.id], this.sessionPersistenceOptions()));
|
||||||
const unreadSelectedSessions = selectedSessions.filter((session) => this.unreadSessionIds.has(session.id));
|
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`
|
return html`
|
||||||
<div class="bulk-row selecting">
|
<div class="bulk-row selecting">
|
||||||
<button ?disabled=${visibleSessions.length === 0} @click=${() => { this.toggleVisibleSelection(visibleSessions, !allVisibleSelected); }}>${allVisibleSelected ? "Clear visible" : "Select visible"}</button>
|
${this.renderSelectionControls("current", visibleSessions)}
|
||||||
<small>${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null}</small>
|
<button ?disabled=${archivableSessions.length === 0} @click=${() => { this.archiveSelectedCurrent(); }}>Archive</button>
|
||||||
<button ?disabled=${archivableSessions.length === 0} @click=${() => { this.archiveSelectedCurrent(); }}>Archive selected</button>
|
|
||||||
<button ?disabled=${unreadSelectedSessions.length === 0} @click=${() => { this.markSelectedCurrentRead(); }}>Mark read</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -248,20 +243,33 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
|||||||
if (visibleSessions.length === 0 || !this.selectionScopes.has("archived")) return null;
|
if (visibleSessions.length === 0 || !this.selectionScopes.has("archived")) return null;
|
||||||
|
|
||||||
const selectedSessions = this.selectedSessions("archived");
|
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`
|
return html`
|
||||||
<div class="bulk-row selecting">
|
<div class="bulk-row selecting">
|
||||||
<button ?disabled=${visibleSessions.length === 0} @click=${() => { this.toggleVisibleSelection(visibleSessions, !allVisibleSelected); }}>${allVisibleSelected ? "Clear visible" : "Select visible"}</button>
|
${this.renderSelectionControls("archived", visibleSessions)}
|
||||||
<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</button>
|
||||||
<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.canDeleteArchived ? null : html`<small class="capability-hint">${this.archivedDeleteUnavailableMessage}</small>`}
|
${this.canDeleteArchived ? null : html`<small class="capability-hint">${this.archivedDeleteUnavailableMessage}</small>`}
|
||||||
</div>
|
</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) {
|
private renderSession(row: SessionRow, descendantCount: number, scope: SessionSelectionScope) {
|
||||||
const { session } = row;
|
const { session } = row;
|
||||||
const cappedDepth = Math.min(row.depth, 2);
|
const cappedDepth = Math.min(row.depth, 2);
|
||||||
@@ -445,13 +453,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
|||||||
this.selectedSessionIds = next;
|
this.selectedSessionIds = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toggleVisibleSelection(sessions: SessionInfo[], selected: boolean): void {
|
private selectVisibleSessions(sessions: SessionInfo[]): void {
|
||||||
const next = new Set(this.selectedSessionIds);
|
this.selectedSessionIds = new Set([...this.selectedSessionIds, ...sessions.map((session) => session.id)]);
|
||||||
for (const session of sessions) {
|
|
||||||
if (selected) next.add(session.id);
|
|
||||||
else next.delete(session.id);
|
|
||||||
}
|
|
||||||
this.selectedSessionIds = next;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private selectedSessions(scope: SessionSelectionScope): SessionInfo[] {
|
private selectedSessions(scope: SessionSelectionScope): SessionInfo[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user