Keep a subagent's words in its own transcript, and count the ones already running
Two corrections to the previous commit. A subagent's closing report belongs in the subagent's transcript, which is where it already is; drawing it as a card in the parent's put the same paragraph in two places for a reader who did not ask for it. The row is a divider now -- a boundary, which is what the transcript actually needed there -- closed, saying only what reported and how it went. Opening it shows the report anyway, since leaving the conversation to read one line has its own cost, and a backgrounded command has no transcript of its own so this is the only place its report exists at all: that one names itself from its summary and has nothing left to open. `TranscriptDivider` grew a `trailing` slot for the chevron rather than the row growing its own copy of the rules. And the status was wrong for a session that was already running before the update, which is every session when the backend is replaced under it. Adoption picks a session's stdout back up from a recorded offset, so the `task_started` lines for subagents launched earlier are behind it and the translator never saw them -- it started with an empty set and reported `idle` with a subagent plainly still working. `Subagents::any_open` reads the directory instead, which is a measurement rather than bookkeeping and is right for a session this process did not start. Both sources are kept and neither subsumes the other: the translator's own set is the only thing that knows about a backgrounded *command*, which has no subagent to be found. The same pair decides whether an ending has already been reported, so a task that began before the restart still gets its divider. Echo's helpers now record their report as their own subagent's closing text, the way the real driver does, so the fixture has the shape being tested. Verified on the emulator: three dividers closed, one opened to its report, and each reply drawn as its own message. 170 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:
1 parent
5711c2568a
commit
ef1aad8776
11 files changed
+342
-120
No files matched your search
@@ -26,9 +26,18 @@ import java.time.format.FormatStyle
|
||||
* two it was is said by the words and the colour.
|
||||
*
|
||||
* The rules take [color] too, so the whole divider reads as one mark of one kind.
|
||||
*
|
||||
* [trailing] is drawn inside the rules, beside the words -- the one divider that opens onto
|
||||
* something needs its chevron there, and giving it its own copy of this layout is how the two would
|
||||
* come to sit at different heights.
|
||||
*/
|
||||
@Composable
|
||||
fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) {
|
||||
fun TranscriptDivider(
|
||||
text: String,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -36,6 +45,7 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
|
||||
) {
|
||||
HorizontalDivider(Modifier.weight(1f), color = color)
|
||||
Text(text, style = MaterialTheme.typography.bodySmall, color = color)
|
||||
trailing?.invoke()
|
||||
HorizontalDivider(Modifier.weight(1f), color = color)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +288,10 @@ fun SessionScreen(
|
||||
// Which messages from other agents are open, by the seq that identifies their row. Closed by
|
||||
// default, which is the rule for anything new in this transcript.
|
||||
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
|
||||
// Which task reports are open, by the seq that identifies their row. Closed by default, which
|
||||
// is the rule for anything new in this transcript -- and doubly so here, since what one opens
|
||||
// onto is already in the subagent's own transcript.
|
||||
var expandedTaskNotes by remember { mutableStateOf(setOf<Long>()) }
|
||||
// Which memory notes are open, by the note's own text. Held here rather than in the card so a
|
||||
// note opened and scrolled past is still open on the way back.
|
||||
var openMemories by remember { mutableStateOf(setOf<String>()) }
|
||||
@@ -1603,7 +1607,25 @@ fun SessionScreen(
|
||||
is TranscriptItem.CompactedNote ->
|
||||
CompactedRow(item)
|
||||
is TranscriptItem.LimitNote -> LimitRow(item)
|
||||
is TranscriptItem.TaskNote -> TaskNoteRow(item)
|
||||
is TranscriptItem.TaskNote ->
|
||||
TaskNoteRow(
|
||||
item,
|
||||
open = item.seq in expandedTaskNotes,
|
||||
// Anchored, so the edge the reader pressed
|
||||
// stays where it was.
|
||||
onToggle = {
|
||||
toggleAnchored(row) {
|
||||
expandedTaskNotes =
|
||||
if (
|
||||
item.seq in
|
||||
expandedTaskNotes
|
||||
)
|
||||
expandedTaskNotes - item.seq
|
||||
else
|
||||
expandedTaskNotes + item.seq
|
||||
}
|
||||
},
|
||||
)
|
||||
// Never reached: a peer message is flattened into
|
||||
// its own units. Here because a `when` over the
|
||||
// item kinds has to stay exhaustive.
|
||||
|
||||
@@ -1,89 +1,110 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A background task reporting back: a subagent that finished, or a backgrounded command.
|
||||
* The mark a background task reporting back leaves: a subagent that finished, or a backgrounded
|
||||
* command.
|
||||
*
|
||||
* A card rather than a divider, and for the same reason a peer message is one -- somebody said
|
||||
* this. A divider is a fact about the conversation ("everything above is out of context"); this is
|
||||
* a message that arrived, and the turn under it is the session answering it.
|
||||
* A divider, drawn like a clear or a compaction, because what it marks is a *boundary*. The turn
|
||||
* below it is the session answering something that arrived, and without a row there the reply that
|
||||
* ended the previous turn and the reply that answers this one met with nothing between them -- the
|
||||
* fold grew the older message and drew two answers as one paragraph, running together mid-sentence.
|
||||
*
|
||||
* The card is also what separates the two turns. Before it existed, a reply woken by one of these
|
||||
* met the previous reply with nothing between them and the transcript ran them into one paragraph,
|
||||
* mid-sentence. The row being *there* is most of the fix; what it says is the rest.
|
||||
*
|
||||
* Drawn whole rather than in [cardPiece] slices, unlike a peer message: a summary is one sentence
|
||||
* the CLI wrote, so there is no unbounded case to bound. If one ever arrives long enough to be
|
||||
* worth splitting, it belongs in the same flatten a peer message goes through.
|
||||
* **Closed, and the subagent's words are not what it says.** A subagent's closing report is
|
||||
* recorded as its own transcript's closing text, which is where somebody who wants it looks;
|
||||
* putting it in the parent by default is the same paragraph in two places for a reader who did not
|
||||
* ask for it. Opened, this shows it anyway, because having to leave the conversation to read one
|
||||
* line is its own cost -- and because a backgrounded *command* has no transcript of its own, so
|
||||
* here is the only place its report exists at all.
|
||||
*/
|
||||
@Composable
|
||||
fun TaskNoteRow(item: TranscriptItem.TaskNote, modifier: Modifier = Modifier) {
|
||||
fun TaskNoteRow(
|
||||
item: TranscriptItem.TaskNote,
|
||||
open: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Blue is the session's own background work -- the same thing `waiting` means in the status
|
||||
// row, so "this is the session getting on with something it started" is learned once. Red only
|
||||
// where something actually went wrong; a cancelled task is a choice somebody made.
|
||||
val colour = if (item.status == "failed") failedColor else waitingColor
|
||||
val line = taskNoteSummary(item.title, item.status, item.summary)
|
||||
// Nothing behind the line: a task that ended without a word, or one whose report *is* the line
|
||||
// already. A control that opens onto nothing, or onto a copy of what is above it, teaches the
|
||||
// reader that the control means nothing.
|
||||
val expandable = item.summary != null && item.summary != line
|
||||
Column(
|
||||
modifier.cardPiece(
|
||||
top = true,
|
||||
bottom = true,
|
||||
fill = CardDefaults.cardColors().containerColor,
|
||||
)
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (expandable) Modifier.clickable(onClick = onToggle) else Modifier)
|
||||
) {
|
||||
Text(
|
||||
taskNoteHeading(item.title, item.status),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = taskNoteColor(item.status),
|
||||
TranscriptDivider(
|
||||
line,
|
||||
colour,
|
||||
trailing =
|
||||
if (!expandable) null
|
||||
else {
|
||||
{
|
||||
Chevron(
|
||||
if (open) Pointing.Up else Pointing.Down,
|
||||
colour = colour,
|
||||
// The row is the control and the chevron is all of its marking, so the
|
||||
// name belongs here: it is the only thing a screen reader has to read.
|
||||
modifier =
|
||||
Modifier.semantics {
|
||||
contentDescription =
|
||||
if (open) "Hide the report" else "Show the report"
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (item.summary != null) {
|
||||
if (open && expandable) {
|
||||
// Not the divider's colour: this is what the task said rather than a mark we drew, and
|
||||
// colouring a quotation as if it were part of the rule around it makes the rule look
|
||||
// like it is carrying some of the meaning.
|
||||
Text(
|
||||
item.summary,
|
||||
item.summary.orEmpty(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the card says: who reported, and how it went.
|
||||
* What the divider says: what reported, and how it went.
|
||||
*
|
||||
* Its own function so the wording is testable without a screen, and because the case that decides
|
||||
* whether this is any good is the one nobody builds a screen for -- a task that failed or was
|
||||
* killed. "Message from" is the right sentence for exactly one of the endings; using it for all of
|
||||
* them would report a task that died as one that had something to say.
|
||||
* killed. "Reported back" is the right phrase for exactly one of the endings; using it for all of
|
||||
* them would announce a task that died as one that had something to say.
|
||||
*
|
||||
* A status word this build has never seen is said as itself rather than mapped onto the nearest
|
||||
* one, since the nearest one would read as a decision somebody made.
|
||||
* one, since the nearest one would read as a fact somebody established.
|
||||
*/
|
||||
fun taskNoteHeading(title: String?, status: String): String {
|
||||
// A backgrounded command has no title of its own -- its summary names it -- so the card says
|
||||
// what it was rather than inventing a name for it.
|
||||
val who = title ?: "a background task"
|
||||
fun taskNoteSummary(title: String?, status: String, summary: String?): String {
|
||||
// A backgrounded command has no title of its own and its summary is already a whole sentence --
|
||||
// `Background command "..." completed (exit code 0)`. Naming it from that beats "a background
|
||||
// task", which says nothing, and there is nothing left behind the line to open.
|
||||
if (title == null && summary != null && status == "completed") return summary
|
||||
val who = title ?: "A background task"
|
||||
return when (status) {
|
||||
"completed" -> "Message from $who"
|
||||
"completed" -> "$who reported back"
|
||||
"failed" -> "$who failed"
|
||||
"cancelled" -> "$who was cancelled"
|
||||
else -> "$who: $status"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The heading's colour: coloured only where something went wrong.
|
||||
*
|
||||
* A task that finished and said something is the ordinary case and takes the ordinary text colour;
|
||||
* the accent is spent on the one ending a reader would want to find by scanning. Cancelled is
|
||||
* neither -- somebody chose it, and a deliberate choice is not a problem to report -- and a word
|
||||
* this build does not recognise is not coloured as a failure, because it is not one. It says
|
||||
* itself, which is the difference in *kind* that no colour can carry.
|
||||
*/
|
||||
@Composable
|
||||
private fun taskNoteColor(status: String): Color =
|
||||
when (status) {
|
||||
"failed" -> failedColor
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
Reference in new issue
Block a user