feat(desktop): billing toast + in-chat status-row banner with smart CTA

On a billing wall, raise a sticky, billing-specific toast (never the generic
error toast) and a persistent in-composer banner for the active session — both
with one recovery action: Nous → in-app Settings → Billing, other providers →
their billing page (deep-linked). The banner reuses the shared StatusRow chrome
(no bordered alert, Codicon glyph, shared buttons), and the composer stays usable
so slash commands keep working.
This commit is contained in:
Brooklyn Nicholson 2026-07-22 18:09:08 -05:00
parent 960d339f86
commit d0c4a82da9
12 changed files with 370 additions and 0 deletions

View file

@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'
import { blurComposerInput } from '@/app/chat/composer/focus'
import { AGENTS_ROUTE } from '@/app/routes'
import { BillingBanner } from '@/components/billing-banner'
import { composerDockCard } from '@/components/chat/composer-dock'
import { StatusSection } from '@/components/chat/status-section'
import { Button } from '@/components/ui/button'
@ -11,6 +12,7 @@ import { Codicon } from '@/components/ui/codicon'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $billingBlock } from '@/store/billing-block'
import {
$statusItemsBySession,
type ComposerStatusItem,
@ -70,6 +72,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
const itemsBySession = useStore($statusItemsBySession)
const previewsBySession = useStore($previewStatusBySession)
const scrolledUp = useStore($threadScrolledUp)
const billing = useStore($billingBlock)
const groups = useMemo(
() => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []),
@ -123,6 +126,13 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
const sections: { key: string; node: ReactNode }[] = []
// Billing wall sits at the very top of the stack — it's the most important
// thing above the composer when the account is out of credits. Rendered here
// (not as a composer-disable) so slash commands stay usable.
if (billing && sessionId && billing.sessionId === sessionId) {
sections.push({ key: 'billing', node: <BillingBanner sessionId={sessionId} /> })
}
for (const group of groups) {
sections.push({
key: group.type,

View file

@ -27,6 +27,7 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat
import { sessionMessagesSignature } from '@/lib/session-signatures'
import { isMessagingSource } from '@/lib/session-source'
import { latestSessionTodos } from '@/lib/todos'
import { $billingSettingsRequest } from '@/store/billing-block'
import { setCronFocusJobId } from '@/store/cron'
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
@ -126,6 +127,10 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const busyRef = useRef(false)
const creatingSessionRef = useRef(false)
// Billing recovery routes to Settings → Billing from surfaces without router
// context (the sticky toast). The shell owns `navigate`, so it consumes the
// intent counter here; the ref skips the initial mount value.
const billingSettingsSeenRef = useRef(0)
const messagingTranscriptSignatureRef = useRef(new Map<string, string>())
// Stable identity for the whole callback surface (see WiringActions). Mutated
// in place each render so memoized surfaces never re-render on churn.
@ -133,7 +138,20 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const gatewayState = useStore($gatewayState)
const activeSessionId = useStore($activeSessionId)
const billingSettingsRequest = useStore($billingSettingsRequest)
const currentCwd = useStore($currentCwd)
useEffect(() => {
if (billingSettingsRequest === billingSettingsSeenRef.current) {
return
}
billingSettingsSeenRef.current = billingSettingsRequest
if (billingSettingsRequest > 0) {
navigate(`${SETTINGS_ROUTE}?tab=billing`)
}
}, [billingSettingsRequest, navigate])
const freshDraftReady = useStore($freshDraftReady)
const resumeFailedSessionId = useStore($resumeFailedSessionId)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)

View file

@ -1,3 +1,4 @@
import type { BillingBlock } from '@hermes/shared'
import type { HermesSkin } from '@hermes/shared/skin'
import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
@ -15,6 +16,7 @@ import { triggerHaptic } from '@/lib/haptics'
import { modelOptionsQueryKey } from '@/lib/model-options'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block'
import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'
import { setSessionCompacting } from '@/store/compaction'
import { refreshBackgroundProcesses } from '@/store/composer-status'
@ -57,6 +59,50 @@ import type { ClientSessionState } from '../../../types'
import { hasSessionInfoStatePatch, sessionInfoStatePatch, SUBAGENT_EVENT_TYPES, toTodoPayload } from './utils'
function firstBillingLine(text: string): string {
return (text || '').split('\n')[0]?.trim() ?? ''
}
/**
* A turn failed on a billing wall (out of credits / payment required). The
* gateway forwards the structured descriptor built by `agent/billing_links.py`;
* we cache it per-session (drives the in-chat banner) AND raise one sticky,
* billing-specific toast never the generic "Hermes error" with a smart CTA
* (Nous in-app Settings Billing, other providers their billing page).
*/
function surfaceBillingBlock(sessionId: string, raw: unknown): void {
if (!raw || typeof raw !== 'object') {
return
}
const block = raw as BillingBlock
if (typeof block.provider !== 'string') {
return
}
setBillingBlock(sessionId, block)
const ctaCopy = {
addCredits: translateNow('billingBlock.addCredits'),
openBilling: translateNow('billingBlock.openBilling')
}
notify({
// Collapse repeat walls from the same provider into one toast.
id: `billing-block:${block.provider}`,
kind: 'warning',
icon: 'credit-card',
title: block.is_nous
? translateNow('billingBlock.titleNous')
: translateNow('billingBlock.titleProvider', block.provider_label),
message: firstBillingLine(block.message) || translateNow('billingBlock.fallbackMessage'),
// Sticky: a credit wall blocks every turn until resolved.
durationMs: 0,
action: { label: billingCtaLabel(block, ctaCopy), onClick: () => runBillingRecovery(block) }
})
}
const COMPACTION_RESUME_EVENT_TYPES = new Set([
'message.delta',
'message.interim',
@ -390,6 +436,9 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
setSessionCompacting(sessionId, false)
compactedTurnRef.current.delete(sessionId)
nativeSubagentSessionsRef.current.delete(sessionId)
// A fresh turn on this session optimistically clears its billing wall;
// if credits are still exhausted the next failure re-raises it.
clearBillingBlock(sessionId)
if (isActiveEvent) {
triggerHaptic('streamStart')
@ -513,6 +562,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
completeAssistantMessage(sessionId, finalText, payload?.response_previewed)
// Structured billing wall forwarded by the gateway (out of credits /
// payment required) — cache it + raise a billing-specific toast.
if (payload?.billing) {
surfaceBillingBlock(sessionId, payload.billing)
}
if (isActiveEvent) {
setTurnStartedAt(null)

View file

@ -0,0 +1,72 @@
import { useStore } from '@nanostores/react'
import { StatusRow } from '@/components/chat/status-row'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { $billingBlock, billingCtaLabel, clearBillingBlock, runBillingRecovery } from '@/store/billing-block'
function firstLine(text: string): string {
return (text || '').split('\n')[0]?.trim() ?? ''
}
/**
* Persistent, in-stack billing wall for THIS session. Rendered as a shared
* {@link StatusRow} same chrome as its status-stack siblings, so it reads as
* one piece with the composer card (no bordered alert-in-a-card). It never
* disables the composer slash commands (`/topup`, `/model`, `/login`) stay
* usable it only offers recovery: Nous opens Settings Billing in-app, other
* providers deep-link out. The sticky toast is the loud surface; this is the calm
* reminder that outlives it.
*/
export function BillingBanner({ sessionId }: { sessionId: null | string }) {
const active = useStore($billingBlock)
const { t } = useI18n()
if (!active || !sessionId || active.sessionId !== sessionId) {
return null
}
const { block } = active
const copy = t.billingBlock
const title = block.is_nous ? copy.titleNous : copy.titleProvider(block.provider_label)
const message = firstLine(block.message) || copy.fallbackMessage
return (
<StatusRow
leading={<Codicon aria-hidden className="text-destructive/85" name="credit-card" size="0.8rem" />}
trailing={
<>
<Button
className="text-foreground/90 hover:text-foreground"
onClick={() => runBillingRecovery(block)}
size="micro"
type="button"
variant="text"
>
{billingCtaLabel(block, copy)}
</Button>
<Tip label={copy.dismiss}>
<Button
aria-label={copy.dismiss}
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={() => clearBillingBlock(sessionId)}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
</Button>
</Tip>
</>
}
trailingVisible
>
<span className="min-w-0 truncate text-[0.73rem] leading-4 text-foreground/92">
<span className="font-medium">{title}</span>
{message && <span className="text-muted-foreground/80"> · {message}</span>}
</span>
</StatusRow>
)
}

View file

@ -177,6 +177,15 @@ export const en: Translations = {
`Software rendering active — remote display detected (${reason}). GPU acceleration is disabled to prevent flickering.`
},
billingBlock: {
titleNous: 'Out of Nous credits',
titleProvider: provider => `Out of credits — ${provider}`,
fallbackMessage: 'Your account is out of credits. Add credits to keep going.',
openBilling: 'Open billing',
addCredits: 'Add credits',
dismiss: 'Dismiss'
},
titlebar: {
hideSidebar: 'Hide sidebar',
showSidebar: 'Show sidebar',

View file

@ -178,6 +178,15 @@ export const ja = defineLocale({
`ソフトウェアレンダリングが有効です — リモートディスプレイを検出しました(${reason})。ちらつきを防ぐため GPU アクセラレーションは無効化されています。`
},
billingBlock: {
titleNous: 'Nous クレジットが不足しています',
titleProvider: provider => `クレジット不足 — ${provider}`,
fallbackMessage: 'アカウントのクレジットが不足しています。続行するにはクレジットを追加してください。',
openBilling: '請求を開く',
addCredits: 'クレジットを追加',
dismiss: '閉じる'
},
titlebar: {
hideSidebar: 'サイドバーを非表示',
showSidebar: 'サイドバーを表示',

View file

@ -218,6 +218,15 @@ export interface Translations {
message: (reason: string) => string
}
billingBlock: {
titleNous: string
titleProvider: (provider: string) => string
fallbackMessage: string
openBilling: string
addCredits: string
dismiss: string
}
titlebar: {
hideSidebar: string
showSidebar: string

View file

@ -172,6 +172,15 @@ export const zhHant = defineLocale({
message: reason => `軟體繪圖已啟用 — 偵測到遠端顯示(${reason})。為防止畫面閃爍,已停用 GPU 加速。`
},
billingBlock: {
titleNous: 'Nous 額度已用盡',
titleProvider: provider => `額度已用盡 — ${provider}`,
fallbackMessage: '您的帳戶額度已用盡。請儲值以繼續使用。',
openBilling: '開啟帳單',
addCredits: '新增額度',
dismiss: '忽略'
},
titlebar: {
hideSidebar: '隱藏側邊欄',
showSidebar: '顯示側邊欄',

View file

@ -172,6 +172,15 @@ export const zh: Translations = {
message: reason => `软件渲染已启用 — 检测到远程显示(${reason})。为防止画面闪烁,已禁用 GPU 加速。`
},
billingBlock: {
titleNous: 'Nous 额度已用尽',
titleProvider: provider => `额度已用尽 — ${provider}`,
fallbackMessage: '您的账户额度已用尽。请充值以继续使用。',
openBilling: '打开账单',
addCredits: '添加额度',
dismiss: '忽略'
},
titlebar: {
hideSidebar: '隐藏侧边栏',
showSidebar: '显示侧边栏',

View file

@ -1,4 +1,5 @@
import type { ThreadMessageLike } from '@assistant-ui/react'
import type { BillingBlock } from '@hermes/shared'
import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images'
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
@ -96,6 +97,10 @@ export type GatewayEventPayload = {
// message.complete — signals the final text was already previewed via
// interim_assistant_callback, so the UI can settle instead of duplicating.
response_previewed?: boolean
// Structured billing wall forwarded on message.complete when a turn fails
// with FailoverReason.billing (shape mirrors @hermes/shared BillingBlock).
billing?: BillingBlock
failure_reason?: string
}
export function textPart(text: string): ChatMessagePart {

View file

@ -0,0 +1,86 @@
import type { BillingBlock } from '@hermes/shared'
import { beforeEach, expect, test, vi } from 'vitest'
vi.mock('@/lib/external-link', () => ({ openExternalLink: vi.fn() }))
import { openExternalLink } from '@/lib/external-link'
import {
$billingBlock,
$billingSettingsRequest,
billingCtaLabel,
clearBillingBlock,
requestBillingSettings,
runBillingRecovery,
setBillingBlock
} from './billing-block'
function makeBlock(overrides: Partial<BillingBlock> = {}): BillingBlock {
return {
billing_url: 'https://platform.openai.com/settings/organization/billing',
is_nous: false,
message: 'You are out of credits.',
model: 'gpt-5',
provider: 'openai',
provider_label: 'OpenAI',
...overrides
}
}
beforeEach(() => {
$billingBlock.set(null)
$billingSettingsRequest.set(0)
vi.clearAllMocks()
})
test('setBillingBlock stores the block against its session', () => {
setBillingBlock('s1', makeBlock())
expect($billingBlock.get()?.sessionId).toBe('s1')
expect($billingBlock.get()?.block.provider).toBe('openai')
})
test('clearBillingBlock scoped to a session leaves a different session block intact', () => {
setBillingBlock('s1', makeBlock())
clearBillingBlock('s2')
expect($billingBlock.get()).not.toBeNull()
clearBillingBlock('s1')
expect($billingBlock.get()).toBeNull()
})
test('clearBillingBlock with no arg clears any active block', () => {
setBillingBlock('s1', makeBlock())
clearBillingBlock()
expect($billingBlock.get()).toBeNull()
})
test('runBillingRecovery routes Nous to in-app Settings, never an external link', () => {
runBillingRecovery(makeBlock({ is_nous: true, provider: 'nous', provider_label: 'Nous Portal' }))
expect($billingSettingsRequest.get()).toBe(1)
expect(openExternalLink).not.toHaveBeenCalled()
})
test('runBillingRecovery deep-links a third-party provider to its billing page', () => {
const block = makeBlock({ billing_url: 'https://openrouter.ai/settings/credits', provider: 'openrouter' })
runBillingRecovery(block)
expect(openExternalLink).toHaveBeenCalledWith('https://openrouter.ai/settings/credits')
expect($billingSettingsRequest.get()).toBe(0)
})
test('runBillingRecovery falls back to in-app settings when a provider has no URL', () => {
runBillingRecovery(makeBlock({ billing_url: null, provider: 'custom' }))
expect(openExternalLink).not.toHaveBeenCalled()
expect($billingSettingsRequest.get()).toBe(1)
})
test('requestBillingSettings increments the intent counter', () => {
requestBillingSettings()
requestBillingSettings()
expect($billingSettingsRequest.get()).toBe(2)
})
test('billingCtaLabel picks the right verb per route', () => {
const copy = { addCredits: 'Add credits', openBilling: 'Open billing' }
expect(billingCtaLabel(makeBlock({ is_nous: true }), copy)).toBe('Open billing')
expect(billingCtaLabel(makeBlock({ is_nous: false }), copy)).toBe('Add credits')
})

View file

@ -0,0 +1,79 @@
import type { BillingBlock } from '@hermes/shared'
import { atom } from 'nanostores'
import { openExternalLink } from '@/lib/external-link'
/**
* The active inference billing wall, if any. Set from the gateway
* `message.complete` / `error` event when a turn fails with
* `FailoverReason.billing` (see `agent/billing_links.py`). One global slot: a
* credit wall on the active session's provider is the whole app's problem, and
* the newest block wins. Cleared when a new turn starts or the user dismisses.
*/
export interface ActiveBillingBlock {
block: BillingBlock
sessionId: string
at: number
}
export const $billingBlock = atom<ActiveBillingBlock | null>(null)
/**
* Navigation intent counter. A toast fired outside React (or any surface
* without router context) bumps this to ask the shell which owns
* `useNavigate` to open Settings Billing in-app. See `contrib/wiring.tsx`.
*/
export const $billingSettingsRequest = atom(0)
export function setBillingBlock(sessionId: string, block: BillingBlock): void {
$billingBlock.set({ at: Date.now(), block, sessionId })
}
export function clearBillingBlock(sessionId?: string): void {
const current = $billingBlock.get()
if (!current) {
return
}
// A scoped clear (new turn on session X) must not wipe a block raised by a
// different session's provider.
if (sessionId && current.sessionId !== sessionId) {
return
}
$billingBlock.set(null)
}
export function requestBillingSettings(): void {
$billingSettingsRequest.set($billingSettingsRequest.get() + 1)
}
/**
* The single recovery action for a billing wall, shared by the toast and the
* in-chat banner so both behave identically: Nous routes to the in-app
* Settings Billing surface; a third-party provider deep-links to its own
* billing page (falling back to the in-app surface only if we have no URL).
*/
export function runBillingRecovery(block: BillingBlock): void {
if (block.is_nous) {
requestBillingSettings()
return
}
if (block.billing_url) {
openExternalLink(block.billing_url)
return
}
requestBillingSettings()
}
export function billingCtaLabel(
block: BillingBlock,
copy: { addCredits: string; openBilling: string }
): string {
return block.is_nous ? copy.openBilling : copy.addCredits
}