From 2fd730fb3f468037d37599eb9e581d4f7dae9127 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:58:14 +0200 Subject: [PATCH 1/5] fix(read-session-file): tolerate concurrent writes to JSONL files readSessionFile JSON.parse'd every line of a session file in a single outer try/catch. If a Claude CLI session was actively writing the file while the worker scan read it, one mid-write line could throw and invalidate the ENTIRE file's session row. With many parallel live sessions this manifested as 'most projects show no sessions after a fresh index'. - Per-line try/catch inside readSessionFile: skip malformed lines, keep parsing the rest. - Per-file try/catch in workers/scan-projects.js: defensive belt and braces so one unparseable file can't abort an entire folder scan. --- read-session-file.js | 7 ++++++- workers/scan-projects.js | 6 ++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/read-session-file.js b/read-session-file.js index f4a533d0..d304d162 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -49,7 +49,12 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) { let agentId = null; let sidechainSeen = false; for (const line of lines) { - const entry = JSON.parse(line); + // Per-line try/catch: a JSONL file being written concurrently by a live + // Claude CLI session can have its tail captured mid-write — one truncated + // line should not invalidate the whole file. Skip the malformed line and + // keep parsing. + let entry; + try { entry = JSON.parse(line); } catch { continue; } if (entry.slug && !slug) slug = entry.slug; if (entry.agentId && !agentId) agentId = entry.agentId; if (entry.isSidechain) sidechainSeen = true; diff --git a/workers/scan-projects.js b/workers/scan-projects.js index be3d5305..2e37666f 100644 --- a/workers/scan-projects.js +++ b/workers/scan-projects.js @@ -15,8 +15,10 @@ function readFolderFromFilesystem(folder) { const indexMtimeMs = getFolderIndexMtimeMs(folderPath); for (const { filePath, parentSessionId } of enumerateSessionFiles(folderPath)) { - const s = readSessionFile(filePath, folder, projectPath, { parentSessionId }); - if (s) sessions.push(s); + try { + const s = readSessionFile(filePath, folder, projectPath, { parentSessionId }); + if (s) sessions.push(s); + } catch {} } return { folder, projectPath, sessions, indexMtimeMs }; From 46d596c83a107f01bfcacf1c9129aa9370e9b5e4 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 09:28:17 +0200 Subject: [PATCH 2/5] fix(derive-project-path): collapse worktree cwd to parent project resolveWorktreePath was defined and exported in the file but never actually called from deriveProjectPath. As a result every worktree under /.claude/worktrees// appeared as a separate project group in the sidebar instead of being grouped under its parent project. Wire the call in both branches of deriveProjectPath (direct .jsonl path and subdirectory path) and export resolveWorktreePath for other callers. --- derive-project-path.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/derive-project-path.js b/derive-project-path.js index f563e35b..829da5e4 100644 --- a/derive-project-path.js +++ b/derive-project-path.js @@ -33,7 +33,7 @@ function deriveProjectPath(folderPath) { for (const e of entries) { if (e.isFile() && e.name.endsWith('.jsonl')) { const cwd = extractCwdFromJsonl(path.join(folderPath, e.name)); - if (cwd) return cwd; + if (cwd) return resolveWorktreePath(cwd); } } // Check session subdirectories (UUID folders with subagent .jsonl files) @@ -52,7 +52,7 @@ function deriveProjectPath(folderPath) { } if (jsonlPath) { const cwd = extractCwdFromJsonl(jsonlPath); - if (cwd) return cwd; + if (cwd) return resolveWorktreePath(cwd); } } } catch {} @@ -61,4 +61,4 @@ function deriveProjectPath(folderPath) { return null; } -module.exports = { deriveProjectPath }; +module.exports = { deriveProjectPath, resolveWorktreePath }; From 3d821b0861d558598e63633559ca16dc6b5cdb87 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:10:08 +0200 Subject: [PATCH 3/5] feat(transitions): detect subagent spawn/completion + live-tail IPC session-transitions.js now tracks per-active-session knownSubagents and emits two new IPC events: - subagent-spawned (parentSessionId, agentId, subagentType, description) when a new agent-*.jsonl file appears under /subagents/ - subagent-completed (parentSessionId, agentId) when an existing file's mtime has been stable for >30s Adds two IPCs for read-only live tailing: - start-subagent-watch (parent, agentId) -> watchId - stop-subagent-watch (watchId) The watcher streams new JSONL entries via subagent-watch-event so the renderer can append them to an open inline-expanded subagent transcript without polling. --- main.js | 60 +++++++++++++++++++++++++++ preload.js | 5 +++ session-transitions.js | 92 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 156 insertions(+), 1 deletion(-) diff --git a/main.js b/main.js index 2040f401..6fbef20e 100644 --- a/main.js +++ b/main.js @@ -80,6 +80,10 @@ const MAX_BUFFER_SIZE = 256 * 1024; const activeSessions = new Map(); let mainWindow = null; +// Subagent live-tail watchers (watchId → { filePath, parentSessionId, agentId }) +const subagentWatchers = new Map(); +let subagentWatcherSeq = 0; + function createWindow() { // Restore saved window bounds const savedBounds = getSetting('global')?.windowBounds; @@ -202,6 +206,11 @@ function createWindow() { } activeSessions.delete(id); } + // Release all subagent file watchers + for (const [, entry] of subagentWatchers) { + try { fs.unwatchFile(entry.filePath); } catch {} + } + subagentWatchers.clear(); mainWindow = null; }); } @@ -940,6 +949,57 @@ ipcMain.handle('list-subagents', (_event, parentSessionId) => { })); }); +// ── Subagent live-tail watchers ────────────────────────────────────────────── + +ipcMain.handle('start-subagent-watch', (_event, parentSessionId, agentId) => { + const row = getCachedSession('sub:' + parentSessionId + ':' + agentId); + if (!row) return { error: 'Subagent not found in cache' }; + const filePath = path.join(PROJECTS_DIR, row.folder, parentSessionId, 'subagents', 'agent-' + agentId + '.jsonl'); + + const watchId = ++subagentWatcherSeq; + let offset = 0; + // Seek to EOF so we only deliver *new* lines + try { offset = fs.statSync(filePath).size; } catch {} + + function readNewEntries() { + try { + const stat = fs.statSync(filePath); + if (stat.size <= offset) return; + const buf = Buffer.alloc(stat.size - offset); + const fd = fs.openSync(filePath, 'r'); + const bytesRead = fs.readSync(fd, buf, 0, buf.length, offset); + fs.closeSync(fd); + if (bytesRead <= 0) return; + offset += bytesRead; + const text = buf.toString('utf8', 0, bytesRead); + const entries = []; + for (const line of text.split('\n')) { + if (!line.trim()) continue; + try { entries.push(JSON.parse(line)); } catch {} + } + if (entries.length > 0 && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('subagent-watch-event', { parentSessionId, agentId, entries }); + } + } catch {} + } + + // fs.watchFile gives reliable polling on Linux where inotify can be unreliable for JSONL appends + fs.watchFile(filePath, { interval: 1000, persistent: false }, readNewEntries); + + subagentWatchers.set(watchId, { filePath, parentSessionId, agentId }); + log.info(`[subagent-watch] start watchId=${watchId} parent=${parentSessionId} agentId=${agentId}`); + return { watchId }; +}); + +ipcMain.handle('stop-subagent-watch', (_event, watchId) => { + const entry = subagentWatchers.get(watchId); + if (!entry) return { ok: false }; + fs.unwatchFile(entry.filePath); + subagentWatchers.delete(watchId); + log.info(`[subagent-watch] stop watchId=${watchId}`); + return { ok: true }; +}); + ipcMain.handle('archive-session', (_event, sessionId, archived) => { const val = archived ? 1 : 0; setArchived(sessionId, val); diff --git a/preload.js b/preload.js index 252d286c..347ef266 100644 --- a/preload.js +++ b/preload.js @@ -23,6 +23,8 @@ contextBridge.exposeInMainWorld('api', { readSessionJsonl: (sessionId) => ipcRenderer.invoke('read-session-jsonl', sessionId), readSubagentJsonl: (parentSessionId, agentId) => ipcRenderer.invoke('read-subagent-jsonl', parentSessionId, agentId), listSubagents: (parentSessionId) => ipcRenderer.invoke('list-subagents', parentSessionId), + startSubagentWatch: (parentSessionId, agentId) => ipcRenderer.invoke('start-subagent-watch', parentSessionId, agentId), + stopSubagentWatch: (watchId) => ipcRenderer.invoke('stop-subagent-watch', watchId), // Settings getSetting: (key) => ipcRenderer.invoke('get-setting', key), @@ -63,6 +65,9 @@ contextBridge.exposeInMainWorld('api', { onSessionForked: (callback) => { ipcRenderer.on('session-forked', (_event, oldId, newId) => callback(oldId, newId)); }, + onSubagentSpawned: (cb) => ipcRenderer.on('subagent-spawned', (_e, payload) => cb(payload)), + onSubagentCompleted: (cb) => ipcRenderer.on('subagent-completed', (_e, payload) => cb(payload)), + onSubagentWatchEvent: (cb) => ipcRenderer.on('subagent-watch-event', (_e, payload) => cb(payload)), onProjectsChanged: (callback) => { ipcRenderer.on('projects-changed', () => callback()); }, diff --git a/session-transitions.js b/session-transitions.js index ef9f25a4..54c49bf9 100644 --- a/session-transitions.js +++ b/session-transitions.js @@ -1,5 +1,6 @@ const path = require('path'); const fs = require('fs'); +const { readSubagentMeta } = require('./read-session-file'); /** * Fork / plan-accept detection for active PTY sessions. @@ -15,6 +16,89 @@ function init(ctx) { rekeyMcpServer = ctx.rekeyMcpServer; } +// --- Subagent spawn / completion detection --- + +/** Walk //subagents/ and detect new or completed subagent files. + * Mutates session.knownSubagents (Map). + * Emits IPC 'subagent-spawned' and 'subagent-completed' via mainWindow. */ +function detectSubagentTransitions(sessionId, session, folderPath) { + const subagentsDir = path.join(folderPath, sessionId, 'subagents'); + let files; + try { + files = fs.readdirSync(subagentsDir).filter(f => f.endsWith('.jsonl')); + } catch { + return; // directory doesn't exist yet — normal + } + + if (!session.knownSubagents) { + session.knownSubagents = new Map(); + } + + const mainWindow = getMainWindow(); + const now = Date.now(); + const STABLE_MS = 30000; // 30 seconds of no mtime advance → completed + + for (const file of files) { + // agent-.jsonl + const m = file.match(/^agent-(.+)\.jsonl$/); + if (!m) continue; + const agentId = m[1]; + const filePath = path.join(subagentsDir, file); + + let stat; + try { stat = fs.statSync(filePath); } catch { continue; } + const mtimeMs = stat.mtimeMs; + + const known = session.knownSubagents.get(agentId); + + if (!known) { + // First sighting — emit subagent-spawned + const meta = readSubagentMeta(filePath) || {}; + session.knownSubagents.set(agentId, { mtimeMs, completed: false }); + log.info(`[subagent-spawn] parent=${sessionId} agentId=${agentId} type=${meta.agentType || 'unknown'}`); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('subagent-spawned', { + parentSessionId: sessionId, + agentId, + subagentType: meta.agentType || null, + description: meta.description || null, + }); + } + } else if (!known.completed) { + if (mtimeMs !== known.mtimeMs) { + // File is still being written — update mtime, reset stability clock + known.mtimeMs = mtimeMs; + known._stableStart = null; + } else { + // mtime stable — start or continue stability timer + if (!known._stableStart) { + known._stableStart = now; + } else if (now - known._stableStart >= STABLE_MS) { + known.completed = true; + log.info(`[subagent-complete] parent=${sessionId} agentId=${agentId}`); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('subagent-completed', { + parentSessionId: sessionId, + agentId, + }); + } + } + } + } + } + + // GC: remove completed entries after 5 minutes to avoid unbounded growth + const GC_TTL = 5 * 60 * 1000; + for (const [agentId, state] of session.knownSubagents) { + if (state.completed && state._completedAt && now - state._completedAt > GC_TTL) { + session.knownSubagents.delete(agentId); + } + if (state.completed && !state._completedAt) { + state._completedAt = now; + } + } +} + // --- Fork / plan-accept detection --- /** Read first few lines of a new .jsonl to extract signals. @@ -85,6 +169,12 @@ function detectSessionTransitions(folder) { } catch { return; } for (const [sessionId, session] of [...activeSessions]) { + // Run subagent detection for all non-exited, non-terminal sessions in this folder + if (!session.exited && !session.isPlainTerminal && session.projectFolder === folder) { + const effectiveSessionId = session.realSessionId || sessionId; + detectSubagentTransitions(effectiveSessionId, session, folderPath); + } + if (session.exited || session.isPlainTerminal || !session.knownJsonlFiles || session.projectFolder !== folder) { if (!session.exited && !session.isPlainTerminal && session.forkFrom) { log.info(`[fork-detect] skipped session=${sessionId} forkFrom=${session.forkFrom||'none'} reason=${session.exited ? 'exited' : session.isPlainTerminal ? 'terminal' : !session.knownJsonlFiles ? 'noKnown' : 'folderMismatch('+session.projectFolder+' vs '+folder+')'}`); @@ -195,4 +285,4 @@ function detectSessionTransitions(folder) { } -module.exports = { init, detectSessionTransitions }; +module.exports = { init, detectSessionTransitions, detectSubagentTransitions }; From 1fea1aa160f5e53334421f7fa52452a6b50a1329 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Thu, 21 May 2026 23:10:17 +0200 Subject: [PATCH 4/5] feat(ui): hierarchical sidebar, grid badges, live indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sidebar.js: subagents are no longer flat siblings of their parent. Each top-level row now shows a 'N subagents' affordance with a disclosure caret; expanded children render indented with a subagentType pill, description as label, and persist expansion state per parent in localStorage. Orphan subagents (parent absent from cache) hoist to a 'Orphan subagents' group. - grid-view.js: each active session card now shows a stack of small pills for currently-running subagents, color-coded by subagentType, capped at 5 with a '+N more' overflow. Listens on onSubagentSpawned/onSubagentCompleted. - jsonl-viewer.js: when an inline-expanded subagent block represents a still- running agent, start an fs.watch via the new IPC and append streamed entries; show a small '● live' indicator until completion. Stops the watch on collapse or subagent-completed. - style.css: minimal styles for new sidebar/grid/live elements, matching existing palette and font weights. --- public/grid-view.js | 109 +++++++++++++++++++++++ public/jsonl-viewer.js | 91 ++++++++++++++++++- public/sidebar.js | 194 +++++++++++++++++++++++++++++++++++++++-- public/style.css | 128 +++++++++++++++++++++++++++ 4 files changed, 514 insertions(+), 8 deletions(-) diff --git a/public/grid-view.js b/public/grid-view.js index 56eea957..f7b76220 100644 --- a/public/grid-view.js +++ b/public/grid-view.js @@ -11,6 +11,111 @@ let gridCards = new Map(); // sessionId → card wrapper element let gridFocusedSessionId = null; +// Active subagents tracked via IPC events (subagent-spawned / subagent-completed). +// parentSessionId → Set of { agentId, subagentType, spawnedAt } +const activeSubagents = new Map(); + +// Subagent type → pill color (matches sidebar palette) +const GRID_SUBAGENT_TYPE_COLORS = { + explore: '#3ecf82', + plan: '#8088ff', + implement: '#ffaa40', + review: '#60bef0', + test: '#ff6464', + default: '#a0a0b4', +}; + +function gridSubagentColor(type) { + return GRID_SUBAGENT_TYPE_COLORS[(type || '').toLowerCase()] || GRID_SUBAGENT_TYPE_COLORS.default; +} + +// Wire IPC listeners (guarded — bindings may not exist yet) +(function initSubagentListeners() { + if (typeof window.api === 'undefined') return; + + if (typeof window.api.onSubagentSpawned === 'function') { + window.api.onSubagentSpawned((event, data) => { + const { parentSessionId, agentId, subagentType } = data || {}; + if (!parentSessionId || !agentId) return; + if (!activeSubagents.has(parentSessionId)) activeSubagents.set(parentSessionId, new Map()); + activeSubagents.get(parentSessionId).set(agentId, { agentId, subagentType, spawnedAt: Date.now() }); + updateGridSubagentPills(parentSessionId); + }); + } + + if (typeof window.api.onSubagentCompleted === 'function') { + window.api.onSubagentCompleted((event, data) => { + const { parentSessionId, agentId } = data || {}; + if (!parentSessionId || !agentId) return; + const map = activeSubagents.get(parentSessionId); + if (map) { + map.delete(agentId); + if (map.size === 0) activeSubagents.delete(parentSessionId); + } + updateGridSubagentPills(parentSessionId); + }); + } +})(); + +// Prune subagents that have been running for more than 60 s without a completion event. +// Called on each grid render cycle. +function pruneStaleSubagents() { + const cutoff = Date.now() - 60000; + for (const [parentId, map] of activeSubagents) { + for (const [agentId, info] of map) { + if (info.spawnedAt < cutoff) map.delete(agentId); + } + if (map.size === 0) activeSubagents.delete(parentId); + } +} + +// Re-render the pill row for a single card (if it exists in the grid). +function updateGridSubagentPills(parentSessionId) { + const card = gridCards.get(parentSessionId); + if (!card) return; + + let pillRow = card.querySelector('.grid-subagent-pills'); + + const map = activeSubagents.get(parentSessionId); + if (!map || map.size === 0) { + if (pillRow) pillRow.remove(); + return; + } + + if (!pillRow) { + pillRow = document.createElement('div'); + pillRow.className = 'grid-subagent-pills'; + // Insert before the footer + const footer = card.querySelector('.grid-card-footer'); + if (footer) { + card.insertBefore(pillRow, footer); + } else { + card.appendChild(pillRow); + } + } + + pillRow.innerHTML = ''; + const entries = [...map.values()]; + const MAX_PILLS = 5; + const shown = entries.slice(0, MAX_PILLS); + const overflow = entries.length - shown.length; + + for (const info of shown) { + const pill = document.createElement('span'); + pill.className = 'grid-subagent-pill'; + pill.title = info.subagentType || 'subagent'; + pill.style.background = gridSubagentColor(info.subagentType); + pillRow.appendChild(pill); + } + + if (overflow > 0) { + const more = document.createElement('span'); + more.className = 'grid-subagent-pill-overflow'; + more.textContent = `+${overflow} more`; + pillRow.appendChild(more); + } +} + function wrapInGridCard(sessionId) { const entry = openSessions.get(sessionId); const session = sessionMap.get(sessionId) || (entry && entry.session); @@ -132,6 +237,10 @@ function wrapInGridCard(sessionId) { gridCards.set(sessionId, card); // Set initial status from the single source of truth updateRunningIndicators(); + + // Render subagent pills for any already-tracked children + pruneStaleSubagents(); + updateGridSubagentPills(sessionId); } function unwrapGridCards() { diff --git a/public/jsonl-viewer.js b/public/jsonl-viewer.js index ffc3593a..2c1a2bca 100644 --- a/public/jsonl-viewer.js +++ b/public/jsonl-viewer.js @@ -8,6 +8,34 @@ let currentViewerSessionId = null; // Reset on each showJsonlViewer call. Key: "||" let agentMatchCounters = {}; +// --- Live subagent tracking --- +// Set of agentIds that are currently live (spawned but not yet completed). +// Keyed as ":" so it's globally unique. +const liveSubagents = new Set(); + +// Register IPC listeners for subagent lifecycle events (called once at module load). +(function initSubagentListeners() { + if (!window.api) return; // guard for non-Electron contexts + window.api.onSubagentSpawned((payload) => { + const key = payload.parentSessionId + ':' + payload.agentId; + liveSubagents.add(key); + }); + window.api.onSubagentCompleted((payload) => { + const key = payload.parentSessionId + ':' + payload.agentId; + liveSubagents.delete(key); + // Notify any active watch container so it can stop the watch and hide the indicator + document.querySelectorAll('[data-subagent-watch-key="' + key + '"]').forEach(el => { + el.dispatchEvent(new CustomEvent('subagent-completed-internal')); + }); + }); + window.api.onSubagentWatchEvent((payload) => { + const key = payload.parentSessionId + ':' + payload.agentId; + document.querySelectorAll('[data-subagent-watch-key="' + key + '"]').forEach(el => { + el.dispatchEvent(new CustomEvent('subagent-watch-data', { detail: payload })); + }); + }); +})() + function renderJsonlText(text) { if (window.marked) { // Escape XML/HTML-like tags so they render as visible text, @@ -237,10 +265,24 @@ const toolRenderers = { let expanded = false; let nestedContainer = null; + let activeWatchId = null; + let liveIndicator = null; + + function stopWatch() { + if (activeWatchId !== null) { + window.api.stopSubagentWatch(activeWatchId).catch(() => {}); + activeWatchId = null; + } + if (liveIndicator) { + liveIndicator.remove(); + liveIndicator = null; + } + } el.addEventListener('click', async () => { if (expanded && nestedContainer) { - // Collapse + // Collapse — stop live watch + stopWatch(); nestedContainer.remove(); nestedContainer = null; expanded = false; @@ -290,6 +332,53 @@ const toolRenderers = { expanded = true; const caret = el.querySelector('.jsonl-agent-caret'); if (caret) caret.innerHTML = '▼'; + + // Start live watch if this subagent is still running + const watchKey = parentSessionId + ':' + match.agentId; + if (liveSubagents.has(watchKey)) { + // Attach the watch key to nestedContainer for event routing + nestedContainer.dataset.subagentWatchKey = watchKey; + + const watchResult = await window.api.startSubagentWatch(parentSessionId, match.agentId); + if (watchResult && watchResult.watchId) { + activeWatchId = watchResult.watchId; + + // Show "● live" indicator in the block header + liveIndicator = document.createElement('span'); + liveIndicator.className = 'jsonl-agent-live'; + liveIndicator.textContent = '● live'; + const toolHeader = el.querySelector('.jsonl-tool-header'); + if (toolHeader) toolHeader.appendChild(liveIndicator); + + // Stream new entries into nestedContainer + nestedContainer.addEventListener('subagent-watch-data', (evt) => { + const { entries: newEntries } = evt.detail; + const merged = mergeLocalCommandEntries(newEntries); + const appendResultMap = new Map(); + for (const entry of merged) { + const blocks2 = entry.message?.content || entry.content; + if (!Array.isArray(blocks2)) continue; + for (const b of blocks2) { + if (b.type === 'tool_result' && b.tool_use_id) { + appendResultMap.set(b.tool_use_id, b.content || b.output || ''); + } + } + } + const savedId = currentViewerSessionId; + currentViewerSessionId = subSessionId; + for (const entry of merged) { + const entryEl = renderJsonlEntry(entry, appendResultMap); + if (entryEl) nestedContainer.appendChild(entryEl); + } + currentViewerSessionId = savedId; + }); + + // Stop watch when subagent completes + nestedContainer.addEventListener('subagent-completed-internal', () => { + stopWatch(); + }); + } + } }); return el; diff --git a/public/sidebar.js b/public/sidebar.js index 45985dd0..2bf193ea 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -17,6 +17,82 @@ function folderId(projectPath) { return 'project-' + projectPath.replace(/[^a-zA-Z0-9_-]/g, '_'); } +// --- Subagent localStorage helpers --- +function getExpandedSubagents() { + try { + return new Set(JSON.parse(localStorage.getItem('expandedSubagents') || '[]')); + } catch (e) { return new Set(); } +} + +function saveExpandedSubagents(set) { + try { + localStorage.setItem('expandedSubagents', JSON.stringify([...set])); + } catch (e) {} +} + +// Subagent type → accent color (background / border) +const SUBAGENT_TYPE_COLORS = { + explore: { bg: 'rgba(62,207,130,0.18)', border: '#3ecf82' }, + plan: { bg: 'rgba(128,136,255,0.20)', border: '#8088ff' }, + implement: { bg: 'rgba(255,170,64,0.18)', border: '#ffaa40' }, + review: { bg: 'rgba(96,190,240,0.18)', border: '#60bef0' }, + test: { bg: 'rgba(255,100,100,0.18)', border: '#ff6464' }, + default: { bg: 'rgba(160,160,180,0.15)', border: '#a0a0b4' }, +}; + +function subagentTypeColor(type) { + const key = (type || '').toLowerCase(); + return SUBAGENT_TYPE_COLORS[key] || SUBAGENT_TYPE_COLORS.default; +} + +function buildSubagentItem(session) { + const item = document.createElement('div'); + item.className = 'sidebar-subagent session-item'; + item.id = 'si-' + session.sessionId; + if (activePtyIds.has(session.sessionId)) item.classList.add('has-running-pty'); + if (attentionSessions.has(session.sessionId)) item.classList.add('needs-attention'); + if (responseReadySessions.has(session.sessionId)) item.classList.add('response-ready'); + if (sessionBusyState.get(session.sessionId)) item.classList.add('cli-busy'); + item.dataset.sessionId = session.sessionId; + item.dataset.subagent = '1'; + + const { bg, border } = subagentTypeColor(session.subagentType); + item.style.borderLeftColor = border; + + const row = document.createElement('div'); + row.className = 'session-row'; + + const typePill = document.createElement('span'); + typePill.className = 'sidebar-subagent-type'; + typePill.textContent = session.subagentType || 'sub'; + typePill.style.background = bg; + typePill.style.borderColor = border; + + const dot = document.createElement('span'); + dot.className = 'session-status-dot' + (activePtyIds.has(session.sessionId) ? ' running' : ''); + + const info = document.createElement('div'); + info.className = 'session-info'; + + const summaryEl = document.createElement('div'); + summaryEl.className = 'session-summary'; + summaryEl.textContent = session.description || session.summary || session.aiTitle || session.sessionId; + + const metaEl = document.createElement('div'); + metaEl.className = 'session-meta'; + metaEl.textContent = session.messageCount ? session.messageCount + ' msgs' : ''; + + info.appendChild(summaryEl); + info.appendChild(metaEl); + + row.appendChild(typePill); + row.appendChild(dot); + row.appendChild(info); + item.appendChild(row); + + return item; +} + function buildSlugGroup(slug, sessions) { const group = document.createElement('div'); const id = slugId(slug); @@ -146,10 +222,25 @@ function renderProjects(projects, resort) { const newSortedOrder = []; + // Build subagent child index from all sessions in this project: parentSessionId → [sessions] + function buildSubagentIndex(sessions) { + const index = new Map(); + for (const s of sessions) { + if (s.parentSessionId) { + if (!index.has(s.parentSessionId)) index.set(s.parentSessionId, []); + index.get(s.parentSessionId).push(s); + } + } + return index; + } + // Process a project's sessions: filter, sort, slug-group, order, and truncate. // Returns { filtered, visible, older, sortOrderEntry } or null if project should be skipped. function processProjectSessions(project, resort) { - let filtered = project.sessions; + // Separate subagents from top-level sessions + const allSessions = project.sessions; + const subagentIndex = buildSubagentIndex(allSessions); + let filtered = allSessions.filter(s => !s.parentSessionId); if (showStarredOnly) filtered = filtered.filter(s => s.starred); if (showRunningOnly) filtered = filtered.filter(s => activePtyIds.has(s.sessionId)); if (showTodayOnly) { @@ -239,17 +330,61 @@ function renderProjects(projects, resort) { } return { - filtered, visible, older, + filtered, visible, older, subagentIndex, sortOrderEntry: { projectPath: project.projectPath, itemIds: allItems.map(item => item.element.id) }, }; } + // Append subagent children beneath a session item element. + function appendSubagentChildren(parentEl, parentSessionId, subagentIndex) { + const children = subagentIndex && subagentIndex.get(parentSessionId); + if (!children || children.length === 0) return; + + const expandedSet = getExpandedSubagents(); + const caretId = 'sub-caret-' + parentSessionId.replace(/[^a-zA-Z0-9_-]/g, '_'); + const isExpanded = expandedSet.has(parentSessionId); + + // Caret/toggle row attached to parent item + const caret = document.createElement('div'); + caret.className = 'sidebar-children-caret'; + caret.id = caretId; + if (isExpanded) caret.classList.add('expanded'); + caret.innerHTML = ` ${children.length} subagent${children.length !== 1 ? 's' : ''}`; + + const childrenContainer = document.createElement('div'); + childrenContainer.className = 'sidebar-subagents-container'; + childrenContainer.id = 'subc-' + parentSessionId.replace(/[^a-zA-Z0-9_-]/g, '_'); + childrenContainer.style.display = isExpanded ? '' : 'none'; + + for (const child of children) { + childrenContainer.appendChild(buildSubagentItem(child)); + } + + caret.addEventListener('click', (e) => { + e.stopPropagation(); + const open = childrenContainer.style.display !== 'none'; + childrenContainer.style.display = open ? 'none' : ''; + caret.classList.toggle('expanded', !open); + const set = getExpandedSubagents(); + if (open) { set.delete(parentSessionId); } else { set.add(parentSessionId); } + saveExpandedSubagents(set); + }); + + parentEl.after(caret); + caret.after(childrenContainer); + } + // Build the sessions list DOM (shared between projects and worktrees) - function buildSessionsList(fId, visible, older) { + function buildSessionsList(fId, visible, older, subagentIndex) { const sessionsList = document.createElement('div'); sessionsList.className = 'project-sessions'; sessionsList.id = 'sessions-' + fId; - for (const item of visible) sessionsList.appendChild(item.element); + for (const item of visible) { + sessionsList.appendChild(item.element); + // Attach subagent children for top-level sessions + const sid = item.element.dataset && item.element.dataset.sessionId; + if (sid) appendSubagentChildren(item.element, sid, subagentIndex); + } if (older.length > 0) { const moreBtn = document.createElement('div'); moreBtn.className = 'sessions-more-toggle'; @@ -259,10 +394,38 @@ function renderProjects(projects, resort) { olderList.className = 'sessions-older'; olderList.id = 'older-list-' + fId; olderList.style.display = 'none'; - for (const item of older) olderList.appendChild(item.element); + for (const item of older) { + olderList.appendChild(item.element); + const sid = item.element.dataset && item.element.dataset.sessionId; + if (sid) appendSubagentChildren(item.element, sid, subagentIndex); + } sessionsList.appendChild(moreBtn); sessionsList.appendChild(olderList); } + + // Orphan subagents: children whose parentSessionId has no top-level session in this project + if (subagentIndex) { + const allTopLevelIds = new Set([...visible, ...older].map(i => i.element.dataset && i.element.dataset.sessionId).filter(Boolean)); + const orphans = []; + for (const [parentId, kids] of subagentIndex) { + if (!allTopLevelIds.has(parentId)) { + for (const k of kids) orphans.push(k); + } + } + if (orphans.length > 0) { + const orphanGroup = document.createElement('div'); + orphanGroup.className = 'sidebar-orphan-subagents'; + const orphanLabel = document.createElement('div'); + orphanLabel.className = 'sidebar-orphan-label'; + orphanLabel.textContent = 'Orphan subagents'; + orphanGroup.appendChild(orphanLabel); + for (const orphan of orphans) { + orphanGroup.appendChild(buildSubagentItem(orphan)); + } + sessionsList.appendChild(orphanGroup); + } + } + return sessionsList; } @@ -311,7 +474,7 @@ function renderProjects(projects, resort) { newBtn.title = 'New session'; header.appendChild(newBtn); - const sessionsList = buildSessionsList(fId, visible, older); + const sessionsList = buildSessionsList(fId, visible, older, subagentIndex); // Auto-collapse if most recent session is older than threshold, or project matched with no sessions if (project._projectMatchedOnly) { @@ -357,7 +520,7 @@ function renderProjects(projects, resort) { wtNewBtn.title = 'New session in worktree'; wtHeader.appendChild(wtNewBtn); - const wtSessionsList = buildSessionsList(wtFId, wtResult.visible, wtResult.older); + const wtSessionsList = buildSessionsList(wtFId, wtResult.visible, wtResult.older, wtResult.subagentIndex); wtSessionsList.className = 'worktree-sessions'; // Auto-collapse worktree if stale @@ -403,6 +566,20 @@ function renderProjects(projects, resort) { toEl.classList.remove('collapsed'); } } + if (fromEl.classList.contains('sidebar-children-caret')) { + if (fromEl.classList.contains('expanded')) { + toEl.classList.add('expanded'); + } else { + toEl.classList.remove('expanded'); + } + } + if (fromEl.classList.contains('sidebar-subagents-container')) { + if (fromEl.style.display !== 'none') { + toEl.style.display = ''; + } else { + toEl.style.display = 'none'; + } + } if (fromEl.classList.contains('sessions-older') && fromEl.style.display !== 'none') { toEl.style.display = ''; } @@ -560,6 +737,9 @@ function rebindSidebarEvents(projects) { item.onclick = () => openSession(session); + // Subagent items are read-only: skip pin, rename, stop, fork, archive, jsonl, launchConfig + if (item.dataset.subagent) return; + const pin = item.querySelector('.session-pin'); if (pin) { pin.onclick = async (e) => { diff --git a/public/style.css b/public/style.css index a50b1e09..39212bc2 100644 --- a/public/style.css +++ b/public/style.css @@ -1480,6 +1480,134 @@ body { display: flex; flex-direction: column; } } +/* ========== SUBAGENT SIDEBAR ========== */ + +/* Caret toggle row that appears below a parent session item */ +.sidebar-children-caret { + display: flex; + align-items: center; + gap: 5px; + margin: 0 12px 0 28px; + padding: 2px 6px; + font-size: 10px; + color: #7a7a96; + cursor: pointer; + user-select: none; + border-radius: 4px; + transition: color 0.12s, background 0.12s; +} + +.sidebar-children-caret:hover { + color: #b0b0c8; + background: rgba(255,255,255,0.05); +} + +.sidebar-children-caret .caret-arrow { + display: inline-block; + font-size: 8px; + transition: transform 0.15s; +} + +.sidebar-children-caret.expanded .caret-arrow { + transform: rotate(90deg); +} + +/* Container for the child subagent items */ +.sidebar-subagents-container { + /* display toggled via JS */ +} + +/* Nested subagent row */ +.sidebar-subagent { + padding-left: 28px !important; + border-left: 2px solid transparent; + margin-left: 12px; + font-size: 11.5px; +} + +.sidebar-subagent .session-row { + padding: 6px 8px !important; + gap: 6px !important; +} + +.sidebar-subagent .session-summary { + font-size: 11.5px; + color: #c0c0d4; +} + +.sidebar-subagent .session-meta { + font-size: 10px; + color: #6a6a80; +} + +/* Subagent type pill */ +.sidebar-subagent-type { + flex-shrink: 0; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.3px; + text-transform: uppercase; + padding: 1px 5px; + border-radius: 3px; + border: 1px solid transparent; + color: #e0e0f0; + white-space: nowrap; + line-height: 1.6; +} + +/* Orphan subagent group label */ +.sidebar-orphan-subagents { + margin-top: 4px; + border-top: 1px solid rgba(255,255,255,0.05); +} + +.sidebar-orphan-label { + padding: 4px 12px; + font-size: 9.5px; + color: #6a6a80; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* ========== SUBAGENT GRID PILLS ========== */ + +/* Horizontal pill strip shown in grid card above the footer */ +.grid-subagent-pills { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + background: rgba(255,255,255,0.02); + border-top: 1px solid rgba(255,255,255,0.04); + flex-shrink: 0; + flex-wrap: nowrap; + overflow: hidden; +} + +/* Single colored dot pill — tooltip shows type */ +.grid-subagent-pill { + width: 10px; + height: 6px; + border-radius: 3px; + flex-shrink: 0; + opacity: 0.85; + transition: opacity 0.12s, transform 0.12s; + cursor: default; +} + +.grid-subagent-pill:hover { + opacity: 1; + transform: scaleY(1.3); +} + +/* "+N more" overflow label */ +.grid-subagent-pill-overflow { + font-size: 9px; + color: #7a7a90; + white-space: nowrap; + margin-left: 2px; +} + /* ========== SCROLLBAR ========== */ #sidebar-content::-webkit-scrollbar, #plans-content::-webkit-scrollbar { width: 5px; } #sidebar-content::-webkit-scrollbar-track, #plans-content::-webkit-scrollbar-track { background: transparent; } From a6210a625606ac6f80f31a33a91e7aa0e861d233 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 09:29:20 +0200 Subject: [PATCH 5/5] fix(transitions): silent cold-start to avoid IPC flood on attach When Switchboard attaches to a session that already has many subagent files on disk (e.g. a long-running session with 100+ Agent calls), the first walk of detectSubagentTransitions treated every existing file as a 'first sighting' and emitted subagent-spawned for each. 30s later it emitted subagent-completed for each. Hundreds of IPC events back to back froze the renderer UI. Distinguish bootstrap (first walk for this session) from steady-state. On bootstrap, record every existing file in knownSubagents silently: - files modified in the last 60s stay in the active lifecycle (could be mid-run, will eventually fire subagent-completed) - older files are marked completed immediately with no IPC Only files that appear AFTER the bootstrap walk fire subagent-spawned. --- session-transitions.js | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/session-transitions.js b/session-transitions.js index 54c49bf9..6da11bc6 100644 --- a/session-transitions.js +++ b/session-transitions.js @@ -30,13 +30,20 @@ function detectSubagentTransitions(sessionId, session, folderPath) { return; // directory doesn't exist yet — normal } - if (!session.knownSubagents) { + // First walk for this session: pre-populate knownSubagents with every + // existing file silently so we don't flood the renderer with spawn/complete + // events for agents that already finished before Switchboard started watching. + // Files modified in the last 60s get a normal lifecycle; older ones are + // recorded as already-completed without IPC. + const isBootstrap = !session.knownSubagents; + if (isBootstrap) { session.knownSubagents = new Map(); } const mainWindow = getMainWindow(); const now = Date.now(); const STABLE_MS = 30000; // 30 seconds of no mtime advance → completed + const BOOTSTRAP_LIVE_MS = 60000; // file modified in last 60s = still alive at boot for (const file of files) { // agent-.jsonl @@ -52,7 +59,19 @@ function detectSubagentTransitions(sessionId, session, folderPath) { const known = session.knownSubagents.get(agentId); if (!known) { - // First sighting — emit subagent-spawned + if (isBootstrap) { + // Cold-start initialization — record silently without firing IPC. + // Treat recently-modified files as still-active so they can complete + // through the normal lifecycle; treat older ones as already done. + const looksAlive = (now - mtimeMs) < BOOTSTRAP_LIVE_MS; + session.knownSubagents.set(agentId, { + mtimeMs, + completed: !looksAlive, + _completedAt: looksAlive ? null : now, + }); + continue; + } + // First sighting post-bootstrap — real spawn event const meta = readSubagentMeta(filePath) || {}; session.knownSubagents.set(agentId, { mtimeMs, completed: false }); log.info(`[subagent-spawn] parent=${sessionId} agentId=${agentId} type=${meta.agentType || 'unknown'}`);