Say when a session is working, and what it was told

Three things a phone could not see, all of them the same shape: the
session was doing something and nothing on screen said so.

A turn nobody here started never reported itself. `Running` was sent
where a message was *sent*, so a session picked up mid-turn, one
compacting on its own, or one another agent wrote to sat there reading
as idle until it finished. The driver now says it from what it observes
-- output that could only come from a turn in flight -- which is the
same set of events that already announced a steer, with the ends
swapped.

An imported session had it worse: nothing but replayed lines ever
reaches it, and a status was not among them, so it was permanently
whatever it was when it was adopted. Its file does not record a turn
ending, but it does record why each assistant message stopped, and
`tool_use` versus anything else answers it. A record that says nothing
leaves the status alone rather than voting for idle.

Messages from other agents were dropped outright: the CLI marks them
meta, and this replayed everything except meta. They are now a row of
their own, closed by default like a tool call, named for the session
that sent it -- not the reader's own bubble, because they did not say
it, and a session working on something this phone never asked for is
exactly what one of these explains.

Measured against a real session file rather than guessed: the peer
record carries the sender's name and the message body in `origin`,
beside a copy wrapped for the model to read.
This commit is contained in:
iris committed 2026-08-29 15:27:46 -04:00
1 parent f18639e4b1
commit 404066fa7d
11 files changed
+577 -29

No files matched your search

@@ -1,7 +1,9 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -51,3 +53,56 @@ fun compactionSummary(item: TranscriptItem.CompactedNote): String {
}
private fun tokens(count: Long): String = "%,d".format(count)
/**
* Where the working indicator goes while a compaction is running.
*
* A bar across the whole row rather than the spinner an ordinary turn gets, because a compaction is
* not an ordinary turn: nothing arrives in the transcript while it runs, so the row it occupies is
* the only thing on screen that is moving, and at the width of a spinner that reads as a session
* that might have hung.
*
* The bar is indeterminate, and that is a statement rather than an omission. The CLI says a
* compaction has started and then says nothing at all until it has finished -- measured, not
* assumed -- so there is no fraction to fill, and a bar that crept along at the pace of the last
* compaction would be this screen inventing the part nobody sent it. What it can honestly say is
* that work is happening and for how long, which is [compactingLabel].
*/
@Composable
fun CompactingRow(seconds: Long?, modifier: Modifier = Modifier) {
Column(modifier.fillMaxWidth()) {
Text(
compactingLabel(seconds),
style = MaterialTheme.typography.bodySmall,
// The colour is stated beside the fill rather than inherited: a semantic colour has
// to carry its own contrast, since the surface under it will not change to rescue it.
color = compactingColor,
)
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
color = compactingColor,
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
)
}
}
/**
* What the working indicator says while a compaction is running.
*
* Elapsed time and nothing else, because elapsed time is all there is: the CLI announces that a
* compaction has begun and then says nothing until it has finished, so any bar, percentage or
* estimate here would be this screen's guess wearing a measurement's clothes. Knowing it has been
* going forty seconds is what a reader actually wants -- it is the difference between waiting and
* going to look at why.
*
* [seconds] is null when this device did not see the compaction start, which is what opening a
* session that is already compacting looks like. That case says only "compacting": no number is the
* honest answer, and a number counted from the moment the screen opened would be wrong in the
* direction that matters, since a compaction somebody is asking about is a long one.
*/
fun compactingLabel(seconds: Long?): String =
when {
seconds == null -> "compacting"
seconds < 60 -> "compacting ${seconds}s"
else -> "compacting ${seconds / 60}m ${seconds % 60}s"
}
@@ -37,6 +37,15 @@ sealed class SessionEvent {
data class Answered(val id: String, val answer: String) : SessionEvent()
/**
* A message another agent sent this session.
*
* Not a [UserMessage]: nobody holding the phone said it, and drawing it in their voice would
* claim they had. It is also the explanation for a session that starts working on something
* this device never asked for.
*/
data class PeerMessage(val from: String, val text: String) : SessionEvent()
data class Status(val state: String) : SessionEvent()
data class UsageDelta(val tokens: Long) : SessionEvent()
@@ -96,6 +105,8 @@ fun parseSeqEvent(json: String): SeqEvent {
about = body.optString("about").ifEmpty { null },
)
"answered" -> SessionEvent.Answered(body.getString("id"), body.getString("answer"))
"peerMessage" ->
SessionEvent.PeerMessage(body.getString("from"), body.getString("text"))
"status" -> SessionEvent.Status(body.getString("state"))
"usageDelta" -> SessionEvent.UsageDelta(body.getLong("tokens"))
"compacted" ->
@@ -0,0 +1,57 @@
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* A message another agent sent this session, closed until somebody asks.
*
* Closed by default, like a tool call and for the same reason: these are long, there can be several
* in a row, and what a reader scanning the transcript needs from one is that it happened and who
* sent it. The first line comes with the heading because a name alone does not say which message
* this was.
*
* Drawn as its own kind rather than as the reader's own bubble. They did not say this, and a
* transcript that puts it in their voice is making a claim about who asked for the work that
* follows -- which is exactly the question a peer message is usually the answer to.
*/
@Composable
fun PeerMessageRow(
item: TranscriptItem.PeerNote,
expanded: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall)
if (!expanded) {
Spacer(Modifier.width(8.dp))
Text(
item.text.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
// The head, not the tail: a message is identified by how it opens.
overflow = TextOverflow.Ellipsis,
)
}
}
if (expanded) MarkdownText(item.text, Modifier.padding(top = 6.dp))
}
}
}
@@ -261,7 +261,7 @@ fun StatusText(status: String) {
when (status) {
"awaitingInput" -> "your turn" to awaitingColor
"running" -> "running" to runningColor
"compacting" -> "compacting" to runningColor
"compacting" -> "compacting" to compactingColor
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
// Said in words, because it differs in kind from the others rather than in degree:
// the session is not idle and has not exited, nobody has been able to find out
@@ -271,9 +271,12 @@ fun StatusText(status: String) {
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (status == "running" || status == "compacting") {
// The same colour as the word beside it: the two are one signal, and a spinner in
// the theme's accent says the state is something other than what the label says.
CircularProgressIndicator(
modifier = Modifier.width(14.dp).height(14.dp),
strokeWidth = 2.dp,
color = color,
)
Spacer(Modifier.width(6.dp))
}
@@ -1,5 +1,6 @@
package com.example.aiapp
import android.os.SystemClock
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
@@ -125,6 +126,14 @@ sealed class TranscriptItem {
/** An image by server-side ref, fetched from the session's files route. */
data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem()
/**
* A message another agent sent this session.
*
* Its own row rather than a [UserMsg]: see [PeerMessageRow] for why the voice matters.
*/
data class PeerNote(override val seq: Long, val from: String, val text: String) :
TranscriptItem()
/** Placeholder row for events this build can't render (newer kinds). */
data class Note(override val seq: Long, val text: String) : TranscriptItem()
@@ -222,6 +231,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
else -> it
}
}
is SessionEvent.PeerMessage ->
items + TranscriptItem.PeerNote(entry.seq, event.from, event.text)
is SessionEvent.Status -> items
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
is SessionEvent.Image ->
@@ -269,6 +280,11 @@ fun SessionScreen(
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
var status by remember { mutableStateOf(summary.status) }
var totalTokens by remember { mutableLongStateOf(0L) }
// When this screen saw the current compaction start, on this device's own clock, and how long
// ago that is. See `compactingLabel`: null is the honest answer whenever the start was not
// witnessed here, which is what opening a session that is already compacting looks like.
var compactingSince by remember { mutableStateOf<Long?>(null) }
var compactingFor by remember { mutableStateOf<Long?>(null) }
var streamError by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
var input by remember { mutableStateOf("") }
@@ -276,6 +292,10 @@ fun SessionScreen(
// Which runs of adjacent tool calls are open. Keyed by the first call's
// id, so a group survives more calls arriving after it.
var expandedGroups by remember { mutableStateOf(setOf<String>()) }
// Which messages from other agents are open, by the seq that identifies their row. Closed
// by default, which is the rule for anything new in this transcript: a screen that opens
// everything it can is one nobody can scan.
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
// What this session is set to now, seeded from the row that opened it and
@@ -326,7 +346,20 @@ fun SessionScreen(
when (val event = entry.event) {
is SessionEvent.UsageDelta -> totalTokens += event.tokens
else -> {
if (event is SessionEvent.Status) status = event.state
if (event is SessionEvent.Status) {
// Started here, or nowhere. `ready` is what separates the live stream from
// the page of history the screen opens with, and a compaction found in that
// page began before anybody here was watching -- timing it from now would
// report the moment we arrived as the moment it started.
compactingSince =
when {
event.state != "compacting" -> null
status == "compacting" -> compactingSince
ready -> SystemClock.elapsedRealtime()
else -> null
}
status = event.state
}
// The message coming back is the session saying it has
// read it, so the bubble held below the indicator becomes
// the row `foldEvent` is about to add. Matched by text --
@@ -344,6 +377,22 @@ fun SessionScreen(
}
}
// A compaction reports nothing about its own progress -- measured against the CLI, which
// says it has started, and then says nothing at all until it is done. So what this counts is
// the one thing anybody here can measure: how long it has been going. A bar filling up would
// be this screen inventing the part the CLI does not send.
LaunchedEffect(compactingSince) {
val since = compactingSince
if (since == null) {
compactingFor = null
return@LaunchedEffect
}
while (true) {
compactingFor = (SystemClock.elapsedRealtime() - since) / 1000
delay(1000)
}
}
// The stream lifecycle: connect, follow, and on any drop reconnect
// from the cursor -- so a flaky link (or a backend restart) costs
// nothing but the gap's latency.
@@ -423,18 +472,29 @@ fun SessionScreen(
// stays started.
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
// Whether the view is pinned to the newest message. The list is laid
// out from the bottom (see the LazyColumn below), so "newest" is index
// 0 and being pinned is simply being at the start of it.
//
// Read from the scroll rather than remembered as a flag: with the list
// anchored this way there is no moment where new content pushes the
// anchor away, so there is nothing to protect a remembered value from.
val followTail by remember {
// Whether the newest message is on screen right now. The list is laid out from the bottom
// (see the LazyColumn below), so "newest" is index 0 and being there is being at the start of
// it. This is what the jump-to-newest button watches: it is about what the reader can see.
val atNewest by remember {
derivedStateOf {
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
}
}
// Whether they *chose* to be there, which is a different question and the one that decides
// whether an arriving message brings the view with it.
//
// Remembered, and only ever written when a scroll settles -- so it records where the reader
// last left the list, and an insertion cannot change the answer. Reading the live position
// instead looks right and is subtly wrong: a keyed list moves its anchor to keep the reader's
// content still, so by the time the new item can be observed the view is already one item
// away from the newest and reports itself as scrolled back. The message then never followed,
// which was visible as a compaction whose progress bar sat just off the bottom of the screen
// while the button that started it said it was running.
var followTail by remember { mutableStateOf(true) }
LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress }
.collect { scrolling -> if (!scrolling) followTail = atNewest }
}
// A new item at the newest end shifts every index by one, so the view
// has to step back to 0 to stay put. One item, instantly -- not a
// journey through the transcript.
@@ -443,8 +503,15 @@ fun SessionScreen(
// from one line to four and the keyboard opens under it, and neither
// is a new message, so watching the item count alone leaves the newest
// text drifting out of sight while somebody writes a reply to it.
//
// Counted in list items rather than in transcript rows, because the rows are not all of it:
// the working indicator and a queued message are items too, and they arrive at exactly the
// same end. Sibling to the paging trigger below, which is the same count read from the other
// end for the same reason.
LaunchedEffect(listState) {
snapshotFlow { Pair(items.size, listState.layoutInfo.viewportSize.height) }
snapshotFlow {
Pair(listState.layoutInfo.totalItemsCount, listState.layoutInfo.viewportSize.height)
}
.collect { (count, _) -> if (followTail && count > 0) listState.scrollToItem(0) }
}
// Reaching the far end of what is loaded -- the oldest item, which in
@@ -658,17 +725,24 @@ fun SessionScreen(
// where the newest message is.
if (running) {
item(key = "indicator") {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.width(14.dp).height(14.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
Text(
if (status == "compacting") "compacting" else "working",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Two shapes for two kinds of busy: an ordinary turn is a spinner beside
// a word, because the answer it is producing appears directly below it,
// and a compaction takes the whole row because nothing else will.
if (status == "compacting") {
CompactingRow(compactingFor)
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.width(14.dp).height(14.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
Text(
"working",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
} else if (status == "exited") {
@@ -768,6 +842,17 @@ fun SessionScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
is TranscriptItem.CompactedNote -> CompactedRow(item)
is TranscriptItem.PeerNote ->
PeerMessageRow(
item = item,
expanded = item.seq in expandedNotes,
onToggle = {
expandedNotes =
if (item.seq in expandedNotes)
expandedNotes - item.seq
else expandedNotes + item.seq
},
)
}
}
}
@@ -783,7 +868,7 @@ fun SessionScreen(
// which is where this goes. The name is carried in the
// description, since an arrow alone says nothing to a screen
// reader and nothing to whoever finds this in six months.
if (!followTail) {
if (!atNewest) {
Surface(
onClick = { scope.launch { listState.animateScrollToItem(0) } },
shape = CircleShape,
@@ -109,6 +109,18 @@ val runningColor: Color
val failedColor: Color
@Composable get() = MaterialTheme.colorScheme.error
/**
* Working on the conversation rather than in it: a compaction.
*
* Its own colour because it is its own kind of busy. Everything else a session does is progress
* through the task; this is the session rewriting what it remembers, it can take minutes, and
* nothing it produces appears in the transcript until it is over. A reader who has learned that
* blue means "not stuck, but not answering you either" has learned the only thing that
* distinguishes it from a session that has hung.
*/
val compactingColor: Color
@Composable get() = Mocha.Blue
/** Waiting on a person: a question, a permission, a turn that is theirs. */
val awaitingColor: Color
@Composable get() = Mocha.Peach