diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt index f18ef79..37eb4c2 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt @@ -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. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 48be157..50b8ef2 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -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