mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
64627838b2
commit
1c720d2850
2 changed files with 115 additions and 18 deletions
|
|
@ -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 = [];
|
||||
|
|
|
|||
93
packages/coding-agent/test/export-html-deep-tree.test.ts
Normal file
93
packages/coding-agent/test/export-html-deep-tree.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue