Compare commits

..

1 Commits

Author SHA1 Message Date
Luke Parker f9b17d851c feat(app): add global locale coverage 2026-08-07 04:33:06 +00:00
207 changed files with 48120 additions and 921 deletions
+4
View File
@@ -28,6 +28,10 @@
- Prefer complete translated phrases. Do not concatenate grammatical fragments or make call sites assemble sentences. Keep placeholders to irreducible dynamic values such as names, paths, and counts.
- If a translation cannot be expressed by the current API, deepen the shared language/UI i18n module so one typed call owns locale selection, plural resolution, fallback, and interpolation. Do not leak that machinery into product code.
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
- For developer-facing terminology, prefer the words already used by the target language's developer community over literal dictionary translations. Cross-check maintained localized developer products such as Firefox, KDE, and VS Code; use at least two independent corpora when they are available. If established practice keeps an English loanword or acronym, keep it rather than inventing a translation.
- Translate complete UI phrases in context. A glossary hit is evidence, not permission to translate word-by-word. Check terse labels such as session, prompt, agent, model, fork, shell, terminal, workspace, and worktree in the same grammatical role before choosing a term.
- Before a locale is ready, audit recurring concepts for one consistent translation and review every value that still equals English. Classify retained English as a product name, provider/tool name, URL, code token, keyboard legend, acronym, asset name, or established borrowing; translate unexplained leftovers.
- In translation review notes, name the corpora used and call out uncertain or region-specific terminology so native speakers can focus review where it matters.
- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels.
## Tool Calling
@@ -123,8 +123,7 @@ export function SessionContextTab() {
() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
const boundary = userMessages().findIndex((message) => message.id === revert)
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
return userMessages().filter((m) => m.id < revert)
},
emptyUserMessages,
{ equals: same },
@@ -15,12 +15,12 @@ const rootSession = (input: { id: string; parentID?: string; archived?: number }
},
}) as Session
const userMessage = (id: string, sessionID: string, created = 1) =>
const userMessage = (id: string, sessionID: string) =>
({
id,
sessionID,
role: "user",
time: { created },
time: { created: 1 },
agent: "assistant",
model: { providerID: "openai", modelID: "gpt" },
}) as Message
@@ -370,13 +370,13 @@ describe("applyDirectoryEvent", () => {
const sessionID = "ses_1"
const [store, setStore] = createStore(
baseState({
message: { [sessionID]: [userMessage("msg_z", sessionID, 1), userMessage("msg_b", sessionID, 3)] },
part: { msg_a: [textPart("prt_1", sessionID, "msg_a")] },
message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_3", sessionID)] },
part: { msg_2: [textPart("prt_1", sessionID, "msg_2")] },
}),
)
applyDirectoryEvent({
event: { type: "message.updated", properties: { info: userMessage("msg_a", sessionID, 2) } },
event: { type: "message.updated", properties: { info: userMessage("msg_2", sessionID) } },
store,
setStore,
push() {},
@@ -384,14 +384,14 @@ describe("applyDirectoryEvent", () => {
loadLsp() {},
})
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a", "msg_b"])
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2", "msg_3"])
applyDirectoryEvent({
event: {
type: "message.updated",
properties: {
info: {
...userMessage("msg_a", sessionID, 2),
...userMessage("msg_2", sessionID),
role: "assistant",
} as Message,
},
@@ -403,10 +403,10 @@ describe("applyDirectoryEvent", () => {
loadLsp() {},
})
expect(store.message[sessionID]?.find((x) => x.id === "msg_a")?.role).toBe("assistant")
expect(store.message[sessionID]?.find((x) => x.id === "msg_2")?.role).toBe("assistant")
applyDirectoryEvent({
event: { type: "message.removed", properties: { sessionID, messageID: "msg_a" } },
event: { type: "message.removed", properties: { sessionID, messageID: "msg_2" } },
store,
setStore,
push() {},
@@ -414,8 +414,8 @@ describe("applyDirectoryEvent", () => {
loadLsp() {},
})
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_b"])
expect(store.part.msg_a).toBeUndefined()
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_3"])
expect(store.part.msg_2).toBeUndefined()
})
test("upserts and prunes message parts", () => {
@@ -15,7 +15,6 @@ import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
import { messageKey } from "@/utils/session-message"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const SESSION_CONTENT_EVENTS = new Set([
@@ -276,7 +275,7 @@ export function applyDirectoryEvent(input: {
input.setStore("message", info.sessionID, [info])
break
}
const result = Binary.search(messages, messageKey(info), messageKey)
const result = Binary.search(messages, info.id, (m) => m.id)
if (result.found) {
input.setStore("message", info.sessionID, result.index, reconcile(info))
break
@@ -296,8 +295,8 @@ export function applyDirectoryEvent(input: {
produce((draft) => {
const messages = draft.message[props.sessionID]
if (messages) {
const index = messages.findIndex((message) => message.id === props.messageID)
if (index >= 0) messages.splice(index, 1)
const result = Binary.search(messages, props.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
}
const parts = draft.part[props.messageID]
if (parts) {
@@ -323,7 +322,7 @@ export function applyDirectoryEvent(input: {
input.setStore("part", part.messageID, [part])
break
}
const result = Binary.search(parts, part.id, (item) => item.id)
const result = Binary.search(parts, part.id, (p) => p.id)
if (result.found) {
input.setStore("part", part.messageID, result.index, reconcile(part))
break
@@ -346,13 +345,13 @@ export function applyDirectoryEvent(input: {
)
const parts = input.store.part[props.messageID]
if (!parts) break
const result = Binary.search(parts, props.partID, (part) => part.id)
const result = Binary.search(parts, props.partID, (p) => p.id)
if (result.found) {
input.setStore(
produce((draft) => {
const list = draft.part[props.messageID]
if (!list) return
const next = Binary.search(list, props.partID, (part) => part.id)
const next = Binary.search(list, props.partID, (p) => p.id)
if (!next.found) return
list.splice(next.index, 1)
if (list.length === 0) delete draft.part[props.messageID]
@@ -365,7 +364,7 @@ export function applyDirectoryEvent(input: {
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
const parts = input.store.part[props.messageID]
if (!parts) break
const result = Binary.search(parts, props.partID, (part) => part.id)
const result = Binary.search(parts, props.partID, (p) => p.id)
if (!result.found) break
const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field]
+51 -88
View File
@@ -8,9 +8,11 @@ import { dict as en } from "@/i18n/en"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
import {
createDesktopNativeBundle,
detectDesktopNativeLocale,
DESKTOP_NATIVE_ENGLISH,
DESKTOP_NATIVE_LABELS,
DESKTOP_NATIVE_LOCALES,
DESKTOP_NATIVE_LOCALE_TAGS,
type DesktopNativeBundle,
type DesktopNativeLocale,
} from "@/i18n/desktop-native"
@@ -18,7 +20,7 @@ import {
export type Locale = DesktopNativeLocale
export type Direction = "ltr" | "rtl"
const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "ur", "pa"])
const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "ur", "pa", "fa", "dv"])
function localeDirection(locale: Locale): Direction {
return RTL_LOCALES.has(locale) ? "rtl" : "ltr"
@@ -39,36 +41,7 @@ function cookie(locale: Locale) {
const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES
const INTL: Record<Locale, string> = {
en: "en",
zh: "zh-Hans",
zht: "zh-Hant",
ko: "ko",
de: "de",
es: "es",
fr: "fr",
da: "da",
ja: "ja",
pl: "pl",
ru: "ru",
uk: "uk",
ar: "ar",
no: "nb-NO",
br: "pt-BR",
th: "th",
bs: "bs",
tr: "tr",
hi: "hi-IN",
nl: "nl-NL",
id: "id-ID",
vi: "vi-VN",
it: "it-IT",
ur: "ur-PK",
pa: "pa-Arab-PK",
az: "az-Latn-AZ",
fi: "fi-FI",
sv: "sv-SE",
}
const INTL = DESKTOP_NATIVE_LOCALE_TAGS
const base = i18n.flatten({ ...en, ...uiEn })
const dicts = new Map<Locale, Dictionary>([["en", base]])
@@ -104,6 +77,40 @@ const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
az: () => merge(import("@/i18n/az"), import("@opencode-ai/ui/i18n/az")),
fi: () => merge(import("@/i18n/fi"), import("@opencode-ai/ui/i18n/fi")),
sv: () => merge(import("@/i18n/sv"), import("@opencode-ai/ui/i18n/sv")),
am: () => merge(import("@/i18n/am"), import("@opencode-ai/ui/i18n/am")),
bg: () => merge(import("@/i18n/bg"), import("@opencode-ai/ui/i18n/bg")),
bn: () => merge(import("@/i18n/bn"), import("@opencode-ai/ui/i18n/bn")),
ca: () => merge(import("@/i18n/ca"), import("@opencode-ai/ui/i18n/ca")),
cs: () => merge(import("@/i18n/cs"), import("@opencode-ai/ui/i18n/cs")),
dv: () => merge(import("@/i18n/dv"), import("@opencode-ai/ui/i18n/dv")),
dz: () => merge(import("@/i18n/dz"), import("@opencode-ai/ui/i18n/dz")),
el: () => merge(import("@/i18n/el"), import("@opencode-ai/ui/i18n/el")),
et: () => merge(import("@/i18n/et"), import("@opencode-ai/ui/i18n/et")),
fa: () => merge(import("@/i18n/fa"), import("@opencode-ai/ui/i18n/fa")),
fo: () => merge(import("@/i18n/fo"), import("@opencode-ai/ui/i18n/fo")),
hr: () => merge(import("@/i18n/hr"), import("@opencode-ai/ui/i18n/hr")),
hu: () => merge(import("@/i18n/hu"), import("@opencode-ai/ui/i18n/hu")),
hy: () => merge(import("@/i18n/hy"), import("@opencode-ai/ui/i18n/hy")),
is: () => merge(import("@/i18n/is"), import("@opencode-ai/ui/i18n/is")),
ka: () => merge(import("@/i18n/ka"), import("@opencode-ai/ui/i18n/ka")),
km: () => merge(import("@/i18n/km"), import("@opencode-ai/ui/i18n/km")),
lo: () => merge(import("@/i18n/lo"), import("@opencode-ai/ui/i18n/lo")),
lt: () => merge(import("@/i18n/lt"), import("@opencode-ai/ui/i18n/lt")),
lv: () => merge(import("@/i18n/lv"), import("@opencode-ai/ui/i18n/lv")),
mk: () => merge(import("@/i18n/mk"), import("@opencode-ai/ui/i18n/mk")),
mn: () => merge(import("@/i18n/mn"), import("@opencode-ai/ui/i18n/mn")),
ms: () => merge(import("@/i18n/ms"), import("@opencode-ai/ui/i18n/ms")),
my: () => merge(import("@/i18n/my"), import("@opencode-ai/ui/i18n/my")),
ne: () => merge(import("@/i18n/ne"), import("@opencode-ai/ui/i18n/ne")),
ro: () => merge(import("@/i18n/ro"), import("@opencode-ai/ui/i18n/ro")),
si: () => merge(import("@/i18n/si"), import("@opencode-ai/ui/i18n/si")),
sk: () => merge(import("@/i18n/sk"), import("@opencode-ai/ui/i18n/sk")),
sl: () => merge(import("@/i18n/sl"), import("@opencode-ai/ui/i18n/sl")),
sq: () => merge(import("@/i18n/sq"), import("@opencode-ai/ui/i18n/sq")),
sr: () => merge(import("@/i18n/sr"), import("@opencode-ai/ui/i18n/sr")),
tg: () => merge(import("@/i18n/tg"), import("@opencode-ai/ui/i18n/tg")),
tk: () => merge(import("@/i18n/tk"), import("@opencode-ai/ui/i18n/tk")),
uz: () => merge(import("@/i18n/uz"), import("@opencode-ai/ui/i18n/uz")),
}
function loadDict(locale: Locale) {
@@ -121,63 +128,9 @@ export function loadLocaleDict(locale: Locale) {
return loadDict(locale).then(() => undefined)
}
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
{ locale: "en", match: (language) => language.startsWith("en") },
{
locale: "zht",
match: (language) =>
language.startsWith("zh") &&
(language.includes("hant") || language.includes("-tw") || language.includes("-hk") || language.includes("-mo")),
},
{ locale: "zh", match: (language) => language.startsWith("zh") },
{ locale: "ko", match: (language) => language.startsWith("ko") },
{ locale: "de", match: (language) => language.startsWith("de") },
{ locale: "es", match: (language) => language.startsWith("es") },
{ locale: "fr", match: (language) => language.startsWith("fr") },
{ locale: "da", match: (language) => language.startsWith("da") },
{ locale: "ja", match: (language) => language.startsWith("ja") },
{ locale: "pl", match: (language) => language.startsWith("pl") },
{ locale: "ru", match: (language) => language.startsWith("ru") },
{ locale: "uk", match: (language) => language.startsWith("uk") },
{ locale: "ar", match: (language) => language.startsWith("ar") },
{
locale: "no",
match: (language) => language.startsWith("no") || language.startsWith("nb") || language.startsWith("nn"),
},
{ locale: "br", match: (language) => language.startsWith("pt") },
{ locale: "th", match: (language) => language.startsWith("th") },
{ locale: "bs", match: (language) => language.startsWith("bs") },
{ locale: "tr", match: (language) => language.startsWith("tr") },
{ locale: "hi", match: (language) => language.startsWith("hi") },
{ locale: "nl", match: (language) => language.startsWith("nl") },
{ locale: "id", match: (language) => language.startsWith("id") },
{ locale: "vi", match: (language) => language.startsWith("vi") },
{ locale: "it", match: (language) => language.startsWith("it") },
{ locale: "ur", match: (language) => language.startsWith("ur") },
{
locale: "pa",
match: (language) => language.startsWith("pa") && (language.includes("arab") || language.includes("-pk")),
},
{
locale: "az",
match: (language) => language.startsWith("az") && !language.includes("arab") && !language.includes("cyrl"),
},
{ locale: "fi", match: (language) => language.startsWith("fi") },
{ locale: "sv", match: (language) => language.startsWith("sv") },
]
function detectLocale(): Locale {
if (typeof navigator !== "object") return "en"
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) {
if (!language) continue
const normalized = language.toLowerCase()
const match = localeMatchers.find((entry) => entry.match(normalized))
if (match) return match.locale
}
return "en"
return detectDesktopNativeLocale(navigator.languages?.length ? navigator.languages : [navigator.language])
}
export function normalizeLocale(value: string): Locale {
@@ -198,7 +151,17 @@ function readStoredLocale() {
}
const warm = readStoredLocale() ?? detectLocale()
if (warm !== "en") void loadDict(warm)
const initialLocale =
warm === "en"
? Promise.resolve(warm)
: loadDict(warm).then(
() => warm,
() => "en" as const,
)
export function loadInitialLocale() {
return initialLocale
}
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
name: "Language",
@@ -244,7 +207,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
createEffect(() => {
if (typeof document !== "object") return
const value = locale()
document.documentElement.lang = value
document.documentElement.lang = intl()
document.documentElement.dir = direction()
document.cookie = cookie(value)
})
@@ -264,7 +264,6 @@ describe("server session", () => {
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
})
test("extends a current page to include the user for split assistant turns", async () => {
@@ -1498,7 +1497,7 @@ describe("server session", () => {
await store.sync("child", { force: true })
expect(store.data.message.child).toEqual([older, boundary])
expect(store.data.message.child).toEqual([boundary, older])
})
test("preserves a part update for a message being loaded from history", async () => {
+29 -28
View File
@@ -18,7 +18,7 @@ import { message as cleanMessage } from "@/utils/diffs"
import { sessionNotFoundError } from "@/utils/server-errors"
import { rootSession } from "@/utils/session-route"
import { normalizeSessionInfo } from "@/utils/session"
import { compareMessages, messageKey, normalizeSessionMessages } from "@/utils/session-message"
import { normalizeSessionMessages } from "@/utils/session-message"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
import type { ServerApi } from "@/utils/server"
@@ -26,6 +26,7 @@ import type { ServerApi } from "@/utils/server"
type MessageApi = ServerApi["message"]
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 20
const historyMessagePageSize = 200
@@ -63,7 +64,7 @@ type MessagePage = {
function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] {
return items
.slice()
.sort((a, b) => compareMessages(a.info, b.info))
.sort((a, b) => cmp(a.info.id, b.info.id))
.map((item) => {
if (item.info.role === "user") {
return {
@@ -110,16 +111,17 @@ function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
const part = new Map(page.part.map((item) => [item.id, item.part]))
const observed: { messageID: string; parts: Part[] }[] = []
for (const item of items) {
const result = Binary.search(session, messageKey(item.message), messageKey)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const result = Binary.search(session, item.message.id, (message) => message.id)
if (!result.found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
const confirmed = found ? item.parts.filter((part) => current?.some((value) => value.id === part.id)) : []
if (found) observed.push({ messageID: item.message.id, parts: confirmed })
const confirmed = result.found
? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found)
: []
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
part.set(
item.message.id,
merge(
found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
item.parts.filter((part) => !confirmed.includes(part)),
),
)
@@ -156,7 +158,6 @@ function reconcileFetched<T extends { id: string }>(
retained?: ReadonlySet<string>
removed?: ReadonlySet<string>
preserveUnfetched?: boolean | ((item: T) => boolean)
compare?: (a: T, b: T) => number
} = {},
) {
const result = new Map(fetched.map((item) => [item.id, item]))
@@ -179,8 +180,7 @@ function reconcileFetched<T extends { id: string }>(
if (!item) result.delete(id)
}
for (const id of options.removed ?? emptyIDs) result.delete(id)
const items = [...result.values()]
return options.compare ? items.sort(options.compare) : items
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
}
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
@@ -413,7 +413,8 @@ export function createServerSession(
if (!load) return
// A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata.
const messages = data.message[sessionID]
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
const parts = load.touchedParts.get(messageID)
if (parts) {
parts.add(partID)
@@ -436,14 +437,16 @@ export function createServerSession(
load.touchedParts.set(messageID, new Set(parts))
load.carriedDeltaParts.set(messageID, new Set(parts))
const messages = data.message[sessionID]
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
}
for (const [messageID, parts] of load.removedParts) {
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched)
const messages = data.message[sessionID]
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
}
for (const [messageID, parts] of load.optimisticParts) {
load.removedMessages.delete(messageID)
@@ -552,7 +555,7 @@ export function createServerSession(
const source = pages.flatMap((page) => page.data).toReversed()
const normalized = normalizeSessionMessages(sessionID, source)
return {
session: normalized.messages.sort(compareMessages),
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
part: [...normalized.parts.entries()]
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)),
@@ -569,7 +572,7 @@ export function createServerSession(
})
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
return {
session: items.map((item) => cleanMessage(item.info)).sort(compareMessages),
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
part: items.map((item) => ({
id: item.info.id,
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
@@ -693,7 +696,7 @@ export function createServerSession(
const normalized = normalizeSessionMessages(sessionID, source)
return {
...page,
session: normalized.messages.sort(compareMessages),
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
part: [...normalized.parts.entries()]
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)),
@@ -710,7 +713,6 @@ export function createServerSession(
retained: load?.retainedMessages,
removed: load?.removedMessages,
preserveUnfetched,
compare: compareMessages,
})
batch(() => {
if (source) setData("session_message", sessionID, reconcile(source))
@@ -752,7 +754,7 @@ export function createServerSession(
try {
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
const first = page.session.reduce<Message | undefined>(
(oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest),
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
undefined,
)
if (generations.get(sessionID) !== active) return
@@ -802,15 +804,14 @@ export function createServerSession(
session: merge(
page.session,
parents.map((parent) => parent.message),
).sort(compareMessages),
),
part: merge(
page.part,
parents.map((parent) => ({ id: parent.message.id, part: parent.parts })),
),
}
const preserveUnfetched =
mode === "prepend" ||
(!result.complete && (!first || ((message: Message) => compareMessages(message, first) < 0)))
mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
applyMessagePage(
sessionID,
result,
@@ -927,7 +928,7 @@ export function createServerSession(
.message({ sessionID, messageID })
.then((message) => {
const current = data.session_message[sessionID] ?? []
const messages = [...current.filter((item) => item.id !== message.id), message].sort(compareMessages)
const messages = [...current.filter((item) => item.id !== message.id), message].sort((a, b) => cmp(a.id, b.id))
projectV2({ sessionID, messages, touched: [message.id] })
})
.catch(() => {})
@@ -1050,7 +1051,7 @@ export function createServerSession(
setData("message", info.sessionID, [info])
return
}
const result = Binary.search(messages, messageKey(info), messageKey)
const result = Binary.search(messages, info.id, (message) => message.id)
if (result.found) setData("message", info.sessionID, result.index, reconcile(info))
if (!result.found)
setData("message", info.sessionID, (value = []) => {
@@ -1083,8 +1084,8 @@ export function createServerSession(
produce((draft) => {
const messages = draft.message[props.sessionID]
if (messages) {
const index = messages.findIndex((message) => message.id === props.messageID)
if (index >= 0) messages.splice(index, 1)
const result = Binary.search(messages, props.messageID, (message) => message.id)
if (result.found) messages.splice(result.index, 1)
}
deleteMessageParts(draft, props.messageID)
}),
@@ -1096,7 +1097,7 @@ export function createServerSession(
if (SKIP_PARTS.has(part.type)) return
const messages = data.message[part.sessionID]
const load = messageLoads.get(part.sessionID)
const missing = !messages?.some((message) => message.id === part.messageID)
const missing = !messages || !Binary.search(messages, part.messageID, (message) => message.id).found
// Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan.
if (
missing &&
@@ -1340,7 +1341,7 @@ export function createServerSession(
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
if (!items)
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
setData(
"part_text_accum_delta",
produce((draft) => {
@@ -4,11 +4,11 @@ import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "
type Text = Extract<Part, { type: "text" }>
const userMessage = (id: string, sessionID: string, created = 1): Message => ({
const userMessage = (id: string, sessionID: string): Message => ({
id,
sessionID,
role: "user",
time: { created },
time: { created: 1 },
agent: "assistant",
model: { providerID: "openai", modelID: "gpt" },
})
@@ -22,21 +22,21 @@ const textPart = (id: string, sessionID: string, messageID: string): Text => ({
})
describe("sync optimistic reducers", () => {
test("applyOptimisticAdd inserts by creation time", () => {
test("applyOptimisticAdd inserts message in sorted order and stores parts", () => {
const sessionID = "ses_1"
const draft = {
message: { [sessionID]: [userMessage("msg_z", sessionID, 1)] },
message: { [sessionID]: [userMessage("msg_2", sessionID)] },
part: {} as Record<string, Part[] | undefined>,
}
applyOptimisticAdd(draft, {
sessionID,
message: userMessage("msg_a", sessionID, 2),
parts: [textPart("prt_2", sessionID, "msg_a"), textPart("prt_1", sessionID, "msg_a")],
message: userMessage("msg_1", sessionID),
parts: [textPart("prt_2", sessionID, "msg_1"), textPart("prt_1", sessionID, "msg_1")],
})
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
expect(draft.part.msg_a?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
expect(draft.part.msg_1?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
})
test("applyOptimisticRemove removes message and part entries", () => {
@@ -60,33 +60,19 @@ describe("sync optimistic reducers", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_z", sessionID, 1)],
part: [{ id: "msg_z", part: [textPart("prt_1", sessionID, "msg_z")] }],
session: [userMessage("msg_1", sessionID)],
part: [{ id: "msg_1", part: [textPart("prt_1", sessionID, "msg_1")] }],
complete: true,
},
[{ message: userMessage("msg_a", sessionID, 2), parts: [textPart("prt_2", sessionID, "msg_a")] }],
[{ message: userMessage("msg_2", sessionID), parts: [textPart("prt_2", sessionID, "msg_2")] }],
)
expect(page.session.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
expect(page.part.find((x) => x.id === "msg_a")?.part.map((x) => x.id)).toEqual(["prt_2"])
expect(page.session.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_2"])
expect(page.confirmed).toEqual([])
expect(page.complete).toBe(true)
})
test("mergeOptimisticPage uses IDs only to break equal-time ties", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_z", sessionID, 1)],
part: [],
complete: true,
},
[{ message: userMessage("msg_a", sessionID, 1), parts: [] }],
)
expect(page.session.map((message) => message.id)).toEqual(["msg_a", "msg_z"])
})
test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
+4 -5
View File
@@ -3,7 +3,6 @@ import { createMemo } from "solid-js"
import { useServerSync } from "./server-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { messageKey } from "@/utils/session-message"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -68,7 +67,7 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[])
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, messageKey(item.message), messageKey)
const result = Binary.search(session, item.message.id, (message) => message.id)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
@@ -93,7 +92,7 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[])
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, messageKey(input.message), messageKey)
const result = Binary.search(messages, input.message.id, (m) => m.id)
messages.splice(result.index, 0, input.message)
} else {
draft.message[input.sessionID] = [input.message]
@@ -104,8 +103,8 @@ export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddI
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const index = messages.findIndex((message) => message.id === input.messageID)
if (index >= 0) messages.splice(index, 1)
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
}
delete draft.part[input.messageID]
}
+28 -25
View File
@@ -3,6 +3,7 @@
import * as Sentry from "@sentry/solid"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
import { loadInitialLocale } from "@/context/language"
import { type Platform, PlatformProvider } from "@/context/platform"
import { createBrowserDraftStore } from "@/utils/draft-store"
import { dict as en } from "@/i18n/en"
@@ -149,29 +150,31 @@ if (import.meta.env.VITE_SENTRY_DSN) {
}
if (root instanceof HTMLElement) {
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
clearAuthToken()
const server: ServerConnection.Http = {
type: "http",
authToken: !!auth,
http: {
url: getCurrentUrl(),
...auth,
},
}
render(
() => (
<PlatformProvider value={platform}>
<AppBaseProviders>
<AppInterface
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
canonicalLocalServer={ServerConnection.key(server)}
servers={[server]}
disableHealthCheck
/>
</AppBaseProviders>
</PlatformProvider>
),
root,
)
void loadInitialLocale().then((locale) => {
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
clearAuthToken()
const server: ServerConnection.Http = {
type: "http",
authToken: !!auth,
http: {
url: getCurrentUrl(),
...auth,
},
}
render(
() => (
<PlatformProvider value={platform}>
<AppBaseProviders locale={locale}>
<AppInterface
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
canonicalLocalServer={ServerConnection.key(server)}
servers={[server]}
disableHealthCheck
/>
</AppBaseProviders>
</PlatformProvider>
),
root,
)
})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,8 @@ import {
DESKTOP_NATIVE_KEYS,
DESKTOP_NATIVE_LABELS,
DESKTOP_NATIVE_LOCALES,
DESKTOP_NATIVE_LOCALE_TAGS,
detectDesktopNativeLocale,
DESKTOP_NATIVE_MAX_PAYLOAD_BYTES,
formatDesktopNativeMessage,
parseDesktopNativeBundle,
@@ -41,6 +43,40 @@ describe("desktop native translations", () => {
"Azərbaycanca",
"Suomi",
"Svenska",
"አማርኛ",
"Български",
"বাংলা",
"Català",
"Čeština",
"ދިވެހި",
"རྫོང་ཁ",
"Ελληνικά",
"Eesti",
"فارسی",
"Føroyskt",
"Hrvatski",
"Magyar",
"Հայերեն",
"Íslenska",
"ქართული",
"ខ្មែរ",
"ລາວ",
"Lietuvių",
"Latviešu",
"Македонски",
"Монгол",
"Bahasa Melayu",
"မြန်မာ",
"नेपाली",
"Română",
"සිංහල",
"Slovenčina",
"Slovenščina",
"Shqip",
"Српски",
"Тоҷикӣ",
"Türkmençe",
"Oʻzbekcha",
])
})
@@ -74,3 +110,42 @@ describe("desktop native translations", () => {
expect(formatDesktopNativeMessage("{{known}} {{unknown}}", { known: "yes" })).toBe("yes {{unknown}}")
})
})
describe("desktop native locale detection", () => {
test("follows preference order and skips invalid or unsupported tags", () => {
expect(detectDesktopNativeLocale(["not_a_locale", "fr-FR"])).toBe("fr")
expect(detectDesktopNativeLocale(["eo", "de-DE"])).toBe("de")
})
test("uses Unicode likely subtags for script-sensitive bundles", () => {
expect(detectDesktopNativeLocale(["zh-TW"])).toBe("zht")
expect(detectDesktopNativeLocale(["zh-SG"])).toBe("zh")
expect(detectDesktopNativeLocale(["pa-PK"])).toBe("pa")
expect(detectDesktopNativeLocale(["pa-IN", "fr"])).toBe("fr")
expect(detectDesktopNativeLocale(["az-Cyrl", "de"])).toBe("de")
expect(detectDesktopNativeLocale(["sr-Cyrl"])).toBe("sr")
expect(detectDesktopNativeLocale(["sr-Latn", "en"])).toBe("en")
expect(detectDesktopNativeLocale(["uz-Latn"])).toBe("uz")
})
test("recognizes Norwegian language tags", () => {
expect(detectDesktopNativeLocale(["no"])).toBe("no")
expect(detectDesktopNativeLocale(["nb-NO"])).toBe("no")
expect(detectDesktopNativeLocale(["nn-NO"])).toBe("no")
})
})
describe("desktop native ICU data", () => {
test("accepts every locale in standard Intl formatters", () => {
for (const locale of DESKTOP_NATIVE_LOCALES) {
const tag = DESKTOP_NATIVE_LOCALE_TAGS[locale]
expect(() => new Intl.Locale(tag), `${locale} locale`).not.toThrow()
expect(() => new Intl.NumberFormat(tag), `${locale} number`).not.toThrow()
expect(() => new Intl.DateTimeFormat(tag), `${locale} date`).not.toThrow()
expect(() => new Intl.PluralRules(tag), `${locale} plural`).not.toThrow()
expect(() => new Intl.ListFormat(tag), `${locale} list`).not.toThrow()
expect(() => new Intl.DisplayNames(tag, { type: "language" }), `${locale} names`).not.toThrow()
expect(() => new Intl.Segmenter(tag), `${locale} segmenter`).not.toThrow()
}
})
})
+159
View File
@@ -27,6 +27,40 @@ export const DESKTOP_NATIVE_LOCALES = [
"az",
"fi",
"sv",
"am",
"bg",
"bn",
"ca",
"cs",
"dv",
"dz",
"el",
"et",
"fa",
"fo",
"hr",
"hu",
"hy",
"is",
"ka",
"km",
"lo",
"lt",
"lv",
"mk",
"mn",
"ms",
"my",
"ne",
"ro",
"si",
"sk",
"sl",
"sq",
"sr",
"tg",
"tk",
"uz",
] as const
export type DesktopNativeLocale = (typeof DESKTOP_NATIVE_LOCALES)[number]
@@ -60,6 +94,131 @@ export const DESKTOP_NATIVE_LABELS: Record<DesktopNativeLocale, string> = {
az: "Azərbaycanca",
fi: "Suomi",
sv: "Svenska",
am: "አማርኛ",
bg: "Български",
bn: "বাংলা",
ca: "Català",
cs: "Čeština",
dv: "ދިވެހި",
dz: "རྫོང་ཁ",
el: "Ελληνικά",
et: "Eesti",
fa: "فارسی",
fo: "Føroyskt",
hr: "Hrvatski",
hu: "Magyar",
hy: "Հայերեն",
is: "Íslenska",
ka: "ქართული",
km: "ខ្មែរ",
lo: "ລາວ",
lt: "Lietuvių",
lv: "Latviešu",
mk: "Македонски",
mn: "Монгол",
ms: "Bahasa Melayu",
my: "မြန်မာ",
ne: "नेपाली",
ro: "Română",
si: "සිංහල",
sk: "Slovenčina",
sl: "Slovenščina",
sq: "Shqip",
sr: "Српски",
tg: "Тоҷикӣ",
tk: "Türkmençe",
uz: "Oʻzbekcha",
}
export const DESKTOP_NATIVE_LOCALE_TAGS: Record<DesktopNativeLocale, string> = {
en: "en",
zh: "zh-Hans",
zht: "zh-Hant",
ko: "ko",
de: "de",
es: "es",
fr: "fr",
da: "da",
ja: "ja",
pl: "pl",
ru: "ru",
uk: "uk",
bs: "bs",
ar: "ar",
no: "nb-NO",
br: "pt-BR",
th: "th",
tr: "tr",
hi: "hi-IN",
nl: "nl-NL",
id: "id-ID",
vi: "vi-VN",
it: "it-IT",
ur: "ur-PK",
pa: "pa-Arab-PK",
az: "az-Latn-AZ",
fi: "fi-FI",
sv: "sv-SE",
am: "am-ET",
bg: "bg-BG",
bn: "bn-BD",
ca: "ca-AD",
cs: "cs-CZ",
dv: "dv-MV",
dz: "dz-BT",
el: "el-GR",
et: "et-EE",
fa: "fa-IR",
fo: "fo-FO",
hr: "hr-HR",
hu: "hu-HU",
hy: "hy-AM",
is: "is-IS",
ka: "ka-GE",
km: "km-KH",
lo: "lo-LA",
lt: "lt-LT",
lv: "lv-LV",
mk: "mk-MK",
mn: "mn-MN",
ms: "ms-MY",
my: "my-MM",
ne: "ne-NP",
ro: "ro-RO",
si: "si-LK",
sk: "sk-SK",
sl: "sl-SI",
sq: "sq-AL",
sr: "sr-Cyrl-RS",
tg: "tg-Cyrl-TJ",
tk: "tk-Latn-TM",
uz: "uz-Latn-UZ",
}
export function detectDesktopNativeLocale(languages: readonly string[]): DesktopNativeLocale {
for (const language of languages) {
const source = locale(language)
if (!source) continue
if (["no", "nb", "nn"].includes(source.language)) return "no"
const match = DESKTOP_NATIVE_LOCALES.find((candidate) => {
const target = locale(DESKTOP_NATIVE_LOCALE_TAGS[candidate])
return target?.language === source.language && target.script === source.script
})
if (match) return match
}
return "en"
}
export function desktopNativePluralCategories(locale: DesktopNativeLocale) {
return new Intl.PluralRules(DESKTOP_NATIVE_LOCALE_TAGS[locale]).resolvedOptions().pluralCategories
}
function locale(value: string) {
try {
return new Intl.Locale(value).maximize()
} catch {
return undefined
}
}
export const DESKTOP_NATIVE_ENGLISH = {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+48 -15
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import { desktopNativePluralCategories } from "./desktop-native"
const appLocales = [
"ar",
@@ -28,19 +29,51 @@ const appLocales = [
"az",
"fi",
"sv",
"am",
"bg",
"bn",
"ca",
"cs",
"dv",
"dz",
"el",
"et",
"fa",
"fo",
"hr",
"hu",
"hy",
"is",
"ka",
"km",
"lo",
"lt",
"lv",
"mk",
"mn",
"ms",
"my",
"ne",
"ro",
"si",
"sk",
"sl",
"sq",
"sr",
"tg",
"tk",
"uz",
] as const
const desktopLocales = appLocales
const pluralCategories: Partial<Record<(typeof appLocales)[number], readonly string[]>> = {
ar: ["zero", "two", "few", "many"],
br: ["many"],
bs: ["few"],
es: ["many"],
fr: ["many"],
it: ["many"],
pl: ["few", "many"],
ru: ["few", "many"],
uk: ["few", "many"],
}
const pluralCategories = new Map(
appLocales.map(
(locale) =>
[
locale,
desktopNativePluralCategories(locale).filter((category) => category !== "one" && category !== "other"),
] as const,
),
)
const domains = [
{
@@ -74,7 +107,7 @@ describe("i18n parity", () => {
.filter((key) => !Object.hasOwn(source, key))
.sort()
const expected = pluralFamilies(source)
.flatMap((key) => (pluralCategories[locale] ?? []).map((category) => `${key}.${category}`))
.flatMap((key) => (pluralCategories.get(locale) ?? []).map((category) => `${key}.${category}`))
.sort()
expect({ domain: domain.name, locale, missing, extra }).toEqual({
domain: domain.name,
@@ -95,7 +128,7 @@ describe("i18n parity", () => {
(key) => Object.hasOwn(target, key) && placeholders(source[key]).join() !== placeholders(target[key]).join(),
)
const pluralMismatched = pluralFamilies(source).flatMap((key) =>
(pluralCategories[locale] ?? [])
(pluralCategories.get(locale) ?? [])
.map((category) => `${key}.${category}`)
.filter((variant) => placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join()),
)
@@ -144,12 +177,12 @@ describe("i18n plural parity", () => {
for (const locale of domain.locales) {
const target = await dictionary(domain.target(locale))
const missing = families.flatMap((key) =>
(pluralCategories[locale] ?? [])
(pluralCategories.get(locale) ?? [])
.map((category) => `${key}.${category}`)
.filter((variant) => !Object.hasOwn(target, variant)),
)
const mismatched = families.flatMap((key) =>
(pluralCategories[locale] ?? [])
(pluralCategories.get(locale) ?? [])
.map((category) => `${key}.${category}`)
.filter(
(variant) =>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -15,7 +15,7 @@ import type { LocalProject } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { compareSessionTime, displayName, errorMessage, projectForSession } from "@/pages/layout/helpers"
import { displayName, errorMessage, projectForSession } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { pathKey } from "@/utils/path-key"
import { showToast } from "@/utils/toast"
@@ -254,7 +254,7 @@ function buildHomeSessionRecords(input: {
const directories = new Set(input.projectDirectories().map(pathKey))
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
.sort(compareSessionTime)
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.flatMap((session) => {
const directory = pathKey(session.directory)
const project =
@@ -10,7 +10,6 @@ import { type Session } from "@opencode-ai/sdk/v2/client"
import {
childSessionOnPath,
closeHomeProject,
compareSessionTime,
displayName,
effectiveWorkspaceOrder,
errorMessage,
@@ -19,7 +18,6 @@ import {
homeProjectDirectories,
homeSessionServerStatus,
latestRootSession,
sortedRootSessions,
toggleHomeProjectSelection,
} from "./helpers"
import { pathKey } from "@/utils/path-key"
@@ -155,30 +153,6 @@ describe("layout workspace helpers", () => {
expect(result?.id).toBe("workspace")
})
test("sorts recent sessions by persisted update time instead of id", () => {
const result = sortedRootSessions(
{
path: { directory: "/workspace" },
session: [
session({ id: "ses_z", directory: "/workspace", time: { created: 1, updated: 2, archived: undefined } }),
session({ id: "ses_a", directory: "/workspace", time: { created: 1, updated: 3, archived: undefined } }),
],
},
3,
)
expect(result.map((item) => item.id)).toEqual(["ses_a", "ses_z"])
})
test("uses id only to break equal session timestamps", () => {
const sessions = [
session({ id: "ses_z", directory: "/workspace", time: { created: 1, updated: 2, archived: undefined } }),
session({ id: "ses_a", directory: "/workspace", time: { created: 1, updated: 2, archived: undefined } }),
]
expect(sessions.sort(compareSessionTime).map((item) => item.id)).toEqual(["ses_a", "ses_z"])
})
test("detects project permissions with a filter", () => {
const result = hasProjectPermissions(
{
+15 -7
View File
@@ -9,10 +9,18 @@ type SessionStore = {
path: { directory: string }
}
export function compareSessionTime(a: Session, b: Session) {
const updated = (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)
if (updated !== 0) return updated
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
function sortSessions(now: number) {
const oneMinuteAgo = now - 60 * 1000
return (a: Session, b: Session) => {
const aUpdated = a.time.updated ?? a.time.created
const bUpdated = b.time.updated ?? b.time.created
const aRecent = aUpdated > oneMinuteAgo
const bRecent = bUpdated > oneMinuteAgo
if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
if (aRecent && !bRecent) return -1
if (!aRecent && bRecent) return 1
return bUpdated - aUpdated
}
}
const isRootVisibleSession = (session: Session, directory: string) =>
@@ -21,10 +29,10 @@ const isRootVisibleSession = (session: Session, directory: string) =>
export const roots = (store: SessionStore) =>
(store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
export const sortedRootSessions = (store: SessionStore, _now: number) => roots(store).sort(compareSessionTime)
export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now))
export const latestRootSession = (stores: SessionStore[], _now: number) =>
stores.flatMap(roots).sort(compareSessionTime)[0]
export const latestRootSession = (stores: SessionStore[], now: number) =>
stores.flatMap(roots).sort(sortSessions(now))[0]
export function hasProjectPermissions<T>(
request: Record<string, T[] | undefined> | undefined,
+2 -6
View File
@@ -1851,9 +1851,7 @@ export default function Page() {
const session = sdk().api.session
const target = sync()
const index = userMessages().findIndex((item) => item.id === id)
if (index < 0) return
const next = userMessages()[index + 1]
const next = userMessages().find((item) => item.id > id)
const last = target.session.get(sessionID)?.revert
await runPromptRollbackMutation({
@@ -1893,10 +1891,8 @@ export default function Page() {
const rolled = createMemo(() => {
const id = revertMessageID()
if (!id) return []
const index = userMessages().findIndex((item) => item.id === id)
if (index < 0) return []
return userMessages()
.slice(index)
.filter((item) => item.id >= id)
.map((item) => ({ id: item.id, text: line(item.id) }))
})
@@ -287,9 +287,7 @@ export function MessageTimeline(props: {
const visible = new Set(props.userMessages.map((message) => message.id))
const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id
const messages = sync().data.session_message[id] ?? []
if (!boundary) return messages
const index = messages.findIndex((message) => message.id === boundary)
return index < 0 ? messages : messages.slice(0, index)
return boundary ? messages.filter((message) => message.id < boundary) : messages
})
const info = createMemo(() => {
const id = sessionID()
@@ -7,11 +7,11 @@ const assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessag
describe("timeline model", () => {
test("selects users and applies the revert boundary", () => {
const messages: Message[] = [user("msg_z"), assistant("msg_a"), user("msg_b"), user("msg_c")]
const messages: Message[] = [user("msg_1"), assistant("msg_2"), user("msg_3"), user("msg_5")]
const users = selectUserMessages(messages)
expect(users.map((message) => message.id)).toEqual(["msg_z", "msg_b", "msg_c"])
expect(selectVisibleUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_z"])
expect(users.map((message) => message.id)).toEqual(["msg_1", "msg_3", "msg_5"])
expect(selectVisibleUserMessages(users, "msg_5").map((message) => message.id)).toEqual(["msg_1", "msg_3"])
expect(selectVisibleUserMessages(users)).toBe(users)
})
@@ -104,8 +104,7 @@ export function isTimelineReady(messages: Message[] | undefined, loading: boolea
export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
if (!revertMessageID) return messages
const boundary = messages.findIndex((message) => message.id === revertMessageID)
return boundary < 0 ? messages : messages.slice(0, boundary)
return messages.filter((message) => message.id < revertMessageID)
}
export async function loadOlderTimeline(input: {
@@ -137,11 +137,11 @@ describe("current session timeline rows", () => {
test("renders an optimistic user turn and thinking before the protocol message arrives", () => {
const source = [
{ id: "msg_z", type: "user", text: "existing", time: { created: 1 } },
{ id: "msg_1", type: "user", text: "existing", time: { created: 1 } },
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const optimistic = {
id: "msg_a",
id: "msg_2",
sessionID: "ses_1",
role: "user" as const,
time: { created: 2 },
@@ -161,10 +161,10 @@ describe("current session timeline rows", () => {
expect(result.activeMessageID).toBe(optimistic.id)
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_z",
"turn-gap:msg_a",
"user-message:msg_a",
"thinking:msg_a",
"user-message:msg_1",
"turn-gap:msg_2",
"user-message:msg_2",
"thinking:msg_2",
])
})
@@ -4,7 +4,6 @@ import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row"
import { uniqueSummaryDiffs } from "./summary-diffs"
import { compareMessages } from "@/utils/session-message"
export { TimelineRow, type SummaryDiff } from "./timeline-row"
@@ -72,12 +71,12 @@ export namespace Timeline {
turns.push(turn)
turnByUserID.set(user.id, turn)
})
const latestUserMessageID = turns.at(-1)?.user.id
projectedUserMessages.forEach((user) => {
if (turnByUserID.has(user.id)) return
if (latestUserMessageID && user.id < latestUserMessageID) return
const turn = { user, assistants: [] }
const index = turns.findIndex((item) => compareMessages(user, item.user) < 0)
if (index < 0) turns.push(turn)
if (index >= 0) turns.splice(index, 0, turn)
turns.push(turn)
turnByUserID.set(user.id, turn)
})
const activeMessageID = turns.at(-1)?.user.id
@@ -100,8 +100,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const visibleUserMessages = () => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
const boundary = userMessages().findIndex((message) => message.id === revert)
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
return userMessages().filter((m) => m.id < revert)
}
const showAllFiles = () => {
@@ -338,9 +337,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const promptSession = prompt.capture()
const revert = info()?.revert?.messageID
const messages = userMessages()
const boundary = revert ? messages.findIndex((message) => message.id === revert) : messages.length
if (boundary < 0) return
const message = messages[boundary - 1]
const message = findLast(messages, (x) => !revert || x.id < revert)
if (!message) return
const parts = sync().data.part[message.id]
@@ -355,7 +352,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
updatePrompt: (promptSession) => {
if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
},
updateViewport: () => setActiveMessage(messages[boundary - 2]),
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < message.id)),
})
}
@@ -370,16 +367,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const boundary = messages.findIndex((message) => message.id === revertMessageID)
if (boundary < 0) return
const next = messages[boundary + 1]
const next = messages.find((x) => x.id > revertMessageID)
if (!next) {
await runCommand({
owner,
prompt: promptSession,
request: () => session.revert.clear({ sessionID }),
updatePrompt: (promptSession) => promptSession.reset(),
updateViewport: () => setActiveMessage(messages.at(-1)),
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)),
})
return
}
@@ -389,7 +384,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
prompt: promptSession,
request: () => session.revert.stage({ sessionID, messageID: next.id }),
updatePrompt: () => undefined,
updateViewport: () => setActiveMessage(messages[boundary]),
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)),
})
}
@@ -12,14 +12,6 @@ const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write
const emptyModel: { id: string; providerID: string; variant?: string } = { id: "", providerID: "" }
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
export function compareMessages(a: Pick<Message, "id" | "time">, b: Pick<Message, "id" | "time">) {
const left = messageKey(a)
const right = messageKey(b)
return left < right ? -1 : left > right ? 1 : 0
}
export const messageKey = (message: Pick<Message, "id" | "time">) => message.time.created + message.id
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
+1 -1
View File
@@ -252,7 +252,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "الاستثناءات التالية",
"go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع",
"go.banner.text": "يحصل DeepSeek V4 Flash على حدود استخدام مضاعفة لفترة محدودة",
"go.banner.text": "يحصل GPT 5.6 Luna على حدود استخدام مضاعفة لفترة محدودة",
"go.meta.description":
"يبدأ Go بسعر $5 للشهر الأول، ثم $10/شهر، مع حدود استخدام سخية ووصول موثوق إلى نماذج البرمجة الرائدة.",
"go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع",
+1 -1
View File
@@ -256,7 +256,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "seguintes exceções",
"go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos",
"go.banner.text": "DeepSeek V4 Flash tem limites de uso 2x maiores por tempo limitado",
"go.banner.text": "GPT 5.6 Luna tem limites de uso 2x maiores por tempo limitado",
"go.meta.description":
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de uso e acesso confiável aos principais modelos de codificação.",
"go.hero.title": "Modelos de codificação de baixo custo para todos",
+1 -1
View File
@@ -254,7 +254,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "følgende undtagelser",
"go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle",
"go.banner.text": "DeepSeek V4 Flash får fordoblet brugsgrænse i en begrænset periode",
"go.banner.text": "GPT 5.6 Luna får fordoblet brugsgrænse i en begrænset periode",
"go.meta.description":
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse brugsgrænser og pålidelig adgang til førende kodningsmodeller.",
"go.hero.title": "Kodningsmodeller til lav pris for alle",
+1 -1
View File
@@ -256,7 +256,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "folgenden Ausnahmen",
"go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle",
"go.banner.text": "DeepSeek V4 Flash erhält für begrenzte Zeit 2x Nutzungslimits",
"go.banner.text": "GPT 5.6 Luna erhält für begrenzte Zeit 2x Nutzungslimits",
"go.meta.description":
"Go beginnt bei $5 für deinen ersten Monat, danach $10/Monat, mit großzügigen Nutzungslimits und zuverlässigem Zugang zu führenden Coding-Modellen.",
"go.hero.title": "Kostengünstige Coding-Modelle für alle",
+1 -1
View File
@@ -253,7 +253,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "following exceptions",
"go.title": "OpenCode Go | Low cost coding models for everyone",
"go.banner.text": "DeepSeek V4 Flash gets 2× usage limits for a limited time",
"go.banner.text": "GPT 5.6 Luna gets 2× usage limits for a limited time",
"go.meta.description":
"Go starts at $5 for your first month, then $10/month, with generous usage limits and reliable access to leading coding models.",
"go.hero.title": "Low cost coding models for everyone",
+1 -1
View File
@@ -257,7 +257,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "siguientes excepciones",
"go.title": "OpenCode Go | Modelos de programación de bajo coste para todos",
"go.banner.text": "DeepSeek V4 Flash tiene límites de uso 2x mayores por tiempo limitado",
"go.banner.text": "GPT 5.6 Luna tiene límites de uso 2x mayores por tiempo limitado",
"go.meta.description":
"Go comienza en $5 el primer mes, luego 10 $/mes, con límites de uso generosos y acceso fiable a modelos de programación líderes.",
"go.hero.title": "Modelos de programación de bajo coste para todos",
+1 -1
View File
@@ -258,7 +258,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "exceptions suivantes",
"go.title": "OpenCode Go | Modèles de code à faible coût pour tous",
"go.banner.text": "DeepSeek V4 Flash bénéficie de limites dutilisation 2x supérieures pour une durée limitée",
"go.banner.text": "GPT 5.6 Luna bénéficie de limites dutilisation 2x supérieures pour une durée limitée",
"go.meta.description":
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites d'utilisation généreuses et un accès fiable aux principaux modèles de codage.",
"go.hero.title": "Modèles de code à faible coût pour tous",
+1 -1
View File
@@ -254,7 +254,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "seguenti eccezioni",
"go.title": "OpenCode Go | Modelli di coding a basso costo per tutti",
"go.banner.text": "DeepSeek V4 Flash offre limiti di utilizzo 2x superiori per un periodo limitato",
"go.banner.text": "GPT 5.6 Luna offre limiti di utilizzo 2x superiori per un periodo limitato",
"go.meta.description":
"Go inizia a $5 per il primo mese, poi $10/mese, con limiti di utilizzo generosi e un accesso affidabile ai principali modelli di coding.",
"go.hero.title": "Modelli di coding a basso costo per tutti",
+1 -1
View File
@@ -253,7 +253,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "以下の例外",
"go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル",
"go.banner.text": "DeepSeek V4 Flashの利用上限が期間限定で2倍に",
"go.banner.text": "GPT 5.6 Lunaの利用上限が期間限定で2倍に",
"go.meta.description":
"Goは最初の月$5、その後$10/月で、主要なコーディングモデルへのゆとりある利用上限と安定したアクセスを提供します。",
"go.hero.title": "すべての人のための低価格なコーディングモデル",
+1 -1
View File
@@ -250,7 +250,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "다음 예외",
"go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델",
"go.banner.text": "DeepSeek V4 Flash 사용 한도가 한시적으로 2배 확대됩니다",
"go.banner.text": "GPT 5.6 Luna 사용 한도가 한시적으로 2배 확대됩니다",
"go.meta.description":
"Go는 첫 달 $5, 이후 $10/월로 시작하며, 넉넉한 사용 한도와 주요 코딩 모델에 대한 안정적인 액세스를 제공합니다.",
"go.hero.title": "모두를 위한 저비용 코딩 모델",
+1 -1
View File
@@ -254,7 +254,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "følgende unntak",
"go.title": "OpenCode Go | Rimelige kodemodeller for alle",
"go.banner.text": "DeepSeek V4 Flash får 2x bruksgrense i en begrenset periode",
"go.banner.text": "GPT 5.6 Luna får 2x bruksgrense i en begrenset periode",
"go.meta.description":
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse bruksgrenser og pålitelig tilgang til ledende kodemodeller.",
"go.hero.title": "Rimelige kodemodeller for alle",
+1 -1
View File
@@ -255,7 +255,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "następującymi wyjątkami",
"go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego",
"go.banner.text": "DeepSeek V4 Flash oferuje 2x wyższe limity użycia przez ograniczony czas",
"go.banner.text": "GPT 5.6 Luna oferuje 2x wyższe limity użycia przez ograniczony czas",
"go.meta.description":
"Go kosztuje $5 za pierwszy miesiąc, a następnie $10/miesiąc, oferując hojne limity użycia i niezawodny dostęp do wiodących modeli do kodowania.",
"go.hero.title": "Niskokosztowe modele do kodowania dla każdego",
+1 -1
View File
@@ -258,7 +258,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "следующими исключениями",
"go.title": "OpenCode Go | Недорогие модели для кодинга для всех",
"go.banner.text": "DeepSeek V4 Flash получает 2x лимиты использования на ограниченное время",
"go.banner.text": "GPT 5.6 Luna получает 2x лимиты использования на ограниченное время",
"go.meta.description":
"Go стоит $5 за первый месяц, затем $10/месяц и предлагает щедрые лимиты использования и надежный доступ к ведущим моделям для кодинга.",
"go.hero.title": "Недорогие модели для кодинга для всех",
+1 -1
View File
@@ -253,7 +253,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "ข้อยกเว้นดังนี้",
"go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
"go.banner.text": "DeepSeek V4 Flash เพิ่มโควตาการใช้งานเป็น 2 เท่าในช่วงเวลาจำกัด",
"go.banner.text": "GPT 5.6 Luna เพิ่มโควตาการใช้งานเป็น 2 เท่าในช่วงเวลาจำกัด",
"go.meta.description":
"Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดการใช้งานที่เอื้อเฟื้อและการเข้าถึงโมเดลเขียนโค้ดชั้นนำอย่างเชื่อถือได้",
"go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
+1 -1
View File
@@ -256,7 +256,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "aşağıdaki istisnalar",
"go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri",
"go.banner.text": "DeepSeek V4 Flash sınırlı bir süre için 2x kullanım limiti sunuyor",
"go.banner.text": "GPT 5.6 Luna sınırlı bir süre için 2x kullanım limiti sunuyor",
"go.meta.description":
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; cömert kullanım limitleri ve önde gelen kodlama modellerine güvenilir erişim sunar.",
"go.hero.title": "Herkes için düşük maliyetli kodlama modelleri",
+1 -1
View File
@@ -255,7 +255,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "такими винятками",
"go.title": "OpenCode Go | Недорогі моделі кодування для всіх",
"go.banner.text": "DeepSeek V4 Flash отримує 2x ліміти використання протягом обмеженого часу",
"go.banner.text": "GPT 5.6 Luna отримує 2x ліміти використання протягом обмеженого часу",
"go.meta.description":
"Go починається від $5 за перший місяць, потім $10/місяць, зі щедрими лімітами використання та надійним доступом до провідних моделей для кодування.",
"go.hero.title": "Недорогі моделі кодування для всіх",
+1 -1
View File
@@ -244,7 +244,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "以下例外情况除外",
"go.title": "OpenCode Go | 人人可用的低成本编程模型",
"go.banner.text": "DeepSeek V4 Flash 限时享受 2 倍使用额度",
"go.banner.text": "GPT 5.6 Luna 限时享受 2 倍使用额度",
"go.meta.description": "Go 首月 $5,之后 $10/月,提供充裕的使用限额,并可可靠访问领先的编程模型。",
"go.hero.title": "人人可用的低成本编程模型",
"go.hero.body":
+1 -1
View File
@@ -244,7 +244,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "以下例外情況",
"go.title": "OpenCode Go | 低成本全民編碼模型",
"go.banner.text": "DeepSeek V4 Flash 限時享有 2 倍使用額度",
"go.banner.text": "GPT 5.6 Luna 限時享有 2 倍使用額度",
"go.meta.description": "Go 首月 $5,之後 $10/月,提供充裕的使用限額,並可穩定存取領先的編碼模型。",
"go.hero.title": "低成本全民編碼模型",
"go.hero.body":
+3 -22
View File
@@ -486,8 +486,8 @@ body {
--bar-go: var(--color-go-2);
@media (max-width: 60rem) {
width: calc(100% - 32px);
max-width: calc(100% - 32px);
width: 50%;
max-width: 50%;
}
[data-slot="plot"] {
@@ -496,10 +496,6 @@ body {
width: 100%;
margin: 0 auto;
margin-left: -56px;
@media (max-width: 60rem) {
margin-left: 0;
}
}
[data-slot="ylabels"] {
@@ -529,7 +525,7 @@ body {
}
@media (max-width: 60rem) {
&:not([data-tick="1"], [data-tick="10"], [data-tick="50"], [data-tick="250"]) {
&:not([data-tick="1"], [data-tick="25"], [data-tick="100"], [data-tick="250"]) {
display: none;
}
}
@@ -576,17 +572,6 @@ body {
opacity: 0;
}
@media (max-width: 60rem) {
[data-item][data-edge] {
right: 0;
left: auto;
transform: translateY(-50%);
max-width: none;
padding-left: 16px;
background: linear-gradient(90deg, transparent, var(--color-background-weak) 12px);
}
}
[data-name] {
color: var(--color-text);
white-space: nowrap;
@@ -604,10 +589,6 @@ body {
font-weight: 400;
line-height: 1;
white-space: nowrap;
@media (max-width: 40rem) {
display: none;
}
}
}
+4 -14
View File
@@ -72,27 +72,19 @@ function LimitsGraph(props: { href: string }) {
{ id: "glm-5.2", name: "GLM-5.2", req: 880, d: "100ms" },
{ id: "minimax-m3", name: "MiniMax M3", req: 3200, d: "210ms" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "270ms" },
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna", req: 4100, baseReq: 2050, d: "290ms" },
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna (2x usage)", req: 4100, baseReq: 2050, d: "290ms" },
{ id: "qwen3.7-plus", name: "Qwen3.7 Plus", req: 4300, d: "300ms" },
{ id: "hy3", name: "Hy3", req: 4300, d: "320ms" },
{ id: "mimo-v2.5", name: "MiMo-V2.5", req: 30100, d: "340ms" },
{
id: "deepseek-v4-flash",
name: "DeepSeek V4 Flash",
req: 63300,
baseReq: 31650,
edge: true,
d: "340ms",
},
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", req: 31650, d: "340ms" },
]
const w = 1040
const chartW = 720
const w = 720
const left = 40
const right = 60
const top = 18
const bottom = 44
const plot = chartW - left - right
const plot = w - left - right
const ratio = (n: number) => n / baseline
const rmax = Math.max(1, ...graph.map((m) => ratio(m.req)))
@@ -203,12 +195,10 @@ function LimitsGraph(props: { href: string }) {
data-item
data-kind="go"
data-model={m.id}
data-edge={"edge" in m ? "" : undefined}
style={{ "--x": px(x(ratio(m.req))), "--y": py(gy(i())), "--d": m.d } as any}
>
<span data-value>{m.req.toLocaleString()}</span>
<span data-name>{m.name}</span>
{m.baseReq && <span data-bonus>2x usage</span>}
</span>
)}
</For>
+3
View File
@@ -8,4 +8,7 @@
- Keep locale and grammar logic in the shared typed i18n layer. Renderer code should resolve copy through the app language API, and the main process should consume typed native-translation bundles through `nativeT(...)`; native menus, dialogs, and IPC handlers must not inspect locales, choose plural categories, or assemble translated sentence fragments.
- Prefer complete translated phrases with only irreducible dynamic placeholders. If native UI needs richer grammar, deepen the shared bundle/API instead of adding locale branches to desktop feature code.
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
- For developer-facing terminology, prefer established usage in the target language's developer community over literal translations. Cross-check maintained Firefox, KDE, and VS Code localizations, using at least two independent corpora when available. Keep established English loanwords and acronyms instead of inventing unfamiliar terms.
- Translate whole native-menu and dialog phrases in context. Audit recurring concepts for consistency and review every exact-English value; retain it only when it is an intentional product/provider/tool name, URL, code token, keyboard legend, acronym, asset name, or established borrowing.
- Record the corpora used and flag uncertain or regional terminology in review notes.
- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels.
+26
View File
@@ -0,0 +1,26 @@
export const dict = {
"desktop.menu.checkForUpdates": "ዝማኔዎችን ይመልከቱ...",
"desktop.menu.installCli": "ጫን CLI...",
"desktop.menu.reloadWebview": "ዳግም ጫን Webview",
"desktop.menu.restart": "ዳግም አስጀምር",
"desktop.dialog.chooseFolder": "አቃፊ ምረጥ",
"desktop.dialog.chooseFile": "ፋይል ምረጥ",
"desktop.dialog.saveFile": "ፋይሉን አስቀምጥ",
"desktop.updater.checkFailed.title": "ማዘመን ቼክ አልተሳካም",
"desktop.updater.checkFailed.message": "ዝማኔዎችን ማረጋገጥ አልተሳካም",
"desktop.updater.none.title": "ምንም ማሻሻያ የለም",
"desktop.updater.none.message": "አሁን የቅርብ ጊዜውን የOpenCode ስሪት እየተጠቀሙ ነው",
"desktop.updater.downloadFailed.title": "ዝማኔ አልተሳካም",
"desktop.updater.downloadFailed.message": "ዝማኔን ማውረድ አልተሳካም",
"desktop.updater.downloaded.title": "ዝማኔው ወርዷል",
"desktop.updater.downloaded.prompt": "ስሪት {{version}} ከOpenCode ወርዷል፣ መጫን እና እንደገና ማስጀመር ይፈልጋሉ?",
"desktop.updater.installFailed.title": "ዝማኔ አልተሳካም",
"desktop.updater.installFailed.message": "ዝማኔን መጫን አልተሳካም",
"desktop.cli.installed.title": "CLI ተጭኗል",
"desktop.cli.installed.message": "CLI ወደ {{path}} ተጭኗል\n\nየ'opencode' ትዕዛዝን ለመጠቀም ተርሚናልዎን እንደገና ያስጀምሩት።",
"desktop.cli.failed.title": "መጫኑ አልተሳካም",
"desktop.cli.failed.message": "CLIን መጫን አልተሳካም፦ {{error}}",
"desktop.error.dev.rootNotFound":
"ሥርወ አካል አልተገኘም። ወደ የእርስዎ index.html ማከልን ረስተዋል? ወይም የመታወቂያ ባህሪው የተሳሳተ ፊደል ተጽፎ ሊሆን ይችላል?",
}
+3
View File
@@ -24,4 +24,7 @@ export const dict = {
"desktop.cli.installed.message": "تم تثبيت CLI في {{path}}\n\nأعد تشغيل الطرفية لاستخدام الأمر 'opencode'.",
"desktop.cli.failed.title": "فشل التثبيت",
"desktop.cli.failed.message": "فشل تثبيت CLI: {{error}}",
"desktop.error.dev.rootNotFound":
"لم يتم العثور على العنصر الجذري. هل نسيت إضافته إلى index.html؟ أو ربما تمت كتابة سمة id بشكل خاطئ؟",
}
+3
View File
@@ -25,4 +25,7 @@ export const dict = {
"CLI {{path}} ünvanına quraşdırıldı\n\n'opencode' əmrindən istifadə etmək üçün terminalı yenidən başladın.",
"desktop.cli.failed.title": "Quraşdırma uğursuz oldu",
"desktop.cli.failed.message": "CLI quraşdırıla bilmədi: {{error}}",
"desktop.error.dev.rootNotFound":
"Kök element tapılmadı. index.html-ə əlavə etməyi unutmusunuz? Yoxsa id atributu səhv yazılıb?",
}
+28
View File
@@ -0,0 +1,28 @@
export const dict = {
"desktop.menu.checkForUpdates": "Проверете за актуализации...",
"desktop.menu.installCli": "Инсталирайте CLI...",
"desktop.menu.reloadWebview": "Презареди Webview",
"desktop.menu.restart": "Рестартирайте",
"desktop.dialog.chooseFolder": "Изберете папка",
"desktop.dialog.chooseFile": "Изберете файл",
"desktop.dialog.saveFile": "Запазете файла",
"desktop.updater.checkFailed.title": "Проверката на актуализацията е неуспешна",
"desktop.updater.checkFailed.message": "Неуспешна проверка за актуализации",
"desktop.updater.none.title": "Няма налична актуализация",
"desktop.updater.none.message": "Вече използвате най-новата версия на OpenCode",
"desktop.updater.downloadFailed.title": "Неуспешна актуализация",
"desktop.updater.downloadFailed.message": "Неуспешно изтегляне на актуализация",
"desktop.updater.downloaded.title": "Актуализацията е изтеглена",
"desktop.updater.downloaded.prompt":
"Версия {{version}} от OpenCode е изтеглена. Искате ли да я инсталирате и рестартирате?",
"desktop.updater.installFailed.title": "Неуспешна актуализация",
"desktop.updater.installFailed.message": "Неуспешно инсталиране на актуализация",
"desktop.cli.installed.title": "CLI е инсталиран",
"desktop.cli.installed.message":
"CLI инсталиран на {{path}}\n\nРестартирайте терминала си, за да използвате командата 'opencode'.",
"desktop.cli.failed.title": "Неуспешно инсталиране",
"desktop.cli.failed.message": "Неуспешно инсталиране на CLI: {{error}}",
"desktop.error.dev.rootNotFound":
"Основният елемент не е намерен. Забравихте ли да го добавите към вашия index.html? Или може би атрибутът id е изписан неправилно?",
}
+27
View File
@@ -0,0 +1,27 @@
export const dict: Record<string, string> = {
"desktop.menu.checkForUpdates": "আপডেটের জন্য চেক করুন...",
"desktop.menu.installCli": "CLI ইনস্টল করুন...",
"desktop.menu.reloadWebview": "Webview পুনরায় লোড করুন",
"desktop.menu.restart": "রিস্টার্ট করুন",
"desktop.dialog.chooseFolder": "একটি ফোল্ডার নির্বাচন করুন",
"desktop.dialog.chooseFile": "একটি ফাইল নির্বাচন করুন",
"desktop.dialog.saveFile": "ফাইল সংরক্ষণ করুন",
"desktop.updater.checkFailed.title": "আপডেট চেক ব্যর্থ হয়েছে",
"desktop.updater.checkFailed.message": "আপডেটের জন্য চেক করতে ব্যর্থ",
"desktop.updater.none.title": "কোন আপডেট উপলব্ধ নেই",
"desktop.updater.none.message": "আপনি ইতিমধ্যেই OpenCode এর সর্বশেষ সংস্করণ ব্যবহার করছেন৷",
"desktop.updater.downloadFailed.title": "আপডেট ব্যর্থ হয়েছে৷",
"desktop.updater.downloadFailed.message": "আপডেট ডাউনলোড করতে ব্যর্থ হয়েছে",
"desktop.updater.downloaded.title": "আপডেট ডাউনলোড হয়েছে",
"desktop.updater.downloaded.prompt":
"OpenCode-এর {{version}} সংস্করণ ডাউনলোড করা হয়েছে, আপনি কি এটি ইনস্টল করে পুনরায় চালু করতে চান?",
"desktop.updater.installFailed.title": "আপডেট ব্যর্থ হয়েছে৷",
"desktop.updater.installFailed.message": "আপডেট ইনস্টল করতে ব্যর্থ হয়েছে",
"desktop.cli.installed.title": "CLI ইনস্টল করা হয়েছে",
"desktop.cli.installed.message": "CLI {{path}}\n\n'ওপেনকোড' কমান্ড ব্যবহার করতে আপনার টার্মিনাল পুনরায় চালু করুন।",
"desktop.cli.failed.title": "ইনস্টলেশন ব্যর্থ হয়েছে",
"desktop.cli.failed.message": "CLI ইনস্টল করতে ব্যর্থ: {{error}}",
"desktop.error.dev.rootNotFound":
"মূল উপাদান পাওয়া যায়নি. আপনি কি আপনার index.html এ যোগ করতে ভুলে গেছেন? অথবা হয়তো আইডি অ্যাট্রিবিউট ভুল বানান হয়েছে?",
}
+3
View File
@@ -24,4 +24,7 @@ export const dict = {
"desktop.cli.installed.message": "CLI instalada em {{path}}\n\nReinicie seu terminal para usar o comando 'opencode'.",
"desktop.cli.failed.title": "Falha na instalação",
"desktop.cli.failed.message": "Falha ao instalar a CLI: {{error}}",
"desktop.error.dev.rootNotFound":
"Elemento raiz não encontrado. Você esqueceu de adicioná-lo ao seu index.html? Ou talvez o atributo id foi escrito incorretamente?",
}
+3
View File
@@ -25,4 +25,7 @@ export const dict = {
"CLI je instaliran u {{path}}\n\nPonovo pokreni terminal da bi koristio komandu 'opencode'.",
"desktop.cli.failed.title": "Instalacija nije uspjela",
"desktop.cli.failed.message": "Neuspjela instalacija CLI-a: {{error}}",
"desktop.error.dev.rootNotFound":
"Korijenski element nije pronađen. Da li si zaboravio da ga dodaš u index.html? Ili je možda id atribut pogrešno napisan?",
}
+28
View File
@@ -0,0 +1,28 @@
export const dict = {
"desktop.menu.checkForUpdates": "Comproveu si hi ha actualitzacions...",
"desktop.menu.installCli": "Instal·la CLI...",
"desktop.menu.reloadWebview": "Torna a carregar Webview",
"desktop.menu.restart": "Reinicia",
"desktop.dialog.chooseFolder": "Trieu una carpeta",
"desktop.dialog.chooseFile": "Trieu un fitxer",
"desktop.dialog.saveFile": "Desa el fitxer",
"desktop.updater.checkFailed.title": "La comprovació d'actualització ha fallat",
"desktop.updater.checkFailed.message": "No s'ha pogut comprovar si hi ha actualitzacions",
"desktop.updater.none.title": "No hi ha cap actualització disponible",
"desktop.updater.none.message": "Ja utilitzeu la versió més recent d'OpenCode",
"desktop.updater.downloadFailed.title": "L'actualització ha fallat",
"desktop.updater.downloadFailed.message": "No s'ha pogut descarregar l'actualització",
"desktop.updater.downloaded.title": "Actualització baixada",
"desktop.updater.downloaded.prompt":
"S'ha baixat la versió {{version}} d'OpenCode. Voleu instal·lar-la i reiniciar l'aplicació?",
"desktop.updater.installFailed.title": "L'actualització ha fallat",
"desktop.updater.installFailed.message": "No s'ha pogut instal·lar l'actualització",
"desktop.cli.installed.title": "CLI instal·lada",
"desktop.cli.installed.message":
"CLI instal·lada a {{path}}\n\nReinicieu el terminal per utilitzar l'ordre 'opencode'.",
"desktop.cli.failed.title": "La instal·lació ha fallat",
"desktop.cli.failed.message": "No s'ha pogut instal·lar CLI: {{error}}",
"desktop.error.dev.rootNotFound":
"No s'ha trobat l'element arrel. T'has oblidat d'afegir-lo al teu index.html? O potser l'atribut id s'ha escrit malament?",
}
+28
View File
@@ -0,0 +1,28 @@
export const dict = {
"desktop.menu.checkForUpdates": "Zkontrolovat aktualizace...",
"desktop.menu.installCli": "Instalovat CLI...",
"desktop.menu.reloadWebview": "Znovu načíst Webview",
"desktop.menu.restart": "Restartovat",
"desktop.dialog.chooseFolder": "Vyberte složku",
"desktop.dialog.chooseFile": "Vyberte soubor",
"desktop.dialog.saveFile": "Uložit soubor",
"desktop.updater.checkFailed.title": "Kontrola aktualizace se nezdařila",
"desktop.updater.checkFailed.message": "Kontrola aktualizací se nezdařila",
"desktop.updater.none.title": "Není k dispozici žádná aktualizace",
"desktop.updater.none.message": "Již používáte nejnovější verzi OpenCode",
"desktop.updater.downloadFailed.title": "Aktualizace se nezdařila",
"desktop.updater.downloadFailed.message": "Stažení aktualizace se nezdařilo",
"desktop.updater.downloaded.title": "Aktualizace stažena",
"desktop.updater.downloaded.prompt":
"Byla stažena verze {{version}} aplikace OpenCode. Chcete ji nainstalovat a aplikaci znovu spustit?",
"desktop.updater.installFailed.title": "Aktualizace se nezdařila",
"desktop.updater.installFailed.message": "Aktualizaci se nepodařilo nainstalovat",
"desktop.cli.installed.title": "CLI nainstalováno",
"desktop.cli.installed.message":
"CLI nainstalováno do {{path}}\n\nRestartujte svůj terminál, abyste mohli použít příkaz 'opencode'.",
"desktop.cli.failed.title": "Instalace se nezdařila",
"desktop.cli.failed.message": "Instalace CLI se nezdařila: {{error}}",
"desktop.error.dev.rootNotFound":
"Kořenový prvek nenalezen. Zapomněli jste to přidat do index.html? Nebo je možná chyba v atributu id?",
}
+3
View File
@@ -25,4 +25,7 @@ export const dict = {
"CLI installeret i {{path}}\n\nGenstart din terminal for at bruge 'opencode'-kommandoen.",
"desktop.cli.failed.title": "Installation mislykkedes",
"desktop.cli.failed.message": "Kunne ikke installere CLI: {{error}}",
"desktop.error.dev.rootNotFound":
"Rodelement ikke fundet. Har du glemt at tilføje det til din index.html? Eller måske er id-attributten stavet forkert?",
}
+3
View File
@@ -25,4 +25,7 @@ export const dict = {
"CLI wurde in {{path}} installiert\n\nStarten Sie Ihr Terminal neu, um den Befehl 'opencode' zu verwenden.",
"desktop.cli.failed.title": "Installation fehlgeschlagen",
"desktop.cli.failed.message": "CLI konnte nicht installiert werden: {{error}}",
"desktop.error.dev.rootNotFound":
"Wurzelelement nicht gefunden. Haben Sie vergessen, es in Ihre index.html aufzunehmen? Oder wurde das ID-Attribut falsch geschrieben?",
}
+28
View File
@@ -0,0 +1,28 @@
export const dict = {
"desktop.menu.checkForUpdates": "އަޕްޑޭޓްސް އަށް ޗެކް ކޮށްލައްވާ...",
"desktop.menu.installCli": "CLI އިންސްޓޯލް ކުރާށެވެ...",
"desktop.menu.reloadWebview": "ވެބްވިއު ރީލޯޑް ކުރާށެވެ",
"desktop.menu.restart": "އަލުން ފަށާށެވެ",
"desktop.dialog.chooseFolder": "ފޯލްޑަރެއް ހޮވާށެވެ",
"desktop.dialog.chooseFile": "ފައިލެއް ހޮވާށެވެ",
"desktop.dialog.saveFile": "ފައިލް ސޭވްކުރުން",
"desktop.updater.checkFailed.title": "އަޕްޑޭޓް ޗެކް ފެއިލްވެއްޖެ",
"desktop.updater.checkFailed.message": "އަޕްޑޭޓްތައް ޗެކް ނުކުރެވުނެވެ",
"desktop.updater.none.title": "އެއްވެސް އަޕްޑޭޓެއް ނުލިބެއެވެ",
"desktop.updater.none.message": "މިހާރުވެސް ބޭނުން ކުރަމުންދަނީ OpenCode ގެ އެންމެ ފަހުގެ ވަރޝަން އެވެ",
"desktop.updater.downloadFailed.title": "އަޕްޑޭޓް ފެއިލްވެއްޖެ",
"desktop.updater.downloadFailed.message": "އަޕްޑޭޓް ޑައުންލޯޑް ނުކުރެވުނެވެ",
"desktop.updater.downloaded.title": "އަޕްޑޭޓް ޑައުންލޯޑް ކުރެވިއްޖެއެވެ",
"desktop.updater.downloaded.prompt":
"OpenCode ގެ ވަރޝަން {{version}} ޑައުންލޯޑް ކުރެވިއްޖެ، އިންސްޓޯލްކޮށް އަލުން ލޯންޗް ކުރަން ބޭނުން ހެއްޔެވެ؟",
"desktop.updater.installFailed.title": "އަޕްޑޭޓް ފެއިލްވެއްޖެ",
"desktop.updater.installFailed.message": "އަޕްޑޭޓް އިންސްޓޯލް ކުރަން ނާކާމިޔާބުވިއެވެ",
"desktop.cli.installed.title": "CLI އިންސްޓޯލް ކުރެވިއްޖެއެވެ",
"desktop.cli.installed.message":
"CLI އިންސްޓޯލްކޮށްފައިވަނީ {{path}} އަށެވެ\n\n'opencode' ކޮމާންޑް ބޭނުން ކުރުމަށް ޓާމިނަލް އަލުން ސްޓާޓް ކުރާށެވެ.",
"desktop.cli.failed.title": "އިންސްޓޯލް ކުރުން ފެއިލްވެއްޖެ",
"desktop.cli.failed.message": "CLI: {{error}} އިންސްޓޯލް ކުރަން ނާކާމިޔާބު",
"desktop.error.dev.rootNotFound":
"ރޫޓް އެލިމެންޓް ނުފެނެއެވެ. ތިބާގެ index.html އަށް އެޑް ކުރަން ހަނދާން ނެތުނީ ހެއްޔެވެ؟ ނުވަތަ id އެޓްރިބިއުޓް ގޯސްކޮށް އިމްތިހާނު ވެދާނެ ހެއްޔެވެ؟",
}
+28
View File
@@ -0,0 +1,28 @@
export const dict: Record<string, string> = {
"desktop.menu.checkForUpdates": "དུས་མཐུན་ཚུ་གི་དོན་ལུ་ཞིབ་དཔྱད་འབད།",
"desktop.menu.installCli": "CLI...",
"desktop.menu.reloadWebview": "ཡང་བསྐྱར་མངོན་གསལ་ Webview།",
"desktop.menu.restart": "ལོག་འགོ་བཙུགས།",
"desktop.dialog.chooseFolder": "སྣོད་འཛིན་ཅིག་གདམ་ཁ་རྐྱབས།",
"desktop.dialog.chooseFile": "ཡིག་སྣོད་གདམ་ཁ་རྐྱབས།",
"desktop.dialog.saveFile": "ཡིག་སྣོད་སྲུངས།",
"desktop.updater.checkFailed.title": "དུས་མཐུན་ཞིབ་དཔྱད་འཐུས་ཤོར་འབྱུང་ཡོདཔ།",
"desktop.updater.checkFailed.message": "དུས་མཐུན་བཟོ་ནིའི་དོན་ལུ་ ཞིབ་དཔྱད་འབད་ནི་ལུ་འཐུས་ཤོར་འབྱུང་ཡོདཔ།",
"desktop.updater.none.title": "དུས་མཐུན་བཟོ་མི་ཚུགས།",
"desktop.updater.none.message": "ཁྱོད་ཀྱིས་ཧེ་མ་ལས་ OpenCodeགི་ཐོན་རིམ་གསརཔ་འདི་ལག་ལེན་འཐབ་དོ།",
"desktop.updater.downloadFailed.title": "དུས་མཐུན་འཐུས་ཤོར་འབྱུང་ཡོདཔ།",
"desktop.updater.downloadFailed.message": "དུས་མཐུན་ཕབ་ལེན་འབད་ནི་ལུ་འཐུས་ཤོར་འབྱུང་ཡོདཔ།",
"desktop.updater.downloaded.title": "དུས་མཐུན་ཕབ་ལེན་འབད་ཡོདཔ།",
"desktop.updater.downloaded.prompt":
"OpenCode གི་ཐོན་རིམ་ {{version}} ཕབ་ལེན་འབད་ཡོདཔ་ལས་ གཞི་བཙུགས་འབད་དེ་ ལོག་འགོ་བཙུགས་ནི་ཨིན་ན?",
"desktop.updater.installFailed.title": "དུས་མཐུན་འཐུས་ཤོར་འབྱུང་ཡོདཔ།",
"desktop.updater.installFailed.message": "དུས་མཐུན་གཞི་བཙུགས་འབད་ནི་ལུ་འཐུས་ཤོར་འབྱུང་ཡོདཔ།",
"desktop.cli.installed.title": "CLI བཙུགས་ཡོདཔ།",
"desktop.cli.installed.message":
"CLI {{path}}\n\nལུ་གཞི་བཙུགས་འབད་ནུག 'opencode' བརྡ་བཀོད་ལག་ལེན་འཐབ་ནིའི་དོན་ལུ་ ཁྱོད་རའི་ཊར་མི་ནཱལ་དེ་ལོག་འགོ་བཙུགས།",
"desktop.cli.failed.title": "གཞི་བཙུགས་འཐུས་ཤོར་འབྱུང་ཡོདཔ།",
"desktop.cli.failed.message": "CLI: {{error}} གཞི་བཙུགས་འབད་ནི་ལུ་འཐུས་ཤོར་འབྱུང་ཡོདཔ།",
"desktop.error.dev.rootNotFound":
"རྩ་བའི་ཆ་ཤས་འཚོལ་མ་ཐོབ། ཁྱོད་ཀྱི་ index.html ལུ་ཁ་སྐོང་འབད་ནི་བརྗེད་སོང་ག? ཡང་ན་ id ཁྱད་ཆོས་འདི་ཡིག་སྡེབ་འཛོལ་བ་འོང་ག?",
}
+28
View File
@@ -0,0 +1,28 @@
export const dict = {
"desktop.menu.checkForUpdates": "Έλεγχος για ενημερώσεις...",
"desktop.menu.installCli": "Εγκατάσταση CLI...",
"desktop.menu.reloadWebview": "Επανάληψη φόρτωσης Webview",
"desktop.menu.restart": "Επανεκκίνηση",
"desktop.dialog.chooseFolder": "Επιλογή φακέλου",
"desktop.dialog.chooseFile": "Επιλογή αρχείου",
"desktop.dialog.saveFile": "Αποθήκευση αρχείου",
"desktop.updater.checkFailed.title": "Ο έλεγχος ενημέρωσης απέτυχε",
"desktop.updater.checkFailed.message": "Απέτυχε ο έλεγχος για ενημερώσεις",
"desktop.updater.none.title": "Δεν υπάρχει διαθέσιμη ενημέρωση",
"desktop.updater.none.message": "Χρησιμοποιείτε ήδη την πιο πρόσφατη έκδοση του OpenCode",
"desktop.updater.downloadFailed.title": "Η ενημέρωση απέτυχε",
"desktop.updater.downloadFailed.message": "Απέτυχε η λήψη της ενημέρωσης",
"desktop.updater.downloaded.title": "Η ενημέρωση λήφθηκε",
"desktop.updater.downloaded.prompt":
"Έχει γίνει λήψη της έκδοσης {{version}} του OpenCode. Θέλετε να την εγκαταστήσετε και να επανεκκινήσετε την εφαρμογή;",
"desktop.updater.installFailed.title": "Η ενημέρωση απέτυχε",
"desktop.updater.installFailed.message": "Αποτυχία εγκατάστασης ενημέρωσης",
"desktop.cli.installed.title": "Το CLI εγκαταστάθηκε",
"desktop.cli.installed.message":
"CLI εγκατεστημένο στο {{path}}\n\nΕπανεκκινήστε το τερματικό σας για να χρησιμοποιήσετε την εντολή 'opencode'.",
"desktop.cli.failed.title": "Η εγκατάσταση απέτυχε",
"desktop.cli.failed.message": "Απέτυχε η εγκατάσταση του CLI: {{error}}",
"desktop.error.dev.rootNotFound":
"Το στοιχείο ρίζας δεν βρέθηκε. Ξεχάσατε να το προσθέσετε στο index.html; Ή μήπως το χαρακτηριστικό id γράφτηκε λάθος;",
}
+3
View File
@@ -24,4 +24,7 @@ export const dict = {
"desktop.cli.installed.message": "CLI installed to {{path}}\n\nRestart your terminal to use the 'opencode' command.",
"desktop.cli.failed.title": "Installation Failed",
"desktop.cli.failed.message": "Failed to install CLI: {{error}}",
"desktop.error.dev.rootNotFound":
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
}
+3
View File
@@ -24,4 +24,7 @@ export const dict = {
"desktop.cli.installed.message": "CLI instalada en {{path}}\n\nReinicia tu terminal para usar el comando 'opencode'.",
"desktop.cli.failed.title": "Instalación fallida",
"desktop.cli.failed.message": "No se pudo instalar la CLI: {{error}}",
"desktop.error.dev.rootNotFound":
"Elemento raíz no encontrado. ¿Olvidaste añadirlo a tu index.html? ¿O tal vez el atributo id está mal escrito?",
}
+28
View File
@@ -0,0 +1,28 @@
export const dict = {
"desktop.menu.checkForUpdates": "Kontrolli värskendusi...",
"desktop.menu.installCli": "Installi CLI...",
"desktop.menu.reloadWebview": "Laadi veebivaade uuesti",
"desktop.menu.restart": "Taaskäivita",
"desktop.dialog.chooseFolder": "Valige kaust",
"desktop.dialog.chooseFile": "Valige fail",
"desktop.dialog.saveFile": "Salvesta fail",
"desktop.updater.checkFailed.title": "Värskenduskontroll ebaõnnestus",
"desktop.updater.checkFailed.message": "Värskenduste kontrollimine ebaõnnestus",
"desktop.updater.none.title": "Värskendus pole saadaval",
"desktop.updater.none.message": "Kasutate juba rakenduse OpenCode uusimat versiooni",
"desktop.updater.downloadFailed.title": "Värskendus ebaõnnestus",
"desktop.updater.downloadFailed.message": "Värskenduse allalaadimine ebaõnnestus",
"desktop.updater.downloaded.title": "Värskendus alla laaditud",
"desktop.updater.downloaded.prompt":
"OpenCode'i versioon {{version}} on alla laaditud. Kas soovite selle installida ja rakenduse taaskäivitada?",
"desktop.updater.installFailed.title": "Värskendus ebaõnnestus",
"desktop.updater.installFailed.message": "Värskenduse installimine ebaõnnestus",
"desktop.cli.installed.title": "CLI installitud",
"desktop.cli.installed.message":
"CLI installitud asukohta {{path}}\n\nKäsu „opencode” kasutamiseks taaskäivitage terminal.",
"desktop.cli.failed.title": "Installimine ebaõnnestus",
"desktop.cli.failed.message": "CLI installimine ebaõnnestus: {{error}}",
"desktop.error.dev.rootNotFound":
"Juurelementi ei leitud. Kas unustasite selle lisada oma loendisse index.html? Või äkki on id-atribuut valesti kirjutatud?",
}
+28
View File
@@ -0,0 +1,28 @@
export const dict = {
"desktop.menu.checkForUpdates": "بررسی به روز رسانی...",
"desktop.menu.installCli": "نصب CLI...",
"desktop.menu.reloadWebview": "بارگذاری مجدد Webview",
"desktop.menu.restart": "راه اندازی مجدد",
"desktop.dialog.chooseFolder": "یک پوشه را انتخاب کنید",
"desktop.dialog.chooseFile": "یک فایل را انتخاب کنید",
"desktop.dialog.saveFile": "ذخیره فایل",
"desktop.updater.checkFailed.title": "بررسی به‌روزرسانی انجام نشد",
"desktop.updater.checkFailed.message": "بررسی به‌روزرسانی‌ها انجام نشد",
"desktop.updater.none.title": "به روز رسانی موجود نیست",
"desktop.updater.none.message": "شما در حال حاضر از آخرین نسخه OpenCode استفاده می کنید",
"desktop.updater.downloadFailed.title": "به روز رسانی انجام نشد",
"desktop.updater.downloadFailed.message": "به روز رسانی دانلود نشد",
"desktop.updater.downloaded.title": "به روز رسانی دانلود شد",
"desktop.updater.downloaded.prompt":
"نسخه {{version}} OpenCode دانلود شده است، آیا می خواهید آن را نصب کنید و دوباره راه اندازی کنید؟",
"desktop.updater.installFailed.title": "به روز رسانی انجام نشد",
"desktop.updater.installFailed.message": "به روز رسانی نصب نشد",
"desktop.cli.installed.title": "CLI نصب شده است",
"desktop.cli.installed.message":
"CLI روی {{path}} نصب شد\n\nترمینال خود را مجددا راه اندازی کنید تا از دستور 'opencode' استفاده کنید.",
"desktop.cli.failed.title": "نصب ناموفق بود",
"desktop.cli.failed.message": "CLI نصب نشد: {{error}}",
"desktop.error.dev.rootNotFound":
"عنصر ریشه یافت نشد. آیا فراموش کرده اید که آن را به index.html خود اضافه کنید؟ یا شاید ویژگی id اشتباه املایی داشته باشد؟",
}
+3
View File
@@ -22,4 +22,7 @@ export const dict = {
"CLI on asennettu polkuun {{path}}\n\nKäynnistä terminaali uudelleen, jotta voit käyttää 'opencode'-komentoa.",
"desktop.cli.failed.title": "Asennus epäonnistui",
"desktop.cli.failed.message": "CLI:n asennus epäonnistui: {{error}}",
"desktop.error.dev.rootNotFound":
"Juurielementtiä ei löydy. Unohditko lisätä sen index.html-tiedostoosi? Tai ehkä id-attribuutti on kirjoitettu väärin?",
}
+28
View File
@@ -0,0 +1,28 @@
export const dict = {
"desktop.menu.checkForUpdates": "Kanna fyri dagføringum...",
"desktop.menu.installCli": "Set upp CLI...",
"desktop.menu.reloadWebview": "Endurlesa vevvísing",
"desktop.menu.restart": "Endurbyrja",
"desktop.dialog.chooseFolder": "Vel eina mappu",
"desktop.dialog.chooseFile": "Vel eina fílu",
"desktop.dialog.saveFile": "Goym fílu",
"desktop.updater.checkFailed.title": "Dagføringarkanningin miseydnaðist",
"desktop.updater.checkFailed.message": "Tað eydnaðist ikki at kanna fyri dagføringum",
"desktop.updater.none.title": "Eingin dagføring er tøk",
"desktop.updater.none.message": "Tú brúkar longu nýggjastu útgávuna av OpenCode.",
"desktop.updater.downloadFailed.title": "Dagføring miseydnaðist",
"desktop.updater.downloadFailed.message": "Tað eydnaðist ikki at heinta dagføring",
"desktop.updater.downloaded.title": "Dagføring heintað",
"desktop.updater.downloaded.prompt":
"Útgáva {{version}} av OpenCode er heintað, vilt tú seta hana upp og seta hana í gongd aftur?",
"desktop.updater.installFailed.title": "Dagføring miseydnaðist",
"desktop.updater.installFailed.message": "Tað eydnaðist ikki at seta upp dagføring",
"desktop.cli.installed.title": "CLI Sett upp",
"desktop.cli.installed.message":
"CLI sett upp í {{path}}\n\nEndurbyrja terminalin fyri at brúka skipanina 'opencode'.",
"desktop.cli.failed.title": "Innleggingin miseydnaðist",
"desktop.cli.failed.message": "Tað eydnaðist ikki at seta upp CLI: {{error}}",
"desktop.error.dev.rootNotFound":
"Rótarevni ikki funnið. Gloymdi tú at leggja tað til títt index.html? Ella kanska fekk id eginleikin skeivt stavað?",
}
+3
View File
@@ -25,4 +25,7 @@ export const dict = {
"Interface en ligne de commande installée dans {{path}}\n\nRedémarrez votre terminal pour utiliser la commande 'opencode'.",
"desktop.cli.failed.title": "Échec de l'installation",
"desktop.cli.failed.message": "Impossible d'installer l'interface en ligne de commande : {{error}}",
"desktop.error.dev.rootNotFound":
"Élément racine introuvable. Avez-vous oublié de l'ajouter à votre index.html ? Ou peut-être que l'attribut id est mal orthographié ?",
}

Some files were not shown because too many files have changed in this diff Show More