diff --git a/.changeset/orphan-parent-session-location.md b/.changeset/orphan-parent-session-location.md new file mode 100644 index 0000000..47fc76c --- /dev/null +++ b/.changeset/orphan-parent-session-location.md @@ -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. diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 97e23f0..ddec44c 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -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 { diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 9793819..d26eb4e 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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 { + 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 { const seq = ++this.navigationSelectionSeq; const isCurrentSelection = () => seq === this.navigationSelectionSeq; diff --git a/src/client/src/components/SessionList.crossWorkspace.test.ts b/src/client/src/components/SessionList.crossWorkspace.test.ts new file mode 100644 index 0000000..c87a454 --- /dev/null +++ b/src/client/src/components/SessionList.crossWorkspace.test.ts @@ -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 { + 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 { + row(list, index).querySelector(".action-menu-toggle")?.click(); + await list.updateComplete; +} + +function findMenuButton(list: SessionList, text: string): HTMLButtonElement | undefined { + return [...list.shadowRoot?.querySelectorAll(".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 { + 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 { + 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, + }; +} diff --git a/src/client/src/components/SessionList.test.ts b/src/client/src/components/SessionList.test.ts index 8943f9b..af75f4a 100644 --- a/src/client/src/components/SessionList.test.ts +++ b/src/client/src/components/SessionList.test.ts @@ -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" }); diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index b73a9aa..ce243c8 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -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; @property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise; @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; @property({ attribute: false }) onReload?: (session: SessionInfo) => void; @@ -283,7 +288,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection >
${showsCheckbox ? html` { event.stopPropagation(); }} @change=${() => { this.toggleSelected(session.id); }}>` : null} - ${row.depth > 0 ? html`` : null}${sessionLabel(session)}${row.depth > 2 ? html` depth ${row.depth}` : null}${row.hasMissingParent ? html` parent unavailable` : null}${this.renderSessionMetaPrefix(session, status, activity)}${String(session.messageCount)} messages + ${this.renderRowMarker(row)}${sessionLabel(session)}${this.renderRowBadges(row)}${this.renderSessionMetaPrefix(session, status, activity)}${this.renderRelatedSessionsMeta(row)}${String(session.messageCount)} messages ${this.renderActivity(indicatorKind, unread)}
@@ -303,6 +308,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection ${descendantCount > 0 ? html`` : null} ` : null} + ${this.renderGoToParentMenuItem(row)} ${session.parentSessionPath !== undefined ? html`` : null} ${canReloadSession ? html`` : 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``; + } + return row.depth > 0 ? html`` : 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`depth ${row.depth}`; + } + + /** + * 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``; + } + 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(); 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): 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 { + 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(); for (const session of sessions) { if (session.archived === true) continue; visible.add(session.id); - let parentPath = session.parentSessionPath; - const seenPaths = new Set([session.path]); - while (parentPath !== undefined && !seenPaths.has(parentPath)) { - seenPaths.add(parentPath); - const parent = byPath.get(parentPath); + let parentKey = session.parentSessionPath === undefined ? undefined : normalizeSessionPath(session.parentSessionPath); + const seenPaths = new Set([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(); 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) => { - 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; diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index d1817b0..07d1a2b 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -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; @property({ attribute: false }) onDeleteArchivedSessions?: (sessions: SessionInfo[]) => void | Promise; @property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) parentSessionLocation?: (session: SessionInfo) => ParentSessionLocation; + @property({ attribute: false }) onGoToParentSession?: (session: SessionInfo, location: ParentSessionLocation) => void | Promise; @property({ attribute: false }) onMarkSessionRead?: (session: SessionInfo) => void | Promise; @property({ attribute: false }) onMarkSessionsRead?: (sessions: SessionInfo[]) => void | Promise; @property({ attribute: false }) onReloadSession?: (session: SessionInfo) => void | Promise; @@ -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; } diff --git a/src/client/src/controllers/sessionController.childElsewhere.test.ts b/src/client/src/controllers/sessionController.childElsewhere.test.ts new file mode 100644 index 0000000..bd02321 --- /dev/null +++ b/src/client/src/controllers/sessionController.childElsewhere.test.ts @@ -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 { + 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, + }; +} diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 78246d4..bc76dd9 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -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, "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, "sessions" | "selectedSession"> { if (messageCount === undefined) return {}; diff --git a/src/client/src/parentSessionLocation.test.ts b/src/client/src/parentSessionLocation.test.ts new file mode 100644 index 0000000..63e2400 --- /dev/null +++ b/src/client/src/parentSessionLocation.test.ts @@ -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; + projects?: readonly Project[]; +}) { + return { + workspaces: overrides.workspaces ?? [], + workspacesByProjectId: overrides.workspacesByProjectId ?? {}, + projects: overrides.projects ?? [], + }; +} diff --git a/src/client/src/parentSessionLocation.ts b/src/client/src/parentSessionLocation.ts new file mode 100644 index 0000000..4307d6a --- /dev/null +++ b/src/client/src/parentSessionLocation.ts @@ -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>; + 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("/"); +} diff --git a/src/client/src/sessionPaths.ts b/src/client/src/sessionPaths.ts new file mode 100644 index 0000000..ce6994a --- /dev/null +++ b/src/client/src/sessionPaths.ts @@ -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); +} diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index 87ede46..45d8311 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -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, diff --git a/src/server/sessiond/sessionServiceDependencies.ts b/src/server/sessiond/sessionServiceDependencies.ts index 30dfbf6..b73b883 100644 --- a/src/server/sessiond/sessionServiceDependencies.ts +++ b/src/server/sessiond/sessionServiceDependencies.ts @@ -19,6 +19,7 @@ export interface SessionServiceDependencyInput { catalogRefreshStatus: NonNullable; /** Omitted when the operator has not enabled session spawning. */ spawnTargets?: NonNullable; + projectWorkspaces?: NonNullable; /** 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, diff --git a/src/server/sessions/parentSessionLocator.test.ts b/src/server/sessions/parentSessionLocator.test.ts new file mode 100644 index 0000000..99acb8b --- /dev/null +++ b/src/server/sessions/parentSessionLocator.test.ts @@ -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) { + return vi.fn((sessionFile: string) => Promise.resolve(headers[sessionFile])); +} diff --git a/src/server/sessions/parentSessionLocator.ts b/src/server/sessions/parentSessionLocator.ts new file mode 100644 index 0000000..ccddf2a --- /dev/null +++ b/src/server/sessions/parentSessionLocator.ts @@ -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; + +/** 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> { + const located = new Map(); + 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 { + const listedPaths = new Set(sessions.map((session) => canonicalizeStoredCwd(session.path))); + const missing = new Set(); + 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 { + 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 { + const listedPaths = new Map(sessions.map((session) => [canonicalizeStoredCwd(session.path), session.path])); + const counts = new Map(); + 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; +} diff --git a/src/server/sessions/piSessionService.crossWorkspace.test.ts b/src/server/sessions/piSessionService.crossWorkspace.test.ts new file mode 100644 index 0000000..f0dbb69 --- /dev/null +++ b/src/server/sessions/piSessionService.crossWorkspace.test.ts @@ -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 { + 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, + 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) }, + }); +} diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 0188603..9b2ffc4 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -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(); /** Parent id/file identities whose persisted links have already been loaded. */ private readonly subsessionHydratedParents = new Set(); + /** Session file path -> its parsed header. Headers are written once, so successful reads are cached. */ + private readonly sessionHeaderCache = new Map(); /** * 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 | 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 { + 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> { + 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 => { + 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 { 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 { - let file: Awaited> | 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 { const header = await readSessionHeaderSummary(sessionFile); if (header?.id !== expected.sessionId) return false; diff --git a/src/server/sessions/sessionFileHeader.test.ts b/src/server/sessions/sessionFileHeader.test.ts new file mode 100644 index 0000000..a887f2f --- /dev/null +++ b/src/server/sessions/sessionFileHeader.test.ts @@ -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[]): Promise { + 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; +} diff --git a/src/server/sessions/sessionFileHeader.ts b/src/server/sessions/sessionFileHeader.ts new file mode 100644 index 0000000..7090645 --- /dev/null +++ b/src/server/sessions/sessionFileHeader.ts @@ -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 { + let file: Awaited> | 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, key: string): string | undefined { + const value = header[key]; + return typeof value === "string" && value !== "" ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/server/sessions/spawnTargetResolver.ts b/src/server/sessions/spawnTargetResolver.ts index 9ae1c8b..9a4fab2 100644 --- a/src/server/sessions/spawnTargetResolver.ts +++ b/src/server/sessions/spawnTargetResolver.ts @@ -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; } -interface ProjectLister { - list(): Promise; -} - -interface WorkspaceLister { - list(project: Project): Promise; -} - -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 { - 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 { - 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; - } } diff --git a/src/server/workspaces/projectWorkspaceCwds.test.ts b/src/server/workspaces/projectWorkspaceCwds.test.ts new file mode 100644 index 0000000..98dbbc0 --- /dev/null +++ b/src/server/workspaces/projectWorkspaceCwds.test.ts @@ -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) { + 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, + })); +} diff --git a/src/server/workspaces/projectWorkspaceCwds.ts b/src/server/workspaces/projectWorkspaceCwds.ts new file mode 100644 index 0000000..a864df4 --- /dev/null +++ b/src/server/workspaces/projectWorkspaceCwds.ts @@ -0,0 +1,52 @@ +import type { Project, Workspace } from "../types.js"; +import { cwdPathsEqual } from "../workingDirectory.js"; + +interface ProjectLister { + list(): Promise; +} + +interface WorkspaceLister { + list(project: Project): Promise; +} + +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; +} + +export class RegisteredProjectWorkspaceCwds implements ProjectWorkspaceCwds { + constructor(private readonly deps: ProjectWorkspaceCwdsDeps) {} + + async forCwd(cwd: string): Promise { + 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 { + const paths = await locator.forCwd(cwd); + return (paths ?? []).filter((path) => !cwdPathsEqual(path, cwd)); +} diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 310c662..2f9a17e 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -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; }