feat(web): i18n (#12471)

This commit is contained in:
Adam
2026-02-06 08:54:51 -06:00
committed by GitHub
parent 0ec5f6608b
commit 812597bb8b
75 changed files with 9868 additions and 726 deletions

View File

@@ -3,11 +3,13 @@ import { Title } from "@solidjs/meta"
import { HttpStatusCode } from "@solidjs/start"
import logoLight from "../asset/logo-ornate-light.svg"
import logoDark from "../asset/logo-ornate-dark.svg"
import { useI18n } from "~/context/i18n"
export default function NotFound() {
const i18n = useI18n()
return (
<main data-page="not-found">
<Title>Not Found | opencode</Title>
<Title>{i18n.t("notFound.title")}</Title>
<HttpStatusCode code={404} />
<div data-component="content">
<section data-component="top">
@@ -15,21 +17,21 @@ export default function NotFound() {
<img data-slot="logo light" src={logoLight} alt="opencode logo light" />
<img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
</a>
<h1 data-slot="title">404 - Page Not Found</h1>
<h1 data-slot="title">{i18n.t("notFound.heading")}</h1>
</section>
<section data-component="actions">
<div data-slot="action">
<a href="/">Home</a>
<a href="/">{i18n.t("notFound.home")}</a>
</div>
<div data-slot="action">
<a href="/docs">Docs</a>
<a href="/docs">{i18n.t("notFound.docs")}</a>
</div>
<div data-slot="action">
<a href="https://github.com/anomalyco/opencode">GitHub</a>
<a href="https://github.com/anomalyco/opencode">{i18n.t("notFound.github")}</a>
</div>
<div data-slot="action">
<a href="/discord">Discord</a>
<a href="/discord">{i18n.t("notFound.discord")}</a>
</div>
</section>
</div>

View File

@@ -3,6 +3,7 @@ import { createAsync, query, useParams } from "@solidjs/router"
import { createSignal, For, Show } from "solid-js"
import { Database, desc, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
import { useI18n } from "~/context/i18n"
interface TaskSource {
repo: string
@@ -100,32 +101,33 @@ function formatDuration(ms: number): string {
export default function BenchDetail() {
const params = useParams()
const i18n = useI18n()
const [benchmarkId, taskId] = (params.id ?? "").split(":")
const task = createAsync(() => queryTaskDetail(benchmarkId, taskId))
return (
<main data-page="bench-detail">
<Title>Benchmark - {taskId}</Title>
<Title>{i18n.t("bench.detail.title", { task: taskId })}</Title>
<div style={{ padding: "1rem" }}>
<Show when={task()} fallback={<p>Task not found</p>}>
<Show when={task()} fallback={<p>{i18n.t("bench.detail.notFound")}</p>}>
<div style={{ "margin-bottom": "1rem" }}>
<div>
<strong>Agent: </strong>
{task()?.agent ?? "N/A"}
<strong>{i18n.t("bench.detail.labels.agent")}: </strong>
{task()?.agent ?? i18n.t("bench.detail.na")}
</div>
<div>
<strong>Model: </strong>
{task()?.model ?? "N/A"}
<strong>{i18n.t("bench.detail.labels.model")}: </strong>
{task()?.model ?? i18n.t("bench.detail.na")}
</div>
<div>
<strong>Task: </strong>
<strong>{i18n.t("bench.detail.labels.task")}: </strong>
{task()!.task.id}
</div>
</div>
<div style={{ "margin-bottom": "1rem" }}>
<div>
<strong>Repo: </strong>
<strong>{i18n.t("bench.detail.labels.repo")}: </strong>
<a
href={`https://github.com/${task()!.task.source.repo}`}
target="_blank"
@@ -136,7 +138,7 @@ export default function BenchDetail() {
</a>
</div>
<div>
<strong>From: </strong>
<strong>{i18n.t("bench.detail.labels.from")}: </strong>
<a
href={`https://github.com/${task()!.task.source.repo}/commit/${task()!.task.source.from}`}
target="_blank"
@@ -147,7 +149,7 @@ export default function BenchDetail() {
</a>
</div>
<div>
<strong>To: </strong>
<strong>{i18n.t("bench.detail.labels.to")}: </strong>
<a
href={`https://github.com/${task()!.task.source.repo}/commit/${task()!.task.source.to}`}
target="_blank"
@@ -161,11 +163,13 @@ export default function BenchDetail() {
<Show when={task()?.task.prompts && task()!.task.prompts!.length > 0}>
<div style={{ "margin-bottom": "1rem" }}>
<strong>Prompt:</strong>
<strong>{i18n.t("bench.detail.labels.prompt")}:</strong>
<For each={task()!.task.prompts}>
{(p) => (
<div style={{ "margin-top": "0.5rem" }}>
<div style={{ "font-size": "0.875rem", color: "#666" }}>Commit: {p.commit.slice(0, 7)}</div>
<div style={{ "font-size": "0.875rem", color: "#666" }}>
{i18n.t("bench.detail.labels.commit")}: {p.commit.slice(0, 7)}
</div>
<p style={{ "margin-top": "0.25rem", "white-space": "pre-wrap" }}>{p.prompt}</p>
</div>
)}
@@ -177,33 +181,35 @@ export default function BenchDetail() {
<div style={{ "margin-bottom": "1rem" }}>
<div>
<strong>Average Duration: </strong>
{task()?.averageDuration ? formatDuration(task()!.averageDuration!) : "N/A"}
<strong>{i18n.t("bench.detail.labels.averageDuration")}: </strong>
{task()?.averageDuration ? formatDuration(task()!.averageDuration!) : i18n.t("bench.detail.na")}
</div>
<div>
<strong>Average Score: </strong>
{task()?.averageScore?.toFixed(3) ?? "N/A"}
<strong>{i18n.t("bench.detail.labels.averageScore")}: </strong>
{task()?.averageScore?.toFixed(3) ?? i18n.t("bench.detail.na")}
</div>
<div>
<strong>Average Cost: </strong>
{task()?.averageUsage?.cost ? `$${task()!.averageUsage!.cost.toFixed(4)}` : "N/A"}
<strong>{i18n.t("bench.detail.labels.averageCost")}: </strong>
{task()?.averageUsage?.cost ? `$${task()!.averageUsage!.cost.toFixed(4)}` : i18n.t("bench.detail.na")}
</div>
</div>
<Show when={task()?.summary}>
<div style={{ "margin-bottom": "1rem" }}>
<strong>Summary:</strong>
<strong>{i18n.t("bench.detail.labels.summary")}:</strong>
<p style={{ "margin-top": "0.5rem", "white-space": "pre-wrap" }}>{task()!.summary}</p>
</div>
</Show>
<Show when={task()?.runs && task()!.runs!.length > 0}>
<div style={{ "margin-bottom": "1rem" }}>
<strong>Runs:</strong>
<strong>{i18n.t("bench.detail.labels.runs")}:</strong>
<table style={{ "margin-top": "0.5rem", "border-collapse": "collapse", width: "100%" }}>
<thead>
<tr>
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Run</th>
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>
{i18n.t("bench.detail.table.run")}
</th>
<th
style={{
border: "1px solid #ccc",
@@ -212,10 +218,14 @@ export default function BenchDetail() {
"white-space": "nowrap",
}}
>
Score (Base - Penalty)
{i18n.t("bench.detail.table.score")}
</th>
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>
{i18n.t("bench.detail.table.cost")}
</th>
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>
{i18n.t("bench.detail.table.duration")}
</th>
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Cost</th>
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Duration</th>
<For each={task()!.runs![0]?.scoreDetails}>
{(detail) => (
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>
@@ -234,10 +244,10 @@ export default function BenchDetail() {
{run.score.final.toFixed(3)} ({run.score.base.toFixed(3)} - {run.score.penalty.toFixed(3)})
</td>
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>
{run.usage?.cost ? `$${run.usage.cost.toFixed(4)}` : "N/A"}
{run.usage?.cost ? `$${run.usage.cost.toFixed(4)}` : i18n.t("bench.detail.na")}
</td>
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>
{run.duration ? formatDuration(run.duration) : "N/A"}
{run.duration ? formatDuration(run.duration) : i18n.t("bench.detail.na")}
</td>
<For each={run.scoreDetails}>
{(detail) => (
@@ -265,17 +275,17 @@ export default function BenchDetail() {
<For each={task()!.runs}>
{(run, index) => (
<div style={{ "margin-top": "1rem" }}>
<h3 style={{ margin: "0 0 0.5rem 0" }}>Run {index() + 1}</h3>
<h3 style={{ margin: "0 0 0.5rem 0" }}>{i18n.t("bench.detail.run.title", { n: index() + 1 })}</h3>
<div>
<strong>Score: </strong>
{run.score.final.toFixed(3)} (Base: {run.score.base.toFixed(3)} - Penalty:{" "}
{run.score.penalty.toFixed(3)})
<strong>{i18n.t("bench.detail.labels.score")}: </strong>
{run.score.final.toFixed(3)} ({i18n.t("bench.detail.labels.base")}: {run.score.base.toFixed(3)} -{" "}
{i18n.t("bench.detail.labels.penalty")}: {run.score.penalty.toFixed(3)})
</div>
<For each={run.scoreDetails}>
{(detail) => (
<div style={{ "margin-top": "1rem", "padding-left": "1rem", "border-left": "2px solid #ccc" }}>
<div>
{detail.criterion} (weight: {detail.weight}){" "}
{detail.criterion} ({i18n.t("bench.detail.labels.weight")}: {detail.weight}){" "}
<For each={detail.judges}>
{(judge) => (
<span
@@ -350,7 +360,7 @@ export default function BenchDetail() {
onClick={() => setJsonExpanded(!jsonExpanded())}
>
<span style={{ "margin-right": "0.5rem" }}>{jsonExpanded() ? "▼" : "▶"}</span>
Raw JSON
{i18n.t("bench.detail.rawJson")}
</button>
<Show when={jsonExpanded()}>
<pre>{JSON.stringify(task(), null, 2)}</pre>

View File

@@ -3,6 +3,7 @@ import { A, createAsync, query } from "@solidjs/router"
import { createMemo, For, Show } from "solid-js"
import { Database, desc } from "@opencode-ai/console-core/drizzle/index.js"
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
import { useI18n } from "~/context/i18n"
interface BenchmarkResult {
averageScore: number
@@ -33,6 +34,7 @@ async function getBenchmarks() {
const queryBenchmarks = query(getBenchmarks, "benchmarks.list")
export default function Bench() {
const i18n = useI18n()
const benchmarks = createAsync(() => queryBenchmarks())
const taskIds = createMemo(() => {
@@ -47,14 +49,14 @@ export default function Bench() {
return (
<main data-page="bench" style={{ padding: "2rem" }}>
<Title>Benchmark</Title>
<h1 style={{ "margin-bottom": "1.5rem" }}>Benchmarks</h1>
<Title>{i18n.t("bench.list.title")}</Title>
<h1 style={{ "margin-bottom": "1.5rem" }}>{i18n.t("bench.list.heading")}</h1>
<table style={{ "border-collapse": "collapse", width: "100%" }}>
<thead>
<tr>
<th style={{ "text-align": "left", padding: "0.75rem" }}>Agent</th>
<th style={{ "text-align": "left", padding: "0.75rem" }}>Model</th>
<th style={{ "text-align": "left", padding: "0.75rem" }}>Score</th>
<th style={{ "text-align": "left", padding: "0.75rem" }}>{i18n.t("bench.list.table.agent")}</th>
<th style={{ "text-align": "left", padding: "0.75rem" }}>{i18n.t("bench.list.table.model")}</th>
<th style={{ "text-align": "left", padding: "0.75rem" }}>{i18n.t("bench.list.table.score")}</th>
<For each={taskIds()}>{(id) => <th style={{ "text-align": "left", padding: "0.75rem" }}>{id}</th>}</For>
</tr>
</thead>

View File

@@ -3,14 +3,19 @@ import { Title, Meta, Link } from "@solidjs/meta"
import { createMemo, createSignal } from "solid-js"
import { github } from "~/lib/github"
import { config } from "~/config"
import { useLanguage } from "~/context/language"
import { LanguagePicker } from "~/component/language-picker"
import { useI18n } from "~/context/i18n"
import Spotlight, { defaultConfig, type SpotlightAnimationState } from "~/component/spotlight"
import "./black.css"
export default function BlackLayout(props: RouteSectionProps) {
const language = useLanguage()
const i18n = useI18n()
const githubData = createAsync(() => github())
const starCount = createMemo(() =>
githubData()?.stars
? new Intl.NumberFormat("en-US", {
? new Intl.NumberFormat(language.tag(language.locale()), {
notation: "compact",
compactDisplay: "short",
}).format(githubData()!.stars!)
@@ -63,26 +68,17 @@ export default function BlackLayout(props: RouteSectionProps) {
return (
<div data-page="black">
<Title>OpenCode Black | Access all the world's best coding models</Title>
<Meta
name="description"
content="Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans."
/>
<Title>{i18n.t("black.meta.title")}</Title>
<Meta name="description" content={i18n.t("black.meta.description")} />
<Link rel="canonical" href={`${config.baseUrl}/black`} />
<Meta property="og:type" content="website" />
<Meta property="og:url" content={`${config.baseUrl}/black`} />
<Meta property="og:title" content="OpenCode Black | Access all the world's best coding models" />
<Meta
property="og:description"
content="Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans."
/>
<Meta property="og:title" content={i18n.t("black.meta.title")} />
<Meta property="og:description" content={i18n.t("black.meta.description")} />
<Meta property="og:image" content="/social-share-black.png" />
<Meta name="twitter:card" content="summary_large_image" />
<Meta name="twitter:title" content="OpenCode Black | Access all the world's best coding models" />
<Meta
name="twitter:description"
content="Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans."
/>
<Meta name="twitter:title" content={i18n.t("black.meta.title")} />
<Meta name="twitter:description" content={i18n.t("black.meta.description")} />
<Meta name="twitter:image" content="/social-share-black.png" />
<Spotlight config={spotlightConfig} class="header-spotlight" onAnimationFrame={handleAnimationFrame} />
@@ -156,8 +152,8 @@ export default function BlackLayout(props: RouteSectionProps) {
</header>
<main data-component="content">
<div data-slot="hero">
<h1>Access all the world's best coding models</h1>
<p>Including Claude, GPT, Gemini and more</p>
<h1>{i18n.t("black.hero.title")}</h1>
<p>{i18n.t("black.hero.subtitle")}</p>
</div>
<div data-slot="hero-black" style={svgLightingStyle()}>
<svg width="591" height="90" viewBox="0 0 591 90" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -266,14 +262,15 @@ export default function BlackLayout(props: RouteSectionProps) {
©{new Date().getFullYear()} <a href="https://anoma.ly">Anomaly</a>
</span>
<a href={config.github.repoUrl} target="_blank">
GitHub <span data-slot="github-stars">[{starCount()}]</span>
{i18n.t("nav.github")} <span data-slot="github-stars">[{starCount()}]</span>
</a>
<a href="/docs">Docs</a>
<a href="/docs">{i18n.t("nav.docs")}</a>
<LanguagePicker align="right" />
<span>
<A href="/legal/privacy-policy">Privacy</A>
<A href="/legal/privacy-policy">{i18n.t("legal.privacy")}</A>
</span>
<span>
<A href="/legal/terms-of-service">Terms</A>
<A href="/legal/terms-of-service">{i18n.t("legal.terms")}</A>
</span>
</div>
<span data-slot="anomaly-alt">

View File

@@ -1,26 +1,29 @@
import { Match, Switch } from "solid-js"
import { useI18n } from "~/context/i18n"
export const plans = [
{ id: "20", multiplier: null },
{ id: "100", multiplier: "5x more usage than Black 20" },
{ id: "200", multiplier: "20x more usage than Black 20" },
{ id: "100", multiplier: "black.plan.multiplier100" },
{ id: "200", multiplier: "black.plan.multiplier200" },
] as const
export type PlanID = (typeof plans)[number]["id"]
export type Plan = (typeof plans)[number]
export function PlanIcon(props: { plan: string }) {
const i18n = useI18n()
return (
<Switch>
<Match when={props.plan === "20"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>Black 20 plan</title>
<title>{i18n.t("black.plan.icon20")}</title>
<rect x="0.5" y="0.5" width="23" height="23" stroke="currentColor" />
</svg>
</Match>
<Match when={props.plan === "100"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>Black 100 plan</title>
<title>{i18n.t("black.plan.icon100")}</title>
<rect x="0.5" y="0.5" width="9" height="9" stroke="currentColor" />
<rect x="0.5" y="14.5" width="9" height="9" stroke="currentColor" />
<rect x="14.5" y="0.5" width="9" height="9" stroke="currentColor" />
@@ -29,7 +32,7 @@ export function PlanIcon(props: { plan: string }) {
</Match>
<Match when={props.plan === "200"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>Black 200 plan</title>
<title>{i18n.t("black.plan.icon200")}</title>
<rect x="0.5" y="0.5" width="3" height="3" stroke="currentColor" />
<rect x="0.5" y="5.5" width="3" height="3" stroke="currentColor" />
<rect x="0.5" y="10.5" width="3" height="3" stroke="currentColor" />

View File

@@ -2,9 +2,11 @@ import { A, useSearchParams } from "@solidjs/router"
import { Title } from "@solidjs/meta"
import { createMemo, createSignal, For, Match, onMount, Show, Switch } from "solid-js"
import { PlanIcon, plans } from "./common"
import { useI18n } from "~/context/i18n"
export default function Black() {
const [params] = useSearchParams()
const i18n = useI18n()
const [selected, setSelected] = createSignal<string | null>((params.plan as string) || null)
const [mounted, setMounted] = createSignal(false)
const selectedPlan = createMemo(() => plans.find((p) => p.id === selected()))
@@ -36,7 +38,7 @@ export default function Black() {
return (
<>
<Title>opencode</Title>
<Title>{i18n.t("black.title")}</Title>
<section data-slot="cta">
<Switch>
<Match when={!selected()}>
@@ -53,9 +55,10 @@ export default function Black() {
<PlanIcon plan={plan.id} />
</div>
<p data-slot="price">
<span data-slot="amount">${plan.id}</span> <span data-slot="period">per month</span>
<span data-slot="amount">${plan.id}</span>{" "}
<span data-slot="period">{i18n.t("black.price.perMonth")}</span>
<Show when={plan.multiplier}>
<span data-slot="multiplier">{plan.multiplier}</span>
{(multiplier) => <span data-slot="multiplier">{i18n.t(multiplier())}</span>}
</Show>
</p>
</button>
@@ -72,26 +75,26 @@ export default function Black() {
</div>
<p data-slot="price">
<span data-slot="amount">${plan().id}</span>{" "}
<span data-slot="period">per person billed monthly</span>
<span data-slot="period">{i18n.t("black.price.perPersonBilledMonthly")}</span>
<Show when={plan().multiplier}>
<span data-slot="multiplier">{plan().multiplier}</span>
{(multiplier) => <span data-slot="multiplier">{i18n.t(multiplier())}</span>}
</Show>
</p>
<ul data-slot="terms" style={{ "view-transition-name": `terms-${plan().id}` }}>
<li>Your subscription will not start immediately</li>
<li>You will be added to the waitlist and activated soon</li>
<li>Your card will be only charged when your subscription is activated</li>
<li>Usage limits apply, heavily automated use may reach limits sooner</li>
<li>Subscriptions for individuals, contact Enterprise for teams</li>
<li>Limits may be adjusted and plans may be discontinued in the future</li>
<li>Cancel your subscription at anytime</li>
<li>{i18n.t("black.terms.1")}</li>
<li>{i18n.t("black.terms.2")}</li>
<li>{i18n.t("black.terms.3")}</li>
<li>{i18n.t("black.terms.4")}</li>
<li>{i18n.t("black.terms.5")}</li>
<li>{i18n.t("black.terms.6")}</li>
<li>{i18n.t("black.terms.7")}</li>
</ul>
<div data-slot="actions" style={{ "view-transition-name": `actions-${plan().id}` }}>
<button type="button" onClick={() => cancel()} data-slot="cancel">
Cancel
{i18n.t("common.cancel")}
</button>
<a href={`/black/subscribe/${plan().id}`} data-slot="continue">
Continue
{i18n.t("black.action.continue")}
</a>
</div>
</div>
@@ -100,7 +103,8 @@ export default function Black() {
</Match>
</Switch>
<p data-slot="fine-print" style={{ "view-transition-name": "fine-print" }}>
Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
{i18n.t("black.finePrint.beforeTerms")} ·{" "}
<A href="/legal/terms-of-service">{i18n.t("black.finePrint.terms")}</A>
</p>
</section>
</>

View File

@@ -14,6 +14,8 @@ import { createList } from "solid-list"
import { Modal } from "~/component/modal"
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { useI18n } from "~/context/i18n"
import { formError } from "~/lib/form-error"
const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record<PlanID, (typeof plans)[number]>
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!)
@@ -56,8 +58,8 @@ const createSetupIntent = async (input: { plan: string; workspaceID: string }) =
"use server"
const { plan, workspaceID } = input
if (!plan || !["20", "100", "200"].includes(plan)) return { error: "Invalid plan" }
if (!workspaceID) return { error: "Workspace ID is required" }
if (!plan || !["20", "100", "200"].includes(plan)) return { error: formError.invalidPlan }
if (!workspaceID) return { error: formError.workspaceRequired }
return withActor(async () => {
const session = await useAuthSession()
@@ -75,7 +77,7 @@ const createSetupIntent = async (input: { plan: string; workspaceID: string }) =
.then((rows) => rows[0]),
)
if (customer?.subscriptionID) {
return { error: "This workspace already has a subscription" }
return { error: formError.alreadySubscribed }
}
let customerID = customer?.customerID
@@ -142,28 +144,34 @@ interface SuccessData {
}
function Failure(props: { message: string }) {
const i18n = useI18n()
return (
<div data-slot="failure">
<p data-slot="message">Uh oh! {props.message}</p>
<p data-slot="message">
{i18n.t("black.subscribe.failurePrefix")} {props.message}
</p>
</div>
)
}
function Success(props: SuccessData) {
const i18n = useI18n()
return (
<div data-slot="success">
<p data-slot="title">You're on the OpenCode Black waitlist</p>
<p data-slot="title">{i18n.t("black.subscribe.success.title")}</p>
<dl data-slot="details">
<div>
<dt>Subscription plan</dt>
<dd>OpenCode Black {props.plan}</dd>
<dt>{i18n.t("black.subscribe.success.subscriptionPlan")}</dt>
<dd>{i18n.t("black.subscribe.success.planName", { plan: props.plan })}</dd>
</div>
<div>
<dt>Amount</dt>
<dd>${props.plan} per month</dd>
<dt>{i18n.t("black.subscribe.success.amount")}</dt>
<dd>{i18n.t("black.subscribe.success.amountValue", { plan: props.plan })}</dd>
</div>
<div>
<dt>Payment method</dt>
<dt>{i18n.t("black.subscribe.success.paymentMethod")}</dt>
<dd>
<Show when={props.paymentMethodLast4} fallback={<span>{props.paymentMethodType}</span>}>
<span>
@@ -173,16 +181,17 @@ function Success(props: SuccessData) {
</dd>
</div>
<div>
<dt>Date joined</dt>
<dd>{new Date().toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</dd>
<dt>{i18n.t("black.subscribe.success.dateJoined")}</dt>
<dd>{new Date().toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}</dd>
</div>
</dl>
<p data-slot="charge-notice">Your card will be charged when your subscription is activated</p>
<p data-slot="charge-notice">{i18n.t("black.subscribe.success.chargeNotice")}</p>
</div>
)
}
function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data: SuccessData) => void }) {
const i18n = useI18n()
const stripe = useStripe()
const elements = useElements()
const [error, setError] = createSignal<string | undefined>(undefined)
@@ -197,7 +206,7 @@ function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data
const result = await elements()!.submit()
if (result.error) {
setError(result.error.message ?? "An error occurred")
setError(result.error.message ?? i18n.t("black.subscribe.error.generic"))
setLoading(false)
return
}
@@ -214,7 +223,7 @@ function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data
})
if (confirmError) {
setError(confirmError.message ?? "An error occurred")
setError(confirmError.message ?? i18n.t("black.subscribe.error.generic"))
setLoading(false)
return
}
@@ -248,15 +257,16 @@ function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data
<p data-slot="error">{error()}</p>
</Show>
<button type="submit" disabled={loading() || !stripe() || !elements()} data-slot="submit-button">
{loading() ? "Processing..." : `Subscribe $${props.plan}`}
{loading() ? i18n.t("black.subscribe.processing") : i18n.t("black.subscribe.submit", { plan: props.plan })}
</button>
<p data-slot="charge-notice">You will only be charged when your subscription is activated</p>
<p data-slot="charge-notice">{i18n.t("black.subscribe.form.chargeNotice")}</p>
</form>
)
}
export default function BlackSubscribe() {
const params = useParams()
const i18n = useI18n()
const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"]
const plan = planData.id
@@ -267,6 +277,16 @@ export default function BlackSubscribe() {
const [clientSecret, setClientSecret] = createSignal<string | undefined>(undefined)
const [stripe, setStripe] = createSignal<Stripe | undefined>(undefined)
const formatError = (error: string) => {
if (error === formError.invalidPlan) return i18n.t("black.subscribe.error.invalidPlan")
if (error === formError.workspaceRequired) return i18n.t("black.subscribe.error.workspaceRequired")
if (error === formError.alreadySubscribed) return i18n.t("black.subscribe.error.alreadySubscribed")
if (error === "Invalid plan") return i18n.t("black.subscribe.error.invalidPlan")
if (error === "Workspace ID is required") return i18n.t("black.subscribe.error.workspaceRequired")
if (error === "This workspace already has a subscription") return i18n.t("black.subscribe.error.alreadySubscribed")
return error
}
// Resolve stripe promise once
createEffect(() => {
stripePromise.then((s) => {
@@ -289,7 +309,7 @@ export default function BlackSubscribe() {
const ws = workspaces()?.find((w) => w.id === id)
if (ws?.billing?.subscriptionID) {
setFailure("This workspace already has a subscription")
setFailure(i18n.t("black.subscribe.error.alreadySubscribed"))
return
}
if (ws?.billing?.paymentMethodID) {
@@ -312,7 +332,7 @@ export default function BlackSubscribe() {
const result = await createSetupIntent({ plan, workspaceID: id })
if (result.error) {
setFailure(result.error)
setFailure(formatError(result.error))
} else if ("clientSecret" in result) {
setClientSecret(result.clientSecret)
}
@@ -338,7 +358,7 @@ export default function BlackSubscribe() {
return (
<>
<Title>Subscribe to OpenCode Black</Title>
<Title>{i18n.t("black.subscribe.title")}</Title>
<section data-slot="subscribe-form">
<div data-slot="form-card">
<Switch>
@@ -347,22 +367,27 @@ export default function BlackSubscribe() {
<Match when={true}>
<>
<div data-slot="plan-header">
<p data-slot="title">Subscribe to OpenCode Black</p>
<p data-slot="title">{i18n.t("black.subscribe.title")}</p>
<p data-slot="price">
<span data-slot="amount">${planData.id}</span> <span data-slot="period">per month</span>
<span data-slot="amount">${planData.id}</span>{" "}
<span data-slot="period">{i18n.t("black.price.perMonth")}</span>
<Show when={planData.multiplier}>
<span data-slot="multiplier">{planData.multiplier}</span>
{(multiplier) => <span data-slot="multiplier">{i18n.t(multiplier())}</span>}
</Show>
</p>
</div>
<div data-slot="divider" />
<p data-slot="section-title">Payment method</p>
<p data-slot="section-title">{i18n.t("black.subscribe.paymentMethod")}</p>
<Show
when={clientSecret() && selectedWorkspace() && stripe()}
fallback={
<div data-slot="loading">
<p>{selectedWorkspace() ? "Loading payment form..." : "Select a workspace to continue"}</p>
<p>
{selectedWorkspace()
? i18n.t("black.subscribe.loadingPaymentForm")
: i18n.t("black.subscribe.selectWorkspaceToContinue")}
</p>
</div>
}
>
@@ -410,7 +435,7 @@ export default function BlackSubscribe() {
</div>
{/* Workspace picker modal */}
<Modal open={showWorkspacePicker() ?? false} onClose={() => {}} title="Select a workspace for this plan">
<Modal open={showWorkspacePicker() ?? false} onClose={() => {}} title={i18n.t("black.workspace.selectPlan")}>
<div data-slot="workspace-picker">
<ul
ref={listRef}
@@ -441,7 +466,8 @@ export default function BlackSubscribe() {
</div>
</Modal>
<p data-slot="fine-print">
Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
{i18n.t("black.finePrint.beforeTerms")} ·{" "}
<A href="/legal/terms-of-service">{i18n.t("black.finePrint.terms")}</A>
</p>
</section>
</>

View File

@@ -5,13 +5,18 @@ import { github } from "~/lib/github"
import { createEffect, createMemo, For, onMount } from "solid-js"
import { config } from "~/config"
import { createList } from "solid-list"
import { useLanguage } from "~/context/language"
import { LanguagePicker } from "~/component/language-picker"
import { useI18n } from "~/context/i18n"
export default function BlackWorkspace() {
const navigate = useNavigate()
const language = useLanguage()
const i18n = useI18n()
const githubData = createAsync(() => github())
const starCount = createMemo(() =>
githubData()?.stars
? new Intl.NumberFormat("en-US", {
? new Intl.NumberFormat(language.tag(language.locale()), {
notation: "compact",
compactDisplay: "short",
}).format(githubData()!.stars!)
@@ -20,15 +25,18 @@ export default function BlackWorkspace() {
// TODO: Frank, replace with real workspaces
const workspaces = [
{ name: "Workspace 1", id: "wrk_123" },
{ name: "Workspace 2", id: "wrk_456" },
{ name: "Workspace 3", id: "wrk_789" },
{ name: "Workspace 4", id: "wrk_111" },
{ name: "Workspace 5", id: "wrk_222" },
{ name: "Workspace 6", id: "wrk_333" },
{ name: "Workspace 7", id: "wrk_444" },
{ name: "Workspace 8", id: "wrk_555" },
]
{ id: "wrk_123", n: 1 },
{ id: "wrk_456", n: 2 },
{ id: "wrk_789", n: 3 },
{ id: "wrk_111", n: 4 },
{ id: "wrk_222", n: 5 },
{ id: "wrk_333", n: 6 },
{ id: "wrk_444", n: 7 },
{ id: "wrk_555", n: 8 },
].map((workspace) => ({
...workspace,
name: i18n.t("black.workspace.name", { n: workspace.n }),
}))
let listRef: HTMLUListElement | undefined
@@ -51,7 +59,7 @@ export default function BlackWorkspace() {
return (
<div data-page="black">
<Title>opencode</Title>
<Title>{i18n.t("black.workspace.title")}</Title>
<div data-component="header-gradient" />
<header data-component="header">
<div data-component="header-logo">
@@ -171,7 +179,7 @@ export default function BlackWorkspace() {
</svg>
</div>
<section data-slot="select-workspace">
<p data-slot="select-workspace-title">Select a workspace for this plan</p>
<p data-slot="select-workspace-title">{i18n.t("black.workspace.selectPlan")}</p>
<ul
ref={listRef}
data-slot="workspaces"
@@ -210,14 +218,15 @@ export default function BlackWorkspace() {
©{new Date().getFullYear()} <a href="https://anoma.ly">Anomaly</a>
</span>
<a href={config.github.repoUrl} target="_blank">
GitHub <span data-slot="github-stars">[{starCount()}]</span>
{i18n.t("nav.github")} <span data-slot="github-stars">[{starCount()}]</span>
</a>
<a href="/docs">Docs</a>
<a href="/docs">{i18n.t("nav.docs")}</a>
<LanguagePicker align="right" />
<span>
<A href="/legal/privacy-policy">Privacy</A>
<A href="/legal/privacy-policy">{i18n.t("legal.privacy")}</A>
</span>
<span>
<A href="/legal/terms-of-service">Terms</A>
<A href="/legal/terms-of-service">{i18n.t("legal.terms")}</A>
</span>
</div>
<span data-slot="anomaly-alt">

View File

@@ -118,6 +118,7 @@
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
@media (max-width: 55rem) {
display: none;

View File

@@ -4,6 +4,7 @@ import { Header } from "~/component/header"
import { config } from "~/config"
import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal"
import { useI18n } from "~/context/i18n"
import previewLogoLight from "../../asset/brand/preview-opencode-logo-light.png"
import previewLogoDark from "../../asset/brand/preview-opencode-logo-dark.png"
import previewWordmarkLight from "../../asset/brand/preview-opencode-wordmark-light.png"
@@ -25,6 +26,7 @@ import wordmarkSimpleDarkSvg from "../../asset/brand/opencode-wordmark-simple-da
const brandAssets = "/opencode-brand-assets.zip"
export default function Brand() {
const i18n = useI18n()
const downloadFile = async (url: string, filename: string) => {
try {
const response = await fetch(url)
@@ -53,21 +55,21 @@ export default function Brand() {
return (
<main data-page="enterprise">
<Title>OpenCode | Brand</Title>
<Title>{i18n.t("brand.title")}</Title>
<Link rel="canonical" href={`${config.baseUrl}/brand`} />
<Meta name="description" content="OpenCode brand guidelines" />
<Meta name="description" content={i18n.t("brand.meta.description")} />
<div data-component="container">
<Header />
<div data-component="content">
<section data-component="brand-content">
<h1>Brand guidelines</h1>
<p>Resources and assets to help you work with the OpenCode brand.</p>
<h1>{i18n.t("brand.heading")}</h1>
<p>{i18n.t("brand.subtitle")}</p>
<button
data-component="download-button"
onClick={() => downloadFile(brandAssets, "opencode-brand-assets.zip")}
>
Download all assets
{i18n.t("brand.downloadAll")}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"

View File

@@ -113,6 +113,7 @@
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
@media (max-width: 55rem) {
display: none;

View File

@@ -8,10 +8,12 @@ import { config } from "~/config"
import { changelog } from "~/lib/changelog"
import type { HighlightGroup } from "~/lib/changelog"
import { For, Show, createSignal } from "solid-js"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
function formatDate(dateString: string) {
function formatDate(dateString: string, locale: string) {
const date = new Date(dateString)
return date.toLocaleDateString("en-US", {
return date.toLocaleDateString(locale, {
year: "numeric",
month: "short",
day: "numeric",
@@ -97,28 +99,30 @@ function CollapsibleSections(props: { sections: { title: string; items: string[]
}
export default function Changelog() {
const i18n = useI18n()
const language = useLanguage()
const data = createAsync(() => changelog())
const releases = () => data() ?? []
return (
<main data-page="changelog">
<Title>OpenCode | Changelog</Title>
<Title>{i18n.t("changelog.title")}</Title>
<Link rel="canonical" href={`${config.baseUrl}/changelog`} />
<Meta name="description" content="OpenCode release notes and changelog" />
<Meta name="description" content={i18n.t("changelog.meta.description")} />
<div data-component="container">
<Header />
<div data-component="content">
<section data-component="changelog-hero">
<h1>Changelog</h1>
<p>New updates and improvements to OpenCode</p>
<h1>{i18n.t("changelog.hero.title")}</h1>
<p>{i18n.t("changelog.hero.subtitle")}</p>
</section>
<section data-component="releases">
<Show when={releases().length === 0}>
<p>
No changelog entries found. <a href="/changelog.json">View JSON</a>
{i18n.t("changelog.empty")} <a href="/changelog.json">{i18n.t("changelog.viewJson")}</a>
</p>
</Show>
<For each={releases()}>
@@ -131,7 +135,7 @@ export default function Changelog() {
{release.tag}
</a>
</div>
<time dateTime={release.date}>{formatDate(release.date)}</time>
<time dateTime={release.date}>{formatDate(release.date, language.tag(language.locale()))}</time>
</header>
<div data-slot="content">
<Show when={release.highlights.length > 0}>

View File

@@ -1,12 +1,18 @@
import type { APIEvent } from "@solidjs/start/server"
import { localeFromCookieHeader, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
const url = new URL(req.url)
const targetUrl = `https://docs.opencode.ai${url.pathname}${url.search}`
const headers = new Headers(req.headers)
const locale = localeFromCookieHeader(req.headers.get("cookie"))
if (locale) headers.set("accept-language", tag(locale))
const response = await fetch(targetUrl, {
method: req.method,
headers: req.headers,
headers,
body: req.body,
})
return response

View File

@@ -1,12 +1,18 @@
import type { APIEvent } from "@solidjs/start/server"
import { localeFromCookieHeader, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
const url = new URL(req.url)
const targetUrl = `https://docs.opencode.ai${url.pathname}${url.search}`
const headers = new Headers(req.headers)
const locale = localeFromCookieHeader(req.headers.get("cookie"))
if (locale) headers.set("accept-language", tag(locale))
const response = await fetch(targetUrl, {
method: req.method,
headers: req.headers,
headers,
body: req.body,
})
return response

View File

@@ -114,6 +114,7 @@
border-radius: 4px;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
@media (max-width: 55rem) {
display: none;

View File

@@ -10,6 +10,7 @@ import { Legal } from "~/component/legal"
import { config } from "~/config"
import { createSignal, onMount, Show, JSX } from "solid-js"
import { DownloadPlatform } from "./types"
import { useI18n } from "~/context/i18n"
type OS = "macOS" | "Windows" | "Linux" | null
@@ -64,6 +65,7 @@ function CopyStatus() {
}
export default function Download() {
const i18n = useI18n()
const [detectedOS, setDetectedOS] = createSignal<OS>(null)
onMount(() => {
@@ -80,24 +82,24 @@ export default function Download() {
}
return (
<main data-page="download">
<Title>OpenCode | Download</Title>
<Title>{i18n.t("download.title")}</Title>
<Link rel="canonical" href={`${config.baseUrl}/download`} />
<Meta name="description" content="Download OpenCode for macOS, Windows, and Linux" />
<Meta name="description" content={i18n.t("download.meta.description")} />
<div data-component="container">
<Header hideGetStarted />
<div data-component="content">
<section data-component="download-hero">
<div data-component="hero-icon">
<img src={desktopAppIcon} alt="OpenCode Desktop" />
<img src={desktopAppIcon} alt="" />
</div>
<div data-component="hero-text">
<h1>Download OpenCode</h1>
<p>Available in Beta for macOS, Windows, and Linux</p>
<h1>{i18n.t("download.hero.title")}</h1>
<p>{i18n.t("download.hero.subtitle")}</p>
<Show when={detectedOS()}>
<a href={getDownloadHref(getDownloadPlatform(detectedOS()))} data-component="download-button">
<IconDownload />
Download for {detectedOS()}
{i18n.t("download.hero.button", { os: detectedOS()! })}
</a>
</Show>
</div>
@@ -105,7 +107,7 @@ export default function Download() {
<section data-component="download-section">
<div data-component="section-label">
<span>[1]</span> OpenCode Terminal
<span>[1]</span> {i18n.t("download.section.terminal")}
</div>
<div data-component="section-content">
<button
@@ -146,7 +148,7 @@ export default function Download() {
<section data-component="download-section">
<div data-component="section-label">
<span>[2]</span> OpenCode Desktop (Beta)
<span>[2]</span> {i18n.t("download.section.desktop")}
</div>
<div data-component="section-content">
<button data-component="cli-row" onClick={handleCopyClick("brew install --cask opencode-desktop")}>
@@ -165,12 +167,10 @@ export default function Download() {
/>
</svg>
</span>
<span>
macOS (<span data-slot="hide-narrow">Apple </span>Silicon)
</span>
<span>{i18n.t("download.platform.macosAppleSilicon")}</span>
</div>
<a href={getDownloadHref("darwin-aarch64-dmg")} data-component="action-button">
Download
{i18n.t("download.action.download")}
</a>
</div>
<div data-component="download-row">
@@ -183,10 +183,10 @@ export default function Download() {
/>
</svg>
</span>
<span>macOS (Intel)</span>
<span>{i18n.t("download.platform.macosIntel")}</span>
</div>
<a href={getDownloadHref("darwin-x64-dmg")} data-component="action-button">
Download
{i18n.t("download.action.download")}
</a>
</div>
<div data-component="download-row">
@@ -206,10 +206,10 @@ export default function Download() {
</defs>
</svg>
</span>
<span>Windows (x64)</span>
<span>{i18n.t("download.platform.windowsX64")}</span>
</div>
<a href={getDownloadHref("windows-x64-nsis")} data-component="action-button">
Download
{i18n.t("download.action.download")}
</a>
</div>
<div data-component="download-row">
@@ -222,10 +222,10 @@ export default function Download() {
/>
</svg>
</span>
<span>Linux (.deb)</span>
<span>{i18n.t("download.platform.linuxDeb")}</span>
</div>
<a href={getDownloadHref("linux-x64-deb")} data-component="action-button">
Download
{i18n.t("download.action.download")}
</a>
</div>
<div data-component="download-row">
@@ -238,10 +238,10 @@ export default function Download() {
/>
</svg>
</span>
<span>Linux (.rpm)</span>
<span>{i18n.t("download.platform.linuxRpm")}</span>
</div>
<a href={getDownloadHref("linux-x64-rpm")} data-component="action-button">
Download
{i18n.t("download.action.download")}
</a>
</div>
{/* Disabled temporarily as it doesn't work */}
@@ -266,7 +266,7 @@ export default function Download() {
<section data-component="download-section">
<div data-component="section-label">
<span>[3]</span> OpenCode Extensions
<span>[3]</span> {i18n.t("download.section.extensions")}
</div>
<div data-component="section-content">
<div data-component="download-row">
@@ -289,7 +289,7 @@ export default function Download() {
<span>VS Code</span>
</div>
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
Install
{i18n.t("download.action.install")}
</a>
</div>
@@ -313,7 +313,7 @@ export default function Download() {
<span>Cursor</span>
</div>
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
Install
{i18n.t("download.action.install")}
</a>
</div>
@@ -330,7 +330,7 @@ export default function Download() {
<span>Zed</span>
</div>
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
Install
{i18n.t("download.action.install")}
</a>
</div>
@@ -347,7 +347,7 @@ export default function Download() {
<span>Windsurf</span>
</div>
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
Install
{i18n.t("download.action.install")}
</a>
</div>
@@ -364,7 +364,7 @@ export default function Download() {
<span>VSCodium</span>
</div>
<a href="https://opencode.ai/docs/ide/" data-component="action-button">
Install
{i18n.t("download.action.install")}
</a>
</div>
</div>
@@ -372,7 +372,7 @@ export default function Download() {
<section data-component="download-section">
<div data-component="section-label">
<span>[4]</span> OpenCode Integrations
<span>[4]</span> {i18n.t("download.section.integrations")}
</div>
<div data-component="section-content">
<div data-component="download-row">
@@ -388,7 +388,7 @@ export default function Download() {
<span>GitHub</span>
</div>
<a href="https://opencode.ai/docs/github/" data-component="action-button">
Install
{i18n.t("download.action.install")}
</a>
</div>
@@ -405,7 +405,7 @@ export default function Download() {
<span>GitLab</span>
</div>
<a href="https://opencode.ai/docs/gitlab/" data-component="action-button">
Install
{i18n.t("download.action.install")}
</a>
</div>
</div>
@@ -414,61 +414,57 @@ export default function Download() {
<section data-component="faq">
<div data-slot="section-title">
<h3>FAQ</h3>
<h3>{i18n.t("common.faq")}</h3>
</div>
<ul>
<li>
<Faq question="What is OpenCode?">
OpenCode is an open source agent that helps you write and run code with any AI model. It's available as
a terminal-based interface, desktop app, or IDE extension.
<Faq question={i18n.t("home.faq.q1")}>{i18n.t("home.faq.a1")}</Faq>
</li>
<li>
<Faq question={i18n.t("home.faq.q2")}>
{i18n.t("home.faq.a2.before")} <a href="/docs">{i18n.t("home.faq.a2.link")}</a>.
</Faq>
</li>
<li>
<Faq question="How do I use OpenCode?">
The easiest way to get started is to read the <a href="/docs">intro</a>.
</Faq>
</li>
<li>
<Faq question="Do I need extra AI subscriptions to use OpenCode?">
Not necessarily, but probably. You'll need an AI subscription if you want to connect OpenCode to a paid
provider, although you can work with{" "}
<Faq question={i18n.t("home.faq.q3")}>
{i18n.t("download.faq.a3.beforeLocal")}{" "}
<a href="/docs/providers/#lm-studio" target="_blank">
local models
{i18n.t("download.faq.a3.localLink")}
</a>{" "}
for free. While we encourage users to use <A href="/zen">Zen</A>, OpenCode works with all popular
providers such as OpenAI, Anthropic, xAI etc.
{i18n.t("download.faq.a3.afterLocal.beforeZen")} <A href="/zen">{i18n.t("nav.zen")}</A>
{i18n.t("download.faq.a3.afterZen")}
</Faq>
</li>
<li>
<Faq question="Can I only use OpenCode in the terminal?">
Not anymore! OpenCode is now available as an app for your <a href="/download">desktop</a> and{" "}
<a href="/docs/cli/#web">web</a>!
<Faq question={i18n.t("home.faq.q5")}>
{i18n.t("home.faq.a5.beforeDesktop")} <a href="/download">{i18n.t("home.faq.a5.desktop")}</a>{" "}
{i18n.t("home.faq.a5.and")} <a href="/docs/cli/#web">{i18n.t("home.faq.a5.web")}</a>!
</Faq>
</li>
<li>
<Faq question="How much does OpenCode cost?">
OpenCode is 100% free to use. Any additional costs will come from your subscription to a model provider.
While OpenCode works with any model provider, we recommend using <A href="/zen">Zen</A>.
<Faq question={i18n.t("home.faq.q6")}>
{i18n.t("download.faq.a5.p1")} {i18n.t("download.faq.a5.p2.beforeZen")}{" "}
<A href="/zen">{i18n.t("nav.zen")}</A>
{i18n.t("download.faq.a5.p2.afterZen")}
</Faq>
</li>
<li>
<Faq question="What about data and privacy?">
Your data and information is only stored when you create sharable links in OpenCode. Learn more about{" "}
<a href="/docs/share/#privacy">share pages</a>.
<Faq question={i18n.t("home.faq.q7")}>
{i18n.t("download.faq.a6.p1")} {i18n.t("download.faq.a6.p2.beforeShare")}{" "}
<a href="/docs/share/#privacy">{i18n.t("download.faq.a6.shareLink")}</a>.
</Faq>
</li>
<li>
<Faq question="Is OpenCode open source?">
Yes, OpenCode is fully open source. The source code is public on{" "}
<Faq question={i18n.t("home.faq.q8")}>
{i18n.t("home.faq.a8.p1")}{" "}
<a href={config.github.repoUrl} target="_blank">
GitHub
{i18n.t("nav.github")}
</a>{" "}
under the{" "}
{i18n.t("home.faq.a8.p2")}{" "}
<a href={`${config.github.repoUrl}?tab=MIT-1-ov-file#readme`} target="_blank">
MIT License
{i18n.t("home.faq.a8.mitLicense")}
</a>
, meaning anyone can use, modify, or contribute to its development. Anyone from the community can file
issues, submit pull requests, and extend functionality.
{i18n.t("home.faq.a8.p3")}
</Faq>
</li>
</ul>

View File

@@ -117,6 +117,7 @@
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
@media (max-width: 55rem) {
display: none;

View File

@@ -6,8 +6,10 @@ import { Header } from "~/component/header"
import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal"
import { Faq } from "~/component/faq"
import { useI18n } from "~/context/i18n"
export default function Enterprise() {
const i18n = useI18n()
const [formData, setFormData] = createSignal({
name: "",
role: "",
@@ -54,9 +56,9 @@ export default function Enterprise() {
return (
<main data-page="enterprise">
<Title>OpenCode | Enterprise solutions for your organisation</Title>
<Title>{i18n.t("enterprise.title")}</Title>
<Link rel="canonical" href={`${config.baseUrl}/enterprise`} />
<Meta name="description" content="Contact OpenCode for enterprise solutions" />
<Meta name="description" content={i18n.t("enterprise.meta.description")} />
<div data-component="container">
<Header />
@@ -64,13 +66,9 @@ export default function Enterprise() {
<section data-component="enterprise-content">
<div data-component="enterprise-columns">
<div data-component="enterprise-column-1">
<h1>Your code is yours</h1>
<p>
OpenCode operates securely inside your organization with no data or context stored and no licensing
restrictions or ownership claims. Start a trial with your team, then deploy it across your
organization by integrating it with your SSO and internal AI gateway.
</p>
<p>Let us know and how we can help.</p>
<h1>{i18n.t("enterprise.hero.title")}</h1>
<p>{i18n.t("enterprise.hero.body1")}</p>
<p>{i18n.t("enterprise.hero.body2")}</p>
<Show when={false}>
<div data-component="testimonial">
@@ -150,59 +148,59 @@ export default function Enterprise() {
<div data-component="enterprise-form">
<form onSubmit={handleSubmit}>
<div data-component="form-group">
<label for="name">Full name</label>
<label for="name">{i18n.t("enterprise.form.name.label")}</label>
<input
id="name"
type="text"
required
value={formData().name}
onInput={handleInputChange("name")}
placeholder="Jeff Bezos"
placeholder={i18n.t("enterprise.form.name.placeholder")}
/>
</div>
<div data-component="form-group">
<label for="role">Role</label>
<label for="role">{i18n.t("enterprise.form.role.label")}</label>
<input
id="role"
type="text"
required
value={formData().role}
onInput={handleInputChange("role")}
placeholder="Executive Chairman"
placeholder={i18n.t("enterprise.form.role.placeholder")}
/>
</div>
<div data-component="form-group">
<label for="email">Company email</label>
<label for="email">{i18n.t("enterprise.form.email.label")}</label>
<input
id="email"
type="email"
required
value={formData().email}
onInput={handleInputChange("email")}
placeholder="jeff@amazon.com"
placeholder={i18n.t("enterprise.form.email.placeholder")}
/>
</div>
<div data-component="form-group">
<label for="message">What problem are you trying to solve?</label>
<label for="message">{i18n.t("enterprise.form.message.label")}</label>
<textarea
id="message"
required
rows={5}
value={formData().message}
onInput={handleInputChange("message")}
placeholder="We need help with..."
placeholder={i18n.t("enterprise.form.message.placeholder")}
/>
</div>
<button type="submit" disabled={isSubmitting()} data-component="submit-button">
{isSubmitting() ? "Sending..." : "Send"}
{isSubmitting() ? i18n.t("enterprise.form.sending") : i18n.t("enterprise.form.send")}
</button>
</form>
{showSuccess() && <div data-component="success-message">Message sent, we'll be in touch soon.</div>}
{showSuccess() && <div data-component="success-message">{i18n.t("enterprise.form.success")}</div>}
</div>
</div>
</div>
@@ -210,35 +208,20 @@ export default function Enterprise() {
<section data-component="faq">
<div data-slot="section-title">
<h3>FAQ</h3>
<h3>{i18n.t("enterprise.faq.title")}</h3>
</div>
<ul>
<li>
<Faq question="What is OpenCode Enterprise?">
OpenCode Enterprise is for organizations that want to ensure that their code and data never leaves
their infrastructure. It can do this by using a centralized config that integrates with your SSO and
internal AI gateway.
</Faq>
<Faq question={i18n.t("enterprise.faq.q1")}>{i18n.t("enterprise.faq.a1")}</Faq>
</li>
<li>
<Faq question="How do I get started with OpenCode Enterprise?">
Simply start with an internal trial with your team. OpenCode by default does not store your code or
context data, making it easy to get started. Then contact us to discuss pricing and implementation
options.
</Faq>
<Faq question={i18n.t("enterprise.faq.q2")}>{i18n.t("enterprise.faq.a2")}</Faq>
</li>
<li>
<Faq question="How does enterprise pricing work?">
We offer per-seat enterprise pricing. If you have your own LLM gateway, we do not charge for tokens
used. For further details, contact us for a custom quote based on your organization's needs.
</Faq>
<Faq question={i18n.t("enterprise.faq.q3")}>{i18n.t("enterprise.faq.a3")}</Faq>
</li>
<li>
<Faq question="Is my data secure with OpenCode Enterprise?">
Yes. OpenCode does not store your code or context data. All processing happens locally or through
direct API calls to your AI provider. With central config and SSO integration, your data remains
secure within your organization's infrastructure.
</Faq>
<Faq question={i18n.t("enterprise.faq.q4")}>{i18n.t("enterprise.faq.a4")}</Faq>
</li>
</ul>
</section>

View File

@@ -259,6 +259,7 @@ body {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
@media (max-width: 55rem) {
display: none;

View File

@@ -14,6 +14,7 @@ import { Legal } from "~/component/legal"
import { github } from "~/lib/github"
import { createMemo } from "solid-js"
import { config } from "~/config"
import { useI18n } from "~/context/i18n"
function CopyStatus() {
return (
@@ -25,6 +26,7 @@ function CopyStatus() {
}
export default function Home() {
const i18n = useI18n()
const githubData = createAsync(() => github())
const release = createMemo(() => githubData()?.release)
@@ -43,7 +45,7 @@ export default function Home() {
return (
<main data-page="opencode">
{/*<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />*/}
<Title>OpenCode | The open source AI coding agent</Title>
<Title>{i18n.t("home.title")}</Title>
<Link rel="canonical" href={config.baseUrl} />
<Meta property="og:image" content="/social-share.png" />
<Meta name="twitter:image" content="/social-share.png" />
@@ -53,16 +55,17 @@ export default function Home() {
<div data-component="content">
<section data-component="hero">
<div data-component="desktop-app-banner">
<span data-slot="badge">New</span>
<span data-slot="badge">{i18n.t("home.banner.badge")}</span>
<div data-slot="content">
<span data-slot="text">
Desktop app available in beta<span data-slot="platforms"> on macOS, Windows, and Linux</span>.
{i18n.t("home.banner.text")}
<span data-slot="platforms"> {i18n.t("home.banner.platforms")}</span>.
</span>
<a href="/download" data-slot="link">
Download now
{i18n.t("home.banner.downloadNow")}
</a>
<a href="/download" data-slot="link-mobile">
Download the desktop beta now
{i18n.t("home.banner.downloadBetaNow")}
</a>
</div>
</div>
@@ -73,16 +76,16 @@ export default function Home() {
{/* target="_blank">*/}
{/* Whats new in {release()?.name ?? "the latest release"}*/}
{/*</a>*/}
<h1>The open source AI coding agent</h1>
<h1>{i18n.t("home.hero.title")}</h1>
<p>
Free models included or connect any model from any provider, <span data-slot="br"></span>including
Claude, GPT, Gemini and more.
{i18n.t("home.hero.subtitle.a")} <span data-slot="br"></span>
{i18n.t("home.hero.subtitle.b")}
</p>
</div>
<div data-slot="installation">
<Tabs
as="section"
aria-label="Install options"
aria-label={i18n.t("home.install.ariaLabel")}
class="tabs"
data-component="tabs"
data-active="curl"
@@ -161,61 +164,61 @@ export default function Home() {
<section data-component="video">
<video src={video} autoplay playsinline loop muted preload="auto" poster={videoPoster}>
Your browser does not support the video tag.
{i18n.t("common.videoUnsupported")}
</video>
</section>
<section data-component="what">
<div data-slot="section-title">
<h3>What is OpenCode?</h3>
<p>OpenCode is an open source agent that helps you write code in your terminal, IDE, or desktop.</p>
<h3>{i18n.t("home.what.title")}</h3>
<p>{i18n.t("home.what.body")}</p>
</div>
<ul>
<li>
<span>[*]</span>
<div>
<strong>LSP enabled</strong> Automatically loads the right LSPs for the LLM
<strong>{i18n.t("home.what.lsp.title")}</strong> {i18n.t("home.what.lsp.body")}
</div>
</li>
<li>
<span>[*]</span>
<div>
<strong>Multi-session</strong> Start multiple agents in parallel on the same project
<strong>{i18n.t("home.what.multiSession.title")}</strong> {i18n.t("home.what.multiSession.body")}
</div>
</li>
<li>
<span>[*]</span>
<div>
<strong>Share links</strong> Share a link to any session for reference or to debug
<strong>{i18n.t("home.what.shareLinks.title")}</strong> {i18n.t("home.what.shareLinks.body")}
</div>
</li>
<li>
<span>[*]</span>
<div>
<strong>GitHub Copilot</strong> Log in with GitHub to use your Copilot account
<strong>{i18n.t("home.what.copilot.title")}</strong> {i18n.t("home.what.copilot.body")}
</div>
</li>
<li>
<span>[*]</span>
<div>
<strong>ChatGPT Plus/Pro</strong> Log in with OpenAI to use your ChatGPT Plus or Pro account
<strong>{i18n.t("home.what.chatgptPlus.title")}</strong> {i18n.t("home.what.chatgptPlus.body")}
</div>
</li>
<li>
<span>[*]</span>
<div>
<strong>Any model</strong> 75+ LLM providers through Models.dev, including local models
<strong>{i18n.t("home.what.anyModel.title")}</strong> {i18n.t("home.what.anyModel.body")}
</div>
</li>
<li>
<span>[*]</span>
<div>
<strong>Any editor</strong> Available as a terminal interface, desktop app, and IDE extension
<strong>{i18n.t("home.what.anyEditor.title")}</strong> {i18n.t("home.what.anyEditor.body")}
</div>
</li>
</ul>
<a href="/docs">
<span>Read docs </span>
<span>{i18n.t("home.what.readDocs")} </span>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.5 12L17 12M13 16.5L17.5 12L13 7.5"
@@ -229,15 +232,17 @@ export default function Home() {
<section data-component="growth">
<div data-slot="section-title">
<h3>The open source AI coding agent</h3>
<h3>{i18n.t("home.growth.title")}</h3>
<div>
<span>[*]</span>
<p>
With over <strong>{config.github.starsFormatted.full}</strong> GitHub stars,{" "}
<strong>{config.stats.contributors}</strong> contributors, and over{" "}
<strong>{config.stats.commits}</strong> commits, OpenCode is used and trusted by over{" "}
<strong>{config.stats.monthlyUsers}</strong> developers every month.
</p>
<p
innerHTML={i18n.t("home.growth.body", {
stars: config.github.starsFormatted.full,
contributors: config.stats.contributors,
commits: config.stats.commits,
monthlyUsers: config.stats.monthlyUsers,
})}
/>
</div>
<div data-component="growth-stats">
@@ -289,7 +294,8 @@ export default function Home() {
</svg>
</div>
<span>
<figure>Fig 1.</figure> <strong>{config.github.starsFormatted.compact}</strong> GitHub Stars
<figure>{i18n.t("common.figure", { n: 1 })}</figure>{" "}
<strong>{config.github.starsFormatted.compact}</strong> {i18n.t("home.growth.githubStars")}
</span>
</div>
@@ -592,7 +598,8 @@ export default function Home() {
</svg>
</div>
<span>
<figure>Fig 2.</figure> <strong>{config.stats.contributors}</strong> Contributors
<figure>{i18n.t("common.figure", { n: 2 })}</figure> <strong>{config.stats.contributors}</strong>{" "}
{i18n.t("home.growth.contributors")}
</span>
</div>
@@ -634,7 +641,8 @@ export default function Home() {
</svg>
</div>
<span>
<figure>Fig 3.</figure> <strong>{config.stats.monthlyUsers}</strong> Monthly Devs
<figure>{i18n.t("common.figure", { n: 3 })}</figure> <strong>{config.stats.monthlyUsers}</strong>{" "}
{i18n.t("home.growth.monthlyDevs")}
</span>
</div>
</div>
@@ -643,13 +651,13 @@ export default function Home() {
<section data-component="privacy">
<div data-slot="privacy-title">
<h3>Built for privacy first</h3>
<h3>{i18n.t("home.privacy.title")}</h3>
<div>
<span>[*]</span>
<p>
OpenCode does not store any of your code or context data, so that it can operate in privacy sensitive
environments. Learn more about <a href="/docs/enterprise/ ">privacy</a>.
{i18n.t("home.privacy.body")} {i18n.t("home.privacy.learnMore")}{" "}
<a href="/docs/enterprise/">{i18n.t("home.privacy.link")}</a>.
</p>
</div>
</div>
@@ -657,70 +665,59 @@ export default function Home() {
<section data-component="faq">
<div data-slot="section-title">
<h3>FAQ</h3>
<h3>{i18n.t("common.faq")}</h3>
</div>
<ul>
<li>
<Faq question="What is OpenCode?">
OpenCode is an open source agent that helps you write and run code with any AI model. It's available
as a terminal-based interface, desktop app, or IDE extension.
<Faq question={i18n.t("home.faq.q1")}>{i18n.t("home.faq.a1")}</Faq>
</li>
<li>
<Faq question={i18n.t("home.faq.q2")}>
{i18n.t("home.faq.a2.before")} <a href="/docs">{i18n.t("home.faq.a2.link")}</a>.
</Faq>
</li>
<li>
<Faq question="How do I use OpenCode?">
The easiest way to get started is to read the <a href="/docs">intro</a>.
</Faq>
</li>
<li>
<Faq question="Do I need extra AI subscriptions to use OpenCode?">
Not necessarily, OpenCode comes with a set of free models that you can use without creating an
account. Aside from these, you can use any of the popular coding models by creating a{" "}
<A href="/zen">Zen</A> account. While we encourage users to use Zen, OpenCode also works with all
popular providers such as OpenAI, Anthropic, xAI etc. You can even connect your{" "}
<Faq question={i18n.t("home.faq.q3")}>
{i18n.t("home.faq.a3.p1")} {i18n.t("home.faq.a3.p2.beforeZen")} <A href="/zen">{i18n.t("nav.zen")}</A>
{i18n.t("home.faq.a3.p2.afterZen")} {i18n.t("home.faq.a3.p3")} {i18n.t("home.faq.a3.p4.beforeLocal")}{" "}
<a href="/docs/providers/#lm-studio" target="_blank">
local models
{i18n.t("home.faq.a3.p4.localLink")}
</a>
.
</Faq>
</li>
<li>
<Faq question="Can I use my existing AI subscriptions with OpenCode?">
Yes, OpenCode supports subscription plans from all major providers. You can use your Claude Pro/Max,
ChatGPT Plus/Pro, or GitHub Copilot subscriptions. <a href="/docs/providers/#directory">Learn more</a>
.
<Faq question={i18n.t("home.faq.q4")}>
{i18n.t("home.faq.a4.p1")} <a href="/docs/providers/#directory">{i18n.t("common.learnMore")}</a>.
</Faq>
</li>
<li>
<Faq question="Can I only use OpenCode in the terminal?">
Not anymore! OpenCode is now available as an app for your <a href="/download">desktop</a> and{" "}
<a href="/docs/web">web</a>!
<Faq question={i18n.t("home.faq.q5")}>
{i18n.t("home.faq.a5.beforeDesktop")} <a href="/download">{i18n.t("home.faq.a5.desktop")}</a>{" "}
{i18n.t("home.faq.a5.and")} <a href="/docs/web">{i18n.t("home.faq.a5.web")}</a>!
</Faq>
</li>
<li>
<Faq question="How much does OpenCode cost?">
OpenCode is 100% free to use. It also comes with a set of free models. There might be additional costs
if you connect any other provider.
<Faq question={i18n.t("home.faq.q6")}>{i18n.t("home.faq.a6")}</Faq>
</li>
<li>
<Faq question={i18n.t("home.faq.q7")}>
{i18n.t("home.faq.a7.p1")} {i18n.t("home.faq.a7.p2.beforeModels")}{" "}
<a href="/docs/zen/#privacy">{i18n.t("home.faq.a7.p2.modelsLink")}</a> {i18n.t("home.faq.a7.p2.and")}{" "}
<a href="/docs/share/#privacy">{i18n.t("home.faq.a7.p2.shareLink")}</a>.
</Faq>
</li>
<li>
<Faq question="What about data and privacy?">
Your data and information is only stored when you use our free models or create sharable links. Learn
more about <a href="/docs/zen/#privacy">our models</a> and{" "}
<a href="/docs/share/#privacy">share pages</a>.
</Faq>
</li>
<li>
<Faq question="Is OpenCode open source?">
Yes, OpenCode is fully open source. The source code is public on{" "}
<Faq question={i18n.t("home.faq.q8")}>
{i18n.t("home.faq.a8.p1")}{" "}
<a href={config.github.repoUrl} target="_blank">
GitHub
{i18n.t("nav.github")}
</a>{" "}
under the{" "}
{i18n.t("home.faq.a8.p2")}{" "}
<a href={`${config.github.repoUrl}?tab=MIT-1-ov-file#readme`} target="_blank">
MIT License
{i18n.t("home.faq.a8.mitLicense")}
</a>
, meaning anyone can use, modify, or contribute to its development. Anyone from the community can file
issues, submit pull requests, and extend functionality.
{i18n.t("home.faq.a8.p3")}
</Faq>
</li>
</ul>
@@ -728,12 +725,8 @@ export default function Home() {
<section data-component="zen-cta">
<div data-slot="zen-cta-copy">
<strong>Access reliable optimized models for coding agents</strong>
<p>
Zen gives you access to a handpicked set of AI models that OpenCode has tested and benchmarked
specifically for coding agents. No need to worry about inconsistent performance and quality across
providers, use validated models that work.
</p>
<strong>{i18n.t("home.zenCta.title")}</strong>
<p>{i18n.t("home.zenCta.body")}</p>
<div data-slot="model-logos">
<div>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -816,7 +809,7 @@ export default function Home() {
</div>
</div>
<A href="/zen">
<span>Learn about Zen </span>
<span>{i18n.t("home.zenCta.link")} </span>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.5 12L17 12M13 16.5L17.5 12L13 7.5"

View File

@@ -1,12 +1,18 @@
import type { APIEvent } from "@solidjs/start/server"
import { localeFromCookieHeader, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
const url = new URL(req.url)
const targetUrl = `https://docs.opencode.ai/docs${url.pathname}${url.search}`
const headers = new Headers(req.headers)
const locale = localeFromCookieHeader(req.headers.get("cookie"))
if (locale) headers.set("accept-language", tag(locale))
const response = await fetch(targetUrl, {
method: req.method,
headers: req.headers,
headers,
body: req.body,
})
return response

View File

@@ -1,12 +1,18 @@
import type { APIEvent } from "@solidjs/start/server"
import { localeFromCookieHeader, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
const url = new URL(req.url)
const targetUrl = `https://enterprise.opencode.ai/${url.pathname}${url.search}`
const headers = new Headers(req.headers)
const locale = localeFromCookieHeader(req.headers.get("cookie"))
if (locale) headers.set("accept-language", tag(locale))
const response = await fetch(targetUrl, {
method: req.method,
headers: req.headers,
headers,
body: req.body,
})
return response

View File

@@ -5,6 +5,7 @@ import logoLight from "../asset/logo-ornate-light.svg"
import logoDark from "../asset/logo-ornate-dark.svg"
import IMG_SPLASH from "../asset/lander/screenshot-splash.png"
import { IconCopy, IconCheck } from "../component/icon"
import { useI18n } from "~/context/i18n"
function CopyStatus() {
return (
@@ -16,6 +17,8 @@ function CopyStatus() {
}
export default function Home() {
const i18n = useI18n()
onMount(() => {
const commands = document.querySelectorAll("[data-copy]")
for (const button of commands) {
@@ -38,24 +41,24 @@ export default function Home() {
return (
<main data-page="home">
<Title>opencode | AI coding agent built for the terminal</Title>
<Title>{i18n.t("temp.title")}</Title>
<div data-component="content">
<section data-component="top">
<img data-slot="logo light" src={logoLight} alt="opencode logo light" />
<img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
<h1 data-slot="title">The AI coding agent built for the terminal</h1>
<h1 data-slot="title">{i18n.t("temp.hero.title")}</h1>
<div data-slot="login">
<a href="/auth">opencode zen</a>
<a href="/auth">{i18n.t("temp.zen")}</a>
</div>
</section>
<section data-component="cta">
<div data-slot="left">
<a href="/docs">Get Started</a>
<a href="/docs">{i18n.t("temp.getStarted")}</a>
</div>
<div data-slot="center">
<a href="/auth">opencode zen</a>
<a href="/auth">{i18n.t("temp.zen")}</a>
</div>
<div data-slot="right">
<button data-copy data-slot="command">
@@ -73,30 +76,32 @@ export default function Home() {
<section data-component="features">
<ul data-slot="list">
<li>
<strong>Native TUI</strong> A responsive, native, themeable terminal UI
<strong>{i18n.t("temp.feature.native.title")}</strong> {i18n.t("temp.feature.native.body")}
</li>
<li>
<strong>LSP enabled</strong> Automatically loads the right LSPs for the LLM
<strong>{i18n.t("home.what.lsp.title")}</strong> {i18n.t("home.what.lsp.body")}
</li>
<li>
<strong>opencode zen</strong> A <a href="/docs/zen">curated list of models</a> provided by opencode{" "}
<label>New</label>
<strong>{i18n.t("temp.zen")}</strong> {i18n.t("temp.feature.zen.beforeLink")}{" "}
<a href="/docs/zen">{i18n.t("temp.feature.zen.link")}</a> {i18n.t("temp.feature.zen.afterLink")}{" "}
<label>{i18n.t("home.banner.badge")}</label>
</li>
<li>
<strong>Multi-session</strong> Start multiple agents in parallel on the same project
<strong>{i18n.t("home.what.multiSession.title")}</strong> {i18n.t("home.what.multiSession.body")}
</li>
<li>
<strong>Shareable links</strong> Share a link to any sessions for reference or to debug
<strong>{i18n.t("home.what.shareLinks.title")}</strong> {i18n.t("home.what.shareLinks.body")}
</li>
<li>
<strong>GitHub Copilot</strong> Log in with GitHub to use your Copilot account
<strong>{i18n.t("home.what.copilot.title")}</strong> {i18n.t("home.what.copilot.body")}
</li>
<li>
<strong>ChatGPT Plus/Pro</strong> Log in with OpenAI to use your ChatGPT Plus or Pro account
<strong>{i18n.t("home.what.chatgptPlus.title")}</strong> {i18n.t("home.what.chatgptPlus.body")}
</li>
<li>
<strong>Use any model</strong> Supports 75+ LLM providers through{" "}
<a href="https://models.dev">Models.dev</a>, including local models
<strong>{i18n.t("home.what.anyModel.title")}</strong> {i18n.t("temp.feature.models.beforeLink")}{" "}
<a href="https://models.dev">Models.dev</a>
{i18n.t("temp.feature.models.afterLink")}
</li>
</ul>
</section>
@@ -142,22 +147,22 @@ export default function Home() {
<section data-component="screenshots">
<figure>
<figcaption>opencode TUI with the tokyonight theme</figcaption>
<figcaption>{i18n.t("temp.screenshot.caption")}</figcaption>
<a href="/docs/cli">
<img src={IMG_SPLASH} alt="opencode TUI with tokyonight theme" />
<img src={IMG_SPLASH} alt={i18n.t("temp.screenshot.alt")} />
</a>
</figure>
</section>
<footer data-component="footer">
<div data-slot="cell">
<a href="https://x.com/opencode">X.com</a>
<a href="https://x.com/opencode">{i18n.t("footer.x")}</a>
</div>
<div data-slot="cell">
<a href="https://github.com/anomalyco/opencode">GitHub</a>
<a href="https://github.com/anomalyco/opencode">{i18n.t("footer.github")}</a>
</div>
<div data-slot="cell">
<a href="https://opencode.ai/discord">Discord</a>
<a href="https://opencode.ai/discord">{i18n.t("footer.discord")}</a>
</div>
</footer>
</div>

View File

@@ -2,6 +2,7 @@ import { action } from "@solidjs/router"
import { getRequestEvent } from "solid-js/web"
import { useAuthSession } from "~/context/auth"
import { Dropdown } from "~/component/dropdown"
import { useI18n } from "~/context/i18n"
import "./user-menu.css"
const logout = action(async () => {
@@ -20,11 +21,12 @@ const logout = action(async () => {
}, "auth.logout")
export function UserMenu(props: { email: string | null | undefined }) {
const i18n = useI18n()
return (
<div data-component="user-menu">
<Dropdown trigger={props.email ?? ""} align="right">
<a href="/auth/logout" data-slot="item">
Logout
{i18n.t("user.logout")}
</a>
</Dropdown>
</div>

View File

@@ -9,6 +9,7 @@ import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { Workspace } from "@opencode-ai/console-core/workspace.js"
import { Dropdown, DropdownItem } from "~/component/dropdown"
import { Modal } from "~/component/modal"
import { useI18n } from "~/context/i18n"
import "./workspace-picker.css"
const getWorkspaces = query(async () => {
@@ -47,6 +48,7 @@ const createWorkspace = action(async (form: FormData) => {
export function WorkspacePicker() {
const params = useParams()
const i18n = useI18n()
const workspaces = createAsync(() => getWorkspaces())
const submission = useSubmission(createWorkspace)
const [store, setStore] = createStore({
@@ -56,7 +58,7 @@ export function WorkspacePicker() {
const currentWorkspace = () => {
const ws = workspaces()?.find((w) => w.id === params.id)
return ws ? ws.name : "Select workspace"
return ws ? ws.name : i18n.t("workspace.select")
}
const handleWorkspaceNew = () => {
@@ -91,11 +93,11 @@ export function WorkspacePicker() {
)}
</For>
<button data-slot="create-item" type="button" onClick={() => handleWorkspaceNew()}>
+ Create New Workspace
{i18n.t("workspace.createNew")}
</button>
</Dropdown>
<Modal open={store.showForm} onClose={() => setStore("showForm", false)} title="Create New Workspace">
<Modal open={store.showForm} onClose={() => setStore("showForm", false)} title={i18n.t("workspace.modal.title")}>
<form data-slot="create-form" action={createWorkspace} method="post">
<div data-slot="create-input-group">
<input
@@ -103,15 +105,15 @@ export function WorkspacePicker() {
data-slot="create-input"
type="text"
name="workspaceName"
placeholder="Enter workspace name"
placeholder={i18n.t("workspace.modal.placeholder")}
required
/>
<div data-slot="button-group">
<button type="button" data-color="ghost" onClick={() => setStore("showForm", false)}>
Cancel
{i18n.t("common.cancel")}
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? "Creating..." : "Create"}
{submission.pending ? i18n.t("common.creating") : i18n.t("common.create")}
</button>
</div>
</div>

View File

@@ -129,12 +129,41 @@
flex: 1;
padding: var(--space-6) var(--space-8);
overflow-y: auto;
display: flex;
flex-direction: column;
@media (max-width: 48rem) {
padding: var(--space-6) var(--space-4);
}
}
[data-component="workspace-main"] {
flex: 1;
}
[data-component="workspace-content"] > [data-component="legal"] {
margin-top: var(--space-16);
padding-top: var(--space-8);
border-top: 1px solid var(--color-border);
color: var(--color-text-weak);
display: flex;
gap: var(--space-8);
a {
color: var(--color-text-weak);
text-decoration: none;
}
a:hover {
color: var(--color-text);
text-decoration: underline;
}
@media (max-width: 48rem) {
gap: var(--space-4);
}
}
[data-page="workspace-[id]"] {
max-width: 64rem;
padding: var(--space-2) var(--space-4);

View File

@@ -2,9 +2,12 @@ import { Show } from "solid-js"
import { createAsync, RouteSectionProps, useParams, A } from "@solidjs/router"
import { querySessionInfo } from "./common"
import "./[id].css"
import { useI18n } from "~/context/i18n"
import { Legal } from "~/component/legal"
export default function WorkspaceLayout(props: RouteSectionProps) {
const params = useParams()
const i18n = useI18n()
const userInfo = createAsync(() => querySessionInfo(params.id!))
return (
@@ -14,20 +17,20 @@ export default function WorkspaceLayout(props: RouteSectionProps) {
<nav data-component="nav-desktop">
<div data-component="workspace-nav-items">
<A href={`/workspace/${params.id}`} end activeClass="active" data-nav-button>
Zen
{i18n.t("workspace.nav.zen")}
</A>
<A href={`/workspace/${params.id}/keys`} activeClass="active" data-nav-button>
API Keys
{i18n.t("workspace.nav.apiKeys")}
</A>
<A href={`/workspace/${params.id}/members`} activeClass="active" data-nav-button>
Members
{i18n.t("workspace.nav.members")}
</A>
<Show when={userInfo()?.isAdmin}>
<A href={`/workspace/${params.id}/billing`} activeClass="active" data-nav-button>
Billing
{i18n.t("workspace.nav.billing")}
</A>
<A href={`/workspace/${params.id}/settings`} activeClass="active" data-nav-button>
Settings
{i18n.t("workspace.nav.settings")}
</A>
</Show>
</div>
@@ -36,26 +39,29 @@ export default function WorkspaceLayout(props: RouteSectionProps) {
<nav data-component="nav-mobile">
<div data-component="workspace-nav-items">
<A href={`/workspace/${params.id}`} end activeClass="active" data-nav-button>
Zen
{i18n.t("workspace.nav.zen")}
</A>
<A href={`/workspace/${params.id}/keys`} activeClass="active" data-nav-button>
API Keys
{i18n.t("workspace.nav.apiKeys")}
</A>
<A href={`/workspace/${params.id}/members`} activeClass="active" data-nav-button>
Members
{i18n.t("workspace.nav.members")}
</A>
<Show when={userInfo()?.isAdmin}>
<A href={`/workspace/${params.id}/billing`} activeClass="active" data-nav-button>
Billing
{i18n.t("workspace.nav.billing")}
</A>
<A href={`/workspace/${params.id}/settings`} activeClass="active" data-nav-button>
Settings
{i18n.t("workspace.nav.settings")}
</A>
</Show>
</div>
</nav>
</nav>
<div data-component="workspace-content">{props.children}</div>
<div data-component="workspace-content">
<div data-component="workspace-main">{props.children}</div>
<Legal />
</div>
</div>
</main>
)

View File

@@ -6,6 +6,8 @@ import { withActor } from "~/context/auth.withActor"
import { IconCreditCard, IconStripe } from "~/component/icon"
import styles from "./billing-section.module.css"
import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common"
import { useI18n } from "~/context/i18n"
import { localizeError } from "~/lib/form-error"
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
@@ -26,6 +28,7 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
export function BillingSection() {
const params = useParams()
const i18n = useI18n()
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const checkoutAction = useAction(createCheckoutUrl)
@@ -137,16 +140,18 @@ export function BillingSection() {
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Billing</h2>
<h2>{i18n.t("workspace.billing.title")}</h2>
<p>
Manage payments methods. <a href="mailto:contact@anoma.ly">Contact us</a> if you have any questions.
{i18n.t("workspace.billing.subtitle.beforeLink")}{" "}
<a href="mailto:contact@anoma.ly">{i18n.t("workspace.billing.contactUs")}</a>{" "}
{i18n.t("workspace.billing.subtitle.afterLink")}
</p>
</div>
<div data-slot="section-content">
<div data-slot="balance-display">
<div data-slot="balance-amount">
<span data-slot="balance-value">${balance()}</span>
<span data-slot="balance-label">Current Balance</span>
<span data-slot="balance-label">{i18n.t("workspace.billing.currentBalance")}</span>
</div>
<Show when={billingInfo()?.customerID}>
<div data-slot="balance-right-section">
@@ -155,7 +160,7 @@ export function BillingSection() {
fallback={
<div data-slot="add-balance-form-container">
<div data-slot="add-balance-form">
<label>Add $</label>
<label>{i18n.t("workspace.billing.add")}</label>
<input
data-component="input"
type="number"
@@ -166,11 +171,11 @@ export function BillingSection() {
setStore("addBalanceAmount", e.currentTarget.value)
checkoutSubmission.clear()
}}
placeholder="Enter amount"
placeholder={i18n.t("workspace.billing.enterAmount")}
/>
<div data-slot="form-actions">
<button data-color="ghost" type="button" onClick={() => hideAddBalanceForm()}>
Cancel
{i18n.t("common.cancel")}
</button>
<button
data-color="primary"
@@ -178,18 +183,20 @@ export function BillingSection() {
disabled={!store.addBalanceAmount || checkoutSubmission.pending || store.checkoutRedirecting}
onClick={onClickCheckout}
>
{checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Add"}
{checkoutSubmission.pending || store.checkoutRedirecting
? i18n.t("workspace.billing.loading")
: i18n.t("workspace.billing.addAction")}
</button>
</div>
</div>
<Show when={checkoutSubmission.result && (checkoutSubmission.result as any).error}>
{(err: any) => <div data-slot="form-error">{err()}</div>}
{(err: any) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
</div>
}
>
<button data-color="primary" onClick={() => showAddBalanceForm()}>
Add Balance
{i18n.t("workspace.billing.addBalance")}
</button>
</Show>
<div data-slot="credit-card">
@@ -209,7 +216,7 @@ export function BillingSection() {
</Show>
</Match>
<Match when={billingInfo()?.paymentMethodType === "link"}>
<span data-slot="type">Linked to Stripe</span>
<span data-slot="type">{i18n.t("workspace.billing.linkedToStripe")}</span>
</Match>
</Switch>
</div>
@@ -218,7 +225,9 @@ export function BillingSection() {
disabled={sessionSubmission.pending || store.sessionRedirecting}
onClick={onClickSession}
>
{sessionSubmission.pending || store.sessionRedirecting ? "Loading..." : "Manage"}
{sessionSubmission.pending || store.sessionRedirecting
? i18n.t("workspace.billing.loading")
: i18n.t("workspace.billing.manage")}
</button>
</div>
</div>
@@ -231,7 +240,9 @@ export function BillingSection() {
disabled={checkoutSubmission.pending || store.checkoutRedirecting}
onClick={onClickCheckout}
>
{checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Enable Billing"}
{checkoutSubmission.pending || store.checkoutRedirecting
? i18n.t("workspace.billing.loading")
: i18n.t("workspace.billing.enable")}
</button>
</Show>
</div>

View File

@@ -10,6 +10,8 @@ import { withActor } from "~/context/auth.withActor"
import { queryBillingInfo } from "../../common"
import styles from "./black-section.module.css"
import waitlistStyles from "./black-waitlist-section.module.css"
import { useI18n } from "~/context/i18n"
import { formError } from "~/lib/form-error"
const querySubscription = query(async (workspaceID: string) => {
"use server"
@@ -47,17 +49,18 @@ const querySubscription = query(async (workspaceID: string) => {
}, workspaceID)
}, "subscription.get")
function formatResetTime(seconds: number) {
function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
const days = Math.floor(seconds / 86400)
if (days >= 1) {
const hours = Math.floor((seconds % 86400) / 3600)
return `${days} ${days === 1 ? "day" : "days"} ${hours} ${hours === 1 ? "hour" : "hours"}`
return `${days} ${days === 1 ? i18n.t("workspace.black.time.day") : i18n.t("workspace.black.time.days")} ${hours} ${hours === 1 ? i18n.t("workspace.black.time.hour") : i18n.t("workspace.black.time.hours")}`
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours >= 1) return `${hours} ${hours === 1 ? "hour" : "hours"} ${minutes} ${minutes === 1 ? "minute" : "minutes"}`
if (minutes === 0) return "a few seconds"
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`
if (hours >= 1)
return `${hours} ${hours === 1 ? i18n.t("workspace.black.time.hour") : i18n.t("workspace.black.time.hours")} ${minutes} ${minutes === 1 ? i18n.t("workspace.black.time.minute") : i18n.t("workspace.black.time.minutes")}`
if (minutes === 0) return i18n.t("workspace.black.time.fewSeconds")
return `${minutes} ${minutes === 1 ? i18n.t("workspace.black.time.minute") : i18n.t("workspace.black.time.minutes")}`
}
const cancelWaitlist = action(async (workspaceID: string) => {
@@ -111,7 +114,7 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
const setUseBalance = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
if (!workspaceID) return { error: formError.workspaceRequired }
const useBalance = form.get("useBalance")?.toString() === "true"
return json(
@@ -134,6 +137,7 @@ const setUseBalance = action(async (form: FormData) => {
export function BlackSection() {
const params = useParams()
const i18n = useI18n()
const billing = createAsync(() => queryBillingInfo(params.id!))
const subscription = createAsync(() => querySubscription(params.id!))
const sessionAction = useAction(createSessionUrl)
@@ -177,42 +181,50 @@ export function BlackSection() {
{(sub) => (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Subscription</h2>
<h2>{i18n.t("workspace.black.subscription.title")}</h2>
<div data-slot="title-row">
<p>You are subscribed to OpenCode Black for ${sub().plan} per month.</p>
<p>{i18n.t("workspace.black.subscription.message", { plan: sub().plan })}</p>
<button
data-color="primary"
disabled={sessionSubmission.pending || store.sessionRedirecting}
onClick={onClickSession}
>
{sessionSubmission.pending || store.sessionRedirecting ? "Loading..." : "Manage Subscription"}
{sessionSubmission.pending || store.sessionRedirecting
? i18n.t("workspace.black.loading")
: i18n.t("workspace.black.subscription.manage")}
</button>
</div>
</div>
<div data-slot="usage">
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">5-hour Usage</span>
<span data-slot="usage-label">{i18n.t("workspace.black.subscription.rollingUsage")}</span>
<span data-slot="usage-value">{sub().rollingUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().rollingUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">Resets in {formatResetTime(sub().rollingUsage.resetInSec)}</span>
<span data-slot="reset-time">
{i18n.t("workspace.black.subscription.resetsIn")}{" "}
{formatResetTime(sub().rollingUsage.resetInSec, i18n)}
</span>
</div>
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">Weekly Usage</span>
<span data-slot="usage-label">{i18n.t("workspace.black.subscription.weeklyUsage")}</span>
<span data-slot="usage-value">{sub().weeklyUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().weeklyUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">Resets in {formatResetTime(sub().weeklyUsage.resetInSec)}</span>
<span data-slot="reset-time">
{i18n.t("workspace.black.subscription.resetsIn")}{" "}
{formatResetTime(sub().weeklyUsage.resetInSec, i18n)}
</span>
</div>
</div>
<form action={setUseBalance} method="post" data-slot="setting-row">
<p>Use your available balance after reaching the usage limits</p>
<p>{i18n.t("workspace.black.subscription.useBalance")}</p>
<input type="hidden" name="workspaceID" value={params.id} />
<input type="hidden" name="useBalance" value={sub().useBalance ? "false" : "true"} />
<label data-slot="toggle-label">
@@ -231,19 +243,23 @@ export function BlackSection() {
<Show when={billing()?.timeSubscriptionBooked}>
<section class={waitlistStyles.root}>
<div data-slot="section-title">
<h2>Waitlist</h2>
<h2>{i18n.t("workspace.black.waitlist.title")}</h2>
<div data-slot="title-row">
<p>
{billing()?.timeSubscriptionSelected
? `We're ready to enroll you into the $${billing()?.subscriptionPlan} per month OpenCode Black plan.`
: `You are on the waitlist for the $${billing()?.subscriptionPlan} per month OpenCode Black plan.`}
? i18n.t("workspace.black.waitlist.ready", { plan: billing()?.subscriptionPlan ?? "" })
: i18n.t("workspace.black.waitlist.joined", { plan: billing()?.subscriptionPlan ?? "" })}
</p>
<button
data-color="danger"
disabled={cancelSubmission.pending || store.cancelled}
onClick={onClickCancel}
>
{cancelSubmission.pending ? "Leaving..." : store.cancelled ? "Left" : "Leave Waitlist"}
{cancelSubmission.pending
? i18n.t("workspace.black.waitlist.leaving")
: store.cancelled
? i18n.t("workspace.black.waitlist.left")
: i18n.t("workspace.black.waitlist.leave")}
</button>
</div>
</div>
@@ -255,11 +271,13 @@ export function BlackSection() {
disabled={enrollSubmission.pending || store.enrolled}
onClick={onClickEnroll}
>
{enrollSubmission.pending ? "Enrolling..." : store.enrolled ? "Enrolled" : "Enroll"}
{enrollSubmission.pending
? i18n.t("workspace.black.waitlist.enrolling")
: store.enrolled
? i18n.t("workspace.black.waitlist.enrolled")
: i18n.t("workspace.black.waitlist.enroll")}
</button>
<p data-slot="enroll-note">
When you click Enroll, your subscription starts immediately and your card will be charged.
</p>
<p data-slot="enroll-note">{i18n.t("workspace.black.waitlist.enrollNote")}</p>
</div>
</Show>
</section>

View File

@@ -5,15 +5,17 @@ import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
import styles from "./monthly-limit-section.module.css"
import { queryBillingInfo } from "../../common"
import { useI18n } from "~/context/i18n"
import { formError, localizeError } from "~/lib/form-error"
const setMonthlyLimit = action(async (form: FormData) => {
"use server"
const limit = form.get("limit")?.toString()
if (!limit) return { error: "Limit is required." }
if (!limit) return { error: formError.limitRequired }
const numericLimit = parseInt(limit)
if (numericLimit < 0) return { error: "Set a valid monthly limit." }
if (numericLimit < 0) return { error: formError.monthlyLimitInvalid }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required." }
if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(
() =>
@@ -28,6 +30,7 @@ const setMonthlyLimit = action(async (form: FormData) => {
export function MonthlyLimitSection() {
const params = useParams()
const i18n = useI18n()
const submission = useSubmission(setMonthlyLimit)
const [store, setStore] = createStore({ show: false })
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
@@ -61,8 +64,8 @@ export function MonthlyLimitSection() {
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Monthly Limit</h2>
<p>Set a monthly usage limit for your account.</p>
<h2>{i18n.t("workspace.monthlyLimit.title")}</h2>
<p>{i18n.t("workspace.monthlyLimit.subtitle")}</p>
</div>
<div data-slot="section-content">
<div data-slot="balance">
@@ -81,42 +84,51 @@ export function MonthlyLimitSection() {
data-component="input"
name="limit"
type="number"
placeholder="50"
placeholder={i18n.t("workspace.monthlyLimit.placeholder")}
/>
<Show when={submission.result && submission.result.error}>
{(err) => <div data-slot="form-error">{err()}</div>}
{(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
</div>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="reset" data-color="ghost" onClick={() => hide()}>
Cancel
{i18n.t("common.cancel")}
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? "Setting..." : "Set"}
{submission.pending
? i18n.t("workspace.monthlyLimit.setting")
: i18n.t("workspace.monthlyLimit.set")}
</button>
</div>
</form>
}
>
<button data-color="primary" onClick={() => show()}>
{billingInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
{billingInfo()?.monthlyLimit
? i18n.t("workspace.monthlyLimit.edit")
: i18n.t("workspace.monthlyLimit.set")}
</button>
</Show>
</div>
<Show when={billingInfo()?.monthlyLimit} fallback={<p data-slot="usage-status">No usage limit set.</p>}>
<Show
when={billingInfo()?.monthlyLimit}
fallback={<p data-slot="usage-status">{i18n.t("workspace.monthlyLimit.noLimit")}</p>}
>
<p data-slot="usage-status">
Current usage for {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $
{i18n.t("workspace.monthlyLimit.currentUsage.beforeMonth")}{" "}
{new Date().toLocaleDateString(undefined, { month: "long", timeZone: "UTC" })}{" "}
{i18n.t("workspace.monthlyLimit.currentUsage.beforeAmount")}
{(() => {
const dateLastUsed = billingInfo()?.timeMonthlyUsageUpdated
if (!dateLastUsed) return "0"
const current = new Date().toLocaleDateString("en-US", {
const current = new Date().toLocaleDateString(undefined, {
year: "numeric",
month: "long",
timeZone: "UTC",
})
const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
const lastUsed = dateLastUsed.toLocaleDateString(undefined, {
year: "numeric",
month: "long",
timeZone: "UTC",

View File

@@ -4,6 +4,7 @@ import { For, Match, Show, Switch } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./payment-section.module.css"
import { useI18n } from "~/context/i18n"
const getPaymentsInfo = query(async (workspaceID: string) => {
"use server"
@@ -19,6 +20,7 @@ const downloadReceipt = action(async (workspaceID: string, paymentID: string) =>
export function PaymentSection() {
const params = useParams()
const i18n = useI18n()
const payments = createAsync(() => getPaymentsInfo(params.id!))
const downloadReceiptAction = useAction(downloadReceipt)
@@ -60,17 +62,17 @@ export function PaymentSection() {
<Show when={payments() && payments()!.length > 0}>
<section class={styles.root}>
<div data-slot="section-title">
<h2>Payments History</h2>
<p>Recent payment transactions.</p>
<h2>{i18n.t("workspace.payments.title")}</h2>
<p>{i18n.t("workspace.payments.subtitle")}</p>
</div>
<div data-slot="payments-table">
<table data-slot="payments-table-element">
<thead>
<tr>
<th>Date</th>
<th>Payment ID</th>
<th>Amount</th>
<th>Receipt</th>
<th>{i18n.t("workspace.payments.table.date")}</th>
<th>{i18n.t("workspace.payments.table.paymentId")}</th>
<th>{i18n.t("workspace.payments.table.amount")}</th>
<th>{i18n.t("workspace.payments.table.receipt")}</th>
</tr>
</thead>
<tbody>
@@ -88,8 +90,13 @@ export function PaymentSection() {
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
${((amount ?? 0) / 100000000).toFixed(2)}
<Switch>
<Match when={payment.enrichment?.type === "credit"}> (credit)</Match>
<Match when={payment.enrichment?.type === "subscription"}> (subscription)</Match>
<Match when={payment.enrichment?.type === "credit"}>
{" "}
({i18n.t("workspace.payments.type.credit")})
</Match>
<Match when={payment.enrichment?.type === "subscription"}>
({i18n.t("workspace.payments.type.subscription")})
</Match>
</Switch>
</td>
<td data-slot="payment-receipt">
@@ -103,7 +110,7 @@ export function PaymentSection() {
}}
data-slot="receipt-button"
>
View
{i18n.t("workspace.payments.view")}
</button>
) : (
<span>-</span>

View File

@@ -7,11 +7,13 @@ import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import styles from "./reload-section.module.css"
import { queryBillingInfo } from "../../common"
import { useI18n } from "~/context/i18n"
import { formError, formErrorReloadAmountMin, formErrorReloadTriggerMin, localizeError } from "~/lib/form-error"
const reload = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
if (!workspaceID) return { error: formError.workspaceRequired }
return json(await withActor(() => Billing.reload(), workspaceID), {
revalidate: queryBillingInfo.key,
})
@@ -20,7 +22,7 @@ const reload = action(async (form: FormData) => {
const setReload = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
if (!workspaceID) return { error: formError.workspaceRequired }
const reloadValue = form.get("reload")?.toString() === "true"
const amountStr = form.get("reloadAmount")?.toString()
const triggerStr = form.get("reloadTrigger")?.toString()
@@ -30,9 +32,9 @@ const setReload = action(async (form: FormData) => {
if (reloadValue) {
if (reloadAmount === null || reloadAmount < Billing.RELOAD_AMOUNT_MIN)
return { error: `Reload amount must be at least $${Billing.RELOAD_AMOUNT_MIN}` }
return { error: formErrorReloadAmountMin(Billing.RELOAD_AMOUNT_MIN) }
if (reloadTrigger === null || reloadTrigger < Billing.RELOAD_TRIGGER_MIN)
return { error: `Balance trigger must be at least $${Billing.RELOAD_TRIGGER_MIN}` }
return { error: formErrorReloadTriggerMin(Billing.RELOAD_TRIGGER_MIN) }
}
return json(
@@ -58,6 +60,7 @@ const setReload = action(async (form: FormData) => {
export function ReloadSection() {
const params = useParams()
const i18n = useI18n()
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const setReloadSubmission = useSubmission(setReload)
const reloadSubmission = useSubmission(reload)
@@ -99,23 +102,26 @@ export function ReloadSection() {
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Auto Reload</h2>
<h2>{i18n.t("workspace.reload.title")}</h2>
<div data-slot="title-row">
<Show
when={billingInfo()?.reload}
fallback={
<p>
Auto reload is <b>disabled</b>. Enable to automatically reload when balance is low.
{i18n.t("workspace.reload.disabled.before")} <b>{i18n.t("workspace.reload.disabled.state")}</b>.{" "}
{i18n.t("workspace.reload.disabled.after")}
</p>
}
>
<p>
Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+${processingFee()}{" "}
processing fee) when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
{i18n.t("workspace.reload.enabled.before")} <b>{i18n.t("workspace.reload.enabled.state")}</b>.{" "}
{i18n.t("workspace.reload.enabled.middle")} <b>${billingInfo()?.reloadAmount}</b> (+${processingFee()}{" "}
{i18n.t("workspace.reload.processingFee")}) {i18n.t("workspace.reload.enabled.after")}{" "}
<b>${billingInfo()?.reloadTrigger}</b>.
</p>
</Show>
<button data-color="primary" type="button" onClick={() => show()}>
{billingInfo()?.reload ? "Edit" : "Enable"}
{billingInfo()?.reload ? i18n.t("workspace.reload.edit") : i18n.t("workspace.reload.enable")}
</button>
</div>
</div>
@@ -123,7 +129,7 @@ export function ReloadSection() {
<form action={setReload} method="post" data-slot="create-form">
<div data-slot="form-field">
<label>
<span data-slot="field-label">Enable Auto Reload</span>
<span data-slot="field-label">{i18n.t("workspace.reload.enableAutoReload")}</span>
<div data-slot="toggle-container">
<label data-slot="model-toggle-label">
<input
@@ -141,7 +147,7 @@ export function ReloadSection() {
<div data-slot="input-row">
<div data-slot="input-field">
<p>Reload $</p>
<p>{i18n.t("workspace.reload.reloadAmount")}</p>
<input
data-component="input"
name="reloadAmount"
@@ -155,7 +161,7 @@ export function ReloadSection() {
/>
</div>
<div data-slot="input-field">
<p>When balance reaches $</p>
<p>{i18n.t("workspace.reload.whenBalanceReaches")}</p>
<input
data-component="input"
name="reloadTrigger"
@@ -171,15 +177,15 @@ export function ReloadSection() {
</div>
<Show when={setReloadSubmission.result && (setReloadSubmission.result as any).error}>
{(err: any) => <div data-slot="form-error">{err()}</div>}
{(err: any) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="button" data-color="ghost" onClick={() => hide()}>
Cancel
{i18n.t("common.cancel")}
</button>
<button type="submit" data-color="primary" disabled={setReloadSubmission.pending}>
{setReloadSubmission.pending ? "Saving..." : "Save"}
{setReloadSubmission.pending ? i18n.t("workspace.reload.saving") : i18n.t("workspace.reload.save")}
</button>
</div>
</form>
@@ -188,21 +194,21 @@ export function ReloadSection() {
<div data-slot="section-content">
<div data-slot="reload-error">
<p>
Reload failed at{" "}
{billingInfo()?.timeReloadError!.toLocaleString("en-US", {
{i18n.t("workspace.reload.failedAt")}{" "}
{billingInfo()?.timeReloadError!.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
})}
. Reason: {billingInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment method and try
again.
. {i18n.t("workspace.reload.reason")} {billingInfo()?.reloadError?.replace(/\.$/, "")}.{" "}
{i18n.t("workspace.reload.updatePaymentMethod")}
</p>
<form action={reload} method="post" data-slot="create-form">
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="ghost" type="submit" disabled={reloadSubmission.pending}>
{reloadSubmission.pending ? "Retrying..." : "Retry"}
{reloadSubmission.pending ? i18n.t("workspace.reload.retrying") : i18n.t("workspace.reload.retry")}
</button>
</form>
</div>

View File

@@ -20,6 +20,7 @@ import {
Legend,
type ChartConfiguration,
} from "chart.js"
import { useI18n } from "~/context/i18n"
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend)
@@ -90,10 +91,8 @@ async function getCosts(workspaceID: string, year: number, month: number) {
usage: usageData,
keys: keysData.map((key) => ({
id: key.keyId,
displayName:
key.timeDeleted !== null
? `${key.userEmail} - ${key.keyName} (deleted)`
: `${key.userEmail} - ${key.keyName}`,
displayName: `${key.userEmail} - ${key.keyName}`,
deleted: key.timeDeleted !== null,
})),
}
}, workspaceID)
@@ -132,7 +131,7 @@ function formatDateLabel(dateStr: string): string {
date.setMonth(m - 1)
date.setDate(d)
date.setHours(0, 0, 0, 0)
const month = date.toLocaleDateString("en-US", { month: "short" })
const month = date.toLocaleDateString(undefined, { month: "short" })
const day = date.getUTCDate().toString().padStart(2, "0")
return `${month} ${day}`
}
@@ -152,6 +151,7 @@ export function GraphSection() {
let canvasRef: HTMLCanvasElement | undefined
let chartInstance: Chart | undefined
const params = useParams()
const i18n = useI18n()
const now = new Date()
const [store, setStore] = createStore({
data: null as Awaited<ReturnType<typeof getCosts>> | null,
@@ -193,13 +193,14 @@ export function GraphSection() {
})
const getKeyName = (keyID: string | null): string => {
if (!keyID || !store.data?.keys) return "All Keys"
if (!keyID || !store.data?.keys) return i18n.t("workspace.cost.allKeys")
const found = store.data.keys.find((k) => k.id === keyID)
return found?.displayName ?? "All Keys"
if (!found) return i18n.t("workspace.cost.allKeys")
return found.deleted ? `${found.displayName} ${i18n.t("workspace.cost.deletedSuffix")}` : found.displayName
}
const formatMonthYear = () =>
new Date(store.year, store.month, 1).toLocaleDateString("en-US", { month: "long", year: "numeric" })
new Date(store.year, store.month, 1).toLocaleDateString(undefined, { month: "long", year: "numeric" })
const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth()
@@ -216,6 +217,7 @@ export function GraphSection() {
const colorText = styles.getPropertyValue("--color-text").trim()
const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim()
const colorBorder = styles.getPropertyValue("--color-border").trim()
const subSuffix = ` (${i18n.t("workspace.cost.subscriptionShort")})`
const dailyDataSub = new Map<string, Map<string, number>>()
const dailyDataNonSub = new Map<string, Map<string, number>>()
@@ -255,7 +257,7 @@ export function GraphSection() {
.map((model) => {
const color = getModelColor(model)
return {
label: `${model} (sub)`,
label: `${model}${subSuffix}`,
data: dates.map((date) => (dailyDataSub.get(date)?.get(model) || 0) / 100_000_000),
backgroundColor: addOpacityToColor(color, 0.5),
hoverBackgroundColor: addOpacityToColor(color, 0.7),
@@ -344,8 +346,8 @@ export function GraphSection() {
chart.data.datasets?.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i)
const label = dataset.label || ""
const isSub = label.endsWith(" (sub)")
const model = isSub ? label.slice(0, -6) : label
const isSub = label.endsWith(subSuffix)
const model = isSub ? label.slice(0, -subSuffix.length) : label
const baseColor = getModelColor(model)
const originalColor = isSub ? addOpacityToColor(baseColor, 0.5) : baseColor
const color = i === legendItem.datasetIndex ? originalColor : addOpacityToColor(baseColor, 0.15)
@@ -360,8 +362,8 @@ export function GraphSection() {
chart.data.datasets?.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i)
const label = dataset.label || ""
const isSub = label.endsWith(" (sub)")
const model = isSub ? label.slice(0, -6) : label
const isSub = label.endsWith(subSuffix)
const model = isSub ? label.slice(0, -subSuffix.length) : label
const baseColor = getModelColor(model)
const color = isSub ? addOpacityToColor(baseColor, 0.5) : baseColor
meta.data.forEach((bar: any) => {
@@ -406,8 +408,8 @@ export function GraphSection() {
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Cost</h2>
<p>Usage costs broken down by model.</p>
<h2>{i18n.t("workspace.cost.title")}</h2>
<p>{i18n.t("workspace.cost.subtitle")}</p>
</div>
<div data-slot="filter-container">
@@ -421,13 +423,13 @@ export function GraphSection() {
</button>
</div>
<Dropdown
trigger={store.model === null ? "All Models" : store.model}
trigger={store.model === null ? i18n.t("workspace.cost.allModels") : store.model}
open={store.modelDropdownOpen}
onOpenChange={(open) => setStore({ modelDropdownOpen: open })}
>
<>
<button data-slot="model-item" onClick={() => onSelectModel(null)}>
<span>All Models</span>
<span>{i18n.t("workspace.cost.allModels")}</span>
</button>
<For each={getModels()}>
{(model) => (
@@ -445,12 +447,14 @@ export function GraphSection() {
>
<>
<button data-slot="model-item" onClick={() => onSelectKey(null)}>
<span>All Keys</span>
<span>{i18n.t("workspace.cost.allKeys")}</span>
</button>
<For each={store.data?.keys || []}>
{(key) => (
<button data-slot="model-item" onClick={() => onSelectKey(key.id)}>
<span>{key.displayName}</span>
<span>
{key.deleted ? `${key.displayName} ${i18n.t("workspace.cost.deletedSuffix")}` : key.displayName}
</span>
</button>
)}
</For>
@@ -462,7 +466,7 @@ export function GraphSection() {
when={chartConfig()}
fallback={
<div data-component="empty-state">
<p>No usage data available for the selected period.</p>
<p>{i18n.t("workspace.cost.empty")}</p>
</div>
}
>

View File

@@ -8,9 +8,11 @@ import { ProviderSection } from "./provider-section"
import { GraphSection } from "./graph-section"
import { IconLogo } from "~/component/icon"
import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common"
import { useI18n } from "~/context/i18n"
export default function () {
const params = useParams()
const i18n = useI18n()
const userInfo = createAsync(() => querySessionInfo(params.id!))
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const checkoutAction = useAction(createCheckoutUrl)
@@ -35,9 +37,9 @@ export default function () {
<IconLogo />
<p>
<span>
Reliable optimized models for coding agents.{" "}
{i18n.t("workspace.home.banner.beforeLink")}{" "}
<a target="_blank" href="/docs/zen">
Learn more
{i18n.t("common.learnMore")}
</a>
.
</span>
@@ -52,12 +54,14 @@ export default function () {
disabled={checkoutSubmission.pending || store.checkoutRedirecting}
onClick={onClickCheckout}
>
{checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Enable billing"}
{checkoutSubmission.pending || store.checkoutRedirecting
? i18n.t("workspace.home.billing.loading")
: i18n.t("workspace.home.billing.enable")}
</button>
}
>
<span data-slot="balance">
Current balance <b>${balance()}</b>
{i18n.t("workspace.home.billing.currentBalance")} <b>${balance()}</b>
</span>
</Show>
</span>

View File

@@ -7,22 +7,24 @@ import { createStore } from "solid-js/store"
import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./key-section.module.css"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { useI18n } from "~/context/i18n"
import { formError, localizeError } from "~/lib/form-error"
const removeKey = action(async (form: FormData) => {
"use server"
const id = form.get("id")?.toString()
if (!id) return { error: "ID is required" }
if (!id) return { error: formError.idRequired }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
if (!workspaceID) return { error: formError.workspaceRequired }
return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key })
}, "key.remove")
const createKey = action(async (form: FormData) => {
"use server"
const name = form.get("name")?.toString().trim()
if (!name) return { error: "Name is required" }
if (!name) return { error: formError.nameRequired }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(
() =>
@@ -45,6 +47,7 @@ const listKeys = query(async (workspaceID: string) => {
export function KeySection() {
const params = useParams()
const i18n = useI18n()
const keys = createAsync(() => listKeys(params.id!))
const submission = useSubmission(createKey)
const [store, setStore] = createStore({ show: false })
@@ -73,11 +76,11 @@ export function KeySection() {
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>API Keys</h2>
<h2>{i18n.t("workspace.keys.title")}</h2>
<div data-slot="title-row">
<p>Manage your API keys for accessing opencode services.</p>
<p>{i18n.t("workspace.keys.subtitle")}</p>
<button data-color="primary" onClick={() => show()}>
Create API Key
{i18n.t("workspace.keys.create")}
</button>
</div>
</div>
@@ -89,19 +92,19 @@ export function KeySection() {
data-component="input"
name="name"
type="text"
placeholder="Enter key name"
placeholder={i18n.t("workspace.keys.placeholder")}
/>
<Show when={submission.result && submission.result.error}>
{(err) => <div data-slot="form-error">{err()}</div>}
{(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
</div>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="reset" data-color="ghost" onClick={() => hide()}>
Cancel
{i18n.t("common.cancel")}
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? "Creating..." : "Create"}
{submission.pending ? i18n.t("common.creating") : i18n.t("common.create")}
</button>
</div>
</form>
@@ -111,17 +114,17 @@ export function KeySection() {
when={keys()?.length}
fallback={
<div data-component="empty-state">
<p>Create an opencode Gateway API key</p>
<p>{i18n.t("workspace.keys.empty")}</p>
</div>
}
>
<table data-slot="api-keys-table-element">
<thead>
<tr>
<th>Name</th>
<th>Key</th>
<th>Created By</th>
<th>Last Used</th>
<th>{i18n.t("workspace.keys.table.name")}</th>
<th>{i18n.t("workspace.keys.table.key")}</th>
<th>{i18n.t("workspace.keys.table.createdBy")}</th>
<th>{i18n.t("workspace.keys.table.lastUsed")}</th>
<th></th>
</tr>
</thead>
@@ -143,7 +146,7 @@ export function KeySection() {
setCopied(true)
setTimeout(() => setCopied(false), 1000)
}}
title="Copy API key"
title={i18n.t("workspace.keys.copyApiKey")}
>
<span>{key.keyDisplay}</span>
<Show when={copied()} fallback={<IconCopy style={{ width: "14px", height: "14px" }} />}>
@@ -160,7 +163,7 @@ export function KeySection() {
<form action={removeKey} method="post">
<input type="hidden" name="id" value={key.id} />
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="ghost">Delete</button>
<button data-color="ghost">{i18n.t("workspace.keys.delete")}</button>
</form>
</td>
</tr>

View File

@@ -7,6 +7,8 @@ import { UserRole } from "@opencode-ai/console-core/schema/user.sql.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { User } from "@opencode-ai/console-core/user.js"
import { RoleDropdown } from "./role-dropdown"
import { useI18n } from "~/context/i18n"
import { formError, localizeError } from "~/lib/form-error"
const listMembers = query(async (workspaceID: string) => {
"use server"
@@ -22,14 +24,14 @@ const listMembers = query(async (workspaceID: string) => {
const inviteMember = action(async (form: FormData) => {
"use server"
const email = form.get("email")?.toString().trim()
if (!email) return { error: "Email is required" }
if (!email) return { error: formError.emailRequired }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
if (!workspaceID) return { error: formError.workspaceRequired }
const role = form.get("role")?.toString() as (typeof UserRole)[number]
if (!role) return { error: "Role is required" }
if (!role) return { error: formError.roleRequired }
const limit = form.get("limit")?.toString()
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
if (monthlyLimit !== null && monthlyLimit < 0) return { error: "Set a valid monthly limit" }
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
return json(
await withActor(
() =>
@@ -45,9 +47,9 @@ const inviteMember = action(async (form: FormData) => {
const removeMember = action(async (form: FormData) => {
"use server"
const id = form.get("id")?.toString()
if (!id) return { error: "ID is required" }
if (!id) return { error: formError.idRequired }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(
() =>
@@ -64,14 +66,14 @@ const updateMember = action(async (form: FormData) => {
"use server"
const id = form.get("id")?.toString()
if (!id) return { error: "ID is required" }
if (!id) return { error: formError.idRequired }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
if (!workspaceID) return { error: formError.workspaceRequired }
const role = form.get("role")?.toString() as (typeof UserRole)[number]
if (!role) return { error: "Role is required" }
if (!role) return { error: formError.roleRequired }
const limit = form.get("limit")?.toString()
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
if (monthlyLimit !== null && monthlyLimit < 0) return { error: "Set a valid monthly limit" }
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
return json(
await withActor(
@@ -85,7 +87,14 @@ const updateMember = action(async (form: FormData) => {
)
}, "member.update")
function MemberRow(props: { member: any; workspaceID: string; actorID: string; actorRole: string }) {
function MemberRow(props: {
member: any
workspaceID: string
actorID: string
actorRole: string
roleOptions: { value: string; label: string; description: string }[]
}) {
const i18n = useI18n()
const submission = useSubmission(updateMember)
const isCurrentUser = () => props.actorID === props.member.id
const isAdmin = () => props.actorRole === "admin"
@@ -120,12 +129,12 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
const dateLastUsed = props.member.timeMonthlyUsageUpdated
if (!dateLastUsed) return 0
const current = new Date().toLocaleDateString("en-US", {
const current = new Date().toLocaleDateString(undefined, {
year: "numeric",
month: "long",
timeZone: "UTC",
})
const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
const lastUsed = dateLastUsed.toLocaleDateString(undefined, {
year: "numeric",
month: "long",
timeZone: "UTC",
@@ -133,18 +142,22 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
return current === lastUsed ? (props.member.monthlyUsage ?? 0) : 0
})()
const limit = props.member.monthlyLimit ? `$${props.member.monthlyLimit}` : "no limit"
const limit = props.member.monthlyLimit
? `$${props.member.monthlyLimit}`
: i18n.t("workspace.members.noLimitLowercase")
return `$${(currentUsage / 100000000).toFixed(2)} / ${limit}`
}
const roleLabel = (value: string) => props.roleOptions.find((option) => option.value === value)?.label ?? value
return (
<tr>
<td data-slot="member-email">{props.member.authEmail ?? props.member.email}</td>
<td data-slot="member-role">
<Show when={store.editing && !isCurrentUser()} fallback={<span>{props.member.role}</span>}>
<Show when={store.editing && !isCurrentUser()} fallback={<span>{roleLabel(props.member.role)}</span>}>
<RoleDropdown
value={store.selectedRole}
options={roleOptions}
options={props.roleOptions}
onChange={(value) => setStore("selectedRole", value as (typeof UserRole)[number])}
/>
</Show>
@@ -156,12 +169,12 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
type="number"
value={store.limit}
onInput={(e) => setStore("limit", e.currentTarget.value)}
placeholder="No limit"
placeholder={i18n.t("workspace.members.noLimit")}
min="0"
/>
</Show>
</td>
<td data-slot="member-joined">{props.member.timeSeen ? "" : "invited"}</td>
<td data-slot="member-joined">{props.member.timeSeen ? "" : i18n.t("workspace.members.invited")}</td>
<Show when={isAdmin()}>
<td data-slot="member-actions">
<Show
@@ -169,13 +182,13 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
fallback={
<>
<button data-color="ghost" onClick={() => show()}>
Edit
{i18n.t("workspace.members.edit")}
</button>
<Show when={!isCurrentUser()}>
<form action={removeMember} method="post">
<input type="hidden" name="id" value={props.member.id} />
<input type="hidden" name="workspaceID" value={props.workspaceID} />
<button data-color="ghost">Delete</button>
<button data-color="ghost">{i18n.t("workspace.members.delete")}</button>
</form>
</Show>
</>
@@ -187,11 +200,11 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
<input type="hidden" name="role" value={store.selectedRole} />
<input type="hidden" name="limit" value={store.limit} />
<button type="submit" data-color="ghost" disabled={submission.pending}>
{submission.pending ? "Saving..." : "Save"}
{submission.pending ? i18n.t("workspace.members.saving") : i18n.t("workspace.members.save")}
</button>
<Show when={!submission.pending}>
<button type="button" data-color="ghost" onClick={() => hide()}>
Cancel
{i18n.t("common.cancel")}
</button>
</Show>
</form>
@@ -202,13 +215,9 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
)
}
const roleOptions = [
{ value: "admin", description: "Can manage models, members, and billing" },
{ value: "member", description: "Can only generate API keys for themselves" },
]
export function MemberSection() {
const params = useParams()
const i18n = useI18n()
const data = createAsync(() => listMembers(params.id!))
const submission = useSubmission(inviteMember)
const [store, setStore] = createStore({
@@ -219,6 +228,19 @@ export function MemberSection() {
let input: HTMLInputElement
const roleOptions = [
{
value: "admin",
label: i18n.t("workspace.members.role.admin"),
description: i18n.t("workspace.members.role.adminDescription"),
},
{
value: "member",
label: i18n.t("workspace.members.role.member"),
description: i18n.t("workspace.members.role.memberDescription"),
},
]
createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) {
setStore("show", false)
@@ -243,20 +265,20 @@ export function MemberSection() {
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Members</h2>
<h2>{i18n.t("workspace.members.title")}</h2>
<div data-slot="title-row">
<p>Manage workspace members and their permissions.</p>
<p>{i18n.t("workspace.members.subtitle")}</p>
<Show when={data()?.actorRole === "admin"}>
<button data-color="primary" onClick={() => show()}>
Invite Member
{i18n.t("workspace.members.invite")}
</button>
</Show>
</div>
</div>
<div data-slot="beta-notice">
Workspaces are free for teams during the beta.{" "}
{i18n.t("workspace.members.beta.beforeLink")}{" "}
<a href="/docs/zen/#for-teams" target="_blank" rel="noopener noreferrer">
Learn more
{i18n.t("common.learnMore")}
</a>
.
</div>
@@ -264,17 +286,17 @@ export function MemberSection() {
<form action={inviteMember} method="post" data-slot="create-form">
<div data-slot="input-row">
<div data-slot="input-field">
<p>Invitee</p>
<p>{i18n.t("workspace.members.form.invitee")}</p>
<input
ref={(r) => (input = r)}
data-component="input"
name="email"
type="text"
placeholder="Enter email"
placeholder={i18n.t("workspace.members.form.emailPlaceholder")}
/>
</div>
<div data-slot="input-field">
<p>Role</p>
<p>{i18n.t("workspace.members.form.role")}</p>
<RoleDropdown
value={store.selectedRole}
options={roleOptions}
@@ -282,12 +304,12 @@ export function MemberSection() {
/>
</div>
<div data-slot="input-field">
<p>Monthly spending limit</p>
<p>{i18n.t("workspace.members.form.monthlyLimit")}</p>
<input
data-component="input"
name="limit"
type="number"
placeholder="No limit"
placeholder={i18n.t("workspace.members.noLimit")}
value={store.limit}
onInput={(e) => setStore("limit", e.currentTarget.value)}
min="0"
@@ -295,16 +317,16 @@ export function MemberSection() {
</div>
</div>
<Show when={submission.result && submission.result.error}>
{(err) => <div data-slot="form-error">{err()}</div>}
{(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
<input type="hidden" name="role" value={store.selectedRole} />
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="reset" data-color="ghost" onClick={() => hide()}>
Cancel
{i18n.t("common.cancel")}
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? "Inviting..." : "Invite"}
{submission.pending ? i18n.t("workspace.members.inviting") : i18n.t("workspace.members.invite")}
</button>
</div>
</form>
@@ -313,9 +335,9 @@ export function MemberSection() {
<table data-slot="members-table-element">
<thead>
<tr>
<th>Email</th>
<th>Role</th>
<th>Month limit</th>
<th>{i18n.t("workspace.members.table.email")}</th>
<th>{i18n.t("workspace.members.table.role")}</th>
<th>{i18n.t("workspace.members.table.monthLimit")}</th>
<th></th>
<Show when={data()?.actorRole === "admin"}>
<th></th>
@@ -331,6 +353,7 @@ export function MemberSection() {
workspaceID={params.id!}
actorID={data()!.actorID}
actorRole={data()!.actorRole}
roleOptions={roleOptions}
/>
)}
</For>

View File

@@ -4,6 +4,7 @@ import "./role-dropdown.css"
interface RoleOption {
value: string
label: string
description: string
}
@@ -15,6 +16,7 @@ interface RoleDropdownProps {
export function RoleDropdown(props: RoleDropdownProps) {
const [open, setOpen] = createSignal(false)
const selected = () => props.options.find((option) => option.value === props.value)?.label ?? props.value
const handleSelect = (value: string) => {
props.onChange(value)
@@ -22,7 +24,7 @@ export function RoleDropdown(props: RoleDropdownProps) {
}
return (
<Dropdown trigger={props.value} open={open()} onOpenChange={setOpen} class="role-dropdown">
<Dropdown trigger={selected()} open={open()} onOpenChange={setOpen} class="role-dropdown">
<>
{props.options.map((option) => (
<button
@@ -32,7 +34,7 @@ export function RoleDropdown(props: RoleDropdownProps) {
onClick={() => handleSelect(option.value)}
>
<div>
<strong>{option.value}</strong>
<strong>{option.label}</strong>
<p>{option.description}</p>
</div>
</button>

View File

@@ -16,6 +16,8 @@ import {
IconXai,
IconZai,
} from "~/component/icon"
import { useI18n } from "~/context/i18n"
import { formError } from "~/lib/form-error"
const getModelLab = (modelId: string) => {
if (modelId.startsWith("claude")) return "Anthropic"
@@ -59,9 +61,9 @@ const getModelsInfo = query(async (workspaceID: string) => {
const updateModel = action(async (form: FormData) => {
"use server"
const model = form.get("model")?.toString()
if (!model) return { error: "Model is required" }
if (!model) return { error: formError.modelRequired }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
if (!workspaceID) return { error: formError.workspaceRequired }
const enabled = form.get("enabled")?.toString() === "true"
return json(
withActor(async () => {
@@ -77,6 +79,7 @@ const updateModel = action(async (form: FormData) => {
export function ModelSection() {
const params = useParams()
const i18n = useI18n()
const modelsInfo = createAsync(() => getModelsInfo(params.id!))
const userInfo = createAsync(() => querySessionInfo(params.id!))
@@ -91,9 +94,10 @@ export function ModelSection() {
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Models</h2>
<h2>{i18n.t("workspace.models.title")}</h2>
<p>
Manage which models workspace members can access. <a href="/docs/zen#pricing ">Learn more</a>.
{i18n.t("workspace.models.subtitle.beforeLink")} <a href="/docs/zen#pricing ">{i18n.t("common.learnMore")}</a>
.
</p>
</div>
<div data-slot="models-list">
@@ -102,9 +106,9 @@ export function ModelSection() {
<table data-slot="models-table-element">
<thead>
<tr>
<th>Model</th>
<th>{i18n.t("workspace.models.table.model")}</th>
<th></th>
<th>Enabled</th>
<th>{i18n.t("workspace.models.table.enabled")}</th>
</tr>
</thead>
<tbody>

View File

@@ -5,6 +5,7 @@ import { Key } from "@opencode-ai/console-core/key.js"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { withActor } from "~/context/auth.withActor"
import styles from "./new-user-section.module.css"
import { useI18n } from "~/context/i18n"
const getUsageInfo = query(async (workspaceID: string) => {
"use server"
@@ -20,6 +21,7 @@ const listKeys = query(async (workspaceID: string) => {
export function NewUserSection() {
const params = useParams()
const i18n = useI18n()
const [copiedKey, setCopiedKey] = createSignal(false)
const keys = createAsync(() => listKeys(params.id!))
const usage = createAsync(() => getUsageInfo(params.id!))
@@ -42,16 +44,16 @@ export function NewUserSection() {
<div class={styles.root}>
<div data-component="feature-grid">
<div data-slot="feature">
<h3>Tested & Verified Models</h3>
<p>We've benchmarked and tested models specifically for coding agents to ensure the best performance.</p>
<h3>{i18n.t("workspace.newUser.feature.tested.title")}</h3>
<p>{i18n.t("workspace.newUser.feature.tested.body")}</p>
</div>
<div data-slot="feature">
<h3>Highest Quality</h3>
<p>Access models configured for optimal performance - no downgrades or routing to cheaper providers.</p>
<h3>{i18n.t("workspace.newUser.feature.quality.title")}</h3>
<p>{i18n.t("workspace.newUser.feature.quality.body")}</p>
</div>
<div data-slot="feature">
<h3>No Lock-in</h3>
<p>Use Zen with any coding agent, and continue using other providers with opencode whenever you want.</p>
<h3>{i18n.t("workspace.newUser.feature.lockin.title")}</h3>
<p>{i18n.t("workspace.newUser.feature.lockin.body")}</p>
</div>
</div>
@@ -68,17 +70,17 @@ export function NewUserSection() {
setCopiedKey(true)
setTimeout(() => setCopiedKey(false), 2000)
}}
title="Copy API key"
title={i18n.t("workspace.newUser.copyApiKey")}
>
<Show
when={copiedKey()}
fallback={
<>
<IconCopy style={{ width: "16px", height: "16px" }} /> Copy Key
<IconCopy style={{ width: "16px", height: "16px" }} /> {i18n.t("workspace.newUser.copyKey")}
</>
}
>
<IconCheck style={{ width: "16px", height: "16px" }} /> Copied!
<IconCheck style={{ width: "16px", height: "16px" }} /> {i18n.t("workspace.newUser.copied")}
</Show>
</button>
</div>
@@ -88,13 +90,15 @@ export function NewUserSection() {
<div data-component="next-steps">
<ol>
<li>Enable billing</li>
<li>{i18n.t("workspace.newUser.step.enableBilling")}</li>
<li>
Run <code>opencode auth login</code> and select opencode
{i18n.t("workspace.newUser.step.login.before")} <code>opencode auth login</code>{" "}
{i18n.t("workspace.newUser.step.login.after")}
</li>
<li>Paste your API key</li>
<li>{i18n.t("workspace.newUser.step.pasteKey")}</li>
<li>
Start opencode and run <code>/models</code> to select a model
{i18n.t("workspace.newUser.step.models.before")} <code>/models</code>{" "}
{i18n.t("workspace.newUser.step.models.after")}
</li>
</ol>
</div>

View File

@@ -4,6 +4,8 @@ import { Provider } from "@opencode-ai/console-core/provider.js"
import { withActor } from "~/context/auth.withActor"
import { createStore } from "solid-js/store"
import styles from "./provider-section.module.css"
import { useI18n } from "~/context/i18n"
import { formError, localizeError } from "~/lib/form-error"
const PROVIDERS = [
{ name: "OpenAI", key: "openai", prefix: "sk-" },
@@ -20,9 +22,9 @@ function maskCredentials(credentials: string) {
const removeProvider = action(async (form: FormData) => {
"use server"
const provider = form.get("provider")?.toString()
if (!provider) return { error: "Provider is required" }
if (!provider) return { error: formError.providerRequired }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
if (!workspaceID) return { error: formError.workspaceRequired }
return json(await withActor(() => Provider.remove({ provider }), workspaceID), {
revalidate: listProviders.key,
})
@@ -32,10 +34,10 @@ const saveProvider = action(async (form: FormData) => {
"use server"
const provider = form.get("provider")?.toString()
const credentials = form.get("credentials")?.toString()
if (!provider) return { error: "Provider is required" }
if (!credentials) return { error: "API key is required" }
if (!provider) return { error: formError.providerRequired }
if (!credentials) return { error: formError.apiKeyRequired }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(
() =>
@@ -55,6 +57,7 @@ const listProviders = query(async (workspaceID: string) => {
function ProviderRow(props: { provider: Provider }) {
const params = useParams()
const i18n = useI18n()
const providers = createAsync(() => listProviders(params.id!))
const saveSubmission = useSubmission(saveProvider, ([fd]) => fd.get("provider")?.toString() === props.provider.key)
const removeSubmission = useSubmission(
@@ -100,13 +103,16 @@ function ProviderRow(props: { provider: Provider }) {
ref={(r) => (input = r)}
name="credentials"
type="text"
placeholder={`Enter ${props.provider.name} API key (${props.provider.prefix}...)`}
placeholder={i18n.t("workspace.providers.placeholder", {
provider: props.provider.name,
prefix: props.provider.prefix,
})}
autocomplete="off"
data-form-type="other"
data-lpignore="true"
/>
<Show when={saveSubmission.result && saveSubmission.result.error}>
{(err) => <div data-slot="form-error">{err()}</div>}
{(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
</div>
<input type="hidden" name="provider" value={props.provider.key} />
@@ -122,19 +128,19 @@ function ProviderRow(props: { provider: Provider }) {
when={!!providerData()}
fallback={
<button data-color="ghost" onClick={() => show()}>
Configure
{i18n.t("workspace.providers.configure")}
</button>
}
>
<div data-slot="configured-actions">
<button data-color="ghost" onClick={() => show()}>
Edit
{i18n.t("workspace.providers.edit")}
</button>
<form action={removeProvider} method="post" data-slot="delete-form">
<input type="hidden" name="provider" value={props.provider.key} />
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="ghost" type="submit" disabled={removeSubmission.pending}>
Delete
{i18n.t("workspace.providers.delete")}
</button>
</form>
</div>
@@ -148,11 +154,11 @@ function ProviderRow(props: { provider: Provider }) {
disabled={saveSubmission.pending}
form={`provider-form-${props.provider.key}`}
>
{saveSubmission.pending ? "Saving..." : "Save"}
{saveSubmission.pending ? i18n.t("workspace.providers.saving") : i18n.t("workspace.providers.save")}
</button>
<Show when={!saveSubmission.pending}>
<button type="reset" data-color="ghost" onClick={() => hide()}>
Cancel
{i18n.t("common.cancel")}
</button>
</Show>
</div>
@@ -163,18 +169,20 @@ function ProviderRow(props: { provider: Provider }) {
}
export function ProviderSection() {
const i18n = useI18n()
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Bring Your Own Key</h2>
<p>Configure your own API keys from AI providers.</p>
<h2>{i18n.t("workspace.providers.title")}</h2>
<p>{i18n.t("workspace.providers.subtitle")}</p>
</div>
<div data-slot="providers-table">
<table data-slot="providers-table-element">
<thead>
<tr>
<th>Provider</th>
<th>API Key</th>
<th>{i18n.t("workspace.providers.table.provider")}</th>
<th>{i18n.t("workspace.providers.table.apiKey")}</th>
<th></th>
</tr>
</thead>

View File

@@ -6,6 +6,8 @@ import { Workspace } from "@opencode-ai/console-core/workspace.js"
import styles from "./settings-section.module.css"
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { useI18n } from "~/context/i18n"
import { formError, localizeError } from "~/lib/form-error"
const getWorkspaceInfo = query(async (workspaceID: string) => {
"use server"
@@ -29,10 +31,10 @@ const getWorkspaceInfo = query(async (workspaceID: string) => {
const updateWorkspace = action(async (form: FormData) => {
"use server"
const name = form.get("name")?.toString().trim()
if (!name) return { error: "Workspace name is required." }
if (name.length > 255) return { error: "Name must be 255 characters or less." }
if (!name) return { error: formError.workspaceNameRequired }
if (name.length > 255) return { error: formError.nameTooLong }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required." }
if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(
() =>
@@ -46,6 +48,7 @@ const updateWorkspace = action(async (form: FormData) => {
export function SettingsSection() {
const params = useParams()
const i18n = useI18n()
const workspaceInfo = createAsync(() => getWorkspaceInfo(params.id!))
const submission = useSubmission(updateWorkspace)
const [store, setStore] = createStore({ show: false })
@@ -74,12 +77,12 @@ export function SettingsSection() {
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Settings</h2>
<p>Update your workspace name and preferences.</p>
<h2>{i18n.t("workspace.settings.title")}</h2>
<p>{i18n.t("workspace.settings.subtitle")}</p>
</div>
<div data-slot="section-content">
<div data-slot="setting">
<p>Workspace name</p>
<p>{i18n.t("workspace.settings.workspaceName")}</p>
<Show
when={!store.show}
fallback={
@@ -91,19 +94,19 @@ export function SettingsSection() {
data-component="input"
name="name"
type="text"
placeholder="Workspace name"
value={workspaceInfo()?.name ?? "Default"}
placeholder={i18n.t("workspace.settings.workspaceName")}
value={workspaceInfo()?.name ?? i18n.t("workspace.settings.defaultName")}
/>
<input type="hidden" name="workspaceID" value={params.id} />
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? "Updating..." : "Save"}
{submission.pending ? i18n.t("workspace.settings.updating") : i18n.t("workspace.settings.save")}
</button>
<button type="reset" data-color="ghost" onClick={() => hide()}>
Cancel
{i18n.t("common.cancel")}
</button>
</div>
<Show when={submission.result && submission.result.error}>
{(err) => <div data-slot="form-error">{err()}</div>}
{(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
</form>
}
@@ -111,7 +114,7 @@ export function SettingsSection() {
<div data-slot="value-with-action">
<p data-slot="current-value">{workspaceInfo()?.name}</p>
<button data-color="primary" onClick={() => show()}>
Edit
{i18n.t("workspace.settings.edit")}
</button>
</div>
</Show>

View File

@@ -6,6 +6,7 @@ import { withActor } from "~/context/auth.withActor"
import { IconChevronLeft, IconChevronRight, IconBreakdown } from "~/component/icon"
import styles from "./usage-section.module.css"
import { createStore } from "solid-js/store"
import { useI18n } from "~/context/i18n"
const PAGE_SIZE = 50
@@ -20,6 +21,7 @@ const queryUsageInfo = query(getUsageInfo, "usage.list")
export function UsageSection() {
const params = useParams()
const i18n = useI18n()
const usage = createAsync(() => queryUsageInfo(params.id!, 0))
const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> })
const [openBreakdownId, setOpenBreakdownId] = createSignal<string | null>(null)
@@ -72,26 +74,26 @@ export function UsageSection() {
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Usage History</h2>
<p>Recent API usage and costs.</p>
<h2>{i18n.t("workspace.usage.title")}</h2>
<p>{i18n.t("workspace.usage.subtitle")}</p>
</div>
<div data-slot="usage-table">
<Show
when={hasResults()}
fallback={
<div data-component="empty-state">
<p>Make your first API call to get started.</p>
<p>{i18n.t("workspace.usage.empty")}</p>
</div>
}
>
<table data-slot="usage-table-element">
<thead>
<tr>
<th>Date</th>
<th>Model</th>
<th>Input</th>
<th>Output</th>
<th>Cost</th>
<th>{i18n.t("workspace.usage.table.date")}</th>
<th>{i18n.t("workspace.usage.table.model")}</th>
<th>{i18n.t("workspace.usage.table.input")}</th>
<th>{i18n.t("workspace.usage.table.output")}</th>
<th>{i18n.t("workspace.usage.table.cost")}</th>
</tr>
</thead>
<tbody>
@@ -126,16 +128,18 @@ export function UsageSection() {
<Show when={isInputOpen()}>
<div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Input</span>
<span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.input")}</span>
<span data-slot="breakdown-value">{usage.inputTokens}</span>
</div>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Cache Read</span>
<span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.cacheRead")}</span>
<span data-slot="breakdown-value">{usage.cacheReadTokens ?? 0}</span>
</div>
<Show when={isClaude}>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Cache Write</span>
<span data-slot="breakdown-label">
{i18n.t("workspace.usage.breakdown.cacheWrite")}
</span>
<span data-slot="breakdown-value">{usage.cacheWrite5mTokens ?? 0}</span>
</div>
</Show>
@@ -158,11 +162,11 @@ export function UsageSection() {
<Show when={isOutputOpen()}>
<div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Output</span>
<span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.output")}</span>
<span data-slot="breakdown-value">{usage.outputTokens}</span>
</div>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Reasoning</span>
<span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.reasoning")}</span>
<span data-slot="breakdown-value">{usage.reasoningTokens ?? 0}</span>
</div>
</div>
@@ -174,7 +178,9 @@ export function UsageSection() {
when={usage.enrichment?.plan === "sub"}
fallback={<>${((usage.cost ?? 0) / 100000000).toFixed(4)}</>}
>
subscription (${((usage.cost ?? 0) / 100000000).toFixed(4)})
{i18n.t("workspace.usage.subscription", {
amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
})}
</Show>
</td>
</tr>

View File

@@ -30,7 +30,7 @@ export function formatDateUTC(date: Date) {
timeZoneName: "short",
timeZone: "UTC",
}
return date.toLocaleDateString("en-US", options)
return date.toLocaleDateString(undefined, options)
}
export function formatBalance(amount: number) {

View File

@@ -177,6 +177,7 @@ body {
border-radius: 4px;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
@media (max-width: 55rem) {
display: none;

View File

@@ -19,6 +19,7 @@ import { Footer } from "~/component/footer"
import { Header } from "~/component/header"
import { getLastSeenWorkspaceID } from "../workspace/common"
import { IconGemini, IconMiniMax, IconZai } from "~/component/icon"
import { useI18n } from "~/context/i18n"
const checkLoggedIn = query(async () => {
"use server"
@@ -28,10 +29,11 @@ const checkLoggedIn = query(async () => {
export default function Home() {
const loggedin = createAsync(() => checkLoggedIn())
const i18n = useI18n()
return (
<main data-page="zen">
{/*<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />*/}
<Title>OpenCode Zen | A curated set of reliable optimized models for coding agents</Title>
<Title>{i18n.t("zen.title")}</Title>
<Link rel="canonical" href={`${config.baseUrl}/zen`} />
<Meta property="og:image" content="/social-share-zen.png" />
<Meta name="twitter:image" content="/social-share-zen.png" />
@@ -43,14 +45,10 @@ export default function Home() {
<div data-component="content">
<section data-component="hero">
<div data-slot="hero-copy">
<img data-slot="zen logo light" src={zenLogoLight} alt="zen logo light" />
<img data-slot="zen logo dark" src={zenLogoDark} alt="zen logo dark" />
<h1>Reliable optimized models for coding agents</h1>
<p>
Zen gives you access to a curated set of AI models that OpenCode has tested and benchmarked specifically
for coding agents. No need to worry about inconsistent performance and quality, use validated models
that work.
</p>
<img data-slot="zen logo light" src={zenLogoLight} alt="" />
<img data-slot="zen logo dark" src={zenLogoDark} alt="" />
<h1>{i18n.t("zen.hero.title")}</h1>
<p>{i18n.t("zen.hero.body")}</p>
<div data-slot="model-logos">
<div>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -123,7 +121,7 @@ export default function Home() {
</div>
</div>
<a href="/auth">
<span>Get started with Zen </span>
<span>{i18n.t("zen.cta.start")}</span>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.5 12L17 12M13 16.5L17.5 12L13 7.5"
@@ -136,66 +134,63 @@ export default function Home() {
</div>
<div data-slot="pricing-copy">
<p>
<strong>Add $20 Pay as you go balance</strong> <span>(+$1.23 card processing fee)</span>
<strong>{i18n.t("zen.pricing.title")}</strong> <span>{i18n.t("zen.pricing.fee")}</span>
</p>
<p>Use with any agent. Set monthly spend limits. Cancel any time.</p>
<p>{i18n.t("zen.pricing.body")}</p>
</div>
</section>
<section data-component="comparison">
<video src={compareVideo} autoplay playsinline loop muted preload="auto" poster={compareVideoPoster}>
Your browser does not support the video tag.
{i18n.t("common.videoUnsupported")}
</video>
</section>
<section data-component="problem">
<div data-slot="section-title">
<h3>What problem is Zen solving?</h3>
<p>
There are so many models available, but only a few work well with coding agents. Most providers
configure them differently with varying results.
</p>
<h3>{i18n.t("zen.problem.title")}</h3>
<p>{i18n.t("zen.problem.body")}</p>
</div>
<p>We're fixing this for everyone, not just OpenCode users.</p>
<p>{i18n.t("zen.problem.subtitle")}</p>
<ul>
<li>
<span>[*]</span> Testing select models and consulting their teams
<span>[*]</span> {i18n.t("zen.problem.item1")}
</li>
<li>
<span>[*]</span> Working with providers to ensure theyre delivered properly
<span>[*]</span> {i18n.t("zen.problem.item2")}
</li>
<li>
<span>[*]</span> Benchmarking all model-provider combinations we recommend
<span>[*]</span> {i18n.t("zen.problem.item3")}
</li>
</ul>
</section>
<section data-component="how">
<div data-slot="section-title">
<h3>How Zen works</h3>
<p>While we suggest you use Zen with OpenCode, you can use Zen with any agent.</p>
<h3>{i18n.t("zen.how.title")}</h3>
<p>{i18n.t("zen.how.body")}</p>
</div>
<ul>
<li>
<span>[1]</span>
<div>
<strong>Sign up and add $20 balance</strong> - follow the{" "}
<a href="/docs/zen/#how-it-works" title="setup instructions">
setup instructions
<strong>{i18n.t("zen.how.step1.title")}</strong> - {i18n.t("zen.how.step1.beforeLink")}{" "}
<a href="/docs/zen/#how-it-works" title={i18n.t("zen.how.step1.link")}>
{i18n.t("zen.how.step1.link")}
</a>
</div>
</li>
<li>
<span>[2]</span>
<div>
<strong>Use Zen with transparent pricing</strong> - <a href="/docs/zen/#pricing">pay per request</a>{" "}
with zero markups
<strong>{i18n.t("zen.how.step2.title")}</strong> -{" "}
<a href="/docs/zen/#pricing">{i18n.t("zen.how.step2.link")}</a> {i18n.t("zen.how.step2.afterLink")}
</div>
</li>
<li>
<span>[3]</span>
<div>
<strong>Auto-top up</strong> - when your balance reaches $5 well automatically add $20
<strong>{i18n.t("zen.how.step3.title")}</strong> - {i18n.t("zen.how.step3.body")}
</div>
</li>
</ul>
@@ -203,12 +198,12 @@ export default function Home() {
<section data-component="privacy">
<div data-slot="privacy-title">
<h3>Your privacy is important to us</h3>
<h3>{i18n.t("zen.privacy.title")}</h3>
<div>
<span>[*]</span>
<p>
All Zen models are hosted in the US. Providers follow a zero-retention policy and do not use your data
for model training, with the <a href="/docs/zen/#privacy">following exceptions</a>.
{i18n.t("zen.privacy.beforeExceptions")}{" "}
<a href="/docs/zen/#privacy">{i18n.t("zen.privacy.exceptionsLink")}</a>.
</p>
</div>
</div>
@@ -224,7 +219,8 @@ export default function Home() {
<span>ex-CEO, Terminal Products</span>
</div>
<div data-slot="quote">
<span>@OpenCode</span> Zen has been life changing, it's truly a no-brainer.
<span>@OpenCode</span>
{" Zen has been life changing, it's truly a no-brainer."}
</div>
</div>
</a>
@@ -237,7 +233,9 @@ export default function Home() {
<span>ex-Founder, SEED, PM, Melt, Pop, Dapt, Cadmus, and ViewPoint</span>
</div>
<div data-slot="quote">
4 out of 5 people on our team love using <span>@OpenCode</span> Zen.
{"4 out of 5 people on our team love using "}
<span>@OpenCode</span>
{" Zen."}
</div>
</div>
</a>
@@ -250,7 +248,9 @@ export default function Home() {
<span>ex-Hero, AWS</span>
</div>
<div data-slot="quote">
I can't recommend <span>@OpenCode</span> Zen enough. Seriously, its really good.
{"I can't recommend "}
<span>@OpenCode</span>
{" Zen enough. Seriously, it's really good."}
</div>
</div>
</a>
@@ -263,7 +263,9 @@ export default function Home() {
<span>ex-Head of Design, Laravel</span>
</div>
<div data-slot="quote">
With <span>@OpenCode</span> Zen I know all the models are tested and perfect for coding agents.
{"With "}
<span>@OpenCode</span>
{" Zen I know all the models are tested and perfect for coding agents."}
</div>
</div>
</a>
@@ -282,54 +284,40 @@ export default function Home() {
<section data-component="faq">
<div data-slot="section-title">
<h3>FAQ</h3>
<h3>{i18n.t("common.faq")}</h3>
</div>
<ul>
<li>
<Faq question="What is OpenCode Zen?">
Zen is a curated set of AI models tested and benchmarked for coding agents created by the team behind
OpenCode.
<Faq question={i18n.t("zen.faq.q1")}>{i18n.t("zen.faq.a1")}</Faq>
</li>
<li>
<Faq question={i18n.t("zen.faq.q2")}>{i18n.t("zen.faq.a2")}</Faq>
</li>
<li>
<Faq question={i18n.t("zen.faq.q3")}>{i18n.t("zen.faq.a3")}</Faq>
</li>
<li>
<Faq question={i18n.t("zen.faq.q4")}>
{i18n.t("zen.faq.a4.p1.beforePricing")}{" "}
<a href="/docs/zen/#pricing">{i18n.t("zen.faq.a4.p1.pricingLink")}</a>{" "}
{i18n.t("zen.faq.a4.p1.afterPricing")} {i18n.t("zen.faq.a4.p2.beforeAccount")}{" "}
<a href="/auth">{i18n.t("zen.faq.a4.p2.accountLink")}</a>. {i18n.t("zen.faq.a4.p3")}
</Faq>
</li>
<li>
<Faq question="What makes Zen more accurate?">
Zen only provides models that have been specifically tested and benchmarked for coding agents. You
wouldnt use a butter knife to cut steak, dont use poor models for coding.
<Faq question={i18n.t("zen.faq.q5")}>
{i18n.t("zen.faq.a5.beforeExceptions")}{" "}
<a href="/docs/zen/#privacy">{i18n.t("zen.faq.a5.exceptionsLink")}</a>.
</Faq>
</li>
<li>
<Faq question="Is Zen cheaper?">
Zen is not for profit. Zen passes through the costs from the model providers to you. The higher Zens
usage the more OpenCode can negotiate better rates and pass those to you.
</Faq>
<Faq question={i18n.t("zen.faq.q6")}>{i18n.t("zen.faq.a6")}</Faq>
</li>
<li>
<Faq question="How much does Zen cost?">
Zen <a href="/docs/zen/#pricing">charges per request</a> with zero markups, so you pay exactly what
the model provider charges. Your total cost depends on usage, and you can set monthly spend limits in
your <a href="/auth">account</a>. To cover costs, OpenCode adds only a small payment processing fee of
$1.23 per $20 balance top-up.
</Faq>
<Faq question={i18n.t("zen.faq.q7")}>{i18n.t("zen.faq.a7")}</Faq>
</li>
<li>
<Faq question="What about data and privacy?">
All Zen models are hosted in the US. Providers follow a zero-retention policy and do not use your data
for model training, with the <a href="/docs/zen/#privacy">following exceptions</a>.
</Faq>
</li>
<li>
<Faq question="Can I set spend limits?">Yes, you can set monthly spending limits in your account.</Faq>
</li>
<li>
<Faq question="Can I cancel?">
Yes, you can disable billing at any time and use your remaining balance.
</Faq>
</li>
<li>
<Faq question="Can I use Zen with other coding agents?">
While Zen works great with OpenCode, you can use Zen with any agent. Follow the setup instructions in
your preferred coding agent.
</Faq>
<Faq question={i18n.t("zen.faq.q8")}>{i18n.t("zen.faq.a8")}</Faq>
</li>
</ul>
</section>