From 0118e6ebe9f692062d0d83561e017cefb53bbcc6 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 9 Jun 2026 13:40:48 +0200 Subject: [PATCH] fix: keep archived parent sessions visible --- .../preserve-archived-session-parents.md | 5 ++ src/client/src/components/SessionList.test.ts | 49 +++++++++++++++++++ src/client/src/components/SessionList.ts | 41 +++++++++++----- 3 files changed, 84 insertions(+), 11 deletions(-) create mode 100644 .changeset/preserve-archived-session-parents.md create mode 100644 src/client/src/components/SessionList.test.ts diff --git a/.changeset/preserve-archived-session-parents.md b/.changeset/preserve-archived-session-parents.md new file mode 100644 index 0000000..1969a23 --- /dev/null +++ b/.changeset/preserve-archived-session-parents.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep archived parent sessions visible in the current session tree while they still have unarchived children. diff --git a/src/client/src/components/SessionList.test.ts b/src/client/src/components/SessionList.test.ts new file mode 100644 index 0000000..b301176 --- /dev/null +++ b/src/client/src/components/SessionList.test.ts @@ -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) { + return rows.map((row) => ({ id: row.session.id, depth: row.depth, hasMissingParent: row.hasMissingParent })); +} + +function session(id: string, overrides: Partial = {}): 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, + }; +} diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index f479c40..fa3adf4 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -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`
- ${this.renderHeading(currentRows.length + archivedRows.length, currentRows.map((row) => row.session))} + ${this.renderHeading(currentRows.length + archivedRows.length, currentSelectableSessions)} ${this.collapsed ? null : html`
- ${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`
{ 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)} @@ -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 [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(); + for (const session of sessions) { + if (session.archived === true) continue; + visible.add(session.id); + let parentPath = session.parentSessionPath; + const seenPaths = new Set([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[] {