fix(coding-agent): render HTML export for sessions deeper than the call stack

Replace recursive tree walks in the export template (sortChildren, mapNodes,
markActive) with iterative traversals, and build getPath with push + reverse.

closes #11225
This commit is contained in:
Petr Houška 2026-10-22 20:40:35 +02:00
parent 64627838b2
commit 1c720d2850
2 changed files with 115 additions and 18 deletions

View file

@ -98,14 +98,12 @@
}
}
// Sort children by timestamp
function sortChildren(node) {
// Sort children by timestamp. Iterative: long sessions form chains deeper than the JS call stack.
for (const node of nodeMap.values()) {
node.children.sort((a, b) =>
new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
);
node.children.forEach(sortChildren);
}
roots.forEach(sortChildren);
return roots;
}
@ -134,14 +132,14 @@
const path = [];
let current = byId.get(targetId);
while (current) {
path.unshift(current);
path.push(current);
// Stop if no parent or self-referencing (root)
if (!current.parentId || current.parentId === current.id) {
break;
}
current = byId.get(current.parentId);
}
return path;
return path.reverse();
}
// Tree node lookup for finding leaves
@ -156,12 +154,12 @@
// Build tree node map lazily
if (!treeNodeMap) {
treeNodeMap = new Map();
const tree = buildTree();
function mapNodes(node) {
const stack = [...buildTree()];
while (stack.length > 0) {
const node = stack.pop();
treeNodeMap.set(node.entry.id, node);
node.children.forEach(mapNodes);
for (const child of node.children) stack.push(child);
}
tree.forEach(mapNodes);
}
const node = treeNodeMap.get(nodeId);
@ -185,16 +183,22 @@
const multipleRoots = roots.length > 1;
// Mark which subtrees contain the active leaf
// Iterative post-order: long sessions form chains deeper than the JS call stack.
const containsActive = new Map();
function markActive(node) {
let has = activePathIds.has(node.entry.id);
for (const child of node.children) {
if (markActive(child)) has = true;
}
containsActive.set(node, has);
return has;
const postOrder = [];
const pending = [...roots];
while (pending.length > 0) {
const node = pending.pop();
postOrder.push(node);
for (const child of node.children) pending.push(child);
}
for (let i = postOrder.length - 1; i >= 0; i--) {
const node = postOrder[i];
containsActive.set(
node,
activePathIds.has(node.entry.id) || node.children.some(child => containsActive.get(child))
);
}
roots.forEach(markActive);
// Stack: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild]
const stack = [];

View file

@ -0,0 +1,93 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createContext, runInContext } from "node:vm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { exportFromFile } from "../src/core/export-html/index.ts";
import { CURRENT_SESSION_VERSION, type SessionHeader, type SessionMessageEntry } from "../src/core/session-manager.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
interface TreeNode {
entry: { id: string };
children: TreeNode[];
}
interface TreeApi {
buildTree: () => TreeNode[];
buildActivePathIds: (targetId: string) => Set<string>;
flattenTree: (roots: TreeNode[], activePathIds: Set<string>) => Array<{ node: TreeNode }>;
findNewestLeaf: (nodeId: string) => string;
getPath: (targetId: string) => Array<{ id: string }>;
}
function loadTreeApi(html: string): TreeApi {
const sessionData = html.match(/<script id="session-data" type="application\/json">([^<]+)<\/script>/)?.[1];
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((match) => match[1]);
const viewer = scripts.pop();
if (!sessionData || !viewer) throw new Error("Missing exported session data or viewer script");
const context = createContext({
atob,
TextDecoder,
URLSearchParams,
document: { getElementById: () => ({ textContent: sessionData }), querySelector: () => null },
window: { location: { search: "" } },
});
return runInContext(
viewer.replace(
"let treeNodeMap = null;",
"let treeNodeMap = null; return { buildTree, buildActivePathIds, flattenTree, findNewestLeaf, getPath };",
),
context,
);
}
// #11225
describe("HTML export deep session tree", () => {
const depth = 20_000;
let tempDir: string;
let api: TreeApi;
beforeAll(async () => {
initTheme("dark");
tempDir = mkdtempSync(join(tmpdir(), "pi-export-deep-"));
const header = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: "deep-test",
timestamp: new Date(0).toISOString(),
cwd: tempDir,
} satisfies SessionHeader;
const lines = [JSON.stringify(header)];
for (let i = 0; i < depth; i++) {
const entry: SessionMessageEntry = {
type: "message",
id: `e${i}`,
parentId: i === 0 ? null : `e${i - 1}`,
timestamp: new Date(i * 1000).toISOString(),
message: { role: "user", content: "x", timestamp: i * 1000 },
};
lines.push(JSON.stringify(entry));
}
const sessionPath = join(tempDir, "session.jsonl");
writeFileSync(sessionPath, `${lines.join("\n")}\n`);
api = loadTreeApi(readFileSync(await exportFromFile(sessionPath, join(tempDir, "out.html")), "utf8"));
});
afterAll(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("builds and flattens a single-child chain deeper than the call stack", () => {
const tree = api.buildTree();
expect(tree).toHaveLength(1);
const leafId = `e${depth - 1}`;
const flat = api.flattenTree(tree, api.buildActivePathIds(leafId));
expect(flat).toHaveLength(depth);
expect(flat[depth - 1].node.entry.id).toBe(leafId);
expect(api.findNewestLeaf("e0")).toBe(leafId);
const path = api.getPath(leafId);
expect(path).toHaveLength(depth);
expect(path[0].id).toBe("e0");
expect(path[depth - 1].id).toBe(leafId);
});
});