Skip to content

feat(composer): show provider account usage in the composer - #6080

Open
gfsaaser24 wants to merge 1 commit into
pingdotgg:mainfrom
gfsaaser24:feat/composer-usage-meters
Open

feat(composer): show provider account usage in the composer#6080
gfsaaser24 wants to merge 1 commit into
pingdotgg:mainfrom
gfsaaser24:feat/composer-usage-meters

Conversation

@gfsaaser24

@gfsaaser24 gfsaaser24 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Up front: this is a very large feature PR (~2,650 lines), which your contributing guide says not to open. I am opening it anyway because the feature is finished, tested, and stable rather than a sketch, and it seemed more useful to you as working code than as an issue describing it. It is a UX addition, not a bug fix. Please close it without ceremony if it is not a direction you want — no reply needed, and I will not re-open or follow up.

Supersedes #5945 — same feature, re-cut as a single clean commit on current main with every review-bot finding from that PR folded in (Android menu tap, provider-name mismatch, backwards-clock debounce, body-read timeout, stale usage across instance rebuilds with an in-flight-pull guard, percent/reset alias fallback).

What Changed

Adds account usage meters to the composer.

Contract — a provider-agnostic ProviderUsageLimits in packages/contracts, shaped { id, label, usedPercent, resetsAt }, so clients map over a list instead of branching per provider.

Server — an in-memory ProviderUsageLimitsStore fed by two sources:

  • ProviderUsageIngestion consumes the account.rate-limits.updated runtime events the Claude and Codex adapters already emit and nothing currently reads. Zero extra network calls.
  • ProviderUsageRefresher does a debounced (60s/instance) pull for Claude only, since Claude reports usage only on request. It reads the token the claude CLI already stored and calls the OAuth usage endpoint, which reports utilization without spending message quota. Codex needs no pull — it volunteers numbers over the app-server connection.

Provider-shaped payloads are flattened in usageLimits.ts. Every normalizer is total: anything unparseable yields no windows rather than an error.

Web — a ring per window beside the send button, collapsing to the worst window when the composer is narrow. Hover gives exact percentages and reset times.

Mobile — one toolbar button instead of three 44pt touch targets, listing the windows on tap.

Providers that do not report usage (Cursor, Grok, OpenCode) render nothing at all, so switching threads between providers costs no layout shift.

Why

Right now you find out you are out of quota when a turn stops halfway through. The data to prevent that is already flowing — both adapters emit rate-limit events at the end of every turn and nothing consumes them.

Design decisions worth flagging, since they are the ones you would push back on:

  • Nothing is persisted. A usage number is only interesting while it is roughly current, and both feeds repopulate within one turn of a restart.
  • The store only announces when a number actually moves. updatedAt is stamped server-side on every reading, so publishing on the stamp would wake the registry and push the entire provider array — every model, capability, and skill — to every connected client once per turn, for no visible change.
  • No tickers. Relative labels ("resets in 2h 15m") are stamped when the popover opens, not on an interval. A continuously repainting composer pegs the GPU on high-refresh displays.
  • Every failure path draws nothing. No credentials, locked keychain, expired token, network blip — all resolve to blank meters. There is no error a user could act on here.

UI Changes

Hovering a meter in the web composer:

Claude usage popover in the composer, showing Session at 0%, Weekly at 17%, and Fable at 16% with reset times, beside the send button

Before is the same composer with no circles and no popover — the meters are purely additive, and providers that report no usage still render exactly that.

No motion or transitions were added, so there is no video.

Testing

Branched off main at e5c82d7. Focused tests for the normalizers, the store's merge/debounce/announce behavior, the ingestion seam, the token reader, and the web components. Typecheck and lint clean across server, web, mobile, contracts, and client-runtime.

Two pre-existing failures in ProviderRegistry.test.ts and server.test.ts reproduce identically on unmodified main on my machine — they look like Windows-only arg-quoting artifacts in the spawn mocks (Unexpected args: ^"--version^"), unrelated to this change.

Checklist

  • This PR is small and focused — it is not; stated plainly above
  • I explained what changed and why
  • I included a screenshot for the UI change
  • I included a video for animation/interaction changes — no motion added

Note

Medium Risk
Touches provider registry, credential reads (Claude OAuth/keychain), and live snapshot publishing; failures are designed to degrade to blank meters, but instance-rebuild races and multi-account config paths need careful review.

Overview
Introduces account usage meters in the composer so users see subscription quota before a turn fails. The feature spans contracts, server plumbing, shared client helpers, and web/mobile UI.

Contracts & RPC — Adds ProviderUsageLimits / ProviderUsageWindow on ServerProvider and a read-scoped server.refreshProviderUsage WebSocket RPC that schedules a pull and returns immediately; fresh numbers arrive on the provider snapshot stream.

Server — A volatile ProviderUsageLimitsStore merges full vs partial readings, debounces pulls per instance (60s), clears usage when an instance is rebuilt, and pushes only usageLimits changes to clients without persisting or re-upserting full snapshots. ProviderUsageIngestion consumes existing account.rate-limits.updated turn events (Claude/Codex). ProviderUsageRefresher pulls Claude via stored OAuth credentials and the usage API (no message quota). Codex usage is read during status probe (account/rateLimits/read, bounded timeout) and normalized in usageLimits.ts. ProviderRegistry decorates snapshots from the store and strips usage from disk cache.

Clients — Shared formatting/selection in @t3tools/client-runtime/state/provider-usage. Web: UsageLimitsMeters ring(s) beside send, compact mode shows worst window, refresh on focus/hover/open. Mobile: single gauge toolbar menu via ControlPillMenu render-function child (Android tap fix) and AppState focus refresh. ControlPillMenu accepts children as (open) => ReactNode so Android triggers can call open() after their own onPress.

Docs: user guide and glossary entry for usage windows.

Reviewed by Cursor Bugbot for commit cee4fe4. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Show provider account usage meters in the composer and mobile thread view

  • Adds ProviderUsageWindow and ProviderUsageLimits schemas to ServerProvider snapshots, populated from Claude OAuth usage endpoint and Codex account/rateLimits/read during provider probes.
  • Introduces ProviderUsageLimitsStore (in-memory, per-instance, debounced) and ProviderUsageIngestionLive to merge usage data from both probe snapshots and live turn events without extra network calls.
  • Adds ProviderUsageRefresher service and a new server.refreshProviderUsage WebSocket RPC so clients can request on-demand usage pulls; results appear on the provider snapshot stream.
  • Renders animated SVG usage rings in the web composer footer (UsageLimitsMeters) and a ControlPillMenu in the mobile composer; both refresh on mount, window/app focus, and menu open.
  • Client-side formatting utilities (formatUsagePercent, formatUsageResetLabel, formatUsageUpdatedAtLabel, pickWorstUsageWindow) handle countdown vs. absolute reset labels and staleness display.
  • Risk: usage data is no longer persisted to disk; stored usage is cleared on instance rebuild, so meters are blank until the first successful refresh after restart.

Macroscope summarized cee4fe4.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e1d6111e-d8d5-4d66-9b5e-a0fe6986908a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 11, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three convention findings on the two newly introduced Effect services (ProviderUsageLimitsStore, ProviderUsageRefresher). They follow the older Services/ + Layers/ + Shape + ...Live shape rather than the canonical single-module Context.Service (inline interface) + make + layer layout used elsewhere in this app (e.g. apps/server/src/provider/providerMaintenanceRunner.ts, apps/server/src/provider/Layers/ProviderEventLoggers.ts, apps/server/src/workspace/WorkspacePaths.ts). Nothing else in the diff raised service-convention issues.

Posted via Macroscope — Effect Service Conventions

*/
export const USAGE_REFRESH_DEBOUNCE_MS = 60_000;

export const ProviderUsageLimitsStoreLive = Layer.effect(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module exports only ProviderUsageLimitsStoreLive with construction inlined into Layer.effect. Convention is to export a real make (the Effect.gen body) plus export const layer = Layer.effect(ProviderUsageLimitsStore, make) from the service's canonical module, so consumers use ProviderUsageLimits.layer / ProviderUsageLimits.make. Folding this file into the tag module (see the comment on Services/ProviderUsageLimits.ts) and renaming to make/layer addresses both.

Posted via Macroscope — Effect Service Conventions


const decodeClaudeSettings = Schema.decodeUnknownEffect(ClaudeSettings);

export const ProviderUsageRefresherLive = Layer.effect(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the store layer: prefer an exported make plus export const layer = Layer.effect(ProviderUsageRefresher, make) in the refresher's own canonical module (apps/server/src/provider/ProviderUsageRefresher.ts) instead of a ...Live layer whose tag lives in a separate Services/ file.

Posted via Macroscope — Effect Service Conventions

import type * as Scope from "effect/Scope";
import type * as Stream from "effect/Stream";

export interface ProviderUsageLimitsStoreShape {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both new services keep a standalone Shape interface (ProviderUsageLimitsStoreShape here, ProviderUsageRefresherShape at line 101) and split the tag from its implementation across Services/ and Layers/. Convention for new services is one canonical module per service, with the interface declared inline in the Context.Service call and make/layer in the same file.

Suggest hoisting to apps/server/src/provider/ProviderUsageLimits.ts (store) and apps/server/src/provider/ProviderUsageRefresher.ts (refresher), inlining each interface into its Context.Service declaration, and dropping the two ...Shape types (referring to the inferred shape as ProviderUsageLimitsStore["Service"] where needed).

Posted via Macroscope — Effect Service Conventions

onPress={() => {
// Restamp the relative labels and ask for a fresh
// reading; the server debounces the pull.
setUsageMenuNonce((nonce) => nonce + 1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium threads/ThreadComposer.tsx:899

On iOS, the usage menu opens showing stale relative-time labels. The trigger's onPress calls setUsageMenuNonce to restamp labels like "resets in 2h 15m" and "as of 12m ago", but ControlPillMenu passes a no-op open on iOS and opens its native menu synchronously from the same tap event. The re-render triggered by the state update has not run yet when the menu presents, so it displays usageMenuActions computed from the previous render with outdated times. If usage data hasn't changed in hours, the labels stay frozen at whatever the clock read when the menu was last built. Consider computing time-sensitive values at open time (e.g., computing nowMs eagerly in the onPress handler) rather than relying on a state update to re-render before the native menu appears.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadComposer.tsx around line 899:

On iOS, the usage menu opens showing stale relative-time labels. The trigger's `onPress` calls `setUsageMenuNonce` to restamp labels like "resets in 2h 15m" and "as of 12m ago", but `ControlPillMenu` passes a no-op `open` on iOS and opens its native menu synchronously from the same tap event. The re-render triggered by the state update has not run yet when the menu presents, so it displays `usageMenuActions` computed from the previous render with outdated times. If usage data hasn't changed in hours, the labels stay frozen at whatever the clock read when the menu was last built. Consider computing time-sensitive values at open time (e.g., computing `nowMs` eagerly in the `onPress` handler) rather than relying on a state update to re-render before the native menu appears.

Comment thread apps/server/src/provider/Layers/ProviderUsageIngestion.ts Outdated
// put this fiber behind the single `Effect.yieldNow` that its
// per-instance subscribers rely on to attach, delaying theirs by a
// tick and dropping the first snapshot published to them.
const usageChanges = yield* usageStore.subscribeChanges;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Layers/ProviderRegistry.ts:710

The usageChanges subscriber never republishes a provider when a usage reading arrives with identical percentages but a newer updatedAt, because the usage store intentionally suppresses change notifications for updatedAt-only updates. As a result, getProviders and WebSocket clients keep showing the stale usageLimits.updatedAt for that instance — so formatUsageUpdatedAtLabel renders an actively-refreshed unchanged quota as "as of … hours/days ago" until some unrelated percentage or snapshot change happens. Consider republishing the instance in the subscriber when updatedAt advances even if the bucket values are unchanged, or having the store emit a notification for updatedAt-only changes.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ProviderRegistry.ts around line 710:

The `usageChanges` subscriber never republishes a provider when a usage reading arrives with identical percentages but a newer `updatedAt`, because the usage store intentionally suppresses change notifications for `updatedAt`-only updates. As a result, `getProviders` and WebSocket clients keep showing the stale `usageLimits.updatedAt` for that instance — so `formatUsageUpdatedAtLabel` renders an actively-refreshed unchanged quota as "as of … hours/days ago" until some unrelated percentage or snapshot change happens. Consider republishing the instance in the subscriber when `updatedAt` advances even if the bucket values are unchanged, or having the store emit a notification for `updatedAt`-only changes.

if (!claimed) {
return;
}
// Snapshot the clear-generation before the pull. If the instance is

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Layers/ProviderUsageRefresher.ts:116

When an instance is rebuilt between claimRefreshSlot and the usageStore.generation snapshot, the refresher writes the previous account's quota onto the rebuilt instance. accessToken and claudeSettings are resolved before the claim, so they belong to the old account; but generation is snapshotted after the claim, so it captures the post-clear generation. The guarded set therefore passes and overwrites the rebuilt instance's usage with stale data from the old account. Snapshot the generation before any credential work (before resolveClaudeConfigDirPath / readClaudeAccessToken) and use that earlier snapshot in the set call.

Also found in 1 other location(s)

apps/server/src/provider/Layers/ProviderUsageLimits.ts:112

clear resets lastRefreshAtRef separately before bumping the generation in usageRef. A refresh that has already read the old account token can interleave after line 112: it claims the newly cleared refresh slot, then clear bumps the generation, and the refresher snapshots that new generation before fetching with the old token. Its guarded set therefore succeeds and restores the previous account's quota onto the rebuilt instance. The debounce state and generation must be cleared atomically, or the refresher must snapshot the generation before any account-specific/token work.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ProviderUsageRefresher.ts around line 116:

When an instance is rebuilt between `claimRefreshSlot` and the `usageStore.generation` snapshot, the refresher writes the previous account's quota onto the rebuilt instance. `accessToken` and `claudeSettings` are resolved before the claim, so they belong to the old account; but `generation` is snapshotted after the claim, so it captures the post-clear generation. The guarded `set` therefore passes and overwrites the rebuilt instance's usage with stale data from the old account. Snapshot the generation before any credential work (before `resolveClaudeConfigDirPath` / `readClaudeAccessToken`) and use that earlier snapshot in the `set` call.

Also found in 1 other location(s):
- apps/server/src/provider/Layers/ProviderUsageLimits.ts:112 -- `clear` resets `lastRefreshAtRef` separately before bumping the generation in `usageRef`. A refresh that has already read the old account token can interleave after line 112: it claims the newly cleared refresh slot, then `clear` bumps the generation, and the refresher snapshots that new generation before fetching with the old token. Its guarded `set` therefore succeeds and restores the previous account's quota onto the rebuilt instance. The debounce state and generation must be cleared atomically, or the refresher must snapshot the generation before any account-specific/token work.

Comment thread apps/web/src/components/chat/ChatComposer.tsx
Comment thread apps/server/src/provider/Layers/ProviderUsageRefresher.ts Outdated
@macroscopeapp

macroscopeapp Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

4 blocking correctness issues found. This PR introduces a new user-facing feature (provider usage meters) with significant new infrastructure including OAuth credential reading, multiple new services, and cross-cutting changes to provider snapshots. Multiple unresolved Medium-severity findings identify race conditions in the refresh/rebuild logic that warrant attention before merging.

You can customize Macroscope's approvability policy. Learn more.

@gfsaaser24
gfsaaser24 force-pushed the feat/composer-usage-meters branch from eeba5d6 to 882ebfb Compare August 11, 2026 01:20
Comment thread apps/server/src/provider/ProviderUsageRefresher.ts Outdated
Comment thread apps/server/src/provider/ProviderUsageLimits.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 882ebfb. Configure here.

Comment thread apps/server/src/provider/Layers/ProviderUsageIngestion.ts Outdated
Comment thread apps/mobile/src/features/threads/ThreadComposer.tsx
@gfsaaser24
gfsaaser24 force-pushed the feat/composer-usage-meters branch from 882ebfb to 9280026 Compare August 11, 2026 01:37
@gfsaaser24

Copy link
Copy Markdown
Contributor Author

Addressed the Macroscope findings in 9280026e (amended in place to keep the PR at one commit):

Fixed

  • Generation snapshot ordering (ProviderUsageRefresher.ts) — the clear-generation is now snapshotted before any credential work, so a rebuild landing between the token read and the pull can no longer slip an old account's reading past the guard.
  • clear atomicity (ProviderUsageLimits.ts) — readings, generations, and the refresh-debounce stamps now live in one Ref, so clear drops all three in a single Ref.modify. A concurrent claimRefreshSlot can no longer interleave and leave a rebuilt instance debounced for 60s.
  • Ingestion generation guard (ProviderUsageIngestion.ts) — turn events now pass through the same ifGenerationIs guard as the OAuth pull, so a stale event from a torn-down session cannot repopulate a cleared instance.
  • Custom-instance display name (ChatComposer.tsx) — the usage popover title prefers the status entry's own displayName before falling back to the driver-level resolution, so a custom instance's quota is no longer labeled with the default instance's name.
  • Relative CLAUDE_CONFIG_DIR (ProviderUsageRefresher.ts) — the credential resolution now passes the workspace cwd (the same cwd the spawned CLI runs with), so a relative config-dir override resolves to the directory the CLI actually uses.

Not changed, deliberately

  • updatedAt-only readings not republishing — this is the design trade-off described in the PR body: updatedAt is stamped server-side on every reading, so publishing stamp-only changes would push the full provider array to every client once per turn for no visible change. The stamp is still stored and rides out on the next real change or snapshot publish.
  • iOS menu presenting with last-render labels — the native UIMenu presents synchronously from the tap and cannot be updated after presentation, so the restamp-on-open pattern that works for the JS-rendered Android menu has no iOS equivalent short of rebuilding the actions every render (which the memo exists to avoid). Worst case is minute-granularity relative labels lagging the last re-render; taking that over a per-render rebuild of the toolbar.

Comment thread apps/server/src/provider/Layers/ProviderRegistry.ts
A provider-agnostic usage-window contract, an in-memory store fed by the
rate-limit events both adapters already emit plus a debounced OAuth pull
for Claude, and meters in the web and mobile composers. Providers that
report nothing render nothing.

Hardening folded in from review: readings survive sparse per-bucket
updates by merging on window id; rebuilt or removed instances drop their
stored reading (generation-guarded against in-flight pulls restoring
them); the refresh debounce survives a backwards wall clock and is not
burned by a missing token; the usage fetch bounds the body read, not
just the headers; percent and reset-time aliases fall through to the
next readable value; Android's usage menu keeps its trigger interactive.

Written by Claude in Claude Code.
@gfsaaser24
gfsaaser24 force-pushed the feat/composer-usage-meters branch from 9280026 to cee4fe4 Compare August 11, 2026 01:49
@gfsaaser24

Copy link
Copy Markdown
Contributor Author

Follow-up in cee4fe4d: the usage-change subscriber no longer round-trips a previously read snapshot through upsertProviders. It now rewrites only the usageLimits decoration on whatever snapshot is current, in a single Ref.modify, publishing when the array actually changed. That removes the race Macroscope flagged (a concurrent live refresh could have its newer status/capabilities rolled back by the stale re-upsert), and it also makes the earlier clear-boomerang hazard structurally impossible — the subscriber reads the store instead of re-seeding it from a snapshot.

}
// The OAuth endpoint reports every bucket, so this is authoritative:
// a window it omits really is gone.
yield* usageStore.set(instanceId, usage, "full", { ifGenerationIs: generation });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium provider/ProviderUsageRefresher.ts:146

When an instance is rebuilt after usageStore.clear() frees its debounce state, a refresh that was already in flight can call claimRefreshSlot(instanceId) against the rebuilt instance. The eventual set is correctly rejected by the generation guard, but the claim is not released — so the rebuilt instance's own refresh is suppressed for the full 60-second debounce window and its meters stay blank or stale. The claim should be released when the guarded set is rejected, or made generation-aware so it does not suppress a rebuilt instance.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/ProviderUsageRefresher.ts around line 146:

When an instance is rebuilt after `usageStore.clear()` frees its debounce state, a refresh that was already in flight can call `claimRefreshSlot(instanceId)` against the rebuilt instance. The eventual `set` is correctly rejected by the generation guard, but the claim is not released — so the rebuilt instance's own refresh is suppressed for the full 60-second debounce window and its meters stay blank or stale. The claim should be released when the guarded `set` is rejected, or made generation-aware so it does not suppress a rebuilt instance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant