Archived
feat: add safe session bulk cleanup actions
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add safe bulk session actions for archiving current sessions and permanently deleting archived sessions.
|
||||
@@ -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)),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(); }}
|
||||
|
||||
@@ -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(), {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user