diff --git a/packages/app/e2e/regression/composer.spec.ts b/packages/app/e2e/regression/composer.spec.ts index 72650470485b..e57638b7cb12 100644 --- a/packages/app/e2e/regression/composer.spec.ts +++ b/packages/app/e2e/regression/composer.spec.ts @@ -107,7 +107,12 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a id: "opencode", name: "OpenCode", models: { - "big-pickle": { id: "big-pickle", name: "Big Pickle", cost: { input: 0, output: 0 }, limit: { context: 200_000 } }, + "big-pickle": { + id: "big-pickle", + name: "Big Pickle", + cost: { input: 0, output: 0 }, + limit: { context: 200_000 }, + }, }, }, ...(state.authorized @@ -117,7 +122,12 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a 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 } }, + "glm-5-go": { + id: "glm-5-go", + name: "GLM-5 Go", + cost: { input: 1, output: 1 }, + limit: { context: 200_000 }, + }, }, }, ] @@ -131,7 +141,9 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a 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" }] : [], + connections: state.authorized + ? [{ type: "credential", id: "cred_console", label: "Anomaly", method: "oauth" }] + : [], }, { id: "opencode-go", @@ -261,62 +273,116 @@ 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 restores the prompt and leaves a muted notice without a dialog", async ({ page }) => { - await page.clock.setFixedTime(T0) - const sessionID = "ses_go_limit" - const model = { id: "glm-5-go", providerID: "opencode-go" } +for (const action of ["tray", "picker"] as const) { + test(`the Go usage limit restores the prompt and dismisses the tray when switching models through the ${action}`, async ({ + page, + context, + }) => { + await page.clock.setFixedTime(T0) + await context.route("https://opencode.ai/**", (route) => route.fulfill({ body: "Go" })) + 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 } }, - ], - }), - }) + const view = await openSession(page, { + 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 } }, + { id: "msg_go_assistant", type: "assistant", agent: "build", model, content: [], time: { created: T0 + 1 } }, + ], + }), + }) - await expect(page.getByText("Refactor the parser", { exact: true })).toBeVisible() + 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 } + // The refusal OpenCode Console sends a Go subscriber at a limit, as core records it. + const body = { + type: "error", + error: { type: "GoUsageLimitError", message: "Go usage limit exceeded" }, + metadata: { workspace: "wrk_go_limit", limitName: "5 hour" }, + } - 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, + await view.push([ + { + id: "evt_go_limit_1", + created: T0 + 2, + type: "session.step.failed", + location: { directory: view.directory }, + durable: { aggregateID: sessionID, seq: 1, version: 1 }, + data: { + sessionID, + assistantMessageID: "msg_go_assistant", + error: { + type: "provider.go-limit", + message: "OpenCode Go 5 hour usage limit reached.", + status: 429, + response: { body: JSON.stringify(body) }, + retryAfterMs: 3_600_000, + }, }, }, - }, - ]) + ]) - 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 expect(page.getByRole("dialog")).toHaveCount(0) -}) + const tray = page.locator('[data-component="session-go-limit-tray"]') + await expect(tray).toContainText(/Go 5 hour usage limit reached\. It resets at .+?\./) + 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 5 hour usage limit reached.")).toHaveCount(0) + await expect(page.getByRole("dialog")).toHaveCount(0) + + // Extra Usage is turned on from the workspace's Go page in OpenCode Console. + const popup = page.waitForEvent("popup") + await tray.getByRole("button", { name: "Turn on Extra Usage", exact: true }).click() + await expect(await popup).toHaveURL("https://opencode.ai/console/wrk_go_limit/go") + + if (action === "tray") await tray.getByRole("button", { name: "Switch to a free model", exact: true }).click() + + if (action === "picker") { + await page.locator('[data-action="composer-model"]').click() + const picker = page.getByRole("dialog", { name: "Select model", exact: true }) + await picker.getByRole("combobox", { name: "Search models", exact: true }).fill("Big Pickle") + await picker.getByRole("option", { name: /Big Pickle/ }).click() + } + + await expect(page.locator('[data-action="composer-model"]')).toContainText("Big Pickle") + await expect(tray).toHaveCount(0) + await expect(view.editor).toHaveText("Refactor the parser") + }) +} test("ChatGPT's usage limit opens the ChatGPT usage dialog", async ({ page }) => { await page.clock.setFixedTime(T0) diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 6e4d6c5cc76b..2015d1d75e48 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -602,6 +602,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.goLimit.title": "Go usage limit reached.", + "session.goLimit.titleNamed": "Go {{name}} usage limit reached.", + "session.goLimit.reset": "It resets at {{time}}.", + "session.goLimit.extraUsage": "Turn on Extra Usage", + "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..c63d00a9b4fe --- /dev/null +++ b/packages/app/src/session/composer/go-limit-tray.tsx @@ -0,0 +1,137 @@ +import { createEffect, createMemo, createSignal, on, onCleanup, Show } from "solid-js" +import { Option, Schema } from "effect" +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 } from "../usage-exceeded-dialogs" +import { Grow } from "./spring-height" + +// OpenCode Console names the workspace and the limit (5 hour, weekly or monthly) in its Go usage limit refusal. +const decodeGoLimitBody = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + metadata: Schema.Struct({ workspace: Schema.optional(Schema.String), limitName: Schema.optional(Schema.String) }), + }), + ), +) + +type GoLimit = { name?: string; reset?: string; link: string } + +function goLimit(locale: string, event: SessionStepFailed): GoLimit { + const metadata = Option.getOrUndefined(decodeGoLimitBody(event.data.error.response?.body))?.metadata + const workspace = metadata?.workspace + + return { + name: metadata?.limitName, + reset: formatLimitReset(locale, event.created, event.data.error.retryAfterMs), + // The Go page is where the workspace turns on Extra Usage to keep going on its balance. + link: workspace ? `https://opencode.ai/console/${encodeURIComponent(workspace)}/go` : "https://opencode.ai/console", + } +} + +// A tray that grows out from under the composer, like the queue panel, when an OpenCode Go subscriber reaches a usage +// limit: when Go is back, and how to keep going now. +export function SessionGoLimitTray(props: { + sessionID: string | undefined + selection: () => ModelSelection | undefined +}) { + const language = useLanguage() + const platform = usePlatform() + const location = useWorkspaceLocation() + const [limit, setLimit] = createSignal() + + const model = createMemo(() => { + const current = props.selection()?.current() + + return current && `${current.provider.id}:${current.id}` + }) + + createEffect(on(model, () => setLimit(undefined), { defer: true })) + + onCleanup( + location().event.on("session.step.failed", (event) => { + if (!props.sessionID || event.data.sessionID !== props.sessionID) return + + if (event.data.error.type === "provider.go-limit") setLimit(goLimit(language.locale(), event)) + }), + ) + + // The next step means the user found a way to keep going, so the tray has done its job. + onCleanup( + location().event.on("session.step.started", (event) => { + if (event.data.sessionID === props.sessionID) setLimit(undefined) + }), + ) + + const title = () => { + const name = limit()?.name + + return name ? language.t("session.goLimit.titleNamed", { name }) : language.t("session.goLimit.title") + } + + const freeModel = () => props.selection()?.list().find(isFreeModel) + + const switchToFree = () => { + const model = freeModel() + + if (!model) return + props.selection()?.set({ providerID: model.provider.id, modelID: model.id }, { recent: true }) + setLimit(undefined) + } + + return ( + + {(current) => ( +
+
+ {title()} + {(time) => <> {language.t("session.goLimit.reset", { time: time() })}} +
+
+ + + + + } + aria-label={language.t("common.dismiss")} + onClick={() => setLimit(undefined)} + /> +
+
+ )} +
+ ) +} diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index c03ae9983635..b8d610797eef 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -37,6 +37,7 @@ import { useSessionFork } from "../fork" 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 { SessionComposerLane, useComposerLaneAlignment } from "./composer-lane" import { SessionTodoPill } from "./todo-pill" @@ -578,6 +579,10 @@ export function ActiveSessionComposerRegion(props: {