feat(sessions): surface cross-worktree parent and child sessions

A session spawned into another worktree recorded a parent that no
listing contained, so the row showed only "parent unavailable" and its
parent's row looked childless. Both facts were accurate and useless:
neither said where the related session actually was.

Report both directions from the session store instead. A missing
parent's cwd and id come from its own file header, so one 4 KB read per
distinct missing parent resolves it without listing other workspaces;
children are counted by listing sibling workspaces and matching the
parent path they already recorded, needing no header reads. Reads are
memoized per path because Pi writes headers once, and the cache is
released on dispose. Both directions are best-effort: an unreadable
header or an unlistable worktree leaves a session unannotated rather
than failing the listing.

In the browser, an orphan child keeps the same child marker as a nested
one, dimmed, so it no longer renders as a root; whereabouts are stated
once on the meta line ("parent in feature/foo", "2 children
elsewhere"), where a clamped title cannot hide them. A "Go to parent
session" action switches to the owning workspace and selects the
parent. Live session.created events keep child counts current instead
of leaving them stale until the next listing.

Session and workspace paths reach the browser from two producers: store
enumeration for a listing, and the live runtime for a broadcast. They
are now compared through one normalizing helper, so tree nesting and
child counts cannot silently miss a link when only a trailing separator
differs.

Extract the shared "workspaces of the project containing this cwd"
lookup out of ProjectScopedSpawnTargetResolver so spawn targeting and
child counting share one implementation, and register it regardless of
whether spawning is enabled: children can predate a config change, and
the tree should stay honest about them either way.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-28 11:56:21 +02:00
parent a4f513dbd1
commit 69b125b001
24 changed files with 1570 additions and 84 deletions
@@ -0,0 +1,74 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { readSessionHeaderSummary } from "./sessionFileHeader.js";
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-web-session-header-test-"));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("readSessionHeaderSummary", () => {
it("reads id, cwd, and parent session from a real session file header", async () => {
const sessionFile = await sessionFileWithLines([
{ type: "session", version: 3, id: "child-id", cwd: "/srv/dev/pi-web-feature", parentSession: "/sessions/parent.jsonl" },
{ type: "model_change", id: "abc", parentId: null },
]);
expect(await readSessionHeaderSummary(sessionFile)).toEqual({
id: "child-id",
cwd: "/srv/dev/pi-web-feature",
parentSession: "/sessions/parent.jsonl",
});
});
it("omits absent and empty optional fields", async () => {
const sessionFile = await sessionFileWithLines([{ type: "session", version: 3, id: "root-id", cwd: "" }]);
expect(await readSessionHeaderSummary(sessionFile)).toEqual({ id: "root-id" });
});
it("returns undefined for a missing file", async () => {
expect(await readSessionHeaderSummary(join(tempDir, "absent.jsonl"))).toBeUndefined();
});
it("returns undefined when the first line is not valid JSON", async () => {
const sessionFile = join(tempDir, "broken.jsonl");
await writeFile(sessionFile, "not json\n", "utf8");
expect(await readSessionHeaderSummary(sessionFile)).toBeUndefined();
});
it("returns undefined when the first line is not a session header", async () => {
const sessionFile = await sessionFileWithLines([{ type: "model_change", id: "abc" }]);
expect(await readSessionHeaderSummary(sessionFile)).toBeUndefined();
});
it("returns undefined when the header carries no session id", async () => {
const sessionFile = await sessionFileWithLines([{ type: "session", version: 3, cwd: "/srv/dev/pi-web" }]);
expect(await readSessionHeaderSummary(sessionFile)).toBeUndefined();
});
it("does not read beyond the header line of a large transcript", async () => {
const sessionFile = join(tempDir, "large.jsonl");
const header = JSON.stringify({ type: "session", version: 3, id: "big-id", cwd: "/srv/dev/pi-web" });
const bulk = Array.from({ length: 500 }, (_unused, index) => JSON.stringify({ type: "message", id: String(index), text: "x".repeat(200) }));
await writeFile(sessionFile, `${[header, ...bulk].join("\n")}\n`, "utf8");
expect(await readSessionHeaderSummary(sessionFile)).toEqual({ id: "big-id", cwd: "/srv/dev/pi-web" });
});
});
async function sessionFileWithLines(lines: readonly Record<string, unknown>[]): Promise<string> {
const sessionFile = join(tempDir, `session-${String(lines.length)}-${Math.random().toString(36).slice(2)}.jsonl`);
await writeFile(sessionFile, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8");
return sessionFile;
}