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:
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
|
||||
|
||||
Reference in new issue
Block a user