From 6680fbb987bcf43a2f43191a8e5e812875f5c9b5 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 29 Aug 2026 20:11:26 -0400 Subject: [PATCH] Clear from the phone, and put the numbers where they are read Four changes to the session screen, three of them Bryan's and one that fell out of them. **/clear is offered like any other session command.** It joins SESSION_COMMANDS, so it suggests itself while being typed and goes out through the command endpoint that /compact already uses -- no new path, and the boundary pump holds it mid-turn exactly as it holds a compaction. **A clear draws a divider, not a deletion.** `Event::Cleared` becomes a ClearedNote row saying that everything above stays here and is no longer sent. That sentence is the row's whole job: the reader can see the conversation is still on screen, so without it the divider reads as something having been thrown away, which is the one thing it is not. It carries no counts, because nothing was measured -- a compaction's numbers are real and there is no equivalent here to report. Compaction and clear now share `TranscriptDivider`. They are the same kind of mark to somebody scrolling back -- "the session no longer has what is above this" -- and the difference belongs in the words rather than in how they are drawn, so the styling is written once and cannot drift. **The five-hour usage bar sits under the session header.** It reports the paid service's own metering for the machine this session runs on, fetched from that machine, refreshed every minute off the backend's cache. It is never derived from the transcript's token counts: those are a different quantity measured differently, and a quota-shaped bar built out of them would be a guess wearing a measurement's clothes. Not knowing has its own appearance and its own words -- "unknown" and why -- because a bar resting at zero because a machine is unreachable reads as plenty of headroom, which is the opposite of the truth. The window is selected by the API's own `kind` ("session"), added to UsageWindow in this change, rather than by matching the label a person reads. **The token total moved from the header to the bottom right of the transcript**, pinned above the input rather than scrolling with it. In the header 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. SessionSummary now carries the setup id, which it deliberately did not. The stated reason was that nothing here addressed a setup and holding both id and name invited showing the wrong one; the usage bar addresses one, so the reason lapsed rather than being overruled, and the comment now carries the rule that replaces it: never display it. The server has always sent the field, so nothing changed on the wire. ktfmt, compileDebugKotlin and lintDebug all clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8 --- .../src/main/kotlin/com/example/aiapp/Api.kt | 20 ++- .../main/kotlin/com/example/aiapp/Commands.kt | 5 + .../kotlin/com/example/aiapp/Compaction.kt | 16 +-- .../main/kotlin/com/example/aiapp/Dividers.kt | 47 +++++++ .../main/kotlin/com/example/aiapp/Events.kt | 10 ++ .../kotlin/com/example/aiapp/SessionScreen.kt | 32 ++++- .../com/example/aiapp/SessionUsageBar.kt | 130 ++++++++++++++++++ server/src/usage.rs | 12 ++ 8 files changed, 258 insertions(+), 14 deletions(-) create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index e3c48d1..49ad60a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -111,9 +111,16 @@ private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Chars data class SessionSummary( val id: String, /** - * The machine's current label. The id is deliberately not carried: nothing here addresses a - * setup, and holding both invites showing the wrong one, which is what happened. + * Id of the machine this session runs on. Only ever used to *address* that machine -- to pick + * this session's row out of the per-machine usage snapshots for the header's five-hour bar. + * + * It was deliberately left out until 2026-08-29, on the grounds that nothing here addressed a + * setup and holding both the id and the name invited showing the wrong one, which had already + * happened once. Something addresses one now, so the reason lapsed rather than being overruled. + * The guard that replaces it is the rule below: never show this. */ + val setup: String, + /** The machine's current label. This is the one to display; [setup] is never shown. */ val setupName: String, val provider: String, val title: String, @@ -131,6 +138,7 @@ data class SessionSummary( private fun parseSession(session: JSONObject) = SessionSummary( id = session.getString("id"), + setup = session.getString("setup"), setupName = session.getString("setupName"), provider = session.getString("provider"), title = session.getString("title"), @@ -410,6 +418,13 @@ fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): // One rate-limit window, rendered as a labeled bar on the usage screen. data class UsageWindow( + /** + * The API's own word for which window this is -- "session" for the five-hour one. + * + * How to find a particular window. The label beside it is written for a person to read, so + * matching on it would select nothing the day its wording changes. + */ + val kind: String, val label: String, val percent: Double, val resetsAt: String?, @@ -450,6 +465,7 @@ fun fetchUsage(settings: ServerSettings): List = windows = snapshot.getJSONArray("windows").mapObjects { window -> UsageWindow( + kind = window.optString("kind").ifEmpty { "unknown" }, label = window.getString("label"), percent = window.getDouble("percent"), resetsAt = window.optString("resetsAt").ifEmpty { null }, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt index 82ac0de..fe2a7a6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt @@ -47,6 +47,11 @@ val SESSION_COMMANDS = "Summarise the conversation so far and carry on from the summary", null, ), + SessionCommand( + "/clear", + "Start fresh: drop the conversation from the session's context, keeping it on screen", + null, + ), SessionCommand("/rename", "Change what this session is called", "name"), ) 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 6f445cc..f18ef79 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt @@ -8,25 +8,19 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp /** * The mark a compaction leaves in the transcript. * - * Centred and dim, because it is a divider rather than something anybody said: everything above it - * is out of the session's context now, and that is a fact about the conversation, not a turn in it. - * It has no collapsed form -- it is already one line, and there is nothing behind it to open. + * A divider rather than something anybody said: everything above it is out of the session's context + * now, and that is a fact about the conversation, not a turn in it. It has no collapsed form -- it + * is already one line, and there is nothing behind it to open. Drawn by [TranscriptDivider], which + * a clear also uses, so the two marks cannot drift apart. */ @Composable fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifier) { - Text( - compactionSummary(item), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = modifier.fillMaxWidth().padding(vertical = 8.dp), - ) + TranscriptDivider(compactionSummary(item), modifier) } /** diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt new file mode 100644 index 0000000..8f765f4 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt @@ -0,0 +1,47 @@ +package com.example.aiapp + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** + * A line across the transcript saying what left the session's context. + * + * Centred and dim, because it is a divider rather than something anybody said. Two things produce + * one -- a compaction and a clear -- and they look the same on purpose: to a reader scrolling back, + * both mean "the session no longer has what is above this", and the difference between summarised + * and dropped is what the words say, not how they are drawn. + * + * Written once here rather than styled at each of them, so the two cannot drift into looking like + * different kinds of thing. + */ +@Composable +fun TranscriptDivider(text: String, modifier: Modifier = Modifier) { + Text( + text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = modifier.fillMaxWidth().padding(vertical = 8.dp), + ) +} + +/** + * The mark a clear leaves. + * + * It says the conversation is still here, because the reader can see that it is -- everything above + * stays on screen and stays scrollable, and the only thing that changed is what the session will + * send. Without that sentence the divider reads as a deletion, which is the one thing it is not. + * + * No counts, unlike a compaction: nothing was measured and nothing was summarised, so there is + * nothing to report but the fact. A plausible-looking number here would be invented. + */ +@Composable +fun ClearedRow(modifier: Modifier = Modifier) { + TranscriptDivider("Cleared -- everything above stays here, and is no longer sent", modifier) +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt index 92b6d9e..158dcce 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt @@ -97,6 +97,15 @@ sealed class SessionEvent { val trigger: String?, ) : SessionEvent() + /** + * The conversation was cleared. Everything above this is still here to read and is no longer in + * the session's context. + * + * An object rather than a class because it carries nothing: what it means is entirely its + * position in the transcript. + */ + data object Cleared : SessionEvent() + data class Error(val message: String) : SessionEvent() /** @@ -171,6 +180,7 @@ fun parseSeqEvent(json: String): SeqEvent { postTokens = if (body.has("postTokens")) body.getLong("postTokens") else null, trigger = body.optString("trigger").ifEmpty { null }, ) + "cleared" -> SessionEvent.Cleared "error" -> SessionEvent.Error(body.getString("message")) else -> SessionEvent.Unknown(type) } 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 f207dc5..48be157 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -161,6 +161,13 @@ sealed class TranscriptItem { * it finishes and this is the part worth keeping: it is the explanation for a gap in the * conversation, and for a minute or two in which the session was busy with nothing to show. */ + /** + * A clear that happened: everything above it left the session's context and stayed on screen. + * + * Carries only its position, because that is all it means. + */ + data class ClearedNote(override val seq: Long) : TranscriptItem() + data class CompactedNote( override val seq: Long, val preTokens: Long?, @@ -277,6 +284,7 @@ fun foldEvent(items: List, entry: SeqEvent): List items + TranscriptItem.ClearedNote(entry.seq) is SessionEvent.Compacted -> items + TranscriptItem.CompactedNote( @@ -726,7 +734,6 @@ fun SessionScreen( // What the session says it is set to now, which is the same fact // the picker below shows and has to be the same answer. model?.let { modelLabel(it) }, - if (totalTokens > 0) "$totalTokens tok" else null, ) .joinToString(" ยท "), style = MaterialTheme.typography.bodySmall, @@ -753,6 +760,11 @@ fun SessionScreen( } } + // Under the header, above everything the session itself says: it is a fact about the + // machine rather than a turn in the conversation, and it is the number that decides + // whether to keep going -- which was a screen away from where that gets decided. + SessionUsageBar(settings = settings, setup = summary.setup) + (streamError ?: actionError)?.let { message -> Text( message, @@ -917,6 +929,7 @@ fun SessionScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) is TranscriptItem.CommandRow -> CommandBubble(item.text) + is TranscriptItem.ClearedNote -> ClearedRow() is TranscriptItem.CompactedNote -> CompactedRow(item) is TranscriptItem.PeerNote -> PeerMessageRow( @@ -934,6 +947,23 @@ 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. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt new file mode 100644 index 0000000..9360669 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt @@ -0,0 +1,130 @@ +package com.example.aiapp + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +/** How much of the five-hour window is gone, or why that isn't known. */ +sealed class FiveHourUsage { + /** Nothing has come back yet. Distinct from every answer, including an empty one. */ + data object Waiting : FiveHourUsage() + + data class Known(val percent: Double, val resetsAt: String?) : FiveHourUsage() + + /** + * The question could not be answered, and why. + * + * Its own state because "we couldn't find out" and "none of it is used" are the pair that must + * never share an appearance: a bar sitting at zero because a machine is unreachable reads as + * plenty of headroom, which is the opposite of the truth. + */ + data class Unavailable(val why: String) : FiveHourUsage() +} + +/** How often to ask again. The backend caches, so this re-reads its cache rather than the API. */ +private const val REFRESH_MS = 60_000L + +/** + * The five-hour window for the machine this session runs on, under the session's own header. + * + * Here rather than only on the usage screen because it is the number that decides whether to keep + * going, and it was a screen away from the place that decision gets made. It reports on this + * session's machine alone -- the usage screen is still where every machine is compared. + * + * What it shows is the paid service's own metering, fetched from the machine that holds the + * account. It is never derived from what this app has watched go past: the transcript's token + * counts are a different quantity, measured differently, and a bar shaped like a quota gauge built + * out of them would be a guess wearing a measurement's clothes. + */ +@Composable +fun SessionUsageBar(settings: ServerSettings, setup: String, modifier: Modifier = Modifier) { + var usage by remember(setup) { mutableStateOf(FiveHourUsage.Waiting) } + + LaunchedEffect(setup) { + while (true) { + usage = + try { + val snapshots = withContext(Dispatchers.IO) { fetchUsage(settings) } + fiveHourFor(snapshots, setup) + } catch (e: ApiException) { + FiveHourUsage.Unavailable(e.message ?: "couldn't reach the backend") + } + delay(REFRESH_MS) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 2.dp), + ) { + when (val state = usage) { + // Words, not a colour and not an empty bar: "couldn't check" is a different kind of + // answer from "this much is used", and only words carry a difference in kind. + is FiveHourUsage.Unavailable -> + Text( + "5-hour usage unknown -- ${state.why}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FiveHourUsage.Waiting -> + Text( + "5-hour usage: checking", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + is FiveHourUsage.Known -> { + LinearProgressIndicator( + progress = { (state.percent / 100.0).toFloat().coerceIn(0f, 1f) }, + modifier = Modifier.weight(1f), + ) + Text( + " ${state.percent.toInt()}% of 5h", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(84.dp), + ) + } + } + } +} + +/** + * The five-hour window for one machine, out of every machine's snapshot. + * + * Selects the window by `kind`, which is the API's own word, rather than by the label beside it -- + * the label is written to be read and would stop matching the day its wording changes, silently + * leaving the bar with nothing to show. + * + * Every way of not having a number is [FiveHourUsage.Unavailable] with the reason in it: a machine + * nobody logged into, one that could not be reached, a snapshot that came back without the window. + * None of them may look like zero. + */ +fun fiveHourFor(snapshots: List, setup: String): FiveHourUsage { + val mine = snapshots.firstOrNull { it.setup == setup } + if (mine == null) { + return FiveHourUsage.Unavailable("this machine reports no usage") + } + if (mine.state != "ok") { + return FiveHourUsage.Unavailable(mine.detail ?: mine.state) + } + val window = + mine.windows.firstOrNull { it.kind == "session" } + ?: return FiveHourUsage.Unavailable("no five-hour window reported") + return FiveHourUsage.Known(window.percent, window.resetsAt) +} diff --git a/server/src/usage.rs b/server/src/usage.rs index a731eb6..d43771c 100644 --- a/server/src/usage.rs +++ b/server/src/usage.rs @@ -53,6 +53,17 @@ const USER_AGENT: &str = "claude-code/2.1.237"; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct UsageWindow { + /// The API's own word for which window this is -- `session` for the + /// five-hour one, `weekly_all`, `weekly_scoped`, or whatever new kind + /// it starts sending. + /// + /// Carried beside the label because a caller that wants one + /// particular window has to be able to ask for it without matching on + /// display text: the label is written for a person, is translated the + /// moment anybody translates this app, and would silently select + /// nothing the day it changes. The session screen's bar picks + /// `session` by this field. + pub kind: String, pub label: String, /// 0-100. pub percent: f64, @@ -266,6 +277,7 @@ fn parse_windows(body: &Value) -> Vec { (other, None) => other.to_string(), }; Some(UsageWindow { + kind: kind.to_string(), label, percent, resets_at: limit