Merge pull request #637 from prakhar1605/feat/out-of-credits-warning

feat: add out-of-credits warning to sidebar plan badge
This commit is contained in:
PRAKHAR PANDEY 2026-07-06 14:11:18 +05:30 committed by GitHub
commit 1a71b38f2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 186 additions and 27 deletions

View file

@ -78,6 +78,7 @@ 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 { dispatchCreditExhausted, dispatchCreditReplenished } from "@/lib/credit-status"
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
import { extractConferenceLink } from '@/lib/calendar-event'
@ -941,6 +942,7 @@ function App() {
lastHandledBillingErrorIdRef.current = item.id
setBillingErrorMatch(match)
setBillingErrorOpen(true)
if (match.kind === 'out_of_credits') dispatchCreditExhausted()
}
return
}
@ -2544,6 +2546,7 @@ function App() {
const nextUsage = normalizeUsage(llmEvent.usage)
if (nextUsage) {
setModelUsage(nextUsage)
dispatchCreditReplenished()
}
}
}

View file

@ -22,6 +22,8 @@ import {
Settings,
Square,
Video,
CircleAlert,
X,
} from "lucide-react"
import {
AlertDialog,
@ -51,6 +53,7 @@ import {
Popover,
PopoverContent,
PopoverTrigger,
PopoverArrow,
} from "@/components/ui/popover"
import {
Tooltip,
@ -58,6 +61,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { isOutOfCredits, CREDIT_EXHAUSTED_EVENT, CREDIT_REPLENISHED_EVENT } from "@/lib/credit-status"
import { SettingsDialog } from "@/components/settings-dialog"
import { MascotFaceIcon } from "@/components/talking-head"
import { extractConferenceLink } from "@/lib/calendar-event"
@ -434,9 +438,13 @@ export function SidebarContentPanel({
const [openConnectionsAfterClose, setOpenConnectionsAfterClose] = useState(false)
const connectorsButtonRef = useRef<HTMLButtonElement | null>(null)
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 [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
// Nav previews: unread important emails + next upcoming meetings (top 2 each).
@ -658,6 +666,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.
const previewEmail = emailThreads[0]
const previewMeeting = meetings[0]
@ -923,31 +975,89 @@ export function SidebarContentPanel({
</SidebarGroup>
</SidebarContent>
{/* Billing / upgrade CTA or Log in CTA */}
{isRowboatConnected && billing ? (
<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`}
{isRowboatConnected && billing ? (() => {
const upgradeLabel = !billing.subscriptionPlanId || currentBillingPlan?.category === 'free' || currentBillingPlan?.category === 'starter' ? 'Upgrade' : 'Manage'
if (outOfCredits) {
return (
<div className="px-3 py-2">
<Popover open={creditPopoverOpen} onOpenChange={setCreditPopoverOpen}>
<div className="flex items-center justify-between rounded-lg border border-red-500/50 bg-red-500/10 px-3 py-2">
<PopoverTrigger asChild>
<button type="button" className="flex min-w-0 flex-1 items-center gap-2 text-left">
<AlertTriangle className="size-4 shrink-0 text-red-500" />
<div className="min-w-0">
<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>
)
})()}
<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>
<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>
) : null}
)
})() : null}
{/* Sign in CTA */}
{!isRowboatConnected && (
<div className="px-3 py-2">

View file

@ -43,4 +43,17 @@ function PopoverAnchor({
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, useEffect, useRef, useState } from 'react';
import { dispatchCreditReplenished } from '@/lib/credit-status';
export type TTSState = 'idle' | 'synthesizing' | 'speaking';
@ -8,9 +9,12 @@ interface SynthesizedAudio {
function synthesize(text: string): Promise<SynthesizedAudio> {
return window.ipc.invoke('voice:synthesize', { text }).then(
(result: { audioBase64: string; mimeType: string }) => ({
dataUrl: `data:${result.mimeType};base64,${result.audioBase64}`,
})
(result: { audioBase64: string; mimeType: string }) => {
// 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 = [
{
kind: 'subscription_required',
pattern: /upgrade required/i,
title: 'A subscription is required',
subtitle: 'Get started with a plan to access AI features in Rowboat.',
cta: 'Subscribe',
},
{
kind: 'out_of_credits',
pattern: /not enough credits/i,
title: "You've run out of credits",
subtitle: 'Upgrade your plan for more usage. Daily usage resets at 00:00 UTC.',
cta: 'Upgrade plan',
},
{
kind: 'subscription_inactive',
pattern: /subscription not active/i,
title: 'Your subscription is inactive',
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))
}