The count beside the status said how much work was going and never what,
so "3 bg tasks" was a number with no way to find out what it was about.
Drivers now report the tasks themselves rather than a size:
`Driver::background_tasks` returns `Vec<BackgroundTask>` -- id, the
provider's own description, and a kind -- served by
`GET /sessions/{id}/background`. It is runtime state, never persisted,
and `null` is "nobody has said", which is what a session with no process
answers and what the panel says in words rather than drawing as an empty
list. `description` is optional because Codex names a background terminal
by a process id, and a number drawn as a name is worse than admitting
there is none.
Claude's `background_tasks_changed` entries turn out to be objects
carrying `task_id`, `task_type` and `description`, so each is read rather
than counted -- and an `ambient` one is now dropped from the list and the
count alike, on the CLI's own instruction: a live-update watcher is not
activity, and counting one left a session reading `waiting` with nothing
to wait for.
The phone draws them in the right-hand panel above the subagents,
collapsed to "2 bg tasks running" and pushing the subagents down when
opened. Both lists are items of one lazy column, so neither can run off
the panel, and the section is refetched whenever the live count moves --
a card for work that has finished is exactly the stale measurement the
count exists not to be.
Verified against the real Claude CLI (2.1.261): a backgrounded `sleep 120`
came back as `{"id":"br16327wr","description":"Sleep for 120 seconds",
"kind":"command"}`, and on the emulator against the echo rig the section
appeared, expanded, and dropped a card as its task finished.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
340 lines
14 KiB
Kotlin
340 lines
14 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.activity.compose.BackHandler
|
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
import androidx.compose.foundation.combinedClickable
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.heightIn
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.CardDefaults
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.LocalContentColor
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.OutlinedCard
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.unit.dp
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
|
|
/**
|
|
* What a session has running beside the turn you are reading: its background tasks, then its
|
|
* subagents, in the panel [SidePanels] slides over it from the right.
|
|
*
|
|
* [active] is whether the panel is being looked at: the lists are fetched then rather than on
|
|
* composition, since the panel is composed for every session whether or not anybody opens it.
|
|
*
|
|
* [backgroundTasks] is the live count from the session's own event stream, and is what the
|
|
* background list is refetched against: a card for work that has since finished is a stale
|
|
* measurement drawn as a current one, which is the one thing a list of what is running now must not
|
|
* do.
|
|
*
|
|
* Both lists are items of one lazy column rather than two stacked scrollers, so expanding the
|
|
* background section pushes the subagents down without either being able to run off the panel.
|
|
*/
|
|
@Composable
|
|
fun SubagentPanel(
|
|
settings: ServerSettings,
|
|
summary: SessionSummary,
|
|
active: Boolean,
|
|
backgroundTasks: Int,
|
|
onClose: () -> Unit,
|
|
onOpenSubagent: (SubagentSummary) -> Unit,
|
|
) {
|
|
val scope = rememberCoroutineScope()
|
|
val context = LocalContext.current
|
|
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
|
|
var rows by
|
|
remember(summary.id) { mutableStateOf<LoadState<List<SubagentSummary>>>(LoadState.Loading) }
|
|
var selected by remember(summary.id) { mutableStateOf(setOf<String>()) }
|
|
var deleting by remember(summary.id) { mutableStateOf(setOf<String>()) }
|
|
var deleteError by remember(summary.id) { mutableStateOf<String?>(null) }
|
|
var confirming by remember(summary.id) { mutableStateOf<List<SubagentSummary>?>(null) }
|
|
var refreshToken by remember(summary.id) { mutableIntStateOf(0) }
|
|
var background by
|
|
remember(summary.id) {
|
|
mutableStateOf<LoadState<List<BackgroundTaskSummary>?>>(LoadState.Loading)
|
|
}
|
|
var backgroundExpanded by remember(summary.id) { mutableStateOf(false) }
|
|
|
|
LaunchedEffect(active, refreshToken) {
|
|
if (!active) return@LaunchedEffect
|
|
rows = LoadState.Loading
|
|
rows =
|
|
try {
|
|
val fetched = withContext(Dispatchers.IO) { fetchSubagents(settings, summary.id) }
|
|
selected = selected intersect fetched.mapTo(mutableSetOf()) { it.id }
|
|
LoadState.Loaded(fetched)
|
|
} catch (e: ApiException) {
|
|
LoadState.failed(e)
|
|
}
|
|
}
|
|
|
|
// No reset to Loading on a refetch: the spinner belongs to the first fetch, and one flashed
|
|
// over the list at every start and end would blink precisely when something happened.
|
|
LaunchedEffect(active, backgroundTasks, refreshToken) {
|
|
if (!active || backgroundTasks == 0) return@LaunchedEffect
|
|
background =
|
|
try {
|
|
LoadState.Loaded(
|
|
withContext(Dispatchers.IO) { fetchBackgroundTasks(settings, summary.id) }
|
|
)
|
|
} catch (e: ApiException) {
|
|
LoadState.failed(e)
|
|
}
|
|
}
|
|
|
|
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
|
|
|
|
val ordered = (rows as? LoadState.Loaded)?.value?.let(::subagentOrder)
|
|
|
|
Column(Modifier.fillMaxSize()) {
|
|
Row(
|
|
horizontalArrangement = Arrangement.End,
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
|
) {
|
|
MarkButton("Close panel", onClose) { Chevron(Pointing.Right) }
|
|
}
|
|
|
|
LazyColumn(
|
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
modifier = Modifier.weight(1f).padding(horizontal = 16.dp),
|
|
) {
|
|
backgroundTaskSection(
|
|
count = backgroundTasks,
|
|
tasks = background,
|
|
expanded = backgroundExpanded,
|
|
onToggle = { backgroundExpanded = !backgroundExpanded },
|
|
onRetry = { refreshToken++ },
|
|
)
|
|
item(key = "subagents-heading") { PanelSectionHeading("Subagents") }
|
|
when (val state = rows) {
|
|
is LoadState.Loading ->
|
|
item(key = "subagents-loading") {
|
|
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
|
|
}
|
|
is LoadState.Error ->
|
|
item(key = "subagents-error") {
|
|
Column {
|
|
Text(
|
|
state.message,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
)
|
|
TextButton(onClick = { refreshToken++ }) { Text("Try again") }
|
|
}
|
|
}
|
|
is LoadState.Loaded ->
|
|
if (ordered.isNullOrEmpty()) {
|
|
item(key = "subagents-empty") {
|
|
Text(
|
|
"No subagents in this session.",
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
)
|
|
}
|
|
} else {
|
|
uniqueItems(ordered, key = { it.id }) { subagent ->
|
|
SubagentCard(
|
|
subagent = subagent,
|
|
selected = subagent.id in selected,
|
|
selecting = selected.isNotEmpty(),
|
|
deleting = subagent.id in deleting,
|
|
onClick = { onOpenSubagent(subagent) },
|
|
onSelect = {
|
|
selected =
|
|
if (subagent.id in selected) selected - subagent.id
|
|
else selected + subagent.id
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (selected.isNotEmpty()) {
|
|
val picked = ordered.orEmpty().filter { it.id in selected }
|
|
SubagentSelectionBar(
|
|
picked = picked,
|
|
onDelete = { confirming = picked },
|
|
modifier = Modifier.padding(horizontal = 16.dp),
|
|
)
|
|
}
|
|
deleteError?.let {
|
|
Text(
|
|
it,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
)
|
|
}
|
|
}
|
|
|
|
confirming?.let { picked ->
|
|
AlertDialog(
|
|
onDismissRequest = { confirming = null },
|
|
title = {
|
|
Text(
|
|
if (picked.size == 1) "Delete this subagent?"
|
|
else "Delete ${picked.size} subagents?"
|
|
)
|
|
},
|
|
text = {
|
|
Text(
|
|
(if (picked.size == 1) "\"${picked.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 = {
|
|
confirming = null
|
|
selected = emptySet()
|
|
val ids = picked.map { it.id }
|
|
val gone = ids.toSet()
|
|
deleting += gone
|
|
deleteError = null
|
|
scope.launch {
|
|
try {
|
|
withContext(Dispatchers.IO) {
|
|
deleteSubagents(settings, summary.id, ids)
|
|
gone.forEach {
|
|
transcriptCache
|
|
.session(TranscriptAddress(summary.id, it))
|
|
.purge()
|
|
}
|
|
}
|
|
val loaded = rows
|
|
if (loaded is LoadState.Loaded) {
|
|
rows =
|
|
LoadState.Loaded(loaded.value.filterNot { it.id in gone })
|
|
}
|
|
} catch (e: ApiException) {
|
|
deleteError = e.message ?: "Delete failed"
|
|
} finally {
|
|
deleting -= gone
|
|
}
|
|
}
|
|
}
|
|
) {
|
|
Text("Delete", color = MaterialTheme.colorScheme.error)
|
|
}
|
|
},
|
|
dismissButton = { TextButton(onClick = { confirming = null }) { Text("Cancel") } },
|
|
)
|
|
}
|
|
}
|
|
|
|
private fun subagentOrder(rows: List<SubagentSummary>): List<SubagentSummary> =
|
|
rows.sortedWith(
|
|
compareByDescending<SubagentSummary> { it.status == "running" }
|
|
.thenByDescending { it.lastActivity }
|
|
)
|
|
|
|
@OptIn(ExperimentalFoundationApi::class)
|
|
@Composable
|
|
private fun SubagentCard(
|
|
subagent: SubagentSummary,
|
|
selected: Boolean,
|
|
selecting: Boolean,
|
|
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(
|
|
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.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,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun SubagentSelectionBar(
|
|
picked: List<SubagentSummary>,
|
|
onDelete: () -> Unit,
|
|
modifier: Modifier = Modifier,
|
|
) {
|
|
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",
|
|
color =
|
|
if (running == 0) MaterialTheme.colorScheme.error
|
|
else LocalContentColor.current,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun subagentStatusLabel(status: String) =
|
|
when (status) {
|
|
"running" -> "running"
|
|
"exited" -> "finished"
|
|
else -> "unknown"
|
|
}
|