From b4d1ded04803ff0f6589a7b696287cfa941c1986 Mon Sep 17 00:00:00 2001 From: Ari Mahpour Date: Sun, 29 Mar 2026 21:26:06 -0700 Subject: [PATCH 01/10] feat: nest subagent sessions inside parent session tile (#19) Subagents now appear nested within their parent session card instead of as separate dashboard tiles. Completed subagents are hidden from the tile (live command center view) but remain visible as expandable "Spawned Agent" blocks in the transcript history. Backend: - Add parent_session_id and agent_type columns to sessions table - SubagentStart/Stop hooks create linked subagent sessions with parent ref - API nests subagents array inside parent session responses - WebSocket initial_state and broadcasts include nested subagents - File watcher detects subagent transcript paths and sets parent link Frontend: - Dashboard tiles show collapsible active subagent rows with status/type/duration - WebSocket updates for subagents route to parent card (never create tiles) - Expand state persists across real-time re-renders - Transcript view renders Agent tool calls as "Spawned Agent" blocks with type badge, prompt, result summary, and expandable full conversation Co-Authored-By: Claude Opus 4.6 (1M context) --- public/css/style.css | 236 +++++++++++++++++++++++++++++++++++++++ public/js/app.js | 21 +++- public/js/dashboard.js | 98 ++++++++++++++++ public/js/terminal.js | 156 ++++++++++++++++++++++++-- server/db.py | 39 ++++++- server/hooks.py | 48 +++++++- server/routes/api.py | 9 +- server/routes/ws.py | 6 +- server/watcher.py | 15 +++ tests/unit/test_api.py | 63 +++++++++++ tests/unit/test_db.py | 61 ++++++++++ tests/unit/test_hooks.py | 101 +++++++++++++++++ 12 files changed, 835 insertions(+), 18 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 8ba2d8d..09617fd 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -456,6 +456,161 @@ body { .context-bar-fill.high { background: var(--status-waiting); } .context-bar-fill.critical { background: var(--danger); } +/* Subagents section */ +.card-subagents { + margin: 8px 0 4px; + border-top: 1px solid var(--border); + padding-top: 6px; +} + +.subagents-header { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); + cursor: pointer; + padding: 2px 0; +} + +.subagents-header:hover { + color: var(--text-primary); +} + +.subagents-list { + margin-top: 4px; +} + +.subagent-row { + display: flex; + align-items: center; + gap: 8px; + padding: 3px 0 3px 16px; + font-size: 12px; + color: var(--text-secondary); +} + +.subagent-type { + font-weight: 600; + color: var(--text-primary); + min-width: 60px; +} + +.subagent-desc { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.subagent-duration { + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 11px; + opacity: 0.7; +} + +.status-dot.small { + width: 6px; + height: 6px; + min-width: 6px; +} + +/* Subagents section */ +.card-subagents { + margin: 8px 0 4px; + border-top: 1px solid var(--border); + padding-top: 6px; +} + +.subagents-header { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); + cursor: pointer; + padding: 2px 0; +} + +.subagents-header:hover { + color: var(--text); +} + +.subagents-list { + margin-top: 4px; +} + +.subagent-item { + border-left: 2px solid var(--border); + margin-left: 4px; +} + +.subagent-row { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0 4px 12px; + font-size: 12px; + color: var(--text-secondary); + cursor: pointer; + border-radius: var(--radius); +} + +.subagent-row:hover { + background: var(--surface-2); +} + +.subagent-type { + font-weight: 600; + color: var(--text); + min-width: 50px; +} + +.subagent-desc { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.subagent-duration { + font-family: var(--font-mono); + font-size: 11px; + opacity: 0.7; +} + +.subagent-expand { + margin-left: auto; + font-size: 10px; + transition: transform 0.15s; +} + +.subagent-transcript { + padding: 6px 12px 6px 16px; + font-size: 12px; + max-height: 300px; + overflow-y: auto; + background: var(--surface-0); + border-radius: 0 0 var(--radius) var(--radius); +} + +.subagent-transcript .preview-msg { + padding: 3px 0; + border-bottom: 1px solid var(--border); + line-height: 1.4; + word-break: break-word; +} + +.subagent-transcript .preview-msg:last-child { + border-bottom: none; +} + +.subagent-loading { + color: var(--text-dim); + font-style: italic; + padding: 4px 0; +} + .card-footer { display: flex; justify-content: space-between; @@ -1351,6 +1506,87 @@ body { display: block; } +/* ---- Agent Blocks in Transcript ---- */ +.agent-block { + margin: 8px 0 12px 38px; + border: 1px solid var(--border-light); + border-left: 3px solid #06b6d4; + border-radius: 0 var(--radius) var(--radius) 0; + background: var(--surface-1); + overflow: hidden; +} + +.agent-block-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--surface-2); + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + border-bottom: 1px solid var(--border); +} + +.agent-block-icon { font-size: 14px; } + +.agent-block-title { + font-weight: 700; + color: var(--text); + font-size: 13px; +} + +.agent-type-badge { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; + padding: 2px 8px; + border-radius: 3px; + background: rgba(6, 182, 212, 0.15); + color: #06b6d4; +} + +.agent-block-prompt { + padding: 8px 12px; + font-size: 13px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border); +} + +.agent-block-result { + padding: 8px 12px; + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary); + max-height: 200px; + overflow-y: auto; +} + +.agent-transcript-toggle { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + font-size: 12px; + color: var(--text-dim); + cursor: pointer; + border-top: 1px solid var(--border); + background: var(--surface-2); +} + +.agent-transcript-toggle:hover { + color: var(--text); +} + +.agent-transcript-content { + border-top: 1px solid var(--border); + padding: 8px; + max-height: 500px; + overflow-y: auto; + background: var(--surface-0); +} + /* ---- History View ---- */ .history-header { display: flex; diff --git a/public/js/app.js b/public/js/app.js index 5037ba6..5eee6c7 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -251,8 +251,25 @@ const App = { this.updateStats(); } else if (data.type === 'session_update') { const session = data.session; - this.sessions[session.id] = session; - Dashboard.updateCard(session); + if (session.parent_session_id) { + // Subagent update — route to parent card, never create a tile + const parent = this.sessions[session.parent_session_id]; + if (parent) { + const subs = parent.subagents || []; + const idx = subs.findIndex(s => s.id === session.id); + if (idx >= 0) subs[idx] = session; + else subs.push(session); + parent.subagents = subs; + Dashboard.updateCard(parent); + } + } else { + // Parent session update — preserve existing subagents if not provided + if (!session.subagents && this.sessions[session.id]) { + session.subagents = this.sessions[session.id].subagents || []; + } + this.sessions[session.id] = session; + Dashboard.updateCard(session); + } this.updateStats(); } }, diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 4ac4009..4fe3b39 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -3,6 +3,9 @@ */ const Dashboard = { + _expandedSubagentSections: new Set(), + _expandedSubagentTranscripts: new Set(), + _emptyHTML: `

No active sessions

@@ -51,6 +54,13 @@ const Dashboard = { const existing = document.querySelector(`.session-card[data-session-id="${session.id}"]`); if (existing) { existing.innerHTML = this._cardHTML(session); + // Reload transcripts for any expanded subagent transcripts + this._expandedSubagentTranscripts.forEach(agentId => { + const container = document.getElementById(`subagent-transcript-${agentId}`); + if (container && container.style.display !== 'none') { + this._loadSubagentTranscript(agentId, container); + } + }); } else { // New session — add card const grid = document.getElementById('session-grid'); @@ -148,6 +158,7 @@ const Dashboard = {
+ ${this._subagentsHTML(s.subagents)}
${this._escapeHTML(promptPreview)}
- ${group.resultSummary ? `
${this._fmtText(group.resultSummary.substring(0, 500))}
` : ''} + ${group.resultSummary ? `
${this._fmtText(String(group.resultSummary).substring(0, 500))}
` : ''} ${transcriptSection} `; }, From f6079f5a1b8cfa41e6a1945d821b28149e14cc19 Mon Sep 17 00:00:00 2001 From: Ari Mahpour Date: Mon, 30 Mar 2026 09:40:26 -0700 Subject: [PATCH 07/10] fix: watcher updates last_activity_at and revives stale sessions When the watcher processes new JSONL entries for a session, it now updates last_activity_at. If the session was marked stale but new entries are arriving (user resumed the session), it resets status to idle. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/watcher.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/watcher.py b/server/watcher.py index a22f150..db9c584 100644 --- a/server/watcher.py +++ b/server/watcher.py @@ -5,6 +5,7 @@ import logging import os import re +from datetime import UTC, datetime from pathlib import Path from server import db @@ -470,7 +471,12 @@ async def _process_file_changes(file_path: str): ) # Enrich session with discovered metadata - session_updates = {} + # Always update last_activity_at when new entries arrive + session_updates: dict = {"last_activity_at": datetime.now(UTC).isoformat()} + # If session was stale but new entries are coming in, mark it active again + session = await db.get_session(session_id) + if session and session.get("status") == "stale": + session_updates["status"] = "idle" if model: session_updates["model"] = model if slug: From 8d92ed6d2ca3f35bd87ee420d9166b703d5ba4a0 Mon Sep 17 00:00:00 2001 From: Ari Mahpour Date: Mon, 30 Mar 2026 09:49:39 -0700 Subject: [PATCH 08/10] fix: only show actively working subagents in tile The tile now only shows subagents with status "working". When none are running, shows a subtle "N subagents completed" label. When some are running with others done, shows "N running (M done)". This keeps the command center focused on what's in flight right now. Co-Authored-By: Claude Opus 4.6 (1M context) --- public/css/style.css | 13 +++++++++++++ public/js/dashboard.js | 22 +++++++++++++--------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 09617fd..0bb2ae0 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -585,6 +585,19 @@ body { transition: transform 0.15s; } +.card-subagents-done { + font-size: 11px; + color: var(--text-dim); + padding: 4px 0; + border-top: 1px solid var(--border); + margin-top: 6px; +} + +.subagents-done-count { + color: var(--text-dim); + font-weight: 400; +} + .subagent-transcript { padding: 6px 12px 6px 16px; font-size: 12px; diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 9fa7e62..3a7f0eb 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -312,22 +312,25 @@ const Dashboard = { }, _subagentsHTML(subagents) { - // Only show in-flight subagents in the tile (not completed or stale) - const active = (subagents || []).filter(a => a.status !== 'completed' && a.status !== 'stale'); - if (active.length === 0) return ''; - subagents = active; + if (!subagents || subagents.length === 0) return ''; + // Only show actively working subagents in the tile + const running = subagents.filter(a => a.status === 'working'); + const doneCount = subagents.length - running.length; + if (running.length === 0) { + // Nothing in flight — just show a subtle completed count + return doneCount > 0 ? `
${doneCount} subagent${doneCount !== 1 ? 's' : ''} completed
` : ''; + } // Use first subagent's parent_session_id as section key, fallback to combined IDs - const sectionKey = subagents[0]?.parent_session_id || subagents.map(a => a.id).join(','); + const sectionKey = running[0]?.parent_session_id || running.map(a => a.id).join(','); const sectionOpen = this._expandedSubagentSections.has(sectionKey); - const rows = subagents.map(a => { - const status = a.status || 'completed'; + const rows = running.map(a => { const type = a.agent_type || 'agent'; const desc = a.task_description ? this._escapeHTML(a.task_description.substring(0, 100)) : ''; const duration = this._duration(a.started_at); const transcriptOpen = this._expandedSubagentTranscripts.has(a.id); return `
- + ${this._escapeHTML(type)} ${desc ? `${desc}` : ''} ${duration} @@ -336,10 +339,11 @@ const Dashboard = {
`; }).join(''); + const doneLabel = doneCount > 0 ? ` (${doneCount} done)` : ''; return `
- ${subagents.length} subagent${subagents.length !== 1 ? 's' : ''} + ${running.length} subagent${running.length !== 1 ? 's' : ''} running${doneLabel}
${rows}
`; From c87463c9bfb35ae1a6fddf3971762d7ab28ef3dd Mon Sep 17 00:00:00 2001 From: Ari Mahpour Date: Mon, 30 Mar 2026 09:53:14 -0700 Subject: [PATCH 09/10] fix: tile shows nothing when no subagents are actively working MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the "N subagents completed" label entirely. The tile is a live command center — if nothing is running, show nothing. Completed subagent history lives in the transcript view only. Co-Authored-By: Claude Opus 4.6 (1M context) --- public/js/dashboard.js | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 3a7f0eb..a11f82c 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -315,11 +315,7 @@ const Dashboard = { if (!subagents || subagents.length === 0) return ''; // Only show actively working subagents in the tile const running = subagents.filter(a => a.status === 'working'); - const doneCount = subagents.length - running.length; - if (running.length === 0) { - // Nothing in flight — just show a subtle completed count - return doneCount > 0 ? `
${doneCount} subagent${doneCount !== 1 ? 's' : ''} completed
` : ''; - } + if (running.length === 0) return ''; // Use first subagent's parent_session_id as section key, fallback to combined IDs const sectionKey = running[0]?.parent_session_id || running.map(a => a.id).join(','); const sectionOpen = this._expandedSubagentSections.has(sectionKey); @@ -339,11 +335,10 @@ const Dashboard = {
`; }).join(''); - const doneLabel = doneCount > 0 ? ` (${doneCount} done)` : ''; return `
- ${running.length} subagent${running.length !== 1 ? 's' : ''} running${doneLabel} + ${running.length} subagent${running.length !== 1 ? 's' : ''} running
${rows}
`; From a763002b75544e020ee295575e61fa6e867583b1 Mon Sep 17 00:00:00 2001 From: Ari Mahpour Date: Mon, 30 Mar 2026 09:55:55 -0700 Subject: [PATCH 10/10] fix: watcher sets new subagent sessions to working status When the watcher creates a subagent session from a transcript file, set status to "working" so it appears on the tile. Previously it defaulted to "idle" which the tile filter excluded, making subagents invisible when hooks go to a different port. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/watcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/watcher.py b/server/watcher.py index db9c584..b9d9f3b 100644 --- a/server/watcher.py +++ b/server/watcher.py @@ -415,7 +415,7 @@ async def _process_file_changes(file_path: str): if session is None: await db.create_session(session_id, project_path=str(Path(file_path).parent)) if parent_id: - await db.update_session(session_id, parent_session_id=parent_id) + await db.update_session(session_id, parent_session_id=parent_id, status="working") # Track latest usage from new entries (not cumulative — use most recent snapshot) latest_input = 0