diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index 4118eb8ae..d9b93a71b 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: @@ -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" { diff --git a/internal/tui/sidebar_test.go b/internal/tui/sidebar_test.go index 4f41a7af4..8fcf1bc4c 100644 --- a/internal/tui/sidebar_test.go +++ b/internal/tui/sidebar_test.go @@ -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()