Show session lineage and support detaching forks

This commit is contained in:
Federico Jaramillo Martinez
2026-05-10 00:28:08 +02:00
parent d539aa2f96
commit 39ea4bfc15
11 changed files with 128 additions and 11 deletions
+2
View File
@@ -7,6 +7,7 @@ import {
parseArchived,
parseClosed,
parseCommandResult,
parseDetached,
parseFileContentResponse,
parseFileSuggestion,
parseFileTreeResponse,
@@ -51,6 +52,7 @@ export const sessionsApi = {
stop: (sessionId: string) => request(`/api/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
archive: (sessionId: string) => request(`/api/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }),
restore: (sessionId: string) => request(`/api/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
detachParent: (sessionId: string) => request(`/api/sessions/${sessionId}/detach-parent`, parseDetached, { method: "POST" }),
};
export const terminalsApi = {
+8
View File
@@ -75,6 +75,7 @@ export function parseWorkspace(value: unknown): Workspace {
export function parseSessionInfo(value: unknown): SessionInfo {
const record = requireRecord(value);
const name = optionalString(record, "name");
const parentSessionPath = optionalString(record, "parentSessionPath");
const archivedAt = optionalString(record, "archivedAt");
return {
id: requireString(record, "id"),
@@ -85,6 +86,7 @@ export function parseSessionInfo(value: unknown): SessionInfo {
modified: requireString(record, "modified"),
messageCount: requireNumber(record, "messageCount"),
firstMessage: requireString(record, "firstMessage"),
...(parentSessionPath === undefined ? {} : { parentSessionPath }),
...(record["archived"] === true ? { archived: true } : {}),
...(archivedAt === undefined ? {} : { archivedAt }),
};
@@ -253,6 +255,12 @@ export function parseRestored(value: unknown): { restored: true } {
return { restored: true };
}
export function parseDetached(value: unknown): { detached: true } {
const record = requireRecord(value);
if (record["detached"] !== true) throw new Error("Expected detached response");
return { detached: true };
}
function optionalNumber(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key];
if (value === undefined) return undefined;
+1 -1
View File
@@ -202,7 +202,7 @@ export class PiWebApp extends LitElement {
</header>
<project-list .projects=${this.state.projects} .selected=${this.state.selectedProject} .onSelect=${(project: Project) => this.withChatScrollTransition(() => this.workspaces.selectProject(project))} .onClose=${(project: Project) => this.projects.closeProject(project.id)}></project-list>
<workspace-list .workspaces=${this.state.workspaces} .selected=${this.state.selectedWorkspace} .onSelect=${(workspace: Workspace) => openChatAfter(() => this.workspaces.selectWorkspace(workspace))}></workspace-list>
<session-list .sessions=${this.state.sessions} .statuses=${this.state.sessionStatuses} .activities=${this.state.sessionActivities} .selected=${this.state.selectedSession} .canStart=${!!this.state.selectedWorkspace} .onStart=${() => openChatAfter(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))} .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} .onRestore=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))}></session-list>
<session-list .sessions=${this.state.sessions} .statuses=${this.state.sessionStatuses} .activities=${this.state.sessionActivities} .selected=${this.state.selectedSession} .canStart=${!!this.state.selectedWorkspace} .onStart=${() => openChatAfter(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))} .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} .onRestore=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))} .onDetachParent=${(session: SessionInfo) => this.sessions.detachParent(session)}></session-list>
`;
}
+68 -9
View File
@@ -8,6 +8,12 @@ function sessionLabel(session: SessionInfo): string {
return session.firstMessage !== "" ? session.firstMessage : session.id.slice(0, 8);
}
interface SessionRow {
session: SessionInfo;
depth: number;
hasMissingParent: boolean;
}
@customElement("session-list")
export class SessionList extends LitElement {
@property({ attribute: false }) sessions: SessionInfo[] = [];
@@ -26,6 +32,7 @@ export class SessionList extends LitElement {
};
@property({ attribute: false }) onArchive?: (session: SessionInfo) => void;
@property({ attribute: false }) onRestore?: (session: SessionInfo) => void;
@property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void;
override connectedCallback(): void {
super.connectedCallback();
@@ -43,30 +50,34 @@ export class SessionList extends LitElement {
}
override render() {
const active = this.sessions.filter((session) => session.archived !== true);
const archived = this.sessions.filter((session) => session.archived === true);
const activeRows = sessionRowsForActiveTree(this.sessions);
const activeIds = new Set(activeRows.map((row) => row.session.id));
const archivedRows = sessionRows(this.sessions.filter((session) => session.archived === true && !activeIds.has(session.id)));
return html`
<section>
<h2>Sessions <button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button></h2>
${active.map((session) => this.renderSession(session))}
${archived.length > 0 ? html`
<h2 class="subheading"><button class="section-toggle" aria-expanded=${String(this.archivedExpanded)} @click=${() => { this.toggleArchived(); }}><span>${this.archivedExpanded ? "▾" : "▸"} Archived</span><small>${archived.length}</small></button></h2>
${this.archivedExpanded ? archived.map((session) => this.renderSession(session)) : null}
${activeRows.map((row) => this.renderSession(row))}
${archivedRows.length > 0 ? html`
<h2 class="subheading"><button class="section-toggle" aria-expanded=${String(this.archivedExpanded)} @click=${() => { this.toggleArchived(); }}><span>${this.archivedExpanded ? "▾" : "▸"} Archived</span><small>${archivedRows.length}</small></button></h2>
${this.archivedExpanded ? archivedRows.map((row) => this.renderSession(row)) : null}
` : null}
</section>
`;
}
private renderSession(session: SessionInfo) {
private renderSession(row: SessionRow) {
const { session } = row;
const cappedDepth = Math.min(row.depth, 2);
return html`
<div class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${session.archived === true ? "archived" : ""}">
<div class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${session.archived === true ? "archived" : ""}" style=${`--depth:${String(cappedDepth)}`}>
<button class="action-main" @click=${() => this.onSelect?.(session)}>
<span>${sessionLabel(session)}</span><small>${this.renderStatus(session)}${String(session.messageCount)} messages</small>
<span>${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span><small>${this.renderStatus(session)}${String(session.messageCount)} messages</small>
</button>
<div class="action-menu">
<button class="action-menu-toggle" title="Session actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(session.id, event.currentTarget); }}>⋯</button>
${this.openMenuSessionId === session.id ? html`
<div class="action-menu-panel" style=${this.menuStyle}>
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
${session.archived === true
? html`<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>`
: html`<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>`}
@@ -109,3 +120,51 @@ export class SessionList extends LitElement {
static override styles = listStyles;
}
function sessionRowsForActiveTree(sessions: SessionInfo[]): SessionRow[] {
const byPath = new Map(sessions.map((session) => [session.path, session]));
const visible = new Set<string>();
for (const session of sessions) {
if (session.archived === true) continue;
visible.add(session.id);
let parentPath = session.parentSessionPath;
const seen = new Set<string>([session.path]);
while (parentPath !== undefined && !seen.has(parentPath)) {
seen.add(parentPath);
const parent = byPath.get(parentPath);
if (parent === undefined) break;
visible.add(parent.id);
parentPath = parent.parentSessionPath;
}
}
return sessionRows(sessions.filter((session) => visible.has(session.id)));
}
function sessionRows(sessions: SessionInfo[]): SessionRow[] {
const byPath = new Map(sessions.map((session) => [session.path, session]));
const childrenByPath = new Map<string, SessionInfo[]>();
const roots: SessionInfo[] = [];
for (const session of sessions) {
const parentPath = session.parentSessionPath;
const parent = parentPath === undefined ? undefined : byPath.get(parentPath);
if (parent === undefined) {
roots.push(session);
continue;
}
const children = childrenByPath.get(parent.path) ?? [];
children.push(session);
childrenByPath.set(parent.path, children);
}
const rows: SessionRow[] = [];
const visit = (session: SessionInfo, depth: number, stack: Set<string>) => {
if (stack.has(session.path)) return;
const parentPath = session.parentSessionPath;
rows.push({ session, depth, hasMissingParent: parentPath !== undefined && !byPath.has(parentPath) });
const nextStack = new Set(stack);
nextStack.add(session.path);
for (const child of childrenByPath.get(session.path) ?? []) visit(child, depth + 1, nextStack);
};
for (const root of roots) visit(root, 0, new Set());
return rows;
}
+3 -1
View File
@@ -107,7 +107,9 @@ export const listStyles = css`
.action-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; margin: 6px 0; }
.action-row.selected .action-main, .action-row.selected .action-menu-toggle { border-color: #58a6ff; background: #0d2847; }
.action-row.archived .action-main { color: #8b949e; }
.action-main { min-width: 0; text-align: left; border-top-right-radius: 0; border-bottom-right-radius: 0; }
.action-main { min-width: 0; text-align: left; border-top-right-radius: 0; border-bottom-right-radius: 0; padding-left: calc(9px + var(--depth, 0) * 16px); }
.tree-marker { color: #6e7681; margin-right: 5px; }
.badge { display: inline-block; margin-left: 5px; border: 1px solid #30363d; border-radius: 999px; color: #8b949e; padding: 0 5px; font-size: 11px; font-weight: 400; }
.action-menu { position: relative; align-self: stretch; }
.action-menu-toggle { display: grid; place-items: center; height: 100%; min-width: 32px; padding: 0; color: #8b949e; border-left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; }
.action-menu-toggle:hover { color: #e6edf3; background: #21262d; }
@@ -193,6 +193,18 @@ export class SessionController {
}
}
async detachParent(session = this.getState().selectedSession) {
if (session?.parentSessionPath === undefined) return;
try {
await api.detachParent(session.id);
const detached = { ...session };
delete detached.parentSessionPath;
this.replaceSession(detached);
} catch (error) {
this.setState({ error: String(error) });
}
}
async stopActiveWork() {
const session = this.getState().selectedSession;
if (!session) return;
@@ -31,6 +31,7 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new Se
app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/stop", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/archive", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/restore", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/detach-parent", (request, reply) => proxy(request, reply));
app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/events", { websocket: true }, (socket, request) => {
bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`));
+21
View File
@@ -1,3 +1,4 @@
import { readFile, writeFile } from "node:fs/promises";
import {
AuthStorage,
createAgentSessionFromServices,
@@ -115,6 +116,7 @@ export class PiSessionService {
modified: s.modified.toISOString(),
messageCount: s.messageCount,
firstMessage: s.firstMessage,
...(s.parentSessionPath === undefined ? {} : { parentSessionPath: s.parentSessionPath }),
...(archived === undefined ? {} : { archived: true, archivedAt: archived.archivedAt }),
};
});
@@ -234,6 +236,13 @@ export class PiSessionService {
await this.archiveStore.restore(sessionId);
}
async detachParent(sessionId: string): Promise<void> {
const session = await this.getOrOpen(sessionId);
const sessionFile = session.sessionFile;
if (sessionFile === undefined || sessionFile === "") throw new Error("Session is not persisted");
await clearParentSession(sessionFile);
}
async abort(sessionId: string): Promise<void> {
const active = this.active.get(sessionId);
if (!active) return;
@@ -414,6 +423,18 @@ export class PiSessionService {
}
}
async function clearParentSession(sessionFile: string): Promise<void> {
const content = await readFile(sessionFile, "utf8");
const newlineIndex = content.indexOf("\n");
const firstLine = newlineIndex === -1 ? content : content.slice(0, newlineIndex);
const rest = newlineIndex === -1 ? "" : content.slice(newlineIndex);
const header: unknown = JSON.parse(firstLine);
if (!isRecord(header) || header["type"] !== "session") throw new Error("Invalid session file header");
if (header["parentSession"] === undefined) return;
delete header["parentSession"];
await writeFile(sessionFile, `${JSON.stringify(header)}${rest}`, "utf8");
}
function userTextMessage(text: string): { role: "user"; content: string } {
return { role: "user", content: text };
}
@@ -113,6 +113,7 @@ export class SessionCommandService {
function clientSessionFromRuntime(runtime: AgentSessionRuntime): ClientSession {
const session = runtime.session;
const parentSessionPath = typeof session.sessionManager.getHeader === "function" ? session.sessionManager.getHeader()?.parentSession : undefined;
return {
id: session.sessionId,
path: session.sessionFile ?? "",
@@ -122,6 +123,7 @@ function clientSessionFromRuntime(runtime: AgentSessionRuntime): ClientSession {
modified: new Date().toISOString(),
messageCount: session.messages.length,
firstMessage: "",
...(parentSessionPath === undefined ? {} : { parentSessionPath }),
};
}
+9
View File
@@ -103,6 +103,15 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
}
});
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/detach-parent`, async (request, reply) => {
try {
await sessions.detachParent(request.params.sessionId);
return { detached: true };
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
eventHub.add(request.params.sessionId, socket);
});
+1
View File
@@ -25,6 +25,7 @@ export interface SessionInfo {
modified: string;
messageCount: number;
firstMessage: string;
parentSessionPath?: string;
archived?: boolean;
archivedAt?: string;
}