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
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
@@ -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
}
}
@@ -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
@@ -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"
}
}
@@ -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)}"
}