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
|
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)}"
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user