Repository navigation
Feat: Collapsible subagent blocks in the events pane - #1601
Conversation
Reads <session>/subagents/agent-<id>.meta.json beside each transcript into SessionMetadata.Subagents, keyed by the bare id the proxy records on a subagent's rows. The agent-*.jsonl transcripts beside them are not read. Skipped (unchanged) sessions keep the Subagents their entry already carried; a session only gains a subagent by appending the Agent call that spawned it, which moves the transcript's mtime and re-parses the session. SessionMetadata is no longer struct-comparable (the map); the one test that compared it against the zero value now checks its fields. Refs rossoctl#1047 Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
A row of exactly one cell now renders as one line spanning every column, in the same row/cursor/match styles a data row gets, instead of one cell truncated to the first column's width. No caller rendered a one-cell row before, so the convention changes nothing that existed; the events pane's subagent block headers are its first user. Refs rossoctl#1047 Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
Each subagent's rows group under a header anchored at the subagent's first surviving row, with a rollup of rows, tokens and cost (rossoctl#1047). Blocks are collapsed by default; Enter on a header toggles it, and a member row's DIR cell carries a │ gutter while its block is open. The main conversation's rows stay top-level in time order between the blocks, so interleaving stays readable. The label is the harvested meta.json description, then the agent type, then the raw id. Grouping is strict: a row belongs to the block its subagent field names, and unattributed rows (an opaque CONNECT carries no agent-id header) stay top-level — folding by contiguity would misfile main rows interleaved with a subagent's. A sort disables grouping, a live search opens every block so a match inside one is findable, and the Timeline's reveal opens the block holding its target rather than waiting for a row that cannot appear. Refs rossoctl#1047 Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
README keybindings and the events-pane section, the claude-code agent doc's Subagents paragraph (the meta.json labels are now harvested and shown), and one sentence on the parentSubagent bullet in CLAUDE.md. Refs rossoctl#1047 Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 43 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The header's search cell was the label only, while the rendered header
carries the rollup too ('· N rows · tok · cost') — so searching a cost
figure did not match the header that displays it, against the README's
contract that a search matches the text the pane shows. The search cell
is now the same string the row renders, built once.
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
clawgenti
left a comment
There was a problem hiding this comment.
Summary
This is clean, well-scoped work. Strict membership by subagent, header anchored at the first surviving row, grouping off under a sort, and the shared rowInactiveHidden predicate all hold up. I traced the empty eventRow header entries through every reader of visibleRows (selectedEventRow, findByKey, the timeline reveal, the search surface), and each one tolerates a nil event. Nothing blocks merge. I left two suggestions about behaviour you'd only see in a live session, which fits the "not live-tested yet" note, plus one nit.
Locally at a7ee1ac: go vet ./cmd/agentop/... ./core/observe/... and gofmt -l are clean, and go test ./cmd/agentop/tui/... ./core/observe/claude/... passes. I also ran two throwaway probes against newBlockModel. One confirmed that opening the last header keeps the cursor on it. The other reproduced the cursor reset described inline.
Author: huang195 (MEMBER — maintainer)
Areas reviewed: Go (agentop TUI, table widget, Claude Code harvest), Docs (CLAUDE.md, agentop README, docs/agents/claude-code.md)
Agent/IDE config (.claude/.vscode): none
Commits: 5 commits, all signed-off: yes
CI status: passing (CodeRabbit and Spellcheck skipped)
| // type alone ("general-purpose") answers less than the id does about WHICH | ||
| // general-purpose agent this was. | ||
| func (m *model) subagentLabel(id string) string { | ||
| if info, ok := m.sessionsData[m.selectedSess].Subagents[id]; ok { |
There was a problem hiding this comment.
suggestion: In a live session, a subagent spawned after agentop started will probably keep showing its raw id. sessionsData is refreshed only by the harvest, and the TUI runs that at startup, when you arrive at the picker, and on the Sessions pane, and only there while some row is still untitled (untitledSettled). An already-titled session that is being watched in the Events pane never triggers another harvest, so its Subagents map stays frozen at launch. That hits exactly the case #1047 is about: watching a fan-out happen. The fallback to the id is graceful, so this doesn't block. It's still worth checking during the live test you mentioned. If it shows up, one option is a cheap re-harvest when a row names a subagent that isn't in Subagents (rate-limited the way the untitled backoff is). Another is to read that session's subagents/ dir directly on a miss.
| // tool call that spawned it to the transcript — which moves the | ||
| // transcript's mtime and re-parses the session, meta files and all. A | ||
| // skipped session keeps the Subagents its `since` entry already carried. | ||
| Subagents: readSubagents(filepath.Join(projDir, id, "subagents")), |
There was a problem hiding this comment.
suggestion: The comment above says the incremental skip is safe because spawning a subagent appends to the transcript first. That holds only if agent-<id>.meta.json is already on disk when that append moves the transcript's mtime. If Claude Code writes the meta file after the line that spawns the subagent, a harvest landing in that window parses the session with no meta. Every later incremental pass then skips it until the parent transcript moves again, which is likely when the subagent's result comes back. In other words the label would be missing for the whole time the subagent is running. A cheap guard is to also compare the subagents/ directory's mtime in the skip test. Its mtime moves when a new file is created in it, which is the event that matters here. Otherwise, write down the observed ordering once you've live-tested it.
| full = append(full, table.SearchRow{Cells: []string{text}}) | ||
| } | ||
| } | ||
| if !m.blockOpen(b.id, q != "") { |
There was a problem hiding this comment.
suggestion: If the cursor is pinned to a member row and its block collapses, the cursor jumps to row 0 and the pin is cleared. The most common way to hit this is clearing a search. A probe showed it: search ete, put the cursor on a1's response (row 3, pinned), then clear the search. The rebuild collapses a1, findByKey misses, and the eviction branch (target = 0, pin cleared) sends the operator to the top of the session. In a long session that loses their place. A natural fallback is the header of the block that now hides the pinned event, e.g. keep a blockOf(eventKey) -> header row lookup next to headerRows and try it before the row-0 clamp.
| // on a header row. Reports whether it acted, so the caller can skip the | ||
| // detail-pane open that Enter otherwise means on this pane. | ||
| func (m *model) toggleSubagentBlock() bool { | ||
| b, ok := m.headerRows[m.eventsTbl.Cursor()] |
There was a problem hiding this comment.
nit: While a search is live or committed, every block renders open (blockOpen ORs in searching) and the header always shows ▾. Enter on a header in that state still flips subagentOpen but changes nothing on screen. The operator only sees what changed later, when they clear the search and the block comes back in the opposite state from the one they last saw. Two options: make the toggle a no-op while q != "", or document that Enter on a header under a search sets the state the block returns to afterwards.
Closes #1047
What
Subagent traffic in the events pane groups into collapsible blocks, so an agent's fan-out stops drowning the conversation and a slow or costly subagent can be picked out:
Enteron a header toggles.Enteron a message row is still the detail drill-down — one key, the row decides.agent-<id>.meta.json(now harvested intoSessionMetadata.Subagents), then its agent type, then the raw id — which is what a teammate, a non-Claude-Code agent, or a stale harvest renders.│gutter in the DIR column (it has a spare character, so no width change, no refit, no picker divergence).Rules
subagentfield names. Unattributed rows stay top-level — an opaque CONNECT carries no agent-id header, and folding by time-contiguity would misfile main-conversation rows interleaved with a subagent's.Enternames subagent rows as readily as the conversation's, and a collapsed target would otherwise make the reveal wait forever.How
Three commits, in dependency order:
core/observe/claude): reads<session>/subagents/agent-<id>.meta.jsonintoSessionMetadata.Subagents, keyed by the bare id the proxy records. Theagent-*.jsonltranscripts beside them are not read. An incrementally-skipped session keeps the Subagents its entry already carried — a session only gains a subagent by appending the Agent call that spawned it, which moves the transcript's mtime and re-parses the session, meta files and all.── (app) ──divider carries a cell per column — so the convention changes nothing that existed.events_blocks.go): block collection shares the hideInactive predicate with the row loop (extracted torowInactiveHiddenso the two cannot drift — a header's rollup counts the rows expanding it would show). The header rows contribute an emptyeventRowtovisibleRowsto keep it parallel;headerRowsmaps rendered-row index → block soEntercan tell a header from a message without a second walk.Testing
./cmd/agentop/...and./core/observe/...suites pass;go vetandgofmtclean.Not live-tested against a real Claude Code session yet — the label join and the block shapes are unit-tested; I will verify against a scratch Cortex with a subagent-spawning session before merge.
Not done, deliberately: no sessions-pane tree (#1063 is closed), no timeline changes (it already shows agent lanes), no new session-API fields, no persisted collapse state.
Assisted-By: Claude (Anthropic AI) noreply@anthropic.com