Order a session's subagents by activity, and delete finished ones by holding

The subcards were oldest first, which buried whatever is working now. They
are ordered on the phone -- still running first, then most recently active --
over the server's stable oldest-first answer, since presentation order is a
display decision and a subagent that is thinking reports nothing meanwhile.

Holding a subcard selects it and several at a time, the import list's gesture
and its confirmation, so selecting is learned once. The selection bar sits
inside the session's card rather than at the bottom of the screen: it belongs
to one card, and one Delete is one request against one parent, so picking a
row in another card moves the selection rather than adding to it. Delete is
disabled, with the reason in words, while anything selected is still running
-- its transcript is still being written to and its process is the session's
to stop, so the server refuses that batch outright.

`POST /sessions/{id}/subagents/delete` takes the batch and checks every id
before removing any, so a set naming a running one is left exactly as it was
rather than half-deleted. It is `Subagents::start`'s path out. What counts as
running is shared with the list route through `has_a_process`, so the two
cannot disagree. On success the phone takes those rows out of that one card
and off the session's count, purges its cached copies, and drops the
expansion when nothing is left -- nothing else is refetched.

Driven on the emulator against the sandbox with ui-trace's new hold-by-name:
selecting two, the dialog, the rows going, a running one holding Delete
disabled, and the expander leaving with the last subagent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-06 13:10:21 -04:00
1 parent cf10b17c5b
commit 13d2d11c2d
5 files changed
+506 -30

No files matched your search

+33 -4
View File
@@ -38,8 +38,10 @@ id becomes a path.
The transcript's sequence numbers are its own, starting at 1. `Transcript`,
`read_window`, `catch_up` and `read_after` work on it unchanged.
Its path out: deleting the session deletes its directory, subagents included.
There is no separate delete.
Its path out: deleting the session deletes its directory, subagents included,
and `POST /sessions/{id}/subagents/delete` removes finished ones on their own
-- all or nothing, and refused while any named one is still running, since its
transcript is still being written to and its process is the session's to stop.
## Lifecycle, as events in the subagent's transcript
@@ -89,7 +91,8 @@ one is given; falling back to the tool's name when the child arrives before
- `session/subagent.rs` -- the registry: `Subagents` (per session, in
`Shared`), `Subagent` (its `Transcript` behind a mutex plus a
`broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title,
prompt)`, `finish(id)`, `reopen(id)`, `finish_all()`, `list()` from disk. Drivers get an
prompt)`, `finish(id)`, `reopen(id)`, `finish_all()`, `list()` from disk, and
`delete(ids)` -- its path out. Drivers get an
`Arc<Subagents>` beside their `EventSink`; llama ignores it.
- `session/claude/translate.rs` -- routes child lines by parent id, holds
one child `Translator` per subagent, remembers pending Task calls'
@@ -100,7 +103,7 @@ one is given; falling back to the tool's name when the child arrives before
finishes about three seconds after starting, and the parent's Task calls
end when their subagent does. Three seconds so the running state can be
seen on the phone.
- `routes.rs` -- three routes, in the doc table.
- `routes.rs` -- four routes, in the doc table.
## Wire shape
@@ -110,8 +113,18 @@ GET /sessions same field on each row
GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest first
GET /sessions/{id}/subagents/{sub}/transcript exactly the session transcript's query and answer
GET /sessions/{id}/subagents/{sub}/events?after=N exactly the session events stream
POST /sessions/{id}/subagents/delete {subagents} -> 204; refused whole if one is running
```
The delete is a batch rather than a `DELETE` per id for the reason the import
list's is: the phone deletes what a reader selected, and one request per row
means a batch can half-arrive, leaving the rows that were missed looking
exactly like rows nobody picked. Unlike an import delete it is local file
removal, so it is done by the time the reply is sent and there is no per-row
state to follow afterwards. What decides "running" is
`Subagents::list`'s own rule, shared through `routes::has_a_process` so the
list and the delete cannot disagree about it.
`status` is the transcript's last `Status` event, serialised like a session's
(`running`, `exited`), except that a subagent whose session is not itself
running cannot be running: the list answers `unknown` for that one. The
@@ -122,6 +135,22 @@ The per-subagent status is only read when the list route is asked for.
## Phone
- The subcards are ordered **still running first, then most recently
active** -- a display decision made on the phone (`subagentOrder`), over the
server's stable oldest-first answer. Two keys rather than activity alone
because a subagent that is thinking reports nothing meanwhile and would sink
below one that just finished.
- **Holding a subcard selects it, and several at a time**, exactly as the
import list works, with the selection bar drawn inside the session's card
rather than at the bottom of the screen: this selection belongs to one card,
and a bar down there would read as acting on the whole list. One card at a
time -- picking a row in another card moves the selection rather than adding
to it, since one Delete is one request against one parent. Delete is
*disabled*, with the reason in words, while anything selected is still
running. Deleting confirms first, dims the rows it is acting on
(`BusyItem`), and on success takes them out of that card and off the
session's count without refetching anything else. The phone's cached copy of
a deleted subagent's transcript is purged with it.
- `SessionSummary.subagents: Int`. A card with a non-zero count ends in an
expander row -- a full-width `Chevron(Pointing.Down)` row that flips to
`Pointing.Up` -- collapsed by default. Expanding fetches