From d0c4a82da97cf1d8605f1bb20f9ab74141be7373 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 18:09:08 -0500 Subject: [PATCH] feat(desktop): billing toast + in-chat status-row banner with smart CTA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../app/chat/composer/status-stack/index.tsx | 10 +++ apps/desktop/src/app/contrib/wiring.tsx | 18 ++++ .../hooks/use-message-stream/gateway-event.ts | 55 ++++++++++++ .../desktop/src/components/billing-banner.tsx | 72 ++++++++++++++++ apps/desktop/src/i18n/en.ts | 9 ++ apps/desktop/src/i18n/ja.ts | 9 ++ apps/desktop/src/i18n/types.ts | 9 ++ apps/desktop/src/i18n/zh-hant.ts | 9 ++ apps/desktop/src/i18n/zh.ts | 9 ++ apps/desktop/src/lib/chat-messages.ts | 5 ++ apps/desktop/src/store/billing-block.test.ts | 86 +++++++++++++++++++ apps/desktop/src/store/billing-block.ts | 79 +++++++++++++++++ 12 files changed, 370 insertions(+) create mode 100644 apps/desktop/src/components/billing-banner.tsx create mode 100644 apps/desktop/src/store/billing-block.test.ts create mode 100644 apps/desktop/src/store/billing-block.ts diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index e8107764d88..739080be8b8 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -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: }) + } + for (const group of groups) { sections.push({ key: group.type, diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index b2d0c03a988..6c36e452e6e 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -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()) // 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) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 5b8fccd8bb0..7cc7d2a9f92 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -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) diff --git a/apps/desktop/src/components/billing-banner.tsx b/apps/desktop/src/components/billing-banner.tsx new file mode 100644 index 00000000000..73f7107decb --- /dev/null +++ b/apps/desktop/src/components/billing-banner.tsx @@ -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 ( + } + trailing={ + <> + + + + + + } + trailingVisible + > + + {title} + {message && · {message}} + + + ) +} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 79351a81517..53802fcd8cd 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index afa02b44bf8..c712198e613 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -178,6 +178,15 @@ export const ja = defineLocale({ `ソフトウェアレンダリングが有効です — リモートディスプレイを検出しました(${reason})。ちらつきを防ぐため GPU アクセラレーションは無効化されています。` }, + billingBlock: { + titleNous: 'Nous クレジットが不足しています', + titleProvider: provider => `クレジット不足 — ${provider}`, + fallbackMessage: 'アカウントのクレジットが不足しています。続行するにはクレジットを追加してください。', + openBilling: '請求を開く', + addCredits: 'クレジットを追加', + dismiss: '閉じる' + }, + titlebar: { hideSidebar: 'サイドバーを非表示', showSidebar: 'サイドバーを表示', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 4ba7fc84c38..18f96afb90d 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index e3c382a8ef5..c19c10bb476 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -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: '顯示側邊欄', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 578a6faa740..8515ec15f42 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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: '显示侧边栏', diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 74beafcffd6..828021b21ea 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -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 { diff --git a/apps/desktop/src/store/billing-block.test.ts b/apps/desktop/src/store/billing-block.test.ts new file mode 100644 index 00000000000..24d2f97402f --- /dev/null +++ b/apps/desktop/src/store/billing-block.test.ts @@ -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 { + 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') +}) diff --git a/apps/desktop/src/store/billing-block.ts b/apps/desktop/src/store/billing-block.ts new file mode 100644 index 00000000000..4d640302b63 --- /dev/null +++ b/apps/desktop/src/store/billing-block.ts @@ -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(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 +}