From 0996030161d57ce129553e3f0fa68d7d7dbf10f3 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:01:25 +0530 Subject: [PATCH] feat(app): show a free limit panel in place of the composer (#63531) --- packages/app/e2e/regression/composer.spec.ts | 6 +- packages/app/src/runtime/i18n/en.ts | 5 + .../src/session/composer/free-limit-panel.tsx | 133 ++++++++++++++++++ packages/app/src/session/composer/region.tsx | 29 ++-- .../src/session/usage-exceeded-dialogs.tsx | 11 +- 5 files changed, 173 insertions(+), 11 deletions(-) create mode 100644 packages/app/src/session/composer/free-limit-panel.tsx diff --git a/packages/app/e2e/regression/composer.spec.ts b/packages/app/e2e/regression/composer.spec.ts index d4b12049302..4e819b704ae 100644 --- a/packages/app/e2e/regression/composer.spec.ts +++ b/packages/app/e2e/regression/composer.spec.ts @@ -149,7 +149,7 @@ test("closing the @ menu aborts its session search", async ({ page }) => { } }) -test("the free usage limit opens the Go dialog once, leaves a readable notice, and restores the prompt", async ({ +test("the free usage limit opens the Go dialog once, swaps the composer for the free limit panel, and restores the prompt", async ({ page, context, }) => { @@ -251,7 +251,9 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a const upsell = page.getByRole("dialog", { name: "Free limit reached" }) await expect(upsell).toContainText("Your free messages reset at") await expect(page.getByText(/^You've used today's free messages\. They reset at /)).toBeVisible() - await expect(view.editor).toHaveText("Refactor the parser") + // With no paid provider the panel stands in for the composer until Go is connected. + await expect(page.locator('[data-component="session-free-limit"]')).toContainText("Free usage resets at") + await expect(view.editor).toHaveCount(0) await expect(page.getByText("Free usage limit reached.")).toHaveCount(0) // Subscribe -> Go API key -> Continue switches the model in the current session without opening a new draft tab. diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 6fb9d989c70..a73a7832e45 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -636,6 +636,11 @@ export const dict = { "session.background.moveRunning": "Move to background", "session.background.moveInline": "Press {{keybind}} to move running work to the background", "command.session.background": "Move to background", + "session.freeLimit.title": "Free limit reached", + "session.freeLimit.reset": "Free usage resets at {{time}}. Connect a provider or subscribe to Go to continue.", + "session.freeLimit.noReset": "Connect a provider or subscribe to Go to continue.", + "session.freeLimit.connect": "Connect provider", + "session.freeLimit.subscribe": "Subscribe to Go ยท $10/mo", "session.goLimit.title": "Go usage limit reached.", "session.goLimit.titleNamed": "Go {{name}} usage limit reached.", "session.goLimit.reset": "It resets at {{time}}.", diff --git a/packages/app/src/session/composer/free-limit-panel.tsx b/packages/app/src/session/composer/free-limit-panel.tsx new file mode 100644 index 00000000000..eb60da3d16b --- /dev/null +++ b/packages/app/src/session/composer/free-limit-panel.tsx @@ -0,0 +1,133 @@ +import { createSignal, onCleanup, Show } from "solid-js" +import { Schema } from "effect" +import { Button } from "@opencode/ui/button" +import { useDialog } from "@opencode/ui/context/dialog" +import { useLanguage } from "@/runtime/i18n/language" +import { usePlatform } from "@/runtime/platform/platform" +import { Persist, persisted } from "@/runtime/persistence/storage" +import { Persistence } from "@/runtime/persistence/schema" +import { useWorkspaceLocation } from "@/workspaces/location" +import type { ModelSelection } from "@/providers/models/selection" +import { limitResetAt } from "../usage-exceeded-dialogs" + +const GO_URL = "https://opencode.ai/go" + +// OpenCode Zen always says when free usage resets; this covers a refusal that doesn't, or a delay out of range. +const FALLBACK_RESET_MS = 3_600_000 + +const FreeLimitState = Persistence.struct({ resetAt: Schema.NullOr(Schema.Finite) }) + +// Free usage is counted per network, not per session, so one refusal anywhere holds until it resets. Connecting a paid +// provider ends it early. +export function useFreeLimit(paid: () => boolean) { + const location = useWorkspaceLocation() + const [state, setState] = persisted(Persist.global("free-limit"), FreeLimitState, { resetAt: null }) + const [now, setNow] = createSignal(Date.now()) + + onCleanup( + location().event.on("session.step.failed", (event) => { + if (event.data.error.type !== "provider.free-limit") return + setState( + "resetAt", + limitResetAt(event.created, event.data.error.retryAfterMs) ?? event.created + FALLBACK_RESET_MS, + ) + }), + ) + + const resetAt = () => state.resetAt + + // Re-check when the limit resets, so the composer comes back on its own. + const timer = setInterval(() => setNow(Date.now()), 30_000) + onCleanup(() => clearInterval(timer)) + + return { + resetAt, + active: () => { + const at = resetAt() + + return !paid() && at !== null && at > now() + }, + } +} + +// Stands in for the composer while free usage is used up: with no paid provider there is nothing the composer can send. +export function SessionFreeLimitPanel(props: { resetAt: number | null; selection: () => ModelSelection | undefined }) { + const language = useLanguage() + const platform = usePlatform() + const dialog = useDialog() + const location = useWorkspaceLocation() + + const reset = () => { + const at = props.resetAt + + if (at === null) return + const sameDay = new Date(at).toDateString() === new Date().toDateString() + + return new Intl.DateTimeFormat( + language.locale(), + sameDay ? { timeStyle: "short" } : { dateStyle: "medium", timeStyle: "short" }, + ).format(at) + } + + const connect = (provider?: string) => { + void import("@/providers/connect/dialog").then((x) => { + const controller = x.useProviderConnectController() + + if (provider) controller.select(provider) + void dialog.show(() => ( + + )) + }) + } + + const subscribe = () => { + platform.openExternal(GO_URL) + connect("opencode-go") + } + + return ( +
+
+
+ {language.t("session.freeLimit.title")} +
+
+ + {(time) => language.t("session.freeLimit.reset", { time: time() })} + +
+
+
+ + +
+
+ ) +} diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index e534adf59f2..2ef53edc2d0 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -38,6 +38,7 @@ import { SessionComposerRegion } from "./session-composer-region" import { createSessionComposerController, type SessionComposerController } from "./controller" import { SessionQueuePanel } from "./queue-panel" import { SessionGoLimitTray } from "./go-limit-tray" +import { SessionFreeLimitPanel, useFreeLimit } from "./free-limit-panel" import { SessionActivityChips, type SessionActivityItem } from "./activity-chips" import { SessionComposerLane, useComposerLaneAlignment } from "./composer-lane" import { SessionTodoPill } from "./todo-pill" @@ -554,6 +555,8 @@ export function ActiveSessionComposerRegion(props: { return props.session.data.info()?.outcome } + const freeLimit = useFreeLimit(() => props.model.composer.model.paid) + return ( - - - + props.model.composer.model.selection} + /> + } + > + + + + } > diff --git a/packages/app/src/session/usage-exceeded-dialogs.tsx b/packages/app/src/session/usage-exceeded-dialogs.tsx index 589a4f6708f..5259f2498a5 100644 --- a/packages/app/src/session/usage-exceeded-dialogs.tsx +++ b/packages/app/src/session/usage-exceeded-dialogs.tsx @@ -22,7 +22,8 @@ const MAX_RETRY_AFTER_MS = 45 * 86_400_000 const CHATGPT_USAGE_LIMIT_WINDOW = 86_400_000 // 24 hrs -export function formatLimitReset(locale: string, created: number, retryAfterMs: number | undefined) { +// When a usage limit resets, or undefined when the refusal's delay is missing or out of range. +export function limitResetAt(created: number, retryAfterMs: number | undefined) { if ( retryAfterMs === undefined || !Number.isFinite(retryAfterMs) || @@ -36,6 +37,14 @@ export function formatLimitReset(locale: string, created: number, retryAfterMs: if (Number.isNaN(new Date(at).getTime())) return undefined + return at +} + +export function formatLimitReset(locale: string, created: number, retryAfterMs: number | undefined) { + const at = limitResetAt(created, retryAfterMs) + + if (at === undefined) return undefined + const now = Date.now() const sameDay = new Date(at).toDateString() === new Date(now).toDateString() && at - now < GO_UPSELL_WINDOW