Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions packages/app/src/components/dialog-usage-exceeded.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { usePlatform } from "@/context/platform"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { JSX } from "solid-js"

export type DialogGoUpsellProps = {
title: string
description: JSX.Element
link?: string
actionLabel: string
onClose?: (dontShowAgain?: boolean) => void
}

export function DialogUsageExceeded(props: DialogGoUpsellProps) {
const dialog = useDialog()
const platform = usePlatform()

const runAction = () => {
if (props.link) platform.openLink(props.link)
props.onClose?.()
dialog.close()
}

const dismiss = () => {
props.onClose?.(true)
dialog.close()
}

return (
<Dialog title={props.title} description={props.description} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={dismiss}>
Don't show again
</Button>
<Button variant="primary" size="large" onClick={runAction}>
{props.actionLabel}
</Button>
</div>
</div>
</Dialog>
)
}
3 changes: 3 additions & 0 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt"
import { same } from "@/utils/same"
import { formatServerError } from "@/utils/server-errors"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"

const emptyUserMessages: UserMessage[] = []
type FollowupItem = FollowupDraft & { id: string }
Expand Down Expand Up @@ -1787,6 +1788,8 @@ export default function Page() {
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
})

useUsageExceededDialogs()

return (
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
{sessionSync() ?? ""}
Expand Down
99 changes: 99 additions & 0 deletions packages/app/src/pages/session/usage-exceeded-dialogs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { useSDK } from "@/context/sdk"
import { Persist, persisted } from "@/utils/persist"
import { SessionStatus } from "@opencode-ai/sdk/v2"
import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useSessionLayout } from "./session-layout"
import { useDialog } from "@opencode-ai/ui/context"
import { DialogUsageExceeded } from "@/components/dialog-usage-exceeded"

const GO_UPSELL_FREE_TIER_LAST_SEEN_AT = "go_upsell_last_seen_at"
const GO_UPSELL_FREE_TIER_DONT_SHOW = "go_upsell_dont_show"
const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_last_seen_at"
const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show"
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])

function goUpsellKeys(status: SessionStatus) {
if (status.type !== "retry" || !status.action) return
const { action } = status
if (!GO_UPSELL_PROVIDERS.has(action.provider)) return
if (action.reason === "free_tier_limit") {
return {
lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT,
dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW,
} as const
}
if (action.reason === "account_rate_limit") {
return {
lastSeenAt: GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT,
dontShow: GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW,
} as const
}
}

export function useUsageExceededDialogs() {
const sdk = useSDK()
const dialog = useDialog()
const { params } = useSessionLayout()

const [goUpsellState, setGoUpsellState] = persisted(
Persist.global("go-upsell"),
createStore({
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: null as null | number,
[GO_UPSELL_FREE_TIER_DONT_SHOW]: null as null | number,
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: null as null | number,
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: null as null | number,
}),
)

onCleanup(
sdk.event.on("session.status", (evt) => {
if (evt.properties.sessionID !== params.id) return
if (evt.properties.status.type !== "retry") return
const { action } = evt.properties.status
if (!action) return
if (dialog.active) return

const keys = goUpsellKeys(evt.properties.status)
if (!keys) return

const seen = goUpsellState[keys.lastSeenAt]
if (seen && Date.now() - seen < GO_UPSELL_WINDOW) return
if (goUpsellState[keys.dontShow]) return

if (action.reason === "free_tier_limit") {
dialog.show(() => (
<DialogUsageExceeded
title="Free limit reached"
description="Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month."
actionLabel="Subscribe"
link={action.link}
onClose={(dontShowAgain) => {
setGoUpsellState(keys.lastSeenAt, Date.now())
if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
else {
void import("../../components/dialog-connect-provider").then((x) =>
dialog.show(() => <x.DialogConnectProvider provider="opencode-go" />),
)
}
}}
/>
))
} else if (action.reason === "account_rate_limit") {
dialog.show(() => (
<DialogUsageExceeded
title="Go limit reached"
description={action.message}
actionLabel="Open settings"
link={action.link}
onClose={(dontShowAgain) => {
setGoUpsellState(keys.lastSeenAt, Date.now())
if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
}}
/>
))
}
}),
)
}
3 changes: 2 additions & 1 deletion packages/ui/src/context/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
runWithOwner,
useContext,
type JSX,
startTransition,
} from "solid-js"
import { Dialog as Kobalte } from "@kobalte/core/dialog"
import { makeEventListener } from "@solid-primitives/event-listener"
Expand Down Expand Up @@ -154,7 +155,7 @@ export function useDialog() {
},
show(element: DialogElement, onClose?: () => void) {
const base = ctx.active?.owner ?? owner
ctx.show(element, base, onClose)
return startTransition(() => ctx.show(element, base, onClose))
},
close() {
ctx.close()
Expand Down
Loading