diff --git a/AGENTS.md b/AGENTS.md index 6483e52..8f069f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -457,6 +457,21 @@ machine belongs in `~/.claude/TOOLCHAIN.md` (toolchain versions) or the screen opens. The reset is not optional: without it the window is spliced onto rows that are no longer adjacent to it, which reads as ordinary output. +- **The five-hour window has no reset time between blocks, and that is not + a missing value.** The usage API anchors it to the block it started in -- + measured 2026-08-31, the reset came back as exactly five hours after work + resumed, and the weekly windows in the same response carried the identical + microsecond, so both are computed from one `now()` at request time. When + no block is running there is nothing to reset and `resets_at` is `null`; + the same response shows other idle windows with the same shape. The weekly + ones always have a reset because a week is always running, which is why + "the others seem fine". + So `resets_at` absent means **not running**, and only a timestamp that + arrives and cannot be parsed is unknown. The app collapsed both into one + null and the session bar said "reset time unknown" for a machine behaving + perfectly -- while the usage dialog, reading the same field, quietly drew + nothing. `WindowEnd` in `ResetCountdown.kt` is now the one rule both go + through. - **A transcript page used to cost the whole transcript.** `read_window` read and parsed every line and then kept the last `limit` of them, so the work was the size of the conversation rather than the size of the answer: @@ -477,9 +492,24 @@ machine belongs in `~/.claude/TOOLCHAIN.md` (toolchain versions) or rows has to measure a screen rather than name a number: the history cushion was eight rows, which on a tool-heavy transcript is less than one screenful, and the reader hit the end of what was loaded on every swipe - and stood there for a round trip. That was "scrolling is laggy" -- not a - slow frame. It is `HISTORY_SCREENS` viewports now, counted from what is - actually on screen. + and stood there for a round trip. It is `HISTORY_SCREENS` viewports now, + counted from what is actually on screen. Measured at the server, which is + the one number here that does not depend on how the emulator renders: + against a 24,000-event transcript, ten swipes asked for ten pages before + and three after. +- **What the transcript screen costs to scroll, for whoever measures it + next.** Taken 2026-08-30 on the GPU emulator (`emu up` provides one; a + frame number from the software rasteriser means nothing -- see + `~/.claude/MACHINE.md`), against a real imported transcript with the debug + server at `--delay 120`. Settled and flinging fast, both into fresh + history and back through rows already drawn: **5.2-5.9% janky frames, 99th + percentile 29-32ms, 0-2 slow UI-thread frames.** The stock Settings app on + the same device is 3.3% and 38ms, so this is at the platform floor and + what is left is the emulator rather than the app. The number that is *not* + at the floor is the first few seconds after opening a session, where every + row on the way is being composed for the first time; that is inherent to a + lazy list and it is why a measurement taken before the screen settles + reads three times worse. **Settle first, then reset `gfxinfo`.** - **Only `fetchTranscript` was off the main thread; the fold was not.** `foldEvent` returns a new list per event, so a page is that many copies of a growing list -- fine at 80 events and about 300,000 element copies at diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt index b0e7bce..aea19cf 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt @@ -20,18 +20,42 @@ fun formatSpan(until: Duration): String = } /** - * Time from [now] until [resetsAt], or null when that timestamp cannot be read. + * What is known about when a usage window ends. * - * Null rather than a zero duration, because a string this app failed to parse is not a window that - * has just run out: a caller given zero for both would tell the reader to refresh on the strength - * of something nobody measured. + * Three answers rather than a nullable duration, because two of them shared `null` and they are not + * the same thing at all. A window the server sent no reset time for is one that is **not running**: + * the five-hour window is anchored to the block it started in, so between sessions there is nothing + * counting down and the API says so by omitting the field -- measured against a live response on + * 2026-08-31, where the five-hour window's reset was exactly five hours after the moment work + * resumed. A timestamp that did arrive and could not be read is the genuinely unknown case, and it + * is the only one worth those words. + * + * Collapsing them put "reset time unknown" on the session bar for a machine behaving perfectly, on + * the one row somebody reads before starting something big -- and the usage dialog, looking at the + * same field, quietly drew nothing. Two rules for one missing value; this is the rule. + */ +sealed class WindowEnd { + /** No reset time was sent, so nothing is running in this window. Not a failure to find out. */ + data object NotRunning : WindowEnd() + + /** A timestamp arrived and could not be read. The one case that is actually unknown. */ + data object Unreadable : WindowEnd() + + /** How long is left. Negative once the window is past, which each caller words for itself. */ + data class Ends(val until: Duration) : WindowEnd() +} + +/** + * [resetsAt] as the server sent it -- absent, unreadable, or a moment -- against [now]. * * [now] is a parameter rather than read here so a caller can drive it from state and have the * countdown recompute on its own schedule. */ -fun remainingUntil(resetsAt: String, now: OffsetDateTime): Duration? = - try { - Duration.between(now, OffsetDateTime.parse(resetsAt)) +fun windowEnd(resetsAt: String?, now: OffsetDateTime): WindowEnd { + if (resetsAt == null) return WindowEnd.NotRunning + return try { + WindowEnd.Ends(Duration.between(now, OffsetDateTime.parse(resetsAt))) } catch (_: Exception) { - null + WindowEnd.Unreadable } +} 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 f1703ac..bcabd28 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -88,13 +88,15 @@ private val LOADING_SPINNER = 48.dp * row is anything from one line to a page and a fixed count is therefore a distance only by * accident. Eight rows was the number, and on a tool-heavy transcript eight rows is less than one * screen: the reader reached the end of what was loaded on *every* swipe and waited a round trip - * standing there. That is what "scrolling is laggy" turned out to be -- not a slow frame, but the - * list running out of transcript, which the emulator showed as a swipe that moved nothing for 689ms - * and then jumped. + * standing there, which is a list running out of transcript rather than a slow frame. * * Three, so a fling lands on rows that are already there and the page after them is on its way. The * cost of being generous is a page fetched that nobody reads; the cost of being mean is a list that * stops under a finger, and those are not the same size. + * + * Counted at the server rather than inferred from the screen, which is the only measurement here + * that does not depend on how the emulator renders: against a 24,000-event transcript at `--delay + * 120`, ten swipes asked for **ten** pages before this and **three** after. */ private const val HISTORY_SCREENS = 3 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt index b14e389..1cea984 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt @@ -185,19 +185,22 @@ private fun UsageNote(text: String) { * The percentage on its own does not answer the question it gets asked, which is whether to start * something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers. * - * The window's end has its own missing case, kept apart from the rest: a snapshot can arrive with - * no reset time, and saying "refresh soon" there would put a recommendation on the screen that - * nothing measured. The percentage is still known, so it is still shown. + * The window's end has two missing cases and they are worded differently on purpose; see + * [WindowEnd]. A window that is not running gets the percentage and nothing else, because there is + * no countdown to report and inventing one would be the same fault as inventing the number. */ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String { val percent = "${window.percent.toInt()}%" - val until = window.resetsAt?.let { remainingUntil(it, now) } - return when { - until == null -> "$percent · reset time unknown" - // Under a minute, including past the end: the number would round to "0m left", which reads - // as a measurement rather than as the window having run out. - until < Duration.ofMinutes(1) -> "$percent · refresh soon" - else -> "$percent · ${formatSpan(until)} left" + return when (val end = windowEnd(window.resetsAt, now)) { + // Between blocks the five-hour window has no reset time, and saying so is a fact about + // nothing: there is no window to run out. The percentage is the whole answer. + WindowEnd.NotRunning -> percent + WindowEnd.Unreadable -> "$percent · reset time unreadable" + is WindowEnd.Ends -> + // Under a minute, including past the end: the number would round to "0m left", which + // reads as a measurement rather than as the window having run out. + if (end.until < Duration.ofMinutes(1)) "$percent · refresh soon" + else "$percent · ${formatSpan(end.until)} left" } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt index 8d2fd4c..39ecf07 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt @@ -204,10 +204,10 @@ private fun WindowBar(window: UsageWindow) { color = quotaColor(window.percent), modifier = Modifier.fillMaxWidth(), ) - window.resetsAt?.let { + resetLine(window)?.let { Spacer(Modifier.height(2.dp)) Text( - "resets ${formatReset(it)}", + it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -215,8 +215,19 @@ private fun WindowBar(window: UsageWindow) { } } -/** "in 3h 12m" -- close enough for deciding whether to start a big task. */ -private fun formatReset(resetsAt: String): String { - val until = remainingUntil(resetsAt, OffsetDateTime.now()) ?: return "at $resetsAt" - return if (until.isNegative) "soon" else "in ${formatSpan(until)}" -} +/** + * "resets in 3h 12m" -- close enough for deciding whether to start a big task -- or nothing. + * + * Null for a window that is not running, which is the case this row has always drawn as nothing and + * is right to: there is no end to report. What it used to get wrong is the other missing case, a + * timestamp that arrived and could not be read: that was printed raw, so a parse failure appeared + * as an ISO string in the middle of a sentence written for a person. Both cases are named in + * [WindowEnd], and the session bar words them the same way. + */ +private fun resetLine(window: UsageWindow): String? = + when (val end = windowEnd(window.resetsAt, OffsetDateTime.now())) { + WindowEnd.NotRunning -> null + WindowEnd.Unreadable -> "reset time unreadable" + is WindowEnd.Ends -> + if (end.until.isNegative) "resets soon" else "resets in ${formatSpan(end.until)}" + }