Merge branch 'main' of git.arirex.me:iris/ai-app

This commit is contained in:
iris committed 2026-08-31 00:19:25 -04:00
commit 3095052df0
5 files changed
+101 -31

No files matched your search

+33 -3
View File
@@ -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 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 spliced onto rows that are no longer adjacent to it, which reads as
ordinary output. 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` - **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 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: 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 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 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 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 and stood there for a round trip. It is `HISTORY_SCREENS` viewports now,
slow frame. It is `HISTORY_SCREENS` viewports now, counted from what is counted from what is actually on screen. Measured at the server, which is
actually on screen. 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.** - **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 `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 a growing list -- fine at 80 events and about 300,000 element copies at
@@ -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 * Three answers rather than a nullable duration, because two of them shared `null` and they are not
* has just run out: a caller given zero for both would tell the reader to refresh on the strength * the same thing at all. A window the server sent no reset time for is one that is **not running**:
* of something nobody measured. * 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 * [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. * countdown recompute on its own schedule.
*/ */
fun remainingUntil(resetsAt: String, now: OffsetDateTime): Duration? = fun windowEnd(resetsAt: String?, now: OffsetDateTime): WindowEnd {
try { if (resetsAt == null) return WindowEnd.NotRunning
Duration.between(now, OffsetDateTime.parse(resetsAt)) return try {
WindowEnd.Ends(Duration.between(now, OffsetDateTime.parse(resetsAt)))
} catch (_: Exception) { } catch (_: Exception) {
null WindowEnd.Unreadable
} }
}
@@ -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 * 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 * 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 * 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 * standing there, which is a list running out of transcript rather than a slow frame.
* list running out of transcript, which the emulator showed as a swipe that moved nothing for 689ms
* and then jumped.
* *
* Three, so a fling lands on rows that are already there and the page after them is on its way. The * 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 * 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. * 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 private const val HISTORY_SCREENS = 3
@@ -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 * 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. * 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 * The window's end has two missing cases and they are worded differently on purpose; see
* no reset time, and saying "refresh soon" there would put a recommendation on the screen that * [WindowEnd]. A window that is not running gets the percentage and nothing else, because there is
* nothing measured. The percentage is still known, so it is still shown. * no countdown to report and inventing one would be the same fault as inventing the number.
*/ */
private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String { private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
val percent = "${window.percent.toInt()}%" val percent = "${window.percent.toInt()}%"
val until = window.resetsAt?.let { remainingUntil(it, now) } return when (val end = windowEnd(window.resetsAt, now)) {
return when { // Between blocks the five-hour window has no reset time, and saying so is a fact about
until == null -> "$percent · reset time unknown" // nothing: there is no window to run out. The percentage is the whole answer.
// Under a minute, including past the end: the number would round to "0m left", which reads WindowEnd.NotRunning -> percent
// as a measurement rather than as the window having run out. WindowEnd.Unreadable -> "$percent · reset time unreadable"
until < Duration.ofMinutes(1) -> "$percent · refresh soon" is WindowEnd.Ends ->
else -> "$percent · ${formatSpan(until)} left" // 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"
} }
} }
@@ -204,10 +204,10 @@ private fun WindowBar(window: UsageWindow) {
color = quotaColor(window.percent), color = quotaColor(window.percent),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
window.resetsAt?.let { resetLine(window)?.let {
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
Text( Text(
"resets ${formatReset(it)}", it,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 { * "resets in 3h 12m" -- close enough for deciding whether to start a big task -- or nothing.
val until = remainingUntil(resetsAt, OffsetDateTime.now()) ?: return "at $resetsAt" *
return if (until.isNegative) "soon" else "in ${formatSpan(until)}" * 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)}"
}