feat(chat): a usage limit pauses the queue and offers to resume at the reset

Merged
AV
11d ago
+2755-36

Stacked on chat-shell-intent-rows → chat-github-work-item-chip.

What

When the provider refuses a turn for the account's usage, a banner sits on the composer: an amber gauge, 5-hour limit reached (or Weekly / Weekly Opus / Weekly Sonnet / Extra usage, or plain "Usage limit reached" when the window is unknown), one line "Sonnet 5.5 · avi@… · Resets in 2m · 12:15 AM", and the composer's pill controls on the right — Account ▾ (the host's logins for the agent with plan and tightest window, the current one checked; picking another makes it the default and reopens the chat on it; plus "Usage settings"), Resume / Resume at reset (→ "Resuming at reset", click again to cancel), and a dismiss. Prompts sent while limited queue instead of hitting the wall again. In the transcript the failed turn is one card ("Stopped by the usage limit" + the agent's words) instead of the same error twice in red.

How

  • Protocol (@superset/chat): SessionState.usageLimit?: { hitAtMs, resetsAtMs?, resumeAtReset?, window?, model? } and a new respondToUsageLimit({ action }) command with resume | dismiss | resume_at_reset | cancel_resume_at_reset. Both additive; released clients are unaffected.
  • ACP adapter (the path every new chat uses): reads the agent's usage_update._meta — _claude/rateLimit (a rejected window not on overage is remembered, with resetsAt seconds → ms and rateLimitType) and _claude/model and reports the limit when the prompt is refused. A refused prompt's turn error now carries the agent's message without the session/prompt: prefix.
  • Claude stream translator (legacy claude-code sessions): rate_limit_event is no longer ignored; same rule. Shared harness/usageLimit helper for both.
  • Live session: on a limit, the queue pauses and new prompts queue. resume continues the cut-short work — the next queued prompt, or a plain "Continue" when nothing is queued. resume_at_reset arms a host-side timer (reset + 30 s grace), so it happens with the window closed. dismiss drops the notice and leaves the queue paused. A plain queue resume also clears the limit. Any harness whose turn fails with the limit in its error text gets the fallback without a reset time.
  • Renderer: UsageLimitNotice in the Composer (window/model/account facts, formatResetLabel countdown ticking every 5 s inside the last two minutes, account switcher over usage.quota + usage.setDefaultAccount, then AcpChatPane's crash-recovery restart so the chat reopens on the new login; modelDisplayName maps the id through the agent's model list or reads it as a name); TurnStatusRow becomes a card with icon + title + message; transcriptRows drops the error notice that duplicates the turn's own error.
  • Eighteen new strings in all 16 locales.

Verified

  • Unit: chat-runtime 280+ (5 live-session tests: pause + resume delivers the queue, resume with an empty queue sends "Continue", dismiss keeps the pause, resume-at-reset fires and can be cancelled, error-text fallback; 4 translator tests; 2 ACP adapter tests), chat 69, desktop ChatSession 249 (notice ×3, status row ×3, row dedupe); tsc for desktop/chat/chat-runtime; Biome; check:i18n.
  • End to end in the dev app with a stand-in ACP agent that refuses the first prompt the way Claude does (a usage_update carrying _claude/rateLimit: rejected, then the prompt failing with "You've hit your usage limit"): the notice appeared with the reset time, "Resume at reset" armed and flipped to "Resuming at reset", the notice turned into "Limit has reset · Resume" at the reset, and 30 s later the host sent "Continue" on its own and the agent replied. Screenshots in /tmp/ul-shot-{3,4,6,9}.png.
  • Live with the stand-in agent reporting rateLimitType + _claude/model: the notice read "5-hour limit · Sonnet 5.5 · avi@superset.sh", and Switch account listed both logins with plan and tightest window (/tmp/ul-shot-10.png, -11.png). Not exercised: actually picking another account — that changes the machine's default login, so it was left for a hand test.
  • Not verified against a real Anthropic rate limit; the rate_limit_info shape is the one the CLI emits in the recorded fixtures (status, resetsAt in seconds, isUsingOverage).

Summary by cubic

When the provider refuses a turn for the account's usage, the chat session now pauses its queue and shows an amber notice on the composer instead of dropping the refusal and failing the turn twice in red.

  • The notice names the limit window, model, and account — the account button shows the current login's email rather than always "Account" — with Resume, Resume at reset, dismiss, and a Switch account menu that reopens the chat on the new login.
  • The queue pauses before the refused turn, so prompts already queued no longer hit the wall; queued work also survives an account switch, and resuming continues the cut-short work or sends a plain "Continue" when nothing is queued.
  • Resume at reset fires from a host-side timer 30 s past the reset and is cancelled when the session ends.
  • Both Claude paths report the limit; a failed turn renders as one card with the agent's own words.
  • respondToUsageLimit and SessionState.usageLimit are additive, so released clients are unaffected.