From aa07c3647aecd7243c8e1ec7a01e1e37c569abc7 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 24 Jun 2026 16:19:09 +0530 Subject: [PATCH 1/2] feat(tui): keep finished swarm members visible during the run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finished swarm members vanished from the AGENTS sidebar after a 1.5s linger, so on a long turn (the orchestrator keeps working after the subagents finish) they were gone before the user could inspect them — the panel read "no agents spawned" mid-run and the clickable drill-in had nothing to click. Now a finished member stays in the panel (solid check, still clickable to open its subchat) for as long as the run is in flight, and only fades out and drops once the turn ends. Spawns are scoped to the active run (row.runID matches activeRunID) so a previous turn's members do not reappear when a later turn keeps members visible. --- internal/tui/sidebar.go | 23 ++++++++++++----- internal/tui/sidebar_test.go | 49 ++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index 4118eb8ae..aa75d7d25 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -209,7 +209,9 @@ func (m model) swarmSpawnedAgents() []swarmAgent { pendingTask := "" havePending := false for _, row := range m.transcript { - if row.tool != "swarm_spawn" { + // Scope to the current run's spawns so finished members from an earlier + // turn don't reappear when a later run keeps members visible (below). + if row.tool != "swarm_spawn" || row.runID != m.activeRunID { continue } switch row.kind { @@ -239,21 +241,30 @@ func (m model) swarmSpawnedAgents() []swarmAgent { agents = append(agents, swarmAgent{id: id, name: name, sessionID: m.swarmSessionMap[id]}) } } - // Members the latest swarm_status reports finished LINGER briefly with a fading - // ✓ (a smooth exit, not an abrupt pop) then drop. Members not yet in a status - // report (just spawned) stay live. The done-time is stamped by the spinner tick - // (stampSwarmDone); until then a freshly-finished member shows as finishing. + // Finished members stay in the panel (✓, still clickable) while the run is in + // flight, so the user can drill into what each subagent did even after it + // completes mid-turn. Only once the turn ends do they LINGER briefly with a + // fading ✓ then drop — a smooth exit, not an abrupt pop. Members not yet in a + // status report (just spawned) stay live. The done-time is stamped by the + // spinner tick (stampSwarmDone) for the post-turn fade. if status := m.swarmMemberStatus(); len(status) > 0 { live := agents[:0:0] for _, a := range agents { a.state = status[a.id] switch a.state { case "done", "failed", "completed", "cancelled": + a.finishing = true + if m.pending { + // Run still going: keep it visible and clickable, no fade. + a.finishedAt = time.Time{} + live = append(live, a) + continue + } + // Turn ended: fade out over the linger window, then drop. doneAt, stamped := m.swarmDoneAt[a.id] if stamped && m.now().Sub(doneAt) >= sidebarAgentLinger { continue // past the linger window — remove } - a.finishing = true a.finishedAt = doneAt live = append(live, a) default: diff --git a/internal/tui/sidebar_test.go b/internal/tui/sidebar_test.go index 4f41a7af4..75f812da1 100644 --- a/internal/tui/sidebar_test.go +++ b/internal/tui/sidebar_test.go @@ -317,6 +317,55 @@ func TestSidebarShowsSwarmSpawnedAgents(t *testing.T) { // TestSwarmSpawnedAgentFallsBackToID covers a result row with no preceding call // row (e.g. a resumed transcript that dropped the call): the member is still +// A finished member stays visible (and clickable) while the run is still in +// flight, so the user can inspect it; only once the turn ends does it drop. +func TestSwarmAgentsPersistWhileRunInFlight(t *testing.T) { + base := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) + m := sidebarTestModel() + m.now = func() time.Time { return base } + m.pending = true // run still going + m.transcript = append(m.transcript, + transcriptRow{kind: rowToolCall, tool: "swarm_spawn", detail: "build homepage"}, + transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task subagent-1 on team default."}, + transcriptRow{kind: rowToolResult, tool: "swarm_collect", detail: "Results: 1 task(s)\n- subagent-1 [done] build homepage"}, + ) + // Long past the linger window — but it must still show while pending. + m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-10 * sidebarAgentLinger)} + + agents := m.swarmSpawnedAgents() + if len(agents) != 1 { + t.Fatalf("a finished member must stay while the run is in flight, got %d: %+v", len(agents), agents) + } + if !agents[0].finishing { + t.Fatalf("a finished member should render done (✓), got %+v", agents[0]) + } + + // Once the turn ends, the long-finished member fades out and drops. + m.pending = false + if got := len(m.swarmSpawnedAgents()); got != 0 { + t.Fatalf("after the turn ends a long-finished member should drop, got %d", got) + } +} + +// Members from a previous run must not reappear in a later run. +func TestSwarmAgentsScopedToActiveRun(t *testing.T) { + m := sidebarTestModel() + m.pending = true + m.activeRunID = 2 + m.transcript = append(m.transcript, + // Old run's spawn (runID 1) — should be ignored now. + transcriptRow{kind: rowToolCall, tool: "swarm_spawn", detail: "old task", runID: 1}, + transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task old-1 on team default.", runID: 1}, + // Current run's spawn (runID 2). + transcriptRow{kind: rowToolCall, tool: "swarm_spawn", detail: "new task", runID: 2}, + transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task new-1 on team default.", runID: 2}, + ) + agents := m.swarmSpawnedAgents() + if len(agents) != 1 || agents[0].id != "new-1" { + t.Fatalf("only the current run's member should show, got %+v", agents) + } +} + // shown, named by its id. func TestSwarmSpawnedAgentFallsBackToID(t *testing.T) { m := sidebarTestModel() From 1077f1acf1b4df6e1e88661f4c16016b7303f24c Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 24 Jun 2026 18:38:31 +0530 Subject: [PATCH 2/2] fix(tui): scope swarm member status to the active run Address review: swarmSpawnedAgents filtered spawn rows by activeRunID, but swarmMemberStatus still read swarm_status/swarm_collect rows from any run. With reused task ids, a stale prior-run status could mark a current member done and wrongly fade/drop it. Scope swarmMemberStatus to the active run too, and tighten the tests (non-zero run id; a stale same-id done status from a prior run). --- internal/tui/sidebar.go | 6 +++++- internal/tui/sidebar_test.go | 28 ++++++++++++++++++---------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index aa75d7d25..d9b93a71b 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -286,10 +286,14 @@ var swarmStatusRe = regexp.MustCompile(`(?m)^\s*[-–—]?\s*(\S+)\s+\[([a-zA-Z] // last report in transcript order wins. Empty when no report has run yet. This // is what lets a swarm_collect that runs while members are still working keep the // AGENTS panel populated instead of clearing it. +// +// Scoped to the active run, exactly like the spawn rows in swarmSpawnedAgents: a +// prior run's status/collect (whose task ids can repeat) must not mark a current +// member done/failed and drop or fade it. func (m model) swarmMemberStatus() map[string]string { status := map[string]string{} for _, row := range m.transcript { - if row.kind != rowToolResult { + if row.kind != rowToolResult || row.runID != m.activeRunID { continue } if row.tool != "swarm_status" && row.tool != "swarm_collect" { diff --git a/internal/tui/sidebar_test.go b/internal/tui/sidebar_test.go index 75f812da1..8fcf1bc4c 100644 --- a/internal/tui/sidebar_test.go +++ b/internal/tui/sidebar_test.go @@ -323,11 +323,12 @@ func TestSwarmAgentsPersistWhileRunInFlight(t *testing.T) { base := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) m := sidebarTestModel() m.now = func() time.Time { return base } - m.pending = true // run still going + m.pending = true // run still going + m.activeRunID = 7 // exercise the run-scoped filter with a non-zero id m.transcript = append(m.transcript, - transcriptRow{kind: rowToolCall, tool: "swarm_spawn", detail: "build homepage"}, - transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task subagent-1 on team default."}, - transcriptRow{kind: rowToolResult, tool: "swarm_collect", detail: "Results: 1 task(s)\n- subagent-1 [done] build homepage"}, + transcriptRow{kind: rowToolCall, tool: "swarm_spawn", detail: "build homepage", runID: 7}, + transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task subagent-1 on team default.", runID: 7}, + transcriptRow{kind: rowToolResult, tool: "swarm_collect", detail: "Results: 1 task(s)\n- subagent-1 [done] build homepage", runID: 7}, ) // Long past the linger window — but it must still show while pending. m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-10 * sidebarAgentLinger)} @@ -347,23 +348,30 @@ func TestSwarmAgentsPersistWhileRunInFlight(t *testing.T) { } } -// Members from a previous run must not reappear in a later run. +// Members AND statuses from a previous run must not bleed into a later run, even +// when task ids repeat — both the spawn rows and the swarm_status/collect rows +// are scoped to the active run. func TestSwarmAgentsScopedToActiveRun(t *testing.T) { m := sidebarTestModel() m.pending = true m.activeRunID = 2 m.transcript = append(m.transcript, - // Old run's spawn (runID 1) — should be ignored now. + // Old run (runID 1): the SAME task id, and a stale "done" status for it. transcriptRow{kind: rowToolCall, tool: "swarm_spawn", detail: "old task", runID: 1}, - transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task old-1 on team default.", runID: 1}, - // Current run's spawn (runID 2). + transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task subagent-1 on team default.", runID: 1}, + transcriptRow{kind: rowToolResult, tool: "swarm_status", detail: "- subagent-1 [done] old task", runID: 1}, + // Current run (runID 2): the same id is reused and is still running. transcriptRow{kind: rowToolCall, tool: "swarm_spawn", detail: "new task", runID: 2}, - transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task new-1 on team default.", runID: 2}, + transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task subagent-1 on team default.", runID: 2}, ) agents := m.swarmSpawnedAgents() - if len(agents) != 1 || agents[0].id != "new-1" { + if len(agents) != 1 || agents[0].id != "subagent-1" { t.Fatalf("only the current run's member should show, got %+v", agents) } + // The stale prior-run "done" status must NOT mark the current member finished. + if agents[0].finishing || agents[0].state == "done" { + t.Fatalf("stale prior-run status must not affect the current member: %+v", agents[0]) + } } // shown, named by its id.