From 4a57013cf8cb163f58638273fd9da8538cd33cb7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:25:06 +0000 Subject: [PATCH 01/16] fix(app): show pending tool details (#40603) Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> --- packages/session-ui/src/components/basic-tool.tsx | 2 +- packages/session-ui/src/components/message-part.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/session-ui/src/components/basic-tool.tsx b/packages/session-ui/src/components/basic-tool.tsx index 2b73db24ed1..efc15c173f9 100644 --- a/packages/session-ui/src/components/basic-tool.tsx +++ b/packages/session-ui/src/components/basic-tool.tsx @@ -204,7 +204,7 @@ export function BasicTool(props: BasicToolProps) { > - + - + {trigger().subtitle} - + {(arg) => {arg}} From f929f8f100581a94f3484b6992d02d57d75fab7f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:05:19 -0500 Subject: [PATCH 02/16] refactor(opencode): simplify retry error matching (#40694) --- packages/opencode/src/session/retry.ts | 37 +++++++------------- packages/opencode/test/session/retry.test.ts | 9 +++-- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 4139665bd2b..d1864cb7a8a 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -122,32 +122,19 @@ export function retryable(error: Err, provider: string) { return { message: error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message } } - // Check for rate limit patterns in plain text error messages - const msg = isRecord(error.data) ? error.data.message : undefined - if (typeof msg === "string") { - const lower = msg.toLowerCase() - if ( - lower.includes("rate increased too quickly") || - lower.includes("rate limit") || - lower.includes("too many requests") - ) { - return { message: msg } - } - } - - const json = parseJSON(msg) - if (!json || typeof json !== "object") return undefined - const code = typeof json.code === "string" ? json.code : "" - - if (json.type === "error" && json.error?.type === "too_many_requests") { - return { message: "Too Many Requests" } - } - if (code.includes("exhausted") || code.includes("unavailable")) { - return { message: "Provider is overloaded" } - } - if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) { - return { message: "Rate Limited" } + const message = isRecord(error.data) ? error.data.message : undefined + if (typeof message !== "string") return undefined + const lower = message.toLowerCase() + if ( + lower.includes("rate increased too quickly") || + lower.includes("rate limit") || + lower.includes("rate_limit") || + lower.includes("too many requests") + ) { + return { message } } + if (lower.includes("too_many_requests")) return { message: "Too Many Requests" } + if (lower.includes("exhausted") || lower.includes("unavailable")) return { message: "Provider is overloaded" } return undefined } diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 30ac879a6a9..0e30a5473a2 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -118,16 +118,21 @@ describe("session.retry.delay", () => { }) describe("session.retry.retryable", () => { - test("maps too_many_requests json messages", () => { + test("retries serialized too_many_requests messages", () => { const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } })) expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Too Many Requests" }) }) - test("maps overloaded provider codes", () => { + test("retries serialized overloaded provider codes", () => { const error = wrap(JSON.stringify({ code: "resource_exhausted" })) expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" }) }) + test("retries serialized rate_limit messages", () => { + const message = JSON.stringify({ type: "error", error: { code: "rate_limit_exceeded" } }) + expect(SessionRetry.retryable(wrap(message), retryProvider)).toEqual({ message }) + }) + test("does not retry unknown json messages", () => { const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } })) expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() From 61aefc07593043a2cef6cc870f7267b09483f5c0 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:39:49 -0500 Subject: [PATCH 03/16] fix(opencode): expand retryable error patterns (#40707) --- packages/opencode/src/session/retry.ts | 29 ++++++++++----- packages/opencode/test/session/retry.test.ts | 39 ++++++++++++++++++++ 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index d1864cb7a8a..22399e8703a 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -28,6 +28,15 @@ export const RETRY_BACKOFF_FACTOR = 2 export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout +const RETRYABLE_MESSAGE_PATTERNS = [ + /429|500|502|503|504|524/i, + /rate increased too quickly|rate limit|rate-limit|rate_limit|too many requests/i, + /overloaded|service unavailable|service_unavailable|service-unavailable|internal error|internal_error|internal server error|server error|server_error|server-error|provider returned error|provider_returned_error|provider-returned-error/i, + /terminated|fetch failed|failed to fetch|network error|upstream connect|connection error|connection refused|connection lost|socket connection was closed|socket hang up|reset before headers|getaddrinfo|enotfound|eai_again|econnrefused|econnreset|etimedout/i, + /^timeout$|\b(?:request|response|connection|network|stream|read) (?:timeout|timed out|time out)\b/i, + /try your request again|retry your request|resource exhausted|resource_exhausted/i, +] + function cap(ms: number) { return Math.min(ms, RETRY_MAX_DELAY) } @@ -72,7 +81,12 @@ export function retryable(error: Err, provider: string) { const status = error.data.statusCode // 5xx errors are transient server failures and should always be retried, // even when the provider SDK doesn't explicitly mark them as retryable. - if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined + if ( + !error.data.isRetryable && + !(status !== undefined && status >= 500) && + !matchesRetryableMessage(error.data.message) && + !matchesRetryableMessage(error.data.responseBody) + ) return undefined if (error.data.responseBody?.includes("FreeUsageLimitError")) { return { message: GO_UPSELL_MESSAGE, @@ -125,19 +139,16 @@ export function retryable(error: Err, provider: string) { const message = isRecord(error.data) ? error.data.message : undefined if (typeof message !== "string") return undefined const lower = message.toLowerCase() - if ( - lower.includes("rate increased too quickly") || - lower.includes("rate limit") || - lower.includes("rate_limit") || - lower.includes("too many requests") - ) { - return { message } - } if (lower.includes("too_many_requests")) return { message: "Too Many Requests" } if (lower.includes("exhausted") || lower.includes("unavailable")) return { message: "Provider is overloaded" } + if (matchesRetryableMessage(message)) return { message } return undefined } +function matchesRetryableMessage(value: unknown) { + return typeof value === "string" && RETRYABLE_MESSAGE_PATTERNS.some((pattern) => pattern.test(value)) +} + function str(value: unknown) { if (value === undefined || value === null) return "" return String(value) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 0e30a5473a2..018f76fc3ea 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -168,6 +168,45 @@ describe("session.retry.retryable", () => { expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg }) }) + test.each([ + "Internal server error", + "internal error", + "server-error", + "Provider returned error", + "provider-returned-error", + "terminated", + "fetch failed", + "connection refused", + "connect ECONNREFUSED", + "request ETIMEDOUT", + "failed to fetch", + "EAI_AGAIN", + "response timed out", + "Please retry your request", + "try your request again", + "upstream returned status 524", + ])("retries matching API error text: %s", (message) => { + expect(SessionRetry.retryable(wrap(message), retryProvider)).toEqual({ message }) + }) + + test("retries hyphenated service-unavailable errors", () => { + expect(SessionRetry.retryable(wrap("service-unavailable"), retryProvider)).toEqual({ + message: "Provider is overloaded", + }) + }) + + test("matches retryable API response bodies", () => { + const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)( + new SessionV1.APIError({ + message: "Request failed", + isRetryable: false, + statusCode: 400, + responseBody: JSON.stringify({ error: { message: "upstream connection refused" } }), + }).toObject(), + ) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Request failed" }) + }) + test("retries transport timeout errors", () => { const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID }) expect(SessionV1.APIError.isInstance(request)).toBe(true) From 057b5a9dee0b151b18ff5a1164a3cf709389497a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 5 Aug 2026 19:41:32 +0000 Subject: [PATCH 04/16] chore: generate --- packages/opencode/src/session/retry.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 22399e8703a..cab48dda633 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -86,7 +86,8 @@ export function retryable(error: Err, provider: string) { !(status !== undefined && status >= 500) && !matchesRetryableMessage(error.data.message) && !matchesRetryableMessage(error.data.responseBody) - ) return undefined + ) + return undefined if (error.data.responseBody?.includes("FreeUsageLimitError")) { return { message: GO_UPSELL_MESSAGE, From b84c63d034c7acbe897bd6515b0042fab58770fc Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:02:39 -0500 Subject: [PATCH 05/16] fix(stats): reduce html payloads --- .../stats/app/src/routes/[lab]/[model].tsx | 193 +++----- packages/stats/app/src/routes/[lab]/index.tsx | 79 ++-- .../stats/app/src/routes/compare-cards.tsx | 2 +- packages/stats/app/src/routes/geo-map.ts | 120 +++++ packages/stats/app/src/routes/index.tsx | 447 +++--------------- 5 files changed, 314 insertions(+), 527 deletions(-) create mode 100644 packages/stats/app/src/routes/geo-map.ts diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 3b2bf341fdb..0b079e91513 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -1,23 +1,17 @@ import { Meta, Title } from "@solidjs/meta" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { geoEquirectangular, geoPath } from "d3-geo" import { scaleSqrt } from "d3-scale" import countryCodesSource from "i18n-iso-countries/codes.json?raw" -import { feature, mesh } from "topojson-client" -import countriesTopologySource from "world-atlas/countries-50m.json?raw" import { getStatsModelData, type CountryEntry, type ModelPeerEntry, type ModelUsagePoint, type StatsModelData, - type UsageRange, } from "@opencode-ai/stats-core/domain/home" import { createAsync, query, useParams } from "@solidjs/router" import { createMemo, createSignal, createUniqueId, For, onMount, Show, type JSX } from "solid-js" import { getRequestEvent } from "solid-js/web" -import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson" -import type { GeometryCollection, Topology } from "topojson-specification" import { LocaleLinks } from "../../component/locale-links" import { useI18n } from "../../context/i18n" import { useLanguage } from "../../context/language" @@ -25,10 +19,10 @@ import { localizedUrl } from "../../lib/language" import { findModelCatalogEntry, formatCatalogLabName, - getModelCatalog, - type ModelCatalog, + loadModelCatalog, type ModelCatalogEntry, } from "../model-catalog" +import { geoMapHeight, geoMapWidth, worldBorderPath, worldCountryMarkers, worldCountryPaths } from "../geo-map" import { SectionHeading } from "../section-heading" import { runStatsEffect } from "../../stats-runtime" import { setStatsPageCacheHeaders } from "../stats-cache" @@ -51,45 +45,41 @@ import { } from "../stats-shell" const statsUnfurlPath = "banner.png" -const geoMapWidth = 960 -const geoMapHeight = 430 const shortMonths = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] as const type IsoCountryCode = readonly [string, string, string] -type WorldCountryProperties = GeoJsonProperties & { name?: string } -type WorldTopology = Topology<{ countries: GeometryCollection }> +type ModelCatalogOption = Pick +type ModelPageCatalog = { + entry: ModelCatalogEntry | null + labs: { id: string; name: string }[] + labModels: ModelCatalogOption[] +} +type StatsModelPageData = Omit & { country: CountryEntry[] } +type ModelPageData = { catalog: ModelPageCatalog; stats: StatsModelPageData | null } const countryNumericIds = new Map( (JSON.parse(countryCodesSource) as IsoCountryCode[]).map((country) => [country[0], country[2]] as const), ) -const worldTopology = JSON.parse(countriesTopologySource) as WorldTopology -const worldCountryGeometries: GeometryCollection = { - ...worldTopology.objects.countries, - geometries: worldTopology.objects.countries.geometries.filter((country) => String(country.id ?? "") !== "010"), -} -const worldCountries = feature(worldTopology, worldCountryGeometries) as FeatureCollection< - GeometryObject, - WorldCountryProperties -> -const worldProjection = geoEquirectangular().fitExtent( - [ - [10, 12], - [geoMapWidth - 10, geoMapHeight - 12], - ], - worldCountries, -) -const worldPath = geoPath(worldProjection) -const worldCountryPaths = worldCountries.features.map((country) => ({ - id: String(country.id ?? "").padStart(3, "0"), - path: worldPath(country) ?? "", - marker: geoCountryMarker(country), -})) -const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a, b) => a !== b)) ?? "" -const getModelData = query(async (lab: string, model: string) => { +const getModelPageData = query(async (labParam: string, modelParam: string) => { "use server" - return runStatsEffect(getStatsModelData(model, lab)) -}, "getStatsModelData") + const catalog = await loadModelCatalog() + const entry = findModelCatalogEntry(catalog, modelParam, labParam) ?? null + const lab = entry?.lab ?? labParam + const model = entry?.slug ?? modelParam + const stats = lab && model ? await runStatsEffect(getStatsModelData(model, lab)) : null + return { + catalog: { + entry, + labs: catalog.labs.map((item) => ({ id: item.id, name: item.name })), + labModels: + catalog.labs + .find((item) => item.id === (entry?.lab ?? providerSlug(labParam))) + ?.models.map((item) => ({ id: item.id, lab: item.lab, slug: item.slug, name: item.name })) ?? [], + }, + stats: stats ? { ...stats, country: stats.country["2M"] } : null, + } satisfies ModelPageData +}, "getStatsModelPageData") export default function StatsModel() { const i18n = useI18n() @@ -99,18 +89,9 @@ export default function StatsModel() { const params = useParams() const labParam = createMemo(() => params.lab ?? "") const modelParam = createMemo(() => params.model ?? "") - const catalog = createAsync(() => getModelCatalog()) - const catalogEntry = createMemo(() => { - const data = catalog() - if (!data) return undefined - return findModelCatalogEntry(data, modelParam(), labParam()) ?? null - }) - const stats = createAsync(() => { - const entry = catalogEntry() - if (catalog() === undefined || entry === undefined) return Promise.resolve(undefined) - if (!entry && (!labParam() || !modelParam())) return Promise.resolve(null) - return getModelData(labParam(), entry?.slug ?? modelParam()) - }) + const page = createAsync(() => getModelPageData(labParam(), modelParam())) + const catalogEntry = createMemo(() => page()?.catalog.entry) + const stats = createMemo(() => page()?.stats) const githubStars = createAsync(() => getGitHubStars()) const [themePreference, setThemePreference] = createSignal("system") const modelName = createMemo(() => catalogEntry()?.name ?? stats()?.model ?? modelParam() ?? i18n.t("model.fallback")) @@ -179,13 +160,13 @@ export default function StatsModel() { - }> + }> }> <> @@ -193,10 +174,10 @@ export default function StatsModel() { - + props.catalog?.name ?? props.data?.model ?? i18n.t("model.fallback") const weights = () => props.catalog?.weights[0] const labs = () => props.catalogData?.labs ?? [] - const labModels = () => - props.catalogData?.labs.find((lab) => lab.id === providerSlug(labId()))?.models ?? - (props.catalog ? [props.catalog] : []) + const labModels = () => props.catalogData?.labModels ?? (props.catalog ? [props.catalog] : []) return ( @@ -403,7 +382,7 @@ function ModelHeroActionIcon(props: { kind: "weights" | "compare" }) { ) } -function ModelHeroSparkline(props: { data: StatsModelData }) { +function ModelHeroSparkline(props: { data: StatsModelPageData }) { const values = () => props.data.usage.slice(-14).map((point) => point.tokens) return ( @@ -466,7 +445,7 @@ function ModelOverview(props: { catalog: ModelCatalogEntry | null }) { ) } -function ModelMomentumSection(props: { data: StatsModelData | null }) { +function ModelMomentumSection(props: { data: StatsModelPageData | null }) { const i18n = useI18n() const language = useLanguage() return ( @@ -505,7 +484,7 @@ function ModelMomentumSection(props: { data: StatsModelData | null }) { ) } -function MomentumChart(props: { data: StatsModelData; locale: string }) { +function MomentumChart(props: { data: StatsModelPageData; locale: string }) { const chart = createMemo(() => momentumChart(props.data.usage, props.data.updatedAt)) const changeState = createMemo(() => (props.data.tokenChange < 0 ? "negative" : "positive")) return ( @@ -562,7 +541,7 @@ function MomentumMetric(props: { label: string; value: string; watermark?: strin ) } -function ModelUsageSection(props: { data: StatsModelData | null }) { +function ModelUsageSection(props: { data: StatsModelPageData | null }) { const i18n = useI18n() return ( @@ -910,11 +889,11 @@ function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: M ) } -function ModelGeoBreakdownSection(props: { data: Record }) { +function ModelGeoBreakdownSection(props: { data: CountryEntry[] }) { const i18n = useI18n() const language = useLanguage() const [activeCountry, setActiveCountry] = createSignal() - const data = createMemo(() => props.data["2M"]) + const data = createMemo(() => props.data) const countryById = createMemo( () => new Map( @@ -1031,31 +1010,30 @@ function GeoWorldMap(props: { - + {(country) => { const entry = () => props.countryById.get(country.id) return ( - - {(marker) => ( - { - const item = entry() - if (!item) return - props.onActiveCountryChange(item.country) - }} - onClick={() => { - const item = entry() - if (!item) return - props.onActiveCountryChange(item.country) - }} - /> - )} + + { + const item = entry() + if (!item) return + props.onActiveCountryChange(item.country) + }} + onClick={() => { + const item = entry() + if (!item) return + props.onActiveCountryChange(item.country) + }} + /> ) }} @@ -1103,7 +1081,7 @@ function GeoCountryList(props: { ) } -function ModelPeersSection(props: { data: StatsModelData | null }) { +function ModelPeersSection(props: { data: StatsModelPageData | null }) { const i18n = useI18n() return ( @@ -1172,9 +1150,9 @@ function ModelEmptyState(props: { title: string; description: string; compact?: } function modelComparisonPairs( - catalog: ModelCatalog | undefined, + catalogModels: ModelCatalogOption[] | undefined, catalogEntry: ModelCatalogEntry | null, - data: StatsModelData | null, + data: StatsModelPageData | null, ) { const current = modelComparisonRef(catalogEntry, data) if (!current) return [] @@ -1192,9 +1170,7 @@ function modelComparisonPairs( }, detail: "Usage peer", })) - const catalogPairs = ( - catalogEntry && catalog ? (catalog.labs.find((lab) => lab.id === catalogEntry.lab)?.models ?? []) : [] - ) + const catalogPairs = (catalogEntry ? (catalogModels ?? []) : []) .filter((model) => model.id !== catalogEntry?.id) .slice(0, 3) .map((model) => ({ @@ -1207,7 +1183,7 @@ function modelComparisonPairs( function modelComparisonRef( catalogEntry: ModelCatalogEntry | null, - data: StatsModelData | null, + data: StatsModelPageData | null, ): ComparisonModelRef | undefined { if (catalogEntry) return modelRefFromCatalog(catalogEntry) if (!data) return undefined @@ -1227,31 +1203,10 @@ function getProviderIconId(author: string) { return author.toLowerCase().replace(/[^a-z0-9]+/g, "") } -function emptyCountryRecord(): Record { - return { - "1D": [], - "1W": [], - "2W": [], - "1M": [], - "2M": [], - "3M": [], - YTD: [], - ALL: [], - } -} - function countryNumericId(country: string) { return countryNumericIds.get(country.toUpperCase())?.padStart(3, "0") } -function geoCountryMarker(country: (typeof worldCountries.features)[number]) { - const bounds = worldPath.bounds(country) - const [x, y] = worldPath.centroid(country) - if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined - if (bounds[1][0] - bounds[0][0] >= 3 && bounds[1][1] - bounds[0][1] >= 3) return undefined - return { x, y } -} - function formatCountryName(country: string, locale: string, i18n: ReturnType) { const code = country.toUpperCase() if (code === "ZZ") return i18n.t("home.unknown") @@ -1508,7 +1463,7 @@ function formatSparklinePoint(value: number) { return Number(value.toFixed(2)).toString() } -function formatModelRankMoveLabel(data: StatsModelData, i18n: ReturnType) { +function formatModelRankMoveLabel(data: StatsModelPageData, i18n: ReturnType) { if (data.rank === null) return i18n.t("model.noUsageLastWeek") if (data.previousRank === null) return i18n.t("model.newThisWeek") const change = data.previousRank - data.rank diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx index 01bb74c039e..8931c2af0cf 100644 --- a/packages/stats/app/src/routes/[lab]/index.tsx +++ b/packages/stats/app/src/routes/[lab]/index.tsx @@ -6,7 +6,6 @@ import { type LabUsageModelEntry, type MarketDay, type ModelUsagePoint, - type StatsHomeData, type StatsLabData, } from "@opencode-ai/stats-core/domain/home" import { createAsync, query, useParams } from "@solidjs/router" @@ -20,7 +19,7 @@ import { catalogSlug, findModelCatalogLab, formatCatalogLabName, - getModelCatalog, + loadModelCatalog, type ModelCatalogEntry, type ModelCatalogLab, } from "../model-catalog" @@ -42,15 +41,33 @@ import { const statsUnfurlPath = "banner.png" -const getLabData = query(async (lab: string) => { - "use server" - return runStatsEffect(getStatsLabData(lab)) -}, "getStatsLabData") +type RelatedCatalogLab = Pick & { + models: Pick[] +} -const getHomeData = query(async () => { +type LabPageData = { + lab: ModelCatalogLab | null + labs: RelatedCatalogLab[] + market: MarketDay[] + stats: StatsLabData | null +} + +const getLabPageData = query(async (labParam: string) => { "use server" - return runStatsEffect(getStatsHomeData()) -}, "getStatsHomeData") + const [catalog, home] = await Promise.all([loadModelCatalog(), runStatsEffect(getStatsHomeData())]) + const lab = findModelCatalogLab(catalog, labParam) ?? null + return { + lab, + labs: catalog.labs.map((entry) => ({ + id: entry.id, + name: entry.name, + description: entry.description, + models: entry.models.map((model) => ({ name: model.name })), + })), + market: home.market["2M"], + stats: lab ? await runStatsEffect(getStatsLabData(lab.id)) : null, + } satisfies LabPageData +}, "getStatsLabPageData") type LabModelTooltipState = { model: ModelCatalogEntry @@ -67,19 +84,9 @@ export default function StatsLab() { setStatsPageCacheHeaders(event?.response.headers) const params = useParams() const labParam = createMemo(() => params.lab ?? "") - const catalog = createAsync(() => getModelCatalog()) - const lab = createMemo(() => { - const data = catalog() - if (!data) return undefined - return findModelCatalogLab(data, labParam()) ?? null - }) - const stats = createAsync(() => { - const entry = lab() - if (catalog() === undefined || entry === undefined) return Promise.resolve(undefined) - if (!entry) return Promise.resolve(null) - return getLabData(entry.id) - }) - const homeStats = createAsync((): Promise => getHomeData()) + const page = createAsync(() => getLabPageData(labParam())) + const lab = createMemo(() => page()?.lab) + const stats = createMemo(() => page()?.stats) const githubStars = createAsync(() => getGitHubStars()) const [themePreference, setThemePreference] = createSignal("system") const labName = createMemo(() => lab()?.name ?? formatCatalogLabName(labParam())) @@ -137,18 +144,18 @@ export default function StatsLab() { - }> - }> + }> + }> {(data) => ( <> - + formatCatalogLabName(props.lab) return ( @@ -194,7 +201,7 @@ function LabNotFound(props: { lab: string; labs: ModelCatalogLab[] }) { ) } -function LabHero(props: { lab: ModelCatalogLab; labs: ModelCatalogLab[] }) { +function LabHero(props: { lab: ModelCatalogLab; labs: RelatedCatalogLab[] }) { return ( @@ -203,7 +210,7 @@ function LabHero(props: { lab: ModelCatalogLab; labs: ModelCatalogLab[] }) { ) } -function LabHeroBreadcrumb(props: { label: string; labs?: ModelCatalogLab[] }) { +function LabHeroBreadcrumb(props: { label: string; labs?: RelatedCatalogLab[] }) { const language = useLanguage() const labs = () => props.labs ?? [] const current = () => labs().find((lab) => lab.name === props.label) @@ -668,7 +675,7 @@ function LabModelTooltip(props: { state: LabModelTooltipState }) { ) } -function LabRelatedSection(props: { lab: ModelCatalogLab; labs: ModelCatalogLab[]; market: MarketDay[] }) { +function LabRelatedSection(props: { lab: ModelCatalogLab; labs: RelatedCatalogLab[]; market: MarketDay[] }) { const related = createMemo(() => relatedLabs(props.lab, props.labs, props.market)) return ( @@ -757,9 +764,9 @@ function labComparisonPairs(lab: ModelCatalogLab, usage: LabUsageModelEntry[]) { ) } -type RelatedLabEntry = { lab: ModelCatalogLab; share: number; tokens: number } +type RelatedLabEntry = { lab: RelatedCatalogLab; share: number; tokens: number } -function relatedLabs(current: ModelCatalogLab, labs: ModelCatalogLab[], market: MarketDay[]): RelatedLabEntry[] { +function relatedLabs(current: ModelCatalogLab, labs: RelatedCatalogLab[], market: MarketDay[]): RelatedLabEntry[] { const stats = relatedLabStats(labs, market) return labs .filter((lab) => lab.id !== current.id) @@ -768,8 +775,8 @@ function relatedLabs(current: ModelCatalogLab, labs: ModelCatalogLab[], market: .slice(0, 3) } -function relatedLabStats(labs: ModelCatalogLab[], market: MarketDay[]) { - const labByKey = new Map() +function relatedLabStats(labs: RelatedCatalogLab[], market: MarketDay[]) { + const labByKey = new Map() labs.forEach((lab) => { labByKey.set(lab.id, lab) labByKey.set(catalogSlug(lab.name), lab) @@ -794,7 +801,7 @@ function relatedLabStats(labs: ModelCatalogLab[], market: MarketDay[]) { ) } -function labRelatedDescription(lab: ModelCatalogLab) { +function labRelatedDescription(lab: RelatedCatalogLab) { return lab.description ?? "" } diff --git a/packages/stats/app/src/routes/compare-cards.tsx b/packages/stats/app/src/routes/compare-cards.tsx index 3216c923b79..47cc07b9295 100644 --- a/packages/stats/app/src/routes/compare-cards.tsx +++ b/packages/stats/app/src/routes/compare-cards.tsx @@ -17,7 +17,7 @@ export type ComparisonPair = { description?: string } -export function modelRefFromCatalog(entry: ModelCatalogEntry): ComparisonModelRef { +export function modelRefFromCatalog(entry: Pick): ComparisonModelRef { return { name: entry.name, lab: entry.lab, diff --git a/packages/stats/app/src/routes/geo-map.ts b/packages/stats/app/src/routes/geo-map.ts new file mode 100644 index 00000000000..53a82eb87fa --- /dev/null +++ b/packages/stats/app/src/routes/geo-map.ts @@ -0,0 +1,120 @@ +import { geoEquirectangular, geoPath } from "d3-geo" +import { feature, mesh } from "topojson-client" +import countriesTopologySource from "world-atlas/countries-110m.json?raw" +import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson" +import type { GeometryCollection, Topology } from "topojson-specification" + +export const geoMapWidth = 960 +export const geoMapHeight = 430 + +type WorldCountryProperties = GeoJsonProperties & { name?: string } +type WorldTopology = Topology<{ countries: GeometryCollection }> + +const worldTopology = JSON.parse(countriesTopologySource) as WorldTopology +const worldCountryGeometries: GeometryCollection = { + ...worldTopology.objects.countries, + geometries: worldTopology.objects.countries.geometries.filter((country) => String(country.id ?? "") !== "010"), +} +const worldCountries = feature(worldTopology, worldCountryGeometries) as FeatureCollection< + GeometryObject, + WorldCountryProperties +> +const worldProjection = geoEquirectangular().fitExtent( + [ + [10, 12], + [geoMapWidth - 10, geoMapHeight - 12], + ], + worldCountries, +) +const worldPath = geoPath(worldProjection) + +export const worldCountryPaths = worldCountries.features.map((country) => ({ + id: String(country.id ?? "").padStart(3, "0"), + path: worldPath(country) ?? "", +})) + +export const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a, b) => a !== b)) ?? "" + +function geoCountryMarker(country: (typeof worldCountries.features)[number]) { + const bounds = worldPath.bounds(country) + const [x, y] = worldPath.centroid(country) + if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined + if (bounds[1][0] - bounds[0][0] >= 3 && bounds[1][1] - bounds[0][1] >= 3) return undefined + return { x, y } +} + +// The 110m topology omits small regions. Geographic centroids keep those countries interactive without shipping 50m paths. +const fallbackCountryMarkerCoordinates = [ + ["016", -170.7179, -14.3046], + ["020", 1.5606, 42.542], + ["028", -61.7945, 17.2762], + ["048", 50.5425, 26.0417], + ["052", -59.5602, 13.1811], + ["060", -64.7558, 32.3131], + ["086", 72.4453, -7.3312], + ["092", -64.4704, 18.5276], + ["132", -23.9576, 15.9551], + ["136", -80.9129, 19.43], + ["174", 43.6844, -11.879], + ["184", -159.7871, -21.2195], + ["212", -61.3576, 15.4394], + ["234", -6.8808, 62.0527], + ["239", -36.4863, -54.4641], + ["248", 19.9528, 60.2153], + ["258", -144.8045, -14.7283], + ["296", -167.9217, 0.893], + ["308", -61.6818, 12.1174], + ["316", 144.767, 13.4406], + ["334", 73.52, -53.0872], + ["336", 12.4343, 41.9021], + ["344", 114.1143, 22.3983], + ["438", 9.5357, 47.1367], + ["446", 113.509, 22.2231], + ["462", 73.4573, 3.7316], + ["470", 14.405, 35.9215], + ["480", 57.5714, -20.2779], + ["492", 7.4073, 43.7526], + ["500", -62.1856, 16.7404], + ["520", 166.9326, -0.5189], + ["531", -68.9721, 12.1957], + ["533", -69.9827, 12.521], + ["534", -63.0572, 18.0509], + ["570", -169.8704, -19.0489], + ["574", 167.9497, -29.0516], + ["580", 145.6193, 15.8288], + ["583", 153.2966, 7.5361], + ["584", 170.3313, 7.015], + ["585", 134.4056, 7.286], + ["612", -128.3167, -24.3649], + ["652", -62.841, 17.8988], + ["654", -9.7009, -12.3548], + ["659", -62.6873, 17.2647], + ["660", -63.066, 18.2243], + ["662", -60.9696, 13.8946], + ["663", -63.0599, 18.0888], + ["666", -56.3037, 46.9187], + ["670", -61.2008, 13.2251], + ["674", 12.4594, 43.9415], + ["678", 6.7235, 0.4434], + ["690", 55.476, -4.6601], + ["702", 103.817, 1.359], + ["776", -174.7998, -20.4161], + ["796", -71.9734, 21.8312], + ["831", -2.5726, 49.4678], + ["832", -2.1272, 49.2181], + ["833", -4.5388, 54.224], + ["850", -64.8028, 17.9555], + ["876", -177.3469, -13.8898], + ["882", -172.1649, -13.7536], +] as const + +export const worldCountryMarkers = [ + ...worldCountries.features.flatMap((country) => { + const marker = geoCountryMarker(country) + return marker ? [{ id: String(country.id ?? "").padStart(3, "0"), marker }] : [] + }), + ...fallbackCountryMarkerCoordinates.flatMap(([id, longitude, latitude]) => { + const marker = worldProjection([longitude, latitude]) + return marker ? [{ id, marker: { x: marker[0], y: marker[1] } }] : [] + }), +] diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index c6549b1f613..29243bd61f0 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -1,10 +1,7 @@ import { Link, Meta, Title } from "@solidjs/meta" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { geoEquirectangular, geoPath } from "d3-geo" import { scaleSqrt } from "d3-scale" import countryCodesSource from "i18n-iso-countries/codes.json?raw" -import { feature, mesh } from "topojson-client" -import countriesTopologySource from "world-atlas/countries-50m.json?raw" import ibmPlexMonoRegularLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Regular-Latin1.woff2?url" import ibmPlexMonoMediumLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2?url" import ibmPlexMonoSemiBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-SemiBold-Latin1.woff2?url" @@ -15,7 +12,6 @@ import { type CountryEntry, type LeaderboardEntry, type MarketDay, - type StatsHomeData, type SessionCostEntry, type TokenCostEntry, type UsagePoint, @@ -23,14 +19,13 @@ import { import { createAsync, query } from "@solidjs/router" import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js" import { getRequestEvent } from "solid-js/web" -import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson" -import type { GeometryCollection, Topology } from "topojson-specification" import { runStatsEffect } from "../stats-runtime" import { LocaleLinks } from "../component/locale-links" import { useI18n } from "../context/i18n" import { useLanguage } from "../context/language" import { localizedUrl } from "../lib/language" -import { findModelCatalogEntry, getModelCatalog, type ModelCatalog } from "./model-catalog" +import { findModelCatalogEntry, loadModelCatalog, type ModelCatalog } from "./model-catalog" +import { geoMapHeight, geoMapWidth, worldBorderPath, worldCountryMarkers, worldCountryPaths } from "./geo-map" import { SectionHeading } from "./section-heading" import { setStatsPageCacheHeaders } from "./stats-cache" import { ComparisonCardsSection, uniqueComparisonPairs, type ComparisonModelRef } from "./compare-cards" @@ -45,9 +40,6 @@ import { type ThemePreference, } from "./stats-shell" -const products = ["All Users", "Zen", "Go"] as const -const tokenProducts = ["Zen", "Go"] as const -const ranges = ["1D", "1W", "2W", "1M", "2M"] as const const comparisonPairIndexes = [ [0, 1, "Top two by recent usage"], [0, 2, "Leader vs challenger"], @@ -69,60 +61,40 @@ const usageColors = [ "#ff6467", ] const marketColors = ["#ed6aff", "#a684ff", "#7c86ff", "#51a2ff", "#00d3f2", "#00d5be", "#00bc7d", "#9ae600", "#ffb900"] -const geoMapWidth = 960 -const geoMapHeight = 430 -type UsageProduct = (typeof products)[number] -type TokenProduct = (typeof tokenProducts)[number] -type UsageRange = (typeof ranges)[number] +type UsageRange = "1D" | "1W" | "2W" | "1M" | "2M" type IsoCountryCode = readonly [string, string, string] -type WorldCountryProperties = GeoJsonProperties & { name?: string } -type WorldTopology = Topology<{ countries: GeometryCollection }> -function productLabel(product: UsageProduct | TokenProduct, i18n: ReturnType) { - if (product === "All Users") return i18n.t("product.allUsers") - if (product === "Zen") return i18n.t("product.zen") - return i18n.t("product.go") -} - -function rangeLabel(range: UsageRange, i18n: ReturnType) { - if (range === "1D") return i18n.t("range.1D") - if (range === "1W") return i18n.t("range.1W") - if (range === "2W") return i18n.t("range.2W") - if (range === "1M") return i18n.t("range.1M") - return i18n.t("range.2M") +type StatsHomePageData = { + updatedAt: string | null + usage: UsagePoint[] + users: UsagePoint[] + leaderboard: LeaderboardEntry[] + market: MarketDay[] + tokenCost: TokenCostEntry[] + cacheRatio: CacheRatioEntry[] + sessionCost: SessionCostEntry[] + country: CountryEntry[] } const countryNumericIds = new Map( (JSON.parse(countryCodesSource) as IsoCountryCode[]).map((country) => [country[0], country[2]] as const), ) -const worldTopology = JSON.parse(countriesTopologySource) as WorldTopology -const worldCountryGeometries: GeometryCollection = { - ...worldTopology.objects.countries, - geometries: worldTopology.objects.countries.geometries.filter((country) => String(country.id ?? "") !== "010"), -} -const worldCountries = feature(worldTopology, worldCountryGeometries) as FeatureCollection< - GeometryObject, - WorldCountryProperties -> -const worldProjection = geoEquirectangular().fitExtent( - [ - [10, 12], - [geoMapWidth - 10, geoMapHeight - 12], - ], - worldCountries, -) -const worldPath = geoPath(worldProjection) -const worldCountryPaths = worldCountries.features.map((country) => ({ - id: String(country.id ?? "").padStart(3, "0"), - path: worldPath(country) ?? "", - marker: geoCountryMarker(country), -})) -const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a, b) => a !== b)) ?? "" const getData = query(async () => { "use server" - return runStatsEffect(getStatsHomeData()) + const [stats, catalog] = await Promise.all([runStatsEffect(getStatsHomeData()), loadModelCatalog()]) + return { + updatedAt: stats.updatedAt, + usage: stats.usage.Go["2M"], + users: stats.users.Go["2M"], + leaderboard: stats.leaderboard.Go["2M"], + market: stats.market["2M"], + tokenCost: priceTokenCostFromCatalog(stats.tokenCost.Go, catalog), + cacheRatio: stats.cacheRatio.Go, + sessionCost: stats.sessionCost.Go, + country: stats.country["2M"], + } satisfies StatsHomePageData }, "getStatsHomeData") export default function StatsHome() { @@ -133,7 +105,6 @@ export default function StatsHome() { const statsHomeUrl = localizedUrl(language.locale(), "/data/") const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString() const data = createAsync(() => getData()) - const catalog = createAsync(() => getModelCatalog()) const githubStars = createAsync(() => getGitHubStars()) const [themePreference, setThemePreference] = createSignal("system") const updateThemePreference = (preference: ThemePreference) => { @@ -185,12 +156,12 @@ export default function StatsHome() { - + ("Go") - const [range, setRange] = createSignal("2M") - const [sheet, setSheet] = createSignal<"product" | "range">() const [activeModel, setActiveModel] = createSignal() - const data = createMemo(() => props.data[product()][range()]) - const leaderboard = createMemo(() => props.leaderboard[product()][range()]) - - createEffect(() => { - if (!sheet()) return - if (typeof document === "undefined") return - const htmlOverflow = document.documentElement.style.overflow - const bodyOverflow = document.body.style.overflow - document.documentElement.style.overflow = "hidden" - document.body.style.overflow = "hidden" - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") setSheet(undefined) - } - document.addEventListener("keydown", onKeyDown) - onCleanup(() => { - document.documentElement.style.overflow = htmlOverflow - document.body.style.overflow = bodyOverflow - document.removeEventListener("keydown", onKeyDown) - }) - }) return ( @@ -438,201 +386,28 @@ function TopModelsSection(props: { data: StatsHomeData["usage"]; leaderboard: St description={i18n.t("home.topModelsDescription")} /> usageTotal(item) > 0)} + when={props.data.some((item) => usageTotal(item) > 0)} fallback={} > 0} + when={props.leaderboard.length > 0} fallback={ } > - - - - - - setSheet(sheet() === "product" ? undefined : "product")} - /> - setSheet(sheet() === "range" ? undefined : "range")} - /> - - - - {(kind) => ( - { - setProduct(value) - setSheet(undefined) - }} - onRangeSelect={(value) => { - setRange(value) - setSheet(undefined) - }} - onClose={() => setSheet(undefined)} - /> - )} + ) } -function MobileFilterButton(props: { label: string; value: string; expanded: boolean; onClick: () => void }) { - return ( - - {props.value} - - - ) -} - -function MobileFilterSheet(props: { - kind: "product" | "range" - product: UsageProduct - range: UsageRange - onProductSelect: (product: UsageProduct) => void - onRangeSelect: (range: UsageRange) => void - onClose: () => void -}) { - const i18n = useI18n() - return ( - - - - {(item) => ( - { - event.stopPropagation() - props.onRangeSelect(item) - }} - > - {rangeLabel(item, i18n)} - - )} - - } - > - - {(item) => ( - { - event.stopPropagation() - props.onProductSelect(item) - }} - > - {productLabel(item, i18n)} - - )} - - - - - ) -} - -function ChevronDown() { - return ( - - - - ) -} - -function StatsFilters(props: { - product: UsageProduct - range: UsageRange - onProductSelect: (product: UsageProduct) => void - onRangeSelect: (range: UsageRange) => void -}) { - const i18n = useI18n() - return ( - <> - productLabel(item, i18n)} - onSelect={props.onProductSelect} - /> - rangeLabel(item, i18n)} - onSelect={props.onRangeSelect} - /> - > - ) -} - -function FilterPills(props: { - items: readonly T[] - selected: T - label: string - variant: "product" | "range" - formatLabel?: (item: T) => string - onSelect: (item: T) => void -}) { - return ( - - - {(item) => ( - props.onSelect(item)} - > - {props.formatLabel ? props.formatLabel(item) : item} - - )} - - - ) -} - function TopModelsChart(props: { data: UsagePoint[] range: UsageRange @@ -823,10 +598,9 @@ function TopModelsChart(props: { ) } -function UniqueUsersSection(props: { data: StatsHomeData["users"] }) { +function UniqueUsersSection(props: { data: UsagePoint[] }) { const i18n = useI18n() const [activeModel, setActiveModel] = createSignal() - const data = createMemo(() => props.data.Go["2M"]) return ( @@ -837,13 +611,13 @@ function UniqueUsersSection(props: { data: StatsHomeData["users"] }) { description={i18n.t("home.uniqueUsersDescription")} /> usageTotal(item) > 0)} + when={props.data.some((item) => usageTotal(item) > 0)} fallback={ } > ) { return `${value}%` } -function MarketShareSection(props: { data: StatsHomeData["market"] }) { +function MarketShareSection(props: { data: MarketDay[] }) { const i18n = useI18n() - const [range, setRange] = createSignal("2M") const [activeIndex, setActiveIndex] = createSignal(2) const [activeAuthor, setActiveAuthor] = createSignal() const [inspecting, setInspecting] = createSignal(false) - const data = createMemo(() => props.data[range()]) - const authorOrder = createMemo(() => getMarketAuthorOrder(data())) - const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0))) - const activeDay = createMemo(() => data()[selectedIndex()]) + const authorOrder = createMemo(() => getMarketAuthorOrder(props.data)) + const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(props.data.length - 1, 0))) + const activeDay = createMemo(() => props.data[selectedIndex()]) return ( ( <> {inspecting() ? formatMarketDate(activeDay(), i18n.t("home.noData")) - : formatMarketRange(data(), i18n.t("home.noData"))} + : formatMarketRange(props.data, i18n.t("home.noData"))} - - { - setRange(item) - setActiveAuthor(undefined) - setInspecting(false) - }} - /> - ) @@ -1333,23 +1092,22 @@ function MarketShareList(props: { ) } -function GeoBreakdownSection(props: { data: StatsHomeData["country"] }) { +function GeoBreakdownSection(props: { data: CountryEntry[] }) { const i18n = useI18n() const language = useLanguage() const [activeCountry, setActiveCountry] = createSignal() - const data = createMemo(() => props.data["2M"]) const countryById = createMemo( () => new Map( - data().flatMap((country) => { + props.data.flatMap((country) => { const id = countryNumericId(country.country) return id ? [[id, country] as const] : [] }), ), ) - const maxTokens = createMemo(() => Math.max(0, ...data().map((country) => country.tokens)) || 1) - const topCountries = createMemo(() => data().slice(0, 15)) - const active = createMemo(() => data().find((country) => country.country === activeCountry()) ?? data()[0]) + const maxTokens = createMemo(() => Math.max(0, ...props.data.map((country) => country.tokens)) || 1) + const topCountries = createMemo(() => props.data.slice(0, 15)) + const active = createMemo(() => props.data.find((country) => country.country === activeCountry()) ?? props.data[0]) return ( 0} + when={props.data.length > 0} fallback={} > @@ -1453,31 +1211,30 @@ function GeoWorldMap(props: { - + {(country) => { const entry = () => props.countryById.get(country.id) return ( - - {(marker) => ( - { - const item = entry() - if (!item) return - props.onActiveCountryChange(item.country) - }} - onClick={() => { - const item = entry() - if (!item) return - props.onActiveCountryChange(item.country) - }} - /> - )} + + { + const item = entry() + if (!item) return + props.onActiveCountryChange(item.country) + }} + onClick={() => { + const item = entry() + if (!item) return + props.onActiveCountryChange(item.country) + }} + /> ) }} @@ -1531,14 +1288,6 @@ function countryNumericId(country: string) { return countryNumericIds.get(country.toUpperCase())?.padStart(3, "0") } -function geoCountryMarker(country: (typeof worldCountries.features)[number]) { - const bounds = worldPath.bounds(country) - const [x, y] = worldPath.centroid(country) - if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined - if (bounds[1][0] - bounds[0][0] >= 3 && bounds[1][1] - bounds[0][1] >= 3) return undefined - return { x, y } -} - function formatCountryName(country: string, locale: string, unknown: string) { const code = country.toUpperCase() if (code === "ZZ") return unknown @@ -1636,12 +1385,10 @@ function marketDateParts(label: string) { return { start: start ?? label, end: end ?? start ?? label } } -function TokenCostSection(props: { data: StatsHomeData["tokenCost"]; catalog: ModelCatalog | null }) { +function TokenCostSection(props: { data: TokenCostEntry[] }) { const i18n = useI18n() - const [product, setProduct] = createSignal("Go") const [activeIndex, setActiveIndex] = createSignal(2) - const data = createMemo(() => priceTokenCostFromCatalog(props.data[product()], props.catalog)) - const visible = createMemo(() => data().slice(0, 13)) + const visible = createMemo(() => props.data.slice(0, 13)) const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(visible().length - 1, 0))) return ( @@ -1660,17 +1407,6 @@ function TokenCostSection(props: { data: StatsHomeData["tokenCost"]; catalog: Mo > - - productLabel(item, i18n)} - onSelect={setProduct} - /> - - ) } @@ -1723,12 +1459,10 @@ function TokenCostChart(props: { ) } -function CacheRatioSection(props: { data: StatsHomeData["cacheRatio"] }) { +function CacheRatioSection(props: { data: CacheRatioEntry[] }) { const i18n = useI18n() - const [product, setProduct] = createSignal("Go") const [activeIndex, setActiveIndex] = createSignal(2) - const data = createMemo(() => props.data[product()]) - const visible = createMemo(() => data().slice(0, 16)) + const visible = createMemo(() => props.data.slice(0, 16)) const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(visible().length - 1, 0))) return ( @@ -1745,17 +1479,6 @@ function CacheRatioSection(props: { data: StatsHomeData["cacheRatio"] }) { > - - productLabel(item, i18n)} - onSelect={setProduct} - /> - - ) } @@ -1853,12 +1576,10 @@ function MetricBar(props: { value: number; max: number; active: boolean }) { ) } -function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) { +function SessionCostSection(props: { data: SessionCostEntry[] }) { const i18n = useI18n() - const [product, setProduct] = createSignal("Go") const [activeIndex, setActiveIndex] = createSignal(2) - const data = createMemo(() => props.data[product()]) - const visible = createMemo(() => data().slice(0, 16)) + const visible = createMemo(() => props.data.slice(0, 16)) const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(visible().length - 1, 0))) return ( @@ -1877,17 +1598,6 @@ function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) { > - - productLabel(item, i18n)} - onSelect={setProduct} - /> - - ) } @@ -1949,11 +1659,6 @@ function SessionCostChart(props: { ) } -function LiveIndicator() { - const i18n = useI18n() - return {i18n.t("chart.live")} -} - function formatTokenCount(value: number) { if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}M` return `${Math.round(value / 1_000)}K` From 3355b78d91876104085995ea5e9dc27ede28d21b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 5 Aug 2026 20:06:05 +0000 Subject: [PATCH 06/16] chore: generate --- packages/stats/app/src/routes/[lab]/[model].tsx | 7 +------ packages/stats/app/src/routes/[lab]/index.tsx | 6 +----- packages/stats/app/src/routes/index.tsx | 7 +------ 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 0b079e91513..c9906a975e9 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -16,12 +16,7 @@ import { LocaleLinks } from "../../component/locale-links" import { useI18n } from "../../context/i18n" import { useLanguage } from "../../context/language" import { localizedUrl } from "../../lib/language" -import { - findModelCatalogEntry, - formatCatalogLabName, - loadModelCatalog, - type ModelCatalogEntry, -} from "../model-catalog" +import { findModelCatalogEntry, formatCatalogLabName, loadModelCatalog, type ModelCatalogEntry } from "../model-catalog" import { geoMapHeight, geoMapWidth, worldBorderPath, worldCountryMarkers, worldCountryPaths } from "../geo-map" import { SectionHeading } from "../section-heading" import { runStatsEffect } from "../../stats-runtime" diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx index 8931c2af0cf..3d4a1a663c3 100644 --- a/packages/stats/app/src/routes/[lab]/index.tsx +++ b/packages/stats/app/src/routes/[lab]/index.tsx @@ -152,11 +152,7 @@ export default function StatsLab() { - + usageTotal(item) > 0)} fallback={} > - + 0} From 709c195905c260299facd5b566d9ab537c48c3bc Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:14:03 -0500 Subject: [PATCH 07/16] fix(opencode): preserve compatible stream errors (#40718) --- bun.lock | 1 + package.json | 3 +- .../test/session/processor-effect.test.ts | 49 +++++++++++++++++++ .../@ai-sdk%2Fopenai-compatible@2.0.41.patch | 39 +++++++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch diff --git a/bun.lock b/bun.lock index 0d08cb8a95a..3ba15ef800b 100644 --- a/bun.lock +++ b/bun.lock @@ -1078,6 +1078,7 @@ "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch", + "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch", }, "overrides": { "@opentui/core": "catalog:", diff --git a/package.json b/package.json index 15725c865fc..58712547b4b 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,7 @@ "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", - "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch" + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", + "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch" } } diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 52876054365..d9466c0c2c9 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -604,6 +604,55 @@ it.live("session.processor effect tests retry recognized structured json errors" ), ) +it.live("session.processor effect tests retry OpenAI-compatible midstream server errors", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + yield* llm.push( + raw({ chunks: [{ error: { type: "server_error", code: "server_error", message: "xxx" } }] }), + ) + yield* llm.text("after") + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "retry midstream server error") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies SessionV1.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "retry midstream server error" }], + tools: {}, + }) + + const parts = yield* MessageV2.parts(msg.id) + + expect(value).toBe("continue") + expect(yield* llm.calls).toBe(2) + expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true) + expect(handle.message.error).toBeUndefined() + }), + { config: (url) => providerCfg(url) }, + ), +) + it.live("session.processor effect tests publish retry status updates", () => provideTmpdirServer( ({ dir, llm }) => diff --git a/patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch b/patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch new file mode 100644 index 00000000000..9f03ec95732 --- /dev/null +++ b/patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch @@ -0,0 +1,39 @@ +diff --git a/dist/index.js b/dist/index.js +index dca128d3a790378c51a24a16d92585178343b278..da75f9d64acd2b607abd15079ce2d03b7a8d675a 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -696,7 +696,7 @@ var OpenAICompatibleChatLanguageModel = class { + finishReason = { unified: "error", raw: void 0 }; + controller.enqueue({ + type: "error", +- error: chunk.value.error.message ++ error: chunk.value.error + }); + return; + } +diff --git a/dist/index.mjs b/dist/index.mjs +index 3b1e1b6bdec5032e3b4fa5ffbcc8cdf3dfe1cc40..eaffc446f80552ea0b26573b89daa4dfc7776e6e 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -683,7 +683,7 @@ var OpenAICompatibleChatLanguageModel = class { + finishReason = { unified: "error", raw: void 0 }; + controller.enqueue({ + type: "error", +- error: chunk.value.error.message ++ error: chunk.value.error + }); + return; + } +diff --git a/src/chat/openai-compatible-chat-language-model.ts b/src/chat/openai-compatible-chat-language-model.ts +index 8c622db23c2d9a7373701f5a1b0c2ba109e24602..643c3db68a6043e097edc1122e0eb53fd13495c5 100644 +--- a/src/chat/openai-compatible-chat-language-model.ts ++++ b/src/chat/openai-compatible-chat-language-model.ts +@@ -442,7 +442,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 { + finishReason = { unified: 'error', raw: undefined }; + controller.enqueue({ + type: 'error', +- error: chunk.value.error.message, ++ error: chunk.value.error, + }); + return; + } From 082fe93e160c042332b3686a006d0f35ce4a6d6e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 5 Aug 2026 20:16:40 +0000 Subject: [PATCH 08/16] chore: generate --- packages/opencode/test/session/processor-effect.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index d9466c0c2c9..052477d0a2e 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -610,9 +610,7 @@ it.live("session.processor effect tests retry OpenAI-compatible midstream server Effect.gen(function* () { const { processors, session, provider } = yield* boot() - yield* llm.push( - raw({ chunks: [{ error: { type: "server_error", code: "server_error", message: "xxx" } }] }), - ) + yield* llm.push(raw({ chunks: [{ error: { type: "server_error", code: "server_error", message: "xxx" } }] })) yield* llm.text("after") const chat = yield* session.create({}) From b1f8cc04af936c3d3b5de8cb0645c6d249968eb4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 5 Aug 2026 20:33:25 +0000 Subject: [PATCH 09/16] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 83bc08a630e..6f321d88bf2 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-GRjnvvyj37H36RqiCB7dz5ALAEwvw16izwuk1wsHEpU=", - "aarch64-linux": "sha256-0OIn1o6dpqIQ5XgIMzpenMCMqsYzraaYVJk+te5eINU=", - "aarch64-darwin": "sha256-sQSQcuox78d8wT1lsKYHqVBl11NusiszQ+gu2XYXZi8=", - "x86_64-darwin": "sha256-SINkhMRd4oM1zABSs1Uf3OAzxJ1lgcycl1U4fmi+baY=" + "x86_64-linux": "sha256-uduwrM143NDSc+tXsi4lVVfoMll2a3BDHRUjuO7GB68=", + "aarch64-linux": "sha256-6DUda78XdXY6DP86lIUkweSjys3iG4Y4mo1PiaNuXbg=", + "aarch64-darwin": "sha256-AkJwfLULLZVwwz+XU1QcFUZoIS7oVPCn+n/MXEaxrqE=", + "x86_64-darwin": "sha256-hAxKGdiITTxQ2uujQt6prNjo3NxGAMMeo+9HlMWK6GU=" } } From 146720e197866afc2e799462374c42b51e12857b Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:54:35 -0500 Subject: [PATCH 10/16] fix(stats): improve page speed --- packages/stats/app/src/entry-server.tsx | 12 ++++++- .../stats/app/src/routes/compare-cards.tsx | 1 - packages/stats/app/src/routes/index.css | 36 +++++++++++++------ packages/stats/app/src/routes/index.tsx | 30 ++++------------ packages/stats/app/vite.config.ts | 3 +- 5 files changed, 45 insertions(+), 37 deletions(-) diff --git a/packages/stats/app/src/entry-server.tsx b/packages/stats/app/src/entry-server.tsx index 3cec2cba04d..1ea92a46e93 100644 --- a/packages/stats/app/src/entry-server.tsx +++ b/packages/stats/app/src/entry-server.tsx @@ -1,7 +1,10 @@ // @refresh reload +import type { Asset, PageEvent } from "@solidjs/start" import { createHandler, StartServer } from "@solidjs/start/server" +import ibmPlexMonoMediumLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2?url" import { getRequestEvent } from "solid-js/web" import { dir, localeFromRequest, tag } from "./lib/language" +import statsStylesheetUrl from "./routes/index.css?url" const statsThemePreloadScript = `;(function () { var preference = "system" @@ -18,8 +21,13 @@ export default createHandler( () => ( { - const event = getRequestEvent() + const event = getRequestEvent() as PageEvent | undefined const locale = event ? localeFromRequest(event.request) : "en" + const stylesheet = (event?.assets as Asset[] | undefined)?.find( + (asset): asset is Extract => + asset.tag === "link" && asset.attrs.rel === "stylesheet", + ) + const stylesheetHref = import.meta.env.DEV ? statsStylesheetUrl : stylesheet?.attrs.href return ( @@ -27,6 +35,8 @@ export default createHandler( + + {stylesheetHref ? : null} {assets} diff --git a/packages/stats/app/src/routes/compare-cards.tsx b/packages/stats/app/src/routes/compare-cards.tsx index 47cc07b9295..10a10558008 100644 --- a/packages/stats/app/src/routes/compare-cards.tsx +++ b/packages/stats/app/src/routes/compare-cards.tsx @@ -86,7 +86,6 @@ function FeaturedComparisonCard(props: { pair: ComparisonPair }) { diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index c378688490c..39877378a43 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -55,7 +55,7 @@ body { --color-background-strong: #161616; --color-background-strong-hover: #242424; --color-text: #5c5c5c; - --color-text-weak: #808080; + --color-text-weak: #707070; --color-text-strong: #161616; --color-text-inverted: #ffffff; --color-border-weak: #0000001a; @@ -66,10 +66,10 @@ body { --stats-line-strong: #00000033; --stats-text: #161616; --stats-muted: #5c5c5c; - --stats-faint: #808080; + --stats-faint: #707070; --stats-theme-icon-active: #3a3a3a; --stats-accent: #3b5cf6; - --stats-accent-text: #6c7dff; + --stats-accent-text: #3b5cf6; --stats-bar-idle: #d4d4d4; --stats-dot: #d4d4d4; --stats-hero-muted: #5c5c5c; @@ -92,6 +92,18 @@ body { display: none !important; } +[data-page="stats"] [data-slot="visually-hidden"] { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + [data-page="stats"][data-layout="compare-detail"] { /* The table contains its own wide rows; keep the page itself out of the horizontal scroll chain. */ overflow-x: visible; @@ -7079,7 +7091,7 @@ body { --color-background-strong: #ffffff; --color-background-strong-hover: #eeeeee; --color-text: #d4d4d4; - --color-text-weak: #808080; + --color-text-weak: #a3a3a3; --color-text-strong: #ffffff; --color-text-inverted: #161616; --color-border-weak: #ffffff1a; @@ -7090,11 +7102,12 @@ body { --stats-line-strong: #ffffff33; --stats-text: #ffffff; --stats-muted: #d4d4d4; - --stats-faint: #808080; + --stats-faint: #a3a3a3; + --stats-accent-text: #8190ff; --stats-theme-icon-active: #fafafa; --stats-bar-idle: #303030; --stats-dot: #303030; - --stats-hero-muted: #808080; + --stats-hero-muted: #a3a3a3; --stats-hero-pattern: #303030; --stats-logo-bg: #f1ecec; --stats-logo-fill: #b7b1b1; @@ -7254,7 +7267,7 @@ body { --color-background-strong: #ffffff; --color-background-strong-hover: #eeeeee; --color-text: #d4d4d4; - --color-text-weak: #808080; + --color-text-weak: #a3a3a3; --color-text-strong: #ffffff; --color-text-inverted: #161616; --color-border-weak: #ffffff1a; @@ -7265,11 +7278,12 @@ body { --stats-line-strong: #ffffff33; --stats-text: #ffffff; --stats-muted: #d4d4d4; - --stats-faint: #808080; + --stats-faint: #a3a3a3; + --stats-accent-text: #8190ff; --stats-theme-icon-active: #fafafa; --stats-bar-idle: #303030; --stats-dot: #303030; - --stats-hero-muted: #808080; + --stats-hero-muted: #a3a3a3; --stats-hero-pattern: #303030; --stats-logo-bg: #f1ecec; --stats-logo-fill: #b7b1b1; @@ -8393,7 +8407,7 @@ body { } [data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"] { - --top-models-mobile-bar-width: 12px; + --top-models-mobile-bar-width: 18px; } [data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"] [data-slot="top-models-axis"], @@ -8402,7 +8416,7 @@ body { } [data-page="stats"] [data-component="market-share"][data-dense-labels="true"] { - --market-mobile-bar-width: 12px; + --market-mobile-bar-width: 18px; } [data-page="stats"] [data-component="market-share"][data-dense-labels="true"] [data-slot="market-labels"], diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index 28e5cf59a33..398f53f7186 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -1,11 +1,7 @@ -import { Link, Meta, Title } from "@solidjs/meta" +import { Meta, Title } from "@solidjs/meta" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { scaleSqrt } from "d3-scale" import countryCodesSource from "i18n-iso-countries/codes.json?raw" -import ibmPlexMonoRegularLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Regular-Latin1.woff2?url" -import ibmPlexMonoMediumLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2?url" -import ibmPlexMonoSemiBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-SemiBold-Latin1.woff2?url" -import ibmPlexMonoBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Bold-Latin1.woff2?url" import { getStatsHomeData, type CacheRatioEntry, @@ -142,10 +138,6 @@ export default function StatsHome() { - - - - @@ -227,7 +219,8 @@ function Hero(props: { updatedAt: string | null }) { return ( - + + {currentUpdatedLabel()} { - element.scrollLeft = element.scrollWidth - element.clientWidth + element.scrollLeft = Number.MAX_SAFE_INTEGER }) } @@ -735,12 +728,11 @@ function Leaderboard(props: { activeModel: string | undefined onActiveModelChange: (model: string | undefined) => void }) { - const i18n = useI18n() const featured = createMemo(() => props.data.slice(0, 3)) const compact = createMemo(() => props.data.slice(3)) return ( - + {(entry) => ( @@ -766,7 +758,7 @@ function Leaderboard(props: { )} - + {(entry) => ( props.onActiveModelChange(props.entry.model)} onPointerLeave={(event) => { if (event.pointerType === "touch") return @@ -956,6 +945,7 @@ function MarketShare(props: { {(day, index) => ( void }) { - const i18n = useI18n() const language = useLanguage() return ( {(item, index) => { - const label = () => - `${item.author} ${formatTrillions(item.tokens)} ${item.share.toFixed(1)} ${i18n.t("chart.percent")}` const content = () => ( <> {String(index() + 1).padStart(2, "0")} @@ -1060,7 +1047,6 @@ function MarketShareList(props: { fallback={ props.onActiveAuthorChange(item.author)} onFocus={() => props.onActiveAuthorChange(item.author)} > @@ -1071,7 +1057,6 @@ function MarketShareList(props: { {(href) => ( props.onActiveAuthorChange(item.author)} onFocus={() => props.onActiveAuthorChange(item.author)} > @@ -1259,7 +1244,6 @@ function GeoCountryList(props: { type="button" data-active={props.activeCountry === country.country ? "true" : undefined} style={{ "--geo-row-opacity": String(opacityScale()(country.tokens)) } as JSX.CSSProperties} - aria-label={`${formatCountryName(country.country, language.tag(language.locale()), i18n.t("home.unknown"))} ${formatGeoTokens(country.tokens)} ${formatGeoShare(country.share)}`} onClick={() => props.onActiveCountryChange(country.country)} onPointerEnter={() => props.onActiveCountryChange(country.country)} onFocus={() => props.onActiveCountryChange(country.country)} diff --git a/packages/stats/app/vite.config.ts b/packages/stats/app/vite.config.ts index 2c588e82002..de00eb14d27 100644 --- a/packages/stats/app/vite.config.ts +++ b/packages/stats/app/vite.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ allowedHosts: true, }, build: { - minify: false, + minify: "esbuild", + cssMinify: true, }, }) From 04513d96920e1a560352755125bdddd0e64d5c06 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 5 Aug 2026 20:56:57 +0000 Subject: [PATCH 11/16] chore: generate --- packages/stats/app/src/entry-server.tsx | 3 +-- packages/stats/app/src/routes/compare-cards.tsx | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/stats/app/src/entry-server.tsx b/packages/stats/app/src/entry-server.tsx index 1ea92a46e93..c5a90384a76 100644 --- a/packages/stats/app/src/entry-server.tsx +++ b/packages/stats/app/src/entry-server.tsx @@ -24,8 +24,7 @@ export default createHandler( const event = getRequestEvent() as PageEvent | undefined const locale = event ? localeFromRequest(event.request) : "en" const stylesheet = (event?.assets as Asset[] | undefined)?.find( - (asset): asset is Extract => - asset.tag === "link" && asset.attrs.rel === "stylesheet", + (asset): asset is Extract => asset.tag === "link" && asset.attrs.rel === "stylesheet", ) const stylesheetHref = import.meta.env.DEV ? statsStylesheetUrl : stylesheet?.attrs.href diff --git a/packages/stats/app/src/routes/compare-cards.tsx b/packages/stats/app/src/routes/compare-cards.tsx index 10a10558008..24077bc3b9c 100644 --- a/packages/stats/app/src/routes/compare-cards.tsx +++ b/packages/stats/app/src/routes/compare-cards.tsx @@ -83,10 +83,7 @@ export function ComparisonCardsSection(props: { function FeaturedComparisonCard(props: { pair: ComparisonPair }) { return ( - + {props.pair.detail} From ebf6fc07a11d5759e1bd79228a8802dae08d5628 Mon Sep 17 00:00:00 2001 From: opencode Date: Wed, 5 Aug 2026 20:58:52 +0000 Subject: [PATCH 12/16] sync release versions for v1.18.14 --- bun.lock | 56 ++++++++++----------- packages/app/package.json | 2 +- packages/cli/package.json | 2 +- packages/codemode/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/console/support/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/effect-drizzle-sqlite/package.json | 2 +- packages/effect-sqlite-node/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/server/package.json | 2 +- packages/session-ui/package.json | 2 +- packages/slack/package.json | 2 +- packages/stats/app/package.json | 2 +- packages/stats/core/package.json | 2 +- packages/stats/server/package.json | 2 +- packages/tui/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 30 files changed, 57 insertions(+), 57 deletions(-) diff --git a/bun.lock b/bun.lock index 3ba15ef800b..0af8b57e383 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -96,7 +96,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.18.13", + "version": "1.18.14", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -144,7 +144,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -158,7 +158,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -194,7 +194,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -221,7 +221,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -243,7 +243,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -267,7 +267,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -287,7 +287,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.18.13", + "version": "1.18.14", "bin": { "opencode": "./bin/opencode", }, @@ -381,7 +381,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@zip.js/zip.js": "2.7.62", "drizzle-orm": "catalog:", @@ -435,7 +435,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -449,7 +449,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "effect": "catalog:", }, @@ -461,7 +461,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -493,7 +493,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -509,7 +509,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -540,7 +540,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -559,7 +559,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.18.13", + "version": "1.18.14", "bin": { "opencode": "./bin/opencode", }, @@ -690,7 +690,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -766,7 +766,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "cross-spawn": "catalog:", }, @@ -781,7 +781,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -796,7 +796,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", @@ -836,7 +836,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -849,7 +849,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -883,7 +883,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -902,7 +902,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -944,7 +944,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -971,7 +971,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1022,7 +1022,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 1ad643f07a6..01a5199d81b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.18.13", + "version": "1.18.14", "description": "", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 66953ce4a96..7096186d861 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.18.13", + "version": "1.18.14", "type": "module", "license": "MIT", "bin": { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index ee9ccd423c2..d73fb91a236 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.18.13", + "version": "1.18.14", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 6cb9d9dfa66..e4b86b2038d 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.18.13", + "version": "1.18.14", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 21948517f68..295ac740b5d 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.18.13", + "version": "1.18.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index f9e93a0f4ee..2fbdcd8b985 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.18.13", + "version": "1.18.14", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 925f89a2e70..ef958483446 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index 72d10956129..beba54220de 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.18.13", + "version": "1.18.14", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 31ae85dfa56..40cffafd9b8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.13", + "version": "1.18.14", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 22a4a4e6376..5e9ce239f7b 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.18.13", + "version": "1.18.14", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 6061ac103f5..986e4aa9e0d 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.13", + "version": "1.18.14", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index ab44b9add75..2765813abbf 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.13", + "version": "1.18.14", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index d5d71410634..ac78a31b356 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.18.13", + "version": "1.18.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index 94f66edfe56..f422e986213 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.18.13", + "version": "1.18.14", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index f5120021ac2..1e63bf1e56f 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.13", + "version": "1.18.14", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index 50f6cf568f6..60c845ebee4 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.13", + "version": "1.18.14", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ed11f8ca933..4b3a7afbdcf 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.13", + "version": "1.18.14", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 7b46a9ccf39..308f377c190 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.18.13", + "version": "1.18.14", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 007e3f571ac..c4031aa2749 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.18.13", + "version": "1.18.14", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index f7c7a0f3037..1dd07dc4ffe 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.18.13", + "version": "1.18.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 1fbf52314a9..12aa2be8d1e 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "1.18.13", + "version": "1.18.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index af88eb2aec4..ef611c59c2e 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.18.13", + "version": "1.18.14", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index af8f31e739c..3e92a9e3860 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.18.13", + "version": "1.18.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index a1c22694fde..db760fea332 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.18.13", + "version": "1.18.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index 811a597fb61..f598b42ba18 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.18.13", + "version": "1.18.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/package.json b/packages/tui/package.json index 43c9cd64182..6e289497730 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.18.13", + "version": "1.18.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index ae2a57b4362..69d260287fb 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.18.13", + "version": "1.18.14", "type": "module", "license": "MIT", "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index 4f781d1588b..43417f4ef5f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.18.13", + "version": "1.18.14", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 49b3369e244..770f5c25c69 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.18.13", + "version": "1.18.14", "publisher": "sst-dev", "repository": { "type": "git", From 23bbc5cd148b0b05379b4916d952e415b043416c Mon Sep 17 00:00:00 2001 From: mridul <65942753+rexdotsh@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:53:32 +0530 Subject: [PATCH 13/16] fix(server): allow blob attachments in web UI (#40692) --- packages/opencode/src/server/shared/ui.ts | 2 +- packages/opencode/test/server/httpapi-ui.test.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts index c2fd3b86375..d3f2c451131 100644 --- a/packages/opencode/src/server/shared/ui.ts +++ b/packages/opencode/src/server/shared/ui.ts @@ -9,7 +9,7 @@ let embeddedUIPromise: Promise | null> | undefined export const UI_UPSTREAM = new URL("https://app.opencode.ai") export const csp = (hash = "") => - `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src * data:` + `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:` export const DEFAULT_CSP = csp() export function themePreloadHash(body: string) { diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 2f10193f3d4..1a7ccc60dab 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -326,7 +326,7 @@ describe("HttpApi UI fallback", () => { }), ) - it.live("allows embedded UI terminal wasm and theme preload CSP", () => + it.live("allows embedded UI terminal wasm, blob attachments, and theme preload CSP", () => Effect.gen(function* () { const script = 'document.documentElement.dataset.theme = "dark"' @@ -351,7 +351,8 @@ describe("HttpApi UI fallback", () => { const csp = response.headers.get("content-security-policy") ?? "" expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval'") expect(csp).toContain(`'sha256-${createHash("sha256").update(script).digest("base64")}'`) - expect(csp).toContain("connect-src * data:") + expect(csp).toContain("img-src 'self' data: https: blob:") + expect(csp).toContain("connect-src * data: blob:") }), ) From 24470e52a537f7a4d08be681b95b3a1891fc1bfa Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:22:11 +1000 Subject: [PATCH 14/16] fix(desktop): embed version in server sidecar (#40764) --- .github/workflows/publish.yml | 1 + packages/opencode/script/build-node.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1c41a66faa4..c86a1fda135 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -322,6 +322,7 @@ jobs: working-directory: packages/desktop env: NODE_OPTIONS: --max-old-space-size=4096 + OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} diff --git a/packages/opencode/script/build-node.ts b/packages/opencode/script/build-node.ts index e6a4171f70f..d33ba2e4382 100755 --- a/packages/opencode/script/build-node.ts +++ b/packages/opencode/script/build-node.ts @@ -21,6 +21,7 @@ await Bun.build({ external: ["jsonc-parser", "@lydell/node-pty"], define: { OPENCODE_MODELS_DEV: generated.modelsData, + OPENCODE_VERSION: `'${Script.version}'`, OPENCODE_CHANNEL: `'${Script.channel}'`, }, files: { From f1adabcddc2fac6089af5c1e86e21d4dd93db979 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:50:33 +1000 Subject: [PATCH 15/16] feat(app): export session as json from ui (#40781) --- .../session/session-context-tab.tsx | 36 ++++++++++- packages/app/src/i18n/en.ts | 9 +++ .../session/timeline/message-timeline.tsx | 30 +++++++++ .../pages/session/use-session-commands.tsx | 36 ++++++++++- packages/app/src/utils/session-export.test.ts | 61 +++++++++++++++++++ packages/app/src/utils/session-export.ts | 61 +++++++++++++++++++ 6 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/utils/session-export.test.ts create mode 100644 packages/app/src/utils/session-export.ts diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 25ad5ab230c..c08c202fa2e 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -5,12 +5,15 @@ import { checksum } from "@opencode-ai/core/util/encode" import { findLast } from "@opencode-ai/core/util/array" import { same } from "@/utils/same" import { Icon } from "@opencode-ai/ui/icon" +import { Button } from "@opencode-ai/ui/button" import { Accordion } from "@opencode-ai/ui/accordion" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { File } from "@opencode-ai/session-ui/file" import { Markdown } from "@opencode-ai/session-ui/markdown" import { ScrollView } from "@opencode-ai/ui/scroll-view" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" +import { showToast } from "@/utils/toast" +import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { useSDK } from "@/context/sdk" @@ -220,6 +223,31 @@ export function SessionContextTab() { { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) }, ] satisfies { label: string; value: () => JSX.Element }[] + const exportSession = async () => { + const sessionID = params.id + if (!sessionID) return + try { + const data = await fetchSessionExport({ + sessionID, + client: sdk().client, + }) + const filename = sessionExportFilename(data.info) + downloadSessionExport(filename, data) + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("toast.session.export.success.title"), + description: language.t("toast.session.export.success.description", { filename }), + }) + } catch (err) { + showToast({ + variant: "error", + title: language.t("toast.session.export.failed.title"), + description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"), + }) + } + } + let scroll: HTMLDivElement | undefined let frame: number | undefined let pending: { x: number; y: number } | undefined @@ -328,7 +356,13 @@ export function SessionContextTab() { - {language.t("context.rawMessages.title")} + + {language.t("context.rawMessages.title")} + + + {language.t("context.export.session")} + + {(message) => ( diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index c37c9c21161..26720c8ac0d 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -95,6 +95,8 @@ export const dict = { "command.session.share.description": "Share this session and copy the URL to clipboard", "command.session.unshare": "Unshare session", "command.session.unshare.description": "Stop sharing this session", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", "palette.search.placeholder": "Search files, commands, and sessions", "palette.search.placeholder.home": "Search commands and sessions", @@ -489,6 +491,7 @@ export const dict = { "context.systemPrompt.title": "System Prompt", "context.rawMessages.title": "Raw messages", + "context.export.session": "Export session", "context.stats.session": "Session", "context.stats.messages": "Messages", @@ -568,6 +571,11 @@ export const dict = { "toast.session.unshare.failed.title": "Failed to unshare session", "toast.session.unshare.failed.description": "An error occurred while unsharing the session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "toast.session.listFailed.title": "Failed to load sessions for {{project}}", "toast.project.reloadFailed.title": "Failed to reload {{project}}", @@ -802,6 +810,7 @@ export const dict = { "common.moreOptions": "More options", "common.learnMore": "Learn more", "common.rename": "Rename", + "common.export": "Export", "common.reset": "Reset", "common.archive": "Archive", "common.delete": "Delete", diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 7d22ce5e166..e69623179fa 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -53,6 +53,7 @@ import type { UserMessage, } from "@opencode-ai/sdk/v2" import { showToast } from "@/utils/toast" +import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { Popover as KobaltePopover } from "@kobalte/core/popover" import { normalize } from "@opencode-ai/session-ui/session-diff" @@ -806,6 +807,29 @@ export function MessageTimeline(props: { navigate(`/${params.dir}/session`) } + const exportSession = async (sessionID: string) => { + try { + const data = await fetchSessionExport({ + sessionID, + client: sdk().client, + }) + const filename = sessionExportFilename(data.info) + downloadSessionExport(filename, data) + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("toast.session.export.success.title"), + description: language.t("toast.session.export.success.description", { filename }), + }) + } catch (err) { + showToast({ + variant: "error", + title: language.t("toast.session.export.failed.title"), + description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"), + }) + } + } + const archiveSession = async (sessionID: string) => { const session = sync().session.get(sessionID) if (!session) return @@ -1564,6 +1588,9 @@ export function MessageTimeline(props: { + exportSession(id)}> + {language.t("common.export")} + void archiveSession(id)}> {language.t("common.archive")} @@ -1635,6 +1662,9 @@ export function MessageTimeline(props: { {language.t("session.share.action.share")}... + exportSession(id)}> + {language.t("common.export")}... + void archiveSession(id)}> {language.t("common.archive")} diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 12dd96a5e66..cfc302e7753 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -12,10 +12,11 @@ import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { showToast } from "@/utils/toast" +import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" import { findLast } from "@opencode-ai/core/util/array" import { createSessionTabs } from "@/pages/session/helpers" import { extractPromptFromParts } from "@/utils/prompt" -import { UserMessage } from "@opencode-ai/sdk/v2" +import { Message, Part, UserMessage } from "@opencode-ai/sdk/v2" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionOwnership } from "./session-ownership" import { useLocal } from "@/context/local" @@ -231,6 +232,31 @@ export const useSessionCommands = (actions: SessionCommandContext) => { ) } + const exportSession = async () => { + const sessionID = params.id + if (!sessionID) return + try { + const data = await fetchSessionExport({ + sessionID, + client: sdk().client, + }) + const filename = sessionExportFilename(data.info) + downloadSessionExport(filename, data) + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("toast.session.export.success.title"), + description: language.t("toast.session.export.success.description", { filename }), + }) + } catch (err) { + showToast({ + variant: "error", + title: language.t("toast.session.export.failed.title"), + description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"), + }) + } + } + const openFile = () => { void openDialog( () => import("@/components/dialog-select-file"), @@ -458,6 +484,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => { disabled: !params.id || visibleUserMessages().length === 0, onSelect: fork, }), + sessionCommand({ + id: "session.export", + title: language.t("command.session.export"), + description: language.t("command.session.export.description"), + slash: "export", + disabled: !params.id, + onSelect: exportSession, + }), ] const fileCmds = () => { diff --git a/packages/app/src/utils/session-export.test.ts b/packages/app/src/utils/session-export.test.ts new file mode 100644 index 00000000000..ff18a16b7fa --- /dev/null +++ b/packages/app/src/utils/session-export.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { fetchSessionExport, sessionExportFilename } from "./session-export" +import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client" + +describe("sessionExportFilename", () => { + test("generates filename from title", () => { + expect(sessionExportFilename({ id: "ses_123", title: "Clone PR in worktree from fork" })).toBe( + "clone-pr-in-worktree-from-fork.json", + ) + }) + + test("generates filename from slug when title missing", () => { + expect(sessionExportFilename({ id: "ses_123", slug: "my-session-slug" })).toBe("my-session-slug.json") + }) + + test("falls back to id when title and slug are empty", () => { + expect(sessionExportFilename({ id: "ses_123" })).toBe("ses_123.json") + }) +}) + +describe("fetchSessionExport", () => { + test("fetches full transcript from client", async () => { + const session = { id: "ses_1", title: "Test Session" } as Session + const msg = { id: "msg_1", role: "user" } as Message + const part = { id: "prt_1", type: "text", text: "hello" } as Part + const messages = [{ info: msg, parts: [part] }] + + const client = { + session: { + get: async () => ({ data: session }), + messages: async () => ({ data: messages }), + }, + } + + const result = await fetchSessionExport({ + sessionID: "ses_1", + client, + }) + + expect(result).toEqual({ + info: session, + messages, + }) + }) + + test("throws when session not found", async () => { + const client = { + session: { + get: async () => ({ data: null }), + messages: async () => ({ data: [] }), + }, + } + + expect( + fetchSessionExport({ + sessionID: "ses_missing", + client, + }), + ).rejects.toThrow("Session not found: ses_missing") + }) +}) diff --git a/packages/app/src/utils/session-export.ts b/packages/app/src/utils/session-export.ts new file mode 100644 index 00000000000..6eb9f9ab6a7 --- /dev/null +++ b/packages/app/src/utils/session-export.ts @@ -0,0 +1,61 @@ +import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client" + +// Matches the exact `{ info, messages: [{ info, parts }] }` structure produced by `opencode export` CLI +export type SessionExportData = { + info: Session + messages: { + info: Message + parts: Part[] + }[] +} + +export type SessionExportClient = { + session: { + get: (input: { sessionID: string }) => Promise<{ data?: Session | null }> + messages: (input: { sessionID: string }) => Promise<{ data?: SessionExportData["messages"] | null }> + } +} + +export async function fetchSessionExport(input: { + sessionID: string + client: SessionExportClient +}): Promise { + const [sessionRes, messagesRes] = await Promise.all([ + input.client.session.get({ sessionID: input.sessionID }), + input.client.session.messages({ sessionID: input.sessionID }), + ]) + + if (!sessionRes?.data) { + throw new Error(`Session not found: ${input.sessionID}`) + } + if (!messagesRes?.data) { + throw new Error(`Failed to load messages for session: ${input.sessionID}`) + } + + return { + info: sessionRes.data, + messages: messagesRes.data, + } +} + +export function sessionExportFilename(session: { id: string; title?: string; slug?: string }) { + const name = session.title || session.slug || session.id + const clean = name + .toLowerCase() + .replace(/[^a-z0-9_-]+/gi, "-") + .replace(/^-+|-+$/g, "") + return `${clean || session.id}.json` +} + +export function downloadSessionExport(filename: string, data: unknown) { + const json = JSON.stringify(data, null, 2) + const blob = new Blob([json], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} From b8bd88901a4870ef3a5752840f4e23e11d54e24e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 6 Aug 2026 01:52:15 +0000 Subject: [PATCH 16/16] chore: generate --- .../app/src/components/session/session-context-tab.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index c08c202fa2e..2f37c33fb37 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -358,7 +358,12 @@ export function SessionContextTab() { {language.t("context.rawMessages.title")} - + {language.t("context.export.session")}
+
+ {currentUpdatedLabel()} { - element.scrollLeft = element.scrollWidth - element.clientWidth + element.scrollLeft = Number.MAX_SAFE_INTEGER }) } @@ -735,12 +728,11 @@ function Leaderboard(props: { activeModel: string | undefined onActiveModelChange: (model: string | undefined) => void }) { - const i18n = useI18n() const featured = createMemo(() => props.data.slice(0, 3)) const compact = createMemo(() => props.data.slice(3)) return ( - + {(entry) => ( @@ -766,7 +758,7 @@ function Leaderboard(props: { )} - + {(entry) => ( props.onActiveModelChange(props.entry.model)} onPointerLeave={(event) => { if (event.pointerType === "touch") return @@ -956,6 +945,7 @@ function MarketShare(props: { {(day, index) => ( void }) { - const i18n = useI18n() const language = useLanguage() return ( {(item, index) => { - const label = () => - `${item.author} ${formatTrillions(item.tokens)} ${item.share.toFixed(1)} ${i18n.t("chart.percent")}` const content = () => ( <> {String(index() + 1).padStart(2, "0")} @@ -1060,7 +1047,6 @@ function MarketShareList(props: { fallback={ props.onActiveAuthorChange(item.author)} onFocus={() => props.onActiveAuthorChange(item.author)} > @@ -1071,7 +1057,6 @@ function MarketShareList(props: { {(href) => ( props.onActiveAuthorChange(item.author)} onFocus={() => props.onActiveAuthorChange(item.author)} > @@ -1259,7 +1244,6 @@ function GeoCountryList(props: { type="button" data-active={props.activeCountry === country.country ? "true" : undefined} style={{ "--geo-row-opacity": String(opacityScale()(country.tokens)) } as JSX.CSSProperties} - aria-label={`${formatCountryName(country.country, language.tag(language.locale()), i18n.t("home.unknown"))} ${formatGeoTokens(country.tokens)} ${formatGeoShare(country.share)}`} onClick={() => props.onActiveCountryChange(country.country)} onPointerEnter={() => props.onActiveCountryChange(country.country)} onFocus={() => props.onActiveCountryChange(country.country)} diff --git a/packages/stats/app/vite.config.ts b/packages/stats/app/vite.config.ts index 2c588e82002..de00eb14d27 100644 --- a/packages/stats/app/vite.config.ts +++ b/packages/stats/app/vite.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ allowedHosts: true, }, build: { - minify: false, + minify: "esbuild", + cssMinify: true, }, }) From 04513d96920e1a560352755125bdddd0e64d5c06 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 5 Aug 2026 20:56:57 +0000 Subject: [PATCH 11/16] chore: generate --- packages/stats/app/src/entry-server.tsx | 3 +-- packages/stats/app/src/routes/compare-cards.tsx | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/stats/app/src/entry-server.tsx b/packages/stats/app/src/entry-server.tsx index 1ea92a46e93..c5a90384a76 100644 --- a/packages/stats/app/src/entry-server.tsx +++ b/packages/stats/app/src/entry-server.tsx @@ -24,8 +24,7 @@ export default createHandler( const event = getRequestEvent() as PageEvent | undefined const locale = event ? localeFromRequest(event.request) : "en" const stylesheet = (event?.assets as Asset[] | undefined)?.find( - (asset): asset is Extract => - asset.tag === "link" && asset.attrs.rel === "stylesheet", + (asset): asset is Extract => asset.tag === "link" && asset.attrs.rel === "stylesheet", ) const stylesheetHref = import.meta.env.DEV ? statsStylesheetUrl : stylesheet?.attrs.href diff --git a/packages/stats/app/src/routes/compare-cards.tsx b/packages/stats/app/src/routes/compare-cards.tsx index 10a10558008..24077bc3b9c 100644 --- a/packages/stats/app/src/routes/compare-cards.tsx +++ b/packages/stats/app/src/routes/compare-cards.tsx @@ -83,10 +83,7 @@ export function ComparisonCardsSection(props: { function FeaturedComparisonCard(props: { pair: ComparisonPair }) { return ( - + {props.pair.detail} From ebf6fc07a11d5759e1bd79228a8802dae08d5628 Mon Sep 17 00:00:00 2001 From: opencode Date: Wed, 5 Aug 2026 20:58:52 +0000 Subject: [PATCH 12/16] sync release versions for v1.18.14 --- bun.lock | 56 ++++++++++----------- packages/app/package.json | 2 +- packages/cli/package.json | 2 +- packages/codemode/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/console/support/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/effect-drizzle-sqlite/package.json | 2 +- packages/effect-sqlite-node/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/server/package.json | 2 +- packages/session-ui/package.json | 2 +- packages/slack/package.json | 2 +- packages/stats/app/package.json | 2 +- packages/stats/core/package.json | 2 +- packages/stats/server/package.json | 2 +- packages/tui/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 30 files changed, 57 insertions(+), 57 deletions(-) diff --git a/bun.lock b/bun.lock index 3ba15ef800b..0af8b57e383 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -96,7 +96,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.18.13", + "version": "1.18.14", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -144,7 +144,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -158,7 +158,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -194,7 +194,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -221,7 +221,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -243,7 +243,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -267,7 +267,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -287,7 +287,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.18.13", + "version": "1.18.14", "bin": { "opencode": "./bin/opencode", }, @@ -381,7 +381,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@zip.js/zip.js": "2.7.62", "drizzle-orm": "catalog:", @@ -435,7 +435,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -449,7 +449,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "effect": "catalog:", }, @@ -461,7 +461,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -493,7 +493,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -509,7 +509,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -540,7 +540,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -559,7 +559,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.18.13", + "version": "1.18.14", "bin": { "opencode": "./bin/opencode", }, @@ -690,7 +690,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -766,7 +766,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "cross-spawn": "catalog:", }, @@ -781,7 +781,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -796,7 +796,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", @@ -836,7 +836,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -849,7 +849,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -883,7 +883,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -902,7 +902,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -944,7 +944,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -971,7 +971,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1022,7 +1022,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 1ad643f07a6..01a5199d81b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.18.13", + "version": "1.18.14", "description": "", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 66953ce4a96..7096186d861 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.18.13", + "version": "1.18.14", "type": "module", "license": "MIT", "bin": { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index ee9ccd423c2..d73fb91a236 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.18.13", + "version": "1.18.14", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 6cb9d9dfa66..e4b86b2038d 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.18.13", + "version": "1.18.14", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 21948517f68..295ac740b5d 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.18.13", + "version": "1.18.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index f9e93a0f4ee..2fbdcd8b985 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.18.13", + "version": "1.18.14", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 925f89a2e70..ef958483446 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.18.13", + "version": "1.18.14", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index 72d10956129..beba54220de 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.18.13", + "version": "1.18.14", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 31ae85dfa56..40cffafd9b8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.13", + "version": "1.18.14", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 22a4a4e6376..5e9ce239f7b 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.18.13", + "version": "1.18.14", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 6061ac103f5..986e4aa9e0d 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.13", + "version": "1.18.14", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index ab44b9add75..2765813abbf 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.13", + "version": "1.18.14", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index d5d71410634..ac78a31b356 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.18.13", + "version": "1.18.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index 94f66edfe56..f422e986213 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.18.13", + "version": "1.18.14", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index f5120021ac2..1e63bf1e56f 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.13", + "version": "1.18.14", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index 50f6cf568f6..60c845ebee4 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.13", + "version": "1.18.14", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ed11f8ca933..4b3a7afbdcf 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.13", + "version": "1.18.14", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 7b46a9ccf39..308f377c190 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.18.13", + "version": "1.18.14", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 007e3f571ac..c4031aa2749 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.18.13", + "version": "1.18.14", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index f7c7a0f3037..1dd07dc4ffe 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.18.13", + "version": "1.18.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 1fbf52314a9..12aa2be8d1e 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "1.18.13", + "version": "1.18.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index af88eb2aec4..ef611c59c2e 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.18.13", + "version": "1.18.14", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index af8f31e739c..3e92a9e3860 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.18.13", + "version": "1.18.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index a1c22694fde..db760fea332 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.18.13", + "version": "1.18.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index 811a597fb61..f598b42ba18 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.18.13", + "version": "1.18.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/package.json b/packages/tui/package.json index 43c9cd64182..6e289497730 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.18.13", + "version": "1.18.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index ae2a57b4362..69d260287fb 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.18.13", + "version": "1.18.14", "type": "module", "license": "MIT", "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index 4f781d1588b..43417f4ef5f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.18.13", + "version": "1.18.14", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 49b3369e244..770f5c25c69 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.18.13", + "version": "1.18.14", "publisher": "sst-dev", "repository": { "type": "git", From 23bbc5cd148b0b05379b4916d952e415b043416c Mon Sep 17 00:00:00 2001 From: mridul <65942753+rexdotsh@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:53:32 +0530 Subject: [PATCH 13/16] fix(server): allow blob attachments in web UI (#40692) --- packages/opencode/src/server/shared/ui.ts | 2 +- packages/opencode/test/server/httpapi-ui.test.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts index c2fd3b86375..d3f2c451131 100644 --- a/packages/opencode/src/server/shared/ui.ts +++ b/packages/opencode/src/server/shared/ui.ts @@ -9,7 +9,7 @@ let embeddedUIPromise: Promise | null> | undefined export const UI_UPSTREAM = new URL("https://app.opencode.ai") export const csp = (hash = "") => - `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src * data:` + `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:` export const DEFAULT_CSP = csp() export function themePreloadHash(body: string) { diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 2f10193f3d4..1a7ccc60dab 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -326,7 +326,7 @@ describe("HttpApi UI fallback", () => { }), ) - it.live("allows embedded UI terminal wasm and theme preload CSP", () => + it.live("allows embedded UI terminal wasm, blob attachments, and theme preload CSP", () => Effect.gen(function* () { const script = 'document.documentElement.dataset.theme = "dark"' @@ -351,7 +351,8 @@ describe("HttpApi UI fallback", () => { const csp = response.headers.get("content-security-policy") ?? "" expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval'") expect(csp).toContain(`'sha256-${createHash("sha256").update(script).digest("base64")}'`) - expect(csp).toContain("connect-src * data:") + expect(csp).toContain("img-src 'self' data: https: blob:") + expect(csp).toContain("connect-src * data: blob:") }), ) From 24470e52a537f7a4d08be681b95b3a1891fc1bfa Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:22:11 +1000 Subject: [PATCH 14/16] fix(desktop): embed version in server sidecar (#40764) --- .github/workflows/publish.yml | 1 + packages/opencode/script/build-node.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1c41a66faa4..c86a1fda135 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -322,6 +322,7 @@ jobs: working-directory: packages/desktop env: NODE_OPTIONS: --max-old-space-size=4096 + OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} diff --git a/packages/opencode/script/build-node.ts b/packages/opencode/script/build-node.ts index e6a4171f70f..d33ba2e4382 100755 --- a/packages/opencode/script/build-node.ts +++ b/packages/opencode/script/build-node.ts @@ -21,6 +21,7 @@ await Bun.build({ external: ["jsonc-parser", "@lydell/node-pty"], define: { OPENCODE_MODELS_DEV: generated.modelsData, + OPENCODE_VERSION: `'${Script.version}'`, OPENCODE_CHANNEL: `'${Script.channel}'`, }, files: { From f1adabcddc2fac6089af5c1e86e21d4dd93db979 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:50:33 +1000 Subject: [PATCH 15/16] feat(app): export session as json from ui (#40781) --- .../session/session-context-tab.tsx | 36 ++++++++++- packages/app/src/i18n/en.ts | 9 +++ .../session/timeline/message-timeline.tsx | 30 +++++++++ .../pages/session/use-session-commands.tsx | 36 ++++++++++- packages/app/src/utils/session-export.test.ts | 61 +++++++++++++++++++ packages/app/src/utils/session-export.ts | 61 +++++++++++++++++++ 6 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/utils/session-export.test.ts create mode 100644 packages/app/src/utils/session-export.ts diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 25ad5ab230c..c08c202fa2e 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -5,12 +5,15 @@ import { checksum } from "@opencode-ai/core/util/encode" import { findLast } from "@opencode-ai/core/util/array" import { same } from "@/utils/same" import { Icon } from "@opencode-ai/ui/icon" +import { Button } from "@opencode-ai/ui/button" import { Accordion } from "@opencode-ai/ui/accordion" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { File } from "@opencode-ai/session-ui/file" import { Markdown } from "@opencode-ai/session-ui/markdown" import { ScrollView } from "@opencode-ai/ui/scroll-view" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" +import { showToast } from "@/utils/toast" +import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { useSDK } from "@/context/sdk" @@ -220,6 +223,31 @@ export function SessionContextTab() { { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) }, ] satisfies { label: string; value: () => JSX.Element }[] + const exportSession = async () => { + const sessionID = params.id + if (!sessionID) return + try { + const data = await fetchSessionExport({ + sessionID, + client: sdk().client, + }) + const filename = sessionExportFilename(data.info) + downloadSessionExport(filename, data) + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("toast.session.export.success.title"), + description: language.t("toast.session.export.success.description", { filename }), + }) + } catch (err) { + showToast({ + variant: "error", + title: language.t("toast.session.export.failed.title"), + description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"), + }) + } + } + let scroll: HTMLDivElement | undefined let frame: number | undefined let pending: { x: number; y: number } | undefined @@ -328,7 +356,13 @@ export function SessionContextTab() { - {language.t("context.rawMessages.title")} + + {language.t("context.rawMessages.title")} + + + {language.t("context.export.session")} + + {(message) => ( diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index c37c9c21161..26720c8ac0d 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -95,6 +95,8 @@ export const dict = { "command.session.share.description": "Share this session and copy the URL to clipboard", "command.session.unshare": "Unshare session", "command.session.unshare.description": "Stop sharing this session", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", "palette.search.placeholder": "Search files, commands, and sessions", "palette.search.placeholder.home": "Search commands and sessions", @@ -489,6 +491,7 @@ export const dict = { "context.systemPrompt.title": "System Prompt", "context.rawMessages.title": "Raw messages", + "context.export.session": "Export session", "context.stats.session": "Session", "context.stats.messages": "Messages", @@ -568,6 +571,11 @@ export const dict = { "toast.session.unshare.failed.title": "Failed to unshare session", "toast.session.unshare.failed.description": "An error occurred while unsharing the session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "toast.session.listFailed.title": "Failed to load sessions for {{project}}", "toast.project.reloadFailed.title": "Failed to reload {{project}}", @@ -802,6 +810,7 @@ export const dict = { "common.moreOptions": "More options", "common.learnMore": "Learn more", "common.rename": "Rename", + "common.export": "Export", "common.reset": "Reset", "common.archive": "Archive", "common.delete": "Delete", diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 7d22ce5e166..e69623179fa 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -53,6 +53,7 @@ import type { UserMessage, } from "@opencode-ai/sdk/v2" import { showToast } from "@/utils/toast" +import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { Popover as KobaltePopover } from "@kobalte/core/popover" import { normalize } from "@opencode-ai/session-ui/session-diff" @@ -806,6 +807,29 @@ export function MessageTimeline(props: { navigate(`/${params.dir}/session`) } + const exportSession = async (sessionID: string) => { + try { + const data = await fetchSessionExport({ + sessionID, + client: sdk().client, + }) + const filename = sessionExportFilename(data.info) + downloadSessionExport(filename, data) + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("toast.session.export.success.title"), + description: language.t("toast.session.export.success.description", { filename }), + }) + } catch (err) { + showToast({ + variant: "error", + title: language.t("toast.session.export.failed.title"), + description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"), + }) + } + } + const archiveSession = async (sessionID: string) => { const session = sync().session.get(sessionID) if (!session) return @@ -1564,6 +1588,9 @@ export function MessageTimeline(props: { + exportSession(id)}> + {language.t("common.export")} + void archiveSession(id)}> {language.t("common.archive")} @@ -1635,6 +1662,9 @@ export function MessageTimeline(props: { {language.t("session.share.action.share")}... + exportSession(id)}> + {language.t("common.export")}... + void archiveSession(id)}> {language.t("common.archive")} diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 12dd96a5e66..cfc302e7753 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -12,10 +12,11 @@ import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { showToast } from "@/utils/toast" +import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" import { findLast } from "@opencode-ai/core/util/array" import { createSessionTabs } from "@/pages/session/helpers" import { extractPromptFromParts } from "@/utils/prompt" -import { UserMessage } from "@opencode-ai/sdk/v2" +import { Message, Part, UserMessage } from "@opencode-ai/sdk/v2" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionOwnership } from "./session-ownership" import { useLocal } from "@/context/local" @@ -231,6 +232,31 @@ export const useSessionCommands = (actions: SessionCommandContext) => { ) } + const exportSession = async () => { + const sessionID = params.id + if (!sessionID) return + try { + const data = await fetchSessionExport({ + sessionID, + client: sdk().client, + }) + const filename = sessionExportFilename(data.info) + downloadSessionExport(filename, data) + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("toast.session.export.success.title"), + description: language.t("toast.session.export.success.description", { filename }), + }) + } catch (err) { + showToast({ + variant: "error", + title: language.t("toast.session.export.failed.title"), + description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"), + }) + } + } + const openFile = () => { void openDialog( () => import("@/components/dialog-select-file"), @@ -458,6 +484,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => { disabled: !params.id || visibleUserMessages().length === 0, onSelect: fork, }), + sessionCommand({ + id: "session.export", + title: language.t("command.session.export"), + description: language.t("command.session.export.description"), + slash: "export", + disabled: !params.id, + onSelect: exportSession, + }), ] const fileCmds = () => { diff --git a/packages/app/src/utils/session-export.test.ts b/packages/app/src/utils/session-export.test.ts new file mode 100644 index 00000000000..ff18a16b7fa --- /dev/null +++ b/packages/app/src/utils/session-export.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { fetchSessionExport, sessionExportFilename } from "./session-export" +import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client" + +describe("sessionExportFilename", () => { + test("generates filename from title", () => { + expect(sessionExportFilename({ id: "ses_123", title: "Clone PR in worktree from fork" })).toBe( + "clone-pr-in-worktree-from-fork.json", + ) + }) + + test("generates filename from slug when title missing", () => { + expect(sessionExportFilename({ id: "ses_123", slug: "my-session-slug" })).toBe("my-session-slug.json") + }) + + test("falls back to id when title and slug are empty", () => { + expect(sessionExportFilename({ id: "ses_123" })).toBe("ses_123.json") + }) +}) + +describe("fetchSessionExport", () => { + test("fetches full transcript from client", async () => { + const session = { id: "ses_1", title: "Test Session" } as Session + const msg = { id: "msg_1", role: "user" } as Message + const part = { id: "prt_1", type: "text", text: "hello" } as Part + const messages = [{ info: msg, parts: [part] }] + + const client = { + session: { + get: async () => ({ data: session }), + messages: async () => ({ data: messages }), + }, + } + + const result = await fetchSessionExport({ + sessionID: "ses_1", + client, + }) + + expect(result).toEqual({ + info: session, + messages, + }) + }) + + test("throws when session not found", async () => { + const client = { + session: { + get: async () => ({ data: null }), + messages: async () => ({ data: [] }), + }, + } + + expect( + fetchSessionExport({ + sessionID: "ses_missing", + client, + }), + ).rejects.toThrow("Session not found: ses_missing") + }) +}) diff --git a/packages/app/src/utils/session-export.ts b/packages/app/src/utils/session-export.ts new file mode 100644 index 00000000000..6eb9f9ab6a7 --- /dev/null +++ b/packages/app/src/utils/session-export.ts @@ -0,0 +1,61 @@ +import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client" + +// Matches the exact `{ info, messages: [{ info, parts }] }` structure produced by `opencode export` CLI +export type SessionExportData = { + info: Session + messages: { + info: Message + parts: Part[] + }[] +} + +export type SessionExportClient = { + session: { + get: (input: { sessionID: string }) => Promise<{ data?: Session | null }> + messages: (input: { sessionID: string }) => Promise<{ data?: SessionExportData["messages"] | null }> + } +} + +export async function fetchSessionExport(input: { + sessionID: string + client: SessionExportClient +}): Promise { + const [sessionRes, messagesRes] = await Promise.all([ + input.client.session.get({ sessionID: input.sessionID }), + input.client.session.messages({ sessionID: input.sessionID }), + ]) + + if (!sessionRes?.data) { + throw new Error(`Session not found: ${input.sessionID}`) + } + if (!messagesRes?.data) { + throw new Error(`Failed to load messages for session: ${input.sessionID}`) + } + + return { + info: sessionRes.data, + messages: messagesRes.data, + } +} + +export function sessionExportFilename(session: { id: string; title?: string; slug?: string }) { + const name = session.title || session.slug || session.id + const clean = name + .toLowerCase() + .replace(/[^a-z0-9_-]+/gi, "-") + .replace(/^-+|-+$/g, "") + return `${clean || session.id}.json` +} + +export function downloadSessionExport(filename: string, data: unknown) { + const json = JSON.stringify(data, null, 2) + const blob = new Blob([json], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} From b8bd88901a4870ef3a5752840f4e23e11d54e24e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 6 Aug 2026 01:52:15 +0000 Subject: [PATCH 16/16] chore: generate --- .../app/src/components/session/session-context-tab.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index c08c202fa2e..2f37c33fb37 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -358,7 +358,12 @@ export function SessionContextTab() { {language.t("context.rawMessages.title")} - + {language.t("context.export.session")}