diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index f0677a4..53b11d2 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -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 = { diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 5dd8905..437833a 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -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, key: string): number | undefined { const value = record[key]; if (value === undefined) return undefined; diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index f118df4..18e1268 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -202,7 +202,7 @@ export class PiWebApp extends LitElement { this.withChatScrollTransition(() => this.workspaces.selectProject(project))} .onClose=${(project: Project) => this.projects.closeProject(project.id)}> openChatAfter(() => this.workspaces.selectWorkspace(workspace))}> - 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))}> + 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)}> `; } diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index fb4e34e..8070500 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -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`

Sessions

- ${active.map((session) => this.renderSession(session))} - ${archived.length > 0 ? html` -

- ${this.archivedExpanded ? archived.map((session) => this.renderSession(session)) : null} + ${activeRows.map((row) => this.renderSession(row))} + ${archivedRows.length > 0 ? html` +

+ ${this.archivedExpanded ? archivedRows.map((row) => this.renderSession(row)) : null} ` : null}
`; } - private renderSession(session: SessionInfo) { + private renderSession(row: SessionRow) { + const { session } = row; + const cappedDepth = Math.min(row.depth, 2); return html` -
+
${this.openMenuSessionId === session.id ? html`
+ ${session.parentSessionPath !== undefined ? html`` : null} ${session.archived === true ? html`` : html``} @@ -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(); + for (const session of sessions) { + if (session.archived === true) continue; + visible.add(session.id); + let parentPath = session.parentSessionPath; + const seen = new Set([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(); + 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) => { + 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; +} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 713c165..b72cff8 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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; } diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index e19ce29..e8b20ff 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -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; diff --git a/src/server/sessiond/sessionProxyRoutes.ts b/src/server/sessiond/sessionProxyRoutes.ts index ab3542c..2d868cf 100644 --- a/src/server/sessiond/sessionProxyRoutes.ts +++ b/src/server/sessiond/sessionProxyRoutes.ts @@ -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`)); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 56448fd..5e3e2ea 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -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 { + 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 { const active = this.active.get(sessionId); if (!active) return; @@ -414,6 +423,18 @@ export class PiSessionService { } } +async function clearParentSession(sessionFile: string): Promise { + 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 }; } diff --git a/src/server/sessions/sessionCommandService.ts b/src/server/sessions/sessionCommandService.ts index da83123..5ca1d9c 100644 --- a/src/server/sessions/sessionCommandService.ts +++ b/src/server/sessions/sessionCommandService.ts @@ -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 }), }; } diff --git a/src/server/sessions/sessionRoutes.ts b/src/server/sessions/sessionRoutes.ts index 5722c14..08fbe0c 100644 --- a/src/server/sessions/sessionRoutes.ts +++ b/src/server/sessions/sessionRoutes.ts @@ -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); }); diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 4971da4..bb4bfff 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -25,6 +25,7 @@ export interface SessionInfo { modified: string; messageCount: number; firstMessage: string; + parentSessionPath?: string; archived?: boolean; archivedAt?: string; }