fix: keep archived parent sessions visible

This commit is contained in:
Federico Jaramillo Martinez
2026-06-09 13:40:48 +02:00
parent 313dbea34e
commit 0118e6ebe9
3 changed files with 84 additions and 11 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Keep archived parent sessions visible in the current session tree while they still have unarchived children.
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import type { SessionInfo } from "../api";
import { sessionRowsForCurrentTree } from "./SessionList";
describe("sessionRowsForCurrentTree", () => {
it("keeps archived ancestors visible while they have unarchived descendants", () => {
const parent = { ...session("parent"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" };
const child = session("child", { parentSessionPath: parent.path });
expect(rowSummaries(sessionRowsForCurrentTree([parent, child]))).toEqual([
{ id: "parent", depth: 0, hasMissingParent: false },
{ id: "child", depth: 1, hasMissingParent: false },
]);
});
it("hides archived parents from the current tree once children are detached", () => {
const parent = { ...session("parent"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" };
const detachedChild = session("child");
expect(rowSummaries(sessionRowsForCurrentTree([parent, detachedChild]))).toEqual([
{ id: "child", depth: 0, hasMissingParent: false },
]);
});
it("still marks unavailable parents when the parent record is missing", () => {
const child = session("child", { parentSessionPath: "/sessions/missing.jsonl" });
expect(rowSummaries(sessionRowsForCurrentTree([child]))).toEqual([
{ id: "child", depth: 0, hasMissingParent: true },
]);
});
});
function rowSummaries(rows: ReturnType<typeof sessionRowsForCurrentTree>) {
return rows.map((row) => ({ id: row.session.id, depth: row.depth, hasMissingParent: row.hasMissingParent }));
}
function session(id: string, overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
id,
path: `/sessions/${id}.jsonl`,
cwd: "/workspace",
created: "2026-06-09T00:00:00.000Z",
modified: "2026-06-09T00:00:00.000Z",
messageCount: 1,
firstMessage: id,
...overrides,
};
}
+30 -11
View File
@@ -14,7 +14,7 @@ function sessionLabel(session: SessionInfo): string {
return session.firstMessage !== "" ? session.firstMessage : session.id.slice(0, 8);
}
interface SessionRow {
export interface SessionRow {
session: SessionInfo;
depth: number;
hasMissingParent: boolean;
@@ -92,15 +92,17 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
}
override render() {
const currentRows = sessionRowsForCurrentSessions(this.sessions);
const archivedRows = sessionRows(this.sessions.filter((session) => session.archived === true));
const currentRows = sessionRowsForCurrentTree(this.sessions);
const currentRowIds = new Set(currentRows.map((row) => row.session.id));
const currentSelectableSessions = currentRows.map((row) => row.session).filter((session) => sessionSelectionScope(session) === "current");
const archivedRows = sessionRows(this.sessions.filter((session) => session.archived === true && !currentRowIds.has(session.id)));
const descendantCounts = unarchivedDescendantCounts(this.sessions);
return html`
<section>
${this.renderHeading(currentRows.length + archivedRows.length, currentRows.map((row) => row.session))}
${this.renderHeading(currentRows.length + archivedRows.length, currentSelectableSessions)}
${this.collapsed ? null : html`
<div class="list-body">
${this.renderCurrentSelectionToolbar(currentRows.map((row) => row.session))}
${this.renderCurrentSelectionToolbar(currentSelectableSessions)}
${currentRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0, "current"))}
${archivedRows.length > 0 ? html`
${this.renderArchivedHeading(archivedRows.map((row) => row.session))}
@@ -193,18 +195,20 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
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 canBulkSelect = sessionSelectionScope(session) === scope;
const selectionActive = this.selectionScopes.has(scope);
const showsCheckbox = selectionActive && canBulkSelect;
const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id);
return html`
<div
class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${bulkSelected ? "bulk-selected" : ""} ${session.archived === true ? "archived" : ""} ${showsCheckbox ? "selecting" : ""}"
class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${bulkSelected ? "bulk-selected" : ""} ${session.archived === true ? "archived" : ""} ${selectionActive ? "selecting" : ""}"
style=${`--depth:${String(cappedDepth)}`}
tabindex="0"
title=${session.path}
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => { this.activateSessionRow(session, scope); }); }}
@keydown=${(event: KeyboardEvent) => { this.handleSessionKeydown(event, session, scope); }}
>
<div class="action-main ${showsCheckbox ? "selecting" : ""}">
<div class="action-main ${selectionActive ? "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)}
@@ -242,7 +246,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
}
private activateSessionRow(session: SessionInfo, scope: SessionSelectionScope): void {
if (this.selectionScopes.has(scope)) {
if (this.selectionScopes.has(scope) && sessionSelectionScope(session) === scope) {
this.toggleSelected(session.id);
return;
}
@@ -411,8 +415,23 @@ function unarchivedDescendantCounts(sessions: SessionInfo[]): Map<string, number
return new Map(sessions.map((session) => [session.id, countFor(session, new Set())]));
}
function sessionRowsForCurrentSessions(sessions: SessionInfo[]): SessionRow[] {
return sessionRows(sessions.filter((session) => session.archived !== true));
export function sessionRowsForCurrentTree(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 seenPaths = new Set<string>([session.path]);
while (parentPath !== undefined && !seenPaths.has(parentPath)) {
seenPaths.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 sessionRows(sessions: SessionInfo[]): SessionRow[] {