Merge pull request #790 from rowboatlabs/fix/billing-error-notice

fix(x): show inline billing error notice instead of blank turn
This commit is contained in:
Ramnique Singh 2026-07-24 16:00:33 +05:30 committed by GitHub
commit f8f5c8614f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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}>