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 }; 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/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; } 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/session-transitions.js b/session-transitions.js index ef9f25a4..6da11bc6 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,108 @@ 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 + } + + // 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 + 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) { + 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'}`); + 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 +188,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 +304,4 @@ function detectSessionTransitions(folder) { } -module.exports = { init, detectSessionTransitions }; +module.exports = { init, detectSessionTransitions, detectSubagentTransitions }; 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 };