diff --git a/.changeset/session-lifecycle-actions.md b/.changeset/session-lifecycle-actions.md new file mode 100644 index 0000000..6063035 --- /dev/null +++ b/.changeset/session-lifecycle-actions.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add safe bulk session actions for archiving current sessions and permanently deleting archived sessions. diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index aab597e..e1588b0 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -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(); diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index 5104513..903f078 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -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)), diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 6da7872..bc55552 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -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"); diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 6d7118d..962c65a 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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(); }} diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index 1c62994..7426799 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -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; @property({ attribute: false }) onFocusNextSection?: () => void | Promise; @property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise; + @property({ attribute: false }) onArchive?: (session: SessionInfo) => void; + @property({ attribute: false }) onArchiveWithDescendants?: (session: SessionInfo) => void; + @property({ attribute: false }) onArchiveMany?: (sessions: SessionInfo[]) => void | Promise; + @property({ attribute: false }) onRestore?: (session: SessionInfo) => void; + @property({ attribute: false }) onDelete?: (session: SessionInfo) => void; + @property({ attribute: false }) onDeleteArchived?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise; + @property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void; + @state() private openMenuSessionId: string | undefined; @state() private menuStyle = ""; @state() private archivedExpanded = false; + @state() private selectionScopes: ReadonlySet = new Set(); + @state() private selectedSessionIds: ReadonlySet = 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): 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`
- ${this.renderHeading(activeRows.length + archivedRows.length)} + ${this.renderHeading(currentRows.length + archivedRows.length, currentRows.map((row) => row.session))} ${this.collapsed ? null : html`
- ${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` -

- ${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}
`} @@ -98,31 +113,96 @@ export class SessionList extends LitElement implements KeyboardNavigableSection `; } - private renderHeading(sessionCount: number) { - if (!this.collapsible) return html`

Sessions

`; + private renderHeading(sessionCount: number, currentSessions: SessionInfo[]) { + if (!this.collapsible) { + return html` +

+ Sessions + ${this.renderCurrentSelectionButton(currentSessions)} + +

+ `; + } const selectedSummary = this.selected === undefined ? "No session selected" : sessionLabel(this.selected); const selectedTitle = this.selected?.path ?? selectedSummary; return html`

- + + ${this.renderCurrentSelectionButton(currentSessions)} + ${sessionCount}

`; } - 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``; + } + + private renderArchivedHeading(archivedSessions: SessionInfo[]) { + const active = this.selectionScopes.has("archived"); + return html` +

+ + ${this.archivedExpanded ? html`` : null} + ${archivedSessions.length} +

+ `; + } + + 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` +
+ + ${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null} + + + +
+ `; + } + + 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` +
+ + ${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null} + + + +
+ `; + } + + 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`
{ 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); }} > -
+
+ ${showsCheckbox ? html` { event.stopPropagation(); }} @change=${() => { this.toggleSelected(session.id); }}>` : null} ${row.depth > 0 ? html`` : null}${sessionLabel(session)}${row.depth > 2 ? html` depth ${row.depth}` : null}${row.hasMissingParent ? html` parent unavailable` : null}${this.renderSessionMetaPrefix(session)}${String(session.messageCount)} messages ${this.renderActivity(session)}
@@ -130,12 +210,15 @@ export class SessionList extends LitElement implements KeyboardNavigableSection ${this.openMenuSessionId === session.id ? html`
- ${session.parentSessionPath !== undefined ? html`` : null} ${isCachedNewSessionInfo(session) ? html`` : session.archived === true - ? html`` + ? html` + + + ` : html` + ${session.parentSessionPath !== undefined ? html`` : null} ${descendantCount > 0 ? html`` : 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, removedIds: readonly string[]): ReadonlySet { + const removed = new Set(removedIds); + return new Set([...sessionIds].filter((sessionId) => !removed.has(sessionId))); } function unarchivedDescendantCounts(sessions: SessionInfo[]): Map { @@ -220,23 +405,8 @@ function unarchivedDescendantCounts(sessions: SessionInfo[]): Map [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(); - for (const session of sessions) { - if (session.archived === true) continue; - visible.add(session.id); - let parentPath = session.parentSessionPath; - const seen = new Set([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[] { diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index 57f6728..e07d007 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -52,8 +52,11 @@ export class AppNavigationPanel extends LitElement { @property({ attribute: false }) onSelectSession?: (session: SessionInfo) => void | Promise; @property({ attribute: false }) onArchiveSession?: (session: SessionInfo) => void | Promise; @property({ attribute: false }) onArchiveSessionWithDescendants?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onArchiveSessions?: (sessions: SessionInfo[]) => void | Promise; @property({ attribute: false }) onRestoreSession?: (session: SessionInfo) => void | Promise; @property({ attribute: false }) onDeleteCachedNewSession?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onDeleteArchivedSession?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onDeleteArchivedSessions?: (sessions: SessionInfo[]) => void | Promise; @property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise; @property({ attribute: false }) onArchivedCollapsed?: () => void | Promise; @property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise; @@ -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"); }} diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index eead33c..4bbc5b0 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -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] }; diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 3bc4bba..ff13d92 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -261,6 +261,52 @@ export class SessionController { } } + async archiveSessions(sessions: readonly SessionInfo[]): Promise { + 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 { + 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[]): 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, sessio return Object.fromEntries(Object.entries(activities).filter(([id]) => id !== sessionId)); } +function uniqueSessionsById(sessions: readonly SessionInfo[]): SessionInfo[] { + const seen = new Set(); + 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(results: readonly PromiseSettledResult[]): T[] { + return results.filter(isFulfilled).map((result) => result.value); +} + +function rejectedReasons(results: readonly PromiseSettledResult[]): string[] { + return results.filter(isRejected).map((result) => errorMessage(result.reason)); +} + +function isFulfilled(result: PromiseSettledResult): result is PromiseFulfilledResult { + return result.status === "fulfilled"; +} + +function isRejected(result: PromiseSettledResult): 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, "sessions" | "selectedSession"> { if (messageCount === undefined) return {}; diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index f04535e..d7133e5 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -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(), { diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 26b6df8..0f23c6a 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -52,7 +52,7 @@ function parsePromptStreamingBehavior(value: unknown): QueuedPromptKind | undefi throw new Error('Prompt streamingBehavior must be "steer" or "followUp"'); } -type SessionArchiveRepository = Pick; +type SessionArchiveRepository = Pick & { deleteArchived?: (sessionId: string) => Promise }; interface PiSessionListEntry { id: string; path: string; @@ -489,6 +489,16 @@ export class PiSessionService { await this.archiveStore.restore(sessionId); } + async deleteArchived(sessionId: string): Promise { + 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 { const session = await this.getOrOpen(sessionId); const sessionFile = session.sessionFile; @@ -530,6 +540,12 @@ export class PiSessionService { } } + private async ensureArchivedRecordMoved(record: ArchivedSessionRecord): Promise { + 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 { const cwd = session.sessionManager.getCwd(); const sessionFile = session.sessionFile; diff --git a/src/server/sessions/sessionArchiveStore.test.ts b/src/server/sessions/sessionArchiveStore.test.ts index f228b8c..ac441fa 100644 --- a/src/server/sessions/sessionArchiveStore.test.ts +++ b/src/server/sessions/sessionArchiveStore.test.ts @@ -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 { diff --git a/src/server/sessions/sessionArchiveStore.ts b/src/server/sessions/sessionArchiveStore.ts index 7c55a50..3655d92 100644 --- a/src/server/sessions/sessionArchiveStore.ts +++ b/src/server/sessions/sessionArchiveStore.ts @@ -88,6 +88,18 @@ export class SessionArchiveStore { }); } + async deleteArchived(sessionId: string): Promise { + 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 { return (await this.get(sessionId)) !== undefined; } diff --git a/src/server/sessions/sessionRoutes.ts b/src/server/sessions/sessionRoutes.ts index 5fa9aab..91b637a 100644 --- a/src/server/sessions/sessionRoutes.ts +++ b/src/server/sessions/sessionRoutes.ts @@ -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); diff --git a/src/shared/federatedRoutes.ts b/src/shared/federatedRoutes.ts index 1796f8e..aae3d84 100644 --- a/src/shared/federatedRoutes.ts +++ b/src/shared/federatedRoutes.ts @@ -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" },