From 1c720d28504189273f1d2a165fbb5df6d84f0983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Thu, 22 Oct 2026 20:40:35 +0200 Subject: [PATCH] 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 --- .../src/core/export-html/template.js | 40 ++++---- .../test/export-html-deep-tree.test.ts | 93 +++++++++++++++++++ 2 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 packages/coding-agent/test/export-html-deep-tree.test.ts diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index d6541ca77..37022818d 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -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 = []; diff --git a/packages/coding-agent/test/export-html-deep-tree.test.ts b/packages/coding-agent/test/export-html-deep-tree.test.ts new file mode 100644 index 000000000..59a8a9851 --- /dev/null +++ b/packages/coding-agent/test/export-html-deep-tree.test.ts @@ -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; + flattenTree: (roots: TreeNode[], activePathIds: Set) => Array<{ node: TreeNode }>; + findNewestLeaf: (nodeId: string) => string; + getPath: (targetId: string) => Array<{ id: string }>; +} + +function loadTreeApi(html: string): TreeApi { + const sessionData = html.match(/