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:
1 parent
cf10b17c5b
commit
13d2d11c2d
5 files changed
+506
-30
No files matched your search
+33
-4
@@ -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
|
||||
|
||||
@@ -305,6 +305,26 @@ fun fetchSubagents(settings: ServerSettings, sessionId: String): List<SubagentSu
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes finished subagents from their session, transcripts and all.
|
||||
*
|
||||
* All or nothing, and only for subagents that have finished: the server checks every id before it
|
||||
* removes any, so a batch naming one that is still running leaves the whole selection as it was.
|
||||
* One request for the batch, for the same reason [deleteImportable] is one -- sent row by row, a
|
||||
* batch could half-arrive and the rows that were missed would look exactly like rows nobody picked.
|
||||
*
|
||||
* Unlike an import delete this is local file removal and is done by the time it returns, so there
|
||||
* is no per-row state to follow afterwards.
|
||||
*/
|
||||
fun deleteSubagents(settings: ServerSettings, sessionId: String, subagentIds: List<String>) {
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/sessions/$sessionId/subagents/delete",
|
||||
method = "POST",
|
||||
jsonBody = JSONObject().put("subagents", JSONArray(subagentIds)).toString(),
|
||||
) {}
|
||||
}
|
||||
|
||||
// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's
|
||||
// config.ron appears here with no app rebuild.
|
||||
//
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
@@ -17,8 +18,10 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Switch
|
||||
@@ -71,6 +74,24 @@ fun SessionListScreen(
|
||||
mutableStateOf(mapOf<String, LoadState<List<SubagentSummary>>>())
|
||||
}
|
||||
|
||||
// Which subagents the reader has picked out, and whose card they are in. One card at a time:
|
||||
// a selection is deleted with one request against its parent session, so a set spanning two
|
||||
// cards is not something one Delete could act on -- picking a row in another card moves the
|
||||
// selection there rather than adding to it. Null means selection mode is off, since a
|
||||
// selection with nothing in it is a mode with no controls and no way out but Back.
|
||||
var subagentSelection by remember { mutableStateOf<SubagentSelection?>(null) }
|
||||
|
||||
// Which subagents have a delete in flight, and what a card's batch failed with. The same two
|
||||
// states the session rows above keep, for the same reasons: the rows are rebuilt from whatever
|
||||
// the server last said, and a failure is shown where it happened. The error is keyed by
|
||||
// session, not by subagent, because the server takes a batch or refuses it whole.
|
||||
var deletingSubagents by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
var subagentErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||
|
||||
// Deleting a subagent's transcript cannot be undone, so it is asked rather than done. Held as
|
||||
// the rows themselves, not a flag, so the dialog can say what it is about.
|
||||
var confirmingSubagents by remember { mutableStateOf<SubagentDeletion?>(null) }
|
||||
|
||||
fun loadSubagents(sessionId: String) {
|
||||
subagentLoads = subagentLoads + (sessionId to LoadState.Loading)
|
||||
scope.launch {
|
||||
@@ -87,6 +108,15 @@ fun SessionListScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a subagent out, or puts it back; see [subagentSelection] for why one card at a time.
|
||||
*/
|
||||
fun toggleSubagent(sessionId: String, subagent: SubagentSummary) {
|
||||
val picked = subagentSelection?.takeIf { it.sessionId == sessionId }?.ids.orEmpty()
|
||||
val next = if (subagent.id in picked) picked - subagent.id else picked + subagent.id
|
||||
subagentSelection = if (next.isEmpty()) null else SubagentSelection(sessionId, next)
|
||||
}
|
||||
|
||||
// Failures that belong to one session rather than to the list, keyed by its id and shown on its
|
||||
// own card. The two scopes are decided by whether the server answered: it answered and refused,
|
||||
// so this says nothing about the other rows.
|
||||
@@ -103,6 +133,73 @@ fun SessionListScreen(
|
||||
val context = LocalContext.current
|
||||
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
|
||||
|
||||
/**
|
||||
* Removes [rows] from [sessionId]'s card, once the reader has confirmed it.
|
||||
*
|
||||
* One request for the batch rather than one per row -- see [deleteSubagents]. The selection is
|
||||
* dropped as the work is handed over, not when it lands: the card goes back to how it started,
|
||||
* and what says the work is happening is the rows it is happening to.
|
||||
*/
|
||||
fun deleteSubagentsIn(sessionId: String, rows: List<SubagentSummary>) {
|
||||
val ids = rows.map { it.id }
|
||||
val gone = ids.toSet()
|
||||
subagentSelection = null
|
||||
deletingSubagents = deletingSubagents + gone
|
||||
subagentErrors = subagentErrors - sessionId
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
deleteSubagents(settings, sessionId, ids)
|
||||
// After it succeeded, not before: a refused delete leaves those subagents
|
||||
// exactly as they were, and this phone's copies of them still worth keeping.
|
||||
gone.forEach {
|
||||
transcriptCache.session(TranscriptAddress(sessionId, it)).purge()
|
||||
}
|
||||
}
|
||||
// Only this card, and only what changed -- refetching the list instead put every
|
||||
// other session back through a spinner to report something never in doubt. The
|
||||
// count on the row is updated with it, because it is what decides the expander is
|
||||
// there at all.
|
||||
val loaded = subagentLoads[sessionId]
|
||||
if (loaded is LoadState.Loaded) {
|
||||
subagentLoads =
|
||||
subagentLoads +
|
||||
(sessionId to
|
||||
LoadState.Loaded(loaded.value.filterNot { it.id in gone }))
|
||||
}
|
||||
val sessions = listState
|
||||
if (sessions is LoadState.Loaded) {
|
||||
listState =
|
||||
LoadState.Loaded(
|
||||
sessions.value.map {
|
||||
if (it.id == sessionId)
|
||||
it.copy(subagents = (it.subagents - gone.size).coerceAtLeast(0))
|
||||
else it
|
||||
}
|
||||
)
|
||||
}
|
||||
// A card with none left has no expander to be open by, so the reader's choice to
|
||||
// expand it goes too -- its path out, rather than a set holding an id that can
|
||||
// never be drawn again.
|
||||
val left =
|
||||
(listState as? LoadState.Loaded)
|
||||
?.value
|
||||
?.firstOrNull { it.id == sessionId }
|
||||
?.subagents ?: 0
|
||||
if (left == 0) {
|
||||
expandedSessions = expandedSessions - sessionId
|
||||
subagentLoads = subagentLoads - sessionId
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
// Kept, because they are still there: the server refused, so what it refused about
|
||||
// is exactly as it was.
|
||||
subagentErrors = subagentErrors + (sessionId to (e.message ?: "Delete failed"))
|
||||
} finally {
|
||||
deletingSubagents = deletingSubagents - gone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
listState = LoadState.Loading
|
||||
scope.launch {
|
||||
@@ -135,6 +232,11 @@ fun SessionListScreen(
|
||||
|
||||
LaunchedEffect(reloadToken) { refresh() }
|
||||
|
||||
// Back leaves the subagent selection rather than the tab, which is the level it is one step
|
||||
// above -- the same nesting the import list's own selection uses. Nested inside MainScreen's
|
||||
// handler, so it wins only while there is a selection.
|
||||
BackHandler(enabled = subagentSelection != null) { subagentSelection = null }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
when (val state = listState) {
|
||||
@@ -171,9 +273,25 @@ fun SessionListScreen(
|
||||
onLongPress = { confirmingDelete = session },
|
||||
expanded = session.id in expandedSessions,
|
||||
subagents = subagentLoads[session.id],
|
||||
subagentError = subagentErrors[session.id],
|
||||
selectedSubagents =
|
||||
subagentSelection
|
||||
?.takeIf { it.sessionId == session.id }
|
||||
?.ids
|
||||
.orEmpty(),
|
||||
deletingSubagents = deletingSubagents,
|
||||
onSelectSubagent = { toggleSubagent(session.id, it) },
|
||||
onDeleteSubagents = { rows ->
|
||||
confirmingSubagents = SubagentDeletion(session.id, rows)
|
||||
},
|
||||
onToggleSubagents = {
|
||||
if (session.id in expandedSessions) {
|
||||
expandedSessions = expandedSessions - session.id
|
||||
// A selection made in a card that is now collapsed has no
|
||||
// rows on screen, so nothing would say what Delete acts on.
|
||||
if (subagentSelection?.sessionId == session.id) {
|
||||
subagentSelection = null
|
||||
}
|
||||
} else {
|
||||
expandedSessions = expandedSessions + session.id
|
||||
loadSubagents(session.id)
|
||||
@@ -311,8 +429,75 @@ fun SessionListScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
confirmingSubagents?.let { pending ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmingSubagents = null },
|
||||
title = {
|
||||
Text(
|
||||
if (pending.rows.size == 1) "Delete this subagent?"
|
||||
else "Delete ${pending.rows.size} subagents?"
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
// One name is worth showing and twelve are not, so the count stands in for
|
||||
// them -- the same shape as the import list's dialog. The sentence after it is
|
||||
// the same either way, because what deleting costs does not change with how
|
||||
// many.
|
||||
(if (pending.rows.size == 1) "\"${pending.rows.first().title}\"\n\n" else "") +
|
||||
"A subagent's transcript is the only record of what it did: the session " +
|
||||
"that started it kept just the Task call. Nothing else has a copy, so " +
|
||||
"this can't be undone. The session itself is untouched."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
confirmingSubagents = null
|
||||
deleteSubagentsIn(pending.sessionId, pending.rows)
|
||||
}
|
||||
) {
|
||||
// Coloured by consequence, wherever it appears -- the same rule the session
|
||||
// and import deletes above follow.
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmingSubagents = null }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The subagents picked out inside one session's card, and which card that is.
|
||||
*
|
||||
* Both together rather than a set beside a session id, which can disagree -- and the pair is what
|
||||
* says a Delete has one parent to send itself to. See [SessionListScreen].
|
||||
*/
|
||||
private data class SubagentSelection(val sessionId: String, val ids: Set<String>)
|
||||
|
||||
/**
|
||||
* A subagent delete waiting to be confirmed: which card, and the rows so the dialog can name one.
|
||||
*/
|
||||
private data class SubagentDeletion(val sessionId: String, val rows: List<SubagentSummary>)
|
||||
|
||||
/**
|
||||
* The order a session's subagents are drawn in: the ones still working first, then most recently
|
||||
* active.
|
||||
*
|
||||
* A display decision, made here rather than on the server, which answers oldest-first and is stable
|
||||
* -- see UI_RULES. Two keys rather than activity alone because a subagent that is thinking has
|
||||
* nothing to report meanwhile, and would sink below one that finished a moment ago; "still working"
|
||||
* is what the reader is looking for at the top.
|
||||
*/
|
||||
private fun subagentOrder(rows: List<SubagentSummary>): List<SubagentSummary> =
|
||||
rows.sortedWith(
|
||||
compareByDescending<SubagentSummary> { it.status == "running" }
|
||||
.thenByDescending { it.lastActivity }
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SessionCard(
|
||||
@@ -333,8 +518,16 @@ private fun SessionCard(
|
||||
expanded: Boolean,
|
||||
/** What the expander's own fetch answered, or null before it has been asked. */
|
||||
subagents: LoadState<List<SubagentSummary>>?,
|
||||
/** What deleting subagents from *this* card failed with, if it has. */
|
||||
subagentError: String?,
|
||||
/** Which of this card's subagents are picked out. Empty means selection mode is off here. */
|
||||
selectedSubagents: Set<String>,
|
||||
/** Every subagent with a delete in flight, whichever card it is in. */
|
||||
deletingSubagents: Set<String>,
|
||||
onToggleSubagents: () -> Unit,
|
||||
onOpenSubagent: (SubagentSummary) -> Unit,
|
||||
onSelectSubagent: (SubagentSummary) -> Unit,
|
||||
onDeleteSubagents: (List<SubagentSummary>) -> Unit,
|
||||
) {
|
||||
BusyItem(label = if (deleting) "deleting" else null) {
|
||||
Card(
|
||||
@@ -433,13 +626,41 @@ private fun SessionCard(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
is LoadState.Loaded ->
|
||||
subagents.value.forEach { subagent ->
|
||||
is LoadState.Loaded -> {
|
||||
val ordered = subagentOrder(subagents.value)
|
||||
ordered.forEach { subagent ->
|
||||
SubagentCard(
|
||||
subagent,
|
||||
selected = subagent.id in selectedSubagents,
|
||||
// Any selection here makes a tap a selection too, so
|
||||
// the reader is never one mis-tap away from leaving
|
||||
// the list they were picking rows in.
|
||||
selecting = selectedSubagents.isNotEmpty(),
|
||||
deleting = subagent.id in deletingSubagents,
|
||||
onClick = { onOpenSubagent(subagent) },
|
||||
onSelect = { onSelectSubagent(subagent) },
|
||||
)
|
||||
}
|
||||
// Beside the rows it acts on rather than at the bottom of the
|
||||
// screen: this selection belongs to one card, and a bar down
|
||||
// there would read as acting on whatever the screen last
|
||||
// showed. See UI_RULES on a control belonging with its thing.
|
||||
if (selectedSubagents.isNotEmpty()) {
|
||||
val picked = ordered.filter { it.id in selectedSubagents }
|
||||
SubagentSelectionBar(
|
||||
picked = picked,
|
||||
onDelete = { onDeleteSubagents(picked) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The server's own words, where the delete was asked for.
|
||||
subagentError?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -453,26 +674,96 @@ private fun SessionCard(
|
||||
* One subagent, indented inside its session's card -- the way dev-updater draws a project's
|
||||
* components (`ComponentCard`, `UpdaterScreen.kt`): an outlined card, not the session card's own
|
||||
* filled one, so the nesting reads as one step rather than as another session.
|
||||
*
|
||||
* Held rather than tapped to pick it out, and tapped to pick it out once anything here is picked --
|
||||
* the import list's gesture, so selecting is learned once.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SubagentCard(
|
||||
subagent: SubagentSummary,
|
||||
selected: Boolean,
|
||||
/** Whether a selection is already open in this card, which makes a tap a selection. */
|
||||
selecting: Boolean,
|
||||
/** Whether this one is being deleted right now -- see [BusyItem]. */
|
||||
deleting: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onSelect: () -> Unit,
|
||||
) {
|
||||
BusyItem(label = if (deleting) "deleting" else null) {
|
||||
OutlinedCard(
|
||||
colors =
|
||||
if (selected)
|
||||
CardDefaults.outlinedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
else CardDefaults.outlinedCardColors(),
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
// Off while the delete is in flight: a card that still opens a transcript
|
||||
// it is deleting is a race the reader can start by tapping. On the card
|
||||
// rather than in [BusyItem], which leaves gestures alone so the list still
|
||||
// scrolls.
|
||||
enabled = !deleting,
|
||||
onClick = { if (selecting) onSelect() else onClick() },
|
||||
onLongClick = onSelect,
|
||||
),
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Text(subagent.title, style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
subagentStatusLabel(subagent.status),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
relativeTime(subagent.lastActivity),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What can be done to the subagents that are selected: delete them, which is the only thing a
|
||||
* subagent has ever had done to it here.
|
||||
*
|
||||
* Delete is **disabled**, not hidden, while anything selected is still running: a subagent's
|
||||
* transcript is still being written to, and its process is its session's to stop -- so the server
|
||||
* refuses that batch, and hiding the button would leave a reader holding a selection with no
|
||||
* controls in it. The reason is in words beside it, because "we won't" and "there is nothing to do"
|
||||
* are different in kind and no shade of a button says which.
|
||||
*/
|
||||
@Composable
|
||||
private fun SubagentCard(subagent: SubagentSummary, onClick: () -> Unit) {
|
||||
OutlinedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
||||
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Text(subagent.title, style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
subagentStatusLabel(subagent.status),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
relativeTime(subagent.lastActivity),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
private fun SubagentSelectionBar(picked: List<SubagentSummary>, onDelete: () -> Unit) {
|
||||
val running = picked.count { it.status == "running" }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
|
||||
) {
|
||||
Text(
|
||||
if (running == 0) "${picked.size} selected" else "$running still running",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onDelete, enabled = running == 0) {
|
||||
Text(
|
||||
"Delete",
|
||||
// Coloured by consequence while it can act; the button's own disabled colour
|
||||
// otherwise, so a red word does not promise something that will not happen.
|
||||
color =
|
||||
if (running == 0) MaterialTheme.colorScheme.error
|
||||
else LocalContentColor.current,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+45
-6
@@ -35,6 +35,8 @@
|
||||
//! against that subagent's own transcript
|
||||
//! GET /sessions/{id}/subagents/{sub}/events?after=N exactly the events route above,
|
||||
//! against that subagent's own stream
|
||||
//! POST /sessions/{id}/subagents/delete {subagents} -- remove finished ones, transcripts
|
||||
//! and all; refused while any named one is running
|
||||
//! POST /sessions/{id}/message {text, attachmentIds?}
|
||||
//! (starts the process first if it has exited)
|
||||
//! POST /sessions/{id}/unqueue {messageId} -- take back one not read yet
|
||||
@@ -138,6 +140,7 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
|
||||
.route("/sessions/{id}/events", get(events))
|
||||
.route("/sessions/{id}/transcript", get(transcript))
|
||||
.route("/sessions/{id}/subagents", get(list_subagents))
|
||||
.route("/sessions/{id}/subagents/delete", post(delete_subagents))
|
||||
.route(
|
||||
"/sessions/{id}/subagents/{sub}/transcript",
|
||||
get(subagent_transcript),
|
||||
@@ -1893,15 +1896,51 @@ async fn list_subagents(
|
||||
UrlPath(id): UrlPath<String>,
|
||||
) -> Result<axum::Json<Vec<SubagentInfo>>, ApiError> {
|
||||
let session = lookup(&manager, &id)?;
|
||||
// Anything but `Exited` or `Unknown` has a process behind it, which is
|
||||
// what decides whether a subagent still reading `Running` from its own
|
||||
// transcript can be believed -- see `SUBAGENTS.md`'s wire shape.
|
||||
let running = !matches!(
|
||||
Ok(axum::Json(
|
||||
session.subagents().list(has_a_process(&session)),
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether this session has a process behind it, which is what decides
|
||||
/// whether a subagent still reading `Running` from its own transcript can be
|
||||
/// believed -- see `SUBAGENTS.md`'s wire shape. Shared by every route that
|
||||
/// asks that question, so listing and deleting cannot disagree about which
|
||||
/// subagents are running.
|
||||
fn has_a_process(session: &LiveSession) -> bool {
|
||||
!matches!(
|
||||
session.status(),
|
||||
crate::session::driver::SessionStatus::Exited
|
||||
| crate::session::driver::SessionStatus::Unknown
|
||||
);
|
||||
Ok(axum::Json(session.subagents().list(running)))
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct DeleteSubagentsRequest {
|
||||
subagents: Vec<String>,
|
||||
}
|
||||
|
||||
/// `POST /sessions/{id}/subagents/delete`: removes finished subagents,
|
||||
/// transcripts and all.
|
||||
///
|
||||
/// A batch rather than a `DELETE` per id, for the reason the import list's
|
||||
/// delete is one: the phone deletes what a reader selected, and one request
|
||||
/// per row means a batch can half-arrive, leaving rows that were missed
|
||||
/// looking exactly like rows nobody picked. Nothing is spawned and nothing
|
||||
/// is reported on a stream -- unlike importing, this is local file removal,
|
||||
/// so it is done by the time the reply is sent.
|
||||
async fn delete_subagents(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<DeleteSubagentsRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let session = lookup(&manager, &id)?;
|
||||
session
|
||||
.subagents()
|
||||
.delete(&body.subagents, has_a_process(&session))
|
||||
.map_err(bad_request)?;
|
||||
tracing::info!("deleted {} subagents of {id}", body.subagents.len());
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Every session's attention-wanting moments, on one stream.
|
||||
|
||||
@@ -313,6 +313,51 @@ impl Subagents {
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes subagents, transcripts and all -- `POST
|
||||
/// /sessions/{id}/subagents/delete`, and the path out for [`start`]
|
||||
/// short of deleting the whole session.
|
||||
///
|
||||
/// **All or nothing, and only for one that has finished.** Every id is
|
||||
/// checked before anything is removed, so a batch naming one that is
|
||||
/// still running leaves the others exactly as they were rather than
|
||||
/// deleting up to the offender -- the reader picked a set, and a set
|
||||
/// half-deleted is indistinguishable, on the list, from rows they never
|
||||
/// picked. Refusing a running one is not withholding the capability:
|
||||
/// its transcript is still being written to, and its process is the
|
||||
/// session's to stop.
|
||||
///
|
||||
/// `session_running` decides what "running" means here, exactly as it
|
||||
/// does in [`Subagents::list`].
|
||||
///
|
||||
/// [`start`]: Subagents::start
|
||||
pub fn delete(&self, ids: &[String], session_running: bool) -> Result<()> {
|
||||
let dirs: Vec<PathBuf> = ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
anyhow::ensure!(is_subagent_id(id), "{id} is not a subagent id");
|
||||
Ok(self.subagents_dir().join(id))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
for (id, dir) in ids.iter().zip(&dirs) {
|
||||
let info = info_of(dir, session_running)
|
||||
.with_context(|| format!("there is no subagent {id} here"))?;
|
||||
anyhow::ensure!(
|
||||
info.status != SessionStatus::Running,
|
||||
"\"{}\" is still running -- it can be deleted once it has finished",
|
||||
info.title
|
||||
);
|
||||
}
|
||||
let mut live = self.live.lock().unwrap();
|
||||
for (id, dir) in ids.iter().zip(&dirs) {
|
||||
fs::remove_dir_all(dir).with_context(|| format!("delete {}", dir.display()))?;
|
||||
// Out of the registry as well as off the disk, so a later child
|
||||
// line for this id starts a new subagent rather than appending
|
||||
// to an unlinked file nothing can read.
|
||||
live.remove(id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every subagent under this session's directory, oldest first --
|
||||
/// `GET /sessions/{id}/subagents`. Read straight from disk rather than
|
||||
/// from `live`, so a subagent from before this process started (or one
|
||||
@@ -463,6 +508,58 @@ mod tests {
|
||||
subagents.finish("never-started");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_finished_subagent_takes_its_directory_with_it() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||
subagents.start("toolu_done", "helper", None);
|
||||
subagents.finish("toolu_done");
|
||||
|
||||
subagents
|
||||
.delete(&["toolu_done".to_string()], true)
|
||||
.expect("delete");
|
||||
|
||||
assert!(subagents.list(true).is_empty());
|
||||
assert!(!dir.path().join("subagents").join("toolu_done").exists());
|
||||
// Out of the live registry too, so a later line starts a new one rather than appending to
|
||||
// a file nothing can read.
|
||||
assert!(subagents.get("toolu_done").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_batch_with_a_running_one_in_it_deletes_none_of_it() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||
subagents.start("toolu_done", "finished helper", None);
|
||||
subagents.finish("toolu_done");
|
||||
subagents.start("toolu_busy", "busy helper", None);
|
||||
|
||||
let err = subagents
|
||||
.delete(&["toolu_done".to_string(), "toolu_busy".to_string()], true)
|
||||
.expect_err("refused");
|
||||
assert!(err.to_string().contains("busy helper"), "{err:#}");
|
||||
assert_eq!(subagents.list(true).len(), 2);
|
||||
|
||||
// The same batch once the session behind it has no process: nothing there is running, so
|
||||
// both go.
|
||||
subagents
|
||||
.delete(&["toolu_done".to_string(), "toolu_busy".to_string()], false)
|
||||
.expect("delete");
|
||||
assert!(subagents.list(false).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_an_id_that_is_not_there_is_refused_rather_than_ignored() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||
assert!(
|
||||
subagents
|
||||
.delete(&["toolu_ghost".to_string()], true)
|
||||
.is_err()
|
||||
);
|
||||
assert!(subagents.delete(&["../../etc".to_string()], true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_all_closes_only_what_is_still_open() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
Reference in new issue
Block a user