feat(panel): per-client usage errors and reset clock times - #175
Conversation
hiskudin
left a comment
There was a problem hiding this comment.
Read through this properly — the shape is good and two bits are better than they needed to be. hasTier is the right call: "non-nil snapshot" and "has anything to draw" genuinely are different questions and having two callers answer them differently was a real bug. And testAbsoluteLabelRoundTripsThroughTheClaudeParser is exactly the producer/parser pinning this repo keeps needing — render it, push it back through parseResetsAt, assert the same instant. That's the class of test the #169 mutation run showed we were missing.
Three things worth a look, one of which is mine to deal with.
1. Holding a stale snapshot collides with the pace bars in #176
This is the one I'd want settled before both land, and it's my problem more than yours.
Hard-fail used to null nav.quota. This PR holds the last-good snapshot and marks it stale, which is the better behaviour on its own terms. But #176 adds an elapsed-time bar to each tier row, derived from resetsAt and recomputed against Date() on every redraw — while utilization stays frozen at whatever the held snapshot says.
So under a sustained claude failure the pale elapsed bar keeps advancing while the solid usage bar sits still, and the row reads as "increasingly comfortably under pace" from data that's minutes or hours old. Nulling the snapshot used to make that impossible; after this it's reachable. The stale note helps, but the bar is the thing people actually read.
Not asking you to change anything — quotaErrors[client] is exactly the signal needed, and you've deliberately left quotaClaudeLastUpdated untouched so the age is still available. I'll gate the elapsed bar on it in #176. Flagging it so the interaction is on the record rather than discovered after both merge.
Also worth knowing: these two PRs touch the same five production files and both rewrite the Resets … line in tierRow, so whichever lands second will conflict there. Small, but not zero.
2. QuotaReset.absoluteLabel mutates a shared formatter
onTheHourFormatter / withMinutesFormatter are cached statics, and absoluteLabel does formatter.timeZone = timeZone on the way through. The comment says this is reused "main thread only, as with RelativeTime" — but RelativeTime only ever reads its cached formatters, so the parallel doesn't quite hold. This one writes.
Every current caller is main-thread (tierRow in a view body, postQuotaBanner off the probe completion), so it's latent rather than live. But DateFormatter mutation isn't thread-safe, and the next caller won't know the constraint exists. Cheapest fixes in rough order of preference: construct the formatter inside the function, key the cache by timezone, or leave the cache and add a dispatchPrecondition(condition: .onQueue(.main)) so a future off-main caller trips immediately instead of returning a quietly wrong string.
3. cliMissing drops a snapshot the rest of the PR would have kept
ProcessOutput.claude() returning nil sets cliMissing, and the handler then nils nav.quota and quotaClaudeLastUpdated. The intent is right — a Codex-only user shouldn't get a phantom Claude error — but this ends up being the one path in the PR that discards good data, which cuts against everything else here.
claude not resolving on one tick isn't strong evidence of "not a Claude user". PATH under a launched .app is materially different from PATH in a shell, and this app's launch environment is already known to be odd (make reload's launchctl kickstart doesn't find the service at all on my machine). A single unlucky resolution silently removes Claude from the sidebar entirely.
Suggestion that keeps your intent: treat cliMissing as "not a Claude user" only when there's no prior snapshot. If one exists, hold it and mark it stale like every other failure here. Someone who genuinely doesn't have Claude never had a snapshot, so they still get silence.
Minor
absoluteLabel renders in the machine's timezone while Claude's CLI prints the account's, so the Usage tab and claude /usage will show different clock times for anyone whose account timezone isn't their machine's. Your comment calls this out and the reasoning holds — the countdown is measured against local — so this is a note, not a request. Might be worth a word in the README so it reads as deliberate rather than a bug.
cf00b7d to
46f6d7d
Compare
Both branches appended to QuotaReset at the same point, and resolving that conflict dropped absoluteFormatter's closing brace — so elapsedFraction nested inside it and everything below fell out of scope. Restores the brace.
* feat(panel): show a pace marker on Usage tab quota bars
The Usage tab drew utilization as a bar but the window's time dimension only
as text ("Resets in 2 days"), so "am I above 50% usage with 50% of the window
left?" meant comparing a bar against a sentence.
Each quota bar now carries a tick at the point the window has reached. Fill
behind it means you'll coast to the reset; fill past it means you'll hit the
cap early. Drawn as an overlay rather than a second bar because the pane fits
only ~3 tier rows at the default panel height and Claude Max has 4.
Positioning it needs a window length, which QuotaTier didn't carry. Codex
publishes one per limit (`window_minutes`) and we were reading past it; Claude's
/usage text has none, so it's inferred from the tier name; Antigravity reports
neither a length nor, for credits, a reset — those simply get no marker.
Reading window_minutes also fixes a live mislabel. The tab titled Codex tiers by
slot, assuming primary = 5h and secondary = weekly. Under `limit_id=codex` there
is no 5-hour window at all and the weekly one arrives as `primary`, so a weekly
quota was displayed as "Current session (5h)" with no weekly row. Titles are now
named from the window actually reported. Across 60 local rollouts (23,696
non-null limit objects) `window_minutes` was present every time.
Mutation testing found the Claude tier-name -> window mapping unpinned: swapping
weekly to 5h left the suite green while pegging every weekly marker to the far
left forever. Covered now by driving the real /usage text through the real
parser, alongside a Codex contract test built from verbatim rollout lines.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(panel): address review — keep the row's accessibility, correct two claims
The pace marker collapsed its row with `.accessibilityElement(children: .ignore)`
and replaced it with one label. That traded three things away for one: the
"Resets in ..." caption became unreachable to VoiceOver (the Usage tab shows the
reset time nowhere else), the ProgressView lost its own progress value, and with
"Show remaining" on the label read "62% used" while the row printed "38% left".
The pace belongs on the bar, not on the row. The ProgressView now carries it as
an accessibilityValue phrased from the same toggle the row renders, and the
percentage and reset captions keep their own elements.
Two claims were also wrong, both mine:
Antigravity does report a reset time — AntigravityUsage.swift parses
quotaInfo.resetTime. What it never reports is a window length, and that is what
suppresses the marker; only the monthly credit pool has neither. The README also
claimed a staleness check that doesn't exist: the sole guard is a reset time
already in the past.
And "under limit_id=codex there is no 5-hour window at all" was too strong. 28 of
11,792 `codex` lines locally carry the 300/10080 pair, so the same limit_id emits
both shapes. That strengthens the case for reading window_minutes rather than
weakening it: neither the slot nor the id can stand in for the window.
Tests: pin the label against the showRemaining toggle, and pin the skip case
production actually hits — rate_limits present but null, which clears the
`contains("rate_limits")` pre-filter (4,240 such lines across 279 rollouts).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(panel): make the pace marker legible and size it to the bar
Review found the tick invisible where it matters most: a single .primary line
at 55% opacity measured 1.16:1 against a yellow fill in dark mode, and all six
fill/appearance combinations fell below the 3:1 non-text floor. The marker only
lands on the fill once you are over pace, so it disappeared in exactly the case
it exists to show. It is now a dark core inside a light sleeve, both fixed
rather than semantic, so one of the two always contrasts with whatever is behind
it.
It was also the wrong size. The GeometryReader is sized to the ProgressView,
which is 20pt tall around a ~7.5pt track, and the Rectangle constrained only its
width — so the "1.5pt tick" rendered 19.5pt, jutting ~6pt of hairline above and
below the bar into whitespace. Now sized to the track and centred on it.
The widget named Codex's rings by slot as well, so once the Usage tab started
naming them from window_minutes the pill contradicted it: "CURRENT WEEK" in the
tab beside "5h" on the pill, same data. Both now read the reported window.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(panel): trim comments
* feat(panel): show elapsed time as a paler bar behind usage, not a tick
The tick read as a rendering artifact rather than a deliberate mark, and it had
to fight the fill for contrast to be seen at all.
Each bar is now two: a pale bar in the tier's own colour for how far through the
window we are, with the solid usage bar inset inside it. You compare two
right-hand edges instead of a line against a fill, and the colour ramp still
carries severity.
Inset rather than layered underneath, which was the obvious reading of "a paler
bar behind": a full-width solid bar drawn over a pale one hides it completely
whenever usage exceeds elapsed — the over-pace case the feature exists to show.
Inset keeps both edges visible in either direction. Measured on screen: the pale
bar occupies 8pt and the solid 4pt, centred within it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(panel): settle the elapsed bar at 0.2 opacity
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(panel): warn on the row when usage is ahead of the clock
The bars show the comparison, but only half of it reads well. Under pace you
see a pale tail extending past the solid bar — obvious. Over pace the solid bar
covers the pale one and the only cue left is a thickness step, which is subtle
and doesn't announce what it means. The design was communicating the good news
loudly and the bad news quietly, which is backwards.
A tier whose usage leads the clock now says so: "22% ahead of pace", in orange,
under the reset caption. Points rather than a ratio, since the row already shows
both numbers as percentages and the gap reads in the same unit.
Only that direction is called out. An "on track" line on every healthy row is a
thing to learn to ignore, and the orange stops meaning anything once it's always
there.
Five points of margin before it fires, or a row sitting at 46% used with 45%
elapsed would flicker the warning on and off while telling the user nothing.
VoiceOver gets the warning too, since it can't see the colour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(panel): close absoluteFormatter after the rebase onto #175
Both branches appended to QuotaReset at the same point, and resolving that
conflict dropped absoluteFormatter's closing brace — so elapsedFraction nested
inside it and everything below fell out of scope. Restores the brace.
* fix(widget): name every client in the pill legend, and drop empty ring rows
Claude had no tag, on the grounds that it's the default and a permanent line
costs every pill something. But that made absence the label: you learn "Codex"
means Codex and have to infer that blank means Claude, and switching between
them reads as a line appearing rather than as a change of client. Claude is
named now like the others. It measures 31pt against the 35pt "5h 50%" beneath
it, so the reserved legend slot is unchanged.
A ring with no tier also rendered "7d —", which says nothing. Once the labels
started coming from the window each slot actually reports, it got worse: a
client publishing only a weekly window produced "7d 15%" above a bare "7d —",
the same label twice. Rows for absent rings are now omitted; hasData already
keeps the legend off entirely when both are empty.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: the pace marker became a bar, so stop calling it a marker
Left over from the earlier tick design that the two-bar version replaced.
* fix(panel): address review — stop the warning reflowing the row
Six defects from an adversarial pass, all in the warning and bar work.
The warning sat on its own line, and it sits on a bare threshold it can cross
back over as the window advances: at 46% used the overshoot shrinks about a
point every three minutes, so the line appeared and vanished and reflowed every
row beneath it each time. My comment claimed five points of margin fixed that;
it only moved the boundary. It now shares the reset caption's line, so toggling
changes text and never height. Widest case measures 291pt against the ~453pt the
pane gets.
Orange text was wrong twice over: .orange is already a severity step on the
widget's ramp, so it read as a fourth level the Usage tab doesn't have, and
orange under a red bar reads as less urgent than the bar it describes. It also
failed contrast on light backgrounds at caption2. The glyph keeps the colour and
the text takes .secondary.
paceOvershoot clamped utilization to 100 while the row prints it unclamped, so
113% used reported "50% ahead of pace" instead of 63 — and the accessibility
string carried all three numbers at once.
VoiceOver read the warning twice: it was appended to the bar's accessibilityValue
and also rendered as visible text, which is its own element. Only the visible
text now.
The elapsed bar had an unconditional 2pt floor, so a window at 0% elapsed drew a
sliver ahead of a zero usage bar. And the usage bar switched between 8pt and 4pt
depending on whether an elapsed bar existed, so Claude's 0%-and-no-reset Sonnet
row rendered fatter than its neighbours. One height now, floor only above zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(widget): stop the pill contradicting the Usage tab, and resize its countdown
Second adversarial pass, all on the slot-vs-window assumption this PR set out to
remove — it turned out to be written down in more places than I fixed.
The hover countdown reserved space with Text("0h00m"), and its comment stated the
dead premise outright: "the 5h window caps the leading digit". The short slot
carries Codex's weekly window on some accounts, so the real worst case is a
two-digit hour — 23h59m measures 37.7pt against the 31.7pt reserved, and
.fixedSize() overflows rather than truncates. Sized from 23h59m now; the day
shapes this PR added are narrower again.
ringLabel folded "no window reported" and "a window we haven't special-cased"
into one branch, so a 1440-minute window read "5h" on the pill while the tab
called it "Current window (1d)" — the exact contradiction the PR exists to
remove. Both now name windows through QuotaWindow.shortName.
The ring tooltip still hardcoded "Inner ring: 5h session quota", so on a
weekly-in-primary account the legend said 7d while the tooltip above it said 5h
session. Built from the tier's own window now, and it drops the outer-ring
clause when there's no second window to describe.
Also: finishes a rebase artifact 61215c1 missed (describe's closing brace was
pulled onto its return line), drops a stale rationale left sitting above the
comment that reverses it, and corrects AttentionPolicy's claim that elapsedLabel
shares shortLabel's vocabulary — it has no day unit.
Tests: the shared Claude fixture omitted windowLength, so it exercised the
unnamed-window path the real probe never takes. It sets it now, which is what
surfaced the tooltip bug. Adds rounding coverage (every prior case landed on a
whole number and passed under floor, ceil or truncation alike), the over-100
path through paceWarning, a just-below-threshold case through the tier rather
than the raw helper, and an unmapped window asserted against both surfaces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Two things, both in the Usage tab.
The usage was flakey and the error state only ever said Claude. The
claude /usageshell-out is the one probe that hits the network, so it fails a fair bit more than the local Codex/Antigravity reads - and on a fail it was nulling the snapshot, so the whole Claude row dropped out of the sidebar and flickered back on the next tick. Now it holds the last-good snapshot and marks it stale, and the error is per-client so the pane names whatever actually failed rather than always pointing at Claude. A missingclaudeon PATH is silent too now, so a Codex-only user doesn't get a phantom Claude error.Second, reset times. Codex reports its reset as a unix timestamp and Antigravity as ISO 8601, so they all render in the same clock format Claude's CLI prints ("in 2 hours · Jun 30 at 6:50pm"), in the Usage tab and the quota banner.
Shared
QuotaSnapshot.hasTierfalls out of both - a snapshot can parse ok and still hold no tier we map, and the two callers (which clients get a row, and whether an error replaces the bars or sits above them) have to agree on that.683 tests, all green.