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
@@ -139,6 +139,71 @@ describe("SessionCommandService", () => {
expect(reloadSession).not.toHaveBeenCalled();
});
it("returns an injected full tree snapshot for /tree without creating a select request", async () => {
const active = activeSession();
const tree = {
nodes: [{ id: "root", parentId: null, kind: "user" as const, summary: "hello" }],
activeLeafId: "root",
activePathIds: ["root"],
};
const getSessionTree = vi.fn(() => tree);
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), { getSessionTree });
await expect(service.run("s1", "/tree")).resolves.toEqual({ type: "tree", tree });
expect(getSessionTree).toHaveBeenCalledWith(active.runtime.session);
expect(active.runtime.fork).not.toHaveBeenCalled();
});
it("rejects /tree for active, empty, and unavailable runtimes", async () => {
const active = activeSession();
let externallyActive = true;
const getSessionTree = vi.fn(() => ({ nodes: [], activeLeafId: null, activePathIds: [] }));
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), {
getSessionTree,
hasActiveWork: () => externallyActive,
});
await expect(service.run("s1", "/tree")).resolves.toEqual({
type: "unsupported",
message: "Cannot open the session tree while the session is active. Stop current activity and try /tree again.",
});
expect(getSessionTree).not.toHaveBeenCalled();
externallyActive = false;
await expect(service.run("s1", "/tree")).resolves.toEqual({
type: "unsupported",
message: "Cannot navigate an empty session tree.",
});
const unavailable = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher());
await expect(unavailable.run("s1", "/tree")).resolves.toEqual({
type: "unsupported",
message: "Session tree navigation is not available with this Pi runtime.",
});
});
it("blocks commands and pending command responses while a tree navigation owns the session gate", async () => {
const active = activeSession();
let navigationActive = false;
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), {
isTreeNavigationActive: () => navigationActive,
});
const pendingFork = await service.run("s1", "/fork");
if (pendingFork.type !== "select") throw new Error("Expected select result");
navigationActive = true;
await expect(service.run("s1", "/name changed")).resolves.toEqual({
type: "unsupported",
message: "Cannot run commands while session tree navigation is active. Stop or finish the navigation first.",
});
await expect(service.respond("s1", pendingFork.requestId, "m1")).resolves.toEqual({
type: "unsupported",
message: "Cannot run commands while session tree navigation is active. Stop or finish the navigation first.",
});
expect(active.runtime.session.setSessionName).not.toHaveBeenCalled();
expect(active.runtime.fork).not.toHaveBeenCalled();
});
it("creates fork selection requests from newest message to oldest and responds with selected entry", async () => {
const active = activeSession({
getUserMessagesForForking: vi.fn(() => [
@@ -202,6 +267,43 @@ describe("SessionCommandService", () => {
expect(cloned.setSessionName).toHaveBeenCalledWith("Build auth — Copy 2");
});
it("does not start a clone if tree navigation takes the gate during async name lookup", async () => {
const active = activeSession();
const names = deferred<readonly string[]>();
const listSessionNames = vi.fn(() => names.promise);
let navigationActive = false;
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), {
isTreeNavigationActive: () => navigationActive,
}, { listSessionNames });
const clone = service.run("s1", "/clone");
await vi.waitFor(() => { expect(listSessionNames).toHaveBeenCalledOnce(); });
navigationActive = true;
names.resolve([]);
await expect(clone).resolves.toEqual({
type: "unsupported",
message: "Cannot run commands while session tree navigation is active. Stop or finish the navigation first.",
});
expect(active.runtime.fork).not.toHaveBeenCalled();
});
it("clones the leaf that is current after asynchronous name lookup", async () => {
let leafId = "leaf-before-navigation";
const active = activeSession({ sessionManager: { getLeafId: () => leafId } });
const names = deferred<readonly string[]>();
const listSessionNames = vi.fn(() => names.promise);
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), {}, { listSessionNames });
const clone = service.run("s1", "/clone");
await vi.waitFor(() => { expect(listSessionNames).toHaveBeenCalledOnce(); });
leafId = "leaf-after-navigation";
names.resolve([]);
await expect(clone).resolves.toMatchObject({ type: "done", message: "Session cloned" });
expect(active.runtime.fork).toHaveBeenCalledWith("leaf-after-navigation", { position: "at" });
});
it("rejects fork and clone while the session has active work", async () => {
const active = activeSession({ isStreaming: true });
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher());
@@ -232,3 +334,11 @@ describe("SessionCommandService", () => {
expect(active.runtime.fork).not.toHaveBeenCalled();
});
});
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}