Merge pull request #14225 from omacom/fix/agents-usage-display
Fix agent usage resets, stale display, and icon underline alignment
This commit is contained in:
5 files changed
+91
-53
No files matched your search
@@ -1031,11 +1031,8 @@ def probe_limits(access_token: str) -> dict[str, Any]:
|
||||
return {"ok": True, "limits": limits}
|
||||
|
||||
|
||||
# A cached percentage outlives the probe that measured it, but only until its
|
||||
# window rolls over: once a window has reset, the figure describes a period
|
||||
# that is over, and a stale 78% would misreport an allowance that is now
|
||||
# untouched. A window with no reset time, or one that will not parse, is kept
|
||||
# — an unreadable timestamp is no reason to throw away a real number.
|
||||
# Cached windows reset to zero when their measured period ends. Without a
|
||||
# fresh probe, the next rolling window has no known reset time yet.
|
||||
def limit_window_open(entry: dict[str, Any], now: dt.datetime) -> bool:
|
||||
raw = str(entry.get("resetsAt") or "")
|
||||
if raw == "":
|
||||
@@ -1054,7 +1051,10 @@ def usable_cached_limits(cached: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
return [entry for entry in entries if isinstance(entry, dict) and limit_window_open(entry, now)]
|
||||
return [
|
||||
entry if limit_window_open(entry, now) else dict(entry, percent=0.0, resetsAt="")
|
||||
for entry in entries if isinstance(entry, dict)
|
||||
]
|
||||
|
||||
|
||||
def collect_limits(access_token: str, expires_at_ms: int, refresh_expires_at_ms: int, force: bool, cache_name: str = "claude-limits.json") -> dict[str, Any]:
|
||||
@@ -1079,7 +1079,7 @@ def collect_limits(access_token: str, expires_at_ms: int, refresh_expires_at_ms:
|
||||
# refreshes the credential file when it runs, so a machine left alone long
|
||||
# enough finds the saved token lapsed. Say so — an empty limits list with
|
||||
# nothing else set hides the whole section and explains nothing — and keep
|
||||
# showing the last numbers whose window has not since reset.
|
||||
# showing the last numbers, resetting elapsed windows to zero.
|
||||
if access_token == "":
|
||||
result["limits"] = fallback
|
||||
result["usageStatusText"] = "Waiting for auth"
|
||||
@@ -1168,24 +1168,6 @@ def registered_accounts() -> list[dict[str, Any]]:
|
||||
return accounts
|
||||
|
||||
|
||||
# A parked account whose sign-in lapsed can't be probed, and once every window
|
||||
# it last saw has reset, the cache has nothing current to offer. That is not
|
||||
# "unknown" — a reset window is an untouched allowance, and switching needs to
|
||||
# know this account is the one with the most room. Each window it last saw
|
||||
# comes back at 0%.
|
||||
def replenished_limits(cache_name: str) -> list[dict[str, Any]]:
|
||||
cached = read_json(cache_root() / cache_name) or {}
|
||||
entries = cached.get("limits")
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
return [
|
||||
dict(entry, percent=0.0, resetsAt="")
|
||||
for entry in entries
|
||||
if isinstance(entry, dict) and not limit_window_open(entry, now)
|
||||
]
|
||||
|
||||
|
||||
def current_account_id(home: Path) -> str:
|
||||
try:
|
||||
account = json.loads(profile_path(home).read_text(encoding="utf-8")).get("oauthAccount") or {}
|
||||
@@ -1211,8 +1193,6 @@ def account_limits(account: dict[str, Any], force: bool) -> dict[str, Any]:
|
||||
else:
|
||||
cache_name = f"claude-limits-{key}.json"
|
||||
limits = collect_limits(access_token, expires_at_ms, refresh_expires_at_ms, force, cache_name)
|
||||
if not limits["live"] and not limits["limits"]:
|
||||
limits["limits"] = replenished_limits(cache_name)
|
||||
return {
|
||||
"id": account["id"],
|
||||
"label": str(account.get("label") or account["id"]),
|
||||
|
||||
@@ -24,7 +24,7 @@ Panel {
|
||||
|
||||
property bool cursorActive: false
|
||||
|
||||
// Countdowns and "as of" ages read this instead of Date.now() so the
|
||||
// Countdowns and "last updated" ages read this instead of Date.now() so the
|
||||
// panel keeps telling the truth while it sits open.
|
||||
property double nowMs: Date.now()
|
||||
|
||||
@@ -419,7 +419,11 @@ Panel {
|
||||
|
||||
function otherTrouble(item) {
|
||||
var status = String(item && item.usageStatusText || "")
|
||||
return status !== "" && !needsSignIn(item) ? status : ""
|
||||
return status !== "" && status !== "Limits paused" && !needsSignIn(item) ? status : ""
|
||||
}
|
||||
|
||||
function pausedWithoutLimits(item) {
|
||||
return !!item && item.usageStatusText === "Limits paused" && limitWindows(item).length === 0
|
||||
}
|
||||
|
||||
// Sign an account that's already here in again, following along in the
|
||||
@@ -499,10 +503,12 @@ Panel {
|
||||
// titled after its model, and a name like "Opus 5 (1M context)" would parse
|
||||
// as a one-minute window.
|
||||
function limitWindow(label, percent, resetAt, title) {
|
||||
var reset = new Date(String(resetAt || "")).getTime()
|
||||
var expired = isFinite(reset) && reset <= root.nowMs
|
||||
return {
|
||||
title: String(title || "") !== "" ? String(title) : windowTitle(label),
|
||||
percent: Number(percent),
|
||||
resetAt: String(resetAt || "")
|
||||
percent: expired ? 0 : Number(percent),
|
||||
resetAt: expired ? "" : String(resetAt || "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1341,6 +1347,12 @@ Panel {
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
UnavailableUsage {
|
||||
visible: !section.multi && root.pausedWithoutLimits(section.provider)
|
||||
width: parent.width
|
||||
record: section.provider
|
||||
}
|
||||
|
||||
Column {
|
||||
visible: !section.multi && section.windows.length > 0
|
||||
width: parent.width
|
||||
@@ -1447,6 +1459,11 @@ Panel {
|
||||
width: parent.width
|
||||
spacing: Style.space(12)
|
||||
|
||||
UnavailableUsage {
|
||||
width: parent.width
|
||||
record: accountBlock.modelData
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.displayWindows({ limits: accountBlock.modelData.limits || [] })
|
||||
|
||||
@@ -1831,16 +1848,38 @@ Panel {
|
||||
}
|
||||
}
|
||||
|
||||
// With no measured windows, leave a place to discover how to get usage.
|
||||
component UnavailableUsage: Item {
|
||||
id: unavailable
|
||||
property var record: null
|
||||
visible: root.pausedWithoutLimits(record)
|
||||
implicitHeight: unavailableLabel.implicitHeight
|
||||
|
||||
Text {
|
||||
id: unavailableLabel
|
||||
text: "Usage unavailable"
|
||||
color: root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
HoverHandler { id: unavailableHover }
|
||||
|
||||
PanelToolTip {
|
||||
visible: unavailableHover.hovered && unavailable.visible
|
||||
text: unavailable.record ? String(unavailable.record.authHelpText || "Usage has not been updated yet.") : ""
|
||||
}
|
||||
}
|
||||
|
||||
// One line per limit window: title, meter, percentage, and reset. A
|
||||
// model-scoped allowance on the same clock ("Fable" on Weekly) is a marker
|
||||
// on this row's meter, named in the row's tooltip.
|
||||
component CompactLimit: Item {
|
||||
id: compact
|
||||
property var window: null
|
||||
// Numbers kept past a failed check dim, and say how old they are on hover.
|
||||
// The age of numbers kept past a failed check is shown only on hover.
|
||||
property bool stale: false
|
||||
property real fetchedAt: 0
|
||||
opacity: stale ? 0.5 : 1.0
|
||||
readonly property var scoped: window && window.scoped ? window.scoped : []
|
||||
readonly property bool alarming: window && window.percent >= 0.9
|
||||
readonly property real resetMs: root.resetMsFor(window)
|
||||
@@ -1858,8 +1897,8 @@ Panel {
|
||||
}
|
||||
if (compact.stale)
|
||||
lines.push(compact.fetchedAt > 0 && root.nowMs - compact.fetchedAt > 60000
|
||||
? "As of " + root.formatDuration(root.nowMs - compact.fetchedAt) + " ago"
|
||||
: "Last known")
|
||||
? "Last updated " + root.formatDuration(root.nowMs - compact.fetchedAt) + " ago"
|
||||
: compact.fetchedAt > 0 ? "Last updated less than a minute ago" : "Last updated time unavailable")
|
||||
for (var i = 0; i < compact.scoped.length; i++)
|
||||
lines.push(compact.scoped[i].title + ": " + Math.round(compact.scoped[i].percent * 100) + "% of its "
|
||||
+ String(compact.window ? compact.window.title : "").toLowerCase() + " allowance")
|
||||
|
||||
@@ -1886,11 +1886,12 @@ Item {
|
||||
// desktop — so it underlines a top bar, overlines a bottom one, and
|
||||
// points inward from a left or right one. It reads as pointing at the
|
||||
// panel that opens on that side.
|
||||
// Snap toward the start of the slot, matching native glyph rendering.
|
||||
x: root.vertical
|
||||
? (root.position === "left" ? parent.width - width - inset : inset)
|
||||
: Math.round((parent.width - width) / 2)
|
||||
: Math.floor((parent.width - width) / 2)
|
||||
y: root.vertical
|
||||
? Math.round((parent.height - height) / 2)
|
||||
? Math.floor((parent.height - height) / 2)
|
||||
: (root.position === "top" ? parent.height - height - inset : inset)
|
||||
z: 50
|
||||
|
||||
|
||||
@@ -171,18 +171,20 @@ expired=$(collect_limits "token" 1000 1000 "$cache")
|
||||
fail "Claude collector says how to refresh an expired sign-in" "$expired"
|
||||
pass "Claude collector reports an expired sign-in instead of hiding the section"
|
||||
|
||||
# The window that has not reset is still true; the one that has is not.
|
||||
[[ $(jq -c '[.limits[].label]' <<<"$expired") == '["Weekly (7-day)"]' ]] ||
|
||||
fail "Claude collector keeps only cached windows that have not reset" "$expired"
|
||||
pass "Claude collector keeps only cached windows that have not reset"
|
||||
# The elapsed session resets to zero while the weekly usage stays intact.
|
||||
[[ $(jq -c '[.limits[].label]' <<<"$expired") == '["Session (5-hour)","Weekly (7-day)"]' ]] ||
|
||||
fail "Claude collector keeps cached windows and resets elapsed ones" "$expired"
|
||||
[[ $(jq '[.limits[].percent] == [0,0.11]' <<<"$expired") == "true" && $(jq -r '.limits[0].resetsAt' <<<"$expired") == "" ]] ||
|
||||
fail "Claude collector resets just the elapsed window" "$expired"
|
||||
pass "Claude collector keeps cached windows and resets elapsed ones"
|
||||
|
||||
# Nothing worth showing: the status still explains the silence.
|
||||
# A wholly reset cache still shows the empty allowance.
|
||||
stale=$(collect_limits "token" 1000 1000 "$(jq -c '.limits |= [.[0]]' <<<"$cache")")
|
||||
[[ $(jq -c '.limits' <<<"$stale") == "[]" && $(jq -r '.usageStatusText' <<<"$stale") == "Sign-in expired" ]] ||
|
||||
fail "Claude collector drops a wholly reset cache but keeps explaining itself" "$stale"
|
||||
[[ $(jq -r '.authHelpText' <<<"$stale") != *"last known"* ]] ||
|
||||
fail "Claude collector promises no last-known limits when it has none" "$stale"
|
||||
pass "Claude collector drops a wholly reset cache but keeps explaining itself"
|
||||
[[ $(jq '.limits | map(.percent) == [0]' <<<"$stale") == "true" && $(jq -r '.usageStatusText' <<<"$stale") == "Sign-in expired" ]] ||
|
||||
fail "Claude collector resets a wholly elapsed cache to zero" "$stale"
|
||||
[[ $(jq -r '.authHelpText' <<<"$stale") == *"last known"* ]] ||
|
||||
fail "Claude collector keeps the timestamp and guidance for a reset cache" "$stale"
|
||||
pass "Claude collector resets a wholly elapsed cache to zero"
|
||||
|
||||
# The access token lives about eight hours; the refresh token behind it lives
|
||||
# about thirty days, and the CLI mints a new access token from it on its next
|
||||
@@ -194,7 +196,7 @@ paused=$(collect_limits "token" 1000 "$live_refresh" "$cache")
|
||||
fail "Claude collector calls a lapsed access token paused, not signed out" "$paused"
|
||||
[[ $(jq -r '.authHelpText' <<<"$paused") != *"claude auth login"* ]] ||
|
||||
fail "Claude collector does not send a signed-in machine back through login" "$paused"
|
||||
[[ $(jq -c '[.limits[].label]' <<<"$paused") == '["Weekly (7-day)"]' ]] ||
|
||||
[[ $(jq -c '[.limits[].label]' <<<"$paused") == '["Session (5-hour)","Weekly (7-day)"]' ]] ||
|
||||
fail "Claude collector keeps open cached windows while the access token is stale" "$paused"
|
||||
pass "Claude collector calls a lapsed access token paused, not signed out"
|
||||
|
||||
@@ -202,7 +204,7 @@ pass "Claude collector calls a lapsed access token paused, not signed out"
|
||||
signed_out=$(collect_limits "" 0 0 "$cache")
|
||||
[[ $(jq -r '.usageStatusText' <<<"$signed_out") == "Waiting for auth" ]] ||
|
||||
fail "Claude collector still reports a missing token" "$signed_out"
|
||||
[[ $(jq -c '[.limits[].label]' <<<"$signed_out") == '["Weekly (7-day)"]' ]] ||
|
||||
[[ $(jq -c '[.limits[].label]' <<<"$signed_out") == '["Session (5-hour)","Weekly (7-day)"]' ]] ||
|
||||
fail "Claude collector serves open cached windows without a token" "$signed_out"
|
||||
pass "Claude collector serves open cached windows without a token"
|
||||
|
||||
@@ -210,14 +212,14 @@ pass "Claude collector serves open cached windows without a token"
|
||||
# time, not by the file's age. A backwards clock correction must not discard
|
||||
# an otherwise open fallback when no token is available to replace it.
|
||||
future_fallback=$(collect_limits "" 0 0 "$cache" 3600)
|
||||
[[ $(jq -c '[.limits[].label]' <<<"$future_fallback") == '["Weekly (7-day)"]' ]] ||
|
||||
[[ $(jq -c '[.limits[].label]' <<<"$future_fallback") == '["Session (5-hour)","Weekly (7-day)"]' ]] ||
|
||||
fail "Claude collector keeps open fallback limits after a backwards clock correction" "$future_fallback"
|
||||
pass "Claude collector keeps open fallback limits after a backwards clock correction"
|
||||
|
||||
# A live token that cannot reach the endpoint keeps the old contract: the open
|
||||
# window stands in, and the shell is asked to retry sooner than its interval.
|
||||
unreachable=$(collect_limits "token" 0 0 "$cache")
|
||||
[[ $(jq -c '[.limits[].label]' <<<"$unreachable") == '["Weekly (7-day)"]' ]] ||
|
||||
[[ $(jq -c '[.limits[].label]' <<<"$unreachable") == '["Session (5-hour)","Weekly (7-day)"]' ]] ||
|
||||
fail "Claude collector falls back to cache when the probe cannot connect" "$unreachable"
|
||||
[[ $(jq -r '.retryAdvised' <<<"$unreachable") == "true" ]] ||
|
||||
fail "Claude collector advises a retry after a transport failure" "$unreachable"
|
||||
@@ -321,10 +323,19 @@ pass "Claude collector caches a successful probe"
|
||||
run_node_test <<'JS'
|
||||
const fs = require('fs')
|
||||
const source = fs.readFileSync(root + '/shell/plugins/agents/Panel.qml', 'utf8')
|
||||
const panelRoot = { nowMs: Date.now() }
|
||||
const start = source.indexOf('function windowIsLong')
|
||||
const end = source.indexOf('// The window that decides')
|
||||
assert(start > 0 && end > start, 'agents panel exposes its limit-window helpers')
|
||||
eval(source.slice(start, end))
|
||||
eval(source.slice(start, end).replaceAll('root.nowMs', 'panelRoot.nowMs'))
|
||||
eval(source.slice(source.indexOf('function needsSignIn'), source.indexOf('// Sign an account')))
|
||||
|
||||
const paused = { usageStatusText: 'Limits paused', limits: [] }
|
||||
assertEqual(pausedWithoutLimits(paused), true, 'paused usage without a cache shows a recovery placeholder')
|
||||
assertEqual(otherTrouble(paused), '', 'paused usage never puts the confusing status in the header')
|
||||
assertEqual(pausedWithoutLimits({ ...paused, limits: [{ label: 'Session (5-hour)', percent: 0, resetsAt: '' }] }), false, 'cached usage replaces the recovery placeholder')
|
||||
assertEqual(pausedWithoutLimits({ usageStatusText: 'Sign-in expired', limits: [] }), false, 'expired sign-ins retain their sign-in action')
|
||||
assertEqual(pausedWithoutLimits(null), false, 'missing records do not show a paused-usage placeholder')
|
||||
|
||||
assertDeepEqual(
|
||||
limitWindows({ limits: [
|
||||
@@ -344,6 +355,13 @@ assertDeepEqual(
|
||||
'agents panel still reads a window out of a label that carries no title'
|
||||
)
|
||||
|
||||
const resetAt = new Date(panelRoot.nowMs + 1000).toISOString()
|
||||
const measured = { limits: [{ label: 'Session (5-hour)', percent: 0.78, resetsAt: resetAt }] }
|
||||
assertEqual(limitWindows(measured)[0].percent, 0.78, 'usage remains until the reset time')
|
||||
panelRoot.nowMs += 1000
|
||||
assertDeepEqual(limitWindows(measured), [{ title: 'Session', percent: 0, resetAt: '' }], 'usage resets at the deadline while the panel stays open')
|
||||
assertEqual(measured.limits[0].percent, 0.78, 'display reset leaves the measured record intact')
|
||||
|
||||
// A model's weekly allowance rides under the Weekly row; one whose window has
|
||||
// no row of its own keeps a row.
|
||||
assertDeepEqual(
|
||||
|
||||
@@ -27,7 +27,7 @@ assert(/target\.kind === "signin"\) signInAgain\(/.test(panelSource) && /target\
|
||||
assert(/target\.kind === "autoswitch"\) setSwitchMode\(/.test(panelSource), 'Enter on Autoswitch flips the switch mode')
|
||||
assert(/keyColumn = use >= 0 \? use : /.test(panelSource), 'moving up or down onto an account lands on Use')
|
||||
assert(/Qt\.callLater\(function\(\) \{ if \(picking\) pointAt\("choice", 0\) \}\)/.test(panelSource), 'picking an agent to add starts with the first one focused')
|
||||
assert(/opacity: stale \? 0\.5 : 1\.0/.test(panelSource) && /"As of " \+ root\.formatDuration/.test(panelSource), 'limits kept from an earlier check dim and say how old they are on hover')
|
||||
assert(!/opacity: stale \?/.test(panelSource) && /"Last updated " \+ root\.formatDuration/.test(panelSource), 'stale limits say how old they are only on hover')
|
||||
assert(/onPickingChanged: resetKeys\(\)/.test(panelSource), 'the cursor starts over when the agent list comes or goes')
|
||||
assert(!/t === "a" \|\| t === "A"/.test(panelSource), 'adding an account has no hotkey; the + is the way in')
|
||||
assert(/if \(!accounts\[a\]\.active\) \{/.test(panelSource) && /if \(row\.length > 0\) rows\.push\(row\)/.test(panelSource), 'the active account with nothing to fix is not a keyboard stop')
|
||||
|
||||
Reference in new issue
Block a user