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

@@ -322,8 +322,18 @@ data class BackgroundTaskSummary(
val description: String?,
/** `agent`, `command`, `workflow`, or `other` for a kind this build has not heard of. */
val kind: String,
/**
* Where the call that started this is in the transcript, for the reader who taps the card.
*
* Null is a provider that does not say which call a task belongs to, or a call the transcript
* no longer holds -- the card is then something to read rather than something to open.
*/
val call: CallSite?,
)
/** Where one thing is in a transcript: the sequence number of the event that is it. */
data class CallSite(val seq: Long)
/**
* What [sessionId] has running in the background right now.
*
@@ -344,6 +354,10 @@ fun fetchBackgroundTasks(
description =
if (row.isNull("description")) null else row.getString("description"),
kind = row.getString("kind"),
call =
row.optJSONObject("call")?.let { call ->
CallSite(seq = call.getLong("seq"))
},
)
}
}
@@ -242,6 +242,11 @@ fun AppRoot(
remember(here.summary.id) {
mutableIntStateOf(here.summary.backgroundTasks)
}
// Where the panel has asked the session screen to put the reader: the
// call a background task was started by. Held here rather than inside
// either, because the two are siblings -- the panel is the one being
// tapped and the transcript is the one that can travel.
var goTo by remember(here.summary.id) { mutableStateOf<CallSite?>(null) }
// 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 --
@@ -279,6 +284,13 @@ fun AppRoot(
backgroundTasks = backgroundTasks,
onClose = close,
onOpenSubagent = { screen = here.copy(subagent = it) },
// Closed with it: what the reader asked to see is under this
// panel, and a panel left open over the answer is the one
// thing the tap cannot have meant.
onOpenCall = {
goTo = it
close()
},
)
},
) {
@@ -293,6 +305,8 @@ fun AppRoot(
share = share,
onShareTaken = { share = null },
onBackgroundTasks = { backgroundTasks = it },
goTo = goTo,
onGoToTaken = { goTo = null },
)
}
}
@@ -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)
}
/**
@@ -146,6 +146,24 @@ val SAVE_GLYPH = glyph(0xF0193)
*/
val DRAG_GLYPH = glyph(0xF035C)
/**
* `md-console_line` -- a shell prompt: a backgrounded command, in the panel beside the turn.
*
* The four marks here are one set, drawn by `backgroundTaskLook`: they exist because the kind of a
* background task used to be a word on a line of its own, which on a list of commands was the same
* two words down the whole panel. Each keeps its words as the description a screen reader is given.
*/
val COMMAND_GLYPH = glyph(0xF07B7)
/** `md-robot` -- a subagent: something running that is doing its own reasoning. */
val AGENT_GLYPH = glyph(0xF06A9)
/** `md-sitemap` -- a workflow: steps arranged by something other than the agent itself. */
val WORKFLOW_GLYPH = glyph(0xF04AA)
/** `md-help_circle_outline` -- a background task of a kind this build has not heard of. */
val UNKNOWN_GLYPH = glyph(0xF0625)
/**
* The size an icon draws at beside a line of text.
*
@@ -203,6 +203,17 @@ fun SessionScreen(
onBackgroundTasks: (Int) -> Unit = {},
/** Said once [share] has been attached here, so it is not attached again. */
onShareTaken: () -> Unit = {},
/**
* Somewhere in this transcript to put the reader, asked for from outside the screen: the call a
* background task was started by, tapped in the session's own panel.
*
* The panel is over this screen rather than a screen of its own, so it cannot travel through
* the transcript itself -- and the travelling is this screen's anyway, since only it knows what
* is loaded and how to load the rest. See [travelTo].
*/
goTo: CallSite? = null,
/** Said once [goTo] has been travelled to, so the same journey is not made twice. */
onGoToTaken: () -> Unit = {},
/**
* Draws this screen read-only, on a subagent's own transcript instead of the session's.
*
@@ -553,6 +564,18 @@ fun SessionScreen(
if (followingNewest && held.isEmpty()) record(entry) else held = held + entry
}
/**
* Applies what arrived while the reader was away from the newest end, oldest first.
*
* Everything at once rather than paced out, and emptied before the first of them is recorded,
* since [apply] holds anything landing while a backlog is still outstanding.
*/
fun releaseHeld() {
val backlog = held
held = listOf()
backlog.forEach { record(it) }
}
/**
* A press on the transcript that would open or close something, and the one thing every such
* press has to check first.
@@ -794,6 +817,101 @@ fun SessionScreen(
}
}
/**
* Puts the reader at [anchor], paging history back until the row holding it is loaded.
*
* Two things travel: reopening a session puts the reader back where they stopped, and tapping a
* background task in the session's panel takes them to the call that started it. They are the
* same journey, and the parts that are easy to get wrong -- how much to ask for, what to do
* about the oldest half-row, waiting for the composition to build the row before turning it
* into an index -- are wrong in the same way for both.
*
* Nothing is drawn while this runs (see [restoring]): opening at the newest end and then
* travelling is exactly the journey a reader must never see.
*
* [openCall] opens the tool call at the anchor, and the run it is drawn inside, on the way
* past. That is what makes a tapped background task land on its own card with its output
* showing, rather than on a shut group the reader then has to find it in.
*/
suspend fun travelTo(anchor: ScrollAnchor, openCall: Boolean = false) {
// Pages until the anchor's row is loaded and has something older behind it. The oldest
// loaded row is a half-row that grows when the page behind it arrives, so anchoring into
// one puts the reader where they were only until that lands.
//
// This terminates because `oldestSeq` walks strictly backwards and the anchor is a seq.
// Keying on the row's *name* instead could not promise that -- a tool run is renamed
// whenever the newest page starts somewhere new, so an anchor on one was never found and
// this paged to the first event of the conversation every time.
while (moreHistory && anchorRow(anchor.seq) == null) {
// The whole span in one request rather than a page at a time. `read_window` counts
// *lines* and a transcript numbers them one per seq, so the distance back to the anchor
// is the number of events to ask for -- and were seqs ever sparse, that difference
// overshoots into older history rather than stopping short.
//
// Capped, and the loop is what makes the cap safe: a span past it comes back in several
// requests instead of one, which is what this did for every restore until now --
// thirteen sequential round trips to reopen a session somebody had read a little way
// back into. Raw, not coalesced: this counts events back to a known seq, and a page
// measured in rows cannot be counted to one.
val behind = oldestSeq - anchor.seq
val loaded =
if (behind < 0) {
// The anchor's row is loaded but is the oldest half-row, which [anchorRow]
// refuses; what completes it is the row before it, and only a page counted in
// rows can promise to reach that. Counted in events the span is negative and
// was coerced to one: a request per delta, six hundred round trips for an
// anchor inside a 1,400-delta reply.
loadOlderPage()
} else {
loadOlderPage(
(behind + RESTORE_PAGE_CUSHION)
.coerceIn(1L, RESTORE_PAGE_MAX.toLong())
.toInt(),
coalesce = false,
)
}
if (!loaded) break
}
// Opened before the anchor is resolved, because opening a call held inside a group is what
// decides how tall that row is and the scroll is aimed at the row.
if (openCall) {
val call =
items.filterIsInstance<TranscriptItem.ToolRun>().firstOrNull {
it.seq == anchor.seq
}
if (call != null) {
// The group it is drawn in, where it is drawn in one: a call inside a shut group is
// open behind a heading, which is nothing at all on screen. Recorded as having been
// in a group at the same moment, which is what the effect below does a composition
// later -- without it, whether opening the call pulls it out of its group depends
// on which of the two got there first.
groupToolRuns(items, heldOut)
.filterIsInstance<TranscriptRow.Tools>()
.firstOrNull { group -> group.calls.any { it.id == call.id } }
?.let {
everGrouped = everGrouped + call.id
expandedGroups = expandedGroups + it.key
}
expandedTools = expandedTools + call.id
}
}
// Resolved to the row that *holds* the position rather than passed straight through,
// because the two are not always the same seq: the events behind a row regroup between the
// save and the reopen. Null is a row no longer in the transcript at all, and means there is
// nothing to put back.
anchorRow(anchor.seq)?.let { rowSeq ->
// The units are built by composition, and this coroutine has been loading rows the
// composition may not have seen -- so wait for the build that holds the anchor's row
// before turning it into an index. Guaranteed to arrive, because the units are a pure
// function of `items`. Nothing is drawn during the wait. One past the index, because
// item zero is the "below" slot.
val index =
snapshotFlow { unitIndexFor(currentUnits, rowSeq, anchor.unit) }
.first { it != null }!!
listState.scrollToItem(index + 1, anchor.offset)
}
}
// Which calls have been inside a group, which is what [heldOut] subtracts: a call the reader
// opened while it stood on its own is held out of the run it belongs to until they close it,
// and a call that has been in a group is one that opening will never take back out.
@@ -901,62 +1019,7 @@ fun SessionScreen(
try {
// Then back where reading stopped. An anchor deeper than the newest page is exactly the
// one worth restoring -- somebody who read to the bottom has no anchor at all.
savedAnchor?.let { anchor ->
// Pages until the anchor's row is loaded and has something older behind it. The
// oldest loaded row is a half-row that grows when the page behind it arrives, so
// anchoring into one puts the reader where they were only until that lands.
//
// This terminates because `oldestSeq` walks strictly backwards and the anchor is a
// seq. Keying on the row's *name* instead could not promise that -- a tool run is
// renamed whenever the newest page starts somewhere new, so an anchor on one was
// never found and this paged to the first event of the conversation every time.
while (moreHistory && anchorRow(anchor.seq) == null) {
// The whole span in one request rather than a page at a time. `read_window`
// counts *lines* and a transcript numbers them one per seq, so the distance
// back to the anchor is the number of events to ask for -- and were seqs ever
// sparse, that difference overshoots into older history rather than stopping
// short.
//
// Capped, and the loop is what makes the cap safe: a span past it comes back in
// several requests instead of one, which is what this did for every restore
// until now -- thirteen sequential round trips to reopen a session somebody had
// read a little way back into. Raw, not coalesced: this counts events back to a
// known seq, and a page measured in rows cannot be counted to one.
val behind = oldestSeq - anchor.seq
val loaded =
if (behind < 0) {
// The anchor's row is loaded but is the oldest half-row, which
// [anchorRow] refuses; what completes it is the row before it, and only
// a page counted in rows can promise to reach that. Counted in events
// the span is negative and was coerced to one: a request per delta, six
// hundred round trips for an anchor inside a 1,400-delta reply.
loadOlderPage()
} else {
loadOlderPage(
(behind + RESTORE_PAGE_CUSHION)
.coerceIn(1L, RESTORE_PAGE_MAX.toLong())
.toInt(),
coalesce = false,
)
}
if (!loaded) break
}
// Resolved to the row that *holds* the saved position rather than passed straight
// through, because the two are not always the same seq: the events behind a row
// regroup between the save and the reopen. Null is a row no longer in the
// transcript at all, and means there is nothing to put back.
anchorRow(anchor.seq)?.let { rowSeq ->
// The units are built by composition, and this coroutine has been loading rows
// the composition may not have seen -- so wait for the build that holds the
// anchor's row before turning it into an index. Guaranteed to arrive, because
// the units are a pure function of `items`. Nothing is drawn during the wait.
// One past the index, because item zero is the "below" slot.
val index =
snapshotFlow { unitIndexFor(currentUnits, rowSeq, anchor.unit) }
.first { it != null }!!
listState.scrollToItem(index + 1, anchor.offset)
}
}
savedAnchor?.let { travelTo(it) }
} catch (e: ApiException) {
// A page of history that never arrived. The reader is left at the newest end rather
// than where they were, which is the state this screen opens in anyway.
@@ -982,6 +1045,34 @@ fun SessionScreen(
}
}
// Where the session's panel asked for the reader to be put. Gated on `ready`, which is the
// opening effect having finished putting them wherever they were: the two both scroll, and a
// jump that won the race would be undone by the restore landing behind it.
LaunchedEffect(goTo, ready, epoch) {
val target = goTo ?: return@LaunchedEffect
if (!ready) return@LaunchedEffect
// What arrived while the reader was back here is in no row at all -- events landing away
// from the newest end are held rather than applied -- and a call started since is exactly
// the one being tapped. So the backlog lands before the journey is planned; the reader is
// about to be moved deliberately anyway, which is the thing holding it exists to avoid.
releaseHeld()
// And whatever arrives from here on is held again: the reader asked to be somewhere, and
// the newest end is not it.
followingNewest = false
restoring = true
try {
travelTo(ScrollAnchor(target.seq, offset = 0), openCall = true)
} catch (e: ApiException) {
// The history the journey needed never arrived, so the reader is where they already
// were. Said rather than silently ignored -- a tap that did nothing at all is a broken
// control.
streamError = e.message
} finally {
restoring = false
onGoToTaken()
}
}
// Only while the screen is actually on screen. Android stops the activity when somebody
// switches away and the socket dies with it, which arrived as "Lost the event stream" waiting
// at the top on their return. Switching apps is a choice somebody made, not a fault to report.
@@ -1094,19 +1185,15 @@ fun SessionScreen(
}
}
// Back at the newest end, so the backlog [apply] held can land. Everything at once rather than
// paced out: they are at the bottom, which is the one place the list is allowed to follow new
// content.
// Back at the newest end, so the backlog held can land: the bottom is the one place the list is
// allowed to follow new content.
LaunchedEffect(listState) {
snapshotFlow { Triple(listState.isScrollInProgress, atNewest, held.isNotEmpty()) }
.collect { (scrolling, newest, hasHeld) ->
if (scrolling && !newest) followingNewest = false
if (!newest) return@collect
followingNewest = true
if (!hasHeld) return@collect
val backlog = held
held = listOf()
backlog.forEach { record(it) }
if (hasHeld) releaseHeld()
}
}
// Where the reader left off, written whenever the list settles somewhere new.
@@ -45,6 +45,9 @@ import kotlinx.coroutines.withContext
* [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.
*
* [onOpenCall] takes the reader to where a background task was started, in the transcript under
* this panel -- so the panel is closed with it, which is the caller's to do.
*
* [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
@@ -61,6 +64,7 @@ fun SubagentPanel(
backgroundTasks: Int,
onClose: () -> Unit,
onOpenSubagent: (SubagentSummary) -> Unit,
onOpenCall: (CallSite) -> Unit,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
@@ -128,6 +132,7 @@ fun SubagentPanel(
expanded = backgroundExpanded,
onToggle = { backgroundExpanded = !backgroundExpanded },
onRetry = { refreshToken++ },
onOpenCall = onOpenCall,
)
item(key = "subagents-heading") { PanelSectionHeading("Subagents") }
when (val state = rows) {