Give the session's own state a line, instead of the transcript's corner

The token total floated over the bottom-right of the transcript, where a
long message ran underneath it, and the working indicator was an item
inside the list -- so it scrolled away exactly when somebody reading back
wanted to know whether anything was still happening.

Both are facts about the session rather than turns in it, so they get one
row directly above the box you type into: the thing they report on is the
next thing you touch. `exited` moves with them, since it is the same kind
of fact and nothing else on the screen would have said it once the
indicator left the list.

The row is drawn whether or not it has anything to say. An empty one
costs a line; a row that came and went would move the text box under a
reader's thumb every time a turn started, and would make its own presence
the signal for a state it never names. For the same reason the compaction
case had to fit the same single line: its bar now takes the row's free
width between the label and the total rather than a row of its own, which
keeps it far wider than a spinner -- the reason it is a bar at all, since
nothing arrives in the transcript while a compaction runs and a small
moving thing there reads as a session that has hung.

Still no fraction to fill, re-measured today rather than assumed: a real
80,346-to-2,088-token compaction took 23 seconds and the CLI emitted not
one line between saying it had started and saying it had finished.
Elapsed seconds remain the only honest number.

Looked at on the emulator in all three states -- idle, working, and six
seconds into a real compaction -- and at 320dp, the narrowest width a
phone actually has, where the row still holds one line.
This commit is contained in:
iris committed 2026-08-29 21:35:30 -04:00
1 parent 549e49bc10
commit 6b4911c67b
2 files changed
+104 -98

No files matched your search

@@ -1,14 +1,7 @@
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* The mark a compaction leaves in the transcript.
@@ -48,38 +41,6 @@ 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 = commandColor,
)
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
color = commandColor,
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
)
}
}
/**
* What the working indicator says while a compaction is running.
*
@@ -27,6 +27,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -794,9 +795,11 @@ fun SessionScreen(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Below the working indicator, because that is where they
// are in the session's reading of events: after everything
// it has taken in, and not yet taken in themselves.
// The last thing in the transcript, because that is where
// they are in the session's reading of events: after
// everything it has taken in, and not yet taken in
// themselves. What the session is *doing* about them is a
// line below, in [SessionStatusRow].
if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) {
item(key = "queued") {
Column(horizontalAlignment = Alignment.End) {
@@ -807,45 +810,6 @@ fun SessionScreen(
}
}
}
// Where the next thing will appear: at the end of what has
// happened, which in this layout is the top of the list.
// In the corner it was a label about the session; here it
// is a place, and the eye is already there because that is
// where the newest message is.
if (running) {
item(key = "indicator") {
// 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") {
// Still said somewhere: a session whose process is gone
// cannot be typed at, and with the corner label removed
// nothing else on this screen would mention it.
item(key = "exited") {
Text(
"exited",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Reversed to match the layout, so index 0 is the newest and
// the reader still sees them in the order they happened.
// Grouped first: adjacent tool calls collapse into one row,
@@ -947,23 +911,6 @@ fun SessionScreen(
}
}
// Bottom right of the transcript, pinned rather than scrolled: it reports on the
// conversation as a whole, so it should not be a thing you scroll away from and then
// wonder about. Sat in the header until 2026-08-29, where it was one item in a run of
// dot-separated facts about the session and read as another of them, rather than as
// the running total it is.
//
// BottomEnd, clear of the jump-to-latest chevron at BottomCentre.
if (totalTokens > 0) {
Text(
"$totalTokens tok",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier.align(Alignment.BottomEnd).padding(end = 12.dp, bottom = 4.dp),
)
}
// Only while the newest message is off-screen. Reading back
// through a conversation is a place to be, not a state to be
// rescued from, so this waits to be wanted.
@@ -993,6 +940,8 @@ fun SessionScreen(
}
}
SessionStatusRow(status = status, compactingFor = compactingFor, totalTokens = totalTokens)
// Between the transcript and the box: above what is being typed, so the list does not
// cover the thing the command is about, and below everything that explains it.
CommandSuggestions(
@@ -1116,6 +1065,102 @@ private fun UserBubble(text: String, pending: Boolean = false) {
* spinner-while-unfinished is exactly "ToolStart with no matching ToolEnd yet".
*/
/**
* What the session is doing, and what the conversation has cost, on one line above the box.
*
* A row of its own because both of these are facts about the session rather than turns in it, and
* both were previously drawn over the transcript: the token total floated in its bottom corner,
* where a long message ran underneath it, and the working indicator was an item inside the list, so
* it scrolled away exactly when somebody reading back wanted to know whether anything was still
* happening. Here they are always in the same place, and the thing they report on -- the session
* you are about to type at -- is directly below.
*
* The row is drawn whether or not it has anything to say. An empty one costs a line; a row that
* came and went would move the text box under the reader's thumb every time a turn started, and
* would make its own presence the signal for a state it never names.
*
* The states are the session's own status words plus the total, and each looks different from the
* others: `exited` is here because a session whose process is gone cannot be typed at, and with the
* indicator gone from the list nothing else on this screen would say so.
*/
@Composable
private fun SessionStatusRow(
status: String,
/** Seconds since this device saw the compaction start; null if it did not see it. */
compactingFor: Long?,
totalTokens: Long,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
) {
when (status) {
// A bar rather than the spinner an ordinary turn gets, and it takes the row's whole
// free width: nothing arrives in the transcript during a compaction, so this is the
// only thing on screen that is moving, and at a spinner's width that reads as a
// session that has hung.
//
// Indeterminate, which is a statement rather than an omission. The CLI says a
// compaction has begun and then says nothing at all until it has finished -- measured
// against 2.1.237 again on 2026-08-29, on a real 80,346-to-2,088-token compaction that
// took 23 seconds and produced not one line in between. So there is no fraction to
// fill, and a bar creeping along at the pace of the last one would be this screen
// inventing the part nobody sent it. Elapsed time is the only honest number here, and
// [compactingLabel] is where it is worded.
"compacting" -> {
Text(
compactingLabel(compactingFor),
style = MaterialTheme.typography.labelSmall,
// 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 = commandColor,
)
LinearProgressIndicator(
color = commandColor,
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp),
)
}
"running" -> {
CircularProgressIndicator(
// Smaller than the line beside it, so the row keeps the text's own height:
// a control taller than a line re-centres it and knocks it out of line with
// the total on the other end.
modifier = Modifier.width(12.dp).height(12.dp),
strokeWidth = 2.dp,
)
Text(
"working",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
Spacer(Modifier.weight(1f))
}
else -> {
if (status == "exited") {
Text(
"exited",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.weight(1f))
}
}
// Nothing rather than "0 tok" before anything has been spent: a total of zero is a fact
// about a conversation that has not started, and it is the one reading nobody needs.
if (totalTokens > 0) {
Text(
"$totalTokens tok",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* A question (or permission request -- same shape) inline in the transcript. Option buttons until
* answered; then the chosen answer, which the `answered` event also resolves on every other