Compare commits

..

11 Commits

Author SHA1 Message Date
Aiden Cline a1d203ca04 refactor(opencode): serialize compaction messages 2026-08-06 04:00:27 +00:00
Aiden Cline 6819949da9 fix(opencode): serialize orphaned compaction history 2026-08-06 03:55:01 +00:00
opencode-agent[bot] b8bd88901a chore: generate 2026-08-06 01:52:15 +00:00
Luke Parker f1adabcddc feat(app): export session as json from ui (#40781) 2026-08-06 11:50:33 +10:00
Luke Parker 24470e52a5 fix(desktop): embed version in server sidecar (#40764) 2026-08-06 07:22:11 +08:00
mridul 23bbc5cd14 fix(server): allow blob attachments in web UI (#40692) 2026-08-05 22:23:32 +00:00
opencode ebf6fc07a1 sync release versions for v1.18.14 2026-08-05 20:58:52 +00:00
opencode-agent[bot] 04513d9692 chore: generate 2026-08-05 20:56:57 +00:00
Adam 146720e197 fix(stats): improve page speed 2026-08-05 15:54:35 -05:00
opencode-agent[bot] b1f8cc04af chore: update nix node_modules hashes 2026-08-05 20:33:25 +00:00
opencode-agent[bot] 082fe93e16 chore: generate 2026-08-05 20:16:40 +00:00
19 changed files with 411 additions and 62 deletions
+1
View File
@@ -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 }}
+4 -4
View File
@@ -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="
}
}
@@ -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,18 @@ export function SessionContextTab() {
</Show>
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<div class="flex items-center justify-between">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<Button
size="small"
variant="ghost"
class="gap-1.5 px-2 text-text-weak hover:text-text-base"
onClick={exportSession}
>
<Icon name="download" size="small" />
<span>{language.t("context.export.session")}</span>
</Button>
</div>
<Accordion multiple>
<For each={messages()}>
{(message) => (
+9
View File
@@ -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",
@@ -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: {
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => exportSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.export")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
@@ -1635,6 +1662,9 @@ export function MessageTimeline(props: {
{language.t("session.share.action.share")}...
</MenuV2.Item>
</Show>
<MenuV2.Item onSelect={() => exportSession(id)}>
{language.t("common.export")}...
</MenuV2.Item>
<MenuV2.Item onSelect={() => void archiveSession(id)}>
{language.t("common.archive")}
</MenuV2.Item>
@@ -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 = () => {
@@ -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")
})
})
+61
View File
@@ -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<SessionExportData> {
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)
}
+1
View File
@@ -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: {
+1 -1
View File
@@ -9,7 +9,7 @@ let embeddedUIPromise: Promise<Record<string, string> | 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) {
+45 -6
View File
@@ -49,6 +49,42 @@ type CompletedCompaction = {
summary: string | undefined
}
const truncate = (value: string) =>
value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
const serialize = (message: SessionV1.WithParts) => {
if (message.info.role === "user") {
const text = message.parts
.filter((part): part is SessionV1.TextPart => part.type === "text" && !part.ignored)
.map((part) => part.text)
.filter(Boolean)
.join("\n")
const files = message.parts.flatMap((part) =>
part.type === "file" ? [`[Attached ${part.mime}: ${part.filename ?? "file"}]`] : [],
)
return [...(text ? [`[User]: ${text}`] : []), ...files].join("\n")
}
return message.parts
.flatMap((part) => {
if (part.type === "text") return part.text ? [`[Assistant]: ${part.text}`] : []
if (part.type === "reasoning") return part.text ? [`[Assistant reasoning]: ${part.text}`] : []
if (part.type !== "tool") return []
const call = `[Assistant tool call]: ${part.tool}(${JSON.stringify(part.state.input)})`
if (part.state.status === "completed") {
const attachments = (part.state.attachments ?? []).map(
(item) => `[Attached ${item.mime}: ${item.filename ?? "file"}]`,
)
const output = part.state.time.compacted
? "[Old tool result content cleared]"
: truncate([part.state.output, ...attachments].join("\n"))
return [call, `[Tool result]: ${output}`]
}
if (part.state.status === "error") return [call, `[Tool error]: ${part.state.error}`]
return [call]
})
.join("\n")
}
function summaryText(message: SessionV1.WithParts) {
const text = message.parts
.filter((part): part is SessionV1.TextPart => part.type === "text")
@@ -348,10 +384,7 @@ const layer = Layer.effect(
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
const msgs = structuredClone(selected.head)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
stripMedia: true,
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
})
const conversation = msgs.map(serialize).filter(Boolean).join("\n\n")
const ctx = yield* InstanceState.context
const msg: SessionV1.Assistant = {
id: MessageID.ascending(),
@@ -392,10 +425,16 @@ const layer = Layer.effect(
tools: {},
system: [],
messages: [
...modelMessages,
{
role: "user",
content: [{ type: "text", text: nextPrompt }],
content: [
{
type: "text",
text: [nextPrompt, "The following is the conversation history:", conversation]
.filter(Boolean)
.join("\n\n"),
},
],
},
],
model,
@@ -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:")
}),
)
@@ -1362,10 +1362,10 @@ describe("session.compaction.process", () => {
"summarizes only the head while keeping recent tail out of summary input",
() => {
const stub = llm()
let captured = ""
let messages: LLM.StreamInput["messages"] = []
stub.push(
reply("summary", (input) => {
captured = JSON.stringify(input.messages)
messages = input.messages
}),
)
return Effect.gen(function* () {
@@ -1386,7 +1386,10 @@ describe("session.compaction.process", () => {
auto: false,
})
expect(captured).toContain("older context")
const captured = JSON.stringify(messages)
expect(messages).toHaveLength(1)
expect(messages[0]?.role).toBe("user")
expect(captured).toContain("[User]: older context")
expect(captured).not.toContain("keep this turn")
expect(captured).not.toContain("and this one too")
expect(captured).not.toContain("What did we do so far?")
@@ -1437,6 +1440,74 @@ describe("session.compaction.process", () => {
{ git: true },
)
itCompaction.instance(
"serializes repeated compaction history as one user message",
() => {
const stub = llm()
let captured: LLM.StreamInput["messages"] = []
stub.push(
reply("summary two", (input) => {
captured = input.messages
}),
)
return Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const test = yield* TestInstance
const session = yield* ssn.create({})
const turn = yield* createUserMessage(session.id, "original request")
const kept = yield* createAssistantMessage(session.id, turn.id, test.directory)
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: kept.id,
sessionID: session.id,
type: "tool",
callID: "read-call",
tool: "read",
state: {
status: "completed",
input: { filePath: "src/index.ts" },
output: "file contents",
title: "src/index.ts",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
})
const previous = yield* ssn.updateMessage({
id: MessageID.ascending(),
role: "user",
model: ref,
sessionID: session.id,
agent: "build",
time: { created: Date.now() },
})
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: previous.id,
sessionID: session.id,
type: "compaction",
auto: false,
tail_start_id: kept.id,
})
yield* createSummaryAssistantMessage(session.id, previous.id, test.directory, "summary one")
yield* createCompactionMarker(session.id)
const msgs = MessageV2.filterCompacted(yield* MessageV2.stream(session.id))
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
expect(captured).toHaveLength(1)
expect(captured[0]?.role).toBe("user")
expect(JSON.stringify(captured)).toContain('[Assistant tool call]: read({\\"filePath\\":\\"src/index.ts\\"})')
expect(JSON.stringify(captured)).toContain("[Tool result]: file contents")
expect(JSON.stringify(captured)).not.toContain('\\"role\\":\\"assistant\\"')
}).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 0 }) }))
},
{ git: true },
)
itCompaction.instance("keeps recent pre-compaction turns across repeated compactions", () => {
const stub = llm()
stub.push(reply("summary one"))
@@ -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({})
+10 -1
View File
@@ -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,12 @@ export default createHandler(
() => (
<StartServer
document={({ assets, children, scripts }) => {
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.tag === "link" && asset.attrs.rel === "stylesheet",
)
const stylesheetHref = import.meta.env.DEV ? statsStylesheetUrl : stylesheet?.attrs.href
return (
<html lang={tag(locale)} dir={dir(locale)} data-locale={locale}>
@@ -27,6 +34,8 @@ export default createHandler(
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script id="stats-theme-preload-script">{statsThemePreloadScript}</script>
<link rel="preload" href={ibmPlexMonoMediumLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
{stylesheetHref ? <link rel="stylesheet" href={stylesheetHref} /> : null}
{assets}
</head>
<body>
@@ -83,11 +83,7 @@ export function ComparisonCardsSection(props: {
function FeaturedComparisonCard(props: { pair: ComparisonPair }) {
return (
<a
data-component="compare-home-card"
href={canonicalComparisonHref(props.pair.first, props.pair.second)}
aria-label={`${props.pair.detail}: ${props.pair.first.name} vs ${props.pair.second.name}`}
>
<a data-component="compare-home-card" href={canonicalComparisonHref(props.pair.first, props.pair.second)}>
<span data-slot="compare-home-card-head">
<span>
<strong>{props.pair.detail}</strong>
+25 -11
View File
@@ -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"],
+7 -23
View File
@@ -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() {
<Meta name="twitter:description" content={i18n.t("app.description")} />
<Meta name="twitter:image" content={statsUnfurlUrl} />
<Meta name="twitter:image:alt" content={i18n.t("app.unfurlAlt")} />
<Link rel="preload" href={ibmPlexMonoRegularLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
<Link rel="preload" href={ibmPlexMonoMediumLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
<Link rel="preload" href={ibmPlexMonoSemiBoldLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
<Link rel="preload" href={ibmPlexMonoBoldLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
<Header githubStars={githubStars() ?? githubLink.fallbackStars} />
<div data-component="container">
<div data-component="content">
@@ -227,7 +219,8 @@ function Hero(props: { updatedAt: string | null }) {
return (
<section data-section="hero">
<p data-slot="hero-meta" aria-live="polite" aria-atomic="true" aria-label={currentUpdatedLabel()}>
<p data-slot="hero-meta" role="status" aria-atomic="true">
<span data-slot="visually-hidden">{currentUpdatedLabel()}</span>
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
<path
fill-rule="evenodd"
@@ -696,7 +689,7 @@ function isDenseColumnRange(range: UsageRange) {
function scrollDenseChartToEnd(element: HTMLDivElement | undefined, range: UsageRange, count: number) {
if (!element || count <= 0 || !isDenseColumnRange(range) || typeof window === "undefined") return
window.requestAnimationFrame(() => {
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 (
<div id="leaderboard" data-component="leaderboard" role="list" aria-label={i18n.t("chart.leaderboardAria")}>
<div id="leaderboard" data-component="leaderboard">
<div data-slot="leaderboard-featured">
<For each={featured()}>
{(entry) => (
@@ -766,7 +758,7 @@ function Leaderboard(props: {
)}
</For>
</div>
<div data-slot="leaderboard-mobile" aria-label={i18n.t("chart.scrollableLeaderboardAria")}>
<div data-slot="leaderboard-mobile">
<For each={props.data}>
{(entry) => (
<LeaderboardCard
@@ -798,9 +790,6 @@ function LeaderboardCard(props: {
href={language.route(
`${import.meta.env.BASE_URL}${modelSlug(props.entry.provider)}/${modelSlug(props.entry.model)}`,
)}
role="listitem"
tabIndex={0}
aria-label={`${String(props.entry.rank).padStart(2, "0")} ${props.entry.model} ${i18n.t("chart.byAuthor", { author: props.entry.author })}`}
onPointerEnter={() => props.onActiveModelChange(props.entry.model)}
onPointerLeave={(event) => {
if (event.pointerType === "touch") return
@@ -956,6 +945,7 @@ function MarketShare(props: {
{(day, index) => (
<button
type="button"
aria-label={`${day.date} ${formatTrillions(day.total)}`}
data-active={props.inspecting && props.activeIndex === index() ? "true" : undefined}
data-label-hidden={isColumnLabelHidden(index(), props.data.length) ? "true" : undefined}
data-mobile-hidden={isMarketMobileLabelHidden(index(), props.data.length) ? "true" : undefined}
@@ -1031,14 +1021,11 @@ function MarketShareList(props: {
activeAuthor: string | undefined
onActiveAuthorChange: (author: string) => void
}) {
const i18n = useI18n()
const language = useLanguage()
return (
<ol data-component="market-share-list">
<For each={props.data}>
{(item, index) => {
const label = () =>
`${item.author} ${formatTrillions(item.tokens)} ${item.share.toFixed(1)} ${i18n.t("chart.percent")}`
const content = () => (
<>
<span>{String(index() + 1).padStart(2, "0")}</span>
@@ -1060,7 +1047,6 @@ function MarketShareList(props: {
fallback={
<button
type="button"
aria-label={label()}
onClick={() => props.onActiveAuthorChange(item.author)}
onFocus={() => props.onActiveAuthorChange(item.author)}
>
@@ -1071,7 +1057,6 @@ function MarketShareList(props: {
{(href) => (
<a
href={href()}
aria-label={label()}
onClick={() => 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)}
+2 -1
View File
@@ -18,6 +18,7 @@ export default defineConfig({
allowedHosts: true,
},
build: {
minify: false,
minify: "esbuild",
cssMinify: true,
},
})