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,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,