Tell "this window is not running" from "we could not read the time"
The five-hour window's reset time is absent between blocks, because the window is anchored to the block it started in and there is nothing to reset until one is running. Measured against a live response: 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. The weeklies always have a reset because a week is always running -- which is why only the five-hour row looked wrong. `remainingUntil` returned null for that and for a timestamp it could not parse, so the session bar announced "reset time unknown" about a machine behaving perfectly, on the one row somebody reads before starting something big. The usage dialog, looking at the same field, drew nothing at all and printed a raw ISO string when a parse did fail. One missing value, two rules, and neither of them right. `WindowEnd` names the three answers and both callers go through it. A window that is not running shows its percentage and no countdown, in the bar as well as the dialog; an unreadable timestamp says so in words rather than showing itself. Looked at both on the emulator, the second by making the server drop the field: 15% with "4h 43m left" when a block is running, "13%" alone when none is, and the dialog's five-hour row with no reset line beside weeklies that have one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3da0f2e2f6
commit
4edc96de09
4 files changed
+78
-25
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:
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
}
|
||||||
@@ -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