feat: archive session descendants

This commit is contained in:
Federico Jaramillo Martinez
2026-05-23 00:05:03 +02:00
parent a1e903f8f9
commit 428f7bb8c2
17 changed files with 416 additions and 25 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
export { activityApi, api, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+1
View File
@@ -71,6 +71,7 @@ export const sessionsApi = {
abort: (sessionId: string) => request(`/api/sessions/${sessionId}/abort`, parseAborted, { method: "POST" }),
stop: (sessionId: string) => request(`/api/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
archive: (sessionId: string) => request(`/api/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }),
archiveWithDescendants: (sessionId: string) => request(`/api/sessions/${sessionId}/archive-tree`, parseArchived, { method: "POST" }),
restore: (sessionId: string) => request(`/api/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
detachParent: (sessionId: string) => request(`/api/sessions/${sessionId}/detach-parent`, parseDetached, { method: "POST" }),
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key" }) => {
+16 -3
View File
@@ -1,4 +1,4 @@
import type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
@@ -46,6 +46,11 @@ function parseUnknownArray(value: unknown): unknown[] {
return value;
}
function arrayOfString(value: unknown, key: string): string[] {
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) throw new Error(`Expected string array field: ${key}`);
return value;
}
export function parseMessagePage(value: unknown): MessagePage {
if (Array.isArray(value)) return { messages: value, start: 0, total: value.length };
const record = requireRecord(value);
@@ -446,10 +451,18 @@ export function parseStopped(value: unknown): { stopped: true } {
return { stopped: true };
}
export function parseArchived(value: unknown): { archived: true } {
export function parseArchived(value: unknown): ArchiveSessionsResponse {
const record = requireRecord(value);
if (record["archived"] !== true) throw new Error("Expected archived response");
return { archived: true };
const sessionIds = record["sessionIds"] === undefined ? undefined : arrayOfString(record["sessionIds"], "sessionIds");
const archivedCount = optionalNumber(record, "archivedCount");
const skippedAlreadyArchivedCount = optionalNumber(record, "skippedAlreadyArchivedCount");
return {
archived: true,
...(sessionIds === undefined ? {} : { sessionIds }),
...(archivedCount === undefined ? {} : { archivedCount }),
...(skippedAlreadyArchivedCount === undefined ? {} : { skippedAlreadyArchivedCount }),
};
}
export function parseRestored(value: unknown): { restored: true } {
+1
View File
@@ -474,6 +474,7 @@ export class PiWebApp extends LitElement {
.onStart=${() => openChatAfter(() => this.sessions.startSession())}
.onSelect=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))}
.onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)}
.onArchiveWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)}
.onRestore=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))}
.onDelete=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)}
.onDetachParent=${(session: SessionInfo) => this.sessions.detachParent(session)}
+39 -4
View File
@@ -40,6 +40,7 @@ export class SessionList extends LitElement {
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;
@@ -71,13 +72,14 @@ export class SessionList extends LitElement {
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 descendantCounts = unarchivedDescendantCounts(this.sessions);
return html`
<section>
${this.renderHeading(activeRows.length + archivedRows.length)}
${this.collapsed ? null : activeRows.map((row) => this.renderSession(row))}
${this.collapsed ? null : activeRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0))}
${this.collapsed ? null : 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)) : null}
${this.archivedExpanded ? archivedRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0)) : null}
` : null}
</section>
`;
@@ -95,7 +97,7 @@ export class SessionList extends LitElement {
`;
}
private renderSession(row: SessionRow) {
private renderSession(row: SessionRow, descendantCount: number) {
const { session } = row;
const cappedDepth = Math.min(row.depth, 2);
return html`
@@ -119,7 +121,10 @@ export class SessionList extends LitElement {
? 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="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>`}
: html`
<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}
`}
</div>
` : null}
</div>
@@ -127,6 +132,11 @@ export class SessionList extends LitElement {
`;
}
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 toggleMenu(sessionId: string, target: EventTarget | null) {
if (this.openMenuSessionId === sessionId) {
this.openMenuSessionId = undefined;
@@ -157,6 +167,31 @@ export class SessionList extends LitElement {
static override styles = listStyles;
}
function unarchivedDescendantCounts(sessions: SessionInfo[]): Map<string, number> {
const childrenByParentPath = new Map<string, SessionInfo[]>();
for (const session of sessions) {
if (session.parentSessionPath === undefined) continue;
const children = childrenByParentPath.get(session.parentSessionPath) ?? [];
children.push(session);
childrenByParentPath.set(session.parentSessionPath, children);
}
const countFor = (session: SessionInfo, seenPaths: Set<string>): number => {
if (seenPaths.has(session.path)) return 0;
const nextSeenPaths = new Set(seenPaths);
nextSeenPaths.add(session.path);
let count = 0;
for (const child of childrenByParentPath.get(session.path) ?? []) {
if (nextSeenPaths.has(child.path)) continue;
if (child.archived !== true) count += 1;
count += countFor(child, nextSeenPaths);
}
return count;
};
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>();
@@ -193,6 +193,32 @@ describe("SessionController", () => {
expect(urlUpdates).toEqual([undefined]);
});
it("archives selected session descendants and selects the next active session", async () => {
const childSession = { ...oldSession, id: "child-session", path: "/tmp/child-session.jsonl", parentSessionPath: oldSession.path };
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession, childSession, nextSession] };
const api: typeof defaultApi = {
...defaultApi,
archiveWithDescendants: () => Promise.resolve({ archived: true, sessionIds: [oldSession.id, childSession.id], archivedCount: 2, skippedAlreadyArchivedCount: 0 }),
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.archiveSessionWithDescendants(oldSession);
expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true });
expect(state.sessions.find((session) => session.id === childSession.id)).toMatchObject({ archived: true });
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] };
@@ -5,7 +5,7 @@ import { clearDraft, moveDraft, saveDraft } from "../promptDraftStorage";
import { ChatTranscriptStore } from "../chatTranscriptStore";
import { isShellInput } from "../inputModes";
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
import { InMemorySessionSelectionMemory, markSessionArchived, selectPreferredSession, selectionAfterArchivingSession, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection";
import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection";
import type { GetState, SetState, UpdateUrl } from "./types";
const MESSAGE_PAGE_SIZE = 100;
@@ -238,6 +238,23 @@ export class SessionController {
}
}
async archiveSessionWithDescendants(session = this.getState().selectedSession) {
if (!session || isCachedNewSessionInfo(session)) return;
try {
const response = await this.api.archiveWithDescendants(session.id);
const archivedIds = response.sessionIds !== undefined && response.sessionIds.length > 0 ? response.sessionIds : [session.id];
const state = this.getState();
const sessions = markSessionsArchived(state.sessions, archivedIds, new Date().toISOString());
const selectionChange = selectionAfterArchivingSessions(sessions, state.selectedSession?.id, archivedIds);
this.setState({ sessions });
if (selectionChange.type === "select") await this.selectSession(selectionChange.session);
else if (selectionChange.type === "clear") this.deselectSession({ forgetRememberedSelection: true });
} catch (error) {
this.setState({ error: String(error) });
}
}
async deleteCachedNewSession(session = this.getState().selectedSession) {
if (!isCachedNewSessionInfo(session)) return;
void this.api.stop(session.id).catch(() => {
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { SessionInfo } from "../api";
import { InMemorySessionSelectionMemory, markSessionArchived, selectPreferredSession, selectionAfterArchivingSession, shouldDeselectAfterArchivedCollapse } from "./sessionSelection";
import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse } from "./sessionSelection";
describe("selectPreferredSession", () => {
it("prefers an explicit target session by id", () => {
@@ -78,6 +78,14 @@ describe("markSessionArchived", () => {
expect(next).toEqual([{ ...sessions[0], archived: true, archivedAt: "later" }, sessions[1]]);
expect(sessions[0]?.archived).toBeUndefined();
});
it("marks multiple matching sessions archived", () => {
const sessions = [testSession("s1"), testSession("s2"), testSession("s3")];
const next = markSessionsArchived(sessions, ["s1", "s3"], "later");
expect(next).toEqual([{ ...sessions[0], archived: true, archivedAt: "later" }, sessions[1], { ...sessions[2], archived: true, archivedAt: "later" }]);
});
});
describe("shouldDeselectAfterArchivedCollapse", () => {
@@ -112,6 +120,10 @@ describe("selectionAfterArchivingSession", () => {
it("clears selection when no active session remains", () => {
expect(selectionAfterArchivingSession([testSession("s1")], "s1", "s1")).toEqual({ type: "clear" });
});
it("clears selection when archiving a selected subtree with no active sessions left", () => {
expect(selectionAfterArchivingSessions([testSession("s1"), testSession("s2")], "s2", ["s1", "s2"])).toEqual({ type: "clear" });
});
});
function testSession(id: string): SessionInfo {
+13 -3
View File
@@ -47,12 +47,22 @@ export type ArchiveSelectionChange =
| { type: "clear" };
export function markSessionArchived(sessions: SessionInfo[], sessionId: string, archivedAt: string): SessionInfo[] {
return sessions.map((session) => session.id === sessionId ? { ...session, archived: true, archivedAt } : session);
return markSessionsArchived(sessions, [sessionId], archivedAt);
}
export function markSessionsArchived(sessions: SessionInfo[], sessionIds: readonly string[], archivedAt: string): SessionInfo[] {
const archivedIds = new Set(sessionIds);
return sessions.map((session) => archivedIds.has(session.id) ? { ...session, archived: true, archivedAt } : session);
}
export function selectionAfterArchivingSession(sessions: SessionInfo[], selectedSessionId: string | undefined, archivedSessionId: string): ArchiveSelectionChange {
if (selectedSessionId !== archivedSessionId) return { type: "unchanged" };
return selectionAfterArchivingSessions(sessions, selectedSessionId, [archivedSessionId]);
}
const nextSession = sessions.find((session) => session.id !== archivedSessionId && session.archived !== true);
export function selectionAfterArchivingSessions(sessions: SessionInfo[], selectedSessionId: string | undefined, archivedSessionIds: readonly string[]): ArchiveSelectionChange {
if (selectedSessionId === undefined || !archivedSessionIds.includes(selectedSessionId)) return { type: "unchanged" };
const archivedIds = new Set(archivedSessionIds);
const nextSession = sessions.find((session) => !archivedIds.has(session.id) && session.archived !== true);
return nextSession === undefined ? { type: "clear" } : { type: "select", session: nextSession };
}