Never run two turns into one, and say when a session waits on its own work

A turn started by something with no row of its own -- a subagent reporting
back, a peer message the CLI only owns up to at the end -- met the previous
reply with nothing between it, and the fold grew that reply rather than
starting a new one. Two answers were drawn as one paragraph, running together
mid-sentence with not even a space between them. The fold now refuses to grow
a settled reply, and `joinPages` carries the same rule across a page boundary.

The other half is the row. `Event::TaskNote` records a background task
reporting back -- a subagent that finished, or a backgrounded command -- with
its title, how it ended and what it said; `TaskNoteRow` draws it as a card,
since somebody said this, and its own row rather than an update to the Task
call's, which is above everything the session has said since. Reported once
however many of the CLI's two lifecycle shapes arrive.

`SessionStatus::Waiting` is a session whose own turn is over while work it
started is not. `Idle` means "waiting for a person" and this means the
opposite, so reporting it as idle sent a "finished" notification at the one
moment that was untrue. Drawn as "waiting" in `waitingColor`; the queue and
the held-command boundary release on either end-of-turn status, so a message
sent while a subagent runs is not held until it finishes.

And a usage limit the account hits inside a subagent now reaches the session
as well as the subagent's transcript. `resume.rs` can only schedule against a
session, and a background Task outliving its parent's turn is the ordinary
case, so auto-resume was doing nothing at all for it.

The status word and its colour were two `when`s on two screens, and the second
missed `waiting` silently; they are `sessionStatusWord`/`sessionStatusColour`
now. Echo's `/subagent n` reproduces the whole shape, staggered a second
apart. Verified on the emulator against the sandbox: 169 server tests, ktfmt,
clippy, rustfmt, Android lint and the JVM unit tests all clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-06 18:57:30 -04:00
1 parent 74c07d687a
commit 5711c2568a
17 files changed
+891 -85

No files matched your search

+43
View File
@@ -208,6 +208,37 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")]
turn_start: Option<u64>,
},
/// A task the session started in the background reporting back: a
/// subagent that has finished, or a backgrounded command.
///
/// Recorded because it is a message the session *received*, and without
/// it the turn it wakes up and runs has nothing in front of it. Two
/// replies then met with no row between them and were folded into one,
/// so a phone drew the answer to a question nobody could see as a
/// continuation of the previous sentence.
///
/// Its own kind rather than an update to the Task call's row: that row
/// is wherever the call was made, which is above everything the session
/// has said since, and a reader at the bottom of the transcript would
/// never see it change.
TaskNote {
/// The `tool_use` id it belongs to. A subagent is named by that id,
/// so this is also how a phone opens the one that just finished.
about: String,
/// What the reader knows the task as -- a subagent's title. `None`
/// for a backgrounded command, which its own summary names; the row
/// says so rather than inventing a title for it.
#[serde(default, skip_serializing_if = "Option::is_none")]
title: Option<String>,
/// How it ended, in the CLI's word: `completed`, `failed`,
/// `cancelled`. Carried rather than folded into the summary because
/// the summary is absent exactly when things went wrong, and
/// "finished" is the wrong word for a task that was killed.
status: String,
/// What it said on the way out, where it said anything.
#[serde(default, skip_serializing_if = "Option::is_none")]
summary: Option<String>,
},
/// The manager's record of a question being answered, so a rendered
/// question card resolves on every device rather than only the one that
/// answered.
@@ -408,6 +439,18 @@ pub enum SessionStatus {
Running,
AwaitingInput,
Compacting,
/// The session's own turn is over, but work it started is still going:
/// a backgrounded subagent, or a command left running.
///
/// Its own state rather than `Idle` because the two differ in kind and
/// only one of them is an invitation. `Idle` means the session is
/// waiting for a person; this means it is waiting for itself, and a
/// notification saying the work had finished would have been wrong. It
/// is also not `Running`: nothing is being written to the transcript,
/// the reply that ended the turn is finished, and a spinner on a session
/// that will not speak again until a task reports back is a promise
/// nobody can keep.
Waiting,
Exited,
/// There is a process recorded for this session and the machine will not
/// say whether it is still running.