Merge branch 'main' of git.arirex.me:iris/ai-app
This commit is contained in:
commit
3095052df0
5 files changed
+100
-30
No files matched your search
@@ -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)}"
|
||||
}
|
||||
Reference in new issue
Block a user