diff --git a/eslint.config.js b/eslint.config.js index 81ede521..49d49df8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -105,6 +105,7 @@ const rendererCrossFileGlobals = { openSettingsViewer: 'readonly', showResumeSessionDialog: 'readonly', showJsonlViewer: 'readonly', + showSubagentTranscript: 'readonly', forkSession: 'readonly', openSession: 'readonly', loadProjects: 'readonly', @@ -156,6 +157,7 @@ const rendererCrossFileGlobals = { updateRunningIndicators: 'readonly', hideAllViewers: 'readonly', hidePlanViewer: 'readonly', + drainViewerWatches: 'readonly', showTerminalHeader: 'readonly', switchPanel: 'readonly', showAddProjectDialog: 'readonly', diff --git a/public/jsonl-viewer.js b/public/jsonl-viewer.js index 2c1a2bca..1109ba82 100644 --- a/public/jsonl-viewer.js +++ b/public/jsonl-viewer.js @@ -13,6 +13,21 @@ let agentMatchCounters = {}; // Keyed as ":" so it's globally unique. const liveSubagents = new Set(); +// Active subagent file watches for the currently-rendered viewer. Each entry +// is a stopWatch closure created when an Agent block expands and starts a +// live tail. Drained on viewer dismissal so we don't leak fs.watchFile polls. +// Attached to `window` so the cross-file hideAllViewers() (in plans-memory-view.js) +// can drain via the function declaration below — top-level `const` in classic +// scripts isn't global. +window.__activeViewerWatches = window.__activeViewerWatches || new Set(); +const activeViewerWatches = window.__activeViewerWatches; +function drainViewerWatches() { + for (const stop of activeViewerWatches) { + try { stop(); } catch {} + } + activeViewerWatches.clear(); +} + // Register IPC listeners for subagent lifecycle events (called once at module load). (function initSubagentListeners() { if (!window.api) return; // guard for non-Electron contexts @@ -277,7 +292,9 @@ const toolRenderers = { liveIndicator.remove(); liveIndicator = null; } + activeViewerWatches.delete(stopWatch); } + activeViewerWatches.add(stopWatch); el.addEventListener('click', async () => { if (expanded && nestedContainer) { @@ -718,6 +735,10 @@ function renderJsonlEntry(entry, toolResultMap) { } async function showJsonlViewer(session) { + // Drain any watches from the previously-rendered viewer first — the new + // render replaces the DOM and we'd otherwise keep polling files for blocks + // the user no longer sees. + drainViewerWatches(); const result = await window.api.readSessionJsonl(session.sessionId); hideAllViewers(); placeholder.style.display = 'none'; @@ -784,3 +805,93 @@ async function showJsonlViewer(session) { // Scroll to the bottom so the most recent messages are visible jsonlViewerBody.scrollTop = jsonlViewerBody.scrollHeight; } + +// --- Subagent transcript view --- +// Renders a read-only transcript for a subagent session. +// Routing decision: the click handler in sidebar.js discriminates on +// session.parentSessionId (present only on subagent rows) and calls this +// function instead of openSession(). Doing the branch at the click-handler +// layer — where we already have the full session object — avoids an extra +// IPC round-trip and keeps the IPC layer ignorant of UI routing concerns. +async function showSubagentTranscript(session) { + const result = await window.api.readSubagentJsonl(session.parentSessionId, session.agentId); + hideAllViewers(); + placeholder.style.display = 'none'; + terminalArea.style.display = 'none'; + jsonlViewer.style.display = 'flex'; + + // Set viewer context for nested Agent block expansion + currentViewerSessionId = session.sessionId; + agentMatchCounters = {}; + + const displayName = session.description || session.summary || session.aiTitle || session.sessionId; + const subagentLabel = session.subagentType ? '[' + session.subagentType + '] ' : '[subagent] '; + jsonlViewerTitle.textContent = subagentLabel + displayName; + jsonlViewerSessionId.textContent = session.sessionId; + jsonlViewerBody.innerHTML = ''; + + // Escape hatch: let the user resume this session in a terminal tab if needed + const escapeBanner = document.createElement('div'); + escapeBanner.className = 'jsonl-subagent-escape-banner'; + escapeBanner.innerHTML = 'Read-only transcript — subagents cannot be re-entered.'; + const resumeBtn = document.createElement('button'); + resumeBtn.className = 'jsonl-subagent-resume-btn'; + resumeBtn.textContent = 'Resume in terminal anyway'; + resumeBtn.addEventListener('click', () => openSession(session)); + escapeBanner.appendChild(resumeBtn); + jsonlViewerBody.appendChild(escapeBanner); + + if (result.error) { + const errEl = document.createElement('div'); + errEl.className = 'plans-empty'; + errEl.textContent = 'Error loading transcript: ' + result.error; + jsonlViewerBody.appendChild(errEl); + return; + } + + const rawEntries = result.entries || []; + const entries = mergeLocalCommandEntries(rawEntries); + + // Build tool_use_id → result content map + const toolResultMap = new Map(); + for (const entry of entries) { + const blocks = entry.message?.content || entry.content; + if (!Array.isArray(blocks)) continue; + for (const block of blocks) { + if (block.type === 'tool_result' && block.tool_use_id) { + toolResultMap.set(block.tool_use_id, block.content || block.output || ''); + } + } + } + + let rendered = 0; + for (const entry of entries) { + const el = renderJsonlEntry(entry, toolResultMap); + if (el) { + jsonlViewerBody.appendChild(el); + rendered++; + } + } + + if (rendered === 0) { + const emptyEl = document.createElement('div'); + emptyEl.className = 'plans-empty'; + emptyEl.textContent = 'No messages found in this subagent transcript.'; + jsonlViewerBody.appendChild(emptyEl); + } + + // Click-to-fullscreen for inline images + jsonlViewerBody.querySelectorAll('.jsonl-clickable-img').forEach(img => { + img.onclick = () => { + const overlay = document.createElement('div'); + overlay.className = 'jsonl-screenshot-fullscreen'; + const fullImg = document.createElement('img'); + fullImg.src = img.src; + overlay.appendChild(fullImg); + overlay.onclick = () => overlay.remove(); + document.body.appendChild(overlay); + }; + }); + + jsonlViewerBody.scrollTop = jsonlViewerBody.scrollHeight; +} diff --git a/public/plans-memory-view.js b/public/plans-memory-view.js index 763c54ca..c658c875 100644 --- a/public/plans-memory-view.js +++ b/public/plans-memory-view.js @@ -98,6 +98,11 @@ function hideAllViewers() { settingsViewer.style.display = 'none'; jsonlViewer.style.display = 'none'; terminalArea.style.display = ''; + // Stop any subagent file-watches kept alive by Agent blocks that the user + // was viewing — without this, fs.watchFile keeps polling indefinitely. + // `drainViewerWatches` lives in jsonl-viewer.js; we reach it via window + // because top-level function declarations in classic scripts attach there. + if (typeof window.drainViewerWatches === 'function') window.drainViewerWatches(); } function hidePlanViewer() { diff --git a/public/sidebar.js b/public/sidebar.js index 7ca2bc42..838e0442 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -768,7 +768,13 @@ function rebindSidebarEvents(projects) { const session = sessionMap.get(sessionId); if (!session) return; - item.onclick = () => openSession(session); + item.onclick = () => { + if (item.dataset.subagent && session.parentSessionId) { + showSubagentTranscript(session); + } else { + openSession(session); + } + }; // Subagent items are read-only: skip pin, rename, stop, fork, archive, jsonl, launchConfig if (item.dataset.subagent) return; diff --git a/public/style.css b/public/style.css index 31c85dad..1308e7ad 100644 --- a/public/style.css +++ b/public/style.css @@ -2527,6 +2527,40 @@ body { display: flex; flex-direction: column; } margin-bottom: 2px; } +.jsonl-subagent-escape-banner { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + margin-bottom: 12px; + background: rgba(160, 160, 180, 0.08); + border: 1px solid rgba(160, 160, 180, 0.2); + border-radius: 6px; + font-size: 12px; +} + +.jsonl-subagent-escape-label { + color: rgba(255, 255, 255, 0.45); + flex: 1; +} + +.jsonl-subagent-resume-btn { + padding: 4px 10px; + font-size: 11px; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 4px; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + white-space: nowrap; + transition: background 0.15s, border-color 0.15s; +} + +.jsonl-subagent-resume-btn:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.25); +} + .jsonl-toggle { font-size: 11px; font-weight: 500; diff --git a/test/dom-subagent-transcript.test.js b/test/dom-subagent-transcript.test.js new file mode 100644 index 00000000..0760976e --- /dev/null +++ b/test/dom-subagent-transcript.test.js @@ -0,0 +1,320 @@ +// Tests for the subagent transcript routing and transcript view. +// +// Routing design: sidebar.js discriminates on session.parentSessionId +// at click-handler time (not in the IPC layer) so the UI can branch +// without an extra round-trip. These tests verify: +// +// 1. Clicking a subagent sidebar item calls showSubagentTranscript +// (never openSession). +// 2. Clicking a top-level session item calls openSession +// (never showSubagentTranscript). +// 3. showSubagentTranscript renders the escape-hatch banner + message +// entries from the JSONL payload. +// 4. showSubagentTranscript shows an error notice when the IPC call fails. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); + +const PUBLIC_DIR = path.join(__dirname, '..', 'public'); +const MORPHDOM_PATH = path.join(__dirname, '..', 'node_modules', 'morphdom', 'dist', 'morphdom-umd.js'); + +// Minimal HTML that sidebar.js and jsonl-viewer.js expect. +const INDEX_HTML = ` + + + +
+
+
+
+
+
+ + +`; + +// Sample JSONL entries: one user turn, one assistant turn. +const SAMPLE_ENTRIES = [ + { type: 'user', message: { content: 'hello from user' }, isSidechain: true }, + { type: 'assistant', message: { content: [{ type: 'text', text: 'hello from assistant' }] } }, +]; + +function evalInWindow(dom, file) { + const src = fs.readFileSync(file, 'utf8'); + vm.runInContext(src, dom.getInternalVMContext(), { filename: file }); +} + +function setupDom({ readSubagentJsonlResult = { entries: SAMPLE_ENTRIES }, readSubagentJsonlError = null, installSpies = true } = {}) { + const dom = new JSDOM(INDEX_HTML, { + url: 'http://localhost/', + runScripts: 'outside-only', + pretendToBeVisual: true, + }); + const { window } = dom; + + // Preload bridge stub (must be set before JS files are eval'd) + window.api = { + readSubagentJsonl: (_parentId, _agentId) => { + if (readSubagentJsonlError) return Promise.resolve({ error: readSubagentJsonlError }); + return Promise.resolve(readSubagentJsonlResult); + }, + readSessionJsonl: () => Promise.resolve({ entries: [] }), + listSubagents: () => Promise.resolve([]), + startSubagentWatch: () => Promise.resolve({}), + stopSubagentWatch: () => Promise.resolve(), + onSubagentSpawned: () => {}, + onSubagentCompleted: () => {}, + onSubagentWatchEvent: () => {}, + }; + + const doc = window.document; + + // DOM element and state stubs (set before sidebar.js is eval'd so it can + // find them as globals; these don't clash with function names) + const stubValues = { + sidebarContent: doc.getElementById('sidebar-content'), + plansContent: doc.getElementById('plans-content'), + statsContent: doc.getElementById('stats-content'), + memoryContent: doc.getElementById('memory-content'), + placeholder: doc.getElementById('placeholder'), + terminalArea: doc.getElementById('terminal-area'), + jsonlViewer: doc.getElementById('jsonl-viewer'), + jsonlViewerTitle: doc.getElementById('jsonl-viewer-title'), + jsonlViewerSessionId: doc.getElementById('jsonl-viewer-session-id'), + jsonlViewerBody: doc.getElementById('jsonl-viewer-body'), + + // Viewer references (hideAllViewers touches these) + planViewer: doc.createElement('div'), + statsViewer: doc.createElement('div'), + memoryViewer: doc.createElement('div'), + settingsViewer: doc.createElement('div'), + + // State + openSessions: new Map(), + activeSessionId: null, + activePtyIds: new Set(), + pendingSessions: new Map(), + sessionMap: new Map(), + lastActivityTime: new Map(), + sortedOrder: [], + searchMatchIds: null, + searchMatchProjectPaths: null, + showStarredOnly: false, + showRunningOnly: false, + showTodayOnly: false, + visibleSessionCount: 10, + sessionMaxAgeDays: 3650, + attentionSessions: new Set(), + responseReadySessions: new Set(), + sessionBusyState: new Map(), + cachedProjects: [], + cachedAllProjects: [], + + // No-op function stubs (sidebar.js wires these in rebindSidebarEvents; + // the real spies for openSession / showSubagentTranscript are installed + // AFTER eval'ing sidebar.js + jsonl-viewer.js so the JS files don't + // overwrite them) + confirmAndStopSession: () => {}, + pollActiveSessions: () => {}, + showNewSessionPopover: () => {}, + openSettingsViewer: () => {}, + showResumeSessionDialog: () => {}, + showJsonlViewer: () => {}, + forkSession: () => {}, + loadProjects: () => {}, + launchScheduleCreator: () => {}, + setActiveSession: () => {}, + // hideAllViewers is defined in plans-memory-view.js in the real app; stub it here. + hideAllViewers: () => {}, + // openSession is stubbed after eval; we pre-stub it so it's present during eval + openSession: () => {}, + }; + + for (const [k, v] of Object.entries(stubValues)) { + Object.defineProperty(window, k, { value: v, writable: true, configurable: true }); + } + + const morphdomSrc = fs.readFileSync(MORPHDOM_PATH, 'utf8'); + vm.runInContext(morphdomSrc, dom.getInternalVMContext(), { filename: 'morphdom-umd.js' }); + + evalInWindow(dom, path.join(PUBLIC_DIR, 'utils.js')); + evalInWindow(dom, path.join(PUBLIC_DIR, 'icons.js')); + evalInWindow(dom, path.join(PUBLIC_DIR, 'sidebar.js')); + // jsonl-viewer.js defines showSubagentTranscript — eval'd here so the real + // implementation is available when tests exercise it directly. For routing + // tests (tests 1-2) we install spies AFTER this eval to override. + evalInWindow(dom, path.join(PUBLIC_DIR, 'jsonl-viewer.js')); + + // Spy captures — installed after JS eval so they override any function + // declarations that landed on window during the eval phase. + // sidebar.js resolves these as free vars from the window context at + // call time, so overriding here is safe. + // + // NOTE: installSpies=true overrides showSubagentTranscript with a spy, + // which is what the routing tests need. Set installSpies=false when the + // test needs to call the real showSubagentTranscript renderer. + const calls = { + openSession: [], + showSubagentTranscript: [], + }; + window.openSession = (session) => calls.openSession.push(session); + if (installSpies) { + window.showSubagentTranscript = (session) => calls.showSubagentTranscript.push(session); + } + + return { window, document: doc, calls, dom }; +} + +// Build a minimal project fixture for renderProjects. +function makeProject() { + const t = new Date().toISOString(); + return { + projectPath: '/home/dev/proj', + sessions: [ + { + sessionId: 'top-1', + summary: 'top level session', + modified: t, + starred: false, + archived: 0, + messageCount: 2, + subagentType: null, + parentSessionId: null, + agentId: null, + }, + { + sessionId: 'sub:top-1:agent-abc', + summary: 'subagent task', + modified: t, + starred: false, + archived: 0, + messageCount: 5, + subagentType: 'implement', + parentSessionId: 'top-1', + agentId: 'agent-abc', + }, + ], + }; +} + +test('sidebar: clicking a top-level session calls openSession, not showSubagentTranscript', () => { + const { window, document, calls } = setupDom(); + try { + const project = makeProject(); + // Pre-populate sessionMap so rebindSidebarEvents can look up sessions by id. + for (const s of project.sessions) window.sessionMap.set(s.sessionId, s); + window.renderProjects([project], true); + + const topItem = document.querySelector('[data-session-id="top-1"]'); + assert.ok(topItem, 'top-1 session item must be rendered'); + assert.ok(!topItem.dataset.subagent, 'top-level item must not have data-subagent'); + + topItem.click(); + + assert.equal(calls.openSession.length, 1, 'openSession must be called once'); + assert.equal(calls.openSession[0].sessionId, 'top-1'); + assert.equal(calls.showSubagentTranscript.length, 0, 'showSubagentTranscript must NOT be called for top-level session'); + } finally { + window.close(); + } +}); + +test('sidebar: clicking a subagent item calls showSubagentTranscript, not openSession', () => { + const { window, document, calls } = setupDom(); + try { + const project = makeProject(); + // Pre-populate sessionMap so rebindSidebarEvents can look up sessions by id. + for (const s of project.sessions) window.sessionMap.set(s.sessionId, s); + window.renderProjects([project], true); + + // Expand the subagent caret so child items are in DOM + const caret = document.getElementById('sub-caret-top-1'); + assert.ok(caret, 'subagent caret for top-1 must exist'); + caret.click(); + + const subItem = document.querySelector('[data-session-id="sub:top-1:agent-abc"]'); + assert.ok(subItem, 'subagent item must be in DOM after caret expand'); + assert.equal(subItem.dataset.subagent, '1', 'subagent item must have data-subagent=1'); + + subItem.click(); + + assert.equal(calls.showSubagentTranscript.length, 1, 'showSubagentTranscript must be called once'); + assert.equal(calls.showSubagentTranscript[0].sessionId, 'sub:top-1:agent-abc'); + assert.equal(calls.openSession.length, 0, 'openSession must NOT be called for subagent item'); + } finally { + window.close(); + } +}); + +test('showSubagentTranscript: renders escape banner and message entries', async () => { + const { window, document } = setupDom({ + readSubagentJsonlResult: { entries: SAMPLE_ENTRIES }, + installSpies: false, + }); + try { + const session = { + sessionId: 'sub:top-1:agent-abc', + parentSessionId: 'top-1', + agentId: 'agent-abc', + subagentType: 'implement', + description: 'do the work', + }; + + await window.showSubagentTranscript(session); + + // Escape hatch banner must be present + const banner = document.querySelector('.jsonl-subagent-escape-banner'); + assert.ok(banner, 'escape banner must be rendered'); + + const resumeBtn = banner.querySelector('.jsonl-subagent-resume-btn'); + assert.ok(resumeBtn, 'resume button must be present in banner'); + assert.match(resumeBtn.textContent, /Resume in terminal/); + + // Title must reflect subagent type + description + const title = document.getElementById('jsonl-viewer-title'); + assert.match(title.textContent, /implement/i); + assert.match(title.textContent, /do the work/); + + // At least one user entry and one assistant entry + const userEntries = document.querySelectorAll('.jsonl-user'); + const assistantEntries = document.querySelectorAll('.jsonl-assistant'); + assert.ok(userEntries.length >= 1, 'at least one user entry must render'); + assert.ok(assistantEntries.length >= 1, 'at least one assistant entry must render'); + } finally { + window.close(); + } +}); + +test('showSubagentTranscript: shows error when IPC call fails', async () => { + const { window, document } = setupDom({ + readSubagentJsonlError: 'Subagent session not found in cache', + installSpies: false, + }); + try { + const session = { + sessionId: 'sub:top-1:agent-missing', + parentSessionId: 'top-1', + agentId: 'agent-missing', + subagentType: 'explore', + description: 'gone', + }; + + await window.showSubagentTranscript(session); + + const body = document.getElementById('jsonl-viewer-body'); + assert.match(body.textContent, /Error loading transcript/); + assert.match(body.textContent, /Subagent session not found/); + } finally { + window.close(); + } +});