feat(app): show a free limit panel in place of the composer (#63531)

This commit is contained in:
Aarav Sareen
2026-11-13 00:01:25 +05:30
committed by GitHub
Unverified
parent 5418e010d7
commit 0996030161
5 changed files with 173 additions and 11 deletions
+4 -2
View File
@@ -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.
+5
View File
@@ -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}}.",
@@ -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(() => (
<x.DialogConnectProvider
controller={controller}
directory={location().directory}
selection={props.selection()}
standalone={!!provider}
/>
))
})
}
const subscribe = () => {
platform.openExternal(GO_URL)
connect("opencode-go")
}
return (
<div
data-component="session-free-limit"
role="status"
class="flex flex-col gap-4 rounded-xl bg-v2-background-bg-base p-2 [[data-color-scheme=dark]_&]:bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-raised)] [font-family:var(--v2-font-family-sans)]"
>
<div class="flex flex-col gap-0.5 ps-1.5 pe-1 pt-1">
<div class="text-[13px] font-[530] leading-[var(--line-height-base)] text-v2-text-text-base">
{language.t("session.freeLimit.title")}
</div>
<div class="text-[13px] font-[440] leading-[var(--line-height-base)] text-v2-text-text-muted [font-variant-numeric:tabular-nums]">
<Show when={reset()} fallback={language.t("session.freeLimit.noReset")}>
{(time) => language.t("session.freeLimit.reset", { time: time() })}
</Show>
</div>
</div>
<div class="flex items-center justify-end gap-1.5">
<Button
data-action="session-free-limit-subscribe"
type="button"
size="normal"
variant="ghost"
class="![font-weight:530]"
onClick={subscribe}
>
{language.t("session.freeLimit.subscribe")}
</Button>
<Button
data-action="session-free-limit-connect"
type="button"
size="normal"
variant="submit"
class="![font-weight:530]"
onClick={() => connect()}
>
{language.t("session.freeLimit.connect")}
</Button>
</div>
</div>
)
}
+21 -8
View File
@@ -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 (
<SessionComposerRegion
controller={props.model.region}
@@ -606,14 +609,24 @@ export function ActiveSessionComposerRegion(props: {
// The container for the composer's `cqw` caps: only the composer re-evaluates as a panel beside it
// animates the chat's width.
<div class="@container">
<VoiceComposer session={props.session.data.info()}>
<Composer
model={props.model.composer}
borderUnderlay
readOnly={props.model.queue.undoing()}
suggestionBoundary={props.suggestionBoundary}
/>
</VoiceComposer>
<Show
when={!freeLimit.active()}
fallback={
<SessionFreeLimitPanel
resetAt={freeLimit.resetAt()}
selection={() => props.model.composer.model.selection}
/>
}
>
<VoiceComposer session={props.session.data.info()}>
<Composer
model={props.model.composer}
borderUnderlay
readOnly={props.model.queue.undoing()}
suggestionBoundary={props.suggestionBoundary}
/>
</VoiceComposer>
</Show>
</div>
}
>
@@ -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