From 57f0e21a4684409e90a45560b65aa1681ec9aa2a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 21 Oct 2026 13:05:39 -0700 Subject: [PATCH 1/3] fix(app): type the Go and ChatGPT usage limits so their dialogs fire core now classifies the Console's GoUsageLimitError 429 as provider.go-limit (keeping retryAfterMs) and ChatGPT's subscription_sharing_usage_limit_exceeded as provider.chatgpt-limit. Desktop opens 'Go limit reached' with the reset time, leaves a muted timeline line and hands the prompt back; the ChatGPT dialog matches the typed error instead of a stale copied string. The TUI restores the prompt and shows a muted Go-limit notice instead of a red error and failure toast. --- packages/app/e2e/regression/composer.spec.ts | 90 +++++++++++++ packages/app/src/session/composer/region.tsx | 9 +- .../session/usage-exceeded-dialogs.test.ts | 40 ++++-- .../src/session/usage-exceeded-dialogs.tsx | 122 ++++++++---------- packages/core/src/session/to-session-error.ts | 15 ++- packages/core/test/session-error.test.ts | 34 ++++- .../session-ui/src/timeline/projection.ts | 21 +-- .../src/timeline/rows-current.test.ts | 19 ++- .../src/timeline/session-timeline-row.tsx | 19 ++- .../session-ui/src/timeline/timeline-row.ts | 8 +- .../feature-plugins/system/notifications.ts | 2 +- packages/tui/src/routes/session/index.tsx | 51 +++++--- packages/tui/test/app-lifecycle.test.tsx | 65 ++++++++++ .../test/cli/cmd/tui/notifications.test.ts | 13 ++ packages/ui/src/i18n/en.ts | 6 +- 15 files changed, 378 insertions(+), 136 deletions(-) diff --git a/packages/app/e2e/regression/composer.spec.ts b/packages/app/e2e/regression/composer.spec.ts index dcfd39867655..0d5bf8974921 100644 --- a/packages/app/e2e/regression/composer.spec.ts +++ b/packages/app/e2e/regression/composer.spec.ts @@ -259,6 +259,96 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a await expect(view.editor).toHaveText("") }) +test("the Go usage limit opens the Go limit dialog with its reset time and restores the prompt; ChatGPT's opens its own", async ({ + page, +}) => { + await page.clock.setFixedTime(T0) + const sessionID = "ses_go_limit" + const model = { id: "glm-5-go", providerID: "opencode-go" } + + const view = await openSession(page, { + name: "GoLimit", + sessions: [{ id: sessionID, agent: "build", model }], + onFileWrite: () => undefined, + pageMessages: () => ({ + items: [ + { id: "msg_go_user", type: "user", text: "Refactor the parser", time: { created: T0 } }, + { id: "msg_go_assistant", type: "assistant", agent: "build", model, content: [], time: { created: T0 + 1 } }, + ], + }), + }) + + await expect(page.getByText("Refactor the parser", { exact: true })).toBeVisible() + + const durable = (seq: number) => ({ aggregateID: sessionID, seq, version: 1 as const }) + const location = { directory: view.directory } + + const body = { + type: "error", + error: { type: "GoUsageLimitError", message: "Go usage limit exceeded" }, + metadata: { workspace: "wrk_go_limit", limitName: "5 hour" }, + } + + await view.push([ + { + id: "evt_go_limit_1", + created: T0 + 2, + type: "session.step.failed", + location, + durable: durable(1), + data: { + sessionID, + assistantMessageID: "msg_go_assistant", + error: { + type: "provider.go-limit", + message: "OpenCode Go usage limit reached.", + status: 429, + response: { body: JSON.stringify(body) }, + retryAfterMs: 3_600_000, + }, + }, + }, + ]) + + const dialog = page.getByRole("dialog", { name: "Go limit reached" }) + await expect(dialog).toContainText("You've reached your OpenCode Go usage limit. It resets at") + await expect(dialog.getByRole("button", { name: "Open settings", exact: true })).toBeVisible() + await expect(page.getByText(/^You've reached your OpenCode Go usage limit\. It resets at /)).toBeVisible() + await expect(view.editor).toHaveText("Refactor the parser") + await expect(page.getByText("OpenCode Go usage limit reached.")).toHaveCount(0) + await dialog.getByRole("button", { name: "Don't show again", exact: true }).click() + await expect(dialog).toHaveCount(0) + + await view.push([ + { + id: "evt_chatgpt_limit_1", + created: T0 + 3, + type: "session.step.started", + location, + durable: durable(2), + data: { sessionID, assistantMessageID: "msg_chatgpt_assistant", agent: "build", model, started: T0 + 3 }, + }, + { + id: "evt_chatgpt_limit_2", + created: T0 + 4, + type: "session.step.failed", + location, + durable: durable(3), + data: { + sessionID, + assistantMessageID: "msg_chatgpt_assistant", + error: { + type: "provider.chatgpt-limit", + message: "ChatGPT usage limit reached. Check ChatGPT Settings → Usage for details.", + status: 429, + }, + }, + }, + ]) + + await expect(page.getByRole("dialog", { name: "ChatGPT usage limit reached" })).toBeVisible() +}) + test("a catalog refresh cannot replace an existing session's model", async ({ page }) => { const alpha = { id: "alpha", name: "Alpha", variants: { high: {} } } const beta = { id: "beta", name: "Beta" } diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index 310510029540..5d8455ddc747 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -203,13 +203,16 @@ export function createActiveSessionRegion(input: { const recalled = new Set() - // The free allowance stops a prompt after it was sent. Hand its text back so switching models and resending is one - // step, unless the user has already started typing something else. + // The free allowance or a Go usage limit stops a prompt after it was sent. Hand its text back so switching models and + // resending is one step, unless the user has already started typing something else. onCleanup( location().event.on("session.step.failed", (event) => { const sessionID = input.session.identity.params.id + const type = event.data.error.type - if (!sessionID || event.data.sessionID !== sessionID || event.data.error.type !== "provider.free-limit") return + if (!sessionID || event.data.sessionID !== sessionID) return + + if (type !== "provider.free-limit" && type !== "provider.go-limit") return if (recalled.has(event.data.assistantMessageID)) return recalled.add(event.data.assistantMessageID) diff --git a/packages/app/src/session/usage-exceeded-dialogs.test.ts b/packages/app/src/session/usage-exceeded-dialogs.test.ts index 001d5003e6a8..afa6b6711435 100644 --- a/packages/app/src/session/usage-exceeded-dialogs.test.ts +++ b/packages/app/src/session/usage-exceeded-dialogs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Schema } from "effect" -import { GoUpsellState, isChatGPTUsageLimit } from "./usage-exceeded-dialogs" +import { GoUpsellState, goLimitLink } from "./usage-exceeded-dialogs" import { Persistence } from "@/runtime/persistence/schema" const decode = Schema.decodeUnknownSync( @@ -39,16 +39,34 @@ describe("usage exceeded preferences", () => { }) }) -describe("ChatGPT usage limit", () => { - const message = - "ChatGPT usage limit reached. Try again after your allowance resets; check ChatGPT Settings → Usage for details." +describe("Go limit link", () => { + const body = (json: string) => ({ + type: "provider.go-limit", + message: "OpenCode Go usage limit reached.", + response: { body: json }, + }) + + test("opens the Go settings of the workspace that hit the limit", () => { + expect( + goLimitLink( + body( + JSON.stringify({ + type: "error", + error: { type: "GoUsageLimitError", message: "Go usage limit exceeded" }, + metadata: { workspace: "wrk_go_limit", limitName: "5 hour" }, + }), + ), + ), + ).toBe("https://opencode.ai/console/wrk_go_limit/go") + }) - test.each([ - [{ type: "provider.rate-limit", message, status: 429 }, true], - [{ type: "provider.unknown", message }, true], - [{ type: "provider.rate-limit", message: "Rate limit exceeded", status: 429 }, false], - [{ type: "provider.rate-limit", message: "ChatGPT usage limit reached" }, false], - ] as const)("matches %o: %p", (failure, expected) => { - expect(isChatGPTUsageLimit(failure)).toBe(expected) + test("falls back to the Console when the body has no workspace", () => { + expect(goLimitLink(body(JSON.stringify({ error: { type: "GoUsageLimitError" } })))).toBe( + "https://opencode.ai/console", + ) + expect(goLimitLink({ type: "provider.go-limit", message: "limit", response: { body: "not json" } })).toBe( + "https://opencode.ai/console", + ) + expect(goLimitLink({ type: "provider.go-limit", message: "limit" })).toBe("https://opencode.ai/console") }) }) diff --git a/packages/app/src/session/usage-exceeded-dialogs.tsx b/packages/app/src/session/usage-exceeded-dialogs.tsx index d9280beb3e73..5af6ac455dd3 100644 --- a/packages/app/src/session/usage-exceeded-dialogs.tsx +++ b/packages/app/src/session/usage-exceeded-dialogs.tsx @@ -1,8 +1,8 @@ import { useWorkspaceLocation } from "@/workspaces/location" import { Persist, persisted } from "@/runtime/persistence/storage" -import type { SessionStatus, SessionStepFailed } from "@opencode/client/promise" +import type { SessionStepFailed } from "@opencode/client/promise" import { onCleanup } from "solid-js" -import { Schema } from "effect" +import { Option, Schema } from "effect" import { Persistence } from "@/runtime/persistence/schema" import { useSessionLayout } from "./session-layout" import { useDialog, useI18n } from "@opencode/ui/context" @@ -20,15 +20,13 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_don const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs -const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"]) - const GO_URL = "https://opencode.ai/go" const MAX_RETRY_AFTER_MS = 7 * 86_400_000 const CHATGPT_USAGE_LIMIT_WINDOW = 86_400_000 // 24 hrs -function formatFreeLimitReset(locale: string, created: number, retryAfterMs: number | undefined) { +function formatLimitReset(locale: string, created: number, retryAfterMs: number | undefined) { if ( retryAfterMs === undefined || !Number.isFinite(retryAfterMs) || @@ -51,11 +49,15 @@ function formatFreeLimitReset(locale: string, created: number, retryAfterMs: num ).format(at) } -export function isChatGPTUsageLimit(error: SessionStepFailed["data"]["error"]) { - return ( - error.message === - "ChatGPT usage limit reached. Try again after your allowance resets; check ChatGPT Settings → Usage for details." - ) +const GoLimitBody = Schema.fromJsonString(Schema.Struct({ metadata: Schema.Struct({ workspace: Schema.String }) })) + +const decodeGoLimitBody = Schema.decodeUnknownOption(GoLimitBody) + +/** The Console page where a Go subscriber turns on usage from their balance, scoped to the workspace that hit the limit. */ +export function goLimitLink(error: SessionStepFailed["data"]["error"]) { + const workspace = Option.getOrUndefined(decodeGoLimitBody(error.response?.body))?.metadata.workspace + + return workspace ? `https://opencode.ai/console/${encodeURIComponent(workspace)}/go` : "https://opencode.ai/console" } export const GoUpsellState = Persistence.struct({ @@ -65,20 +67,6 @@ export const GoUpsellState = Persistence.struct({ [GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: Schema.NullOr(Schema.Finite), }) -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 === "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(input?: { selection?: () => ModelSelection | undefined onDone?: () => void @@ -87,7 +75,7 @@ export function useUsageExceededDialogs(input?: { const dialog = useDialog() const local = useLocal() const { params } = useSessionLayout() - const { t, tDynamic, locale } = useI18n() + const { t, locale } = useI18n() const [goUpsellState, setGoUpsellState, goUpsellInit, goUpsellReady] = persisted( Persist.global("go-upsell"), @@ -117,7 +105,7 @@ export function useUsageExceededDialogs(input?: { // Counted when shown, so dismissing with Escape or the backdrop also waits out the window. setGoUpsellState(GO_UPSELL_FREE_TIER_LAST_SEEN_AT, Date.now()) - const reset = formatFreeLimitReset(locale(), evt.created, evt.data.error.retryAfterMs) + const reset = formatLimitReset(locale(), evt.created, evt.data.error.retryAfterMs) dialog.show(() => ( { + if (evt.data.sessionID !== params.id || dialog.active) return + const seen = goUpsellState[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT] + + if (seen && Date.now() - seen < GO_UPSELL_WINDOW) return + + if (goUpsellState[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]) return + + setGoUpsellState(GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT, Date.now()) + + const reset = formatLimitReset(locale(), evt.created, evt.data.error.retryAfterMs) + + dialog.show(() => ( + { + if (dontShowAgain) setGoUpsellState(GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW, Date.now()) + }} + /> + )) + } + + const whenGoUpsellReady = (show: (evt: SessionStepFailed) => void, evt: SessionStepFailed) => { + if (goUpsellReady()) show(evt) + else if (goUpsellInit instanceof Promise) void goUpsellInit.then(() => show(evt)) + } + onCleanup( sdk().event.on("session.step.failed", (evt) => { if (evt.data.sessionID !== params.id || dialog.active) return const error = evt.data.error - if (error.type === "provider.free-limit") { - if (goUpsellReady()) showFreeLimitDialog(evt) - else if (goUpsellInit instanceof Promise) void goUpsellInit.then(() => showFreeLimitDialog(evt)) + if (error.type === "provider.free-limit") return whenGoUpsellReady(showFreeLimitDialog, evt) - return - } + if (error.type === "provider.go-limit") return whenGoUpsellReady(showGoLimitDialog, evt) - if (!isChatGPTUsageLimit(error)) return + if (error.type !== "provider.chatgpt-limit") return if (chatgptUsageLimit.lastSeenAt && Date.now() - chatgptUsageLimit.lastSeenAt < CHATGPT_USAGE_LIMIT_WINDOW) return @@ -175,43 +194,4 @@ export function useUsageExceededDialogs(input?: { }) }), ) - - onCleanup( - sdk().event.on("session.status", (evt) => { - if (evt.data.sessionID !== params.id) return - - if (evt.data.status.type !== "retry") return - const { action } = evt.data.status - - if (!action) return - - if (dialog.active) return - - const keys = goUpsellKeys(evt.data.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 === "account_rate_limit") { - dialog.show(() => ( - { - setGoUpsellState(keys.lastSeenAt, Date.now()) - - if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now()) - }} - /> - )) - } - }), - ) } diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index 2fa9f70a1b34..4cddb5d36b23 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -28,19 +28,24 @@ const tokenSharingMessages = { const FREE_USAGE_LIMIT_MESSAGE = "Free usage limit reached. Connect a provider or subscribe to OpenCode Go to keep going, or switch models." +const GO_USAGE_LIMIT_MESSAGE = + "OpenCode Go usage limit reached. Switch models, or enable usage from your balance in the Console to keep going." + export function toSessionError(cause: unknown): SessionError.Error { if (cause instanceof AIError) { + if (cause.reason.body?.includes("subscription_sharing_usage_limit_exceeded")) + return providerError("provider.chatgpt-limit", cause.reason) switch (cause.reason._tag) { case "RateLimit": return providerError("provider.rate-limit", cause.reason) case "Authentication": return providerError("provider.auth", cause.reason) case "QuotaExceeded": - if (!cause.reason.body?.includes("FreeUsageLimitError")) return providerError("provider.quota", cause.reason) - return { - ...providerError("provider.free-limit", cause.reason), - message: FREE_USAGE_LIMIT_MESSAGE, - } + if (cause.reason.body?.includes("FreeUsageLimitError")) + return { ...providerError("provider.free-limit", cause.reason), message: FREE_USAGE_LIMIT_MESSAGE } + if (cause.reason.body?.includes("GoUsageLimitError")) + return { ...providerError("provider.go-limit", cause.reason), message: GO_USAGE_LIMIT_MESSAGE } + return providerError("provider.quota", cause.reason) case "ContentPolicy": return providerError("provider.content-filter", cause.reason) case "Transport": diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts index fcf8fe210838..72b235f2b7c8 100644 --- a/packages/core/test/session-error.test.ts +++ b/packages/core/test/session-error.test.ts @@ -113,13 +113,32 @@ describe("toSessionError", () => { expect(SessionRunnerRetry.isRetryable(paid)).toBe(true) expect(toSessionError(paid)).toMatchObject({ type: "provider.rate-limit", retryAfterMs: 45_000 }) - const go = fail( - "https://opencode.ai/inference/go/openai/v1/chat/completions", - { type: "error", error: { type: "GoUsageLimitError", message: "Subscription quota exceeded." } }, - { "retry-after": "120" }, - ) + const goBody = { + type: "error", + error: { type: "GoUsageLimitError", message: "Go usage limit exceeded" }, + metadata: { workspace: "wrk_go_limit", limitName: "5 hour" }, + } + const go = fail("https://opencode.ai/inference/go/openai/v1/chat/completions", goBody, { "retry-after": "120" }) expect(SessionRunnerRetry.isRetryable(go)).toBe(false) - expect(toSessionError(go)).toMatchObject({ type: "provider.quota", retryAfterMs: 120_000 }) + expect(toSessionError(go)).toEqual({ + type: "provider.go-limit", + message: + "OpenCode Go usage limit reached. Switch models, or enable usage from your balance in the Console to keep going.", + status: 429, + response: { body: JSON.stringify(goBody) }, + retryAfterMs: 120_000, + }) + + const credit = RequestExecutor.httpFailure({ + message: "Credit limit exceeded.", + url: "https://opencode.ai/inference/openai/v1/chat/completions", + status: 402, + responseBody: JSON.stringify({ + type: "error", + error: { type: "CreditLimitExceeded", message: "Credit limit exceeded." }, + }), + }) + expect(toSessionError(credit)).toMatchObject({ type: "provider.quota" }) const other = fail( "https://api.openai.com/v1/chat/completions", @@ -215,6 +234,9 @@ describe("toSessionError", () => { const error = toSessionError(llm(new UnknownProviderError({ message: "Request failed", body }))) expect(error.message).toContain(guidance) expect(error.response).toEqual({ body }) + expect(error.type).toBe( + code === "subscription_sharing_usage_limit_exceeded" ? "provider.chatgpt-limit" : "provider.unknown", + ) } } }) diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index a17d7e798a4b..cd881b24c2c9 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -584,7 +584,7 @@ export namespace Timeline { new TimelineRow.Error({ userMessageID: turnID, text: unwrapErrorMessage(lastAssistant.error.message), - ...freeLimit(lastAssistant), + ...usageLimit(lastAssistant), }), ) } @@ -605,7 +605,7 @@ export namespace Timeline { new TimelineRow.Error({ userMessageID: turnID, text: unwrapErrorMessage(entry.message.error.message), - ...freeLimit(entry.message), + ...usageLimit(entry.message), }), ) @@ -658,11 +658,15 @@ export namespace Timeline { const MAX_RETRY_AFTER_MS = 7 * 86_400_000 -function freeLimit( +function usageLimit( message: Pick & { time: { created: number; completed?: number } }, ) { - if (message.error?.type !== "provider.free-limit") return {} - const delay = message.error.retryAfterMs + const type = message.error?.type + + if (type !== "provider.free-limit" && type !== "provider.go-limit") return {} + + const kind = type === "provider.go-limit" ? ("go-limit" as const) : ("free-limit" as const) + const delay = message.error?.retryAfterMs const base = message.time.completed ?? message.time.created if ( @@ -673,12 +677,9 @@ function freeLimit( !Number.isFinite(base) || Number.isNaN(new Date(base + delay).getTime()) ) - return { kind: "free-limit" as const } + return { kind } - return { - kind: "free-limit" as const, - resetAt: base + delay, - } + return { kind, resetAt: base + delay } } function isInterrupted(error: SessionMessageAssistant["error"]) { diff --git a/packages/session-ui/src/timeline/rows-current.test.ts b/packages/session-ui/src/timeline/rows-current.test.ts index eddd39894b8e..dc2dd93caebf 100644 --- a/packages/session-ui/src/timeline/rows-current.test.ts +++ b/packages/session-ui/src/timeline/rows-current.test.ts @@ -1603,7 +1603,7 @@ describe("current session timeline rows", () => { ) }) - test("marks the free usage limit and its reset time, whichever message records the failure", () => { + test("marks the free and Go usage limits and their reset times, whichever message records the failure", () => { const error = { type: "provider.free-limit", message: "Free usage limit reached.", retryAfterMs: 60_000 } const user = { id: "msg_user", type: "user", text: "prompt", time: { created: 1 } } satisfies SessionMessageInfo @@ -1627,7 +1627,12 @@ describe("current session timeline rows", () => { const quota = [ user, - { ...assistant, error: { type: "provider.quota", message: "Go usage limit exceeded", retryAfterMs: 60_000 } }, + { ...assistant, error: { type: "provider.quota", message: "Credit limit exceeded.", retryAfterMs: 60_000 } }, + ] satisfies SessionMessageInfo[] + + const go = [ + user, + { ...assistant, error: { type: "provider.go-limit", message: "Go usage limit reached.", retryAfterMs: 120_000 } }, ] satisfies SessionMessageInfo[] expect(Timeline.constructSessionMessageRows(step, true, { type: "idle" }).rows.at(-1)).toEqual( @@ -1655,8 +1660,16 @@ describe("current session timeline rows", () => { kind: "free-limit", }), ) + expect(Timeline.constructSessionMessageRows(go, true, { type: "idle" }).rows.at(-1)).toEqual( + new TimelineRow.Error({ + userMessageID: "msg_user", + text: "Go usage limit reached.", + kind: "go-limit", + resetAt: 121_000, + }), + ) expect(Timeline.constructSessionMessageRows(quota, true, { type: "idle" }).rows.at(-1)).toEqual( - new TimelineRow.Error({ userMessageID: "msg_user", text: "Go usage limit exceeded" }), + new TimelineRow.Error({ userMessageID: "msg_user", text: "Credit limit exceeded." }), ) }) diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index cf81c728b91e..52292c96ee3e 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -1093,12 +1093,19 @@ export function createSessionTimelineRowRenderer(input: { return value } - const freeLimit = createMemo(() => { - if (current().kind !== "free-limit") return + const usageLimit = createMemo(() => { + const kind = current().kind + + if (!kind) return + + const keys = + kind === "go-limit" + ? ({ title: "ui.sessionTurn.goLimit.title", lineReset: "ui.sessionTurn.goLimit.lineReset" } as const) + : ({ title: "ui.sessionTurn.freeLimit.title", lineReset: "ui.sessionTurn.freeLimit.lineReset" } as const) + const at = current().resetAt - if (at === undefined || !Number.isFinite(at) || Number.isNaN(new Date(at).getTime())) - return i18n.t("ui.sessionTurn.freeLimit.title") + if (at === undefined || !Number.isFinite(at) || Number.isNaN(new Date(at).getTime())) return i18n.t(keys.title) const now = Date.now() const sameDay = new Date(at).toDateString() === new Date(now).toDateString() && at - now < 86_400_000 @@ -1108,13 +1115,13 @@ export function createSessionTimelineRowRenderer(input: { sameDay ? { timeStyle: "short" } : { dateStyle: "medium", timeStyle: "short" }, ).format(at) - return i18n.t("ui.sessionTurn.freeLimit.lineReset", { time }) + return i18n.t(keys.lineReset, { time }) }) return (
- }> + }> {(text) => (
{} /** - * `free-limit` marks OpenCode Zen's daily free allowance running out; `resetAt` is when the allowance comes back, if - * known. + * `free-limit` marks OpenCode Zen's daily free allowance running out and `go-limit` an OpenCode Go subscription's + * usage limit; `resetAt` is when the allowance comes back, if known. */ export class Error extends Data.TaggedClass("Error")<{ userMessageID: string text: string - kind?: "free-limit" + kind?: "free-limit" | "go-limit" resetAt?: number }> {} @@ -148,7 +148,7 @@ export type TimelineRowMap = { } Thinking: { userMessageID: string; ref: PartRef } Retry: { userMessageID: string } - Error: { userMessageID: string; text: string; kind?: "free-limit"; resetAt?: number } + Error: { userMessageID: string; text: string; kind?: "free-limit" | "go-limit"; resetAt?: number } CompactionQueued: { userMessageID: string; inboxID: string } TurnDiff: { userMessageID: string; messageID: string; from: string; files: readonly string[] } Activity: { userMessageID: string } diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index fe21e258a000..8876080ffc3c 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -66,7 +66,7 @@ export default Plugin.define({ return } errored.add(sessionID) - if (event.data.error.type !== "provider.free-limit") { + if (event.data.error.type !== "provider.free-limit" && event.data.error.type !== "provider.go-limit") { notify(context, sessionID, event.data.error.message, "error") context.ui.toast.show({ sessionID, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index d586a60ad6fa..1963da1fac40 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -638,10 +638,13 @@ export function Session(props: { onCleanup(() => { sessionDisposed = true }) - // Restore the prompt when the free allowance stops it, and offer OpenCode Go on the first hit in a day. + // Restore the prompt when the free allowance or a Go usage limit stops it, and offer OpenCode Go on the first free-limit + // hit in a day. onCleanup( data.on("session.step.failed", (event) => { - if (event.data.sessionID !== route.sessionID || event.data.error.type !== "provider.free-limit") return + const type = event.data.error.type + if (event.data.sessionID !== route.sessionID) return + if (type !== "provider.free-limit" && type !== "provider.go-limit") return if (handledFreeLimits.has(event.data.assistantMessageID)) return handledFreeLimits.add(event.data.assistantMessageID) const current = prompt() @@ -664,7 +667,7 @@ export function Session(props: { break } } - if (dialog.stack.length > 0) return + if (type === "provider.go-limit" || dialog.stack.length > 0) return if (goUpsell.dontShow || goUpsellMemory.dontShow) return const now = Date.now() const seen = Math.max(goUpsell.lastSeenAt ?? 0, goUpsellMemory.lastSeenAt ?? 0) @@ -724,7 +727,7 @@ export function Session(props: { if (!claimed || !canOpen()) return dialog.replace(() => ( { updateGoUpsellMemory((draft) => { @@ -2022,19 +2025,22 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) { const duration = createMemo(() => SessionTurn.duration(messages(), props.message, turn())) const tokensPerSecond = createMemo(() => SessionTurn.tokensPerSecond(messages(), props.message, turn())) const interrupted = createMemo(() => props.message.error?.message === "Step interrupted") - const freeLimit = createMemo(() => props.message.error?.type === "provider.free-limit") + const usageLimit = createMemo(() => { + const type = props.message.error?.type + return type === "provider.free-limit" || type === "provider.go-limit" + }) return ( <> Error: {errorMessage(props.message.error)} } > - + @@ -2506,7 +2512,7 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp ) } -function freeLimitReset(delay: number | undefined, base: number) { +function limitReset(delay: number | undefined, base: number) { if (delay === undefined || !Number.isFinite(delay) || delay < 0 || delay > 7 * 86_400_000 || !Number.isFinite(base)) return const at = base + delay @@ -2517,18 +2523,33 @@ function freeLimitReset(delay: number | undefined, base: number) { : Locale.datetime(at) } -function FreeLimitNotice(props: { message: Pick }) { +function UsageLimitNotice(props: { message: Pick }) { const theme = useTheme() + const go = createMemo(() => props.message.error?.type === "provider.go-limit") const reset = createMemo(() => - freeLimitReset(props.message.error?.retryAfterMs, props.message.time.completed ?? props.message.time.created), + limitReset(props.message.error?.retryAfterMs, props.message.time.completed ?? props.message.time.created), ) + const headline = createMemo(() => { + if (go()) return `You've reached your OpenCode Go usage limit.${reset() ? ` It resets at ${reset()}.` : ""}` + return `You've used today's free messages.${reset() ? ` They reset at ${reset()}.` : ""}` + }) return ( - You've used today's free messages.{reset() ? ` They reset at ${reset()}.` : ""} - - Upgrade to OpenCode Go → /connect · Switch model →{" "} - /models - + {headline()} + + Upgrade to OpenCode Go → /connect · Switch model →{" "} + /models + + } + > + + Switch model → /models · Enable usage from your balance in the + Console + + ) } diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index d3050c43cefb..87b4179c4da7 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -2549,6 +2549,71 @@ test("free usage limit claims suppression atomically across two TUI instances an expect(frame).not.toContain("Connect OpenCode Go") }) +test("Go usage limit restores the prompt and leaves a muted notice with the reset time instead of an error", async () => { + await using state = await tmpdir() + const session = { + id: "ses_go_limit", + projectID: "proj_test", + location: { directory }, + title: "Go limit", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + } + const error = { + type: "provider.go-limit", + message: "OpenCode Go usage limit reached.", + status: 429, + retryAfterMs: 3_600_000, + } + await using setup = await createAppFixture({ + width: 100, + state: state.path, + args: { sessionID: session.id }, + config: { animations: false, tabs: { mode: "off" } }, + fetch: (url) => { + if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) + if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) + if (url.pathname === `/api/session/${session.id}/message`) + return json({ + data: [ + { + id: "msg_go_assistant", + type: "assistant", + agent: "build", + model: { id: "glm-5-go", providerID: "opencode-go" }, + content: [], + time: { created: 2 }, + }, + { id: "msg_go_user", type: "user", text: "Refactor the parser", time: { created: 1 } }, + ], + cursor: {}, + }) + if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname)) + return json({ data: [] }) + return undefined + }, + }) + await setup.ready + await setup.waitForFrame((frame) => frame.includes("Refactor the parser")) + setup.events.emit({ + id: "evt_go_limit_1", + created: 3, + type: "session.step.failed", + durable: { aggregateID: session.id, seq: 1, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_go_assistant", error }, + }) + await setup.waitForFrame( + (frame) => + frame.includes("You've reached your OpenCode Go usage limit. It resets at") && + frame.split("Refactor the parser").length === 3, + ) + const frame = setup.captureCharFrame() + expect(frame).toContain("Switch model → /models") + expect(frame).not.toContain("Error:") + expect(frame).not.toContain("Free limit reached") +}) + test("free usage limit modal manages live animation FPS and adapts to 44x24 and 60x12 terminals", async () => { const session = { id: "ses_free_limit_anim", diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index 6e2ed7e2cdb5..9232cabbc857 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -170,6 +170,19 @@ describe("internal notifications TUI plugin", () => { } as OpenCodeEvent) expect(harness.toasts).toHaveLength(2) expect(harness.notifications).toHaveLength(2) + + harness.emit(executionStarted("go-limit")) + harness.emit({ + id: "go-limit-failed", + created: 0, + type: "session.execution.failed", + data: { + sessionID: "session", + error: { type: "provider.go-limit", message: "OpenCode Go usage limit reached." }, + }, + } as OpenCodeEvent) + expect(harness.toasts).toHaveLength(2) + expect(harness.notifications).toHaveLength(2) }) test.each([{ type: "home" }, { type: "session", sessionID: "other" }])( diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index faa18282439f..005a94b3503f 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -81,6 +81,8 @@ const source = { "ui.sessionTurn.retry.geminiHot": "gemini is way too hot right now", "ui.sessionTurn.freeLimit.title": "You've used today's free messages.", "ui.sessionTurn.freeLimit.lineReset": "You've used today's free messages. They reset at {{time}}.", + "ui.sessionTurn.goLimit.title": "You've reached your OpenCode Go usage limit.", + "ui.sessionTurn.goLimit.lineReset": "You've reached your OpenCode Go usage limit. It resets at {{time}}.", "ui.sessionTurn.error.freeUsageExceeded": "Free usage exceeded", "ui.sessionTurn.error.addCredits": "Add credits", @@ -91,7 +93,9 @@ const source = { "dialog.usageExceeded.freeTier.actionLabel": "Subscribe", "dialog.usageExceeded.accountRateLimit.title": "Go limit reached", "dialog.usageExceeded.accountRateLimit.description": - "Usage limit reached. To continue using this model now, enable usage from your available balance", + "You've reached your OpenCode Go usage limit. To keep using Go models now, enable usage from your balance.", + "dialog.usageExceeded.accountRateLimit.descriptionReset": + "You've reached your OpenCode Go usage limit. It resets at {{time}}. To keep using Go models now, enable usage from your balance.", "dialog.usageExceeded.accountRateLimit.actionLabel": "Open settings", "ui.sessionTurn.status.delegating": "Delegating work", From 78ccc2a74a7b4c5165c43143b1019f32c44359f8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 21 Oct 2026 13:41:10 -0700 Subject: [PATCH 2/3] test(app): split the ChatGPT limit check into its own session and scope the Go notice to the status line --- packages/app/e2e/regression/composer.spec.ts | 49 ++++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/packages/app/e2e/regression/composer.spec.ts b/packages/app/e2e/regression/composer.spec.ts index 0d5bf8974921..af956f2fa186 100644 --- a/packages/app/e2e/regression/composer.spec.ts +++ b/packages/app/e2e/regression/composer.spec.ts @@ -259,9 +259,7 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a await expect(view.editor).toHaveText("") }) -test("the Go usage limit opens the Go limit dialog with its reset time and restores the prompt; ChatGPT's opens its own", async ({ - page, -}) => { +test("the Go usage limit opens the Go limit dialog with its reset time and restores the prompt", async ({ page }) => { await page.clock.setFixedTime(T0) const sessionID = "ses_go_limit" const model = { id: "glm-5-go", providerID: "opencode-go" } @@ -313,27 +311,48 @@ test("the Go usage limit opens the Go limit dialog with its reset time and resto const dialog = page.getByRole("dialog", { name: "Go limit reached" }) await expect(dialog).toContainText("You've reached your OpenCode Go usage limit. It resets at") await expect(dialog.getByRole("button", { name: "Open settings", exact: true })).toBeVisible() - await expect(page.getByText(/^You've reached your OpenCode Go usage limit\. It resets at /)).toBeVisible() + await expect( + page.getByRole("status").filter({ hasText: /^You've reached your OpenCode Go usage limit\. It resets at / }), + ).toBeVisible() await expect(view.editor).toHaveText("Refactor the parser") await expect(page.getByText("OpenCode Go usage limit reached.")).toHaveCount(0) await dialog.getByRole("button", { name: "Don't show again", exact: true }).click() await expect(dialog).toHaveCount(0) +}) + +test("ChatGPT's usage limit opens the ChatGPT usage dialog", async ({ page }) => { + await page.clock.setFixedTime(T0) + const sessionID = "ses_chatgpt_limit" + const model = { id: "gpt-5.5", providerID: "openai" } + + const view = await openSession(page, { + name: "ChatGPTLimit", + sessions: [{ id: sessionID, agent: "build", model }], + onFileWrite: () => undefined, + pageMessages: () => ({ + items: [ + { id: "msg_chatgpt_user", type: "user", text: "Refactor the parser", time: { created: T0 } }, + { + id: "msg_chatgpt_assistant", + type: "assistant", + agent: "build", + model, + content: [], + time: { created: T0 + 1 }, + }, + ], + }), + }) + + await expect(page.getByText("Refactor the parser", { exact: true })).toBeVisible() await view.push([ { id: "evt_chatgpt_limit_1", - created: T0 + 3, - type: "session.step.started", - location, - durable: durable(2), - data: { sessionID, assistantMessageID: "msg_chatgpt_assistant", agent: "build", model, started: T0 + 3 }, - }, - { - id: "evt_chatgpt_limit_2", - created: T0 + 4, + created: T0 + 2, type: "session.step.failed", - location, - durable: durable(3), + location: { directory: view.directory }, + durable: { aggregateID: sessionID, seq: 1, version: 1 }, data: { sessionID, assistantMessageID: "msg_chatgpt_assistant", From c70d6f28190466e8d187d4fdbd5facbc99a4f96e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 21 Oct 2026 19:56:29 -0700 Subject: [PATCH 3/3] feat(app): show the Go limit in a tray that grows out of the composer Replaces the Go limit dialog with a tray above the composer: when Go is back, Use credits, and Switch to a free model, which keeps the prompt. If free models run out too while the Go limit holds, the tray says so with both reset times instead of opening the free-tier upsell. --- packages/app/e2e/regression/composer.spec.ts | 69 +++++- packages/app/src/runtime/i18n/en.ts | 9 + .../src/session/composer/go-limit-tray.tsx | 197 ++++++++++++++++++ packages/app/src/session/composer/region.tsx | 5 + .../src/session/usage-exceeded-dialogs.tsx | 49 ++--- 5 files changed, 290 insertions(+), 39 deletions(-) create mode 100644 packages/app/src/session/composer/go-limit-tray.tsx diff --git a/packages/app/e2e/regression/composer.spec.ts b/packages/app/e2e/regression/composer.spec.ts index af956f2fa186..098f5ab9f0c4 100644 --- a/packages/app/e2e/regression/composer.spec.ts +++ b/packages/app/e2e/regression/composer.spec.ts @@ -259,7 +259,9 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a await expect(view.editor).toHaveText("") }) -test("the Go usage limit opens the Go limit dialog with its reset time and restores the prompt", async ({ page }) => { +test("the Go usage limit opens the composer tray, switches to a free model, and says when free models run out too", async ({ + page, +}) => { await page.clock.setFixedTime(T0) const sessionID = "ses_go_limit" const model = { id: "glm-5-go", providerID: "opencode-go" } @@ -268,6 +270,26 @@ test("the Go usage limit opens the Go limit dialog with its reset time and resto name: "GoLimit", sessions: [{ id: sessionID, agent: "build", model }], onFileWrite: () => undefined, + provider: () => ({ + all: [ + { + id: "opencode", + name: "OpenCode", + models: { + "big-pickle": { id: "big-pickle", name: "Big Pickle", cost: { input: 0, output: 0 }, limit: { context: 200_000 } }, + }, + }, + { + id: "opencode-go", + name: "OpenCode Go", + models: { + "glm-5-go": { id: "glm-5-go", name: "GLM-5 Go", cost: { input: 1, output: 1 }, limit: { context: 200_000 } }, + }, + }, + ], + connected: ["opencode", "opencode-go"], + default: { build: "opencode-go/glm-5-go" }, + }), pageMessages: () => ({ items: [ { id: "msg_go_user", type: "user", text: "Refactor the parser", time: { created: T0 } }, @@ -308,16 +330,51 @@ test("the Go usage limit opens the Go limit dialog with its reset time and resto }, ]) - const dialog = page.getByRole("dialog", { name: "Go limit reached" }) - await expect(dialog).toContainText("You've reached your OpenCode Go usage limit. It resets at") - await expect(dialog.getByRole("button", { name: "Open settings", exact: true })).toBeVisible() + const tray = page.locator('[data-component="session-go-limit-tray"]') + await expect(tray).toContainText("Go limit reached.") + await expect(tray).toContainText(/Back at .+, in 1h 0m\./) + await expect(page.getByRole("dialog")).toHaveCount(0) await expect( page.getByRole("status").filter({ hasText: /^You've reached your OpenCode Go usage limit\. It resets at / }), ).toBeVisible() await expect(view.editor).toHaveText("Refactor the parser") await expect(page.getByText("OpenCode Go usage limit reached.")).toHaveCount(0) - await dialog.getByRole("button", { name: "Don't show again", exact: true }).click() - await expect(dialog).toHaveCount(0) + + // One click moves the session to a free model and keeps the prompt. + await tray.getByRole("button", { name: "Switch to a free model", exact: true }).click() + await expect(tray).toHaveCount(0) + await expect(page.locator('[data-action="composer-model"]')).toContainText("Big Pickle") + await expect(view.editor).toHaveText("Refactor the parser") + + // Running out of free messages while the Go limit holds reopens the tray instead of the free-tier upsell. + await view.push([ + { + id: "evt_go_limit_2", + created: T0 + 3, + type: "session.step.started", + location, + durable: durable(2), + data: { sessionID, assistantMessageID: "msg_go_assistant_2", agent: "build", model, started: T0 + 3 }, + }, + { + id: "evt_go_limit_3", + created: T0 + 4, + type: "session.step.failed", + location, + durable: durable(3), + data: { + sessionID, + assistantMessageID: "msg_go_assistant_2", + error: { type: "provider.free-limit", message: "Free usage limit reached.", status: 429, retryAfterMs: 1_800_000 }, + }, + }, + ]) + + await expect(tray).toContainText("Go and free limits reached.") + await expect(tray).toContainText(/Go is back at .+, free models at .+\./) + await expect(tray.getByRole("button", { name: "Use credits", exact: true })).toBeVisible() + await expect(tray.getByRole("button", { name: "Switch to a free model", exact: true })).toHaveCount(0) + await expect(page.getByRole("dialog", { name: "Free limit reached" })).toHaveCount(0) }) test("ChatGPT's usage limit opens the ChatGPT usage dialog", async ({ page }) => { diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index bc610a5e5dfb..f2a50e5a3e23 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -566,6 +566,15 @@ 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.goLimit.title": "Go limit reached.", + "session.goLimit.detail": "Back at {{time}}, in {{left}}.", + "session.goLimit.inHours": "{{hours}}h {{minutes}}m", + "session.goLimit.inMinutes": "{{minutes}}m", + "session.goLimit.both.title": "Go and free limits reached.", + "session.goLimit.both.detail": "Go is back at {{go}}, free models at {{free}}.", + "session.goLimit.both.detailGo": "Go is back at {{go}}.", + "session.goLimit.useCredits": "Use credits", + "session.goLimit.switchFree": "Switch to a free model", "session.queue.count.one": "{{count}} queued", "session.queue.count.other": "{{count}} queued", "session.queue.steer": "Steer", diff --git a/packages/app/src/session/composer/go-limit-tray.tsx b/packages/app/src/session/composer/go-limit-tray.tsx new file mode 100644 index 000000000000..d462bf895492 --- /dev/null +++ b/packages/app/src/session/composer/go-limit-tray.tsx @@ -0,0 +1,197 @@ +import { createMemo, createSignal, onCleanup, Show } from "solid-js" +import type { SessionStepFailed } from "@opencode/client/promise" +import { Button } from "@opencode/ui/button" +import { Icon } from "@opencode/ui/icon" +import { IconButton } from "@opencode/ui/icon-button" +import { useLanguage } from "@/runtime/i18n/language" +import { usePlatform } from "@/runtime/platform/platform" +import { useWorkspaceLocation } from "@/workspaces/location" +import { isFreeModel } from "@/providers/models/models" +import type { ModelSelection } from "@/providers/models/selection" +import { formatLimitReset, goLimitActive, goLimited, goLimitLink } from "../usage-exceeded-dialogs" +import { Grow } from "./spring-height" + +// Go's usage window: limits refill over five hours. +const GO_WINDOW_MS = 5 * 3_600_000 + +type GoLimit = { + /** When Go is back, if the server said. */ + goAt?: number + /** Free models ran out too, after switching away from Go. */ + both: boolean + freeAt?: number + link: string +} + +const resetAt = (event: SessionStepFailed) => { + const ms = event.data.error.retryAfterMs + + return ms !== undefined && Number.isFinite(ms) && ms >= 0 ? event.created + ms : undefined +} + +// Pullout above the composer for a Go subscriber who hit their usage limit: when Go is back, and one click to keep +// going. It grows out from under the composer like the queue panel. +export function SessionGoLimitTray(props: { sessionID: string | undefined; selection: () => ModelSelection | undefined }) { + const language = useLanguage() + const platform = usePlatform() + const location = useWorkspaceLocation() + const [limit, setLimit] = createSignal() + const [open, setOpen] = createSignal(false) + const [now, setNow] = createSignal(Date.now()) + + const timer = setInterval(() => setNow(Date.now()), 15_000) + onCleanup(() => clearInterval(timer)) + + onCleanup( + location().event.on("session.step.failed", (event) => { + if (!props.sessionID || event.data.sessionID !== props.sessionID) return + const type = event.data.error.type + + if (type === "provider.go-limit") { + const goAt = resetAt(event) + goLimited.set(props.sessionID, goAt) + setLimit({ goAt, both: false, link: goLimitLink(event.data.error) }) + setNow(Date.now()) + + return setOpen(true) + } + + if (type === "provider.free-limit" && goLimitActive(props.sessionID)) { + setLimit((current) => ({ + goAt: current?.goAt ?? goLimited.get(props.sessionID!), + link: current?.link ?? goLimitLink(event.data.error), + both: true, + freeAt: resetAt(event), + })) + setNow(Date.now()) + setOpen(true) + } + }), + ) + + const time = (at: number | undefined) => (at === undefined ? undefined : formatLimitReset(language.locale(), at, 0)) + + const left = (at: number | undefined) => { + if (at === undefined) return undefined + const minutes = Math.max(1, Math.round((at - now()) / 60_000)) + const hours = Math.floor(minutes / 60) + + return hours > 0 + ? language.t("session.goLimit.inHours", { hours, minutes: minutes % 60 }) + : language.t("session.goLimit.inMinutes", { minutes }) + } + + // How much of Go's window is still to wait: the ring empties as the reset gets closer. + const remaining = createMemo(() => { + const at = limit()?.goAt + + return at === undefined ? 1 : Math.max(0, Math.min(1, (at - now()) / GO_WINDOW_MS)) + }) + + const text = createMemo(() => { + const current = limit() + + if (!current) return { title: "", detail: "" } + const go = time(current.goAt) + const free = time(current.freeAt) + + if (current.both) { + const detail = () => { + if (go && free) return language.t("session.goLimit.both.detail", { go, free }) + + if (go) return language.t("session.goLimit.both.detailGo", { go }) + + return "" + } + + return { title: language.t("session.goLimit.both.title"), detail: detail() } + } + + return { + title: language.t("session.goLimit.title"), + detail: go ? language.t("session.goLimit.detail", { time: go, left: left(current.goAt) ?? "" }) : "", + } + }) + + const both = () => limit()?.both === true + + const freeModel = () => props.selection()?.list().find(isFreeModel) + + const switchToFree = () => { + const selection = props.selection() + const model = freeModel() + + if (!selection || !model) return + selection.set({ providerID: model.provider.id, modelID: model.id }, { recent: true }) + setOpen(false) + } + + const useCredits = () => { + const current = limit() + + if (current) platform.openExternal(current.link) + } + + return ( + +
+ +
+ {text().title} + {text().detail} +
+
+ + + + + } + aria-label={language.t("common.dismiss")} + onClick={() => setOpen(false)} + /> +
+
+
+ ) +} diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index 5d8455ddc747..79ee568965a7 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -32,6 +32,7 @@ import { createSessionRevert } from "../revert" import { SessionComposerRegion } from "./session-composer-region" import { createSessionComposerController, type SessionComposerController } from "./controller" import { SessionQueuePanel } from "./queue-panel" +import { SessionGoLimitTray } from "./go-limit-tray" import { SessionActivityChips, type SessionActivityItem } from "./activity-chips" import { useOpenSessionRoute } from "../session-identity-header" import { resolveSessionComposerSelection } from "./selection" @@ -483,6 +484,10 @@ export function ActiveSessionComposerRegion(props: { } composer={ diff --git a/packages/app/src/session/usage-exceeded-dialogs.tsx b/packages/app/src/session/usage-exceeded-dialogs.tsx index 5af6ac455dd3..98caa0466d1d 100644 --- a/packages/app/src/session/usage-exceeded-dialogs.tsx +++ b/packages/app/src/session/usage-exceeded-dialogs.tsx @@ -26,7 +26,19 @@ const MAX_RETRY_AFTER_MS = 7 * 86_400_000 const CHATGPT_USAGE_LIMIT_WINDOW = 86_400_000 // 24 hrs -function formatLimitReset(locale: string, created: number, retryAfterMs: number | undefined) { +// Sessions whose Go limit is still in effect, with when Go is back. The composer's Go-limit tray shows the limit; a +// free-limit error in one of these sessions comes from a Go subscriber who switched to a free model, so the tray +// answers it too instead of the free-tier upsell. +export const goLimited = new Map() + +export function goLimitActive(sessionID: string | undefined) { + if (!sessionID || !goLimited.has(sessionID)) return false + const at = goLimited.get(sessionID) + + return at === undefined || at > Date.now() +} + +export function formatLimitReset(locale: string, created: number, retryAfterMs: number | undefined) { if ( retryAfterMs === undefined || !Number.isFinite(retryAfterMs) || @@ -140,35 +152,6 @@ export function useUsageExceededDialogs(input?: { )) } - const showGoLimitDialog = (evt: SessionStepFailed) => { - if (evt.data.sessionID !== params.id || dialog.active) return - const seen = goUpsellState[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT] - - if (seen && Date.now() - seen < GO_UPSELL_WINDOW) return - - if (goUpsellState[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]) return - - setGoUpsellState(GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT, Date.now()) - - const reset = formatLimitReset(locale(), evt.created, evt.data.error.retryAfterMs) - - dialog.show(() => ( - { - if (dontShowAgain) setGoUpsellState(GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW, Date.now()) - }} - /> - )) - } - const whenGoUpsellReady = (show: (evt: SessionStepFailed) => void, evt: SessionStepFailed) => { if (goUpsellReady()) show(evt) else if (goUpsellInit instanceof Promise) void goUpsellInit.then(() => show(evt)) @@ -179,9 +162,9 @@ export function useUsageExceededDialogs(input?: { if (evt.data.sessionID !== params.id || dialog.active) return const error = evt.data.error - if (error.type === "provider.free-limit") return whenGoUpsellReady(showFreeLimitDialog, evt) - - if (error.type === "provider.go-limit") return whenGoUpsellReady(showGoLimitDialog, evt) + // A Go limit, and running out of free models while one is in effect, show in the composer's Go-limit tray. + if (error.type === "provider.free-limit" && !goLimitActive(params.id)) + return whenGoUpsellReady(showFreeLimitDialog, evt) if (error.type !== "provider.chatgpt-limit") return