feat: add safe session bulk cleanup actions

This commit is contained in:
Federico Jaramillo Martinez
2026-06-08 17:20:00 +02:00
parent b2a79750a3
commit a3b5b722c9
15 changed files with 474 additions and 46 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add safe bulk session actions for archiving current sessions and permanently deleting archived sessions.
+2
View File
@@ -8,6 +8,7 @@ import {
parseAuthProvidersResponse,
parseClosed,
parseCommandResult,
parseDeleted,
parseDetached,
parseFileContentResponse,
parseFileSuggestion,
@@ -98,6 +99,7 @@ export const sessionsApi = {
archive: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }),
archiveWithDescendants: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/archive-tree`, parseArchived, { method: "POST" }),
restore: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
deleteArchived: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}`, parseDeleted, { method: "DELETE" }),
detachParent: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/detach-parent`, parseDetached, { method: "POST" }),
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => {
const params = new URLSearchParams();
@@ -58,6 +58,7 @@ describe("federated route contract", () => {
ignoreParseFailure(sessionsApi.archive("s 1", machineId)),
ignoreParseFailure(sessionsApi.archiveWithDescendants("s 1", machineId)),
ignoreParseFailure(sessionsApi.restore("s 1", machineId)),
ignoreParseFailure(sessionsApi.deleteArchived("s 1", machineId)),
ignoreParseFailure(sessionsApi.detachParent("s 1", machineId)),
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
+6
View File
@@ -638,6 +638,12 @@ export function parseRestored(value: unknown): { restored: true } {
return { restored: true };
}
export function parseDeleted(value: unknown): { deleted: true } {
const record = requireRecord(value);
if (record["deleted"] !== true) throw new Error("Expected deleted response");
return { deleted: true };
}
export function parseDetached(value: unknown): { detached: true } {
const record = requireRecord(value);
if (record["detached"] !== true) throw new Error("Expected detached response");
+3
View File
@@ -890,8 +890,11 @@ export class PiWebApp extends LitElement {
.onSelectSession=${(session: SessionInfo) => this.selectNavigationItem("sessions", "chat", () => this.sessions.selectSession(session))}
.onArchiveSession=${(session: SessionInfo) => this.sessions.archiveSession(session)}
.onArchiveSessionWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)}
.onArchiveSessions=${(sessions: SessionInfo[]) => this.sessions.archiveSessions(sessions)}
.onRestoreSession=${(session: SessionInfo) => this.selectNavigationItem("sessions", "chat", () => this.sessions.restoreSession(session))}
.onDeleteCachedNewSession=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)}
.onDeleteArchivedSession=${(session: SessionInfo) => this.sessions.deleteArchivedSessions([session])}
.onDeleteArchivedSessions=${(sessions: SessionInfo[]) => this.sessions.deleteArchivedSessions(sessions)}
.onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)}
.onFocusNavigationTarget=${(target: NavigationFocusTarget) => { void this.focusNavigationTarget(target); }}
.onCancelKeyboardNavigation=${() => { void this.focusChatComposer(); }}
+215 -45
View File
@@ -1,4 +1,4 @@
import { LitElement, html, type PropertyValues } from "lit";
import { LitElement, css, html, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
import { isCachedNewSessionInfo } from "../cachedNewSessions";
@@ -20,6 +20,8 @@ interface SessionRow {
hasMissingParent: boolean;
}
type SessionSelectionScope = "current" | "archived";
@customElement("session-list")
export class SessionList extends LitElement implements KeyboardNavigableSection {
@property({ attribute: false }) sessions: SessionInfo[] = [];
@@ -36,18 +38,25 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
@property({ attribute: false }) onFocusPreviousSection?: () => void | Promise<void>;
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
@property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise<void>;
@property({ attribute: false }) onArchive?: (session: SessionInfo) => void;
@property({ attribute: false }) onArchiveWithDescendants?: (session: SessionInfo) => void;
@property({ attribute: false }) onArchiveMany?: (sessions: SessionInfo[]) => void | Promise<void>;
@property({ attribute: false }) onRestore?: (session: SessionInfo) => void;
@property({ attribute: false }) onDelete?: (session: SessionInfo) => void;
@property({ attribute: false }) onDeleteArchived?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise<void>;
@property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void;
@state() private openMenuSessionId: string | undefined;
@state() private menuStyle = "";
@state() private archivedExpanded = false;
@state() private selectionScopes: ReadonlySet<SessionSelectionScope> = new Set();
@state() private selectedSessionIds: ReadonlySet<string> = new Set();
private readonly onDocumentClick = (event: MouseEvent) => {
if (event.composedPath().includes(this)) return;
this.openMenuSessionId = undefined;
};
@property({ attribute: false }) onArchive?: (session: SessionInfo) => void;
@property({ attribute: false }) onArchiveWithDescendants?: (session: SessionInfo) => void;
@property({ attribute: false }) onRestore?: (session: SessionInfo) => void;
@property({ attribute: false }) onDelete?: (session: SessionInfo) => void;
@property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void;
override connectedCallback(): void {
super.connectedCallback();
@@ -60,9 +69,12 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
}
protected override updated(changed: PropertyValues<this>): void {
if (changed.has("sessions") && this.openMenuSessionId !== undefined && !this.sessions.some((session) => session.id === this.openMenuSessionId)) this.openMenuSessionId = undefined;
if (changed.has("sessions")) {
if (this.openMenuSessionId !== undefined && !this.sessions.some((session) => session.id === this.openMenuSessionId)) this.openMenuSessionId = undefined;
if (!this.sessions.some((session) => session.archived === true)) this.archivedExpanded = false;
this.pruneSelectedSessionIds();
}
if (changed.has("collapsed") && this.collapsed) this.openMenuSessionId = undefined;
if (changed.has("sessions") && !this.sessions.some((session) => session.archived === true)) this.archivedExpanded = false;
const previousSelected = changed.get("selected");
if (changed.has("selected") && this.selected?.archived === true && (previousSelected?.id !== this.selected.id || previousSelected.archived !== true) && !this.archivedExpanded) {
this.archivedExpanded = true;
@@ -78,19 +90,22 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
}
override render() {
const activeRows = sessionRowsForActiveTree(this.sessions);
const activeIds = new Set(activeRows.map((row) => row.session.id));
const archivedRows = sessionRows(this.sessions.filter((session) => session.archived === true && !activeIds.has(session.id)));
const currentRows = sessionRowsForCurrentSessions(this.sessions);
const archivedRows = sessionRows(this.sessions.filter((session) => session.archived === true));
const descendantCounts = unarchivedDescendantCounts(this.sessions);
return html`
<section>
${this.renderHeading(activeRows.length + archivedRows.length)}
${this.renderHeading(currentRows.length + archivedRows.length, currentRows.map((row) => row.session))}
${this.collapsed ? null : html`
<div class="list-body">
${activeRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0))}
${this.renderCurrentSelectionToolbar(currentRows.map((row) => row.session))}
${currentRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0, "current"))}
${archivedRows.length > 0 ? html`
<h2 class="subheading"><button class="section-toggle" aria-expanded=${String(this.archivedExpanded)} @click=${() => { this.toggleArchived(); }}><span>${this.archivedExpanded ? "▾" : "▸"} Archived</span><small>${archivedRows.length}</small></button></h2>
${this.archivedExpanded ? archivedRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0)) : null}
${this.renderArchivedHeading(archivedRows.map((row) => row.session))}
${this.archivedExpanded ? html`
${this.renderArchivedSelectionToolbar(archivedRows.map((row) => row.session))}
${archivedRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0, "archived"))}
` : null}
` : null}
</div>
`}
@@ -98,31 +113,96 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
`;
}
private renderHeading(sessionCount: number) {
if (!this.collapsible) return html`<h2>Sessions <button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button></h2>`;
private renderHeading(sessionCount: number, currentSessions: SessionInfo[]) {
if (!this.collapsible) {
return html`
<h2>
Sessions
${this.renderCurrentSelectionButton(currentSessions)}
<button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button>
</h2>
`;
}
const selectedSummary = this.selected === undefined ? "No session selected" : sessionLabel(this.selected);
const selectedTitle = this.selected?.path ?? selectedSummary;
return html`
<h2>
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span><small class="section-count">${sessionCount}</small></button>
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span></button>
${this.renderCurrentSelectionButton(currentSessions)}
<small class="section-count">${sessionCount}</small>
<button ?disabled=${!this.canStart} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onStart?.(); }}>+</button>
</h2>
`;
}
private renderSession(row: SessionRow, descendantCount: number) {
private renderCurrentSelectionButton(currentSessions: SessionInfo[]) {
if (this.collapsed || currentSessions.length === 0) return null;
const active = this.selectionScopes.has("current");
return html`<button class="bulk-select-entry ${active ? "selected" : ""}" title=${active ? "Close current session selection" : "Select current sessions"} aria-label=${active ? "Close current session selection" : "Select current sessions"} aria-expanded=${String(active)} aria-pressed=${String(active)} @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleSelection("current", currentSessions); }}>☑</button>`;
}
private renderArchivedHeading(archivedSessions: SessionInfo[]) {
const active = this.selectionScopes.has("archived");
return html`
<h2 class="subheading">
<button class="section-toggle" aria-expanded=${String(this.archivedExpanded)} @click=${() => { this.toggleArchived(); }}><span>${this.archivedExpanded ? "▾" : "▸"} Archived</span></button>
${this.archivedExpanded ? html`<button class="bulk-select-entry ${active ? "selected" : ""}" title=${active ? "Close archived session selection" : "Select archived sessions"} aria-label=${active ? "Close archived session selection" : "Select archived sessions"} aria-expanded=${String(active)} aria-pressed=${String(active)} @click=${() => { this.toggleSelection("archived", archivedSessions); }}>☑</button>` : null}
<small class="section-count">${archivedSessions.length}</small>
</h2>
`;
}
private renderCurrentSelectionToolbar(visibleSessions: SessionInfo[]) {
if (visibleSessions.length === 0 || !this.selectionScopes.has("current")) return null;
const selectedSessions = this.selectedSessions("current");
const archivableSessions = selectedSessions.filter((session) => !isCachedNewSessionInfo(session));
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>
<button @click=${() => { this.clearSelection("current"); }}>Clear</button>
<button @click=${() => { this.closeSelection("current"); }}>Done</button>
</div>
`;
}
private renderArchivedSelectionToolbar(visibleSessions: SessionInfo[]) {
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" ?disabled=${selectedSessions.length === 0} @click=${() => { this.confirmDeleteSelectedArchived(); }}>Delete selected</button>
<button @click=${() => { this.clearSelection("archived"); }}>Clear</button>
<button @click=${() => { this.closeSelection("archived"); }}>Done</button>
</div>
`;
}
private renderSession(row: SessionRow, descendantCount: number, scope: SessionSelectionScope) {
const { session } = row;
const cappedDepth = Math.min(row.depth, 2);
const showsCheckbox = this.selectionScopes.has(scope);
const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id);
return html`
<div
class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${session.archived === true ? "archived" : ""}"
class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${bulkSelected ? "bulk-selected" : ""} ${session.archived === true ? "archived" : ""} ${showsCheckbox ? "selecting" : ""}"
style=${`--depth:${String(cappedDepth)}`}
tabindex="0"
title=${session.path}
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(session)); }}
@keydown=${(event: KeyboardEvent) => { this.handleSessionKeydown(event, session); }}
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => { this.activateSessionRow(session, scope); }); }}
@keydown=${(event: KeyboardEvent) => { this.handleSessionKeydown(event, session, scope); }}
>
<div class="action-main">
<div class="action-main ${showsCheckbox ? "selecting" : ""}">
${showsCheckbox ? html`<input class="session-checkbox" type="checkbox" aria-label=${`Select ${sessionLabel(session)}`} .checked=${bulkSelected} @click=${(event: MouseEvent) => { event.stopPropagation(); }} @change=${() => { this.toggleSelected(session.id); }}>` : null}
<span class="action-name">${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span><small>${this.renderSessionMetaPrefix(session)}${String(session.messageCount)} messages</small>
${this.renderActivity(session)}
</div>
@@ -130,12 +210,15 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
<button class="action-menu-toggle" title="Session actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(session.id, event.currentTarget); }}>⋯</button>
${this.openMenuSessionId === session.id ? html`
<div class="action-menu-panel" style=${this.menuStyle}>
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
${isCachedNewSessionInfo(session)
? html`<button title="Delete browser-cached new session" @click=${() => { this.openMenuSessionId = undefined; this.onDelete?.(session); }}>Delete</button>`
: session.archived === true
? html`<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>`
? html`
<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>
<button class="danger" title="Permanently delete archived session" @click=${() => { this.openMenuSessionId = undefined; this.confirmDeleteArchived(session); }}>Delete archived session</button>
`
: html`
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>
${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : null}
`}
@@ -146,20 +229,99 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
`;
}
private handleSessionKeydown(event: KeyboardEvent, session: SessionInfo): void {
private handleSessionKeydown(event: KeyboardEvent, session: SessionInfo, scope: SessionSelectionScope): void {
handleSelectableRowKeyboard(event, {
activate: () => this.onSelect?.(session),
activate: () => { this.activateSessionRow(session, scope); },
previousSection: this.onFocusPreviousSection === undefined ? undefined : () => { void this.onFocusPreviousSection?.(); },
nextSection: this.onFocusNextSection === undefined ? undefined : () => { void this.onFocusNextSection?.(); },
cancel: this.onCancelKeyboardNavigation === undefined ? undefined : () => { void this.onCancelKeyboardNavigation?.(); },
});
}
private activateSessionRow(session: SessionInfo, scope: SessionSelectionScope): void {
if (this.selectionScopes.has(scope)) {
this.toggleSelected(session.id);
return;
}
this.onSelect?.(session);
}
private confirmArchiveWithDescendants(session: SessionInfo, descendantCount: number): void {
const noun = descendantCount === 1 ? "descendant session" : "descendant sessions";
if (confirm(`Archive “${sessionLabel(session)}” and ${String(descendantCount)} ${noun}?`)) this.onArchiveWithDescendants?.(session);
}
private confirmDeleteArchived(session: SessionInfo): void {
if (confirm(`Permanently delete archived session “${sessionLabel(session)}”? This cannot be undone.`)) void this.onDeleteArchived?.(session);
}
private confirmDeleteSelectedArchived(): void {
const archived = this.selectedSessions("archived");
if (archived.length === 0) return;
const noun = archived.length === 1 ? "archived session" : "archived sessions";
if (!confirm(`Permanently delete ${String(archived.length)} selected ${noun}? This cannot be undone.`)) return;
this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, archived.map((session) => session.id));
void this.onDeleteArchivedMany?.(archived);
}
private archiveSelectedCurrent(): void {
const sessions = this.selectedSessions("current").filter((session) => !isCachedNewSessionInfo(session));
this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, sessions.map((session) => session.id));
void this.onArchiveMany?.(sessions);
}
private toggleSelection(scope: SessionSelectionScope, visibleSessions: SessionInfo[]): void {
if (this.selectionScopes.has(scope)) {
this.closeSelection(scope);
return;
}
this.startSelection(scope, visibleSessions);
}
private startSelection(scope: SessionSelectionScope, visibleSessions: SessionInfo[]): void {
this.selectionScopes = new Set([...this.selectionScopes, scope]);
const onlyVisibleSession = visibleSessions.length === 1 ? visibleSessions[0] : undefined;
if (onlyVisibleSession !== undefined) this.selectedSessionIds = new Set([...this.selectedSessionIds, onlyVisibleSession.id]);
}
private closeSelection(scope: SessionSelectionScope): void {
this.selectionScopes = new Set([...this.selectionScopes].filter((candidate) => candidate !== scope));
this.clearSelection(scope);
}
private clearSelection(scope: SessionSelectionScope): void {
const sessionIds = this.sessions.filter((session) => sessionSelectionScope(session) === scope).map((session) => session.id);
this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, sessionIds);
}
private toggleSelected(sessionId: string): void {
const next = new Set(this.selectedSessionIds);
if (next.has(sessionId)) next.delete(sessionId);
else next.add(sessionId);
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 selectedSessions(scope: SessionSelectionScope): SessionInfo[] {
return this.sessions.filter((session) => this.selectedSessionIds.has(session.id) && sessionSelectionScope(session) === scope);
}
private pruneSelectedSessionIds(): void {
const existing = new Set(this.sessions.map((session) => session.id));
const next = new Set([...this.selectedSessionIds].filter((sessionId) => existing.has(sessionId)));
if (next.size !== this.selectedSessionIds.size) this.selectedSessionIds = next;
if (this.selectionScopes.has("archived") && !this.sessions.some((session) => session.archived === true)) this.closeSelection("archived");
if (this.selectionScopes.has("current") && !this.sessions.some((session) => session.archived !== true)) this.closeSelection("current");
}
private toggleMenu(sessionId: string, target: EventTarget | null) {
if (this.openMenuSessionId === sessionId) {
this.openMenuSessionId = undefined;
@@ -173,6 +335,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
this.archivedExpanded = !this.archivedExpanded;
if (!this.archivedExpanded) {
this.openMenuSessionId = undefined;
if (this.selectionScopes.has("archived")) this.closeSelection("archived");
this.onArchivedCollapsed?.();
}
}
@@ -192,7 +355,29 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
return renderActionActivityIndicator(isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "session" : undefined, "Session active");
}
static override styles = listStyles;
static override styles = [listStyles, css`
h2 { min-height: 30px; }
h2 > .section-count { flex: 0 0 auto; display: inline; color: var(--pi-muted); font-size: inherit; }
.bulk-select-entry { box-sizing: border-box; flex: 0 0 auto; display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; font-size: 13px; line-height: 1; text-transform: none; }
.bulk-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 6px; }
.bulk-row button { padding: 5px 7px; font-size: 12px; }
.bulk-row small { display: inline; min-width: 0; color: var(--pi-muted); }
.bulk-row.selecting { padding: 6px; border: 1px solid var(--pi-border-muted); border-radius: 8px; background: color-mix(in srgb, var(--pi-surface) 65%, transparent); }
button.danger, .action-menu-panel button.danger { color: var(--pi-danger); }
button.danger:hover, .action-menu-panel button.danger:hover { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); }
.action-row.bulk-selected .action-main { border-color: var(--pi-accent); box-shadow: inset 3px 0 0 var(--pi-accent); }
.action-main.selecting { padding-left: calc(32px + var(--depth, 0) * 16px); }
.session-checkbox { position: absolute; top: 9px; left: calc(8px + var(--depth, 0) * 16px); z-index: 2; margin: 0; }
`];
}
function sessionSelectionScope(session: SessionInfo): SessionSelectionScope {
return session.archived === true ? "archived" : "current";
}
function removeSessionIds(sessionIds: ReadonlySet<string>, removedIds: readonly string[]): ReadonlySet<string> {
const removed = new Set(removedIds);
return new Set([...sessionIds].filter((sessionId) => !removed.has(sessionId)));
}
function unarchivedDescendantCounts(sessions: SessionInfo[]): Map<string, number> {
@@ -220,23 +405,8 @@ function unarchivedDescendantCounts(sessions: SessionInfo[]): Map<string, number
return new Map(sessions.map((session) => [session.id, countFor(session, new Set())]));
}
function sessionRowsForActiveTree(sessions: SessionInfo[]): SessionRow[] {
const byPath = new Map(sessions.map((session) => [session.path, session]));
const visible = new Set<string>();
for (const session of sessions) {
if (session.archived === true) continue;
visible.add(session.id);
let parentPath = session.parentSessionPath;
const seen = new Set<string>([session.path]);
while (parentPath !== undefined && !seen.has(parentPath)) {
seen.add(parentPath);
const parent = byPath.get(parentPath);
if (parent === undefined) break;
visible.add(parent.id);
parentPath = parent.parentSessionPath;
}
}
return sessionRows(sessions.filter((session) => visible.has(session.id)));
function sessionRowsForCurrentSessions(sessions: SessionInfo[]): SessionRow[] {
return sessionRows(sessions.filter((session) => session.archived !== true));
}
function sessionRows(sessions: SessionInfo[]): SessionRow[] {
@@ -52,8 +52,11 @@ export class AppNavigationPanel extends LitElement {
@property({ attribute: false }) onSelectSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onArchiveSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onArchiveSessionWithDescendants?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onArchiveSessions?: (sessions: SessionInfo[]) => void | Promise<void>;
@property({ attribute: false }) onRestoreSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onDeleteCachedNewSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onDeleteArchivedSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onDeleteArchivedSessions?: (sessions: SessionInfo[]) => void | Promise<void>;
@property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>;
@property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise<void>;
@@ -156,8 +159,11 @@ export class AppNavigationPanel extends LitElement {
.onSelect=${(session: SessionInfo) => this.onSelectSession?.(session)}
.onArchive=${(session: SessionInfo) => this.onArchiveSession?.(session)}
.onArchiveWithDescendants=${(session: SessionInfo) => this.onArchiveSessionWithDescendants?.(session)}
.onArchiveMany=${(sessions: SessionInfo[]) => this.onArchiveSessions?.(sessions)}
.onRestore=${(session: SessionInfo) => this.onRestoreSession?.(session)}
.onDelete=${(session: SessionInfo) => this.onDeleteCachedNewSession?.(session)}
.onDeleteArchived=${(session: SessionInfo) => this.onDeleteArchivedSession?.(session)}
.onDeleteArchivedMany=${(sessions: SessionInfo[]) => this.onDeleteArchivedSessions?.(sessions)}
.onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)}
.onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }}
.onFocusNextSection=${() => { this.focusNextFrom("sessions"); }}
@@ -290,6 +290,66 @@ describe("SessionController", () => {
expect(state.selectedSession?.id).toBe(nextSession.id);
});
it("archives selected sessions in bulk", async () => {
const secondSession = { ...oldSession, id: "second-session", path: "/tmp/second-session.jsonl" };
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
const archivedIds: string[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession, secondSession, nextSession] };
const api: typeof defaultApi = {
...defaultApi,
archive: (sessionId) => {
archivedIds.push(sessionId);
return Promise.resolve({ archived: true });
},
messages: () => Promise.resolve(emptyPage),
status: (sessionId) => Promise.resolve(status(sessionId)),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.selectSession(oldSession, { updateUrl: false });
await controller.archiveSessions([oldSession, secondSession]);
expect(archivedIds).toEqual([oldSession.id, secondSession.id]);
expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true });
expect(state.sessions.find((session) => session.id === secondSession.id)).toMatchObject({ archived: true });
expect(state.selectedSession?.id).toBe(nextSession.id);
});
it("deletes selected archived sessions in bulk and selects the next current session", async () => {
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
const deletedIds: string[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: archivedSession, sessions: [archivedSession, nextSession] };
const api: typeof defaultApi = {
...defaultApi,
deleteArchived: (sessionId) => {
deletedIds.push(sessionId);
return Promise.resolve({ deleted: true });
},
messages: () => Promise.resolve(emptyPage),
status: (sessionId) => Promise.resolve(status(sessionId)),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.deleteArchivedSessions([archivedSession]);
expect(deletedIds).toEqual([archivedSession.id]);
expect(state.sessions.map((session) => session.id)).toEqual([nextSession.id]);
expect(state.selectedSession?.id).toBe(nextSession.id);
});
it("forgets archived selections when the archived section collapse clears selection", async () => {
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] };
@@ -261,6 +261,52 @@ export class SessionController {
}
}
async archiveSessions(sessions: readonly SessionInfo[]): Promise<void> {
const candidates = uniqueSessionsById(sessions).filter((session) => session.archived !== true && !isCachedNewSessionInfo(session));
if (candidates.length === 0) return;
const machineId = selectedMachineId(this.getState());
const results = await Promise.allSettled(candidates.map(async (session) => {
await this.api.archive(session.id, machineId);
return session.id;
}));
const archivedIds = fulfilledValues(results);
if (archivedIds.length > 0) {
const state = this.getState();
const nextSessions = markSessionsArchived(state.sessions, archivedIds, new Date().toISOString());
const selectionChange = selectionAfterArchivingSessions(nextSessions, state.selectedSession?.id, archivedIds);
this.setState({ sessions: nextSessions });
if (selectionChange.type === "select") await this.selectSession(selectionChange.session);
else if (selectionChange.type === "clear") this.deselectSession({ forgetRememberedSelection: true });
}
this.applyBulkSessionError("Archive", results);
}
async deleteArchivedSessions(sessions: readonly SessionInfo[]): Promise<void> {
const candidates = uniqueSessionsById(sessions).filter((session) => session.archived === true);
if (candidates.length === 0) return;
const machineId = selectedMachineId(this.getState());
const results = await Promise.allSettled(candidates.map(async (session) => {
await this.api.deleteArchived(session.id, machineId);
return session.id;
}));
const deletedIds = fulfilledValues(results);
if (deletedIds.length > 0) {
const deletedIdSet = new Set(deletedIds);
const state = this.getState();
const nextSessions = state.sessions.filter((session) => !deletedIdSet.has(session.id));
this.setState({ sessions: nextSessions });
if (state.selectedSession !== undefined && deletedIdSet.has(state.selectedSession.id)) {
const next = nextSessions.find((session) => session.archived !== true) ?? nextSessions[0];
if (next !== undefined) await this.selectSession(next);
else this.deselectSession({ forgetRememberedSelection: true });
}
}
this.applyBulkSessionError("Delete", results);
}
async deleteCachedNewSession(session = this.getState().selectedSession) {
if (!isCachedNewSessionInfo(session)) return;
void this.api.stop(session.id, selectedMachineId(this.getState())).catch(() => {
@@ -397,6 +443,12 @@ export class SessionController {
}
}
private applyBulkSessionError(action: string, results: readonly PromiseSettledResult<string>[]): void {
const failures = rejectedReasons(results);
if (failures.length === 0) return;
this.setState({ error: `${action} failed for ${String(failures.length)} session${failures.length === 1 ? "" : "s"}: ${failures.join("; ")}` });
}
private sessionCacheKey(sessionId: string): string {
return machineSessionKey(selectedMachineId(this.getState()), sessionId);
}
@@ -561,6 +613,37 @@ function omitSessionActivity(activities: Record<string, SessionActivity>, sessio
return Object.fromEntries(Object.entries(activities).filter(([id]) => id !== sessionId));
}
function uniqueSessionsById(sessions: readonly SessionInfo[]): SessionInfo[] {
const seen = new Set<string>();
const unique: SessionInfo[] = [];
for (const session of sessions) {
if (seen.has(session.id)) continue;
seen.add(session.id);
unique.push(session);
}
return unique;
}
function fulfilledValues<T>(results: readonly PromiseSettledResult<T>[]): T[] {
return results.filter(isFulfilled).map((result) => result.value);
}
function rejectedReasons(results: readonly PromiseSettledResult<unknown>[]): string[] {
return results.filter(isRejected).map((result) => errorMessage(result.reason));
}
function isFulfilled<T>(result: PromiseSettledResult<T>): result is PromiseFulfilledResult<T> {
return result.status === "fulfilled";
}
function isRejected<T>(result: PromiseSettledResult<T>): result is PromiseRejectedResult {
return result.status === "rejected";
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function sessionMessageCountPatch(state: AppState, sessionId: string, messageCount: number | undefined): Pick<Partial<AppState>, "sessions" | "selectedSession"> {
if (messageCount === undefined) return {};
@@ -318,6 +318,33 @@ describe("PiSessionService", () => {
await service.dispose();
});
it("permanently deletes archived sessions through the archive store", async () => {
const deletedSessionIds: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
list: () => Promise.resolve([]),
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" }
: undefined),
archive: () => { throw new Error("archive should not be called for records that already have archive files"); },
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
deleteArchived: (sessionId) => {
deletedSessionIds.push(sessionId);
return Promise.resolve();
},
},
sessionManager: sessionGateway([sessionRecord("active")]),
heartbeatIntervalMs: 60_000,
});
await expect(service.deleteArchived("arch")).resolves.toBeUndefined();
await expect(service.deleteArchived("active")).rejects.toThrow("Archived session not found");
expect(deletedSessionIds).toEqual(["archived"]);
await service.dispose();
});
it("reconciles workspace activity when listing only archived sessions", async () => {
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
+17 -1
View File
@@ -52,7 +52,7 @@ function parsePromptStreamingBehavior(value: unknown): QueuedPromptKind | undefi
throw new Error('Prompt streamingBehavior must be "steer" or "followUp"');
}
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived">;
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived"> & { deleteArchived?: (sessionId: string) => Promise<void> };
interface PiSessionListEntry {
id: string;
path: string;
@@ -489,6 +489,16 @@ export class PiSessionService {
await this.archiveStore.restore(sessionId);
}
async deleteArchived(sessionId: string): Promise<void> {
const record = await this.archiveStore.get(sessionId);
if (record === undefined) throw new Error("Archived session not found");
if (this.archiveStore.deleteArchived === undefined) throw new Error("Archive store does not support deletion");
await this.closeActive(record.sessionId);
if (record.archivePath === undefined) await this.ensureArchivedRecordMoved(record);
await this.archiveStore.deleteArchived(record.sessionId);
}
async detachParent(sessionId: string): Promise<void> {
const session = await this.getOrOpen(sessionId);
const sessionFile = session.sessionFile;
@@ -530,6 +540,12 @@ export class PiSessionService {
}
}
private async ensureArchivedRecordMoved(record: ArchivedSessionRecord): Promise<ArchivedSessionRecord> {
const session = (await this.sessionManager.list(record.cwd)).find((candidate) => candidate.id === record.sessionId);
if (session === undefined) return record;
return this.archiveStore.archive(archiveInputFromListEntry(session));
}
private async archiveInputForSession(session: PiAgentSession): Promise<ArchiveSessionInput> {
const cwd = session.sessionManager.getCwd();
const sessionFile = session.sessionFile;
@@ -44,6 +44,33 @@ describe("SessionArchiveStore", () => {
expect(await exists(record.archivePath)).toBe(false);
await expect(store.list()).resolves.toEqual([]);
});
it("permanently deletes archived session files and records", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-web-archive-delete-"));
tempRoots.push(root);
const activeDir = join(root, "active");
await mkdir(activeDir, { recursive: true });
const sourcePath = join(activeDir, "2026-01-01_s1.jsonl");
await writeFile(sourcePath, "session contents\n", "utf8");
const store = new SessionArchiveStore(join(root, "archived-sessions.json"), join(root, "archived-files"));
const record = await store.archive({
sessionId: "s1",
cwd: "/workspace",
path: sourcePath,
created: "2026-01-01T00:00:00.000Z",
modified: "2026-01-01T00:01:00.000Z",
messageCount: 2,
firstMessage: "hello",
});
if (record.archivePath === undefined) throw new Error("Expected archive path");
await store.deleteArchived("s1");
expect(await exists(sourcePath)).toBe(false);
expect(await exists(record.archivePath)).toBe(false);
await expect(store.list()).resolves.toEqual([]);
});
});
async function exists(path: string): Promise<boolean> {
@@ -88,6 +88,18 @@ export class SessionArchiveStore {
});
}
async deleteArchived(sessionId: string): Promise<void> {
await this.exclusive(async () => {
const data = await this.read();
const record = data.sessions.find((session) => session.sessionId === sessionId);
if (record === undefined) return;
if (record.archivePath !== undefined && await pathExists(record.archivePath)) await unlink(record.archivePath);
const sessions = data.sessions.filter((session) => session.sessionId !== sessionId);
await this.write({ sessions });
});
}
async isArchived(sessionId: string): Promise<boolean> {
return (await this.get(sessionId)) !== undefined;
}
+9
View File
@@ -164,6 +164,15 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
}
});
app.delete<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId`, async (request, reply) => {
try {
await sessions.deleteArchived(request.params.sessionId);
return { deleted: true };
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/detach-parent`, async (request, reply) => {
try {
await sessions.detachParent(request.params.sessionId);
+1
View File
@@ -48,6 +48,7 @@ export const FEDERATED_HTTP_ROUTES = [
{ method: "POST", path: "/sessions/:sessionId/archive" },
{ method: "POST", path: "/sessions/:sessionId/archive-tree" },
{ method: "POST", path: "/sessions/:sessionId/restore" },
{ method: "DELETE", path: "/sessions/:sessionId" },
{ method: "POST", path: "/sessions/:sessionId/detach-parent" },
{ method: "GET", path: "/auth/providers" },
{ method: "POST", path: "/auth/api-key" },