mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
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:
parent
f0e8e51a0e
commit
cc5819b469
3 changed files with 48 additions and 21 deletions
|
|
@ -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}>
|
||||
|
|
|
|||
19
apps/x/apps/renderer/src/components/billing-error-notice.tsx
Normal file
19
apps/x/apps/renderer/src/components/billing-error-notice.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue