Skip to content

Feat: Collapsible subagent blocks in the events pane - #1601

Merged
huang195 merged 5 commits into
rossoctl:mainfrom
huang195:feat/subagent-blocks-1047
Oct 30, 2026
Merged

huang195 merged 5 commits into
rossoctl:mainfrom
huang195:feat/subagent-blocks-1047

Conversation

@huang195

Copy link
Copy Markdown
Member

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:

 #  TIME  DIR  ACTION  ...
 41 14:02  out modify  ...
▸─ PR review (execution-first) · 14 rows · 9.4k tok · $0.21
 44 14:05  out modify  ...
 61 14:12  out modify  ...
▾─ PR review (reasoning-first) · 12 rows · 7.1k tok · $0.16
   45 14:05 │out modify ...
  • Time-anchored (option the issue's discussion points at): each block's header sits at the subagent's first surviving row, and the main conversation's rows stay top-level in time order between blocks — the interleaving stays honest rather than clustering into groups.
  • Collapsed by default; Enter on a header toggles. Enter on a message row is still the detail drill-down — one key, the row decides.
  • Header carries the rollup the issue asks for: rows, tokens, cost, from the same readings the TOKENS/COST cells sort by.
  • Label = the subagent's description from Claude Code's own agent-<id>.meta.json (now harvested into SessionMetadata.Subagents), then its agent type, then the raw id — which is what a teammate, a non-Claude-Code agent, or a stale harvest renders.
  • Member rows carry a │ gutter in the DIR column (it has a spare character, so no width change, no refit, no picker divergence).

Rules

  • Grouping is strict: a row belongs to the block its subagent field 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.
  • A sort disables grouping — the row order is then a ranking, and grouping would contradict it. The gutter disappears with it.
  • A live search opens every block, so a match inside a collapsed one is findable (hidden rows are unreachable from a search that only sees rendered rows).
  • The Timeline's reveal opens the block holding its target — the timeline draws a lane per subagent, so its Enter names subagent rows as readily as the conversation's, and a collapsed target would otherwise make the reveal wait forever.
  • Collapse state survives rebuilds (live tail) and is cleared when another session is opened; ids are per-session.

How

Three commits, in dependency order:

  1. Harvest (core/observe/claude): reads <session>/subagents/agent-<id>.meta.json into SessionMetadata.Subagents, keyed by the bare id the proxy records. The agent-*.jsonl transcripts 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.
  2. Table: a row of exactly one cell now renders as one full-width line (a "section row"), in the same cursor/match styles a data row gets. No caller rendered a one-cell row before — the pipeline pane's ── (app) ── divider carries a cell per column — so the convention changes nothing that existed.
  3. Events pane (events_blocks.go): block collection shares the hideInactive predicate with the row loop (extracted to rowInactiveHidden so the two cannot drift — a header's rollup counts the rows expanding it would show). The header rows contribute an empty eventRow to visibleRows to keep it parallel; headerRows maps rendered-row index → block so Enter can tell a header from a message without a second walk.

Testing

  • Harvest: meta files parsed and keyed by bare id; transcripts and malformed meta files ignored; no map for sessions without subagents.
  • Table: a section row survives whole (not truncated to the first column's width), renders under the cursor, and is searchable.
  • Events pane: collapsed-by-default with the header at the anchor; Enter toggles (and is a no-op on a message row); search expands; sort disables; DIR gutter on member rows only while blocks are active; label sources in precedence order; no blocks for a session without subagent rows; collapse state is per-session; the rollup follows the inactive filter (a subagent whose whole exchange is hidden leaves no header); the Timeline reveal opens a collapsed block.
  • Full ./cmd/agentop/... and ./core/observe/... suites pass; go vet and gofmt clean.

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

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>
@huang195
huang195 requested a review from a team as a code owner October 30, 2026 13:06
@coderabbitai

coderabbitai Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You'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.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 00487a9a-dfcb-423d-885a-215b58f87b79
Commits

Reviewing files that changed from the base of the PR and between 20ffb36 and a7ee1ac.

Files selected for processing: 15
  • CLAUDE.md
  • cmd/agentop/README.md
  • cmd/agentop/tui/app.go
  • cmd/agentop/tui/events_blocks.go
  • cmd/agentop/tui/events_blocks_test.go
  • cmd/agentop/tui/events_columns.go
  • cmd/agentop/tui/events_pane.go
  • cmd/agentop/tui/keys.go
  • cmd/agentop/tui/table/table.go
  • cmd/agentop/tui/table/table_test.go
  • cmd/agentop/tui/timeline_pane.go
  • core/observe/claude/harvest.go
  • core/observe/claude/harvest_test.go
  • core/observe/claude/metadata.go
  • docs/agents/claude-code.md
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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 clawgenti left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 != "") {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@huang195
huang195 merged commit ae1beb5 into rossoctl:main Oct 30, 2026
40 checks passed
@github-project-automation github-project-automation Bot moved this from New/ToDo to Done in Rossoctl Issue Prioritization Oct 30, 2026
@huang195
huang195 deleted the feat/subagent-blocks-1047 branch October 30, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

feature: ability to distinguish Claude Code subagent traffic within abctl

2 participants