feat: add hierarchical session tree navigator

This commit is contained in:
Federico Jaramillo Martinez
2026-07-20 10:40:17 +02:00
parent a77c83b309
commit 4ca4a1d096
31 changed files with 4062 additions and 122 deletions
+66 -10
View File
@@ -1,6 +1,6 @@
import crypto from "node:crypto";
import type { SessionUiEvent } from "../../shared/apiTypes.js";
import type { ClientCommandResult, ClientSession } from "../types.js";
import type { ClientCommandResult, ClientSession, ClientSessionTreeSnapshot } from "../types.js";
import { isBuiltinCommand } from "./builtinCommands.js";
export interface CommandSession {
@@ -51,6 +51,10 @@ export interface SessionCommandLifecycle<TSession extends CommandSession = Comma
onCompactionStart?: (session: TSession) => void;
onCompactionEnd?: (session: TSession, result: "success" | "error", detail?: string) => void;
reloadSession?: (session: TSession) => Promise<void>;
getSessionTree?: (session: TSession) => ClientSessionTreeSnapshot | undefined;
hasActiveWork?: (session: TSession) => boolean;
isTreeNavigationActive?: (session: TSession) => boolean;
runSessionReplacement?: <T>(session: TSession, operation: () => Promise<T>) => Promise<T>;
}
export interface SessionCommandNaming {
@@ -81,6 +85,8 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
const [name = "", ...args] = text.trim().replace(/^\//, "").split(/\s+/);
const rest = args.join(" ").trim();
if (this.lifecycle.isTreeNavigationActive?.(session) === true) return treeNavigationActiveUnsupported();
if (!isBuiltinCommand(name)) {
if (this.isRuntimeCommand(session, name)) {
// The command is forwarded to the agent, which expands it (e.g. /skill:*
@@ -99,6 +105,7 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
if (name === "reload") return this.reload(session);
if (name === "clone") return this.clone(active);
if (name === "fork") return this.fork(active);
if (name === "tree") return this.tree(session);
return { type: "unsupported", message: `/${name} is not implemented in the web UI yet` };
}
@@ -109,11 +116,17 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
this.pendingSelects.delete(requestId);
const active = await this.getActive(sessionId);
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("fork");
if (this.lifecycle.isTreeNavigationActive?.(active.runtime.session) === true) return treeNavigationActiveUnsupported();
if (this.hasActiveWork(active.runtime.session)) return forkActiveUnsupported("fork");
const relatedName = await this.nextRelatedSessionName(active, "fork");
const result = await active.runtime.fork(value);
if (this.lifecycle.isTreeNavigationActive?.(active.runtime.session) === true) return treeNavigationActiveUnsupported();
if (this.hasActiveWork(active.runtime.session)) return forkActiveUnsupported("fork");
const result = await this.runSessionReplacement(active.runtime, async () => {
const forkResult = await active.runtime.fork(value);
if (!forkResult.cancelled) this.tryNameRelatedSession(active.runtime.session, relatedName);
return forkResult;
});
if (result.cancelled) return { type: "done", message: "Fork cancelled" };
this.tryNameRelatedSession(active.runtime.session, relatedName);
return { type: "done", message: "Session forked", session: clientSessionFromRuntime(active.runtime), ...promptDraft(result.selectedText) };
}
@@ -145,7 +158,7 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
}
private async reload(session: TSession): Promise<ClientCommandResult> {
if (sessionHasActiveWork(session)) return { type: "unsupported", message: "Cannot reload while the session is active. Stop current activity before reloading." };
if (this.hasActiveWork(session)) return { type: "unsupported", message: "Cannot reload while the session is active. Stop current activity before reloading." };
if (this.lifecycle.reloadSession === undefined) return { type: "unsupported", message: "/reload is not available for this session runtime." };
try {
@@ -158,18 +171,27 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
}
private async clone(active: CommandActiveSession<TSession>): Promise<ClientCommandResult> {
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("clone");
if (this.hasActiveWork(active.runtime.session)) return forkActiveUnsupported("clone");
const initialLeafId = active.runtime.session.sessionManager.getLeafId();
if (initialLeafId === null || initialLeafId === "") return { type: "unsupported", message: "Cannot clone: no current session entry" };
const relatedName = await this.nextRelatedSessionName(active, "copy");
if (this.lifecycle.isTreeNavigationActive?.(active.runtime.session) === true) return treeNavigationActiveUnsupported();
if (this.hasActiveWork(active.runtime.session)) return forkActiveUnsupported("clone");
// The active leaf may have changed while related-session names were loaded.
// Clone the position that is current when the serialized replacement begins.
const leafId = active.runtime.session.sessionManager.getLeafId();
if (leafId === null || leafId === "") return { type: "unsupported", message: "Cannot clone: no current session entry" };
const relatedName = await this.nextRelatedSessionName(active, "copy");
const result = await active.runtime.fork(leafId, { position: "at" });
const result = await this.runSessionReplacement(active.runtime, async () => {
const cloneResult = await active.runtime.fork(leafId, { position: "at" });
if (!cloneResult.cancelled) this.tryNameRelatedSession(active.runtime.session, relatedName);
return cloneResult;
});
if (result.cancelled) return { type: "done", message: "Clone cancelled" };
this.tryNameRelatedSession(active.runtime.session, relatedName);
return { type: "done", message: "Session cloned", session: clientSessionFromRuntime(active.runtime) };
}
private fork(active: CommandActiveSession<TSession>): ClientCommandResult {
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("fork");
if (this.hasActiveWork(active.runtime.session)) return forkActiveUnsupported("fork");
const messages = active.runtime.session.getUserMessagesForForking();
if (!messages.length) return { type: "unsupported", message: "No user messages to fork from" };
const requestId = crypto.randomUUID();
@@ -182,6 +204,32 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
};
}
private tree(session: TSession): ClientCommandResult {
if (this.hasActiveWork(session)) {
return { type: "unsupported", message: "Cannot open the session tree while the session is active. Stop current activity and try /tree again." };
}
if (this.lifecycle.getSessionTree === undefined) return treeUnavailableUnsupported();
try {
const tree = this.lifecycle.getSessionTree(session);
if (tree === undefined) return treeUnavailableUnsupported();
if (tree.nodes.length === 0) return { type: "unsupported", message: "Cannot navigate an empty session tree." };
return { type: "tree", tree };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return { type: "unsupported", message: `Unable to open the session tree: ${message}` };
}
}
private hasActiveWork(session: TSession): boolean {
return sessionHasActiveWork(session) || this.lifecycle.hasActiveWork?.(session) === true;
}
private runSessionReplacement<T>(runtime: CommandRuntime<TSession>, operation: () => Promise<T>): Promise<T> {
const runReplacement = this.lifecycle.runSessionReplacement;
return runReplacement === undefined ? operation() : runReplacement(runtime.session, operation);
}
private async nextRelatedSessionName(active: CommandActiveSession<TSession>, kind: RelatedSessionKind): Promise<string> {
const sourceTitle = relatedSessionSourceTitle(active.runtime.session);
const sourceName = normalizedName(active.runtime.session.sessionName);
@@ -291,6 +339,14 @@ function forkActiveUnsupported(command: "fork" | "clone"): ClientCommandResult {
return { type: "unsupported", message: `Cannot ${command} while the session is active. Stop current activity before ${command === "fork" ? "forking" : "cloning"}.` };
}
function treeUnavailableUnsupported(): ClientCommandResult {
return { type: "unsupported", message: "Session tree navigation is not available with this Pi runtime." };
}
function treeNavigationActiveUnsupported(): ClientCommandResult {
return { type: "unsupported", message: "Cannot run commands while session tree navigation is active. Stop or finish the navigation first." };
}
function promptDraft(text: string | undefined): Partial<Pick<Extract<ClientCommandResult, { type: "done" }>, "promptDraft">> {
return text === undefined ? {} : { promptDraft: text };
}