From c965e2cbc66753e36e2aff40abefd8d814c6cac4 Mon Sep 17 00:00:00 2001 From: Lain Date: Mon, 10 Aug 2026 20:27:25 +0200 Subject: [PATCH] fix(usage): poll the plan limits even when the chat is not on screen The poll was gated on isShowing, so a collapsed tool window or a chat tab that was not the selected one asked for nothing at all. A quota window is not this plugin's state: other sessions, other devices and claude.ai spend the same windows, and a reset is a wall-clock event that owes nothing to the IDE. So the figure on screen was whatever the last probe happened to catch, and it only moved again when something else triggered one -- a turn, or opening the dashboard. "It only updates when I talk to the agent" is what a visibility-gated poll looks like from outside. The gate is gone and the period is 30s; the event-driven refreshes are unchanged. The bar row now carries the countdown: "Reset time: 4h 18m" on its own line under each window's bar, with the sentence in the tooltip. A percentage alone does not say whether it is urgent -- 90% with eight minutes left and 90% with six hours left are different situations, and only the dashboard was answering that. Under the bar rather than beside it, because the row is already three items wide per window and a fourth made the countdown the first thing to be squeezed out. One implementation in app-core (CC.resetIn/resetInShort), shared with the dashboard card, and RateLimitInfo.resetsAtIso() puts the epoch-to-ISO conversion on the model so an event-sourced window and a report-sourced one are interchangeable to both surfaces. Every get_usage poll now logs the reply verbatim at INFO. The derived per-window lines cannot say whether a number is stale, because a window the reply omits leaves no line and a carried-forward one looks fresh. It earned its place immediately: a live capture showed two of three consecutive polls returning the header-seeded shape (five_hour/seven_day only, no limits[]), which is the degraded reply 5.1.0's merge exists for, and confirmed the binary does not cache the endpoint. The release procedure now states that the release PR is closed to late commits, cosmetic ones above all: a commit pushed onto an open release PR invalidates the gates that passed on it, and main publishes. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 36 +++++++++++++++++++ RELEASE_NOTES.md | 11 ++++++ build.gradle.kts | 2 +- docs/RELEASE_PROCEDURE.md | 13 +++++++ .../dev/lain/claudejb/protocol/Protocol.kt | 7 ++++ .../lain/claudejb/session/ClaudeSession.kt | 32 ++++++++++++++++- .../dev/lain/claudejb/ui/JcefChatPanel.kt | 25 ++++++++----- .../lain/claudejb/ui/jcef/JcefSessionData.kt | 6 +--- .../dev/lain/claudejb/ui/jcef/JcefState.kt | 22 ++++++++---- src/main/resources/jcef/app-composer.js | 17 +++++++-- src/main/resources/jcef/app-core.js | 28 +++++++++++++++ src/main/resources/jcef/app-session.js | 10 ++---- src/main/resources/jcef/app.css | 22 ++++++++++-- src/test/frontend/readout.test.js | 28 +++++++++++++++ .../lain/claudejb/protocol/UsageReportTest.kt | 9 +++++ 15 files changed, 233 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c64b57a4..5309f760 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,42 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [5.1.1] — 2026-08-10 + +### Fixed +- **The plan limits stopped refreshing whenever the panel was not on screen.** The poll was gated on + `isShowing`, so a collapsed tool window or a chat tab that was not the selected one asked for nothing at + all — and a quota window is not the plugin's state to begin with: other sessions, other devices and + claude.ai spend the same windows, and a **reset is a wall-clock event that owes nothing to this IDE**. The + figure on screen was therefore whatever the last probe happened to catch, and it only moved again when + something else triggered one — a turn, or opening the dashboard. "It only updates when I talk to the agent" + is precisely what a visibility-gated poll looks like from outside. The gate is gone and the period is 30 s; + what it was saving is one control request per half minute against a process that is already running, and + the event-driven refreshes (turn edges, `rate_limit_event`, dashboard open, session ready) are unchanged. + +### Added +- **The chat's plan-limit row now says how long each window has left** — `Reset time: 4h 18m` on its own line + directly under that window's bar, with the full sentence in the tooltip. A percentage alone does not say + whether it is urgent: 90% with eight minutes to go and 90% with six hours to go are different situations, + and only the dashboard was answering that. Under the bar rather than beside it because the row is already + three items wide per window, and a fourth made the countdown the first thing to be squeezed out — the one + case where it matters most. The countdown is computed by one function in `app-core` + (`CC.resetIn`/`resetInShort`) that the dashboard card now shares, and a window with no reset time renders + no element rather than an empty slot that would read as "resets now". + +### Changed +- **Every `get_usage` poll now logs the reply it got**, `rate_limits` verbatim (truncated), at INFO. The + derived per-window lines cannot answer the question that keeps coming up — *is the number on screen stale, + or is the server still saying that?* — because a window the reply omits leaves no line at all, and one + carried forward from the previous poll is indistinguishable from a fresh one. It immediately earned its + place: a live capture showed **two of three consecutive polls** coming back in the header-seeded shape + (`five_hour`/`seven_day` only, no `limits[]`, `resets_at` rounded to `.000Z`), which is the degraded reply + 5.1.0's merge exists for, and confirmed the binary does not cache the endpoint. +- `RateLimitInfo.resetsAtIso()` puts the epoch-seconds → ISO-8601 conversion on the model, so a window that + reaches a surface from the *event* stream and one that arrives in the `get_usage` *report* are + interchangeable to everything that renders them. It was a private copy in the dashboard's builder, and the + composer needed the same thing. + ## [5.1.0] — 2026-08-10 ### Added diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index a6995677..b820968c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,14 @@ +## v5.1.1 — 2026-08-10 + +**The plan limits kept updating only when you talked to the agent.** The poll stopped whenever the chat was +not on screen — a collapsed tool window, or another tab selected — so a limit could reset, or fill up from +another device, and the bars went on showing the last figure they happened to catch until something made you +send a message. They now refresh every 30 seconds regardless of what you are looking at. + +**And the bars say how long each window has left** — `Reset time: 4h 18m`, right under each one. 90% with +eight minutes to go and 90% with six hours to go are not the same situation, and until now only the +dashboard told you which one you were in. + ## v5.1.0 — 2026-08-10 **Older models are selectable again.** The model picker has an **Other models** group with the previous diff --git a/build.gradle.kts b/build.gradle.kts index 883128c3..0401e086 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,7 +28,7 @@ plugins { } group = "dev.lain" -version = "5.1.0" +version = "5.1.1" repositories { mavenCentral() diff --git a/docs/RELEASE_PROCEDURE.md b/docs/RELEASE_PROCEDURE.md index 39760250..61b8159f 100644 --- a/docs/RELEASE_PROCEDURE.md +++ b/docs/RELEASE_PROCEDURE.md @@ -145,6 +145,19 @@ Merge once the GitHub Actions CI workflow is green. **Do not** rebase onto `main` — use a merge commit so the tag points to a commit that exists on both branches. +**The release PR is closed to late commits, cosmetic ones above all.** Everything the release +contains — code, tests, changelog, release notes, the version bump, and any wording or formatting +touch-up — goes in *before* the PR is opened. Once it is open, the branch is frozen except for a +fix to something the review or CI actually found. + +This is not tidiness. A commit pushed onto an open release PR invalidates every gate that already +passed on it: the full local battery was run against a tree that is no longer the tree being +merged, and `main` is a publishing branch, so what lands there reaches users without a further +approval step. A "cosmetic" commit is the worst version of this, because it is the one nobody +re-verifies — the diff looks harmless, so the checks get read as still valid when they belong to a +different commit. If something cosmetic turns up mid-PR, it waits for the next release; if it truly +cannot wait, close the PR, land the change, re-run the full battery, and open a new one. + ### 7. Tag and push After the PR is merged: diff --git a/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt b/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt index f535dfaa..5d75f4cf 100644 --- a/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt +++ b/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt @@ -291,6 +291,13 @@ data class RateLimitInfo( fun utilizationPercent(): Int? = utilization?.let { Math.round(it * PERCENT).toInt().coerceIn(0, 100) } + /** + * [resetsAt] as ISO-8601, the shape `get_usage` already uses, so a window sourced from an EVENT and one + * sourced from the REPORT are interchangeable to every surface that renders a countdown. The conversion + * lives on the model because both UI builders need it and two copies would be two chances to drift. + */ + fun resetsAtIso(): String? = resetsAt?.let { java.time.Instant.ofEpochSecond(it).toString() } + val isWarning: Boolean get() = status == "allowed_warning" || status == "rejected" val isExhausted: Boolean get() = status == "rejected" diff --git a/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt b/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt index 1524dc1b..70229c8f 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt @@ -45,6 +45,7 @@ import dev.lain.claudejb.settings.SecretStore import dev.lain.claudejb.ui.ClaudeSettingsConfigurable import dev.lain.claudejb.ui.ReviewPrompt import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull @@ -1669,10 +1670,36 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : onResult(report) } }, - decode = ::parseUsageReport, + decode = { payload -> + logUsageReply(payload) + parseUsageReport(payload) + }, ) } + /** + * Logs what the `get_usage` poll actually came back with, once per poll, at INFO. + * + * The derived per-window lines below cannot answer the question that keeps coming up — *is the figure on + * screen stale, or is the server really still saying that?* — because a window the reply omits leaves no + * line at all, and a carried-forward one is indistinguishable from a fresh one. This prints the wire: + * `rate_limits` verbatim, truncated. It is what told us the binary was not caching and that the replies + * during a two-hour exhausted window were complete rather than the header-seeded fallback. + * + * One line every 30 s is the deliberate cost. It is bounded (the payload is a handful of windows) and the + * alternative is a user reporting a wrong number with nothing in `idea.log` to check it against. + */ + private fun logUsageReply(payload: JsonObject?) { + val limits = payload?.get("rate_limits") + if (limits == null || limits is JsonNull) { + // NOT the same as an empty object: the binary sends null when plan limits do not apply at all + // (API key, Bedrock, Vertex) or when its own fetch had nothing to fall back on. + log.info("get_usage: rate_limits=null (available=${payload?.get("rate_limits_available")})") + return + } + log.info("get_usage: ${limits.toString().take(USAGE_LOG_CHARS)}") + } + /** * Announces the first time a quota window crosses 65% and again at 85%. * @@ -2648,6 +2675,9 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : private val QUOTA_THRESHOLDS = listOf(65, 85) private const val QUOTA_THRESHOLD_HIGH = 85 + /** Truncation for the `get_usage` reply trace — a bound, since the payload is not ours to size. */ + private const val USAGE_LOG_CHARS = 2000 + /** Transports JetBrains' MCP server exposes; stdio is synthesized from the running IDE. */ val IDE_MCP_TRANSPORTS = McpTransport.entries.map { it.wire } diff --git a/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt b/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt index 23d77199..068f5b07 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt @@ -93,13 +93,20 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : private var lastUsageAt = 0L /** - * Plan-limits poll. Unlike context and cost — which cannot move while the session idles, so their timer - * retires at turn end — the quota IS shared state: other sessions, other devices and claude.ai itself - * consume the same windows, and a window reset is a wall-clock event. So this ticks for the panel's whole - * lifetime, gated on [isShowing]: a background tab skips the round-trip and catches up within one tick of - * being brought forward. + * Plan-limits poll, unconditional for the panel's whole lifetime. + * + * Unlike context and cost — which cannot move while the session idles, so their timer retires at turn end + * — the quota IS shared state: other sessions, other devices and claude.ai itself consume the same + * windows, and **a window reset is a wall-clock event that owes nothing to this IDE**. + * + * It used to be gated on [isShowing], and that gate is the bug: a tool window the user had collapsed, or + * a chat tab that was not the selected one, stopped asking entirely — so a window could reset, or fill + * from another device, and the panel went on displaying the last figure it happened to catch until + * something else (a turn, opening the dashboard) triggered a probe. "It only updates when I talk to the + * agent" is exactly what a visibility-gated poll looks like from outside. The round-trip it saved is one + * control request every half minute against a process that is already running. */ - private val usageTimer = Timer(USAGE_POLL_MS) { if (isShowing) requestUsage() }.apply { isRepeats = true } + private val usageTimer = Timer(USAGE_POLL_MS) { requestUsage() }.apply { isRepeats = true } /** Last observed process liveness, so [onStateChanged] can spot a restart. EDT-confined. */ private var wasRunning = false @@ -917,13 +924,13 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : /** How many recently-opened files the attach menu offers before the user has to search. */ private const val RECENT_FILES_LIMIT = 14 - /** Period of the plan-limits poll while the panel is visible. */ - private const val USAGE_POLL_MS = 15_000 + /** Period of the plan-limits poll, visible or not — a window reset happens on wall-clock time. */ + private const val USAGE_POLL_MS = 30_000 /** * Floor between `get_usage` round-trips — burst protection for the event-driven triggers. MUST stay * below [USAGE_POLL_MS], or the periodic tick is silently throttled away and the poll only *looks* - * like it runs every 15 s. + * like it runs on its period. */ private const val USAGE_MIN_INTERVAL_MS = 12_000L diff --git a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt index 95c0836f..432fec8d 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt @@ -93,7 +93,7 @@ object JcefSessionData { .filterKeys { key -> fromReport.none { it.key == key } } .map { (key, info) -> val pct = info.utilization?.let { it * 100 } - Window(key, RateLimitInfo.windowTitleFor(key), pct, info.resetsAt?.let(::isoOf), info.isExhausted) + Window(key, RateLimitInfo.windowTitleFor(key), pct, info.resetsAtIso(), info.isExhausted) } val windows = fromReport + fromEvents if (windows.isEmpty() && report?.extra == null) return null @@ -135,10 +135,6 @@ object JcefSessionData { put("limitReached", extra.spendLimitReached) } - /** Epoch seconds → ISO-8601, so event-sourced windows match the shape `get_usage` already returns. */ - private fun isoOf(epochSeconds: Long): String = - java.time.Instant.ofEpochSecond(epochSeconds).toString() - private const val TEN = 10.0 private fun Double.pow(exp: Int): Double = Math.pow(this, exp.toDouble()) diff --git a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt index 8170401c..cb464af1 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt @@ -35,22 +35,30 @@ object JcefState { // EXPERIMENT (Lain's comma test): carry the raw decimals here too, so the composer readout does not // round to Int either — otherwise the decimal never shows and the test can't see a comma vs a dot. val fromReport = usage?.windows.orEmpty().mapNotNull { (key, w) -> - w.utilization?.let { Triple(key, w.title(key), it) } + w.utilization?.let { CompactWindow(key, w.title(key), it, w.resetsAt) } } val fromEvents = session.rateLimits - .filterKeys { key -> fromReport.none { it.first == key } } + .filterKeys { key -> fromReport.none { it.key == key } } .mapNotNull { (key, info) -> - info.utilization?.let { Triple(key, RateLimitInfo.windowTitleFor(key), it * 100) } + info.utilization?.let { + CompactWindow(key, RateLimitInfo.windowTitleFor(key), it * 100, info.resetsAtIso()) + } } - (fromReport + fromEvents).forEach { (key, label, pct) -> + (fromReport + fromEvents).forEach { w -> addJsonObject { - put("key", key) - put("label", label) - put("pct", pct) + put("key", w.key) + put("label", w.label) + put("pct", w.pct) + // The countdown, so the readout can say how long the window has left. A percentage alone does + // not tell you whether 90% is urgent; only the dashboard was answering that. + w.resetsAt?.let { put("resetsAt", it) } } } } + /** One window as the composer readout needs it; a Triple stopped being readable at four fields. */ + private data class CompactWindow(val key: String, val label: String, val pct: Double, val resetsAt: String?) + fun stateJson(session: ClaudeSession, usage: UsageReport? = null): String { val provider = session.provider val mode = session.permissionMode diff --git a/src/main/resources/jcef/app-composer.js b/src/main/resources/jcef/app-composer.js index aa1853f1..85509ae1 100644 --- a/src/main/resources/jcef/app-composer.js +++ b/src/main/resources/jcef/app-composer.js @@ -1108,15 +1108,28 @@ // not, because a window reported at 103% is exactly the number the user needs to see. var fill = h('i', { class: usageLevel(win.pct) }); fill.style.width = Math.max(0, Math.min(100, win.pct)) + '%'; - host.appendChild( + // How long the window has left, on its OWN line under the bar. A percentage without it says how much is + // spent but not whether that matters: 90% with eight minutes to go and 90% with six hours to go are + // different situations, and only the dashboard was answering that. Under the bar rather than beside it + // because the bar row is already three items wide per window — a fourth turned the countdown into the + // first thing to be squeezed out, which is the one case where it matters most. + var reset = CC.resetInShort(win.resetsAt); + var item = h( + 'div', + { + class: 'ub-item', + title: label + ' — ' + pct + ' used' + (reset ? ' · ' + CC.resetIn(win.resetsAt) : ''), + }, h( 'div', - { class: 'ub-item', title: label + ' — ' + pct + ' used' }, + { class: 'ub-row' }, h('span', { class: 'ub-label', text: label }), h('span', { class: 'ub-track' }, fill), h('span', { class: 'ub-pct', text: pct }) ) ); + if (reset) item.appendChild(h('span', { class: 'ub-reset', text: 'Reset time: ' + reset })); + host.appendChild(item); shown++; } if (shown > 0) host.removeAttribute('hidden'); diff --git a/src/main/resources/jcef/app-core.js b/src/main/resources/jcef/app-core.js index ed024657..79350f59 100644 --- a/src/main/resources/jcef/app-core.js +++ b/src/main/resources/jcef/app-core.js @@ -314,6 +314,34 @@ return EXT_LANG[ext] || null; }; + // --------------------------------------------------------------------------- + // How long a quota window has left. Relative, because an absolute timestamp + // makes the reader do the arithmetic. Lives here rather than in one of the + // two modules that render it, so the dashboard card and the composer's bar + // row can never disagree about what "shortly" means. + // --------------------------------------------------------------------------- + /** Minutes until `iso`, or null when it is missing or unparseable. */ + function minutesUntil(iso) { + if (!iso) return null; + var when = Date.parse(iso); + if (isNaN(when)) return null; + return Math.round((when - Date.now()) / 60000); + } + /** "4h 50m" / "12m" / "soon" — the compact form, for the composer's bar row. */ + CC.resetInShort = function (iso) { + var mins = minutesUntil(iso); + if (mins === null) return null; + if (mins <= 0) return 'soon'; + var hours = Math.floor(mins / 60); + return hours > 0 ? hours + 'h ' + (mins % 60) + 'm' : mins + 'm'; + }; + /** "Resets in 4h 50m" — the sentence form, for the dashboard card and tooltips. */ + CC.resetIn = function (iso) { + var short = CC.resetInShort(iso); + if (short === null) return null; + return short === 'soon' ? 'Resets shortly' : 'Resets in ' + short; + }; + // --------------------------------------------------------------------------- // Tiny event bus: on(event, fn) / emit(event, ...args). // --------------------------------------------------------------------------- diff --git a/src/main/resources/jcef/app-session.js b/src/main/resources/jcef/app-session.js index eca50f2e..20ba4bf8 100644 --- a/src/main/resources/jcef/app-session.js +++ b/src/main/resources/jcef/app-session.js @@ -176,15 +176,9 @@ return 'lvl-low'; } - /** "Resets in 4h 50m" — relative, because an absolute timestamp makes you do the arithmetic yourself. */ + /** "Resets in 4h 50m" — one implementation, in app-core, shared with the composer's bar row. */ function resetIn(iso) { - if (!iso) return null; - var when = Date.parse(iso); - if (isNaN(when)) return null; - var mins = Math.round((when - Date.now()) / 60000); - if (mins <= 0) return 'Resets shortly'; - var hours = Math.floor(mins / 60); - return 'Resets in ' + (hours > 0 ? hours + 'h ' + (mins % 60) + 'm' : mins + 'm'); + return CC.resetIn(iso); } function buildContextCard(ctx) { diff --git a/src/main/resources/jcef/app.css b/src/main/resources/jcef/app.css index e9bb5a7e..c0fb8620 100644 --- a/src/main/resources/jcef/app.css +++ b/src/main/resources/jcef/app.css @@ -1441,17 +1441,24 @@ mark.cc-hit.active { window narrows instead of leaving a ragged tail or overflowing. */ .usage-bars { display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 3px 14px; margin: -4px 4px 8px; font-size: 12px; color: var(--dim); } +/* Two lines per window: the bar row, and the countdown under it. */ .usage-bars .ub-item { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; /* without this a long label refuses to shrink and the grid overflows */ +} +.usage-bars .ub-row { display: flex; align-items: center; gap: 6px; - min-width: 0; /* without this a long label refuses to shrink and the grid overflows */ + min-width: 0; } .usage-bars .ub-label { flex: 0 1 auto; @@ -1487,6 +1494,17 @@ mark.cc-hit.active { flex: 0 0 auto; font-variant-numeric: tabular-nums; } +/* Time left in the window, on its own line under the bar. Dimmer and smaller on + purpose: it qualifies the percentage, it does not compete with it. Never wraps + — a countdown broken across two lines reads as two values. */ +.usage-bars .ub-reset { + font-size: 11px; + opacity: 0.72; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; +} /* ════════════════════════════════════════════════════════════════════════════ SLASH PALETTE diff --git a/src/test/frontend/readout.test.js b/src/test/frontend/readout.test.js index ebb371f6..dcf7d0f5 100644 --- a/src/test/frontend/readout.test.js +++ b/src/test/frontend/readout.test.js @@ -107,6 +107,34 @@ describe('plan-limit bars', () => { expect(items()[0].querySelector('.ub-pct').textContent).toBe('103.0%'); }); + it('shows how long each window has left, compactly, with the sentence in the tooltip', () => { + // A percentage alone does not say whether it is urgent: 90% with eight minutes to go and 90% with six + // hours to go are different situations, and only the dashboard was answering that. + const in90min = new Date(Date.now() + 90 * 60000).toISOString(); + win.cc.state({ + ...base, + usage: [ + { key: 'five_hour', label: 'Current session', pct: 90, resetsAt: in90min }, + { key: 'seven_day', label: 'All models', pct: 9 }, + ], + }); + expect(items()[0].querySelector('.ub-reset').textContent).toBe('Reset time: 1h 30m'); + // Its own line under the bar row, not a fourth item squeezed into it. + expect(items()[0].querySelector('.ub-row .ub-reset')).toBeNull(); + expect(items()[0].querySelector('.ub-row .ub-pct')).not.toBeNull(); + expect(items()[0].getAttribute('title')).toContain('Resets in 1h 30m'); + // A window with no reset time gets no element at all — an empty slot would read as "resets now". + expect(items()[1].querySelector('.ub-reset')).toBeNull(); + expect(items()[1].getAttribute('title')).not.toContain('Resets'); + }); + + it('says "soon" once the reset time has passed rather than a negative countdown', () => { + const past = new Date(Date.now() - 60000).toISOString(); + win.cc.state({ ...base, usage: [{ key: 'a', label: 'Over', pct: 100, resetsAt: past }] }); + expect(items()[0].querySelector('.ub-reset').textContent).toBe('Reset time: soon'); + expect(items()[0].getAttribute('title')).toContain('Resets shortly'); + }); + it('hides the row entirely when no window carries a percentage', () => { win.cc.state({ ...base }); expect(bars().hasAttribute('hidden')).toBe(true); diff --git a/src/test/kotlin/dev/lain/claudejb/protocol/UsageReportTest.kt b/src/test/kotlin/dev/lain/claudejb/protocol/UsageReportTest.kt index 3cc15eb6..04d67cf3 100644 --- a/src/test/kotlin/dev/lain/claudejb/protocol/UsageReportTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/protocol/UsageReportTest.kt @@ -418,6 +418,15 @@ class UsageReportTest { assertNull(withRawLimits(rawLimit("Fable", 5)).mergedOver(previous).extra) } + @Test + fun `an event-sourced window converts its reset time to the shape the report already uses`() { + // The composer readout and the dashboard both render a countdown, and a window can reach them from + // either path. One conversion on the model, so an event-sourced window and a report-sourced one are + // interchangeable to every surface instead of ISO in one place and epoch seconds in the other. + assertEquals("2026-08-13T16:59:59Z", RateLimitInfo(resetsAt = 1_786_640_399L).resetsAtIso()) + assertNull(RateLimitInfo().resetsAtIso()) + } + // --- UsageWindow.utilizationPercent: the scale, and the bug that came from guessing it --- @Test