Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions app/api/sessions/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ export async function GET(
const subagent = header
? readSubagentRun(entries as never, header.id, filePath)
: null;
// A third-party subagent session has no metadata of its own, so its relation
// comes from the cached catalogue (the parent's recorded runs) rather than a
// second read of the parent file here.
const externalRelation = !subagent && header?.parentSession && parentSessionId
? (await listAllSessions()).find((session) => session.id === header.id)?.relation
: undefined;
const toolNames = readSubagentSessionResources(entries as never)?.tools
?? readSessionToolSelection(entries as never);
const info = header ? (await attachSessionProjectInfo([{
Expand All @@ -89,9 +95,11 @@ export async function GET(
parentSessionId,
...(subagent
? { relation: { kind: "subagent" as const, parentSessionId: subagent.parentSessionId, profile: subagent.profile, description: subagent.description, status: liveRpc?.isRunning() ? "running" as const : subagent.status } }
: header.parentSession
? { relation: { kind: "fork" as const, ...(parentSessionId ? { originSessionId: parentSessionId } : {}) } }
: {}),
: externalRelation?.kind === "subagent"
? { relation: externalRelation }
: header.parentSession
? { relation: { kind: "fork" as const, ...(parentSessionId ? { originSessionId: parentSessionId } : {}) } }
: {}),
transient: !filePath || !existsSync(filePath),
}]))[0] : null;

Expand Down Expand Up @@ -177,7 +185,10 @@ export async function DELETE(
);
const childrenByParent = new Map<string, string[]>();
for (const session of sessions) {
if (session.relation?.kind !== "subagent") continue;
// Third-party subagent runs are someone else's transcripts: the parent's own
// delete must not take them down, so they fall through to the re-parent path
// below like any other child session (issue #762 covers Pi Web's own runs).
if (session.relation?.kind !== "subagent" || session.relation.source === "external") continue;
const children = childrenByParent.get(session.relation.parentSessionId) ?? [];
children.push(session.id);
childrenByParent.set(session.relation.parentSessionId, children);
Expand Down
9 changes: 5 additions & 4 deletions components/SessionSidebar.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,10 @@ test("does not expose disk-backed actions for transient sessions", () => {
assert.match(sessionItemSource, /\{hovered && !session\.transient && \(/);
});

test("hides subagent rows and aggregates their state into the main session row", () => {
test("renders parentSession children beneath their root session", () => {
assert.match(source, /const sessionFamilies = listSessionFamilies\(filteredSessions\)/);
assert.match(source, /familySessions\.some\(\(session\) => session\.id === selectedSessionId\)/);
assert.match(source, /familySessions\.some\(\(session\) => runningSessionIds\.has\(session\.id\)\)/);
assert.doesNotMatch(source, /function SessionTreeItem/);
assert.match(source, /family\.children\.map\(\(child\) =>/);
assert.match(source, /depth: 1/);
assert.match(source, /onClick=\{\(\) => handleSelectSessionFromList\(row\.session\)\}/);
assert.match(source, /session\.relation\?\.kind === "subagent"/);
});
57 changes: 40 additions & 17 deletions components/SessionSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio
const sessionSearchActive = sessionSearchOpen && Boolean(sessionSearchQuery.trim());
const [changesCount, setChangesCount] = useState(0);
const [changesCollapsed, setChangesCollapsed] = useState(true);
const [collapsedSessionFamilyIds, setCollapsedSessionFamilyIds] = useState<Set<string>>(() => new Set());
const [explorerRefreshDone, setExplorerRefreshDone] = useState(false);
const [runningSessionIds, setRunningSessionIds] = useState<Set<string>>(() => new Set());
const [unreadSessionIds, setUnreadSessionIds] = useState<Set<string>>(() => loadUnreadSessionIds());
Expand Down Expand Up @@ -1005,11 +1006,32 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio

const sessionFamilies = listSessionFamilies(filteredSessions);

// Windowed rows: one entry per visible session, so nested children keep the
// fixed row height the virtualization math relies on.
const sessionRows = sessionFamilies.flatMap((family) => {
const collapsed = collapsedSessionFamilyIds.has(family.root.id);
const rootSession = family.latestModified === family.root.modified
? family.root
: { ...family.root, modified: family.latestModified };
const rootRow = { family, session: rootSession, depth: 0, collapsed };
if (collapsed) return [rootRow];
return [rootRow, ...family.children.map((child) => ({ family, session: child, depth: 1, collapsed }))];
});

const toggleSessionFamily = (rootId: string) => {
setCollapsedSessionFamilyIds((current) => {
const next = new Set(current);
if (next.has(rootId)) next.delete(rootId);
else next.add(rootId);
return next;
});
};

const virtualIndices = getSessionListIndices(
sessionFamilies.length,
sessionRows.length,
listScrollTop,
listViewportH,
sessionFamilies.findIndex((family) => family.root.id === focusedSessionId),
sessionRows.findIndex((row) => row.session.id === focusedSessionId),
);

return (
Expand Down Expand Up @@ -1694,38 +1716,39 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio
{t("sidebar.noSessions")}
</div>
)}
{sessionFamilies.length > 0 && (
{sessionRows.length > 0 && (
<div
style={{
position: "relative",
height: sessionFamilies.length * SESSION_LIST_ITEM_HEIGHT,
height: sessionRows.length * SESSION_LIST_ITEM_HEIGHT,
}}
>
{virtualIndices.map((index) => {
const family = sessionFamilies[index];
const familySessions = [family.root, ...family.subagents];
const displaySession = family.latestModified === family.root.modified
? family.root
: { ...family.root, modified: family.latestModified };
const row = sessionRows[index];
const rowSessions = row.depth === 0 ? [row.family.root, ...row.family.children] : [row.session];
// Bubble blur after the input's save handler before unpinning the row.
return (
<div
key={family.root.id}
onFocus={() => setFocusedSessionId(family.root.id)}
key={row.session.id}
onFocus={() => setFocusedSessionId(row.session.id)}
onBlur={() => setFocusedSessionId(null)}
style={{ position: "absolute", top: index * SESSION_LIST_ITEM_HEIGHT, left: 0, right: 0 }}
>
<SessionItem
session={displaySession}
isSelected={familySessions.some((session) => session.id === selectedSessionId)}
isRunning={familySessions.some((session) => runningSessionIds.has(session.id))}
isUnread={familySessions.some((session) => unreadSessionIds.has(session.id))}
onClick={() => handleSelectSessionFromList(family.root)}
session={row.session}
isSelected={rowSessions.some((session) => session.id === selectedSessionId)}
isRunning={rowSessions.some((session) => runningSessionIds.has(session.id))}
isUnread={rowSessions.some((session) => unreadSessionIds.has(session.id))}
onClick={() => handleSelectSessionFromList(row.session)}
onRenamed={loadSessions}
onDeleted={(id) => {
onSessionDeleted?.(id);
loadSessions();
}}
depth={row.depth}
hasChildren={row.depth === 0 && row.family.children.length > 0}
collapsed={row.collapsed}
onToggleCollapse={() => toggleSessionFamily(row.family.root.id)}
/>
</div>
);
Expand Down Expand Up @@ -2207,7 +2230,7 @@ function SessionItem({
/* ── Normal view ── */
<>
{/* Subagent indicator for child sessions */}
{depth > 0 && (
{depth > 0 && session.relation?.kind === "subagent" && (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<rect x="5" y="7" width="14" height="11" rx="2" />
<path d="M9 11h.01M15 11h.01M9 15h6M12 7V4M10 4h4" />
Expand Down
11 changes: 8 additions & 3 deletions lib/session-family.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ function session(id, modified, relation) {
};
}

test("groups nested subagents under their main session and uses family activity for sorting", () => {
test("groups forks and nested subagents under their root while preserving subagent semantics", () => {
const main = session("main", "2026-01-01T00:00:00.000Z");
const fork = session("fork", "2026-01-05T00:00:00.000Z", {
kind: "fork", originSessionId: "main",
});
const child = session("child", "2026-01-04T00:00:00.000Z", {
kind: "subagent", parentSessionId: "main", profile: "explore", description: "Explore", status: "completed",
});
Expand All @@ -27,10 +30,12 @@ test("groups nested subagents under their main session and uses family activity
});
const newerRoot = session("newer-root", "2026-01-02T00:00:00.000Z");

const families = listSessionFamilies([main, child, grandchild, newerRoot]);
const families = listSessionFamilies([main, fork, child, grandchild, newerRoot]);
assert.deepEqual(families.map((family) => family.root.id), ["main", "newer-root"]);
assert.deepEqual(families[0].children.map((item) => item.id), ["fork", "child", "grandchild"]);
assert.deepEqual(families[0].subagents.map((item) => item.id), ["child", "grandchild"]);
assert.equal(getSessionFamily([main, child, grandchild], "grandchild")?.root.id, "main");
assert.equal(getSessionFamily([main, fork, child, grandchild], "fork")?.root.id, "main");
assert.equal(getSessionFamily([main, fork, child, grandchild], "grandchild")?.root.id, "main");
});

test("does not promote orphaned or cyclic subagent metadata into the main session list", () => {
Expand Down
28 changes: 20 additions & 8 deletions lib/session-family.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@ import type { SessionInfo } from "./types";

export interface SessionFamily {
root: SessionInfo;
/** Every descendant linked through parentSession, including forks and subagents. */
children: SessionInfo[];
/** Metadata-confirmed Pi Web subagents only. */
subagents: SessionInfo[];
latestModified: string;
}

function parentId(session: SessionInfo): string | undefined {
if (session.relation?.kind === "subagent") return session.relation.parentSessionId;
if (session.relation?.kind === "fork") return session.relation.originSessionId;
return undefined;
}

function resolveFamilyRoots(sessions: readonly SessionInfo[]): Map<string, string | null> {
const byId = new Map(sessions.map((session) => [session.id, session]));
const roots = new Map<string, string | null>();
Expand All @@ -29,11 +38,12 @@ function resolveFamilyRoots(sessions: readonly SessionInfo[]): Map<string, strin
path.push(currentId);
const current = byId.get(currentId);
if (!current) break;
if (current.relation?.kind !== "subagent") {
const parent = parentId(current);
if (!parent) {
rootId = current.id;
break;
}
currentId = current.relation.parentSessionId;
currentId = parent;
}

for (const id of path) roots.set(id, rootId);
Expand All @@ -42,26 +52,28 @@ function resolveFamilyRoots(sessions: readonly SessionInfo[]): Map<string, strin
return roots;
}

/** Groups visible main/fork sessions with every persisted subagent descendant. */
/** Groups every resolvable parentSession descendant under its root session. */
export function listSessionFamilies(sessions: readonly SessionInfo[]): SessionFamily[] {
const rootsBySessionId = resolveFamilyRoots(sessions);
const families = new Map<string, SessionFamily>();

for (const session of sessions) {
if (session.relation?.kind === "subagent") continue;
if (rootsBySessionId.get(session.id) !== session.id) continue;
families.set(session.id, {
root: session,
children: [],
subagents: [],
latestModified: session.modified,
});
}

for (const session of sessions) {
if (session.relation?.kind !== "subagent") continue;
const rootId = rootsBySessionId.get(session.id);
const family = rootId ? families.get(rootId) : undefined;
if (!rootId || rootId === session.id) continue;
const family = families.get(rootId);
if (!family) continue;
family.subagents.push(session);
family.children.push(session);
if (session.relation?.kind === "subagent") family.subagents.push(session);
if (session.modified > family.latestModified) family.latestModified = session.modified;
}

Expand All @@ -75,6 +87,6 @@ export function getSessionFamily(
if (!sessionId) return null;
return listSessionFamilies(sessions).find((family) => (
family.root.id === sessionId
|| family.subagents.some((session) => session.id === sessionId)
|| family.children.some((session) => session.id === sessionId)
)) ?? null;
}
58 changes: 58 additions & 0 deletions lib/session-list-scanner.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ test("discards malformed persisted entries and rebuilds the list from valid sess
["invalid created", (entry) => { entry.info.created = "not a date"; }],
["invalid modified", (entry) => { entry.info.modified = "not a date"; }],
["null date", (entry) => { entry.info.created = null; }],
["invalid subagent runs", (entry) => { entry.info.subagentRuns = "nope"; }],
["invalid subagent run entry", (entry) => { entry.info.subagentRuns = [{ id: 1, profile: "x", description: "", status: "completed" }]; }],
];
for (const [label, corrupt] of corruptions) {
const index = JSON.parse(pristine);
Expand Down Expand Up @@ -167,3 +169,59 @@ test("discovers sessions through project directory symlinks", { skip: process.pl
fs.symlinkSync(external, dir, "dir");
assert.deepEqual(await listSessionsIncremental(), await sdkMetadata());
});

test("links third-party subagent children through the runs their parent recorded", async (t) => {
const { write } = fixture(t);
const parent = write("parent", [
{
type: "message", id: "p1", parentId: null, timestamp,
message: {
role: "toolResult",
content: [{ type: "text", text: "Agent started in background." }],
details: { subagentType: "Explore", description: "Still running", status: "background", agentId: "aa11bb22-cc33" },
},
},
{
type: "custom", customType: "subagents:record", id: "p2", parentId: "p1", timestamp,
data: { id: "35a50c91-c27b-473", type: "general-purpose", description: "Wait one minute", status: "steered", result: "succeed" },
},
]);
// Children of a third-party extension only share the parent path and a name that
// embeds the run id; the parent's own entries are what make them identifiable.
write("raw-child", [
message("c1", "user", "wait"),
{ type: "session_info", id: "c2", parentId: "c1", timestamp, name: "general-purpose#35a50c91" },
], { parentSession: parent });
write("running-child", [
message("d1", "user", "inspect"),
{ type: "session_info", id: "d2", parentId: "d1", timestamp, name: "Explore#aa11bb22" },
], { parentSession: parent });
write("user-fork", [message("f1", "user", "continue")], { parentSession: parent });

const sessions = new Map((await listAllSessions({ force: true })).map((s) => [s.id, s]));
assert.deepEqual(sessions.get("raw-child").relation, {
kind: "subagent",
parentSessionId: "parent",
profile: "general-purpose",
description: "Wait one minute",
status: "completed",
// The delete route keeps third-party runs out of the parent's cascade.
source: "external",
});
assert.deepEqual(sessions.get("running-child").relation, {
kind: "subagent",
parentSessionId: "parent",
profile: "Explore",
description: "Still running",
status: "running",
source: "external",
});
// A fork the parent never spawned keeps fork semantics.
assert.deepEqual(sessions.get("user-fork").relation, { kind: "fork", originSessionId: "parent" });

// The persisted index carries the runs, so a restart classifies identically.
resetSessionScanIndexForTests();
const afterRestart = new Map((await listAllSessions({ force: true })).map((s) => [s.id, s]));
assert.equal(afterRestart.get("raw-child").relation.kind, "subagent");
assert.equal(afterRestart.get("user-fork").relation.kind, "fork");
});
Loading