Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions internal/tui/sidebar.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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:
Expand All @@ -275,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" {
Expand Down
57 changes: 57 additions & 0 deletions internal/tui/sidebar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,63 @@ 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.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", 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)}

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 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 (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 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 subagent-1 on team default.", runID: 2},
)
agents := m.swarmSpawnedAgents()
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.
func TestSwarmSpawnedAgentFallsBackToID(t *testing.T) {
m := sidebarTestModel()
Expand Down
Loading