feat: add out-of-credits warning to sidebar plan badge

Show a warning state + popover on the sidebar plan badge when the user is
out of credits, prompting them to upgrade. Detection comes from two existing
signals: the /v1/me billing payload (daily or monthly bucket exhausted, the
same data the Settings usage bars use) and the existing 'not enough credits'
API-error path. The warning clears on a successful cost-incurring call (LLM
finish-step or a successful voice synth) and re-fetches /me once, only when
the warning was actually showing -- no polling. Popover auto-opens on first
detection and can be dismissed while the indicator persists.
This commit is contained in:
Prakhar Pandey 2026-06-23 18:11:42 +05:30
parent 6ba6f494c1
commit e1fae252bf
6 changed files with 186 additions and 27 deletions

View file

@ -76,6 +76,7 @@ import { Button } from "@/components/ui/button"
import { Toaster } from "@/components/ui/sonner" import { Toaster } from "@/components/ui/sonner"
import { BillingErrorDialog } from "@/components/billing-error-dialog" import { BillingErrorDialog } from "@/components/billing-error-dialog"
import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error" import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error"
import { dispatchCreditExhausted, dispatchCreditReplenished } from "@/lib/credit-status"
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
import { extractConferenceLink } from '@/lib/calendar-event' import { extractConferenceLink } from '@/lib/calendar-event'
@ -923,6 +924,7 @@ function App() {
lastHandledBillingErrorIdRef.current = item.id lastHandledBillingErrorIdRef.current = item.id
setBillingErrorMatch(match) setBillingErrorMatch(match)
setBillingErrorOpen(true) setBillingErrorOpen(true)
if (match.kind === 'out_of_credits') dispatchCreditExhausted()
} }
return return
} }
@ -2370,6 +2372,7 @@ function App() {
const nextUsage = normalizeUsage(llmEvent.usage) const nextUsage = normalizeUsage(llmEvent.usage)
if (nextUsage) { if (nextUsage) {
setModelUsage(nextUsage) setModelUsage(nextUsage)
dispatchCreditReplenished()
} }
} }
} }

View file

@ -22,6 +22,8 @@ import {
Settings, Settings,
Square, Square,
Video, Video,
CircleAlert,
X,
} from "lucide-react" } from "lucide-react"
import { import {
AlertDialog, AlertDialog,
@ -51,6 +53,7 @@ import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
PopoverArrow,
} from "@/components/ui/popover" } from "@/components/ui/popover"
import { import {
Tooltip, Tooltip,
@ -58,6 +61,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { isOutOfCredits, CREDIT_EXHAUSTED_EVENT, CREDIT_REPLENISHED_EVENT } from "@/lib/credit-status"
import { SettingsDialog } from "@/components/settings-dialog" import { SettingsDialog } from "@/components/settings-dialog"
import { extractConferenceLink } from "@/lib/calendar-event" import { extractConferenceLink } from "@/lib/calendar-event"
import { useBilling } from "@/hooks/useBilling" import { useBilling } from "@/hooks/useBilling"
@ -430,9 +434,13 @@ export function SidebarContentPanel({
const [openConnectionsAfterClose, setOpenConnectionsAfterClose] = useState(false) const [openConnectionsAfterClose, setOpenConnectionsAfterClose] = useState(false)
const connectorsButtonRef = useRef<HTMLButtonElement | null>(null) const connectorsButtonRef = useRef<HTMLButtonElement | null>(null)
const [isRowboatConnected, setIsRowboatConnected] = useState(false) const [isRowboatConnected, setIsRowboatConnected] = useState(false)
const [creditPopoverOpen, setCreditPopoverOpen] = useState(false)
const [outOfCredits, setOutOfCredits] = useState(false)
const outOfCreditsRef = useRef(false)
const creditPopoverAutoShownRef = useRef(false)
const [loggingIn, setLoggingIn] = useState(false) const [loggingIn, setLoggingIn] = useState(false)
const [appUrl, setAppUrl] = useState<string | null>(null) const [appUrl, setAppUrl] = useState<string | null>(null)
const { billing } = useBilling(isRowboatConnected) const { billing, refresh: refreshBilling } = useBilling(isRowboatConnected)
const currentBillingPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null const currentBillingPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null
// Nav previews: unread important emails + next upcoming meetings (top 2 each). // Nav previews: unread important emails + next upcoming meetings (top 2 each).
@ -654,6 +662,50 @@ export function SidebarContentPanel({
} }
}, []) }, [])
// Re-anchor the warning whenever billing (re)loads — billing is authoritative.
useEffect(() => {
if (billing) {
const next = isOutOfCredits(billing)
outOfCreditsRef.current = next
setOutOfCredits(next)
}
}, [billing])
// Live signals: a usage API error flips it on; a successful cost-incurring
// call flips it off and triggers a single billing refresh to reconcile.
useEffect(() => {
const onExhausted = () => {
outOfCreditsRef.current = true
setOutOfCredits(true)
}
const onReplenished = () => {
const wasOut = outOfCreditsRef.current
outOfCreditsRef.current = false
setOutOfCredits(false)
if (wasOut) void refreshBilling()
}
window.addEventListener(CREDIT_EXHAUSTED_EVENT, onExhausted)
window.addEventListener(CREDIT_REPLENISHED_EVENT, onReplenished)
return () => {
window.removeEventListener(CREDIT_EXHAUSTED_EVENT, onExhausted)
window.removeEventListener(CREDIT_REPLENISHED_EVENT, onReplenished)
}
}, [refreshBilling])
// Auto-open the popover the first time we go out of credits; reset when
// credits return so it can auto-open again on a future episode.
useEffect(() => {
if (outOfCredits) {
if (!creditPopoverAutoShownRef.current) {
creditPopoverAutoShownRef.current = true
setCreditPopoverOpen(true)
}
} else {
creditPopoverAutoShownRef.current = false
setCreditPopoverOpen(false)
}
}, [outOfCredits])
// Single preview shown as a sublabel on the Email / Meetings nav buttons. // Single preview shown as a sublabel on the Email / Meetings nav buttons.
const previewEmail = emailThreads[0] const previewEmail = emailThreads[0]
const previewMeeting = meetings[0] const previewMeeting = meetings[0]
@ -913,31 +965,89 @@ export function SidebarContentPanel({
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
{/* Billing / upgrade CTA or Log in CTA */} {/* Billing / upgrade CTA or Log in CTA */}
{isRowboatConnected && billing ? ( {isRowboatConnected && billing ? (() => {
<div className="px-3 py-2"> const upgradeLabel = !billing.subscriptionPlanId || currentBillingPlan?.category === 'free' || currentBillingPlan?.category === 'starter' ? 'Upgrade' : 'Manage'
<div className="flex items-center justify-between rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2"> if (outOfCredits) {
<div className="min-w-0"> return (
<span className="text-xs font-medium capitalize text-sidebar-foreground"> <div className="px-3 py-2">
{currentBillingPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')} <Popover open={creditPopoverOpen} onOpenChange={setCreditPopoverOpen}>
</span> <div className="flex items-center justify-between rounded-lg border border-red-500/50 bg-red-500/10 px-3 py-2">
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt && (() => { <PopoverTrigger asChild>
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24))) <button type="button" className="flex min-w-0 flex-1 items-center gap-2 text-left">
return ( <AlertTriangle className="size-4 shrink-0 text-red-500" />
<p className="text-[10px] text-sidebar-foreground/60"> <div className="min-w-0">
{days === 0 ? 'Trial expires today' : days === 1 ? '1 day left' : `${days} days left`} <span className="text-xs font-medium capitalize text-sidebar-foreground">
{currentBillingPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')}
</span>
<p className="text-[10px] text-red-500">Out of credits</p>
</div>
</button>
</PopoverTrigger>
<button
onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}
className="shrink-0 rounded-md bg-sidebar-foreground/10 px-2.5 py-1 text-[11px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-foreground/20"
>
{upgradeLabel}
</button>
</div>
<PopoverContent side="top" align="start" sideOffset={10} className="w-72">
<PopoverArrow className="fill-popover" />
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-red-500/15 text-red-500">
<CircleAlert className="size-4" />
</span>
<h4 className="text-sm font-bold text-foreground">You&apos;ve run out of credits</h4>
</div>
<button
type="button"
aria-label="Close"
onClick={() => setCreditPopoverOpen(false)}
className="rounded-md p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Upgrade your plan to continue using all features.
</p> </p>
) <button
})()} onClick={() => { appUrl && window.open(`${appUrl}?intent=upgrade`); setCreditPopoverOpen(false) }}
className="mt-3 w-full rounded-md bg-red-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-600"
>
Upgrade now
</button>
</PopoverContent>
</Popover>
</div>
)
}
return (
<div className="px-3 py-2">
<div className="flex items-center justify-between rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2">
<div className="min-w-0">
<span className="text-xs font-medium capitalize text-sidebar-foreground">
{currentBillingPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')}
</span>
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt && (() => {
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
return (
<p className="text-[10px] text-sidebar-foreground/60">
{days === 0 ? 'Trial expires today' : days === 1 ? '1 day left' : `${days} days left`}
</p>
)
})()}
</div>
<button
onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}
className="shrink-0 rounded-md bg-sidebar-foreground/10 px-2.5 py-1 text-[11px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-foreground/20"
>
{upgradeLabel}
</button>
</div> </div>
<button
onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}
className="shrink-0 rounded-md bg-sidebar-foreground/10 px-2.5 py-1 text-[11px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-foreground/20"
>
{!billing.subscriptionPlanId || currentBillingPlan?.category === 'free' || currentBillingPlan?.category === 'starter' ? 'Upgrade' : 'Manage'}
</button>
</div> </div>
</div> )
) : null} })() : null}
{/* Sign in CTA */} {/* Sign in CTA */}
{!isRowboatConnected && ( {!isRowboatConnected && (
<div className="px-3 py-2"> <div className="px-3 py-2">

View file

@ -43,4 +43,17 @@ function PopoverAnchor({
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} /> return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
} }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } function PopoverArrow({
className,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Arrow>) {
return (
<PopoverPrimitive.Arrow
data-slot="popover-arrow"
className={cn("fill-popover", className)}
{...props}
/>
)
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverArrow }

View file

@ -1,4 +1,5 @@
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import { dispatchCreditReplenished } from '@/lib/credit-status';
export type TTSState = 'idle' | 'synthesizing' | 'speaking'; export type TTSState = 'idle' | 'synthesizing' | 'speaking';
@ -8,9 +9,12 @@ interface SynthesizedAudio {
function synthesize(text: string): Promise<SynthesizedAudio> { function synthesize(text: string): Promise<SynthesizedAudio> {
return window.ipc.invoke('voice:synthesize', { text }).then( return window.ipc.invoke('voice:synthesize', { text }).then(
(result: { audioBase64: string; mimeType: string }) => ({ (result: { audioBase64: string; mimeType: string }) => {
dataUrl: `data:${result.mimeType};base64,${result.audioBase64}`, // A successful Rowboat voice synth is a cost-incurring call that
}) // returned OK, so it proves credits are available again.
dispatchCreditReplenished();
return { dataUrl: `data:${result.mimeType};base64,${result.audioBase64}` };
}
); );
} }

View file

@ -1,17 +1,20 @@
export const BILLING_ERROR_PATTERNS = [ export const BILLING_ERROR_PATTERNS = [
{ {
kind: 'subscription_required',
pattern: /upgrade required/i, pattern: /upgrade required/i,
title: 'A subscription is required', title: 'A subscription is required',
subtitle: 'Get started with a plan to access AI features in Rowboat.', subtitle: 'Get started with a plan to access AI features in Rowboat.',
cta: 'Subscribe', cta: 'Subscribe',
}, },
{ {
kind: 'out_of_credits',
pattern: /not enough credits/i, pattern: /not enough credits/i,
title: "You've run out of credits", title: "You've run out of credits",
subtitle: 'Upgrade your plan for more usage. Daily usage resets at 00:00 UTC.', subtitle: 'Upgrade your plan for more usage. Daily usage resets at 00:00 UTC.',
cta: 'Upgrade plan', cta: 'Upgrade plan',
}, },
{ {
kind: 'subscription_inactive',
pattern: /subscription not active/i, pattern: /subscription not active/i,
title: 'Your subscription is inactive', title: 'Your subscription is inactive',
subtitle: 'Reactivate your subscription to continue using AI features.', subtitle: 'Reactivate your subscription to continue using AI features.',

View file

@ -0,0 +1,26 @@
import type { BillingInfo } from '@x/shared/dist/billing.js'
/**
* A user is "out of credits" when EITHER the daily or the monthly bucket is
* exhausted. Either exhaustion is what triggers the backend's "not enough
* credits" API error, and this mirrors the two usage bars shown in Settings
* (Plan usage = monthly, Daily use = daily). `availableCredits` already comes
* down in the /v1/me payload, so no extra API work is needed.
*/
export function isOutOfCredits(billing: BillingInfo): boolean {
return billing.daily.availableCredits <= 0 || billing.monthly.availableCredits <= 0
}
/** Fired when we learn the user is out of credits (billing data or a usage API error). */
export const CREDIT_EXHAUSTED_EVENT = 'credit-status:exhausted'
/** Fired when a successful cost-incurring call (LLM / voice) proves credits are available again. */
export const CREDIT_REPLENISHED_EVENT = 'credit-status:replenished'
export function dispatchCreditExhausted(): void {
window.dispatchEvent(new Event(CREDIT_EXHAUSTED_EVENT))
}
export function dispatchCreditReplenished(): void {
window.dispatchEvent(new Event(CREDIT_REPLENISHED_EVENT))
}