|
@@ -88,7 +100,7 @@ export function PaymentSection() {
|
{payment.id} |
- ${((amount ?? 0) / 100000000).toFixed(2)}
+ {money(amount, currency)}
{" "}
diff --git a/packages/console/app/src/routes/workspace/[id]/go/index.tsx b/packages/console/app/src/routes/workspace/[id]/go/index.tsx
new file mode 100644
index 000000000000..fb89e3c7025a
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/go/index.tsx
@@ -0,0 +1,30 @@
+import { IconGo } from "~/component/icon"
+import { useI18n } from "~/context/i18n"
+import { useLanguage } from "~/context/language"
+import { LiteSection } from "./lite-section"
+
+export default function () {
+ const i18n = useI18n()
+ const language = useLanguage()
+
+ return (
+
+ )
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/lite-section.module.css b/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css
similarity index 83%
rename from packages/console/app/src/routes/workspace/[id]/billing/lite-section.module.css
rename to packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css
index 76d9bcfb099c..05daf43b7a97 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/lite-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css
@@ -167,6 +167,11 @@
color: var(--color-text-secondary);
line-height: 1.5;
margin-top: var(--space-2);
+
+ strong {
+ color: var(--color-text);
+ font-weight: 600;
+ }
}
[data-slot="promo-models-title"] {
@@ -183,8 +188,45 @@
line-height: 1.4;
}
+ [data-slot="subscribe-actions"] {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+ margin-top: var(--space-4);
+ }
+
[data-slot="subscribe-button"] {
- align-self: flex-start;
+ align-self: stretch;
+ }
+
+ [data-slot="other-methods"] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ }
+
+ [data-slot="other-methods-icons"] {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ [data-slot="modal-actions"] {
+ display: flex;
+ gap: var(--space-3);
margin-top: var(--space-4);
+
+ button {
+ flex: 1;
+ }
+ }
+
+ [data-slot="method-button"] {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: var(--space-2);
+ height: 48px;
}
}
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx
similarity index 67%
rename from packages/console/app/src/routes/workspace/[id]/billing/lite-section.tsx
rename to packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx
index f67775d79c81..2f8ad8aba4fc 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/lite-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx
@@ -1,6 +1,7 @@
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
import { createStore } from "solid-js/store"
-import { Show } from "solid-js"
+import { createMemo, For, Show } from "solid-js"
+import { Modal } from "~/component/modal"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
@@ -14,6 +15,8 @@ import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { formError } from "~/lib/form-error"
+import { IconAlipay, IconUpi } from "~/component/icon"
+
const queryLiteSubscription = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
@@ -78,22 +81,25 @@ function formatResetTime(seconds: number, i18n: ReturnType) {
return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
}
-const createLiteCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
- "use server"
- return json(
- await withActor(
- () =>
- Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl })
- .then((data) => ({ error: undefined, data }))
- .catch((e) => ({
- error: e.message as string,
- data: undefined,
- })),
- workspaceID,
- ),
- { revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
- )
-}, "liteCheckoutUrl")
+const createLiteCheckoutUrl = action(
+ async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => {
+ "use server"
+ return json(
+ await withActor(
+ () =>
+ Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method })
+ .then((data) => ({ error: undefined, data }))
+ .catch((e) => ({
+ error: e.message as string,
+ data: undefined,
+ })),
+ workspaceID,
+ ),
+ { revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
+ )
+ },
+ "liteCheckoutUrl",
+)
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
@@ -138,6 +144,8 @@ export function LiteSection() {
const params = useParams()
const i18n = useI18n()
const language = useLanguage()
+ const billingInfo = createAsync(() => queryBillingInfo(params.id!))
+ const isBlack = createMemo(() => billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked)
const lite = createAsync(() => queryLiteSubscription(params.id!))
const sessionAction = useAction(createSessionUrl)
const sessionSubmission = useSubmission(createSessionUrl)
@@ -145,40 +153,47 @@ export function LiteSection() {
const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
const useBalanceSubmission = useSubmission(setLiteUseBalance)
const [store, setStore] = createStore({
- redirecting: false,
+ loading: undefined as undefined | "session" | "checkout" | "alipay" | "upi",
+ showModal: false,
})
+ const busy = createMemo(() => !!store.loading)
+
async function onClickSession() {
+ setStore("loading", "session")
const result = await sessionAction(params.id!, window.location.href)
if (result.data) {
- setStore("redirecting", true)
window.location.href = result.data
+ return
}
+ setStore("loading", undefined)
}
- async function onClickSubscribe() {
- const result = await checkoutAction(params.id!, window.location.href, window.location.href)
+ async function onClickSubscribe(method?: "alipay" | "upi") {
+ setStore("loading", method ?? "checkout")
+ const result = await checkoutAction(params.id!, window.location.href, window.location.href, method)
if (result.data) {
- setStore("redirecting", true)
window.location.href = result.data
+ return
}
+ setStore("loading", undefined)
}
return (
<>
-
+
+
+ {i18n.t("workspace.lite.black.message")}
+
+
+
{(sub) => (
- {i18n.t("workspace.lite.subscription.title")}
{i18n.t("workspace.lite.subscription.message")}
-
+ setStore("showModal", false)}
+ title={i18n.t("workspace.lite.promo.selectMethod")}
>
- {checkoutSubmission.pending || store.redirecting
- ? i18n.t("workspace.lite.promo.subscribing")
- : i18n.t("workspace.lite.promo.subscribe")}
-
+
+ onClickSubscribe("alipay")}
+ >
+
+
+
+ {store.loading === "alipay" ? i18n.t("workspace.lite.promo.subscribing") : "Alipay"}
+
+ onClickSubscribe("upi")}
+ >
+
+
+
+ {store.loading === "upi" ? i18n.t("workspace.lite.promo.subscribing") : "UPI"}
+
+
+
>
diff --git a/packages/console/app/src/routes/workspace/[id]/index.tsx b/packages/console/app/src/routes/workspace/[id]/index.tsx
index c91cfd2bcc85..b5c597b12198 100644
--- a/packages/console/app/src/routes/workspace/[id]/index.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/index.tsx
@@ -1,12 +1,10 @@
-import { Match, Show, Switch, createMemo } from "solid-js"
+import { Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router"
import { NewUserSection } from "./new-user-section"
-import { UsageSection } from "./usage-section"
import { ModelSection } from "./model-section"
import { ProviderSection } from "./provider-section"
-import { GraphSection } from "./graph-section"
-import { IconLogo } from "~/component/icon"
+import { IconZen } from "~/component/icon"
import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
@@ -36,7 +34,7 @@ export default function () {
return (
-
+
{i18n.t("workspace.home.banner.beforeLink")}{" "}
@@ -73,14 +71,10 @@ export default function () {
)
diff --git a/packages/console/app/src/routes/workspace/[id]/model-section.tsx b/packages/console/app/src/routes/workspace/[id]/model-section.tsx
index a4b64889cad7..bf19f81cd2bc 100644
--- a/packages/console/app/src/routes/workspace/[id]/model-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/model-section.tsx
@@ -8,12 +8,15 @@ import { querySessionInfo } from "../common"
import {
IconAlibaba,
IconAnthropic,
+ IconArcee,
IconGemini,
IconMiniMax,
IconMoonshotAI,
+ IconNvidia,
IconOpenAI,
IconStealth,
IconXai,
+ IconXiaomi,
IconZai,
} from "~/component/icon"
import { useI18n } from "~/context/i18n"
@@ -29,6 +32,9 @@ const getModelLab = (modelId: string) => {
if (modelId.startsWith("qwen")) return "Alibaba"
if (modelId.startsWith("minimax")) return "MiniMax"
if (modelId.startsWith("grok")) return "xAI"
+ if (modelId.startsWith("mimo")) return "Xiaomi"
+ if (modelId.startsWith("nemotron")) return "NVIDIA"
+ if (modelId.startsWith("trinity")) return "Arcee"
return "Stealth"
}
@@ -139,6 +145,12 @@ export function ModelSection() {
return
case "MiniMax":
return
+ case "Xiaomi":
+ return
+ case "NVIDIA":
+ return
+ case "Arcee":
+ return
default:
return
}
diff --git a/packages/console/app/src/routes/workspace/[id]/graph-section.module.css b/packages/console/app/src/routes/workspace/[id]/usage/graph-section.module.css
similarity index 100%
rename from packages/console/app/src/routes/workspace/[id]/graph-section.module.css
rename to packages/console/app/src/routes/workspace/[id]/usage/graph-section.module.css
diff --git a/packages/console/app/src/routes/workspace/[id]/graph-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage/graph-section.tsx
similarity index 100%
rename from packages/console/app/src/routes/workspace/[id]/graph-section.tsx
rename to packages/console/app/src/routes/workspace/[id]/usage/graph-section.tsx
diff --git a/packages/console/app/src/routes/workspace/[id]/usage/index.tsx b/packages/console/app/src/routes/workspace/[id]/usage/index.tsx
new file mode 100644
index 000000000000..3a9c8db29619
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/usage/index.tsx
@@ -0,0 +1,21 @@
+import { Show } from "solid-js"
+import { createAsync, useParams } from "@solidjs/router"
+import { GraphSection } from "./graph-section"
+import { UsageSection } from "./usage-section"
+import { querySessionInfo } from "../../common"
+
+export default function () {
+ const params = useParams()
+ const user = createAsync(() => querySessionInfo(params.id!))
+
+ return (
+
+ )
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/usage-section.module.css b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css
similarity index 100%
rename from packages/console/app/src/routes/workspace/[id]/usage-section.module.css
rename to packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css
diff --git a/packages/console/app/src/routes/workspace/[id]/usage-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx
similarity index 99%
rename from packages/console/app/src/routes/workspace/[id]/usage-section.tsx
rename to packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx
index a20a5bf0d1b5..2cf8ef850a6f 100644
--- a/packages/console/app/src/routes/workspace/[id]/usage-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx
@@ -1,7 +1,7 @@
import { Billing } from "@opencode-ai/console-core/billing.js"
import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, For, Show, Switch, Match, createEffect, createSignal } from "solid-js"
-import { formatDateUTC, formatDateForTable } from "../common"
+import { formatDateUTC, formatDateForTable } from "../../common"
import { withActor } from "~/context/auth.withActor"
import { IconChevronLeft, IconChevronRight, IconBreakdown } from "~/component/icon"
import styles from "./usage-section.module.css"
diff --git a/packages/console/app/src/routes/zen/index.tsx b/packages/console/app/src/routes/zen/index.tsx
index 5b5581b53b2e..62e8f5d37966 100644
--- a/packages/console/app/src/routes/zen/index.tsx
+++ b/packages/console/app/src/routes/zen/index.tsx
@@ -24,8 +24,7 @@ import { LocaleLinks } from "~/component/locale-links"
const checkLoggedIn = query(async () => {
"use server"
- const workspaceID = await getLastSeenWorkspaceID().catch(() => {})
- if (workspaceID) throw redirect(`/workspace/${workspaceID}`)
+ return await getLastSeenWorkspaceID().catch(() => {})
}, "checkLoggedIn.get")
export default function Home() {
diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts
index d0d8f172bfce..9dbadf1eef3b 100644
--- a/packages/console/app/src/routes/zen/util/handler.ts
+++ b/packages/console/app/src/routes/zen/util/handler.ts
@@ -24,7 +24,13 @@ import {
FreeUsageLimitError,
SubscriptionUsageLimitError,
} from "./error"
-import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
+import {
+ buildCostChunk,
+ createBodyConverter,
+ createStreamPartConverter,
+ createResponseConverter,
+ UsageInfo,
+} from "./provider/provider"
import { anthropicHelper } from "./provider/anthropic"
import { googleHelper } from "./provider/google"
import { openaiHelper } from "./provider/openai"
@@ -74,8 +80,9 @@ export async function handler(
const dict = i18n(localeFromRequest(input.request))
const t = (key: Key, params?: Record ) => resolve(dict[key], params)
const ADMIN_WORKSPACES = [
- "wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
- "wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
+ "wrk_01K46JDFR0E75SG2Q8K172KF3Y", // anomaly
+ "wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // benchmark
+ "wrk_01KKZDKDWCS1VTJF8QTX62DD50", // contributors
]
try {
@@ -89,7 +96,7 @@ export async function handler(
const projectId = input.request.headers.get("x-opencode-project") ?? ""
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
logger.metric({
- is_tream: isStream,
+ is_stream: isStream,
session: sessionId,
request: requestId,
client: ocClient,
@@ -97,8 +104,8 @@ export async function handler(
const zenData = ZenData.list(opts.modelList)
const modelInfo = validateModel(zenData, model)
const dataDumper = createDataDumper(sessionId, requestId, projectId)
- const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip)
- const trialProvider = await trialLimiter?.check()
+ const trialLimiter = createTrialLimiter(modelInfo.trialProviders, ip)
+ const trialProviders = await trialLimiter?.check()
const rateLimiter = createRateLimiter(
modelInfo.id,
modelInfo.allowAnonymous,
@@ -119,8 +126,9 @@ export async function handler(
zenData,
authInfo,
modelInfo,
+ ip,
sessionId,
- trialProvider,
+ trialProviders,
retry,
stickyProvider,
)
@@ -136,6 +144,11 @@ export async function handler(
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
...(providerInfo.payloadModifier ?? {}),
+ ...Object.fromEntries(
+ Object.entries(providerInfo.payloadMappings ?? {})
+ .map(([k, v]) => [k, input.request.headers.get(v)])
+ .filter(([_k, v]) => !!v),
+ ),
},
authInfo?.workspaceID,
),
@@ -223,7 +236,7 @@ export async function handler(
const body = JSON.stringify(
responseConverter({
...json,
- cost: calculateOccuredCost(billingSource, costInfo),
+ cost: calculateOccurredCost(billingSource, costInfo),
}),
)
logger.metric({ response_length: body.length })
@@ -267,8 +280,8 @@ export async function handler(
await trialLimiter?.track(usageInfo)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo)
- const cost = calculateOccuredCost(billingSource, costInfo)
- c.enqueue(encoder.encode(usageParser.buidlCostChunk(cost)))
+ const cost = calculateOccurredCost(billingSource, costInfo)
+ c.enqueue(encoder.encode(buildCostChunk(opts.format, cost)))
}
c.close()
return
@@ -325,6 +338,7 @@ export async function handler(
logger.metric({
"error.type": error.constructor.name,
"error.message": error.message,
+ "error.cause": error.cause?.toString(),
})
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
@@ -395,8 +409,9 @@ export async function handler(
zenData: ZenData,
authInfo: AuthInfo,
modelInfo: ModelInfo,
+ ip: string,
sessionId: string,
- trialProvider: string | undefined,
+ trialProviders: string[] | undefined,
retry: RetryOptions,
stickyProvider: string | undefined,
) {
@@ -405,15 +420,17 @@ export async function handler(
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
}
- if (trialProvider) {
- return modelInfo.providers.find((provider) => provider.id === trialProvider)
- }
-
if (stickyProvider) {
const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider)
if (provider) return provider
}
+ if (trialProviders) {
+ const trialProvider = trialProviders[Math.floor(Math.random() * trialProviders.length)]
+ const provider = modelInfo.providers.find((provider) => provider.id === trialProvider)
+ if (provider) return provider
+ }
+
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
const providers = modelInfo.providers
.filter((provider) => !provider.disabled)
@@ -421,10 +438,11 @@ export async function handler(
.flatMap((provider) => Array(provider.weight ?? 1).fill(provider))
// Use the last 4 characters of session ID to select a provider
+ const identifier = sessionId.length ? sessionId : ip
let h = 0
- const l = sessionId.length
+ const l = identifier.length
for (let i = l - 4; i < l; i++) {
- h = (h * 31 + sessionId.charCodeAt(i)) | 0 // 32-bit int
+ h = (h * 31 + identifier.charCodeAt(i)) | 0 // 32-bit int
}
const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1
const provider = providers[index || 0]
@@ -443,12 +461,17 @@ export async function handler(
...modelProvider,
...zenData.providers[modelProvider.id],
...(() => {
- const format = zenData.providers[modelProvider.id].format
+ const providerProps = zenData.providers[modelProvider.id]
+ const format = providerProps.format
const providerModel = modelProvider.model
if (format === "anthropic") return anthropicHelper({ reqModel, providerModel })
if (format === "google") return googleHelper({ reqModel, providerModel })
if (format === "openai") return openaiHelper({ reqModel, providerModel })
- return oaCompatHelper({ reqModel, providerModel })
+ return oaCompatHelper({
+ reqModel,
+ providerModel,
+ adjustCacheUsage: providerProps.adjustCacheUsage,
+ })
})(),
}
}
@@ -806,7 +829,7 @@ export async function handler(
}
}
- function calculateOccuredCost(billingSource: BillingSource, costInfo: CostInfo) {
+ function calculateOccurredCost(billingSource: BillingSource, costInfo: CostInfo) {
return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0"
}
diff --git a/packages/console/app/src/routes/zen/util/provider/anthropic.ts b/packages/console/app/src/routes/zen/util/provider/anthropic.ts
index 95c50fbdbf67..b63be8688a58 100644
--- a/packages/console/app/src/routes/zen/util/provider/anthropic.ts
+++ b/packages/console/app/src/routes/zen/util/provider/anthropic.ts
@@ -20,6 +20,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
const isBedrockModelArn = providerModel.startsWith("arn:aws:bedrock:")
const isBedrockModelID = providerModel.startsWith("global.anthropic.")
const isBedrock = isBedrockModelArn || isBedrockModelID
+ const isDatabricks = providerModel.startsWith("databricks-claude-")
const supports1m = reqModel.includes("sonnet") || reqModel.includes("opus-4-6")
return {
format: "anthropic",
@@ -28,7 +29,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
? `${providerApi}/model/${isBedrockModelArn ? encodeURIComponent(providerModel) : providerModel}/${isStream ? "invoke-with-response-stream" : "invoke"}`
: providerApi + "/messages",
modifyHeaders: (headers: Headers, body: Record, apiKey: string) => {
- if (isBedrock) {
+ if (isBedrock || isDatabricks) {
headers.set("Authorization", `Bearer ${apiKey}`)
} else {
headers.set("x-api-key", apiKey)
@@ -47,9 +48,14 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
model: undefined,
stream: undefined,
}
- : {
- service_tier: "standard_only",
- }),
+ : isDatabricks
+ ? {
+ anthropic_version: "bedrock-2023-05-31",
+ anthropic_beta: supports1m ? ["context-1m-2025-08-07"] : undefined,
+ }
+ : {
+ service_tier: "standard_only",
+ }),
}),
createBinaryStreamDecoder: () => {
if (!isBedrock) return undefined
@@ -167,7 +173,6 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
}
},
retrieve: () => usage,
- buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
}
},
normalizeUsage: (usage: Usage) => ({
@@ -175,7 +180,8 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
outputTokens: usage.output_tokens ?? 0,
reasoningTokens: undefined,
cacheReadTokens: usage.cache_read_input_tokens ?? undefined,
- cacheWrite5mTokens: usage.cache_creation?.ephemeral_5m_input_tokens ?? undefined,
+ cacheWrite5mTokens:
+ usage.cache_creation?.ephemeral_5m_input_tokens ?? usage.cache_creation_input_tokens ?? undefined,
cacheWrite1hTokens: usage.cache_creation?.ephemeral_1h_input_tokens ?? undefined,
}),
}
diff --git a/packages/console/app/src/routes/zen/util/provider/google.ts b/packages/console/app/src/routes/zen/util/provider/google.ts
index ecf3b2d4d4d5..f6f7d6e19b29 100644
--- a/packages/console/app/src/routes/zen/util/provider/google.ts
+++ b/packages/console/app/src/routes/zen/util/provider/google.ts
@@ -56,7 +56,6 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
usage = json.usageMetadata
},
retrieve: () => usage,
- buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ type: "ping", cost })}\n\n`,
}
},
normalizeUsage: (usage: Usage) => {
diff --git a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts
index 046bf8f0c62d..6cb4b6a75363 100644
--- a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts
+++ b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts
@@ -21,7 +21,7 @@ type Usage = {
}
}
-export const oaCompatHelper: ProviderHelper = () => ({
+export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
format: "oa-compat",
modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
modifyHeaders: (headers: Headers, body: Record, apiKey: string) => {
@@ -54,14 +54,18 @@ export const oaCompatHelper: ProviderHelper = () => ({
usage = json.usage
},
retrieve: () => usage,
- buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ choices: [], cost })}\n\n`,
}
},
normalizeUsage: (usage: Usage) => {
- const inputTokens = usage.prompt_tokens ?? 0
+ let inputTokens = usage.prompt_tokens ?? 0
const outputTokens = usage.completion_tokens ?? 0
const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined
- const cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
+ let cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
+
+ if (adjustCacheUsage && !cacheReadTokens) {
+ cacheReadTokens = Math.floor(inputTokens * 0.9)
+ }
+
return {
inputTokens: inputTokens - (cacheReadTokens ?? 0),
outputTokens,
diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts
index 596b38cc5a4b..e5649239e7c4 100644
--- a/packages/console/app/src/routes/zen/util/provider/openai.ts
+++ b/packages/console/app/src/routes/zen/util/provider/openai.ts
@@ -44,7 +44,6 @@ export const openaiHelper: ProviderHelper = () => ({
usage = json.response.usage
},
retrieve: () => usage,
- buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
}
},
normalizeUsage: (usage: Usage) => {
diff --git a/packages/console/app/src/routes/zen/util/provider/provider.ts b/packages/console/app/src/routes/zen/util/provider/provider.ts
index 1f9492845f8a..64444ec9e9ab 100644
--- a/packages/console/app/src/routes/zen/util/provider/provider.ts
+++ b/packages/console/app/src/routes/zen/util/provider/provider.ts
@@ -33,7 +33,7 @@ export type UsageInfo = {
cacheWrite1hTokens?: number
}
-export type ProviderHelper = (input: { reqModel: string; providerModel: string }) => {
+export type ProviderHelper = (input: { reqModel: string; providerModel: string; adjustCacheUsage?: boolean }) => {
format: ZenData.Format
modifyUrl: (providerApi: string, isStream?: boolean) => string
modifyHeaders: (headers: Headers, body: Record, apiKey: string) => void
@@ -43,7 +43,6 @@ export type ProviderHelper = (input: { reqModel: string; providerModel: string }
createUsageParser: () => {
parse: (chunk: string) => void
retrieve: () => any
- buidlCostChunk: (cost: string) => string
}
normalizeUsage: (usage: any) => UsageInfo
}
@@ -162,6 +161,19 @@ export interface CommonChunk {
}
}
+export function buildCostChunk(format: ZenData.Format, cost: string): string {
+ switch (format) {
+ case "anthropic":
+ return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
+ case "openai":
+ return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
+ case "oa-compat":
+ return `data: ${JSON.stringify({ choices: [], cost })}\n\n`
+ default:
+ return `data: ${JSON.stringify({ type: "ping", cost })}\n\n`
+ }
+}
+
export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) {
return (body: any): any => {
if (from === to) return body
diff --git a/packages/console/app/src/routes/zen/util/trialLimiter.ts b/packages/console/app/src/routes/zen/util/trialLimiter.ts
index 1ae0ab32924e..319825dd79f0 100644
--- a/packages/console/app/src/routes/zen/util/trialLimiter.ts
+++ b/packages/console/app/src/routes/zen/util/trialLimiter.ts
@@ -3,8 +3,8 @@ import { IpTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { UsageInfo } from "./provider/provider"
import { Subscription } from "@opencode-ai/console-core/subscription.js"
-export function createTrialLimiter(trialProvider: string | undefined, ip: string) {
- if (!trialProvider) return
+export function createTrialLimiter(trialProviders: string[] | undefined, ip: string) {
+ if (!trialProviders) return
if (!ip) return
const limit = Subscription.getFreeLimits().promoTokens
@@ -24,7 +24,7 @@ export function createTrialLimiter(trialProvider: string | undefined, ip: string
)
_isTrial = (data?.usage ?? 0) < limit
- return _isTrial ? trialProvider : undefined
+ return _isTrial ? trialProviders : undefined
},
track: async (usageInfo: UsageInfo) => {
if (!_isTrial) return
diff --git a/packages/console/core/package.json b/packages/console/core/package.json
index 209a0e2df36d..f2bb6ac745e0 100644
--- a/packages/console/core/package.json
+++ b/packages/console/core/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
- "version": "1.2.24",
+ "version": "1.3.0",
"private": true,
"type": "module",
"license": "MIT",
@@ -42,7 +42,7 @@
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2",
- "@types/bun": "1.3.0",
+ "@types/bun": "catalog:",
"@types/node": "catalog:",
"drizzle-kit": "catalog:",
"mysql2": "3.14.4",
diff --git a/packages/console/core/script/lookup-user.ts b/packages/console/core/script/lookup-user.ts
index 0dfda24116d0..360fc6272241 100644
--- a/packages/console/core/script/lookup-user.ts
+++ b/packages/console/core/script/lookup-user.ts
@@ -3,6 +3,7 @@ import { AuthTable } from "../src/schema/auth.sql.js"
import { UserTable } from "../src/schema/user.sql.js"
import { BillingTable, PaymentTable, SubscriptionTable, BlackPlans, UsageTable } from "../src/schema/billing.sql.js"
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
+import { KeyTable } from "../src/schema/key.sql.js"
import { BlackData } from "../src/black.js"
import { centsToMicroCents } from "../src/util/price.js"
import { getWeekBounds } from "../src/util/date.js"
@@ -10,13 +11,46 @@ import { getWeekBounds } from "../src/util/date.js"
// get input from command line
const identifier = process.argv[2]
if (!identifier) {
- console.error("Usage: bun lookup-user.ts ")
+ console.error("Usage: bun lookup-user.ts ")
process.exit(1)
}
+// loop up by workspace ID
if (identifier.startsWith("wrk_")) {
await printWorkspace(identifier)
-} else {
+}
+// lookup by API key ID
+else if (identifier.startsWith("key_")) {
+ const key = await Database.use((tx) =>
+ tx
+ .select()
+ .from(KeyTable)
+ .where(eq(KeyTable.id, identifier))
+ .then((rows) => rows[0]),
+ )
+ if (!key) {
+ console.error("API key not found")
+ process.exit(1)
+ }
+ await printWorkspace(key.workspaceID)
+}
+// lookup by API key value
+else if (identifier.startsWith("sk-")) {
+ const key = await Database.use((tx) =>
+ tx
+ .select()
+ .from(KeyTable)
+ .where(eq(KeyTable.key, identifier))
+ .then((rows) => rows[0]),
+ )
+ if (!key) {
+ console.error("API key not found")
+ process.exit(1)
+ }
+ await printWorkspace(key.workspaceID)
+}
+// lookup by email
+else {
const authData = await Database.use(async (tx) =>
tx.select().from(AuthTable).where(eq(AuthTable.subject, identifier)),
)
diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts
index fcf238a35385..66b98069851f 100644
--- a/packages/console/core/src/billing.ts
+++ b/packages/console/core/src/billing.ts
@@ -212,13 +212,14 @@ export namespace Billing {
invoice_creation: {
enabled: true,
},
- payment_intent_data: {
- setup_future_usage: "on_session",
- },
- payment_method_types: ["card"],
- payment_method_data: {
- allow_redisplay: "always",
+ payment_method_options: {
+ card: {
+ setup_future_usage: "on_session",
+ },
},
+ //payment_method_data: {
+ // allow_redisplay: "always",
+ //},
tax_id_collection: {
enabled: true,
},
@@ -238,10 +239,11 @@ export namespace Billing {
z.object({
successUrl: z.string(),
cancelUrl: z.string(),
+ method: z.enum(["alipay", "upi"]).optional(),
}),
async (input) => {
const user = Actor.assert("user")
- const { successUrl, cancelUrl } = input
+ const { successUrl, cancelUrl, method } = input
const email = await User.getAuthEmail(user.properties.userID)
const billing = await Billing.get()
@@ -249,38 +251,102 @@ export namespace Billing {
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
- const session = await Billing.stripe().checkout.sessions.create({
- mode: "subscription",
- billing_address_collection: "required",
- line_items: [{ price: LiteData.priceID(), quantity: 1 }],
- ...(billing.customerID
- ? {
- customer: billing.customerID,
- customer_update: {
- name: "auto",
- address: "auto",
- },
+ const createSession = () =>
+ Billing.stripe().checkout.sessions.create({
+ mode: "subscription",
+ discounts: [{ coupon: LiteData.firstMonth50Coupon() }],
+ ...(billing.customerID
+ ? {
+ customer: billing.customerID,
+ customer_update: {
+ name: "auto",
+ address: "auto",
+ },
+ }
+ : {
+ customer_email: email!,
+ }),
+ ...(() => {
+ if (method === "alipay") {
+ return {
+ line_items: [{ price: LiteData.priceID(), quantity: 1 }],
+ payment_method_types: ["alipay"],
+ adaptive_pricing: {
+ enabled: false,
+ },
+ }
}
- : {
- customer_email: email!,
- }),
- currency: "usd",
- payment_method_types: ["card"],
- tax_id_collection: {
- enabled: true,
- },
- success_url: successUrl,
- cancel_url: cancelUrl,
- subscription_data: {
- metadata: {
- workspaceID: Actor.workspace(),
- userID: user.properties.userID,
- type: "lite",
+ if (method === "upi") {
+ return {
+ line_items: [
+ {
+ price_data: {
+ currency: "inr",
+ product: LiteData.productID(),
+ recurring: {
+ interval: "month",
+ interval_count: 1,
+ },
+ unit_amount: LiteData.priceInr(),
+ },
+ quantity: 1,
+ },
+ ],
+ payment_method_types: ["upi"] as any,
+ adaptive_pricing: {
+ enabled: false,
+ },
+ }
+ }
+ return {
+ line_items: [{ price: LiteData.priceID(), quantity: 1 }],
+ billing_address_collection: "required",
+ }
+ })(),
+ tax_id_collection: {
+ enabled: true,
},
- },
- })
+ success_url: successUrl,
+ cancel_url: cancelUrl,
+ subscription_data: {
+ metadata: {
+ workspaceID: Actor.workspace(),
+ userID: user.properties.userID,
+ type: "lite",
+ },
+ },
+ })
- return session.url
+ try {
+ const session = await createSession()
+ return session.url
+ } catch (e: any) {
+ if (
+ e.type !== "StripeInvalidRequestError" ||
+ !e.message.includes("You cannot combine currencies on a single customer")
+ )
+ throw e
+
+ // get pending payment intent
+ const intents = await Billing.stripe().paymentIntents.search({
+ query: `-status:'canceled' AND -status:'processing' AND -status:'succeeded' AND customer:'${billing.customerID}'`,
+ })
+ if (intents.data.length === 0) throw e
+
+ for (const intent of intents.data) {
+ // get checkout session
+ const sessions = await Billing.stripe().checkout.sessions.list({
+ customer: billing.customerID!,
+ payment_intent: intent.id,
+ })
+
+ // delete pending payment intent
+ await Billing.stripe().checkout.sessions.expire(sessions.data[0].id)
+ }
+
+ const session = await createSession()
+ return session.url
+ }
},
)
diff --git a/packages/console/core/src/lite.ts b/packages/console/core/src/lite.ts
index c6f7d5a3e411..2c4a09f71186 100644
--- a/packages/console/core/src/lite.ts
+++ b/packages/console/core/src/lite.ts
@@ -10,5 +10,7 @@ export namespace LiteData {
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
+ export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr)
+ export const firstMonth50Coupon = fn(z.void(), () => Resource.ZEN_LITE_PRICE.firstMonth50Coupon)
export const planName = fn(z.void(), () => "lite")
}
diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts
index 223839bf13df..6f28dd798e7d 100644
--- a/packages/console/core/src/model.ts
+++ b/packages/console/core/src/model.ts
@@ -26,7 +26,7 @@ export namespace ZenData {
allowAnonymous: z.boolean().optional(),
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
stickyProvider: z.enum(["strict", "prefer"]).optional(),
- trialProvider: z.string().optional(),
+ trialProviders: z.array(z.string()).optional(),
fallbackProvider: z.string().optional(),
rateLimit: z.number().optional(),
providers: z.array(
@@ -36,6 +36,7 @@ export namespace ZenData {
weight: z.number().optional(),
disabled: z.boolean().optional(),
storeModel: z.string().optional(),
+ payloadModifier: z.record(z.string(), z.any()).optional(),
}),
),
})
@@ -46,6 +47,8 @@ export namespace ZenData {
format: FormatSchema.optional(),
headerMappings: z.record(z.string(), z.string()).optional(),
payloadModifier: z.record(z.string(), z.any()).optional(),
+ payloadMappings: z.record(z.string(), z.string()).optional(),
+ adjustCacheUsage: z.boolean().optional(),
})
const ModelsSchema = z.object({
diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts
index a5c70c211544..b06ca8966d0c 100644
--- a/packages/console/core/src/schema/billing.sql.ts
+++ b/packages/console/core/src/schema/billing.sql.ts
@@ -88,6 +88,7 @@ export const PaymentTable = mysqlTable(
enrichment: json("enrichment").$type<
| {
type: "subscription" | "lite"
+ currency?: "inr"
couponID?: string
}
| {
diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts
index 23ae6e44bfb1..6b842639add6 100644
--- a/packages/console/core/sst-env.d.ts
+++ b/packages/console/core/sst-env.d.ts
@@ -95,6 +95,18 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
+ "SALESFORCE_CLIENT_ID": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
+ "SALESFORCE_CLIENT_SECRET": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
+ "SALESFORCE_INSTANCE_URL": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string
@@ -131,7 +143,9 @@ declare module "sst" {
"value": string
}
"ZEN_LITE_PRICE": {
+ "firstMonth50Coupon": string
"price": string
+ "priceInr": number
"product": string
"type": "sst.sst.Linkable"
}
diff --git a/packages/console/function/package.json b/packages/console/function/package.json
index 377ab97cf61a..93e0ba71cb0f 100644
--- a/packages/console/function/package.json
+++ b/packages/console/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
- "version": "1.2.24",
+ "version": "1.3.0",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts
index 23ae6e44bfb1..6b842639add6 100644
--- a/packages/console/function/sst-env.d.ts
+++ b/packages/console/function/sst-env.d.ts
@@ -95,6 +95,18 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
+ "SALESFORCE_CLIENT_ID": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
+ "SALESFORCE_CLIENT_SECRET": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
+ "SALESFORCE_INSTANCE_URL": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string
@@ -131,7 +143,9 @@ declare module "sst" {
"value": string
}
"ZEN_LITE_PRICE": {
+ "firstMonth50Coupon": string
"price": string
+ "priceInr": number
"product": string
"type": "sst.sst.Linkable"
}
diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json
index 9a383f19fbea..e0c677446c49 100644
--- a/packages/console/mail/package.json
+++ b/packages/console/mail/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
- "version": "1.2.24",
+ "version": "1.3.0",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts
index 23ae6e44bfb1..6b842639add6 100644
--- a/packages/console/resource/sst-env.d.ts
+++ b/packages/console/resource/sst-env.d.ts
@@ -95,6 +95,18 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
+ "SALESFORCE_CLIENT_ID": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
+ "SALESFORCE_CLIENT_SECRET": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
+ "SALESFORCE_INSTANCE_URL": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string
@@ -131,7 +143,9 @@ declare module "sst" {
"value": string
}
"ZEN_LITE_PRICE": {
+ "firstMonth50Coupon": string
"price": string
+ "priceInr": number
"product": string
"type": "sst.sst.Linkable"
}
diff --git a/packages/containers/bun-node/Dockerfile b/packages/containers/bun-node/Dockerfile
index e6cad9c27252..485375dd9f61 100644
--- a/packages/containers/bun-node/Dockerfile
+++ b/packages/containers/bun-node/Dockerfile
@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
SHELL ["/bin/bash", "-lc"]
ARG NODE_VERSION=24.4.0
-ARG BUN_VERSION=1.3.5
+ARG BUN_VERSION=1.3.11
ENV BUN_INSTALL=/opt/bun
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts
index 80c1d6b704c5..6903d5ed20cc 100644
--- a/packages/desktop-electron/electron.vite.config.ts
+++ b/packages/desktop-electron/electron.vite.config.ts
@@ -27,7 +27,7 @@ export default defineConfig({
},
renderer: {
plugins: [appPlugin],
- publicDir: "../app/public",
+ publicDir: "../../../app/public",
root: "src/renderer",
build: {
rollupOptions: {
diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json
index 45fa7355f8a2..b7872acc9866 100644
--- a/packages/desktop-electron/package.json
+++ b/packages/desktop-electron/package.json
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
- "version": "1.2.24",
+ "version": "1.3.0",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
@@ -30,6 +30,7 @@
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
+ "effect": "catalog:",
"electron-log": "^5",
"electron-store": "^10",
"electron-updater": "^6",
diff --git a/packages/desktop-electron/scripts/finalize-latest-yml.ts b/packages/desktop-electron/scripts/finalize-latest-yml.ts
index 42ec23b642c9..aa2ae5c96e65 100644
--- a/packages/desktop-electron/scripts/finalize-latest-yml.ts
+++ b/packages/desktop-electron/scripts/finalize-latest-yml.ts
@@ -78,9 +78,17 @@ async function read(subdir: string, filename: string): Promise = {}
-// Windows: single arch, pass through
-const win = await read("latest-yml-x86_64-pc-windows-msvc", "latest.yml")
-if (win) output["latest.yml"] = serialize(win)
+// Windows: merge arm64 + x64 into single file
+const winX64 = await read("latest-yml-x86_64-pc-windows-msvc", "latest.yml")
+const winArm64 = await read("latest-yml-aarch64-pc-windows-msvc", "latest.yml")
+if (winX64 || winArm64) {
+ const base = winArm64 ?? winX64!
+ output["latest.yml"] = serialize({
+ version: base.version,
+ files: [...(winArm64?.files ?? []), ...(winX64?.files ?? [])],
+ releaseDate: base.releaseDate,
+ })
+}
// Linux x64: pass through
const linuxX64 = await read("latest-yml-x86_64-unknown-linux-gnu", "latest-linux.yml")
diff --git a/packages/desktop-electron/scripts/utils.ts b/packages/desktop-electron/scripts/utils.ts
index 4c9af1fc7ed5..1c0add87d306 100644
--- a/packages/desktop-electron/scripts/utils.ts
+++ b/packages/desktop-electron/scripts/utils.ts
@@ -19,6 +19,11 @@ export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; ass
ocBinary: "opencode-darwin-x64-baseline",
assetExt: "zip",
},
+ {
+ rustTarget: "aarch64-pc-windows-msvc",
+ ocBinary: "opencode-windows-arm64",
+ assetExt: "zip",
+ },
{
rustTarget: "x86_64-pc-windows-msvc",
ocBinary: "opencode-windows-x64-baseline",
@@ -41,7 +46,7 @@ export const RUST_TARGET = Bun.env.RUST_TARGET
function nativeTarget() {
const { platform, arch } = process
if (platform === "darwin") return arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
- if (platform === "win32") return "x86_64-pc-windows-msvc"
+ if (platform === "win32") return arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc"
if (platform === "linux") return arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"
throw new Error(`Unsupported platform: ${platform}/${arch}`)
}
diff --git a/packages/desktop-electron/src/main/cli.ts b/packages/desktop-electron/src/main/cli.ts
index fba301f36c22..f2d918bd2138 100644
--- a/packages/desktop-electron/src/main/cli.ts
+++ b/packages/desktop-electron/src/main/cli.ts
@@ -35,6 +35,7 @@ export type CommandEvent =
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
export type CommandChild = {
+ pid: number | undefined
kill: () => void
}
@@ -191,7 +192,7 @@ export function spawnCommand(args: string, extraEnv: Record) {
treeKill(child.pid)
}
- return { events, child: { kill }, exit }
+ return { events, child: { pid: child.pid, kill }, exit }
}
function handleSqliteProgress(events: EventEmitter, line: string) {
diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts
index 7b6acd14774e..032343204cc5 100644
--- a/packages/desktop-electron/src/main/index.ts
+++ b/packages/desktop-electron/src/main/index.ts
@@ -5,7 +5,7 @@ import { createServer } from "node:net"
import { homedir } from "node:os"
import { join } from "node:path"
import type { Event } from "electron"
-import { app, type BrowserWindow, dialog } from "electron"
+import { app, BrowserWindow, dialog } from "electron"
import pkg from "electron-updater"
const APP_NAMES: Record = {
@@ -31,35 +31,13 @@ import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigratio
import { initLogging } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
-import {
- checkHealth,
- checkHealthOrAskRetry,
- getDefaultServerUrl,
- getSavedServerUrl,
- getWslConfig,
- setDefaultServerUrl,
- setWslConfig,
- spawnLocalServer,
-} from "./server"
-import { createLoadingWindow, createMainWindow, setDockIcon } from "./windows"
-
-type ServerConnection =
- | { variant: "existing"; url: string }
- | {
- variant: "cli"
- url: string
- password: null | string
- health: {
- wait: Promise
- }
- events: any
- }
+import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
+import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" }
let mainWindow: BrowserWindow | null = null
-const loadingWindow: BrowserWindow | null = null
let sidecar: CommandChild | null = null
const loadingComplete = defer()
@@ -103,6 +81,17 @@ function setupApp() {
killSidecar()
})
+ app.on("will-quit", () => {
+ killSidecar()
+ })
+
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
+ process.on(signal, () => {
+ killSidecar()
+ app.exit(0)
+ })
+ }
+
void app.whenReady().then(async () => {
// migrate()
app.setAsDefaultProtocolClient("opencode")
@@ -131,112 +120,75 @@ function setInitStep(step: InitStep) {
initEmitter.emit("step", step)
}
-async function setupServerConnection(): Promise {
- const customUrl = await getSavedServerUrl()
-
- if (customUrl && (await checkHealthOrAskRetry(customUrl))) {
- serverReady.resolve({ url: customUrl, password: null })
- return { variant: "existing", url: customUrl }
- }
+async function initialize() {
+ const needsMigration = !sqliteFileExists()
+ const sqliteDone = needsMigration ? defer() : undefined
+ let overlay: BrowserWindow | null = null
const port = await getSidecarPort()
const hostname = "127.0.0.1"
- const localUrl = `http://${hostname}:${port}`
-
- if (await checkHealth(localUrl)) {
- serverReady.resolve({ url: localUrl, password: null })
- return { variant: "existing", url: localUrl }
- }
-
+ const url = `http://${hostname}:${port}`
const password = randomUUID()
+
+ logger.log("spawning sidecar", { url })
const { child, health, events } = spawnLocalServer(hostname, port, password)
sidecar = child
-
- return {
- variant: "cli",
- url: localUrl,
+ serverReady.resolve({
+ url,
+ username: "opencode",
password,
- health,
- events,
- }
-}
-
-async function initialize() {
- const needsMigration = !sqliteFileExists()
- const sqliteDone = needsMigration ? defer() : undefined
+ })
const loadingTask = (async () => {
- logger.log("setting up server connection")
- const serverConnection = await setupServerConnection()
- logger.log("server connection ready", {
- variant: serverConnection.variant,
- url: serverConnection.url,
- })
+ logger.log("sidecar connection started", { url })
- const cliHealthCheck = (() => {
- if (serverConnection.variant == "cli") {
- return async () => {
- const { events, health } = serverConnection
- events.on("sqlite", (progress: SqliteMigrationProgress) => {
- setInitStep({ phase: "sqlite_waiting" })
- if (loadingWindow) sendSqliteMigrationProgress(loadingWindow, progress)
- if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
- if (progress.type === "Done") sqliteDone?.resolve()
- })
- await health.wait
- serverReady.resolve({
- url: serverConnection.url,
- password: serverConnection.password,
- })
- }
- } else {
- serverReady.resolve({ url: serverConnection.url, password: null })
- return null
- }
- })()
-
- logger.log("server connection started")
+ events.on("sqlite", (progress: SqliteMigrationProgress) => {
+ setInitStep({ phase: "sqlite_waiting" })
+ if (overlay) sendSqliteMigrationProgress(overlay, progress)
+ if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
+ if (progress.type === "Done") sqliteDone?.resolve()
+ })
- if (cliHealthCheck) {
- if (needsMigration) await sqliteDone?.promise
- cliHealthCheck?.()
+ if (needsMigration) {
+ await sqliteDone?.promise
}
+ await Promise.race([
+ health.wait,
+ delay(30_000).then(() => {
+ throw new Error("Sidecar health check timed out")
+ }),
+ ]).catch((error) => {
+ logger.error("sidecar health check failed", error)
+ })
+
logger.log("loading task finished")
})()
const globals = {
updaterEnabled: UPDATER_ENABLED,
- wsl: getWslConfig().enabled,
deepLinks: pendingDeepLinks,
}
- const loadingWindow = await (async () => {
- if (needsMigration /** TOOD: 1 second timeout */) {
- // showLoading = await Promise.race([init.then(() => false).catch(() => false), delay(1000).then(() => true)])
- const loadingWindow = createLoadingWindow(globals)
- await delay(1000)
- return loadingWindow
- } else {
- logger.log("showing main window without loading window")
- mainWindow = createMainWindow(globals)
- wireMenu()
+ if (needsMigration) {
+ const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
+ if (show) {
+ overlay = createLoadingWindow(globals)
+ await delay(1_000)
}
- })()
+ }
await loadingTask
setInitStep({ phase: "done" })
- if (loadingWindow) {
+ if (overlay) {
await loadingComplete.promise
}
- if (!mainWindow) {
- mainWindow = createMainWindow(globals)
- wireMenu()
- }
+ mainWindow = createMainWindow(globals)
+ wireMenu()
- loadingWindow?.close()
+ overlay?.close()
}
function wireMenu() {
@@ -288,12 +240,20 @@ registerIpcHandlers({
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail),
checkUpdate: async () => checkUpdate(),
installUpdate: async () => installUpdate(),
+ setBackgroundColor: (color) => setBackgroundColor(color),
})
function killSidecar() {
if (!sidecar) return
+ const pid = sidecar.pid
sidecar.kill()
sidecar = null
+ // tree-kill is async; also send process group signal as immediate fallback
+ if (pid && process.platform !== "win32") {
+ try {
+ process.kill(-pid, "SIGTERM")
+ } catch {}
+ }
}
function ensureLoopbackNoProxy() {
diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts
index bbb5379bb7a9..543f857a5e81 100644
--- a/packages/desktop-electron/src/main/ipc.ts
+++ b/packages/desktop-electron/src/main/ipc.ts
@@ -2,8 +2,14 @@ import { execFile } from "node:child_process"
import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
-import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
+import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, WslConfig } from "../preload/types"
import { getStore } from "./store"
+import { setTitlebar } from "./windows"
+
+const pickerFilters = (ext?: string[]) => {
+ if (!ext || ext.length === 0) return undefined
+ return [{ name: "Files", extensions: ext }]
+}
type Deps = {
killSidecar: () => void
@@ -23,6 +29,7 @@ type Deps = {
runUpdater: (alertOnFail: boolean) => Promise | void
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise | void
+ setBackgroundColor: (color: string) => void
}
export function registerIpcHandlers(deps: Deps) {
@@ -52,6 +59,7 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
ipcMain.handle("check-update", () => deps.checkUpdate())
ipcMain.handle("install-update", () => deps.installUpdate())
+ ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
const store = getStore(name)
const value = store.get(key)
@@ -91,11 +99,15 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle(
"open-file-picker",
- async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
+ async (
+ _event: IpcMainInvokeEvent,
+ opts?: { multiple?: boolean; title?: string; defaultPath?: string; accept?: string[]; extensions?: string[] },
+ ) => {
const result = await dialog.showOpenDialog({
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
title: opts?.title ?? "Choose a file",
defaultPath: opts?.defaultPath,
+ filters: pickerFilters(opts?.extensions),
})
if (result.canceled) return null
return opts?.multiple ? result.filePaths : result.filePaths[0]
@@ -139,6 +151,8 @@ export function registerIpcHandlers(deps: Deps) {
new Notification({ title, body }).show()
})
+ ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length)
+
ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win?.isFocused() ?? false
@@ -161,6 +175,11 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => event.sender.setZoomFactor(factor))
+ ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => {
+ const win = BrowserWindow.fromWebContents(event.sender)
+ if (!win) return
+ setTitlebar(win, theme)
+ })
}
export function sendSqliteMigrationProgress(win: BrowserWindow, progress: SqliteMigrationProgress) {
diff --git a/packages/desktop-electron/src/main/menu.ts b/packages/desktop-electron/src/main/menu.ts
index 53707ba7f255..d8997be310fe 100644
--- a/packages/desktop-electron/src/main/menu.ts
+++ b/packages/desktop-electron/src/main/menu.ts
@@ -1,6 +1,7 @@
import { BrowserWindow, Menu, shell } from "electron"
import { UPDATER_ENABLED } from "./constants"
+import { createMainWindow } from "./windows"
type Deps = {
trigger: (id: string) => void
@@ -48,6 +49,11 @@ export function createMenu(deps: Deps) {
submenu: [
{ label: "New Session", accelerator: "Shift+Cmd+S", click: () => deps.trigger("session.new") },
{ label: "Open Project...", accelerator: "Cmd+O", click: () => deps.trigger("project.open") },
+ {
+ label: "New Window",
+ accelerator: "Cmd+Shift+N",
+ click: () => createMainWindow({ updaterEnabled: UPDATER_ENABLED }),
+ },
{ type: "separator" },
{ role: "close" },
],
diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts
index 92018e72e755..2d09d119f584 100644
--- a/packages/desktop-electron/src/main/server.ts
+++ b/packages/desktop-electron/src/main/server.ts
@@ -1,6 +1,4 @@
-import { dialog } from "electron"
-
-import { getConfig, serve, type CommandChild, type Config } from "./cli"
+import { serve, type CommandChild } from "./cli"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import { store } from "./store"
@@ -31,15 +29,6 @@ export function setWslConfig(config: WslConfig) {
store.set(WSL_ENABLED_KEY, config.enabled)
}
-export async function getSavedServerUrl(): Promise {
- const direct = getDefaultServerUrl()
- if (direct) return direct
-
- const config = await getConfig().catch(() => null)
- if (!config) return null
- return getServerUrlFromConfig(config)
-}
-
export function spawnLocalServer(hostname: string, port: number, password: string) {
const { child, exit, events } = serve(hostname, port, password)
@@ -94,36 +83,4 @@ export async function checkHealth(url: string, password?: string | null): Promis
}
}
-export async function checkHealthOrAskRetry(url: string): Promise {
- while (true) {
- if (await checkHealth(url)) return true
-
- const result = await dialog.showMessageBox({
- type: "warning",
- message: `Could not connect to configured server:\n${url}\n\nWould you like to retry or start a local server instead?`,
- title: "Connection Failed",
- buttons: ["Retry", "Start Local"],
- defaultId: 0,
- cancelId: 1,
- })
-
- if (result.response === 0) continue
- return false
- }
-}
-
-export function normalizeHostnameForUrl(hostname: string) {
- if (hostname === "0.0.0.0") return "127.0.0.1"
- if (hostname === "::") return "[::1]"
- if (hostname.includes(":") && !hostname.startsWith("[")) return `[${hostname}]`
- return hostname
-}
-
-export function getServerUrlFromConfig(config: Config) {
- const server = config.server
- if (!server?.port) return null
- const host = server.hostname ? normalizeHostnameForUrl(server.hostname) : "127.0.0.1"
- return `http://${host}:${server.port}`
-}
-
export type { CommandChild }
diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts
index 9178457f8dc1..0b7783f289ec 100644
--- a/packages/desktop-electron/src/main/windows.ts
+++ b/packages/desktop-electron/src/main/windows.ts
@@ -1,16 +1,26 @@
import windowState from "electron-window-state"
-import { app, BrowserWindow, nativeImage } from "electron"
+import { app, BrowserWindow, nativeImage, nativeTheme } from "electron"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
+import type { TitlebarTheme } from "../preload/types"
type Globals = {
updaterEnabled: boolean
- wsl: boolean
deepLinks?: string[]
}
const root = dirname(fileURLToPath(import.meta.url))
+let backgroundColor: string | undefined
+
+export function setBackgroundColor(color: string) {
+ backgroundColor = color
+}
+
+export function getBackgroundColor(): string | undefined {
+ return backgroundColor
+}
+
function iconsDir() {
return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
}
@@ -20,6 +30,24 @@ function iconPath() {
return join(iconsDir(), `icon.${ext}`)
}
+function tone() {
+ return nativeTheme.shouldUseDarkColors ? "dark" : "light"
+}
+
+function overlay(theme: Partial = {}) {
+ const mode = theme.mode ?? tone()
+ return {
+ color: "#00000000",
+ symbolColor: mode === "dark" ? "white" : "black",
+ height: 40,
+ }
+}
+
+export function setTitlebar(win: BrowserWindow, theme: Partial = {}) {
+ if (process.platform !== "win32") return
+ win.setTitleBarOverlay(overlay(theme))
+}
+
export function setDockIcon() {
if (process.platform !== "darwin") return
app.dock?.setIcon(nativeImage.createFromPath(join(iconsDir(), "128x128@2x.png")))
@@ -31,6 +59,7 @@ export function createMainWindow(globals: Globals) {
defaultHeight: 800,
})
+ const mode = tone()
const win = new BrowserWindow({
x: state.x,
y: state.y,
@@ -39,6 +68,7 @@ export function createMainWindow(globals: Globals) {
show: true,
title: "OpenCode",
icon: iconPath(),
+ backgroundColor,
...(process.platform === "darwin"
? {
titleBarStyle: "hidden" as const,
@@ -49,11 +79,7 @@ export function createMainWindow(globals: Globals) {
? {
frame: false,
titleBarStyle: "hidden" as const,
- titleBarOverlay: {
- color: "transparent",
- symbolColor: "#999",
- height: 40,
- },
+ titleBarOverlay: overlay({ mode }),
}
: {}),
webPreferences: {
@@ -71,6 +97,7 @@ export function createMainWindow(globals: Globals) {
}
export function createLoadingWindow(globals: Globals) {
+ const mode = tone()
const win = new BrowserWindow({
width: 640,
height: 480,
@@ -78,16 +105,13 @@ export function createLoadingWindow(globals: Globals) {
center: true,
show: true,
icon: iconPath(),
+ backgroundColor,
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
...(process.platform === "win32"
? {
frame: false,
titleBarStyle: "hidden" as const,
- titleBarOverlay: {
- color: "transparent",
- symbolColor: "#999",
- height: 40,
- },
+ titleBarOverlay: overlay({ mode }),
}
: {}),
webPreferences: {
@@ -118,7 +142,6 @@ function injectGlobals(win: BrowserWindow, globals: Globals) {
const deepLinks = globals.deepLinks ?? []
const data = {
updaterEnabled: globals.updaterEnabled,
- wsl: globals.wsl,
deepLinks: Array.isArray(deepLinks) ? deepLinks.splice(0) : deepLinks,
}
void win.webContents.executeJavaScript(
diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts
index a6520ab42429..296fcb2f1cc1 100644
--- a/packages/desktop-electron/src/preload/index.ts
+++ b/packages/desktop-electron/src/preload/index.ts
@@ -28,6 +28,7 @@ const api: ElectronAPI = {
storeKeys: (name) => ipcRenderer.invoke("store-keys", name),
storeLength: (name) => ipcRenderer.invoke("store-length", name),
+ getWindowCount: () => ipcRenderer.invoke("get-window-count"),
onSqliteMigrationProgress: (cb) => {
const handler = (_: unknown, progress: SqliteMigrationProgress) => cb(progress)
ipcRenderer.on("sqlite-migration-progress", handler)
@@ -57,10 +58,12 @@ const api: ElectronAPI = {
relaunch: () => ipcRenderer.send("relaunch"),
getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"),
setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
+ setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
checkUpdate: () => ipcRenderer.invoke("check-update"),
installUpdate: () => ipcRenderer.invoke("install-update"),
+ setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
}
contextBridge.exposeInMainWorld("api", api)
diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts
index af5410f5f55e..f8e6d52c7db6 100644
--- a/packages/desktop-electron/src/preload/types.ts
+++ b/packages/desktop-electron/src/preload/types.ts
@@ -2,6 +2,7 @@ export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" }
export type ServerReadyData = {
url: string
+ username: string | null
password: string | null
}
@@ -10,6 +11,9 @@ export type SqliteMigrationProgress = { type: "InProgress"; value: number } | {
export type WslConfig = { enabled: boolean }
export type LinuxDisplayBackend = "wayland" | "auto"
+export type TitlebarTheme = {
+ mode: "light" | "dark"
+}
export type ElectronAPI = {
killSidecar: () => Promise
@@ -32,6 +36,7 @@ export type ElectronAPI = {
storeKeys: (name: string) => Promise
storeLength: (name: string) => Promise
+ getWindowCount: () => Promise
onSqliteMigrationProgress: (cb: (progress: SqliteMigrationProgress) => void) => () => void
onMenuCommand: (cb: (id: string) => void) => () => void
onDeepLink: (cb: (urls: string[]) => void) => () => void
@@ -45,6 +50,8 @@ export type ElectronAPI = {
multiple?: boolean
title?: string
defaultPath?: string
+ accept?: string[]
+ extensions?: string[]
}) => Promise
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise
openLink: (url: string) => void
@@ -57,8 +64,10 @@ export type ElectronAPI = {
relaunch: () => void
getZoomFactor: () => Promise
setZoomFactor: (factor: number) => Promise
+ setTitlebar: (theme: TitlebarTheme) => Promise
loadingWindowComplete: () => void
runUpdater: (alertOnFail: boolean) => Promise
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise
+ setBackgroundColor: (color: string) => Promise
}
diff --git a/packages/desktop-electron/src/renderer/html.test.ts b/packages/desktop-electron/src/renderer/html.test.ts
new file mode 100644
index 000000000000..bd8281c2fbe5
--- /dev/null
+++ b/packages/desktop-electron/src/renderer/html.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, test } from "bun:test"
+import { join, dirname, resolve } from "node:path"
+import { existsSync } from "node:fs"
+import { fileURLToPath } from "node:url"
+
+const dir = dirname(fileURLToPath(import.meta.url))
+const root = resolve(dir, "../..")
+
+const html = async (name: string) => Bun.file(join(dir, name)).text()
+
+/**
+ * Electron loads renderer HTML via `win.loadFile()` which uses the `file://`
+ * protocol. Absolute paths like `src="/foo.js"` resolve to the filesystem root
+ * (e.g. `file:///C:/foo.js` on Windows) instead of relative to the app bundle.
+ *
+ * All local resource references must use relative paths (`./`).
+ */
+describe("electron renderer html", () => {
+ for (const name of ["index.html", "loading.html"]) {
+ describe(name, () => {
+ test("script src attributes use relative paths", async () => {
+ const content = await html(name)
+ const srcs = [...content.matchAll(/\bsrc=["']([^"']+)["']/g)].map((m) => m[1])
+ for (const src of srcs) {
+ expect(src).not.toMatch(/^\/[^/]/)
+ }
+ })
+
+ test("link href attributes use relative paths", async () => {
+ const content = await html(name)
+ const hrefs = [...content.matchAll(/]+href=["']([^"']+)["']/g)].map((m) => m[1])
+ for (const href of hrefs) {
+ expect(href).not.toMatch(/^\/[^/]/)
+ }
+ })
+
+ test("no web manifest link (not applicable in Electron)", async () => {
+ const content = await html(name)
+ expect(content).not.toContain('rel="manifest"')
+ })
+ })
+ }
+})
+
+/**
+ * Vite resolves `publicDir` relative to `root`, not the config file.
+ * This test reads the actual values from electron.vite.config.ts to catch
+ * regressions where the publicDir path no longer resolves correctly
+ * after the renderer root is accounted for.
+ */
+describe("electron vite publicDir", () => {
+ test("configured publicDir resolves to a directory with oc-theme-preload.js", async () => {
+ const config = await Bun.file(join(root, "electron.vite.config.ts")).text()
+ const pub = config.match(/publicDir:\s*["']([^"']+)["']/)
+ const rendererRoot = config.match(/root:\s*["']([^"']+)["']/)
+ expect(pub).not.toBeNull()
+ expect(rendererRoot).not.toBeNull()
+ const resolved = resolve(root, rendererRoot![1], pub![1])
+ expect(existsSync(resolved)).toBe(true)
+ expect(existsSync(join(resolved, "oc-theme-preload.js"))).toBe(true)
+ })
+})
diff --git a/packages/desktop-electron/src/renderer/index.html b/packages/desktop-electron/src/renderer/index.html
index 175640819699..dd8675ee6b99 100644
--- a/packages/desktop-electron/src/renderer/index.html
+++ b/packages/desktop-electron/src/renderer/index.html
@@ -4,20 +4,19 @@
OpenCode
-
-
-
-
-
+
+
+
+
-
-
-
+
+
+
-
+
|