diff --git a/packages/app/src/components/dialog-usage-exceeded.tsx b/packages/app/src/components/dialog-usage-exceeded.tsx
new file mode 100644
index 000000000000..e428d4c2bbbb
--- /dev/null
+++ b/packages/app/src/components/dialog-usage-exceeded.tsx
@@ -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 (
+
+ )
+}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 1e73ed590fc5..79abd576330b 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -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 }
@@ -1787,6 +1788,8 @@ export default function Page() {
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
})
+ useUsageExceededDialogs()
+
return (
{sessionSync() ?? ""}
diff --git a/packages/app/src/pages/session/usage-exceeded-dialogs.tsx b/packages/app/src/pages/session/usage-exceeded-dialogs.tsx
new file mode 100644
index 000000000000..fed6401212c6
--- /dev/null
+++ b/packages/app/src/pages/session/usage-exceeded-dialogs.tsx
@@ -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(() => (
+ {
+ setGoUpsellState(keys.lastSeenAt, Date.now())
+ if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
+ else {
+ void import("../../components/dialog-connect-provider").then((x) =>
+ dialog.show(() => ),
+ )
+ }
+ }}
+ />
+ ))
+ } else if (action.reason === "account_rate_limit") {
+ dialog.show(() => (
+ {
+ setGoUpsellState(keys.lastSeenAt, Date.now())
+ if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
+ }}
+ />
+ ))
+ }
+ }),
+ )
+}
diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx
index c1c56212b5f6..6c9b4c0d77da 100644
--- a/packages/ui/src/context/dialog.tsx
+++ b/packages/ui/src/context/dialog.tsx
@@ -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"
@@ -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()