diff --git a/packages/app/e2e/regression/composer.spec.ts b/packages/app/e2e/regression/composer.spec.ts index 87fe50661dff..4d653950eef1 100644 --- a/packages/app/e2e/regression/composer.spec.ts +++ b/packages/app/e2e/regression/composer.spec.ts @@ -1,9 +1,181 @@ import { expect, test, type Locator, type Page } from "@playwright/test" -import { NO_PROVIDER, T0, provider } from "../utils/app" +import { NO_PROVIDER, T0, expectPath, provider, sessionHref } from "../utils/app" import { openDraft, openSession } from "../utils/workspace" test.use({ permissions: ["clipboard-read", "clipboard-write"] }) +test("the free usage limit opens the Go dialog once, leaves a readable notice, and restores the prompt", async ({ + page, + context, +}) => { + await page.clock.setFixedTime(T0) + await context.route("https://**", (route) => route.fulfill({ body: "Sign in" })) + const sessionID = "ses_free_limit" + const model = { id: "big-pickle", providerID: "opencode" } + const state = { authorized: false } + + const view = await openSession(page, { + name: "FreeLimit", + 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 } }, + }, + }, + ...(state.authorized + ? [ + { + id: "opencode-go", + integrationID: "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: state.authorized ? ["opencode", "opencode-go"] : ["opencode"], + default: { build: "opencode/big-pickle" }, + }), + integrations: () => [ + { + id: "opencode", + name: "OpenCode Console", + methods: [{ id: "device", type: "oauth", label: "OpenCode Console account" }], + connections: state.authorized ? [{ type: "credential", id: "cred_console", label: "Anomaly", method: "oauth" }] : [], + }, + { + id: "opencode-go", + name: "OpenCode Go", + methods: [{ type: "key" }], + connections: state.authorized ? [{ type: "credential", id: "cred_go", label: "Anomaly", method: "oauth" }] : [], + }, + ], + onIntegrationOAuth: () => ({ url: "https://auth.example.test/device?user_code=GOGO-1234" }), + onIntegrationOAuthStatus: () => { + state.authorized = true + + return "complete" + }, + pageMessages: () => ({ + items: [ + { id: "msg_free_user", type: "user", text: "Refactor the parser", time: { created: T0 } }, + { id: "msg_free_assistant", type: "assistant", agent: "build", model, content: [], time: { created: T0 + 1 } }, + ], + }), + }) + + await expect(page.getByText("Refactor the parser", { exact: true })).toBeVisible() + + const error = { + type: "provider.free-limit", + message: "Free usage limit reached.", + status: 429, + retryAfterMs: 3_600_000, + } + + const durable = (seq: number) => ({ aggregateID: sessionID, seq, version: 1 as const }) + const location = { directory: view.directory } + + await view.push([ + { + id: "evt_free_limit_1", + created: T0 + 2, + type: "session.step.failed", + location, + durable: durable(1), + data: { sessionID, assistantMessageID: "msg_free_assistant", error }, + }, + ]) + + 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") + await expect(page.getByText("Free usage limit reached.")).toHaveCount(0) + + // Subscribe -> Go sign-in -> Continue switches the model in the current session without opening a new draft tab. + await upsell.getByRole("button", { name: "Subscribe", exact: true }).click() + const connect = page.getByRole("dialog") + await connect.getByRole("button", { name: "Continue in the browser", exact: true }).click() + const connected = page.getByRole("dialog", { name: "Connected to OpenCode Go" }) + await expect(connected.getByRole("radio", { name: "GLM-5 Go", exact: true })).toHaveAttribute("aria-checked", "true") + await connected.getByRole("button", { name: "Continue", exact: true }).click() + await expect(page.getByRole("dialog")).toHaveCount(0) + await expectPath(page, sessionHref(sessionID)) + await expect(page.locator("[data-titlebar-tab-slot]")).toHaveCount(1) + await expect(page.locator('[data-action="composer-model"]')).toContainText("GLM-5 Go") + await expect(view.editor).toHaveText("Refactor the parser") + + // After the 24-hour window, "Don't show again" permanently suppresses the dialog across reloads, and an attachment-only draft survives. + const day2 = T0 + 2 * 86_400_000 + await page.clock.setFixedTime(day2) + + await view.push([ + { + id: "evt_free_limit_2", + created: day2 + 1, + type: "session.step.started", + location, + durable: durable(2), + data: { sessionID, assistantMessageID: "msg_free_assistant_2", agent: "build", model, started: day2 + 1 }, + }, + { + id: "evt_free_limit_3", + created: day2 + 2, + type: "session.step.failed", + location, + durable: durable(3), + data: { sessionID, assistantMessageID: "msg_free_assistant_2", error }, + }, + ]) + + await upsell.getByRole("button", { name: "Don't show again", exact: true }).click() + await expect(upsell).toHaveCount(0) + + const later = T0 + 4 * 86_400_000 + await page.clock.setFixedTime(later) + await page.reload() + await expect(view.editor).toBeVisible() + await view.editor.fill("") + await page.locator('input[type="file"]').setInputFiles({ + name: "diagram.png", + mimeType: "image/png", + buffer: Buffer.from("png-bytes"), + }) + await expect(page.locator('[data-component="composer-attachments"]')).toContainText("diagram.png") + + await view.push([ + { + id: "evt_free_limit_4", + created: later + 3, + type: "session.step.started", + location, + durable: durable(4), + data: { sessionID, assistantMessageID: "msg_free_assistant_3", agent: "build", model, started: later + 3 }, + }, + { + id: "evt_free_limit_5", + created: later + 4, + type: "session.step.failed", + location, + durable: durable(5), + data: { sessionID, assistantMessageID: "msg_free_assistant_3", error }, + }, + ]) + + await expect(page.getByRole("status").filter({ hasText: "You've used today's free messages." })).toBeVisible() + await expect(page.getByRole("dialog")).toHaveCount(0) + await expect(page.locator('[data-component="composer-attachments"]')).toContainText("diagram.png") + await expect(view.editor).toHaveText("") +}) + 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 072f61caf18c..1315a869f353 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -4,7 +4,7 @@ import { useDialog } from "@opencode/ui/context/dialog" import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode/ui/scroll-view" import { makeEventListener } from "@solid-primitives/event-listener" import { useNavigate } from "@solidjs/router" -import { createEffect, createMemo, on, onMount, Show, type Accessor } from "solid-js" +import { createEffect, createMemo, on, onCleanup, onMount, Show, type Accessor } from "solid-js" import { Composer } from "@/composer/composer" import { useComposerState } from "@/composer/persistence" import { createComposerControls } from "@/composer/selection" @@ -190,6 +190,43 @@ export function createActiveSessionRegion(input: { const revertMessage: NonNullable = ({ messageID }) => revert.to(messageID) + 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. + onCleanup( + location().event.on("session.step.failed", (event) => { + const sessionID = input.session.identity.params.id + + if (!sessionID || event.data.sessionID !== sessionID || event.data.error.type !== "provider.free-limit") return + + if (recalled.has(event.data.assistantMessageID)) return + recalled.add(event.data.assistantMessageID) + + if ( + !prompt.ready() || + !prompt.current().every((part) => part.type === "text" && part.content.length === 0) || + prompt.context.items().length > 0 + ) + return + + const messages = input.session.history.messages() + const end = messages.findIndex((item) => item.id === event.data.assistantMessageID) + + for (let i = end - 1; i >= 0; i--) { + const item = messages[i] + + if (!item || item.type === "shell" || item.type === "idle") return + + if (item.type === "user" && !data.session.input.has(sessionID, item.id)) { + revert.recall(item) + + return + } + } + }), + ) + const notifyFailed = () => { showToast({ title: language.t("common.requestFailed") }) } diff --git a/packages/app/src/session/revert.ts b/packages/app/src/session/revert.ts index 94eed7b1118a..ca79e163561a 100644 --- a/packages/app/src/session/revert.ts +++ b/packages/app/src/session/revert.ts @@ -115,7 +115,10 @@ export function createSessionRevert(input: { ) } - return { to, undo, redo } + /** Puts a sent prompt back in the composer without rewinding the Session. */ + const recall = (message: SessionMessageUser) => restore(prompt.capture(), message) + + return { to, undo, redo, recall } } export type SessionRevert = ReturnType diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index fb5a261f0e8d..a2cd38935d0c 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -225,7 +225,10 @@ function SessionScreenContent(props: { }) props.bindBackground(composer.requests.background.tasks) - useUsageExceededDialogs() + useUsageExceededDialogs({ + selection: () => composer.active()?.composer.model.selection, + onDone: () => composer.active()?.composer.restoreFocus(), + }) const sessionErrorFallback = (cause: unknown, reset: () => void) => { createEffect(on(session.identity.sessionKey, reset, { defer: true })) diff --git a/packages/app/src/session/usage-exceeded-dialogs.tsx b/packages/app/src/session/usage-exceeded-dialogs.tsx index fbc1ff55d004..47cc06c5fedf 100644 --- a/packages/app/src/session/usage-exceeded-dialogs.tsx +++ b/packages/app/src/session/usage-exceeded-dialogs.tsx @@ -7,6 +7,7 @@ import { Persistence } from "@/runtime/persistence/schema" import { useSessionLayout } from "./session-layout" import { useDialog, useI18n } from "@opencode/ui/context" import { DialogUsageExceeded } from "@/providers/connect/usage-exceeded" +import { useLocal, type ModelSelection } from "@/providers/models/selection" const GO_UPSELL_FREE_TIER_LAST_SEEN_AT = "go_upsell_last_seen_at" @@ -20,8 +21,35 @@ 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) { + if ( + retryAfterMs === undefined || + !Number.isFinite(retryAfterMs) || + retryAfterMs < 0 || + retryAfterMs > MAX_RETRY_AFTER_MS || + !Number.isFinite(created) + ) + return undefined + + const at = created + retryAfterMs + + if (Number.isNaN(new Date(at).getTime())) return undefined + + const now = Date.now() + const sameDay = new Date(at).toDateString() === new Date(now).toDateString() && at - now < GO_UPSELL_WINDOW + + return new Intl.DateTimeFormat( + locale, + sameDay ? { timeStyle: "short" } : { dateStyle: "medium", timeStyle: "short" }, + ).format(at) +} + export function isChatGPTUsageLimit(error: SessionStepFailed["data"]["error"]) { return ( error.message === @@ -42,13 +70,6 @@ function goUpsellKeys(status: SessionStatus) { if (!GO_UPSELL_PROVIDERS.has(action.provider)) return - if (action.reason === "free_tier_limit") { - return { - lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT, - dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW, - } as const - } - if (action.reason === "account_rate_limit") { return { lastSeenAt: GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT, @@ -57,18 +78,26 @@ function goUpsellKeys(status: SessionStatus) { } } -export function useUsageExceededDialogs() { +export function useUsageExceededDialogs(input?: { + selection?: () => ModelSelection | undefined + onDone?: () => void +}) { const sdk = useWorkspaceLocation() const dialog = useDialog() + const local = useLocal() const { params } = useSessionLayout() - const { tDynamic } = useI18n() - - const [goUpsellState, setGoUpsellState] = persisted(Persist.global("go-upsell"), GoUpsellState, { - [GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: null, - [GO_UPSELL_FREE_TIER_DONT_SHOW]: null, - [GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: null, - [GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: null, - }) + const { t, tDynamic, locale } = useI18n() + + const [goUpsellState, setGoUpsellState, goUpsellInit, goUpsellReady] = persisted( + Persist.global("go-upsell"), + GoUpsellState, + { + [GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: null, + [GO_UPSELL_FREE_TIER_DONT_SHOW]: null, + [GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: null, + [GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: null, + }, + ) const [chatgptUsageLimit, setChatGPTUsageLimit] = persisted( Persist.global("chatgpt-usage-limit"), @@ -76,11 +105,63 @@ export function useUsageExceededDialogs() { { lastSeenAt: null }, ) + const showFreeLimitDialog = (evt: SessionStepFailed) => { + if (evt.data.sessionID !== params.id || dialog.active) return + const seen = goUpsellState[GO_UPSELL_FREE_TIER_LAST_SEEN_AT] + + if (seen && Date.now() - seen < GO_UPSELL_WINDOW) return + + if (goUpsellState[GO_UPSELL_FREE_TIER_DONT_SHOW]) return + + // 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) + + dialog.show(() => ( + { + if (dontShowAgain) setGoUpsellState(GO_UPSELL_FREE_TIER_DONT_SHOW, Date.now()) + else { + void import("@/providers/connect/dialog").then((x) => { + const controller = x.useProviderConnectController() + controller.select("opencode-go") + void dialog.show(() => ( + + )) + }) + } + }} + /> + )) + } + onCleanup( sdk().event.on("session.step.failed", (evt) => { - if (evt.data.sessionID !== params.id) return + 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)) + + return + } - if (!isChatGPTUsageLimit(evt.data.error) || dialog.active) return + if (!isChatGPTUsageLimit(error)) return if (chatgptUsageLimit.lastSeenAt && Date.now() - chatgptUsageLimit.lastSeenAt < CHATGPT_USAGE_LIMIT_WINDOW) return @@ -113,28 +194,7 @@ export function useUsageExceededDialogs() { if (goUpsellState[keys.dontShow]) return - if (action.reason === "free_tier_limit") { - dialog.show(() => ( - { - setGoUpsellState(keys.lastSeenAt, Date.now()) - - if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now()) - else { - void import("@/providers/connect/dialog").then((x) => { - const controller = x.useProviderConnectController() - controller.select("opencode-go") - void dialog.show(() => ) - }) - } - }} - /> - )) - } else if (action.reason === "account_rate_limit") { + if (action.reason === "account_rate_limit") { dialog.show(() => ( & { time: { created: number; completed?: number } }, +) { + if (message.error?.type !== "provider.free-limit") return {} + const delay = message.error.retryAfterMs + const base = message.time.completed ?? message.time.created + + if ( + delay === undefined || + !Number.isFinite(delay) || + delay < 0 || + delay > MAX_RETRY_AFTER_MS || + !Number.isFinite(base) || + Number.isNaN(new Date(base + delay).getTime()) + ) + return { kind: "free-limit" as const } + + return { + kind: "free-limit" as const, + resetAt: base + delay, + } +} + function isInterrupted(error: SessionMessageAssistant["error"]) { return error?.type.toLowerCase().includes("abort") || error?.type.toLowerCase().includes("interrupt") } diff --git a/packages/session-ui/src/timeline/rows-current.test.ts b/packages/session-ui/src/timeline/rows-current.test.ts index 816e95cf48a6..b0039cb60dd2 100644 --- a/packages/session-ui/src/timeline/rows-current.test.ts +++ b/packages/session-ui/src/timeline/rows-current.test.ts @@ -1451,6 +1451,63 @@ describe("current session timeline rows", () => { ) }) + test("marks the free usage limit and its reset time, 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 + + const assistant = { + id: "msg_assistant", + type: "assistant", + agent: "build", + model: { id: "big-pickle", providerID: "opencode" }, + content: [], + finish: "error", + error, + time: { created: 2, completed: 1_000 }, + } satisfies SessionMessageAssistant + + const step = [user, assistant] satisfies SessionMessageInfo[] + + const idle = [ + user, + { id: "msg_idle", type: "idle", outcome: "failed", error, time: { created: 2_000 } }, + ] satisfies SessionMessageInfo[] + + const quota = [ + user, + { ...assistant, error: { type: "provider.quota", message: "Go usage limit exceeded", retryAfterMs: 60_000 } }, + ] satisfies SessionMessageInfo[] + + expect(Timeline.constructSessionMessageRows(step, true, { type: "idle" }).rows.at(-1)).toEqual( + new TimelineRow.Error({ + userMessageID: "msg_user", + text: "Free usage limit reached.", + kind: "free-limit", + resetAt: 61_000, + }), + ) + expect(Timeline.constructSessionMessageRows(idle, true, { type: "idle" }).rows.at(-1)).toMatchObject({ + kind: "free-limit", + resetAt: 62_000, + }) + expect( + Timeline.constructSessionMessageRows( + [user, { ...assistant, error: { ...error, retryAfterMs: 9_000_000_000_000_000 } }], + true, + { type: "idle" }, + ).rows.at(-1), + ).toEqual( + new TimelineRow.Error({ + userMessageID: "msg_user", + text: "Free usage limit reached.", + kind: "free-limit", + }), + ) + expect(Timeline.constructSessionMessageRows(quota, true, { type: "idle" }).rows.at(-1)).toEqual( + new TimelineRow.Error({ userMessageID: "msg_user", text: "Go usage limit exceeded" }), + ) + }) + test("summarizes a turn's changed files after the idle marker that ends it", () => { const step = (id: string, files?: string[]) => ({ diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index 6515f49d891d..2d4d3e0b0392 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -1010,10 +1010,37 @@ export function createSessionTimelineRowRenderer(input: { return value } + const freeLimit = createMemo(() => { + if (current().kind !== "free-limit") return + const at = current().resetAt + + if (at === undefined || !Number.isFinite(at) || Number.isNaN(new Date(at).getTime())) + return i18n.t("ui.sessionTurn.freeLimit.title") + + const now = Date.now() + const sameDay = new Date(at).toDateString() === new Date(now).toDateString() && at - now < 86_400_000 + + const time = new Intl.DateTimeFormat( + i18n.locale(), + sameDay ? { timeStyle: "short" } : { dateStyle: "medium", timeStyle: "short" }, + ).format(at) + + return i18n.t("ui.sessionTurn.freeLimit.lineReset", { time }) + }) + return (
- + }> + {(text) => ( +
+ {text()} +
+ )} +
) diff --git a/packages/session-ui/src/timeline/timeline-row.ts b/packages/session-ui/src/timeline/timeline-row.ts index 8d81e4db7d72..3ff5b4465cbf 100644 --- a/packages/session-ui/src/timeline/timeline-row.ts +++ b/packages/session-ui/src/timeline/timeline-row.ts @@ -57,9 +57,15 @@ export namespace TimelineRow { ref: PartRef }> {} + /** + * `free-limit` marks OpenCode Zen's daily free allowance running out; `resetAt` is when the allowance comes back, if + * known. + */ export class Error extends Data.TaggedClass("Error")<{ userMessageID: string text: string + kind?: "free-limit" + resetAt?: number }> {} export class Retry extends Data.TaggedClass("Retry")<{ @@ -142,7 +148,7 @@ export type TimelineRowMap = { } Thinking: { userMessageID: string; ref: PartRef } Retry: { userMessageID: string } - Error: { userMessageID: string; text: string } + Error: { userMessageID: string; text: string; kind?: "free-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/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 783b2e06b784..7186c1f0f472 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -75,12 +75,15 @@ const source = { "ui.sessionTurn.retry.attemptWaiting.other": "Attempt {{attempt}} - retrying in {{count}}s", "ui.sessionTurn.retry.attemptRetryingNow": "Attempt {{attempt}} - retrying", "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.error.freeUsageExceeded": "Free usage exceeded", "ui.sessionTurn.error.addCredits": "Add credits", "dialog.usageExceeded.freeTier.title": "Free limit reached", "dialog.usageExceeded.freeTier.description": "Subscribe to OpenCode Go for reliable access to the best open-source models for $10/month.", + "dialog.usageExceeded.freeTier.reset": "Your free messages reset at {{time}}.", "dialog.usageExceeded.freeTier.actionLabel": "Subscribe", "dialog.usageExceeded.accountRateLimit.title": "Go limit reached", "dialog.usageExceeded.accountRateLimit.description":