Archived
Show session lineage and support detaching forks
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`));
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface SessionInfo {
|
||||
modified: string;
|
||||
messageCount: number;
|
||||
firstMessage: string;
|
||||
parentSessionPath?: string;
|
||||
archived?: boolean;
|
||||
archivedAt?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user