From cc5819b469be9a8068f2ddf053f98c7d67f82802 Mon Sep 17 00:00:00 2001 From: hrsvrn Date: Fri, 24 Jul 2026 14:23:29 +0530 Subject: [PATCH] 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 --- apps/x/apps/renderer/src/App.tsx | 44 +++++++++++-------- .../src/components/billing-error-notice.tsx | 19 ++++++++ .../renderer/src/components/chat-sidebar.tsx | 6 ++- 3 files changed, 48 insertions(+), 21 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/billing-error-notice.tsx diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 58b78d14..711c5c27 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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([]) const [billingErrorMatch, setBillingErrorMatch] = useState(null) const [billingErrorOpen, setBillingErrorOpen] = useState(false) - const lastHandledBillingErrorIdRef = useRef(null) + const handledBillingErrorIdsRef = useRef>(new Set()) const [currentAssistantMessage, setCurrentAssistantMessage] = useState('') - - 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(null) const [runId, setRunId] = useState(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(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 } return ( diff --git a/apps/x/apps/renderer/src/components/billing-error-notice.tsx b/apps/x/apps/renderer/src/components/billing-error-notice.tsx new file mode 100644 index 00000000..df871edb --- /dev/null +++ b/apps/x/apps/renderer/src/components/billing-error-notice.tsx @@ -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 ( + + +

{match.title}

+

{match.subtitle}

+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index b3d5850c..0a7a327b 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -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 } return (