From 5be0a11f9830d10c607a5ecefad12f44efe48a87 Mon Sep 17 00:00:00 2001 From: gagan Date: Fri, 22 May 2026 00:20:35 +0530 Subject: [PATCH] feat: show out-of-credits dialog instead of raw API error (#568) * feat: show billing error dialog on out-of-credits errors * fix: suppress chat dump and toast when billing dialog shows --- apps/x/apps/renderer/src/App.tsx | 32 ++++++++- .../src/components/billing-error-dialog.tsx | 61 ++++++++++++++++ .../renderer/src/components/chat-sidebar.tsx | 70 +------------------ apps/x/apps/renderer/src/lib/billing-error.ts | 26 +++++++ 4 files changed, 121 insertions(+), 68 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/billing-error-dialog.tsx create mode 100644 apps/x/apps/renderer/src/lib/billing-error.ts diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index e6c050b3..79a2b266 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -63,6 +63,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { Toaster } from "@/components/ui/sonner" +import { BillingErrorDialog } from "@/components/billing-error-dialog" +import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { extractConferenceLink } from '@/lib/calendar-event' @@ -793,7 +795,25 @@ function App() { // Chat state const [, setMessage] = useState('') const [conversation, setConversation] = useState([]) + const [billingErrorMatch, setBillingErrorMatch] = useState(null) + const [billingErrorOpen, setBillingErrorOpen] = useState(false) + const lastHandledBillingErrorIdRef = useRef(null) 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) + } + return + } + }, [conversation]) const [, setModelUsage] = useState(null) const [runId, setRunId] = useState(null) const runIdRef = useRef(null) @@ -2271,7 +2291,9 @@ function App() { message: event.error, timestamp: Date.now(), }]) - toast.error(event.error.split('\n')[0] || 'Model error') + if (!matchBillingError(event.error)) { + toast.error(event.error.split('\n')[0] || 'Model error') + } console.error('Run error:', event.error) break } @@ -4629,6 +4651,9 @@ function App() { } if (isErrorMessage(item)) { + if (matchBillingError(item.message)) { + return null + } return ( @@ -5399,6 +5424,11 @@ function App() { /> + void +} + +export function BillingErrorDialog({ open, match, onOpenChange }: BillingErrorDialogProps) { + const [appUrl, setAppUrl] = useState(null) + + useEffect(() => { + if (!open) return + window.ipc + .invoke('account:getRowboat', null) + .then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null)) + .catch(() => {}) + }, [open]) + + if (!match) return null + + const handleUpgrade = () => { + if (appUrl) window.open(`${appUrl}?intent=upgrade`) + onOpenChange(false) + } + + 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 06680652..3b4021c8 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -52,6 +52,7 @@ import { parseAttachedFiles, toToolState, } from '@/lib/chat-conversation' +import { matchBillingError } from '@/lib/billing-error' const streamdownComponents = { pre: MarkdownPreOverride } @@ -85,60 +86,6 @@ function AutoScrollPre({ className, children }: { className?: string; children: ) } -/* ─── Billing error helpers ─── */ - -const BILLING_ERROR_PATTERNS = [ - { - pattern: /upgrade required/i, - title: 'A subscription is required', - subtitle: 'Get started with a plan to access AI features in Rowboat.', - cta: 'Subscribe', - }, - { - pattern: /not enough credits/i, - title: 'You\'ve run out of credits', - subtitle: 'Upgrade your plan for more credits. Free usage resets daily at 00:00 UTC.', - cta: 'Upgrade plan', - }, - { - pattern: /subscription not active/i, - title: 'Your subscription is inactive', - subtitle: 'Reactivate your subscription to continue using AI features.', - cta: 'Reactivate', - }, -] as const - -function matchBillingError(message: string) { - return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null -} - -interface BillingRowboatAccount { - config?: { - appUrl?: string | null - } | null -} - -function BillingErrorCTA({ label }: { label: string }) { - const [appUrl, setAppUrl] = useState(null) - - useEffect(() => { - window.ipc.invoke('account:getRowboat', null) - .then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null)) - .catch(() => {}) - }, []) - - if (!appUrl) return null - - return ( - - ) -} - const MIN_WIDTH = 360 const MAX_WIDTH = 1600 const MIN_MAIN_PANE_WIDTH = 420 @@ -491,19 +438,8 @@ export function ChatSidebar({ } if (isErrorMessage(item)) { - const billingError = matchBillingError(item.message) - if (billingError) { - return ( - - -
-

{billingError.title}

-

{billingError.subtitle}

- -
-
-
- ) + if (matchBillingError(item.message)) { + return null } return ( diff --git a/apps/x/apps/renderer/src/lib/billing-error.ts b/apps/x/apps/renderer/src/lib/billing-error.ts new file mode 100644 index 00000000..ffb77470 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/billing-error.ts @@ -0,0 +1,26 @@ +export const BILLING_ERROR_PATTERNS = [ + { + pattern: /upgrade required/i, + title: 'A subscription is required', + subtitle: 'Get started with a plan to access AI features in Rowboat.', + cta: 'Subscribe', + }, + { + pattern: /not enough credits/i, + title: "You've run out of credits", + subtitle: 'Upgrade your plan for more credits. Free usage resets daily at 00:00 UTC.', + cta: 'Upgrade plan', + }, + { + pattern: /subscription not active/i, + title: 'Your subscription is inactive', + subtitle: 'Reactivate your subscription to continue using AI features.', + cta: 'Reactivate', + }, +] as const + +export type BillingErrorMatch = (typeof BILLING_ERROR_PATTERNS)[number] + +export function matchBillingError(message: string): BillingErrorMatch | null { + return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null +}