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()