Archived
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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Show cross-workspace session relationships in the session list. A session whose parent lives in another worktree now names that parent's workspace or branch instead of only reporting an unavailable parent, and offers a "Go to parent session" action that switches to the owning workspace and selects the parent. A session with children in other workspaces of the same project now shows how many, so a parent no longer looks childless when its children are not nested beneath it.
|
||||
@@ -160,6 +160,9 @@ export function parseSessionInfo(value: unknown): SessionInfo {
|
||||
const name = optionalString(record, "name");
|
||||
const persisted = parseOptionalBoolean(record["persisted"], "persisted");
|
||||
const parentSessionPath = optionalString(record, "parentSessionPath");
|
||||
const parentSessionCwd = optionalString(record, "parentSessionCwd");
|
||||
const parentSessionId = optionalString(record, "parentSessionId");
|
||||
const childSessionsElsewhere = parseOptionalCount(record["childSessionsElsewhere"], "childSessionsElsewhere");
|
||||
const archivedAt = optionalString(record, "archivedAt");
|
||||
return {
|
||||
id: requireString(record, "id"),
|
||||
@@ -172,6 +175,9 @@ export function parseSessionInfo(value: unknown): SessionInfo {
|
||||
messageCount: requireNumber(record, "messageCount"),
|
||||
firstMessage: requireString(record, "firstMessage"),
|
||||
...(parentSessionPath === undefined ? {} : { parentSessionPath }),
|
||||
...(parentSessionCwd === undefined ? {} : { parentSessionCwd }),
|
||||
...(parentSessionId === undefined ? {} : { parentSessionId }),
|
||||
...(childSessionsElsewhere === undefined ? {} : { childSessionsElsewhere }),
|
||||
...(record["archived"] === true ? { archived: true } : {}),
|
||||
...(archivedAt === undefined ? {} : { archivedAt }),
|
||||
};
|
||||
@@ -1273,6 +1279,13 @@ function parseOptionalBoolean(value: unknown, key: string): boolean | undefined
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Optional non-negative integer count; zero is normalized away so absent and none read alike. */
|
||||
function parseOptionalCount(value: unknown, key: string): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error(`Expected optional count field: ${key}`);
|
||||
return value === 0 ? undefined : value;
|
||||
}
|
||||
|
||||
export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
|
||||
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
||||
import { selectedMachineId } from "../controllers/types";
|
||||
import { machineSessionKey } from "../machineKeys";
|
||||
import { resolveParentSessionLocation, type ParentSessionLocation } from "../parentSessionLocation";
|
||||
import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi";
|
||||
import { selectedNotificationView } from "../sessionNotifications";
|
||||
import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence";
|
||||
@@ -1380,6 +1381,8 @@ export class PiWebApp extends LitElement {
|
||||
.onDeleteArchivedSession=${(session: SessionInfo) => this.sessions.deleteArchivedSessions([session])}
|
||||
.onDeleteArchivedSessions=${(sessions: SessionInfo[]) => this.sessions.deleteArchivedSessions(sessions)}
|
||||
.onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)}
|
||||
.parentSessionLocation=${this.parentSessionLocationFor}
|
||||
.onGoToParentSession=${(session: SessionInfo, location: ParentSessionLocation) => this.goToParentSession(location)}
|
||||
.onReloadSession=${(session: SessionInfo) => this.sessions.reloadSession(session)}
|
||||
.onCleanupSessions=${() => { this.openSessionCleanupDialog(); }}
|
||||
.onFocusNavigationTarget=${(target: NavigationFocusTarget) => { void this.focusNavigationTarget(target); }}
|
||||
@@ -1405,6 +1408,36 @@ export class PiWebApp extends LitElement {
|
||||
await this.focusNavigationTarget(nextTarget);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a listed session's parent lives, when that parent is outside the
|
||||
* selected workspace. Bound once so the session list receives a stable
|
||||
* resolver identity across renders.
|
||||
*/
|
||||
private readonly parentSessionLocationFor = (session: SessionInfo): ParentSessionLocation => resolveParentSessionLocation(session, {
|
||||
workspaces: this.state.workspaces,
|
||||
workspacesByProjectId: this.state.workspacesByProjectId,
|
||||
projects: this.state.projects,
|
||||
});
|
||||
|
||||
/**
|
||||
* Select the workspace that owns an out-of-workspace parent session, and the
|
||||
* parent session itself when its id is known. Cross-project parents go through
|
||||
* `selectProject`, which loads that project's workspaces first.
|
||||
*/
|
||||
private async goToParentSession(location: ParentSessionLocation): Promise<void> {
|
||||
if (location.kind !== "workspace") return;
|
||||
await this.selectNavigationItem("sessions", "chat", async () => {
|
||||
const workspace = this.state.workspaces.find((candidate) => candidate.id === location.workspaceId);
|
||||
if (workspace !== undefined) {
|
||||
await this.workspaces.selectWorkspace(workspace, { sessionId: location.sessionId });
|
||||
return;
|
||||
}
|
||||
const project = this.state.projects.find((candidate) => candidate.id === location.projectId);
|
||||
if (project === undefined) return;
|
||||
await this.workspaces.selectProject(project, { workspaceId: location.workspaceId, sessionId: location.sessionId });
|
||||
});
|
||||
}
|
||||
|
||||
private async startSessionFromNavigation(): Promise<void> {
|
||||
const seq = ++this.navigationSelectionSeq;
|
||||
const isCurrentSelection = () => seq === this.navigationSelectionSeq;
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionInfo } from "../api";
|
||||
import type { ParentSessionLocation } from "../parentSessionLocation";
|
||||
import { SessionList } from "./SessionList";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
const workspaceLocation: ParentSessionLocation = {
|
||||
kind: "workspace",
|
||||
label: "feature/parent-links",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-feature",
|
||||
sessionId: "parent-id",
|
||||
cwd: "/srv/dev/pi-web-feature",
|
||||
};
|
||||
|
||||
describe("orphan child row indicator", () => {
|
||||
it("marks a session whose parent is missing as a child rather than a root row", async () => {
|
||||
const list = await renderList({ sessions: [orphan()], parentLocation: () => workspaceLocation });
|
||||
|
||||
const marker = row(list).querySelector(".tree-marker.orphan-marker");
|
||||
// Same glyph as an ordinary child: the left marker answers "is this a child",
|
||||
// not "where is the parent".
|
||||
expect(marker?.textContent).toBe("↳");
|
||||
expect(marker?.getAttribute("aria-label")).toBe("parent in feature/parent-links");
|
||||
expect(marker?.getAttribute("title")).toBe("Parent session is in feature/parent-links (/srv/dev/pi-web-feature)");
|
||||
});
|
||||
|
||||
it("uses the same child glyph for orphan and nested children, distinguished only by styling", async () => {
|
||||
const parent = session("parent");
|
||||
const list = await renderList({
|
||||
sessions: [parent, session("nested", { parentSessionPath: parent.path }), orphan()],
|
||||
parentLocation: () => workspaceLocation,
|
||||
});
|
||||
|
||||
const markers = [...list.shadowRoot?.querySelectorAll(".tree-marker") ?? []];
|
||||
expect(markers.map((marker) => marker.textContent)).toEqual(["↳", "↳"]);
|
||||
expect(markers.filter((marker) => marker.classList.contains("orphan-marker"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("states the parent's whereabouts exactly once, on the meta line", async () => {
|
||||
const list = await renderList({ sessions: [orphan()], parentLocation: () => workspaceLocation });
|
||||
|
||||
// The badge used to repeat what the meta line already says; only one
|
||||
// statement of parent whereabouts should survive.
|
||||
expect(row(list).querySelectorAll(".row-badges")).toHaveLength(0);
|
||||
const parentMentions = [...row(list).querySelectorAll("*")]
|
||||
.filter((element) => element.children.length === 0 && element.textContent.includes("parent"));
|
||||
expect(parentMentions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("states where the parent lives at the start of the meta line", async () => {
|
||||
const list = await renderList({ sessions: [orphan()], parentLocation: () => workspaceLocation });
|
||||
|
||||
expect(row(list).querySelector("small")?.textContent).toBe("parent in feature/parent-links · 3 messages");
|
||||
});
|
||||
|
||||
it("falls back to the generic wording when the parent location is unknown", async () => {
|
||||
const list = await renderList({ sessions: [orphan()], parentLocation: () => ({ kind: "unknown" }) });
|
||||
|
||||
expect(row(list).querySelector("small")?.textContent).toBe("parent unavailable · 3 messages");
|
||||
expect(row(list).querySelector(".tree-marker.orphan-marker")?.getAttribute("aria-label")).toBe("parent unavailable");
|
||||
});
|
||||
|
||||
it("adds no orphan marker or parent meta to an ordinary root session", async () => {
|
||||
const list = await renderList({ sessions: [session("root")] });
|
||||
|
||||
expect(row(list).querySelector(".orphan-marker")).toBeNull();
|
||||
expect(row(list).querySelector(".row-badges")).toBeNull();
|
||||
expect(row(list).querySelector("small")?.textContent).toBe("3 messages");
|
||||
});
|
||||
|
||||
it("shows a nested child under a present parent with the ordinary child marker", async () => {
|
||||
const parent = session("parent");
|
||||
const child = session("child", { parentSessionPath: parent.path });
|
||||
const list = await renderList({ sessions: [parent, child] });
|
||||
|
||||
const childRow = [...list.shadowRoot?.querySelectorAll(".action-row") ?? []][1];
|
||||
expect(childRow?.querySelector(".tree-marker")?.textContent).toBe("↳");
|
||||
expect(childRow?.querySelector(".orphan-marker")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("children in other workspaces", () => {
|
||||
it("states the count on the meta line, where a long title cannot clamp it away", async () => {
|
||||
const longName = "A very long session name ".repeat(20);
|
||||
const list = await renderList({ sessions: [session("parent", { name: longName, childSessionsElsewhere: 2 })] });
|
||||
|
||||
expect(row(list).querySelector("small")?.textContent).toBe("2 children elsewhere · 3 messages");
|
||||
});
|
||||
|
||||
it("uses singular wording for a single child elsewhere", async () => {
|
||||
const list = await renderList({ sessions: [session("parent", { childSessionsElsewhere: 1 })] });
|
||||
|
||||
expect(row(list).querySelector("small")?.textContent).toBe("1 child elsewhere · 3 messages");
|
||||
});
|
||||
|
||||
it("says nothing for a session with no children elsewhere", async () => {
|
||||
const list = await renderList({ sessions: [session("parent"), session("zero", { childSessionsElsewhere: 0 })] });
|
||||
|
||||
expect(row(list, 0).querySelector("small")?.textContent).toBe("3 messages");
|
||||
expect(row(list, 1).querySelector("small")?.textContent).toBe("3 messages");
|
||||
});
|
||||
|
||||
it("uses no badge, so the badge area stays reserved for depth", async () => {
|
||||
const list = await renderList({ sessions: [session("parent", { childSessionsElsewhere: 3 })] });
|
||||
|
||||
expect(row(list).querySelector(".row-badges")).toBeNull();
|
||||
});
|
||||
|
||||
it("states both directions on one meta line for a child that is itself a parent", async () => {
|
||||
const list = await renderList({
|
||||
sessions: [orphan({ childSessionsElsewhere: 1 })],
|
||||
parentLocation: () => workspaceLocation,
|
||||
});
|
||||
|
||||
expect(row(list).querySelector("small")?.textContent).toBe("parent in feature/parent-links · 1 child elsewhere · 3 messages");
|
||||
});
|
||||
|
||||
it("keeps the transient-session prefix ahead of cross-workspace details", async () => {
|
||||
const list = await renderList({ sessions: [session("new", { persisted: false, childSessionsElsewhere: 2 })] });
|
||||
|
||||
expect(row(list).querySelector("small")?.textContent).toBe("new · 2 children elsewhere · 3 messages");
|
||||
});
|
||||
});
|
||||
|
||||
describe("go to parent session action", () => {
|
||||
it("offers the action for a resolvable parent workspace and forwards the location", async () => {
|
||||
const orphanSession = orphan();
|
||||
const onGoToParent = vi.fn<(session: SessionInfo, location: ParentSessionLocation) => void>();
|
||||
const list = await renderList({ sessions: [orphanSession], parentLocation: () => workspaceLocation, onGoToParent });
|
||||
|
||||
await openMenu(list);
|
||||
menuButton(list, "Go to parent session").click();
|
||||
|
||||
expect(onGoToParent).toHaveBeenCalledWith(orphanSession, workspaceLocation);
|
||||
});
|
||||
|
||||
it("omits the action when the parent workspace cannot be resolved", async () => {
|
||||
const list = await renderList({
|
||||
sessions: [orphan()],
|
||||
parentLocation: () => ({ kind: "path", label: "…/other/dir", cwd: "/srv/other/dir" }),
|
||||
onGoToParent: vi.fn(),
|
||||
});
|
||||
|
||||
await openMenu(list);
|
||||
expect(findMenuButton(list, "Go to parent session")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits the action for a session whose parent is present in the list", async () => {
|
||||
const parent = session("parent");
|
||||
const list = await renderList({
|
||||
sessions: [parent, session("child", { parentSessionPath: parent.path })],
|
||||
parentLocation: () => workspaceLocation,
|
||||
onGoToParent: vi.fn(),
|
||||
});
|
||||
|
||||
await openMenu(list, 1);
|
||||
expect(findMenuButton(list, "Go to parent session")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
async function renderList(options: {
|
||||
sessions: SessionInfo[];
|
||||
parentLocation?: (session: SessionInfo) => ParentSessionLocation;
|
||||
onGoToParent?: (session: SessionInfo, location: ParentSessionLocation) => void;
|
||||
}): Promise<SessionList> {
|
||||
const list = new SessionList();
|
||||
list.sessions = options.sessions;
|
||||
if (options.parentLocation !== undefined) list.parentLocation = options.parentLocation;
|
||||
if (options.onGoToParent !== undefined) list.onGoToParent = options.onGoToParent;
|
||||
document.body.append(list);
|
||||
await list.updateComplete;
|
||||
return list;
|
||||
}
|
||||
|
||||
function row(list: SessionList, index = 0): Element {
|
||||
const found = [...list.shadowRoot?.querySelectorAll(".action-row") ?? []][index];
|
||||
if (found === undefined) throw new Error(`No session row at index ${String(index)}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
async function openMenu(list: SessionList, index = 0): Promise<void> {
|
||||
row(list, index).querySelector<HTMLButtonElement>(".action-menu-toggle")?.click();
|
||||
await list.updateComplete;
|
||||
}
|
||||
|
||||
function findMenuButton(list: SessionList, text: string): HTMLButtonElement | undefined {
|
||||
return [...list.shadowRoot?.querySelectorAll<HTMLButtonElement>(".action-menu-panel button") ?? []]
|
||||
.find((button) => button.textContent.trim() === text);
|
||||
}
|
||||
|
||||
function menuButton(list: SessionList, text: string): HTMLButtonElement {
|
||||
const button = findMenuButton(list, text);
|
||||
if (button === undefined) throw new Error(`No menu button labelled ${text}`);
|
||||
return button;
|
||||
}
|
||||
|
||||
function orphan(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
return session("child", {
|
||||
parentSessionPath: "/sessions/--srv-dev-pi-web-feature--/parent.jsonl",
|
||||
parentSessionCwd: "/srv/dev/pi-web-feature",
|
||||
parentSessionId: "parent-id",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function session(id: string, overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
return {
|
||||
id,
|
||||
path: `/sessions/${id}.jsonl`,
|
||||
cwd: "/srv/dev/pi-web",
|
||||
created: "2026-07-28T00:00:00.000Z",
|
||||
modified: "2026-07-28T00:00:00.000Z",
|
||||
messageCount: 3,
|
||||
firstMessage: id,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -178,6 +178,18 @@ describe("sessionRowsForCurrentTree", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("nests a child whose recorded parent path differs only by a trailing separator", () => {
|
||||
// A session.created broadcast carries the live runtime's file path, while the
|
||||
// listed parent's path comes from the session store enumeration.
|
||||
const parent = session("parent");
|
||||
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("still marks unavailable parents when the parent record is missing", () => {
|
||||
const child = session("child", { parentSessionPath: "/sessions/missing.jsonl" });
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
|
||||
import { isCachedNewSessionInfo } from "../cachedNewSessions";
|
||||
import { shortSessionId } from "../sessionLabels";
|
||||
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
|
||||
import { parentSessionLocationLabel, parentSessionLocationTitle, type ParentSessionLocation } from "../parentSessionLocation";
|
||||
import { normalizeSessionPath } from "../sessionPaths";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
import { actionMenuPanelStyle } from "./actionMenu";
|
||||
import { renderActionActivityIndicator, type ActivityIndicatorKind } from "./activityBadge";
|
||||
@@ -57,6 +59,9 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
@property({ attribute: false }) onDeleteArchived?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise<void>;
|
||||
@property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void;
|
||||
/** Resolves where a row's out-of-workspace parent lives; defaults to "unknown" so the list works standalone. */
|
||||
@property({ attribute: false }) parentLocation: (session: SessionInfo) => ParentSessionLocation = () => ({ kind: "unknown" });
|
||||
@property({ attribute: false }) onGoToParent?: (session: SessionInfo, location: ParentSessionLocation) => void;
|
||||
@property({ attribute: false }) onMarkRead?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onMarkReadMany?: (sessions: SessionInfo[]) => void | Promise<void>;
|
||||
@property({ attribute: false }) onReload?: (session: SessionInfo) => void;
|
||||
@@ -283,7 +288,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
>
|
||||
<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-line"><span class="action-name" dir="auto">${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></span><small>${this.renderSessionMetaPrefix(session, status, activity)}${String(session.messageCount)} messages</small>
|
||||
<span class="action-name-line"><span class="action-name" dir="auto">${this.renderRowMarker(row)}${sessionLabel(session)}</span>${this.renderRowBadges(row)}</span><small>${this.renderSessionMetaPrefix(session, status, activity)}${this.renderRelatedSessionsMeta(row)}${String(session.messageCount)} messages</small>
|
||||
${this.renderActivity(indicatorKind, unread)}
|
||||
</div>
|
||||
<div class="action-menu">
|
||||
@@ -303,6 +308,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
<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}
|
||||
` : null}
|
||||
${this.renderGoToParentMenuItem(row)}
|
||||
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
|
||||
${canReloadSession ? html`<button title=${isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "Stop current session activity before reloading from disk" : "Reload session from disk without refreshing Pi runtime resources"} ?disabled=${isSessionActive(this.statuses[session.id], this.activities[session.id])} @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload from disk</button>` : null}
|
||||
`}
|
||||
@@ -313,6 +319,52 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leading marker stating that the row is a child of another session. Orphan
|
||||
* children (a recorded parent that is not in this list) render at depth 0 and
|
||||
* would otherwise look like roots, so they keep the same child glyph, dimmed
|
||||
* to signal that the parent itself is not shown here. Where that parent lives
|
||||
* is a separate question, answered by the badge on the other side of the row.
|
||||
*/
|
||||
private renderRowMarker(row: SessionRow) {
|
||||
if (row.hasMissingParent) {
|
||||
const location = this.parentLocation(row.session);
|
||||
return html`<span class="tree-marker orphan-marker" title=${parentSessionLocationTitle(location)} aria-label=${parentSessionLocationLabel(location)}>↳</span>`;
|
||||
}
|
||||
return row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Badges live outside `.action-name` so the clamped, ellipsizing title cannot
|
||||
* hide them. Cross-workspace relationships are not badges: they are stated on
|
||||
* the meta line below, where both directions read alike.
|
||||
*/
|
||||
private renderRowBadges(row: SessionRow) {
|
||||
if (row.depth <= 2) return null;
|
||||
return html`<span class="row-badges"><span class="badge">depth ${row.depth}</span></span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-workspace relationships, at the start of the meta line so they survive
|
||||
* truncation: where an out-of-workspace parent is, and how many children live
|
||||
* in other workspaces. Both are stated plainly rather than flagged, since a
|
||||
* session tree spanning worktrees is normal rather than a problem.
|
||||
*/
|
||||
private renderRelatedSessionsMeta(row: SessionRow) {
|
||||
const parts = [
|
||||
row.hasMissingParent ? parentSessionLocationLabel(this.parentLocation(row.session)) : undefined,
|
||||
childrenElsewhereLabel(row.session.childSessionsElsewhere),
|
||||
].filter((part) => part !== undefined);
|
||||
return parts.length === 0 ? null : `${parts.join(" · ")} · `;
|
||||
}
|
||||
|
||||
private renderGoToParentMenuItem(row: SessionRow) {
|
||||
if (!row.hasMissingParent || this.onGoToParent === undefined) return null;
|
||||
const location = this.parentLocation(row.session);
|
||||
if (location.kind !== "workspace") return null;
|
||||
return html`<button title=${parentSessionLocationTitle(location)} @click=${() => { this.openMenuSessionId = undefined; this.onGoToParent?.(row.session, location); }}>Go to parent session</button>`;
|
||||
}
|
||||
|
||||
private handleSessionKeydown(event: KeyboardEvent, session: SessionInfo, scope: SessionSelectionScope): void {
|
||||
handleSelectableRowKeyboard(event, {
|
||||
activate: () => { this.activateSessionRow(session, scope); },
|
||||
@@ -470,6 +522,11 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
.plain-heading { min-width: 0; }
|
||||
.action-name-line { min-width: 0; display: flex; align-items: flex-start; gap: 6px; }
|
||||
.action-name-line .action-name { flex: 1 1 auto; min-width: 0; }
|
||||
/* Badges must not sit inside the line-clamped title, or a long name hides them entirely. */
|
||||
.row-badges { flex: 0 0 auto; display: flex; align-items: flex-start; gap: 4px; }
|
||||
.row-badges .badge { margin-left: 0; white-space: nowrap; }
|
||||
/* Same glyph as a normal child marker, dimmed: the row is a child whose parent is not displayed here. */
|
||||
.orphan-marker { color: var(--pi-dim); opacity: .65; }
|
||||
.bulk-row .capability-hint { flex: 1 0 100%; color: var(--pi-warning); }
|
||||
.bulk-row.selecting { padding: 6px; border: 1px solid var(--pi-border-muted); border-radius: 8px; background: color-mix(in srgb, var(--pi-surface) 65%, transparent); }
|
||||
button.danger, .action-menu-panel button.danger { color: var(--pi-danger); }
|
||||
@@ -491,6 +548,12 @@ export function unreadSessionCount(
|
||||
return sessions.filter((session) => sessionRowUnread(session, unreadSessionIds)).length;
|
||||
}
|
||||
|
||||
/** Plain-text count of children living in other workspaces, or undefined when there are none. */
|
||||
function childrenElsewhereLabel(count: number | undefined): string | undefined {
|
||||
if (count === undefined || count === 0) return undefined;
|
||||
return count === 1 ? "1 child elsewhere" : `${String(count)} children elsewhere`;
|
||||
}
|
||||
|
||||
function sessionSelectionScope(session: SessionInfo): SessionSelectionScope {
|
||||
return session.archived === true ? "archived" : "current";
|
||||
}
|
||||
@@ -504,18 +567,20 @@ 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) ?? [];
|
||||
const parentKey = normalizeSessionPath(session.parentSessionPath);
|
||||
const children = childrenByParentPath.get(parentKey) ?? [];
|
||||
children.push(session);
|
||||
childrenByParentPath.set(session.parentSessionPath, children);
|
||||
childrenByParentPath.set(parentKey, children);
|
||||
}
|
||||
|
||||
const countFor = (session: SessionInfo, seenPaths: Set<string>): number => {
|
||||
if (seenPaths.has(session.path)) return 0;
|
||||
const sessionKey = normalizeSessionPath(session.path);
|
||||
if (seenPaths.has(sessionKey)) return 0;
|
||||
const nextSeenPaths = new Set(seenPaths);
|
||||
nextSeenPaths.add(session.path);
|
||||
nextSeenPaths.add(sessionKey);
|
||||
let count = 0;
|
||||
for (const child of childrenByParentPath.get(session.path) ?? []) {
|
||||
if (nextSeenPaths.has(child.path)) continue;
|
||||
for (const child of childrenByParentPath.get(sessionKey) ?? []) {
|
||||
if (nextSeenPaths.has(normalizeSessionPath(child.path))) continue;
|
||||
if (child.archived !== true) count += 1;
|
||||
count += countFor(child, nextSeenPaths);
|
||||
}
|
||||
@@ -557,49 +622,61 @@ export function sessionRowUnread(session: SessionInfo, unreadSessionIds: Readonl
|
||||
return unreadSessionIds.has(session.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Index sessions by their normalized path. Parent links can arrive from a
|
||||
* different server producer than the listing itself (a `session.created`
|
||||
* broadcast carries the live runtime's file path), so keys are normalized to
|
||||
* keep tree building from silently missing a link.
|
||||
*/
|
||||
function sessionsByNormalizedPath(sessions: readonly SessionInfo[]): Map<string, SessionInfo> {
|
||||
return new Map(sessions.map((session) => [normalizeSessionPath(session.path), session]));
|
||||
}
|
||||
|
||||
export function sessionRowsForCurrentTree(sessions: SessionInfo[]): SessionRow[] {
|
||||
const byPath = new Map(sessions.map((session) => [session.path, session]));
|
||||
const byPath = sessionsByNormalizedPath(sessions);
|
||||
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);
|
||||
let parentKey = session.parentSessionPath === undefined ? undefined : normalizeSessionPath(session.parentSessionPath);
|
||||
const seenPaths = new Set<string>([normalizeSessionPath(session.path)]);
|
||||
while (parentKey !== undefined && !seenPaths.has(parentKey)) {
|
||||
seenPaths.add(parentKey);
|
||||
const parent = byPath.get(parentKey);
|
||||
if (parent === undefined) break;
|
||||
visible.add(parent.id);
|
||||
parentPath = parent.parentSessionPath;
|
||||
parentKey = parent.parentSessionPath === undefined ? undefined : normalizeSessionPath(parent.parentSessionPath);
|
||||
}
|
||||
}
|
||||
return sessionRows(sessions.filter((session) => visible.has(session.id)));
|
||||
}
|
||||
|
||||
function sessionRows(sessions: SessionInfo[]): SessionRow[] {
|
||||
const byPath = new Map(sessions.map((session) => [session.path, session]));
|
||||
const byPath = sessionsByNormalizedPath(sessions);
|
||||
const childrenByPath = new Map<string, SessionInfo[]>();
|
||||
const roots: SessionInfo[] = [];
|
||||
for (const session of sessions) {
|
||||
const parentPath = session.parentSessionPath;
|
||||
const parent = parentPath === undefined ? undefined : byPath.get(parentPath);
|
||||
const parent = parentPath === undefined ? undefined : byPath.get(normalizeSessionPath(parentPath));
|
||||
if (parent === undefined) {
|
||||
roots.push(session);
|
||||
continue;
|
||||
}
|
||||
const children = childrenByPath.get(parent.path) ?? [];
|
||||
const parentKey = normalizeSessionPath(parent.path);
|
||||
const children = childrenByPath.get(parentKey) ?? [];
|
||||
children.push(session);
|
||||
childrenByPath.set(parent.path, children);
|
||||
childrenByPath.set(parentKey, children);
|
||||
}
|
||||
|
||||
const rows: SessionRow[] = [];
|
||||
const visit = (session: SessionInfo, depth: number, stack: Set<string>) => {
|
||||
if (stack.has(session.path)) return;
|
||||
const sessionKey = normalizeSessionPath(session.path);
|
||||
if (stack.has(sessionKey)) return;
|
||||
const parentPath = session.parentSessionPath;
|
||||
rows.push({ session, depth, hasMissingParent: parentPath !== undefined && !byPath.has(parentPath) });
|
||||
rows.push({ session, depth, hasMissingParent: parentPath !== undefined && !byPath.has(normalizeSessionPath(parentPath)) });
|
||||
const nextStack = new Set(stack);
|
||||
nextStack.add(session.path);
|
||||
for (const child of childrenByPath.get(session.path) ?? []) visit(child, depth + 1, nextStack);
|
||||
nextStack.add(sessionKey);
|
||||
for (const child of childrenByPath.get(sessionKey) ?? []) visit(child, depth + 1, nextStack);
|
||||
};
|
||||
for (const root of roots) visit(root, 0, new Set());
|
||||
return rows;
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NavigationSection } from "../../appShell/navigationState";
|
||||
import { NAVIGATION_SECTION_ORDER } from "../../appShell/navigationState";
|
||||
import { EMPTY_UNREAD_PRESENCE, type UnreadPresence } from "../../unreadPresence";
|
||||
import type { KeyboardNavigableSection } from "../navigationFocus";
|
||||
import type { ParentSessionLocation } from "../../parentSessionLocation";
|
||||
import "../MachineList";
|
||||
import "../MachineSwitcher";
|
||||
import "../ProjectList";
|
||||
@@ -69,6 +70,8 @@ export class AppNavigationPanel extends LitElement {
|
||||
@property({ attribute: false }) onDeleteArchivedSession?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onDeleteArchivedSessions?: (sessions: SessionInfo[]) => void | Promise<void>;
|
||||
@property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) parentSessionLocation?: (session: SessionInfo) => ParentSessionLocation;
|
||||
@property({ attribute: false }) onGoToParentSession?: (session: SessionInfo, location: ParentSessionLocation) => void | Promise<void>;
|
||||
@property({ attribute: false }) onMarkSessionRead?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onMarkSessionsRead?: (sessions: SessionInfo[]) => void | Promise<void>;
|
||||
@property({ attribute: false }) onReloadSession?: (session: SessionInfo) => void | Promise<void>;
|
||||
@@ -193,6 +196,8 @@ export class AppNavigationPanel extends LitElement {
|
||||
.onDeleteArchived=${(session: SessionInfo) => this.onDeleteArchivedSession?.(session)}
|
||||
.onDeleteArchivedMany=${(sessions: SessionInfo[]) => this.onDeleteArchivedSessions?.(sessions)}
|
||||
.onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)}
|
||||
.parentLocation=${this.parentSessionLocation ?? unknownParentSessionLocation}
|
||||
.onGoToParent=${this.onGoToParentSession === undefined ? undefined : (session: SessionInfo, location: ParentSessionLocation) => this.onGoToParentSession?.(session, location)}
|
||||
.onMarkRead=${(session: SessionInfo) => this.onMarkSessionRead?.(session)}
|
||||
.onMarkReadMany=${(sessions: SessionInfo[]) => this.onMarkSessionsRead?.(sessions)}
|
||||
.onReload=${(session: SessionInfo) => this.onReloadSession?.(session)}
|
||||
@@ -248,6 +253,9 @@ export class AppNavigationPanel extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/** Stable default so the session list does not see a new resolver identity each render. */
|
||||
const unknownParentSessionLocation = (): ParentSessionLocation => ({ kind: "unknown" });
|
||||
|
||||
export function shouldShowMachinesSection(machines: readonly Machine[]): boolean {
|
||||
return machines.length > 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionInfo } from "../api";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { childElsewhereCountPatch } from "./sessionController";
|
||||
|
||||
const PARENT_PATH = "/sessions/--srv-dev-pi-web--/parent.jsonl";
|
||||
|
||||
describe("childElsewhereCountPatch", () => {
|
||||
it("starts the count when a first child is created in another workspace", () => {
|
||||
const parent = session("parent", { path: PARENT_PATH });
|
||||
|
||||
const patch = childElsewhereCountPatch(stateWith([parent]), childElsewhere());
|
||||
|
||||
expect(patch?.sessions?.[0]).toMatchObject({ id: "parent", childSessionsElsewhere: 1 });
|
||||
});
|
||||
|
||||
it("increments an existing count so repeated spawns stay accurate", () => {
|
||||
const parent = session("parent", { path: PARENT_PATH, childSessionsElsewhere: 2 });
|
||||
|
||||
const patch = childElsewhereCountPatch(stateWith([parent]), childElsewhere());
|
||||
|
||||
expect(patch?.sessions?.[0]).toMatchObject({ childSessionsElsewhere: 3 });
|
||||
});
|
||||
|
||||
it("leaves other listed sessions untouched", () => {
|
||||
const parent = session("parent", { path: PARENT_PATH });
|
||||
const unrelated = session("unrelated", { path: "/sessions/--srv-dev-pi-web--/unrelated.jsonl" });
|
||||
|
||||
const patch = childElsewhereCountPatch(stateWith([parent, unrelated]), childElsewhere());
|
||||
|
||||
expect(patch?.sessions?.[1]).toBe(unrelated);
|
||||
});
|
||||
|
||||
it("ignores a created session whose parent is not in the current listing", () => {
|
||||
const state = stateWith([session("other", { path: "/sessions/--srv-dev-pi-web--/other.jsonl" })]);
|
||||
|
||||
expect(childElsewhereCountPatch(state, childElsewhere())).toBeUndefined();
|
||||
});
|
||||
|
||||
it("credits the parent when the broadcast path differs only by a trailing separator", () => {
|
||||
// The created session's parentSessionPath comes from the live runtime, while
|
||||
// the listed parent's path comes from the session store enumeration.
|
||||
const parent = session("parent", { path: PARENT_PATH });
|
||||
const created = session("child", { cwd: "/srv/dev/pi-web-feature", parentSessionPath: `${PARENT_PATH}/` });
|
||||
|
||||
const patch = childElsewhereCountPatch(stateWith([parent]), created);
|
||||
|
||||
expect(patch?.sessions?.[0]).toMatchObject({ childSessionsElsewhere: 1 });
|
||||
});
|
||||
|
||||
it("ignores a created root session, which has no parent to credit", () => {
|
||||
const parent = session("parent", { path: PARENT_PATH });
|
||||
const root = session("root", { cwd: "/srv/dev/pi-web-feature" });
|
||||
delete root.parentSessionPath;
|
||||
|
||||
expect(childElsewhereCountPatch(stateWith([parent]), root)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function stateWith(sessions: SessionInfo[]): AppState {
|
||||
return { ...initialAppState(), sessions };
|
||||
}
|
||||
|
||||
function childElsewhere(): SessionInfo {
|
||||
return session("child", { cwd: "/srv/dev/pi-web-feature", parentSessionPath: PARENT_PATH });
|
||||
}
|
||||
|
||||
function session(id: string, overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
return {
|
||||
id,
|
||||
path: `/sessions/${id}.jsonl`,
|
||||
cwd: "/srv/dev/pi-web",
|
||||
created: "2026-07-28T00:00:00.000Z",
|
||||
modified: "2026-07-28T00:00:00.000Z",
|
||||
messageCount: 1,
|
||||
firstMessage: id,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/ca
|
||||
import type { PromptAttachmentDelivery, SessionNotificationInboxEvent, SessionStartupProgressEvent } from "../../../shared/apiTypes";
|
||||
import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection";
|
||||
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
|
||||
import { sessionPathsEqual } from "../sessionPaths";
|
||||
import { TrailingRefreshCoordinator } from "./trailingRefreshCoordinator";
|
||||
|
||||
const MESSAGE_PAGE_SIZE = 100;
|
||||
@@ -1236,10 +1237,14 @@ export class SessionController {
|
||||
|
||||
private applyCreatedSession(session: SessionInfo) {
|
||||
const state = this.getState();
|
||||
// Only surface sessions for the workspace currently in view; others are
|
||||
// picked up when their workspace is opened. Skip if already present (e.g.
|
||||
// the optimistic insert from startSession in this same tab).
|
||||
if (state.selectedWorkspace?.path !== session.cwd) return;
|
||||
// A session created in another workspace is not listed here, but it may be a
|
||||
// child of one that is: keep that parent's cross-workspace child count live
|
||||
// instead of leaving it stale until the next listing.
|
||||
if (state.selectedWorkspace?.path !== session.cwd) {
|
||||
const patch = childElsewhereCountPatch(state, session);
|
||||
if (patch !== undefined) this.setState(patch);
|
||||
return;
|
||||
}
|
||||
if (state.sessions.some((candidate) => candidate.id === session.id)) return;
|
||||
const machineId = selectedMachineId(state);
|
||||
if (this.hasPendingStartFor(session.cwd, machineId)) {
|
||||
@@ -1633,6 +1638,27 @@ function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* State patch incrementing the cross-workspace child count of the listed parent
|
||||
* of `createdSession`, or undefined when that parent is not in view.
|
||||
*
|
||||
* Exported for testing: it encodes when a session created in another workspace
|
||||
* is relevant to the current listing at all.
|
||||
*/
|
||||
export function childElsewhereCountPatch(state: AppState, createdSession: SessionInfo): Pick<Partial<AppState>, "sessions"> | undefined {
|
||||
const parentPath = createdSession.parentSessionPath;
|
||||
if (parentPath === undefined || parentPath === "") return undefined;
|
||||
// The created session's parent path and the listed session's own path reach the
|
||||
// browser from different server producers, so they are compared tolerantly.
|
||||
const isParent = (candidate: SessionInfo) => sessionPathsEqual(candidate.path, parentPath);
|
||||
if (!state.sessions.some(isParent)) return undefined;
|
||||
return {
|
||||
sessions: state.sessions.map((candidate) => isParent(candidate)
|
||||
? { ...candidate, childSessionsElsewhere: (candidate.childSessionsElsewhere ?? 0) + 1 }
|
||||
: candidate),
|
||||
};
|
||||
}
|
||||
|
||||
function sessionMessageCountPatch(state: AppState, sessionId: string, messageCount: number | undefined): Pick<Partial<AppState>, "sessions" | "selectedSession"> {
|
||||
if (messageCount === undefined) return {};
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Project, SessionInfo, Workspace } from "./api";
|
||||
import { parentSessionLocationLabel, parentSessionLocationTitle, resolveParentSessionLocation } from "./parentSessionLocation";
|
||||
|
||||
describe("resolveParentSessionLocation", () => {
|
||||
it("names and targets a parent living in a sibling worktree of the selected project", () => {
|
||||
const session = child({ parentSessionCwd: "/srv/dev/pi-web-feature", parentSessionId: "parent-id" });
|
||||
|
||||
const location = resolveParentSessionLocation(session, sources({ workspaces: [mainWorkspace, featureWorkspace] }));
|
||||
|
||||
expect(location).toEqual({
|
||||
kind: "workspace",
|
||||
label: "feature/parent-links",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-feature",
|
||||
sessionId: "parent-id",
|
||||
cwd: "/srv/dev/pi-web-feature",
|
||||
});
|
||||
});
|
||||
|
||||
it("qualifies a parent in another loaded project with that project's name", () => {
|
||||
const otherWorkspace: Workspace = { ...featureWorkspace, id: "workspace-other", projectId: "project-2", path: "/srv/dev/other", branch: "main" };
|
||||
const session = child({ parentSessionCwd: "/srv/dev/other", parentSessionId: "parent-id" });
|
||||
|
||||
const location = resolveParentSessionLocation(session, sources({
|
||||
workspaces: [mainWorkspace],
|
||||
workspacesByProjectId: { "project-2": [otherWorkspace] },
|
||||
projects: [{ id: "project-2", name: "other-project", path: "/srv/dev/other", createdAt: "2026-07-28T00:00:00.000Z" }],
|
||||
}));
|
||||
|
||||
expect(location).toMatchObject({ kind: "workspace", label: "other-project · main", projectId: "project-2", workspaceId: "workspace-other" });
|
||||
});
|
||||
|
||||
it("falls back to a shortened path when the parent cwd belongs to no loaded workspace", () => {
|
||||
const session = child({ parentSessionCwd: "/srv/dev/unknown/deeply/nested", parentSessionId: "parent-id" });
|
||||
|
||||
const location = resolveParentSessionLocation(session, sources({ workspaces: [mainWorkspace] }));
|
||||
|
||||
expect(location).toEqual({ kind: "path", label: "…/deeply/nested", cwd: "/srv/dev/unknown/deeply/nested" });
|
||||
});
|
||||
|
||||
it("reports unknown when the server sent no parent cwd", () => {
|
||||
const location = resolveParentSessionLocation(child({}), sources({ workspaces: [mainWorkspace, featureWorkspace] }));
|
||||
|
||||
expect(location).toEqual({ kind: "unknown" });
|
||||
});
|
||||
|
||||
it("matches workspace paths that differ only by a trailing separator", () => {
|
||||
const session = child({ parentSessionCwd: "/srv/dev/pi-web-feature", parentSessionId: "parent-id" });
|
||||
|
||||
const location = resolveParentSessionLocation(session, sources({ workspaces: [{ ...featureWorkspace, path: "/srv/dev/pi-web-feature/" }] }));
|
||||
|
||||
expect(location).toMatchObject({ kind: "workspace", workspaceId: "workspace-feature" });
|
||||
});
|
||||
|
||||
it("prefers the selected project's workspaces over another project with the same path", () => {
|
||||
const session = child({ parentSessionCwd: "/srv/dev/pi-web-feature", parentSessionId: "parent-id" });
|
||||
|
||||
const location = resolveParentSessionLocation(session, sources({
|
||||
workspaces: [featureWorkspace],
|
||||
workspacesByProjectId: { "project-2": [{ ...featureWorkspace, id: "workspace-duplicate", projectId: "project-2" }] },
|
||||
}));
|
||||
|
||||
expect(location).toMatchObject({ kind: "workspace", workspaceId: "workspace-feature", label: "feature/parent-links" });
|
||||
});
|
||||
|
||||
it("labels a detached parent workspace by its workspace label when it has no branch", () => {
|
||||
const detached: Workspace = { ...featureWorkspace, label: "detached" };
|
||||
delete detached.branch;
|
||||
const session = child({ parentSessionCwd: "/srv/dev/pi-web-feature", parentSessionId: "parent-id" });
|
||||
|
||||
const location = resolveParentSessionLocation(session, sources({ workspaces: [detached] }));
|
||||
|
||||
expect(location).toMatchObject({ kind: "workspace", label: "detached" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parentSessionLocationLabel", () => {
|
||||
it("names the parent's workspace for a resolved location", () => {
|
||||
expect(parentSessionLocationLabel({ kind: "path", label: "…/deeply/nested", cwd: "/srv/dev/unknown/deeply/nested" }))
|
||||
.toBe("parent in …/deeply/nested");
|
||||
});
|
||||
|
||||
it("keeps the generic wording when nothing is known about the parent", () => {
|
||||
expect(parentSessionLocationLabel({ kind: "unknown" })).toBe("parent unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parentSessionLocationTitle", () => {
|
||||
it("includes the full parent path so the row tooltip stays precise", () => {
|
||||
const title = parentSessionLocationTitle({
|
||||
kind: "workspace",
|
||||
label: "feature/parent-links",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-feature",
|
||||
sessionId: "parent-id",
|
||||
cwd: "/srv/dev/pi-web-feature",
|
||||
});
|
||||
|
||||
expect(title).toBe("Parent session is in feature/parent-links (/srv/dev/pi-web-feature)");
|
||||
});
|
||||
|
||||
it("explains an unknown parent without inventing a location", () => {
|
||||
expect(parentSessionLocationTitle({ kind: "unknown" })).toBe("Parent session is not available in this workspace");
|
||||
});
|
||||
});
|
||||
|
||||
const mainWorkspace: Workspace = {
|
||||
id: "workspace-main",
|
||||
projectId: "project-1",
|
||||
path: "/srv/dev/pi-web",
|
||||
label: "main",
|
||||
branch: "main",
|
||||
isMain: true,
|
||||
isGitRepo: true,
|
||||
isGitWorktree: true,
|
||||
};
|
||||
|
||||
const featureWorkspace: Workspace = {
|
||||
id: "workspace-feature",
|
||||
projectId: "project-1",
|
||||
path: "/srv/dev/pi-web-feature",
|
||||
label: "feature/parent-links",
|
||||
branch: "feature/parent-links",
|
||||
isMain: false,
|
||||
isGitRepo: true,
|
||||
isGitWorktree: true,
|
||||
};
|
||||
|
||||
function child(parent: { parentSessionCwd?: string; parentSessionId?: string }): SessionInfo {
|
||||
return {
|
||||
id: "child-id",
|
||||
path: "/sessions/--srv-dev-pi-web--/child.jsonl",
|
||||
cwd: "/srv/dev/pi-web",
|
||||
created: "2026-07-28T00:00:00.000Z",
|
||||
modified: "2026-07-28T00:00:00.000Z",
|
||||
messageCount: 3,
|
||||
firstMessage: "do the thing",
|
||||
parentSessionPath: "/sessions/--srv-dev-pi-web-feature--/parent.jsonl",
|
||||
...(parent.parentSessionCwd === undefined ? {} : { parentSessionCwd: parent.parentSessionCwd }),
|
||||
...(parent.parentSessionId === undefined ? {} : { parentSessionId: parent.parentSessionId }),
|
||||
};
|
||||
}
|
||||
|
||||
function sources(overrides: {
|
||||
workspaces?: readonly Workspace[];
|
||||
workspacesByProjectId?: Record<string, readonly Workspace[]>;
|
||||
projects?: readonly Project[];
|
||||
}) {
|
||||
return {
|
||||
workspaces: overrides.workspaces ?? [],
|
||||
workspacesByProjectId: overrides.workspacesByProjectId ?? {},
|
||||
projects: overrides.projects ?? [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Project, SessionInfo, Workspace } from "./api";
|
||||
import { normalizeSessionPath } from "./sessionPaths";
|
||||
|
||||
/**
|
||||
* Where a session's parent lives, when the parent is not in the current
|
||||
* workspace's session list.
|
||||
*
|
||||
* - `workspace`: the parent's cwd matches a workspace PI WEB knows about, so the
|
||||
* browser can both name it and navigate to it.
|
||||
* - `path`: the parent's cwd is known but belongs to no loaded workspace (for
|
||||
* example a project that is not open); it can be named but not navigated to.
|
||||
* - `unknown`: no parent cwd was reported, so nothing beyond "unavailable" can
|
||||
* be said.
|
||||
*/
|
||||
export type ParentSessionLocation =
|
||||
| { kind: "workspace"; label: string; projectId: string; workspaceId: string; sessionId: string | undefined; cwd: string }
|
||||
| { kind: "path"; label: string; cwd: string }
|
||||
| { kind: "unknown" };
|
||||
|
||||
export interface ParentSessionLocationSources {
|
||||
/** Workspaces of the currently selected project. */
|
||||
workspaces: readonly Workspace[];
|
||||
/** Workspaces of every project loaded so far, keyed by project id. */
|
||||
workspacesByProjectId: Readonly<Record<string, readonly Workspace[]>>;
|
||||
projects: readonly Project[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a display label and navigation target for a session whose parent is
|
||||
* missing from the current list.
|
||||
*
|
||||
* The selected project's workspaces are searched first: `spawn_subsession`
|
||||
* constrains children to workspaces of the spawning session's own project, so
|
||||
* that lookup succeeds for the case this exists to explain — a child spawned
|
||||
* into a sibling worktree.
|
||||
*/
|
||||
export function resolveParentSessionLocation(session: SessionInfo, sources: ParentSessionLocationSources): ParentSessionLocation {
|
||||
const parentCwd = session.parentSessionCwd;
|
||||
if (parentCwd === undefined || parentCwd === "") return { kind: "unknown" };
|
||||
|
||||
const currentProjectMatch = findWorkspaceByPath(sources.workspaces, parentCwd);
|
||||
if (currentProjectMatch !== undefined) {
|
||||
return workspaceLocation(currentProjectMatch, session.parentSessionId, undefined);
|
||||
}
|
||||
|
||||
for (const [projectId, workspaces] of Object.entries(sources.workspacesByProjectId)) {
|
||||
const match = findWorkspaceByPath(workspaces, parentCwd);
|
||||
if (match === undefined) continue;
|
||||
const projectName = sources.projects.find((project) => project.id === projectId)?.name;
|
||||
return workspaceLocation(match, session.parentSessionId, projectName);
|
||||
}
|
||||
|
||||
return { kind: "path", label: shortenPath(parentCwd), cwd: parentCwd };
|
||||
}
|
||||
|
||||
/** Short user-facing text for the row indicator, e.g. `parent in feature/foo`. */
|
||||
export function parentSessionLocationLabel(location: ParentSessionLocation): string {
|
||||
return location.kind === "unknown" ? "parent unavailable" : `parent in ${location.label}`;
|
||||
}
|
||||
|
||||
/** Full detail for the row tooltip, where the whole path is useful. */
|
||||
export function parentSessionLocationTitle(location: ParentSessionLocation): string {
|
||||
switch (location.kind) {
|
||||
case "workspace": return `Parent session is in ${location.label} (${location.cwd})`;
|
||||
case "path": return `Parent session is in ${location.cwd}`;
|
||||
case "unknown": return "Parent session is not available in this workspace";
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceLocation(workspace: Workspace, sessionId: string | undefined, projectName: string | undefined): ParentSessionLocation {
|
||||
const workspaceLabel = workspace.branch ?? workspace.label;
|
||||
return {
|
||||
kind: "workspace",
|
||||
label: projectName === undefined ? workspaceLabel : `${projectName} · ${workspaceLabel}`,
|
||||
projectId: workspace.projectId,
|
||||
workspaceId: workspace.id,
|
||||
sessionId,
|
||||
cwd: workspace.path,
|
||||
};
|
||||
}
|
||||
|
||||
function findWorkspaceByPath(workspaces: readonly Workspace[], cwd: string): Workspace | undefined {
|
||||
const target = normalizeSessionPath(cwd);
|
||||
return workspaces.find((workspace) => normalizeSessionPath(workspace.path) === target);
|
||||
}
|
||||
|
||||
function shortenPath(path: string): string {
|
||||
const segments = normalizeSessionPath(path).split(/[/\\]/u).filter((segment) => segment !== "");
|
||||
const tail = segments.slice(-2);
|
||||
if (tail.length === 0) return path;
|
||||
return segments.length > tail.length ? `…/${tail.join("/")}` : tail.join("/");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Path comparison for session and workspace paths inside the browser.
|
||||
*
|
||||
* These paths are absolute and produced by the server, but not by a single code
|
||||
* path: a listed session's `path` comes from Pi's session store enumeration,
|
||||
* while the `parentSessionPath` on a `session.created` broadcast comes from the
|
||||
* live runtime's session file. Comparing them with `===` therefore depends on
|
||||
* two independent producers agreeing on trailing separators, which is exactly
|
||||
* the kind of coincidence that breaks silently.
|
||||
*
|
||||
* Node's `path` module is not available here, so this normalizes the only
|
||||
* difference that can realistically appear between two server-produced absolute
|
||||
* paths: trailing separators. It deliberately does not resolve `.`/`..` or
|
||||
* symlinks, which the server already handles before values reach the browser.
|
||||
*/
|
||||
export function normalizeSessionPath(path: string): string {
|
||||
return path.replace(/[/\\]+$/u, "");
|
||||
}
|
||||
|
||||
/** Whether two server-produced absolute paths refer to the same location. */
|
||||
export function sessionPathsEqual(a: string, b: string): boolean {
|
||||
return normalizeSessionPath(a) === normalizeSessionPath(b);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
|
||||
import { SessionNotificationStore } from "./sessions/sessionNotificationStore.js";
|
||||
import { FileSessionUnreadPersistence, SessionUnreadStore } from "./sessions/sessionUnreadStore.js";
|
||||
import { ProjectScopedSpawnTargetResolver } from "./sessions/spawnTargetResolver.js";
|
||||
import { RegisteredProjectWorkspaceCwds } from "./workspaces/projectWorkspaceCwds.js";
|
||||
import { ProjectService } from "./projects/projectService.js";
|
||||
import { ProjectStore } from "./storage/projectStore.js";
|
||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
@@ -68,15 +69,19 @@ await runSessionDaemonStartup({
|
||||
});
|
||||
catalogRefresher.start();
|
||||
auth.subscribe(() => { catalogRefresher.requestRefresh(); });
|
||||
const spawnTargets = config.spawnSessions
|
||||
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
|
||||
: undefined;
|
||||
// Cross-workspace session relationships are reported regardless of whether
|
||||
// agents may spawn sessions: children can predate a config change, and the
|
||||
// session tree should stay honest about them either way.
|
||||
const projectWorkspaceDeps = { projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() };
|
||||
const projectWorkspaces = new RegisteredProjectWorkspaceCwds(projectWorkspaceDeps);
|
||||
const spawnTargets = config.spawnSessions ? new ProjectScopedSpawnTargetResolver(projectWorkspaceDeps) : undefined;
|
||||
const sessions = new PiSessionService(eventHub, sessionServiceDependencies({
|
||||
modelRuntime: auth.runtime,
|
||||
agentDir: activeAgentProfile.dir,
|
||||
workspaceActivity,
|
||||
logger: app.log,
|
||||
...(spawnTargets === undefined ? {} : { spawnTargets }),
|
||||
projectWorkspaces,
|
||||
subsessionsEnabled: config.subsessions,
|
||||
askUserEnabled: config.askUser,
|
||||
notificationStore,
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface SessionServiceDependencyInput {
|
||||
catalogRefreshStatus: NonNullable<PiSessionServiceDependencies["catalogRefreshStatus"]>;
|
||||
/** Omitted when the operator has not enabled session spawning. */
|
||||
spawnTargets?: NonNullable<PiSessionServiceDependencies["spawnTargets"]>;
|
||||
projectWorkspaces?: NonNullable<PiSessionServiceDependencies["projectWorkspaces"]>;
|
||||
/** The operator's subsessions preference, which also requires spawning. */
|
||||
subsessionsEnabled: boolean;
|
||||
/** Whether agents may post structured question sets to the browser. */
|
||||
@@ -42,6 +43,7 @@ export function sessionServiceDependencies(input: SessionServiceDependencyInput)
|
||||
workspaceActivity: input.workspaceActivity,
|
||||
logger: input.logger,
|
||||
...(input.spawnTargets === undefined ? {} : { spawnTargets: input.spawnTargets }),
|
||||
...(input.projectWorkspaces === undefined ? {} : { projectWorkspaces: input.projectWorkspaces }),
|
||||
// Tracked subsessions share the spawn capability's project-scope resolver,
|
||||
// so they stay off unless spawning is configured too.
|
||||
subsessionsEnabled: input.spawnTargets !== undefined && input.subsessionsEnabled,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { countOutOfListingChildren, locateOutOfListingParents } from "./parentSessionLocator.js";
|
||||
import type { SessionHeaderSummary } from "./sessionFileHeader.js";
|
||||
|
||||
const PARENT_PATH = "/sessions/--srv-other--/parent.jsonl";
|
||||
const LISTING_CWD = "/srv/dev/pi-web";
|
||||
const PARENT_CWD = "/srv/other-worktree";
|
||||
|
||||
describe("locateOutOfListingParents", () => {
|
||||
it("reports the cwd and id of a parent that is not in the listing", async () => {
|
||||
const readHeader = headerReader({ [PARENT_PATH]: { id: "parent-id", cwd: PARENT_CWD } });
|
||||
|
||||
const located = await locateOutOfListingParents([child(PARENT_PATH)], LISTING_CWD, readHeader);
|
||||
|
||||
expect(located.get(PARENT_PATH)).toEqual({ parentSessionId: "parent-id", parentSessionCwd: PARENT_CWD });
|
||||
});
|
||||
|
||||
it("does not read headers for parents already present in the listing", async () => {
|
||||
const readHeader = headerReader({});
|
||||
|
||||
const located = await locateOutOfListingParents([{ path: PARENT_PATH }, child(PARENT_PATH)], LISTING_CWD, readHeader);
|
||||
|
||||
expect(located.size).toBe(0);
|
||||
expect(readHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads each distinct missing parent once even when several children share it", async () => {
|
||||
const readHeader = headerReader({ [PARENT_PATH]: { id: "parent-id", cwd: PARENT_CWD } });
|
||||
|
||||
await locateOutOfListingParents([child(PARENT_PATH), child(PARENT_PATH), child(PARENT_PATH)], LISTING_CWD, readHeader);
|
||||
|
||||
expect(readHeader).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("omits parents whose header cannot be read, so callers keep the generic unavailable state", async () => {
|
||||
const readHeader = headerReader({});
|
||||
|
||||
const located = await locateOutOfListingParents([child(PARENT_PATH)], LISTING_CWD, readHeader);
|
||||
|
||||
expect(located.size).toBe(0);
|
||||
});
|
||||
|
||||
it("omits parents whose header carries no cwd, as in very old session files", async () => {
|
||||
const readHeader = headerReader({ [PARENT_PATH]: { id: "parent-id" } });
|
||||
|
||||
const located = await locateOutOfListingParents([child(PARENT_PATH)], LISTING_CWD, readHeader);
|
||||
|
||||
expect(located.size).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores sessions without a recorded parent", async () => {
|
||||
const readHeader = headerReader({});
|
||||
|
||||
const located = await locateOutOfListingParents([{ path: "/sessions/root.jsonl" }], LISTING_CWD, readHeader);
|
||||
|
||||
expect(located.size).toBe(0);
|
||||
expect(readHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("locateOutOfListingParents cwd comparison", () => {
|
||||
it("omits a parent that resolves to the listing's own cwd, which needs no jump target", async () => {
|
||||
const readHeader = headerReader({ [PARENT_PATH]: { id: "parent-id", cwd: LISTING_CWD } });
|
||||
|
||||
const located = await locateOutOfListingParents([child(PARENT_PATH)], LISTING_CWD, readHeader);
|
||||
|
||||
expect(located.size).toBe(0);
|
||||
});
|
||||
|
||||
it("treats a cwd differing only by a trailing separator as the same workspace", async () => {
|
||||
const readHeader = headerReader({ [PARENT_PATH]: { id: "parent-id", cwd: `${LISTING_CWD}/` } });
|
||||
|
||||
const located = await locateOutOfListingParents([child(PARENT_PATH)], LISTING_CWD, readHeader);
|
||||
|
||||
expect(located.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countOutOfListingChildren", () => {
|
||||
it("counts children in other workspaces per listed parent session", () => {
|
||||
const parentA = { path: "/sessions/--srv-dev--/parent-a.jsonl" };
|
||||
const parentB = { path: "/sessions/--srv-dev--/parent-b.jsonl" };
|
||||
|
||||
const counts = countOutOfListingChildren([parentA, parentB], [parentA.path, parentA.path, parentB.path]);
|
||||
|
||||
expect(counts.get(parentA.path)).toBe(2);
|
||||
expect(counts.get(parentB.path)).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores children pointing at sessions that are not in the listing", () => {
|
||||
const counts = countOutOfListingChildren(
|
||||
[{ path: "/sessions/--srv-dev--/listed.jsonl" }],
|
||||
["/sessions/--srv-other--/unlisted.jsonl"],
|
||||
);
|
||||
|
||||
expect(counts.size).toBe(0);
|
||||
});
|
||||
|
||||
it("matches parent paths that differ only by normalization", () => {
|
||||
const counts = countOutOfListingChildren(
|
||||
[{ path: "/sessions/--srv-dev--/parent.jsonl" }],
|
||||
["/sessions/--srv-dev--/./parent.jsonl"],
|
||||
);
|
||||
|
||||
expect(counts.get("/sessions/--srv-dev--/parent.jsonl")).toBe(1);
|
||||
});
|
||||
|
||||
it("reports nothing when no sessions elsewhere claim a parent", () => {
|
||||
expect(countOutOfListingChildren([{ path: "/sessions/a.jsonl" }], []).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function child(parentSessionPath: string) {
|
||||
return { path: `/sessions/--srv-dev--/child-${Math.random().toString(36).slice(2)}.jsonl`, parentSessionPath };
|
||||
}
|
||||
|
||||
function headerReader(headers: Record<string, SessionHeaderSummary>) {
|
||||
return vi.fn((sessionFile: string) => Promise.resolve(headers[sessionFile]));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
||||
import type { SessionHeaderSummary } from "./sessionFileHeader.js";
|
||||
|
||||
/** Reads a session file header; injected so locating parents is testable without a filesystem. */
|
||||
export type SessionHeaderReader = (sessionFile: string) => Promise<SessionHeaderSummary | undefined>;
|
||||
|
||||
/** The subset of a listed session this locator needs. */
|
||||
export interface ParentLocatableSession {
|
||||
path: string;
|
||||
parentSessionPath?: string;
|
||||
}
|
||||
|
||||
/** Where an out-of-listing parent session lives, as far as its file header reveals. */
|
||||
export interface ParentSessionLocation {
|
||||
parentSessionId: string;
|
||||
parentSessionCwd: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the parents of `sessions` that live in a different working directory,
|
||||
* keyed by the `parentSessionPath` the child recorded.
|
||||
*
|
||||
* Sessions are listed per working directory, so a child spawned into another
|
||||
* worktree of the same project has a `parentSessionPath` that resolves to
|
||||
* nothing in its own list. The parent's cwd and id are recorded in the parent
|
||||
* session file's header, so one small header read per distinct missing parent is
|
||||
* enough to point at it — no cross-workspace listing required.
|
||||
*
|
||||
* Parents already present in `sessions` are skipped without a read: they are
|
||||
* linkable by path already, so the IO would buy nothing. Headers for a given
|
||||
* path are immutable in practice, so `readHeader` is free to cache; this
|
||||
* function stays stateless.
|
||||
*/
|
||||
export async function locateOutOfListingParents(
|
||||
sessions: readonly ParentLocatableSession[],
|
||||
listingCwd: string,
|
||||
readHeader: SessionHeaderReader,
|
||||
): Promise<Map<string, ParentSessionLocation>> {
|
||||
const located = new Map<string, ParentSessionLocation>();
|
||||
for (const parentSessionPath of missingParentPaths(sessions)) {
|
||||
const location = await parentLocation(parentSessionPath, readHeader);
|
||||
// A parent sharing the listing's cwd is not "elsewhere": it is absent for
|
||||
// some other reason (archived and moved, or simply not listed), and offering
|
||||
// a jump to this same workspace would not help.
|
||||
if (location === undefined || cwdPathsEqual(location.parentSessionCwd, listingCwd)) continue;
|
||||
located.set(parentSessionPath, location);
|
||||
}
|
||||
return located;
|
||||
}
|
||||
|
||||
/** Distinct recorded parent paths that no session in the listing occupies. */
|
||||
function missingParentPaths(sessions: readonly ParentLocatableSession[]): Set<string> {
|
||||
const listedPaths = new Set(sessions.map((session) => canonicalizeStoredCwd(session.path)));
|
||||
const missing = new Set<string>();
|
||||
for (const session of sessions) {
|
||||
const parentSessionPath = session.parentSessionPath;
|
||||
if (parentSessionPath === undefined || parentSessionPath === "") continue;
|
||||
if (listedPaths.has(canonicalizeStoredCwd(parentSessionPath))) continue;
|
||||
missing.add(parentSessionPath);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* A parent whose header is unreadable or carries no cwd (very old session files)
|
||||
* has no reportable location, so the browser keeps saying only that the parent is
|
||||
* unavailable.
|
||||
*/
|
||||
async function parentLocation(parentSessionPath: string, readHeader: SessionHeaderReader): Promise<ParentSessionLocation | undefined> {
|
||||
const header = await readHeader(parentSessionPath);
|
||||
if (header?.cwd === undefined) return undefined;
|
||||
return { parentSessionId: header.id, parentSessionCwd: canonicalizeStoredCwd(header.cwd) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Count, per listed session path, how many sessions outside this listing record
|
||||
* it as their parent, so a parent row can show that it has children that are not
|
||||
* visible beneath it.
|
||||
*
|
||||
* Children are identified only by the parent session *file* path they recorded,
|
||||
* which is exactly the link Pi writes into the child header; neither a header
|
||||
* read of the parent nor the child's own location is required.
|
||||
*/
|
||||
export function countOutOfListingChildren(
|
||||
sessions: readonly ParentLocatableSession[],
|
||||
childParentSessionPaths: readonly string[],
|
||||
): Map<string, number> {
|
||||
const listedPaths = new Map(sessions.map((session) => [canonicalizeStoredCwd(session.path), session.path]));
|
||||
const counts = new Map<string, number>();
|
||||
for (const parentSessionPath of childParentSessionPaths) {
|
||||
const listedPath = listedPaths.get(canonicalizeStoredCwd(parentSessionPath));
|
||||
if (listedPath === undefined) continue;
|
||||
counts.set(listedPath, (counts.get(listedPath) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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 { PiSessionService, type PiSessionListEntry } from "./piSessionService.js";
|
||||
import { CapturingSessionEventHub, emptyArchiveStore, fakeSessionManager, sessionRecord, testModelRuntime, type SessionGateway } from "./piSessionService.testSupport.js";
|
||||
|
||||
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||
const CHILD_CWD = "/srv/dev/pi-web";
|
||||
const PARENT_CWD = "/srv/dev/pi-web-feature";
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "pi-web-parent-location-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("PiSessionService.list parent locations", () => {
|
||||
it("reports the cwd and id of a parent living in another worktree", async () => {
|
||||
const parentFile = await parentSessionFile({ id: "parent-id", cwd: PARENT_CWD });
|
||||
const service = serviceListing([childRecord(parentFile)]);
|
||||
|
||||
const [child] = await service.list(CHILD_CWD);
|
||||
|
||||
expect(child).toMatchObject({ id: "child", parentSessionCwd: PARENT_CWD, parentSessionId: "parent-id" });
|
||||
});
|
||||
|
||||
it("leaves sessions untouched when the parent is in the same workspace listing", async () => {
|
||||
const parent = sessionRecord("parent", CHILD_CWD);
|
||||
const child = { ...sessionRecord("child", CHILD_CWD), parentSessionPath: parent.path };
|
||||
const service = serviceListing([parent, child]);
|
||||
|
||||
const listed = await service.list(CHILD_CWD);
|
||||
|
||||
expect(listed.find((session) => session.id === "child")).not.toHaveProperty("parentSessionCwd");
|
||||
});
|
||||
|
||||
it("does not annotate a parent whose file records the same cwd, since it is not elsewhere", async () => {
|
||||
const parentFile = await parentSessionFile({ id: "parent-id", cwd: CHILD_CWD });
|
||||
const service = serviceListing([childRecord(parentFile)]);
|
||||
|
||||
const [child] = await service.list(CHILD_CWD);
|
||||
|
||||
expect(child).not.toHaveProperty("parentSessionCwd");
|
||||
expect(child).toHaveProperty("parentSessionPath", parentFile);
|
||||
});
|
||||
|
||||
it("still lists a child whose parent file is gone, without location fields", async () => {
|
||||
const service = serviceListing([childRecord(join(tempDir, "deleted-parent.jsonl"))]);
|
||||
|
||||
const [child] = await service.list(CHILD_CWD);
|
||||
|
||||
expect(child).toMatchObject({ id: "child" });
|
||||
expect(child).not.toHaveProperty("parentSessionCwd");
|
||||
expect(child).not.toHaveProperty("parentSessionId");
|
||||
});
|
||||
|
||||
it("reads each parent header only once across repeated listings", async () => {
|
||||
const parentFile = await parentSessionFile({ id: "parent-id", cwd: PARENT_CWD });
|
||||
const service = serviceListing([childRecord(parentFile)]);
|
||||
await service.list(CHILD_CWD);
|
||||
|
||||
// A cached header keeps the annotation after the file is removed, proving no
|
||||
// second read happened for the same path.
|
||||
await rm(parentFile);
|
||||
const [child] = await service.list(CHILD_CWD);
|
||||
|
||||
expect(child).toMatchObject({ parentSessionCwd: PARENT_CWD, parentSessionId: "parent-id" });
|
||||
});
|
||||
|
||||
it("releases cached headers on dispose so the cache cannot outlive the service", async () => {
|
||||
const parentFile = await parentSessionFile({ id: "parent-id", cwd: PARENT_CWD });
|
||||
const service = serviceListing([childRecord(parentFile)]);
|
||||
await service.list(CHILD_CWD);
|
||||
|
||||
await service.dispose();
|
||||
await rm(parentFile);
|
||||
const [child] = await service.list(CHILD_CWD);
|
||||
|
||||
expect(child).not.toHaveProperty("parentSessionCwd");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PiSessionService.list children in sibling workspaces", () => {
|
||||
it("counts children that live in other workspaces of the same project", async () => {
|
||||
const parent = sessionRecord("parent", CHILD_CWD);
|
||||
const service = serviceListing({
|
||||
[CHILD_CWD]: [parent],
|
||||
[PARENT_CWD]: [{ ...sessionRecord("child-a", PARENT_CWD), parentSessionPath: parent.path }, { ...sessionRecord("child-b", PARENT_CWD), parentSessionPath: parent.path }],
|
||||
}, [CHILD_CWD, PARENT_CWD]);
|
||||
|
||||
const [listed] = await service.list(CHILD_CWD);
|
||||
|
||||
expect(listed).toMatchObject({ id: "parent", childSessionsElsewhere: 2 });
|
||||
});
|
||||
|
||||
it("does not count children nested in the same workspace listing", async () => {
|
||||
const parent = sessionRecord("parent", CHILD_CWD);
|
||||
const service = serviceListing({
|
||||
[CHILD_CWD]: [parent, { ...sessionRecord("child", CHILD_CWD), parentSessionPath: parent.path }],
|
||||
[PARENT_CWD]: [],
|
||||
}, [CHILD_CWD, PARENT_CWD]);
|
||||
|
||||
const listed = await service.list(CHILD_CWD);
|
||||
|
||||
expect(listed.find((session) => session.id === "parent")).not.toHaveProperty("childSessionsElsewhere");
|
||||
});
|
||||
|
||||
it("skips sibling scanning when the cwd belongs to no registered project", async () => {
|
||||
const parent = sessionRecord("parent", CHILD_CWD);
|
||||
const service = serviceListing({
|
||||
[CHILD_CWD]: [parent],
|
||||
[PARENT_CWD]: [{ ...sessionRecord("child", PARENT_CWD), parentSessionPath: parent.path }],
|
||||
}, undefined);
|
||||
|
||||
const [listed] = await service.list(CHILD_CWD);
|
||||
|
||||
expect(listed).not.toHaveProperty("childSessionsElsewhere");
|
||||
});
|
||||
|
||||
it("still lists sessions when a sibling workspace cannot be listed", async () => {
|
||||
const parent = sessionRecord("parent", CHILD_CWD);
|
||||
const service = serviceListing({ [CHILD_CWD]: [parent] }, [CHILD_CWD, PARENT_CWD], {
|
||||
listCwd: (cwd) => {
|
||||
if (cwd === PARENT_CWD) throw new Error("sibling workspace is gone");
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const [listed] = await service.list(CHILD_CWD);
|
||||
|
||||
expect(listed).toMatchObject({ id: "parent" });
|
||||
expect(listed).not.toHaveProperty("childSessionsElsewhere");
|
||||
});
|
||||
|
||||
it("reports both an out-of-workspace parent and children elsewhere on one listing", async () => {
|
||||
const grandparentFile = await parentSessionFile({ id: "grandparent-id", cwd: PARENT_CWD });
|
||||
const middle = { ...sessionRecord("middle", CHILD_CWD), parentSessionPath: grandparentFile };
|
||||
const service = serviceListing({
|
||||
[CHILD_CWD]: [middle],
|
||||
[PARENT_CWD]: [{ ...sessionRecord("grandchild", PARENT_CWD), parentSessionPath: middle.path }],
|
||||
}, [CHILD_CWD, PARENT_CWD]);
|
||||
|
||||
const [listed] = await service.list(CHILD_CWD);
|
||||
|
||||
expect(listed).toMatchObject({ id: "middle", parentSessionCwd: PARENT_CWD, parentSessionId: "grandparent-id", childSessionsElsewhere: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
function childRecord(parentSessionPath: string) {
|
||||
return { ...sessionRecord("child", CHILD_CWD), parentSessionPath };
|
||||
}
|
||||
|
||||
async function parentSessionFile(header: { id: string; cwd: string }): Promise<string> {
|
||||
const path = join(tempDir, `${header.id}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ type: "session", version: 3, ...header }),
|
||||
JSON.stringify({ type: "model_change", id: "m1", parentId: null }),
|
||||
];
|
||||
await writeFile(path, `${lines.join("\n")}\n`, "utf8");
|
||||
return path;
|
||||
}
|
||||
|
||||
type SessionRecord = PiSessionListEntry;
|
||||
|
||||
/**
|
||||
* Build a service over per-cwd session listings. `projectCwds` is the workspace
|
||||
* set of the containing project, or undefined to model an unregistered cwd.
|
||||
*/
|
||||
function serviceListing(
|
||||
recordsByCwd: SessionRecord[] | Record<string, SessionRecord[]>,
|
||||
projectCwds?: string[],
|
||||
options: { listCwd?: (cwd: string) => void } = {},
|
||||
): PiSessionService {
|
||||
const listings = Array.isArray(recordsByCwd) ? { [CHILD_CWD]: recordsByCwd } : recordsByCwd;
|
||||
const gateway: SessionGateway = {
|
||||
create: () => fakeSessionManager(),
|
||||
list: (cwd: string) => {
|
||||
options.listCwd?.(cwd);
|
||||
return Promise.resolve(listings[cwd] ?? []);
|
||||
},
|
||||
open: () => fakeSessionManager(),
|
||||
};
|
||||
return new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
archiveStore: emptyArchiveStore(),
|
||||
sessionManager: gateway,
|
||||
heartbeatIntervalMs: 60_000,
|
||||
projectWorkspaces: { forCwd: () => Promise.resolve(projectCwds) },
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { open, readFile, writeFile } from "node:fs/promises";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import type { ImageContent } from "@earendil-works/pi-ai";
|
||||
import type { StreamFn } from "@earendil-works/pi-agent-core";
|
||||
import {
|
||||
@@ -56,6 +56,9 @@ import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from ".
|
||||
|
||||
import { type AuthChange } from "./authService.js";
|
||||
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
||||
import { readSessionHeaderSummary, type SessionHeaderSummary } from "./sessionFileHeader.js";
|
||||
import { countOutOfListingChildren, locateOutOfListingParents, type SessionHeaderReader } from "./parentSessionLocator.js";
|
||||
import { siblingWorkspaceCwds, type ProjectWorkspaceCwds } from "../workspaces/projectWorkspaceCwds.js";
|
||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||
import { createAskUserToolDefinition, type AskUserInvocation, type AskUserToolDeps } from "./askUserTool.js";
|
||||
import { PendingAskStore, renderAskUserAnswersText, type PendingAskCloseResult, type PendingAskOpenResult } from "./pendingAskStore.js";
|
||||
@@ -694,6 +697,13 @@ export interface PiSessionServiceDependencies {
|
||||
* Omit to keep the capability disabled.
|
||||
*/
|
||||
spawnTargets?: SpawnTargetResolver;
|
||||
/**
|
||||
* When provided, session listings report related sessions living in sibling
|
||||
* workspaces of the same project: where an out-of-workspace parent is, and how
|
||||
* many children a listed session has elsewhere. Omit to list each workspace in
|
||||
* isolation.
|
||||
*/
|
||||
projectWorkspaces?: ProjectWorkspaceCwds;
|
||||
/**
|
||||
* Beta: when true (and `spawnTargets` is provided), the tracked-subsession
|
||||
* tools are available to sessions whose creation provenance permits
|
||||
@@ -752,6 +762,8 @@ export class PiSessionService implements SessionRouteService {
|
||||
private readonly subsessionLinks = new Map<string, TrackedSubsessionLink>();
|
||||
/** Parent id/file identities whose persisted links have already been loaded. */
|
||||
private readonly subsessionHydratedParents = new Set<string>();
|
||||
/** Session file path -> its parsed header. Headers are written once, so successful reads are cached. */
|
||||
private readonly sessionHeaderCache = new Map<string, SessionHeaderSummary>();
|
||||
/**
|
||||
* Tracked subsession id -> whether a completion notification is armed.
|
||||
* Armed when the child starts working; firing on completion disarms it so a
|
||||
@@ -766,6 +778,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
private readonly modelRuntime: ModelRuntime;
|
||||
private readonly workspaceActivity: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity"> | undefined;
|
||||
private readonly spawnTargets: SpawnTargetResolver | undefined;
|
||||
private readonly projectWorkspaces: ProjectWorkspaceCwds | undefined;
|
||||
private readonly logger: PiSessionLogger;
|
||||
private readonly now: () => Date;
|
||||
private readonly notificationStore: SessionNotificationStore;
|
||||
@@ -788,6 +801,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
this.sessionManager = deps.sessionManager;
|
||||
this.modelRuntime = deps.modelRuntime;
|
||||
this.spawnTargets = deps.spawnTargets;
|
||||
this.projectWorkspaces = deps.projectWorkspaces;
|
||||
this.logger = deps.logger ?? noopLogger;
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
|
||||
@@ -974,6 +988,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
this.subsessionLinks.clear();
|
||||
this.subsessionHydratedParents.clear();
|
||||
this.subsessionNotifyArmed.clear();
|
||||
this.sessionHeaderCache.clear();
|
||||
this.notificationStore.clearAll("service-dispose");
|
||||
await Promise.all(activeSessions.map(async (active) => {
|
||||
active.unsubscribe();
|
||||
@@ -1008,9 +1023,78 @@ export class PiSessionService implements SessionRouteService {
|
||||
.sort(compareArchivedRecords)
|
||||
.map((record) => clientSessionFromArchivedRecord(record, sessionsById.get(record.sessionId)))
|
||||
.filter(isDefined);
|
||||
return [...unarchivedSessions, ...archivedSessions];
|
||||
return await this.withRelatedSessionsElsewhere([...unarchivedSessions, ...archivedSessions], cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Annotate a listing with the session relationships that cross workspace
|
||||
* boundaries: where an out-of-listing parent lives, and how many children a
|
||||
* listed session has in sibling workspaces.
|
||||
*
|
||||
* Both directions are best-effort. An unreadable parent header or a sibling
|
||||
* workspace that cannot be listed leaves the session unannotated rather than
|
||||
* failing the listing, and the browser falls back to its generic states.
|
||||
*/
|
||||
private async withRelatedSessionsElsewhere(sessions: readonly ClientSession[], cwd: string): Promise<ClientSession[]> {
|
||||
const [parentLocations, childCounts] = await Promise.all([
|
||||
locateOutOfListingParents(sessions, cwd, this.readCachedSessionHeader),
|
||||
this.countChildrenInSiblingWorkspaces(sessions, cwd),
|
||||
]);
|
||||
return sessions.map((session) => {
|
||||
const parent = session.parentSessionPath === undefined ? undefined : parentLocations.get(session.parentSessionPath);
|
||||
const childrenElsewhere = childCounts.get(session.path);
|
||||
if (parent === undefined && childrenElsewhere === undefined) return session;
|
||||
const annotated = { ...session };
|
||||
if (parent !== undefined) {
|
||||
annotated.parentSessionId = parent.parentSessionId;
|
||||
annotated.parentSessionCwd = parent.parentSessionCwd;
|
||||
}
|
||||
if (childrenElsewhere !== undefined) annotated.childSessionsElsewhere = childrenElsewhere;
|
||||
return annotated;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Count children of the listed sessions that live in other workspaces of the
|
||||
* same project.
|
||||
*
|
||||
* Only sibling workspaces are scanned: agents may only spawn into workspaces
|
||||
* of the spawning session's own project, so that bounds where a child can be.
|
||||
* Listing is skipped entirely when no project-workspace locator is configured
|
||||
* or the cwd belongs to no registered project.
|
||||
*/
|
||||
private async countChildrenInSiblingWorkspaces(sessions: readonly ClientSession[], cwd: string): Promise<Map<string, number>> {
|
||||
if (this.projectWorkspaces === undefined || sessions.length === 0) return new Map();
|
||||
try {
|
||||
const siblingCwds = await siblingWorkspaceCwds(this.projectWorkspaces, cwd);
|
||||
if (siblingCwds.length === 0) return new Map();
|
||||
const listings = await Promise.all(siblingCwds.map(async (siblingCwd): Promise<string[]> => {
|
||||
const entries = await this.sessionManager.list(siblingCwd);
|
||||
return entries.flatMap((entry) => entry.parentSessionPath === undefined ? [] : [entry.parentSessionPath]);
|
||||
}));
|
||||
return countOutOfListingChildren(sessions, listings.flat());
|
||||
} catch (error: unknown) {
|
||||
this.logger.info(
|
||||
{ cwd, error: error instanceof Error ? error.message : String(error) },
|
||||
"failed to count child sessions in sibling workspaces",
|
||||
);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a session file header, memoized per path. Pi writes the header once at
|
||||
* session creation, so a successful read stays valid for the process lifetime;
|
||||
* failures are not cached so a session file that appears later is picked up.
|
||||
*/
|
||||
private readonly readCachedSessionHeader: SessionHeaderReader = async (sessionFile) => {
|
||||
const cached = this.sessionHeaderCache.get(sessionFile);
|
||||
if (cached !== undefined) return cached;
|
||||
const header = await readSessionHeaderSummary(sessionFile);
|
||||
if (header !== undefined) this.sessionHeaderCache.set(sessionFile, header);
|
||||
return header;
|
||||
};
|
||||
|
||||
async start(cwd: string, options: StartSessionOptions = {}): Promise<ClientSession> {
|
||||
return this.startSession(cwd, options);
|
||||
}
|
||||
@@ -3626,30 +3710,6 @@ function trackedLinkParentFileMatches(link: TrackedSubsessionLink, parentSession
|
||||
return link.parentSessionFile !== undefined && sessionPathsEqual(link.parentSessionFile, parentSessionFile);
|
||||
}
|
||||
|
||||
interface SessionHeaderSummary {
|
||||
id: string;
|
||||
parentSession?: string;
|
||||
}
|
||||
|
||||
async function readSessionHeaderSummary(sessionFile: string): Promise<SessionHeaderSummary | undefined> {
|
||||
let file: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
file = await open(sessionFile, "r");
|
||||
const buffer = Buffer.alloc(4096);
|
||||
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
||||
const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n", 1)[0];
|
||||
if (firstLine === undefined || firstLine === "") return undefined;
|
||||
const header: unknown = JSON.parse(firstLine);
|
||||
if (!isRecord(header) || header["type"] !== "session" || typeof header["id"] !== "string") return undefined;
|
||||
const parentSession = getString(header, "parentSession");
|
||||
return { id: header["id"], ...(parentSession === undefined ? {} : { parentSession }) };
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
await file?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function sessionFileHeaderMatches(sessionFile: string, expected: { sessionId: string; parentSessionFile?: string | undefined }): Promise<boolean> {
|
||||
const header = await readSessionHeaderSummary(sessionFile);
|
||||
if (header?.id !== expected.sessionId) return false;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { open } from "node:fs/promises";
|
||||
|
||||
/** Bytes read from a session file to parse its single-line JSON header. */
|
||||
const HEADER_READ_BYTES = 4096;
|
||||
|
||||
/**
|
||||
* The header fields PI WEB reads directly from a Pi session file.
|
||||
*
|
||||
* Pi writes this as the first line of the `.jsonl` session file when the
|
||||
* session is created and never rewrites it, except for `parentSession`, which
|
||||
* PI WEB itself can clear when detaching a child. `cwd` and `id` are therefore
|
||||
* safe to treat as immutable for a given path.
|
||||
*/
|
||||
export interface SessionHeaderSummary {
|
||||
id: string;
|
||||
/** Working directory the session was started in. Absent in very old session files. */
|
||||
cwd?: string;
|
||||
/** Session file of the parent session, when this session was spawned or forked from one. */
|
||||
parentSession?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a Pi session file's header without loading the whole transcript.
|
||||
*
|
||||
* Returns undefined for any unreadable, non-JSON, or non-session first line:
|
||||
* callers use this to verify links between sessions, so an unusable header must
|
||||
* behave the same as a missing one rather than throwing.
|
||||
*/
|
||||
export async function readSessionHeaderSummary(sessionFile: string): Promise<SessionHeaderSummary | undefined> {
|
||||
let file: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
file = await open(sessionFile, "r");
|
||||
const buffer = Buffer.alloc(HEADER_READ_BYTES);
|
||||
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
||||
const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n", 1)[0];
|
||||
if (firstLine === undefined || firstLine === "") return undefined;
|
||||
const header: unknown = JSON.parse(firstLine);
|
||||
if (!isRecord(header) || header["type"] !== "session" || typeof header["id"] !== "string") return undefined;
|
||||
const cwd = nonEmptyStringField(header, "cwd");
|
||||
const parentSession = nonEmptyStringField(header, "parentSession");
|
||||
return {
|
||||
id: header["id"],
|
||||
...(cwd === undefined ? {} : { cwd }),
|
||||
...(parentSession === undefined ? {} : { parentSession }),
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
await file?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function nonEmptyStringField(header: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = header[key];
|
||||
return typeof value === "string" && value !== "" ? value : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Project, Workspace } from "../types.js";
|
||||
import { cwdPathsEqual } from "../workingDirectory.js";
|
||||
import { RegisteredProjectWorkspaceCwds, type ProjectWorkspaceCwds, type ProjectWorkspaceCwdsDeps } from "../workspaces/projectWorkspaceCwds.js";
|
||||
|
||||
/**
|
||||
* Decision describing whether a LLM-spawned session may target a given cwd.
|
||||
@@ -33,18 +33,7 @@ export interface SpawnTargetResolver {
|
||||
resolveSpawnTarget(spawningCwd: string, requestedCwd: string | undefined): Promise<SpawnTargetDecision>;
|
||||
}
|
||||
|
||||
interface ProjectLister {
|
||||
list(): Promise<Project[]>;
|
||||
}
|
||||
|
||||
interface WorkspaceLister {
|
||||
list(project: Project): Promise<Workspace[]>;
|
||||
}
|
||||
|
||||
export interface ProjectScopedSpawnTargetResolverDeps {
|
||||
projects: ProjectLister;
|
||||
workspaces: WorkspaceLister;
|
||||
}
|
||||
export type ProjectScopedSpawnTargetResolverDeps = ProjectWorkspaceCwdsDeps;
|
||||
|
||||
/**
|
||||
* Default resolver composing the project registry and live worktree discovery.
|
||||
@@ -52,28 +41,18 @@ export interface ProjectScopedSpawnTargetResolverDeps {
|
||||
* spawning session's cwd, then validates the requested target against that set.
|
||||
*/
|
||||
export class ProjectScopedSpawnTargetResolver implements SpawnTargetResolver {
|
||||
constructor(private readonly deps: ProjectScopedSpawnTargetResolverDeps) {}
|
||||
private readonly projectWorkspaces: ProjectWorkspaceCwds;
|
||||
|
||||
constructor(deps: ProjectScopedSpawnTargetResolverDeps) {
|
||||
this.projectWorkspaces = new RegisteredProjectWorkspaceCwds(deps);
|
||||
}
|
||||
|
||||
async resolveSpawnTarget(spawningCwd: string, requestedCwd: string | undefined): Promise<SpawnTargetDecision> {
|
||||
const allowedCwds = await this.allowedSpawnTargets(spawningCwd);
|
||||
const allowedCwds = await this.projectWorkspaces.forCwd(spawningCwd);
|
||||
if (allowedCwds === undefined) return { allowed: false, reason: "not-registered" };
|
||||
const target = requestedCwd === undefined || requestedCwd === "" ? spawningCwd : requestedCwd;
|
||||
const match = allowedCwds.find((path) => cwdPathsEqual(path, target));
|
||||
if (match === undefined) return { allowed: false, reason: "out-of-project", allowedCwds };
|
||||
return { allowed: true, cwd: match };
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace paths of the registered project that owns `spawningCwd`, or
|
||||
* `undefined` when no registered project contains it.
|
||||
*/
|
||||
private async allowedSpawnTargets(spawningCwd: string): Promise<string[] | undefined> {
|
||||
const projects = await this.deps.projects.list();
|
||||
for (const project of projects) {
|
||||
const workspaces = await this.deps.workspaces.list(project);
|
||||
const paths = workspaces.map((workspace) => workspace.path);
|
||||
if (paths.some((path) => cwdPathsEqual(path, spawningCwd))) return paths;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Project, Workspace } from "../types.js";
|
||||
import { RegisteredProjectWorkspaceCwds, siblingWorkspaceCwds } from "./projectWorkspaceCwds.js";
|
||||
|
||||
describe("RegisteredProjectWorkspaceCwds", () => {
|
||||
it("returns every workspace path of the project containing the cwd", async () => {
|
||||
const locator = new RegisteredProjectWorkspaceCwds(deps({
|
||||
"project-1": ["/srv/dev/pi-web", "/srv/dev/pi-web-feature"],
|
||||
"project-2": ["/srv/dev/other"],
|
||||
}));
|
||||
|
||||
expect(await locator.forCwd("/srv/dev/pi-web-feature")).toEqual(["/srv/dev/pi-web", "/srv/dev/pi-web-feature"]);
|
||||
});
|
||||
|
||||
it("returns undefined when no registered project contains the cwd", async () => {
|
||||
const locator = new RegisteredProjectWorkspaceCwds(deps({ "project-1": ["/srv/dev/pi-web"] }));
|
||||
|
||||
expect(await locator.forCwd("/srv/dev/unregistered")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("matches a cwd that differs from the stored workspace path only by normalization", async () => {
|
||||
const locator = new RegisteredProjectWorkspaceCwds(deps({ "project-1": ["/srv/dev/pi-web"] }));
|
||||
|
||||
expect(await locator.forCwd("/srv/dev/pi-web/")).toEqual(["/srv/dev/pi-web"]);
|
||||
});
|
||||
|
||||
it("stops listing workspaces once the owning project is found", async () => {
|
||||
const workspaceLister = vi.fn((project: Project) => Promise.resolve(workspacesFor(project, ["/srv/dev/pi-web"])));
|
||||
const locator = new RegisteredProjectWorkspaceCwds({
|
||||
projects: { list: () => Promise.resolve([project("project-1"), project("project-2")]) },
|
||||
workspaces: { list: workspaceLister },
|
||||
});
|
||||
|
||||
await locator.forCwd("/srv/dev/pi-web");
|
||||
|
||||
expect(workspaceLister).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("siblingWorkspaceCwds", () => {
|
||||
it("excludes the cwd itself from its project's workspaces", async () => {
|
||||
const locator = new RegisteredProjectWorkspaceCwds(deps({ "project-1": ["/srv/dev/pi-web", "/srv/dev/pi-web-feature"] }));
|
||||
|
||||
expect(await siblingWorkspaceCwds(locator, "/srv/dev/pi-web")).toEqual(["/srv/dev/pi-web-feature"]);
|
||||
});
|
||||
|
||||
it("reports no siblings for a single-workspace project", async () => {
|
||||
const locator = new RegisteredProjectWorkspaceCwds(deps({ "project-1": ["/srv/dev/pi-web"] }));
|
||||
|
||||
expect(await siblingWorkspaceCwds(locator, "/srv/dev/pi-web")).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports no siblings for an unregistered cwd", async () => {
|
||||
const locator = new RegisteredProjectWorkspaceCwds(deps({ "project-1": ["/srv/dev/pi-web"] }));
|
||||
|
||||
expect(await siblingWorkspaceCwds(locator, "/srv/dev/elsewhere")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function deps(pathsByProjectId: Record<string, string[]>) {
|
||||
const projects = Object.keys(pathsByProjectId).map(project);
|
||||
return {
|
||||
projects: { list: () => Promise.resolve(projects) },
|
||||
workspaces: { list: (candidate: Project) => Promise.resolve(workspacesFor(candidate, pathsByProjectId[candidate.id] ?? [])) },
|
||||
};
|
||||
}
|
||||
|
||||
function project(id: string): Project {
|
||||
return { id, name: id, path: `/srv/dev/${id}`, createdAt: "2026-07-28T00:00:00.000Z" };
|
||||
}
|
||||
|
||||
function workspacesFor(owner: Project, paths: string[]): Workspace[] {
|
||||
return paths.map((path, index) => ({
|
||||
id: `${owner.id}-workspace-${String(index)}`,
|
||||
projectId: owner.id,
|
||||
path,
|
||||
label: path,
|
||||
isMain: index === 0,
|
||||
isGitRepo: true,
|
||||
isGitWorktree: true,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Project, Workspace } from "../types.js";
|
||||
import { cwdPathsEqual } from "../workingDirectory.js";
|
||||
|
||||
interface ProjectLister {
|
||||
list(): Promise<Project[]>;
|
||||
}
|
||||
|
||||
interface WorkspaceLister {
|
||||
list(project: Project): Promise<Workspace[]>;
|
||||
}
|
||||
|
||||
export interface ProjectWorkspaceCwdsDeps {
|
||||
projects: ProjectLister;
|
||||
workspaces: WorkspaceLister;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the set of workspace paths that belong to the same registered project
|
||||
* as a given working directory.
|
||||
*
|
||||
* Sessions spawned by an agent are constrained to workspaces of the spawning
|
||||
* session's own project, so this set bounds where related sessions of any cwd
|
||||
* can live. It is evaluated live so a worktree created with `git worktree add`
|
||||
* moments ago is included.
|
||||
*/
|
||||
export interface ProjectWorkspaceCwds {
|
||||
/**
|
||||
* Workspace paths of the registered project containing `cwd`, or undefined
|
||||
* when no registered project contains it. The result includes `cwd` itself.
|
||||
*/
|
||||
forCwd(cwd: string): Promise<string[] | undefined>;
|
||||
}
|
||||
|
||||
export class RegisteredProjectWorkspaceCwds implements ProjectWorkspaceCwds {
|
||||
constructor(private readonly deps: ProjectWorkspaceCwdsDeps) {}
|
||||
|
||||
async forCwd(cwd: string): Promise<string[] | undefined> {
|
||||
const projects = await this.deps.projects.list();
|
||||
for (const project of projects) {
|
||||
const workspaces = await this.deps.workspaces.list(project);
|
||||
const paths = workspaces.map((workspace) => workspace.path);
|
||||
if (paths.some((path) => cwdPathsEqual(path, cwd))) return paths;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Workspace paths of `cwd`'s project other than `cwd` itself. */
|
||||
export async function siblingWorkspaceCwds(locator: ProjectWorkspaceCwds, cwd: string): Promise<string[]> {
|
||||
const paths = await locator.forCwd(cwd);
|
||||
return (paths ?? []).filter((path) => !cwdPathsEqual(path, cwd));
|
||||
}
|
||||
@@ -350,6 +350,21 @@ export interface SessionInfo extends SessionRef {
|
||||
messageCount: number;
|
||||
firstMessage: string;
|
||||
parentSessionPath?: string;
|
||||
/**
|
||||
* Working directory of the parent session, read from the parent session file
|
||||
* header. Only populated when the parent is outside this listing's cwd, so a
|
||||
* child whose parent lives in another worktree can point at it instead of
|
||||
* only reporting that the parent is unavailable here.
|
||||
*/
|
||||
parentSessionCwd?: string;
|
||||
/** Session id of an out-of-cwd parent, so the browser can select it after switching workspace. */
|
||||
parentSessionId?: string;
|
||||
/**
|
||||
* Number of sessions in other workspaces of the same project that record this
|
||||
* session as their parent. Only set when non-zero, so a parent can show that
|
||||
* it has children which are not nested beneath it in this workspace.
|
||||
*/
|
||||
childSessionsElsewhere?: number;
|
||||
archived?: boolean;
|
||||
archivedAt?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user