List a session's background tasks above its subagents
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>
This commit is contained in:
1 parent
c8bfc958ad
commit
942edd6b31
17 files changed
+620
-115
No files matched your search
@@ -308,6 +308,46 @@ data class SubagentSummary(
|
||||
val lastActivity: Double,
|
||||
)
|
||||
|
||||
/**
|
||||
* One thing a session has running while it is free to do something else: a backgrounded command, a
|
||||
* subagent, whatever else its provider can leave going. See `GET /sessions/{id}/background`.
|
||||
*/
|
||||
data class BackgroundTaskSummary(
|
||||
/** The provider's own id. Never shown -- it is what keys the list and matches two snapshots. */
|
||||
val id: String,
|
||||
/**
|
||||
* What it is doing, in the provider's own words. Null where it names a task by nothing a reader
|
||||
* would recognise -- Codex reports a process id -- and the card says so instead.
|
||||
*/
|
||||
val description: String?,
|
||||
/** `agent`, `command`, `workflow`, or `other` for a kind this build has not heard of. */
|
||||
val kind: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* What [sessionId] has running in the background right now.
|
||||
*
|
||||
* Null is "its provider has not said", which a stopped session and an old backend both answer, and
|
||||
* is a different thing from the empty list.
|
||||
*/
|
||||
fun fetchBackgroundTasks(
|
||||
settings: ServerSettings,
|
||||
sessionId: String,
|
||||
): List<BackgroundTaskSummary>? =
|
||||
requestFromServer(settings, "/sessions/$sessionId/background") { connection ->
|
||||
val body = connection.inputStream.bufferedReader().readText()
|
||||
if (body.trim() == "null") null
|
||||
else
|
||||
JSONArray(body).mapObjects { row ->
|
||||
BackgroundTaskSummary(
|
||||
id = row.getString("id"),
|
||||
description =
|
||||
if (row.isNull("description")) null else row.getString("description"),
|
||||
kind = row.getString("kind"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchSubagents(settings: ServerSettings, sessionId: String): List<SubagentSummary> =
|
||||
requestFromServer(settings, "/sessions/$sessionId/subagents") {
|
||||
it.jsonObjects { row ->
|
||||
|
||||
@@ -235,6 +235,13 @@ fun AppRoot(
|
||||
else Modifier
|
||||
)
|
||||
) {
|
||||
// How much background work the session has, from the one subscription
|
||||
// to its events the screen below holds. Here because the panel and that
|
||||
// screen both draw it, and must draw the same number.
|
||||
var backgroundTasks by
|
||||
remember(here.summary.id) {
|
||||
mutableIntStateOf(here.summary.backgroundTasks)
|
||||
}
|
||||
// The two panels this session can be pulled aside for: its subagents
|
||||
// from the right, and the whole main screen from the left. Both are here
|
||||
// rather than screens of their own for the same reason the explorer is --
|
||||
@@ -269,6 +276,7 @@ fun AppRoot(
|
||||
settings = current,
|
||||
summary = here.summary,
|
||||
active = active,
|
||||
backgroundTasks = backgroundTasks,
|
||||
onClose = close,
|
||||
onOpenSubagent = { screen = here.copy(subagent = it) },
|
||||
)
|
||||
@@ -284,6 +292,7 @@ fun AppRoot(
|
||||
onFiles = { screen = here.copy(files = it) },
|
||||
share = share,
|
||||
onShareTaken = { share = null },
|
||||
onBackgroundTasks = { backgroundTasks = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.LazyListScope
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* The background work a session has going, above its subagents in the panel [SidePanels] slides
|
||||
* over it from the right.
|
||||
*
|
||||
* Collapsed to its one-line count by default, the way everything else this app adds to a screen
|
||||
* arrives: what a reader came to the panel for is the subagents, and a run of cards about work
|
||||
* nobody asked after would push them off it. Expanding pushes them down instead of covering them,
|
||||
* so the two are read together.
|
||||
*
|
||||
* Nothing is drawn at all when the count is zero -- including when the provider never said, which
|
||||
* is the same absence the status row draws. A permanently visible "0 bg tasks" would be a line
|
||||
* about nothing on every session that has never backgrounded anything, which is most of them.
|
||||
*/
|
||||
fun LazyListScope.backgroundTaskSection(
|
||||
count: Int,
|
||||
tasks: LoadState<List<BackgroundTaskSummary>?>,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
if (count == 0) return
|
||||
item(key = "background-heading") {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable(onClick = onToggle),
|
||||
) {
|
||||
Text(
|
||||
"${backgroundTaskLabel(count)} running",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Chevron(if (expanded) Pointing.Up else Pointing.Down)
|
||||
}
|
||||
}
|
||||
if (!expanded) return
|
||||
when (tasks) {
|
||||
is LoadState.Loading ->
|
||||
item(key = "background-loading") {
|
||||
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
|
||||
}
|
||||
is LoadState.Error ->
|
||||
item(key = "background-error") {
|
||||
Column {
|
||||
Text(
|
||||
tasks.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
TextButton(onClick = onRetry) { Text("Try again") }
|
||||
}
|
||||
}
|
||||
// Null is the provider declining to say, which a session whose process has gone answers.
|
||||
// Said in words: the count above came from somewhere, and an empty space under it would
|
||||
// read as the tasks having finished rather than as nobody being left to ask.
|
||||
is LoadState.Loaded ->
|
||||
when (val rows = tasks.value) {
|
||||
null ->
|
||||
item(key = "background-unknown") {
|
||||
Text(
|
||||
"This session isn't saying what these are.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
else ->
|
||||
uniqueItems(rows, key = { "background-${it.id}" }) { BackgroundTaskCard(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One background task: what it is doing, and what kind of thing is doing it.
|
||||
*
|
||||
* Not something to open, unlike the subagent cards below it -- a task is a provider's runtime state
|
||||
* and has no transcript of its own. A backgrounded agent that does is also in the subagent list,
|
||||
* under its own name.
|
||||
*/
|
||||
@Composable
|
||||
private fun BackgroundTaskCard(task: BackgroundTaskSummary) {
|
||||
val kind = backgroundTaskKindLabel(task.kind)
|
||||
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
// The kind stands in as the title where the provider gave no description, rather than
|
||||
// the id it named the task by: Codex reports a process number, which says nothing to
|
||||
// the person reading and would look like a name somebody chose.
|
||||
Text(
|
||||
task.description ?: kind,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color =
|
||||
if (task.description == null) MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else LocalContentColor.current,
|
||||
)
|
||||
if (task.description != null) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
kind,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a [BackgroundTaskSummary.kind] is called on screen.
|
||||
*
|
||||
* A kind this build has not heard of is named by what every one of them has in common rather than
|
||||
* by the nearest word we do know, which would be this screen asserting something the server never
|
||||
* said.
|
||||
*/
|
||||
private fun backgroundTaskKindLabel(kind: String) =
|
||||
when (kind) {
|
||||
"agent" -> "subagent"
|
||||
"command" -> "background command"
|
||||
"workflow" -> "workflow"
|
||||
else -> "background task"
|
||||
}
|
||||
|
||||
/**
|
||||
* The heading over one group in the panel, so neither list is a run of cards with no name.
|
||||
*
|
||||
* The same band as the background section's own heading row above, rather than a gap chosen to look
|
||||
* right here: what separates a heading from the cards above it is that both headings sit in a row
|
||||
* of one height.
|
||||
*/
|
||||
@Composable
|
||||
fun PanelSectionHeading(text: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.heightIn(min = 48.dp)) {
|
||||
Text(text, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,12 @@ fun SessionScreen(
|
||||
onFiles: (FilesTarget) -> Unit,
|
||||
/** What another app shared in while this session is the one open; see [ShareRequest]. */
|
||||
share: ShareRequest? = null,
|
||||
/**
|
||||
* The live background-task count, passed out so the session's panel can list those tasks
|
||||
* against the same number this screen draws beside the status. This screen holds the only
|
||||
* subscription to the session's events, and the panel opening its own would be a second copy.
|
||||
*/
|
||||
onBackgroundTasks: (Int) -> Unit = {},
|
||||
/** Said once [share] has been attached here, so it is not attached again. */
|
||||
onShareTaken: () -> Unit = {},
|
||||
/**
|
||||
@@ -525,6 +531,7 @@ fun SessionScreen(
|
||||
}
|
||||
if (event is SessionEvent.BackgroundTasks && !isSubagent) {
|
||||
backgroundTasks = event.count
|
||||
onBackgroundTasks(event.count)
|
||||
}
|
||||
if (!isSubagent) loginOpen = authenticationPromptAfter(loginOpen, event)
|
||||
// In order, always: one late event recorded ahead of the backlog would fold a
|
||||
|
||||
@@ -39,16 +39,26 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* A session's subagents, listed in the panel [SidePanels] slides over it from the right.
|
||||
* 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 list is fetched then rather than on
|
||||
* [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,
|
||||
) {
|
||||
@@ -62,6 +72,11 @@ fun SubagentPanel(
|
||||
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
|
||||
@@ -76,49 +91,71 @@ fun SubagentPanel(
|
||||
}
|
||||
}
|
||||
|
||||
// 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),
|
||||
) {
|
||||
Text(
|
||||
"Subagents",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
MarkButton("Close subagents", onClose) { Chevron(Pointing.Right) }
|
||||
MarkButton("Close panel", onClose) { Chevron(Pointing.Right) }
|
||||
}
|
||||
|
||||
when (val state = rows) {
|
||||
is LoadState.Loading ->
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.padding(16.dp).width(24.dp).height(24.dp)
|
||||
)
|
||||
is LoadState.Error ->
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
state.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
TextButton(onClick = { refreshToken++ }) { Text("Try again") }
|
||||
}
|
||||
is LoadState.Loaded -> {
|
||||
val ordered = subagentOrder(state.value)
|
||||
if (ordered.isEmpty()) {
|
||||
Text(
|
||||
"No subagents in this session.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 16.dp),
|
||||
) {
|
||||
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,
|
||||
@@ -134,25 +171,25 @@ fun SubagentPanel(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (selected.isNotEmpty()) {
|
||||
val picked = ordered.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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 ->
|
||||
|
||||
Reference in new issue
Block a user