fix(x): show inline billing error notice instead of blank turn

Billing errors (out of credits, subscription required/inactive) were
rendered as null in the transcript, so if the upgrade dialog was
dismissed or missed the turn ended silently blank. Render an inline
BillingErrorNotice in both the main chat and the chat sidebar instead.

Also watch the sessions-runtime conversation (not just legacy state)
for billing failures, and track handled error ids in a Set so a
previously seen error doesn't block newer ones from popping the dialog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hrsvrn 2026-07-24 14:23:29 +05:30
parent f0e8e51a0e
commit cc5819b469
3 changed files with 48 additions and 21 deletions

View file

@ -81,6 +81,7 @@ import { Button } from "@/components/ui/button"
import { Toaster } from "@/components/ui/sonner"
import { UpdateCard } from "@/components/update-card"
import { BillingErrorDialog } from "@/components/billing-error-dialog"
import { BillingErrorNotice } from "@/components/billing-error-notice"
import { CreditCelebration } from "@/components/credit-celebration"
import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error"
import { dispatchCreditExhausted, dispatchCreditReplenished } from "@/lib/credit-status"
@ -954,30 +955,34 @@ function App() {
const [conversation, setConversation] = useState<ConversationItem[]>([])
const [billingErrorMatch, setBillingErrorMatch] = useState<BillingErrorMatch | null>(null)
const [billingErrorOpen, setBillingErrorOpen] = useState(false)
const lastHandledBillingErrorIdRef = useRef<string | null>(null)
const handledBillingErrorIdsRef = useRef<Set<string>>(new Set())
const [currentAssistantMessage, setCurrentAssistantMessage] = useState<string>('')
useEffect(() => {
for (let i = conversation.length - 1; i >= 0; i--) {
const item = conversation[i]
if (!isErrorMessage(item)) continue
if (item.id === lastHandledBillingErrorIdRef.current) return
const match = matchBillingError(item.message)
if (match) {
lastHandledBillingErrorIdRef.current = item.id
setBillingErrorMatch(match)
setBillingErrorOpen(true)
if (match.kind === 'out_of_credits') dispatchCreditExhausted()
}
return
}
}, [conversation])
const [, setModelUsage] = useState<UsageSummary | null>(null)
const [runId, setRunId] = useState<string | null>(null)
// New runtime: the active session's chat data + actions. All logic lives in
// SessionChatStore (tested headlessly); the hook is a thin subscription.
// runId IS the session id in the sessions runtime.
const sessionChat = useSessionChat(runId)
// Watch the conversation that is actually rendered — the sessions-runtime
// one when loaded, the legacy state otherwise — so billing failures
// (out of credits, subscription lapsed) always pop the upgrade dialog.
const billingWatchedConversation = sessionChat.chatState?.conversation ?? conversation
useEffect(() => {
for (let i = billingWatchedConversation.length - 1; i >= 0; i--) {
const item = billingWatchedConversation[i]
if (!isErrorMessage(item)) continue
if (handledBillingErrorIdsRef.current.has(item.id)) return
const match = matchBillingError(item.message)
if (match) {
handledBillingErrorIdsRef.current.add(item.id)
setBillingErrorMatch(match)
setBillingErrorOpen(true)
if (match.kind === 'out_of_credits') dispatchCreditExhausted()
}
return
}
}, [billingWatchedConversation])
const runIdRef = useRef<string | null>(null)
const loadRunRequestIdRef = useRef(0)
const [isProcessing, setIsProcessing] = useState(false)
@ -6167,8 +6172,9 @@ function App() {
}
if (isErrorMessage(item)) {
if (matchBillingError(item.message)) {
return null
const billingMatch = matchBillingError(item.message)
if (billingMatch) {
return <BillingErrorNotice key={item.id} id={item.id} match={billingMatch} />
}
return (
<Message key={item.id} from="assistant" data-message-id={item.id}>

View file

@ -0,0 +1,19 @@
import { Message, MessageContent } from '@/components/ai-elements/message'
import type { BillingErrorMatch } from '@/lib/billing-error'
/**
* Inline transcript notice for billing failures (out of credits, subscription
* required/inactive). The BillingErrorDialog is the attention-grabber; this
* stays in the conversation so the turn never ends silently blank if the
* dialog is dismissed or missed.
*/
export function BillingErrorNotice({ id, match }: { id: string; match: BillingErrorMatch }) {
return (
<Message from="assistant" data-message-id={id}>
<MessageContent className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3">
<p className="text-sm font-medium text-foreground">{match.title}</p>
<p className="mt-1 text-xs text-muted-foreground">{match.subtitle}</p>
</MessageContent>
</Message>
)
}

View file

@ -60,6 +60,7 @@ import {
toToolState,
} from '@/lib/chat-conversation'
import { matchBillingError } from '@/lib/billing-error'
import { BillingErrorNotice } from '@/components/billing-error-notice'
import { TokenUsageMenu } from '@/components/token-usage-menu'
const streamdownComponents = { pre: MarkdownPreOverride }
@ -510,8 +511,9 @@ export function ChatSidebar({
}
if (isErrorMessage(item)) {
if (matchBillingError(item.message)) {
return null
const billingMatch = matchBillingError(item.message)
if (billingMatch) {
return <BillingErrorNotice key={item.id} id={item.id} match={billingMatch} />
}
return (
<Message key={item.id} from="assistant" data-message-id={item.id}>