diff --git a/packages/coding-agent/src/core/export-html/template.css b/packages/coding-agent/src/core/export-html/template.css index eac001a1..c29fa203 100644 --- a/packages/coding-agent/src/core/export-html/template.css +++ b/packages/coding-agent/src/core/export-html/template.css @@ -221,6 +221,25 @@ font-size: 11px; color: var(--warning); margin-bottom: var(--line-height); + display: flex; + align-items: center; + gap: 12px; + } + + .download-json-btn { + font-size: 10px; + padding: 2px 8px; + background: var(--container-bg); + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text); + cursor: pointer; + font-family: inherit; + } + + .download-json-btn:hover { + background: var(--hover); + border-color: var(--borderAccent); } /* Header */ diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index c7a1bff3..25de6715 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -54,11 +54,11 @@ } // Label lookup (entryId -> label string) - // Labels are stored in 'label' entries that reference their target via parentId + // Labels are stored in 'label' entries that reference their target via targetId const labelMap = new Map(); for (const entry of entries) { - if (entry.type === 'label' && entry.parentId && entry.label) { - labelMap.set(entry.parentId, entry.label); + if (entry.type === 'label' && entry.targetId && entry.label) { + labelMap.set(entry.targetId, entry.label); } } @@ -144,6 +144,37 @@ return path; } + // Tree node lookup for finding leaves + let treeNodeMap = null; + + /** + * Find the newest leaf node reachable from a given node. + * This allows clicking any node in a branch to show the full branch. + * Children are sorted by timestamp, so the newest is always last. + */ + function findNewestLeaf(nodeId) { + // Build tree node map lazily + if (!treeNodeMap) { + treeNodeMap = new Map(); + const tree = buildTree(); + function mapNodes(node) { + treeNodeMap.set(node.entry.id, node); + node.children.forEach(mapNodes); + } + tree.forEach(mapNodes); + } + + const node = treeNodeMap.get(nodeId); + if (!node) return nodeId; + + // Follow the newest (last) child at each level + let current = node; + while (current.children.length > 0) { + current = current.children[current.children.length - 1]; + } + return current.entry.id; + } + /** * Flatten tree into list with indentation and connector info. * Returns array of { node, indent, showConnector, isLast, gutters, isVirtualRootChild, multipleRoots }. @@ -674,7 +705,11 @@ div.appendChild(prefixSpan); div.appendChild(marker); div.appendChild(content); - div.addEventListener('click', () => navigateTo(entry.id)); + // Navigate to the newest leaf through this node, but scroll to the clicked node + div.addEventListener('click', () => { + const leafId = findNewestLeaf(entry.id); + navigateTo(leafId, 'target', entry.id); + }); container.appendChild(div); } @@ -954,6 +989,33 @@ return html; } + /** + * Download the session data as a JSONL file. + * Reconstructs the original format: header line + entry lines. + */ + window.downloadSessionJson = function() { + // Build JSONL content: header first, then all entries + const lines = []; + if (header) { + lines.push(JSON.stringify({ type: 'header', ...header })); + } + for (const entry of entries) { + lines.push(JSON.stringify(entry)); + } + const jsonlContent = lines.join('\n'); + + // Create download + const blob = new Blob([jsonlContent], { type: 'application/x-ndjson' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${header?.id || 'session'}.jsonl`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + /** * Build a shareable URL for a specific message. * URL format: base?gistId&leafId=&targetId= @@ -1212,7 +1274,10 @@ let html = `

Session: ${escapeHtml(header?.id || 'unknown')}

-
Ctrl+T toggle thinking · Ctrl+O toggle tools
+
+ Ctrl+T toggle thinking · Ctrl+O toggle tools + +
Date:${header?.timestamp ? new Date(header.timestamp).toLocaleString() : 'unknown'}
Models:${globalStats.models.join(', ') || 'unknown'}