Draw a background task as what it ran, and go there on a tap

A card in the session's panel said "background command" under every
description -- and for Codex, which names a terminal by a process id and
gives no description at all, that phrase was the whole of every card.

Both halves of the answer are in the transcript rather than in what the
provider says: a driver now reports which tool call its task belongs to
(Claude's `task_started` carries the `tool_use_id`, Codex's terminal list
the `itemId`), and `LiveSession::background_tasks` resolves those ids
against the transcript into a sequence number and, where the provider said
nothing, the command the call was made with. So the card draws the command,
and the kind shrinks to a mark beside it whose name is what a screen reader
is given.

Tapping one goes to that call in the transcript, opened, which is where a
backgrounded command's output already lands -- rather than drawing a second
copy of it beside the panel. The journey is the one a reopened session
already makes to put a reader back where they stopped, now one function
(`travelTo`). It has to release the held backlog first: events arriving
while the reader is away from the newest end are held rather than applied,
so a task started since they scrolled back was in no row at all and the tap
looked like it had done nothing.

Verified against the sandbox on the emulator: the panel draws
`sleep 120 && echo done` for an echo session's `/background`, and tapping
it lands on that Bash card with its output showing.
This commit is contained in:
iris-ai committed 2026-09-20 18:46:22 -04:00
1 parent 3b309766d7
commit cedb18e8c1
18 files changed
+635 -151

No files matched your search

@@ -19,6 +19,10 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
@@ -40,6 +44,7 @@ fun LazyListScope.backgroundTaskSection(
expanded: Boolean,
onToggle: () -> Unit,
onRetry: () -> Unit,
onOpenCall: (CallSite) -> Unit,
) {
if (count == 0) return
item(key = "background-heading") {
@@ -86,58 +91,102 @@ fun LazyListScope.backgroundTaskSection(
)
}
else ->
uniqueItems(rows, key = { "background-${it.id}" }) { BackgroundTaskCard(it) }
uniqueItems(rows, key = { "background-${it.id}" }) { task ->
BackgroundTaskCard(
task,
onOpen = task.call?.let { call -> { onOpenCall(call) } },
)
}
}
}
}
/**
* One background task: what it is doing, and what kind of thing is doing it.
* One background task: what it is doing, drawn as one line with its kind as the mark beside 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.
* The kind used to be a second line under the words, which on a list of backgrounded commands was
* "background command" repeated down the panel -- and for a provider that names a task by a process
* id it was the *whole* card, so every row said the same two words. A mark carries the same
* difference in a width the text does not have to make room for, and it is the [Glyph]'s
* description that keeps the words for anybody who cannot see it.
*
* [onOpen] is where the call that started this is in the transcript, for the readers who tap it:
* null where the provider never said which call it was, or where that call is no longer in the
* transcript, and the card is then a statement rather than a control. The chevron is what says
* which of the two this is, since a card that quietly does nothing when pressed is worse than one
* that never invited the press.
*/
@Composable
private fun BackgroundTaskCard(task: BackgroundTaskSummary) {
val kind = backgroundTaskKindLabel(task.kind)
private fun BackgroundTaskCard(task: BackgroundTaskSummary, onOpen: (() -> Unit)?) {
val look = backgroundTaskLook(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
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.then(
if (onOpen == null) Modifier
else
Modifier.clickable(
onClickLabel = "Show where this started",
onClick = onOpen,
)
)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Glyph(
look.glyph,
colour = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.semantics { contentDescription = look.words },
)
Spacer(Modifier.width(10.dp))
// The kind stands in as the words 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.
//
// Cut at its tail: what identifies a command is the program at its head, and the long
// ones are exactly the ones being read closely.
Text(
task.description ?: kind,
style = MaterialTheme.typography.titleSmall,
task.description ?: look.words,
style =
if (look.mono)
MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace)
else MaterialTheme.typography.bodyMedium,
color =
if (task.description == null) MaterialTheme.colorScheme.onSurfaceVariant
else LocalContentColor.current,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (task.description != null) {
Spacer(Modifier.height(2.dp))
Text(
kind,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (onOpen != null) {
Spacer(Modifier.width(8.dp))
Chevron(Pointing.Right)
}
}
}
}
/** How one kind of background task is drawn: see [backgroundTaskLook]. */
private data class TaskLook(val glyph: String, val words: String, val mono: Boolean)
/**
* What a [BackgroundTaskSummary.kind] is called on screen.
* Everything a [BackgroundTaskSummary.kind] decides, answered by one `when`.
*
* 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.
* One rather than three, which is the rule this screen already learned once with the status word
* and its colour: three `when`s over one set is two of them waiting to miss a member.
*
* A kind this build has not heard of takes the question mark and is named by what every one of them
* has in common. The nearest word or mark we do know -- a robot, a terminal -- would be this screen
* deciding what the server meant by a word it invented after this build shipped.
*/
private fun backgroundTaskKindLabel(kind: String) =
private fun backgroundTaskLook(kind: String) =
when (kind) {
"agent" -> "subagent"
"command" -> "background command"
"workflow" -> "workflow"
else -> "background task"
// A command is drawn in the face a command is drawn in everywhere else here.
"command" -> TaskLook(COMMAND_GLYPH, "background command", mono = true)
"agent" -> TaskLook(AGENT_GLYPH, "subagent", mono = false)
"workflow" -> TaskLook(WORKFLOW_GLYPH, "workflow", mono = false)
else -> TaskLook(UNKNOWN_GLYPH, "background task", mono = false)
}
/**