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