feat(composer): show provider usage limits and stop the send button jumping - #64
feat(composer): show provider usage limits and stop the send button jumping#64gfsaaser24 wants to merge 3 commits into
Conversation
…umping The composer had no way to tell you how much of your Claude or Codex subscription you had left, even though both adapters already emit an `account.rate-limits.updated` runtime event that nothing consumed. Separately, the primary action changed size the moment a turn started — send is 36px below the `sm` breakpoint, stop was 32px — so the button and the meters beside it shifted at every turn boundary. Reconnects the existing event feed into a new provider usage store, adds a debounced pull for Claude (whose numbers only ever arrive on request), and renders the result as small circles beside the context-window meter. Claude shows session/weekly/Fable, Codex shows its two windows, and providers that report nothing render nothing. The primary action now sits in a fixed slot that is the same size in every state, with room for a running-state overlay to be drawn over it without contributing layout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…labels
CodeRabbit review pass on the usage-meter work. The real ones: a Claude
credentials file that stores `expiresAt` in epoch seconds was read as
milliseconds, so every such token looked decades expired and the meters
stayed permanently blank; `Number("")` is 0, so a provider reporting an
empty percentage drew a confident "0% used"; the popover's reset labels
were computed against epoch zero before it opened; a defect handling one
usage event could take the whole ingestion feed down for the life of the
process; the composer slot test passed when neither side matched; and the
glossary's new links redefined reference labels [20] and [21], silently
repointing the checkpointing entries.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis change adds provider quota usage contracts, normalization, volatile server storage, runtime ingestion, refresh RPCs, and usage meters to web and mobile composers. It supports Claude and Codex usage sources and adds related tests and documentation. ChangesProvider usage reporting
Estimated code review effort: 4 (Complex) | ~60 minutes Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/server/src/provider/claudeUsageFetch.ts`:
- Around line 165-176: Update the request effect around client.execute so
Effect.timeoutOption(USAGE_REQUEST_TIMEOUT_MS) encompasses response status
validation and httpResponse.json decoding, ensuring stalled bodies also time
out. Preserve the existing null result for timeouts, non-2xx responses, and JSON
failures, and keep the effect requirements limited to HttpClient.HttpClient
without introducing Scope.
In `@apps/server/src/provider/Layers/ProviderRegistry.ts`:
- Around line 369-385: The applyProviderUsageLimits flow currently retains stale
usage limits when a same-ID provider rebuild omits usageLimits. Clear the
usageStore entry before applying limits for rebuilt instances, clear it when an
instance is removed, and add a regression test covering a same-instanceId
rebuild with no new usageLimits so the published provider does not reuse prior
values.
In `@apps/server/src/provider/usageLimits.ts`:
- Around line 90-95: Update the alias selection in readClaudeBucket so each
percent field is passed through readPercent in order, falling through to
utilization, usedPercentage, and percent when an earlier value is present but
unreadable. Use the first non-null parsed result, and return null only when none
of the aliases parses successfully.
In `@apps/web/src/components/chat/ChatComposer.tsx`:
- Around line 3256-3258: Update the usage-meter props near usageWindows and
usageUpdatedAt to derive the displayed provider name from selectedProviderEntry
or selectedInstanceId, matching the usage selection logic around the existing
usage lookup. Do not use activeThreadProviderDisplayName, which can remain stale
after an unsaved picker change, and add coverage for switching providers before
sending.
In `@apps/web/src/components/chat/ComposerPrimaryActions.tsx`:
- Around line 20-25: Update the production ChatComposer call to
ComposerPrimaryActions to pass the intended activity node through activityOrb,
preserving the shared visibility condition with the stop button; alternatively
remove activityOrb and its isolated test if no production activity indicator is
intended.
In `@docs/user/usage-meters.md`:
- Around line 28-31: Update the usage-meter documentation text around the hover
description to say “displayed percentage” instead of “exact percentage,”
matching the value produced by formatUsagePercent and the mobile usage rows.
In `@packages/contracts/src/server.ts`:
- Around line 180-188: Update ProviderUsageLimitsStore.set so accepted refreshes
publish updatedAt changes even when windows are unchanged, while retaining
deduplication for unchanged full provider snapshots. Ensure client usageLimits
receives the new timestamp so formatUsageUpdatedAtLabel reflects the latest
successful refresh.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: f3d87ff3-6504-4ec8-b57f-80ab7e3d6c28
📒 Files selected for processing (33)
apps/mobile/src/components/AppSymbol.tsxapps/mobile/src/features/threads/ThreadComposer.tsxapps/server/src/auth/RpcAuthorization.tsapps/server/src/provider/Drivers/ClaudeSkills.tsapps/server/src/provider/Layers/CodexProvider.tsapps/server/src/provider/Layers/ProviderRegistry.test.tsapps/server/src/provider/Layers/ProviderRegistry.tsapps/server/src/provider/Layers/ProviderUsageIngestion.test.tsapps/server/src/provider/Layers/ProviderUsageIngestion.tsapps/server/src/provider/Layers/ProviderUsageLimits.test.tsapps/server/src/provider/Layers/ProviderUsageLimits.tsapps/server/src/provider/Layers/ProviderUsageRefresher.tsapps/server/src/provider/Services/ProviderUsageLimits.tsapps/server/src/provider/claudeUsageFetch.test.tsapps/server/src/provider/claudeUsageFetch.tsapps/server/src/provider/usageLimits.test.tsapps/server/src/provider/usageLimits.tsapps/server/src/server.test.tsapps/server/src/server.tsapps/server/src/ws.tsapps/web/src/components/chat/ChatComposer.tsxapps/web/src/components/chat/ComposerPrimaryActions.test.tsapps/web/src/components/chat/ComposerPrimaryActions.tsxapps/web/src/components/chat/UsageLimitsMeters.test.tsxapps/web/src/components/chat/UsageLimitsMeters.tsxdocs/internals/glossary.mddocs/user/usage-meters.mdpackages/client-runtime/package.jsonpackages/client-runtime/src/state/providerUsage.test.tspackages/client-runtime/src/state/providerUsage.tspackages/client-runtime/src/state/server.tspackages/contracts/src/rpc.tspackages/contracts/src/server.ts
| const applyProviderUsageLimits = Effect.fn("applyProviderUsageLimits")(function* ( | ||
| provider: ServerProvider, | ||
| ) { | ||
| if (provider.usageLimits !== undefined) { | ||
| yield* usageStore.set(provider.instanceId, provider.usageLimits, "full"); | ||
| } | ||
| const usageLimits = yield* usageStore.get(provider.instanceId); | ||
| if (!usageLimits) { | ||
| const { usageLimits: _usageLimits, ...providerWithoutUsage } = provider; | ||
| return providerWithoutUsage; | ||
| } | ||
| return { ...provider, usageLimits }; | ||
| }); | ||
|
|
||
| const decorateProvider = Effect.fn("decorateProvider")(function* (provider: ServerProvider) { | ||
| return yield* applyProviderUsageLimits(yield* applyProviderUpdateState(provider)); | ||
| }); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Clear retained usage when an instance is rebuilt or removed.
When a rebuilt instance keeps its instanceId and its snapshot omits usageLimits, Line 375 restores the prior store entry. The registry can then publish limits from the previous account or driver configuration.
Clear the usage-store entry before accepting a rebuilt instance. Clear it when an instance is removed. Add a regression test for a same-ID instance rebuild with no new usage reading.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/server/src/provider/Layers/ProviderRegistry.ts` around lines 369 - 385,
The applyProviderUsageLimits flow currently retains stale usage limits when a
same-ID provider rebuild omits usageLimits. Clear the usageStore entry before
applying limits for rebuilt instances, clear it when an instance is removed, and
add a regression test covering a same-instanceId rebuild with no new usageLimits
so the published provider does not reuse prior values.
| const usedPercent = readPercent( | ||
| value.used_percentage ?? value.utilization ?? value.usedPercentage ?? value.percent, | ||
| ); | ||
| if (usedPercent === null) { | ||
| return null; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Fall through to the next alias when a percent field is present but unreadable.
?? stops at the first non-null value. If used_percentage is "" or a non-numeric string, the chain never reaches utilization, and readClaudeBucket drops the whole bucket. Select the first alias that parses instead.
🐛 Proposed fix
- const usedPercent = readPercent(
- value.used_percentage ?? value.utilization ?? value.usedPercentage ?? value.percent,
- );
+ const usedPercent = [
+ value.used_percentage,
+ value.utilization,
+ value.usedPercentage,
+ value.percent,
+ ].reduce<number | null>((found, candidate) => found ?? readPercent(candidate), null);
if (usedPercent === null) {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const usedPercent = readPercent( | |
| value.used_percentage ?? value.utilization ?? value.usedPercentage ?? value.percent, | |
| ); | |
| if (usedPercent === null) { | |
| return null; | |
| } | |
| const usedPercent = [ | |
| value.used_percentage, | |
| value.utilization, | |
| value.usedPercentage, | |
| value.percent, | |
| ].reduce<number | null>((found, candidate) => found ?? readPercent(candidate), null); | |
| if (usedPercent === null) { | |
| return null; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/server/src/provider/usageLimits.ts` around lines 90 - 95, Update the
alias selection in readClaudeBucket so each percent field is passed through
readPercent in order, falling through to utilization, usedPercentage, and
percent when an earlier value is present but unreadable. Use the first non-null
parsed result, and return null only when none of the aliases parses
successfully.
| /** | ||
| * Activity indicator shown beside the stop button. Passed in rather than | ||
| * derived here: this component stays a dumb renderer, and the orb and the | ||
| * stop button share one condition, so they appear and disappear together. | ||
| */ | ||
| activityOrb?: ReactNode; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Show every declaration and production call site that references activityOrb.
rg -n -C 4 '\bactivityOrb\b' apps/webRepository: gfsaaser24/t3code
Length of output: 4507
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- ComposerPrimaryActions references ---'
rg -n -C 3 '\bComposerPrimaryActions\b' .
printf '%s\n' '--- ChatComposer relevant sections ---'
rg -n -C 8 'ComposerPrimaryActions|activity|orb' apps/web/src/components/chat/ChatComposer.tsxRepository: gfsaaser24/t3code
Length of output: 12848
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- Direct caller at ChatComposer.tsx:443 ---'
sed -n '420,475p' apps/web/src/components/chat/ChatComposer.tsx
printf '%s\n' '--- Mobile callers ---'
sed -n '2828,2875p' apps/web/src/components/chat/ChatComposer.tsx
sed -n '3115,3165p' apps/web/src/components/chat/ChatComposer.tsx
printf '%s\n' '--- Footer primary-actions composition ---'
sed -n '3238,3295p' apps/web/src/components/chat/ChatComposer.tsx
rg -n -C 6 'function ComposerFooterPrimaryActions|const ComposerFooterPrimaryActions|export .*ComposerFooterPrimaryActions' apps/web/src/components/chatRepository: gfsaaser24/t3code
Length of output: 10620
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '390,425p' apps/web/src/components/chat/ChatComposer.tsx
rg -n -i -C 4 'activity.*orb|orb.*activity|ActivityOrb|activityOrb' apps/web/srcRepository: gfsaaser24/t3code
Length of output: 9637
Connect activityOrb from a production caller.
All production ComposerPrimaryActions call sites omit activityOrb. The orb therefore renders only in the unit test and is absent from the web composer. Pass the intended activity node from ChatComposer, or remove the unused prop and isolated test.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/components/chat/ComposerPrimaryActions.tsx` around lines 20 -
25, Update the production ChatComposer call to ComposerPrimaryActions to pass
the intended activity node through activityOrb, preserving the shared visibility
condition with the stop button; alternatively remove activityOrb and its
isolated test if no production activity indicator is intended.
| Hover a circle for the exact percentage and when that limit resets. Resets | ||
| less than a day away count down ("resets in 2h 15m"); further out they show | ||
| the day and time ("resets Mon 9:00 AM"), and beyond a couple of days the date | ||
| comes along too. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Describe the value as the displayed percentage.
formatUsagePercent rounds values of 10% or more to whole percentages and smaller values to one decimal place. The mobile usage rows use this formatter, so the UI does not expose the raw percentage. Replace “exact percentage” with “displayed percentage” unless the detail view shows the raw value.
Proposed wording
-Hover a circle for the exact percentage and when that limit resets.
+Hover a circle for the displayed percentage and when that limit resets.📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| Hover a circle for the exact percentage and when that limit resets. Resets | |
| less than a day away count down ("resets in 2h 15m"); further out they show | |
| the day and time ("resets Mon 9:00 AM"), and beyond a couple of days the date | |
| comes along too. | |
| Hover a circle for the displayed percentage and when that limit resets. Resets | |
| less than a day away count down ("resets in 2h 15m"); further out they show | |
| the day and time ("resets Mon 9:00 AM"), and beyond a couple of days the date | |
| comes along too. |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/user/usage-meters.md` around lines 28 - 31, Update the usage-meter
documentation text around the hover description to say “displayed percentage”
instead of “exact percentage,” matching the value produced by formatUsagePercent
and the mobile usage rows.
| /** | ||
| * Account-level quota usage for one configured provider instance. | ||
| * `updatedAt` answers the only staleness question the UI asks ("as of when?"). | ||
| */ | ||
| export const ProviderUsageLimits = Schema.Struct({ | ||
| windows: Schema.Array(ProviderUsageWindow), | ||
| updatedAt: IsoDateTime, | ||
| }); | ||
| export type ProviderUsageLimits = typeof ProviderUsageLimits.Type; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Keep updatedAt synchronized with accepted refreshes.
ProviderUsageLimitsStore.set stores the new timestamp but suppresses publication when windows are unchanged. The client then retains the old usageLimits.updatedAt. After a successful refresh with unchanged usage, formatUsageUpdatedAtLabel can report stale data.
Publish timestamp-only changes, or add a lightweight usage freshness update path. Preserve full provider-snapshot deduplication separately.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/contracts/src/server.ts` around lines 180 - 188, Update
ProviderUsageLimitsStore.set so accepted refreshes publish updatedAt changes
even when windows are unchanged, while retaining deduplication for unchanged
full provider snapshots. Ensure client usageLimits receives the new timestamp so
formatUsageUpdatedAtLabel reflects the latest successful refresh.
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
Four real ones from the bot pass: - The Android usage menu never ran its own onPress. `AndroidAnchoredMenu` wraps a plain child in a `pointerEvents="none"` view so the anchor owns the tap, so opening the menu never restamped the relative labels or asked for a fresh reading. `ControlPillMenu` now also accepts the render-function child form, which keeps the trigger interactive there. - The web popover titled one provider's quota with another's name. The meters read the selected instance (the choice for the next turn) while the title read the thread's persisted provider, so picking a new model swapped the numbers but not the label. - `claimRefreshSlot` wedged when the wall clock moved backwards: a negative elapsed read as "still inside the debounce window" and blocked refreshes until the clock caught up. - `fetchClaudeUsage` only bounded `client.execute`, so a 2xx response with a trickled body parked the refresh fiber past the timeout. Written by Claude Opus 5 in Claude Code.
Note
Update: also cherry-picked
fix(usage): address review findings on the usage metersfrom the upstream submission (pingdotgg#5945), which fixes four review findings: the Android usage menu never firing its ownonPress(now usesControlPillMenu's render-function child), the web popover titling one provider's quota with another's name,claimRefreshSlotwedging when the wall clock moves backwards, andfetchClaudeUsage's timeout not covering a trickled response body. One conflict hand-resolved inControlPill.tsx(main's iOS branch lacks the upstream long-press pass-through, so only the render-function support was taken). Typecheck and affected suites re-verified clean.Note
Duplicate of #54, ported to
main. That PR merged intoturboonly;maindidn't have the feature. Both commits were cherry-picked ontomainwith three files hand-resolved where the branches had diverged (ComposerPrimaryActions.tsx+ its test, and the mobileThreadComposer.tsx— main lacks the turbo-only settings sheet and orb context, so the mobile delta was re-applied against main's toolbar). Port verification: typecheck clean across contracts, client-runtime, server, web, and mobile (0 errors); the PR's test suites pass (18 client-runtime, 20 web, 61 server) withProviderRegistry.test.tsat its documented Windows-flaky baseline (19–21 failures, unrelated to this change).Original PR description follows.
Implements
docs/superpowers/specs/2026-08-09-composer-usage-limits-design.md.The problem
The composer never showed how much of your Claude or Codex subscription was left — even though both adapters already emit an
account.rate-limits.updatedruntime event that nothing consumed. The pipe was wired to a wall.Separately, the primary action changed size the moment a turn started: send is 36px below the
smbreakpoint, stop was 32px. The button and the meters beside it shifted at every turn boundary.What changed
Usage limits. One provider-agnostic window type (
{ id, label, usedPercent, resetsAt }) onServerProvider. Two feeds write into a usage store: the free turn events, and a 60s-debounced pull for Claude, whose numbers only ever arrive on request. Claude renders session / weekly / Fable, Codex its two windows, and Cursor / Grok / OpenCode render nothing at all — no placeholder, so switching providers costs no layout shift. Compact mode collapses to one circle showing the bucket closest to running out.Stop-button containment. The primary action now sits in a fixed slot, identical in every state,
relativeand non-clipping so a running-state overlay can be drawn over it contributing zero layout.Notes for review
sm.apps/web/src/turbo/orbs/is onturbo-beta, not here, so theflex-colstacking bug the spec describes isn't present. The slot is built to the spec's shape so the orb drops in with no further layout work.rate_limit_eventdoes not break out Fable — it carries one bucket per event and has no Fable bucket type. Fable populates from the pull only, as the spec anticipated.Review pass
Four parallel Opus 5 reviewers went over this (CodeRabbit CLI isn't installed on this machine). Nine real defects found and fixed, including three that would have shipped broken:
normalizeClaudeUsageexpected a bucketed payload, but the SDK sends a flat single-bucketrate_limit_info. The test that "covered" it asserted against an invented shape. Now normalized properly, with a test at the actual adapter→normalizer seam.fullreadings retire a window.CLAUDE_CONFIG_DIRignored). Now reuses the canonical resolver.updatedAtdefeated the change check.Part B was verified clean at every breakpoint. One pre-existing gap left alone: the pending-question branch still uses a 32px control, so it retains a 4px jump — out of scope here.
Verification
Typecheck clean across contracts, client-runtime, server, and web. Mobile unchanged at its pre-existing 64 navigation-typing errors. Lint and format clean. 62 tests pass across six files.
ProviderRegistryandserversuites match baseline — that suite is flaky on Windows independent of this change (baseline 19–21 failures, this branch 19–20).Not run: the app itself. No browser verification without your say-so.
Model: Claude Opus 5 (1M context), harness: Claude Code.
🤖 Generated with Claude Code
Summary by CodeRabbit