mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
feat(app): show a free limit panel in place of the composer (#63531)
This commit is contained in:
committed by
GitHub
Unverified
parent
5418e010d7
commit
0996030161
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user