summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/context
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-25 06:25:57 -0500
committerAdam <[email protected]>2026-03-25 06:25:57 -0500
commit1041ae91d1a39401fe099747e3bc093bdcdaa079 (patch)
treeca5515910ad01f76639577ef8e3a991b644a5ade /packages/app/src/context
parent898456a25cf2edbfc4ae4961b37424f633419dd6 (diff)
downloadopencode-1041ae91d1a39401fe099747e3bc093bdcdaa079.tar.gz
opencode-1041ae91d1a39401fe099747e3bc093bdcdaa079.zip
Reapply "fix(app): startup efficiency"
This reverts commit 898456a25cf2edbfc4ae4961b37424f633419dd6.
Diffstat (limited to 'packages/app/src/context')
-rw-r--r--packages/app/src/context/global-sync.tsx58
-rw-r--r--packages/app/src/context/global-sync/bootstrap.ts337
-rw-r--r--packages/app/src/context/language.tsx144
-rw-r--r--packages/app/src/context/notification.tsx6
-rw-r--r--packages/app/src/context/settings.tsx13
-rw-r--r--packages/app/src/context/sync.tsx7
-rw-r--r--packages/app/src/context/terminal-title.ts51
7 files changed, 331 insertions, 285 deletions
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index 2d1e50135..cbd08e99f 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -9,17 +9,7 @@ import type {
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
-import {
- createContext,
- getOwner,
- Match,
- onCleanup,
- onMount,
- type ParentProps,
- Switch,
- untrack,
- useContext,
-} from "solid-js"
+import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist"
@@ -80,6 +70,8 @@ function createGlobalSync() {
let active = true
let projectWritten = false
+ let bootedAt = 0
+ let bootingRoot = false
onCleanup(() => {
active = false
@@ -258,6 +250,11 @@ function createGlobalSync() {
const sdk = sdkFor(directory)
await bootstrapDirectory({
directory,
+ global: {
+ config: globalStore.config,
+ project: globalStore.project,
+ provider: globalStore.provider,
+ },
sdk,
store: child[0],
setStore: child[1],
@@ -278,15 +275,20 @@ function createGlobalSync() {
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
+ const recent = bootingRoot || Date.now() - bootedAt < 1500
if (directory === "global") {
applyGlobalEvent({
event,
project: globalStore.project,
- refresh: queue.refresh,
+ refresh: () => {
+ if (recent) return
+ queue.refresh()
+ },
setGlobalProject: setProjects,
})
if (event.type === "server.connected" || event.type === "global.disposed") {
+ if (recent) return
for (const directory of Object.keys(children.children)) {
queue.push(directory)
}
@@ -325,17 +327,19 @@ function createGlobalSync() {
})
async function bootstrap() {
- await bootstrapGlobal({
- globalSDK: globalSDK.client,
- connectErrorTitle: language.t("dialog.server.add.error"),
- connectErrorDescription: language.t("error.globalSync.connectFailed", {
- url: globalSDK.url,
- }),
- requestFailedTitle: language.t("common.requestFailed"),
- translate: language.t,
- formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
- setGlobalStore: setBootStore,
- })
+ bootingRoot = true
+ try {
+ await bootstrapGlobal({
+ globalSDK: globalSDK.client,
+ requestFailedTitle: language.t("common.requestFailed"),
+ translate: language.t,
+ formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
+ setGlobalStore: setBootStore,
+ })
+ bootedAt = Date.now()
+ } finally {
+ bootingRoot = false
+ }
}
onMount(() => {
@@ -392,13 +396,7 @@ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
export function GlobalSyncProvider(props: ParentProps) {
const value = createGlobalSync()
- return (
- <Switch>
- <Match when={value.ready}>
- <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
- </Match>
- </Switch>
- )
+ return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
}
export function useGlobalSync() {
diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts
index 13494b7ad..47be3abcb 100644
--- a/packages/app/src/context/global-sync/bootstrap.ts
+++ b/packages/app/src/context/global-sync/bootstrap.ts
@@ -31,73 +31,102 @@ type GlobalStore = {
reload: undefined | "pending" | "complete"
}
+function waitForPaint() {
+ return new Promise<void>((resolve) => {
+ let done = false
+ const finish = () => {
+ if (done) return
+ done = true
+ resolve()
+ }
+ const timer = setTimeout(finish, 50)
+ if (typeof requestAnimationFrame !== "function") return
+ requestAnimationFrame(() => {
+ clearTimeout(timer)
+ finish()
+ })
+ })
+}
+
+function errors(list: PromiseSettledResult<unknown>[]) {
+ return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
+}
+
+function runAll(list: Array<() => Promise<unknown>>) {
+ return Promise.allSettled(list.map((item) => item()))
+}
+
+function showErrors(input: {
+ errors: unknown[]
+ title: string
+ translate: (key: string, vars?: Record<string, string | number>) => string
+ formatMoreCount: (count: number) => string
+}) {
+ if (input.errors.length === 0) return
+ const message = formatServerError(input.errors[0], input.translate)
+ const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
+ showToast({
+ variant: "error",
+ title: input.title,
+ description: message + more,
+ })
+}
+
export async function bootstrapGlobal(input: {
globalSDK: OpencodeClient
- connectErrorTitle: string
- connectErrorDescription: string
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
setGlobalStore: SetStoreFunction<GlobalStore>
}) {
- const health = await input.globalSDK.global
- .health()
- .then((x) => x.data)
- .catch(() => undefined)
- if (!health?.healthy) {
- showToast({
- variant: "error",
- title: input.connectErrorTitle,
- description: input.connectErrorDescription,
- })
- input.setGlobalStore("ready", true)
- return
- }
+ const fast = [
+ () =>
+ retry(() =>
+ input.globalSDK.path.get().then((x) => {
+ input.setGlobalStore("path", x.data!)
+ }),
+ ),
+ () =>
+ retry(() =>
+ input.globalSDK.global.config.get().then((x) => {
+ input.setGlobalStore("config", x.data!)
+ }),
+ ),
+ () =>
+ retry(() =>
+ input.globalSDK.provider.list().then((x) => {
+ input.setGlobalStore("provider", normalizeProviderList(x.data!))
+ }),
+ ),
+ ]
- const tasks = [
- retry(() =>
- input.globalSDK.path.get().then((x) => {
- input.setGlobalStore("path", x.data!)
- }),
- ),
- retry(() =>
- input.globalSDK.global.config.get().then((x) => {
- input.setGlobalStore("config", x.data!)
- }),
- ),
- retry(() =>
- input.globalSDK.project.list().then((x) => {
- const projects = (x.data ?? [])
- .filter((p) => !!p?.id)
- .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
- .slice()
- .sort((a, b) => cmp(a.id, b.id))
- input.setGlobalStore("project", projects)
- }),
- ),
- retry(() =>
- input.globalSDK.provider.list().then((x) => {
- input.setGlobalStore("provider", normalizeProviderList(x.data!))
- }),
- ),
- retry(() =>
- input.globalSDK.provider.auth().then((x) => {
- input.setGlobalStore("provider_auth", x.data ?? {})
- }),
- ),
+ const slow = [
+ () =>
+ retry(() =>
+ input.globalSDK.project.list().then((x) => {
+ const projects = (x.data ?? [])
+ .filter((p) => !!p?.id)
+ .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
+ .slice()
+ .sort((a, b) => cmp(a.id, b.id))
+ input.setGlobalStore("project", projects)
+ }),
+ ),
]
- const results = await Promise.allSettled(tasks)
- const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
- if (errors.length) {
- const message = formatServerError(errors[0], input.translate)
- const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
- showToast({
- variant: "error",
- title: input.requestFailedTitle,
- description: message + more,
- })
- }
+ showErrors({
+ errors: errors(await runAll(fast)),
+ title: input.requestFailedTitle,
+ translate: input.translate,
+ formatMoreCount: input.formatMoreCount,
+ })
+ await waitForPaint()
+ showErrors({
+ errors: errors(await runAll(slow)),
+ title: input.requestFailedTitle,
+ translate: input.translate,
+ formatMoreCount: input.formatMoreCount,
+ })
input.setGlobalStore("ready", true)
}
@@ -111,6 +140,10 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
}, {})
}
+function projectID(directory: string, projects: Project[]) {
+ return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
+}
+
export async function bootstrapDirectory(input: {
directory: string
sdk: OpencodeClient
@@ -119,88 +152,130 @@ export async function bootstrapDirectory(input: {
vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void
translate: (key: string, vars?: Record<string, string | number>) => string
+ global: {
+ config: Config
+ project: Project[]
+ provider: ProviderListResponse
+ }
}) {
- if (input.store.status !== "complete") input.setStore("status", "loading")
-
- const blockingRequests = {
- project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
- provider: () =>
- input.sdk.provider.list().then((x) => {
- input.setStore("provider", normalizeProviderList(x.data!))
- }),
- agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
- config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
+ const loading = input.store.status !== "complete"
+ const seededProject = projectID(input.directory, input.global.project)
+ if (seededProject) input.setStore("project", seededProject)
+ if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
+ input.setStore("provider", input.global.provider)
+ }
+ if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
+ input.setStore("config", input.global.config)
+ }
+ if (loading) input.setStore("status", "partial")
+
+ const fast = [
+ () =>
+ seededProject
+ ? Promise.resolve()
+ : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
+ () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))),
+ () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
+ () =>
+ retry(() =>
+ input.sdk.path.get().then((x) => {
+ input.setStore("path", x.data!)
+ const next = projectID(x.data?.directory ?? input.directory, input.global.project)
+ if (next) input.setStore("project", next)
+ }),
+ ),
+ () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
+ () =>
+ retry(() =>
+ input.sdk.vcs.get().then((x) => {
+ const next = x.data ?? input.store.vcs
+ input.setStore("vcs", next)
+ if (next?.branch) input.vcsCache.setStore("value", next)
+ }),
+ ),
+ () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
+ () =>
+ retry(() =>
+ input.sdk.permission.list().then((x) => {
+ const grouped = groupBySession(
+ (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
+ )
+ batch(() => {
+ for (const sessionID of Object.keys(input.store.permission)) {
+ if (grouped[sessionID]) continue
+ input.setStore("permission", sessionID, [])
+ }
+ for (const [sessionID, permissions] of Object.entries(grouped)) {
+ input.setStore(
+ "permission",
+ sessionID,
+ reconcile(
+ permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
+ { key: "id" },
+ ),
+ )
+ }
+ })
+ }),
+ ),
+ () =>
+ retry(() =>
+ input.sdk.question.list().then((x) => {
+ const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
+ batch(() => {
+ for (const sessionID of Object.keys(input.store.question)) {
+ if (grouped[sessionID]) continue
+ input.setStore("question", sessionID, [])
+ }
+ for (const [sessionID, questions] of Object.entries(grouped)) {
+ input.setStore(
+ "question",
+ sessionID,
+ reconcile(
+ questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
+ { key: "id" },
+ ),
+ )
+ }
+ })
+ }),
+ ),
+ ]
+
+ const slow = [
+ () =>
+ retry(() =>
+ input.sdk.provider.list().then((x) => {
+ input.setStore("provider", normalizeProviderList(x.data!))
+ }),
+ ),
+ () => Promise.resolve(input.loadSessions(input.directory)),
+ () => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))),
+ () => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))),
+ ]
+
+ const errs = errors(await runAll(fast))
+ if (errs.length > 0) {
+ console.error("Failed to bootstrap instance", errs[0])
+ const project = getFilename(input.directory)
+ showToast({
+ variant: "error",
+ title: input.translate("toast.project.reloadFailed.title", { project }),
+ description: formatServerError(errs[0], input.translate),
+ })
}
- try {
- await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
- } catch (err) {
- console.error("Failed to bootstrap instance", err)
+ await waitForPaint()
+ const slowErrs = errors(await runAll(slow))
+ if (slowErrs.length > 0) {
+ console.error("Failed to finish bootstrap instance", slowErrs[0])
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
- description: formatServerError(err, input.translate),
+ description: formatServerError(slowErrs[0], input.translate),
})
- input.setStore("status", "partial")
- return
}
- if (input.store.status !== "complete") input.setStore("status", "partial")
-
- Promise.all([
- input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
- input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
- input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
- input.loadSessions(input.directory),
- input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
- input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
- input.sdk.vcs.get().then((x) => {
- const next = x.data ?? input.store.vcs
- input.setStore("vcs", next)
- if (next?.branch) input.vcsCache.setStore("value", next)
- }),
- input.sdk.permission.list().then((x) => {
- const grouped = groupBySession(
- (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
- )
- batch(() => {
- for (const sessionID of Object.keys(input.store.permission)) {
- if (grouped[sessionID]) continue
- input.setStore("permission", sessionID, [])
- }
- for (const [sessionID, permissions] of Object.entries(grouped)) {
- input.setStore(
- "permission",
- sessionID,
- reconcile(
- permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
- { key: "id" },
- ),
- )
- }
- })
- }),
- input.sdk.question.list().then((x) => {
- const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
- batch(() => {
- for (const sessionID of Object.keys(input.store.question)) {
- if (grouped[sessionID]) continue
- input.setStore("question", sessionID, [])
- }
- for (const [sessionID, questions] of Object.entries(grouped)) {
- input.setStore(
- "question",
- sessionID,
- reconcile(
- questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
- { key: "id" },
- ),
- )
- }
- })
- }),
- ]).then(() => {
- input.setStore("status", "complete")
- })
+ if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
}
diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx
index b1edd541c..51dc09cd7 100644
--- a/packages/app/src/context/language.tsx
+++ b/packages/app/src/context/language.tsx
@@ -1,42 +1,10 @@
import * as i18n from "@solid-primitives/i18n"
-import { createEffect, createMemo } from "solid-js"
+import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { Persist, persisted } from "@/utils/persist"
import { dict as en } from "@/i18n/en"
-import { dict as zh } from "@/i18n/zh"
-import { dict as zht } from "@/i18n/zht"
-import { dict as ko } from "@/i18n/ko"
-import { dict as de } from "@/i18n/de"
-import { dict as es } from "@/i18n/es"
-import { dict as fr } from "@/i18n/fr"
-import { dict as da } from "@/i18n/da"
-import { dict as ja } from "@/i18n/ja"
-import { dict as pl } from "@/i18n/pl"
-import { dict as ru } from "@/i18n/ru"
-import { dict as ar } from "@/i18n/ar"
-import { dict as no } from "@/i18n/no"
-import { dict as br } from "@/i18n/br"
-import { dict as th } from "@/i18n/th"
-import { dict as bs } from "@/i18n/bs"
-import { dict as tr } from "@/i18n/tr"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
-import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
-import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
-import { dict as uiKo } from "@opencode-ai/ui/i18n/ko"
-import { dict as uiDe } from "@opencode-ai/ui/i18n/de"
-import { dict as uiEs } from "@opencode-ai/ui/i18n/es"
-import { dict as uiFr } from "@opencode-ai/ui/i18n/fr"
-import { dict as uiDa } from "@opencode-ai/ui/i18n/da"
-import { dict as uiJa } from "@opencode-ai/ui/i18n/ja"
-import { dict as uiPl } from "@opencode-ai/ui/i18n/pl"
-import { dict as uiRu } from "@opencode-ai/ui/i18n/ru"
-import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
-import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
-import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
-import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
-import { dict as uiBs } from "@opencode-ai/ui/i18n/bs"
-import { dict as uiTr } from "@opencode-ai/ui/i18n/tr"
export type Locale =
| "en"
@@ -59,6 +27,7 @@ export type Locale =
type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary>
+type Source = { dict: Record<string, string> }
function cookie(locale: Locale) {
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
@@ -125,24 +94,43 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
}
const base = i18n.flatten({ ...en, ...uiEn })
-const DICT: Record<Locale, Dictionary> = {
- en: base,
- zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) },
- zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) },
- ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) },
- de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) },
- es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) },
- fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) },
- da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) },
- ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) },
- pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) },
- ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) },
- ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) },
- no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) },
- br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) },
- th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) },
- bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) },
- tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) },
+const dicts = new Map<Locale, Dictionary>([["en", base]])
+
+const merge = (app: Promise<Source>, ui: Promise<Source>) =>
+ Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary)
+
+const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
+ zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")),
+ zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")),
+ ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")),
+ de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")),
+ es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")),
+ fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")),
+ da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")),
+ ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")),
+ pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")),
+ ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
+ ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
+ no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
+ br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
+ th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
+ bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
+ tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
+}
+
+function loadDict(locale: Locale) {
+ const hit = dicts.get(locale)
+ if (hit) return Promise.resolve(hit)
+ if (locale === "en") return Promise.resolve(base)
+ const load = loaders[locale]
+ return load().then((next: Dictionary) => {
+ dicts.set(locale, next)
+ return next
+ })
+}
+
+export function loadLocaleDict(locale: Locale) {
+ return loadDict(locale).then(() => undefined)
}
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
@@ -168,27 +156,6 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
{ locale: "tr", match: (language) => language.startsWith("tr") },
]
-type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
-const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
- zh,
- zht,
- ko,
- de,
- es,
- fr,
- da,
- ja,
- pl,
- ru,
- ar,
- no,
- br,
- th,
- bs,
- tr,
-}
-void PARITY_CHECK
-
function detectLocale(): Locale {
if (typeof navigator !== "object") return "en"
@@ -203,27 +170,48 @@ function detectLocale(): Locale {
return "en"
}
-function normalizeLocale(value: string): Locale {
+export function normalizeLocale(value: string): Locale {
return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
}
+function readStoredLocale() {
+ if (typeof localStorage !== "object") return
+ try {
+ const raw = localStorage.getItem("opencode.global.dat:language")
+ if (!raw) return
+ const next = JSON.parse(raw) as { locale?: string }
+ if (typeof next?.locale !== "string") return
+ return normalizeLocale(next.locale)
+ } catch {
+ return
+ }
+}
+
+const warm = readStoredLocale() ?? detectLocale()
+if (warm !== "en") void loadDict(warm)
+
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
name: "Language",
- init: () => {
+ init: (props: { locale?: Locale }) => {
+ const initial = props.locale ?? readStoredLocale() ?? detectLocale()
const [store, setStore, _, ready] = persisted(
Persist.global("language", ["language.v1"]),
createStore({
- locale: detectLocale() as Locale,
+ locale: initial,
}),
)
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
- console.log("locale", locale())
const intl = createMemo(() => INTL[locale()])
- const dict = createMemo<Dictionary>(() => DICT[locale()])
+ const [dict] = createResource(locale, loadDict, {
+ initialValue: dicts.get(initial) ?? base,
+ })
- const t = i18n.translator(dict, i18n.resolveTemplate)
+ const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
+ key: keyof Dictionary,
+ params?: Record<string, string | number | boolean>,
+ ) => string
const label = (value: Locale) => t(LABEL_KEY[value])
diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx
index 04bc2fdaa..281a1ef33 100644
--- a/packages/app/src/context/notification.tsx
+++ b/packages/app/src/context/notification.tsx
@@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist"
-import { playSound, soundSrc } from "@/utils/sound"
+import { playSoundById } from "@/utils/sound"
type NotificationBase = {
directory?: string
@@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (session.parentID) return
if (settings.sounds.agentEnabled()) {
- playSound(soundSrc(settings.sounds.agent()))
+ void playSoundById(settings.sounds.agent())
}
append({
@@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (session?.parentID) return
if (settings.sounds.errorsEnabled()) {
- playSound(soundSrc(settings.sounds.errors()))
+ void playSoundById(settings.sounds.errors())
}
const error = "error" in event.properties ? event.properties.error : undefined
diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx
index 48788fe8e..eddd752eb 100644
--- a/packages/app/src/context/settings.tsx
+++ b/packages/app/src/context/settings.tsx
@@ -104,6 +104,13 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
return createMemo(() => read() ?? fallback)
}
+let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
+
+function loadFont() {
+ font ??= import("@opencode-ai/ui/font-loader")
+ return font
+}
+
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
name: "Settings",
init: () => {
@@ -111,7 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => {
if (typeof document === "undefined") return
- document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
+ const id = store.appearance?.font ?? defaultSettings.appearance.font
+ if (id !== defaultSettings.appearance.font) {
+ void loadFont().then((x) => x.ensureMonoFont(id))
+ }
+ document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id))
})
return {
diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx
index 66b889e2a..bbf4fc5ec 100644
--- a/packages/app/src/context/sync.tsx
+++ b/packages/app/src/context/sync.tsx
@@ -180,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
- const messagePageSize = 200
+ const initialMessagePageSize = 80
+ const historyMessagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
@@ -463,7 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
- const limit = meta.limit[key] ?? messagePageSize
+ const limit = meta.limit[key] ?? initialMessagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
@@ -560,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
- const step = count ?? messagePageSize
+ const step = count ?? historyMessagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
const before = meta.cursor[key]
diff --git a/packages/app/src/context/terminal-title.ts b/packages/app/src/context/terminal-title.ts
index 3e8fa9af2..c8b18f421 100644
--- a/packages/app/src/context/terminal-title.ts
+++ b/packages/app/src/context/terminal-title.ts
@@ -1,45 +1,18 @@
-import { dict as ar } from "@/i18n/ar"
-import { dict as br } from "@/i18n/br"
-import { dict as bs } from "@/i18n/bs"
-import { dict as da } from "@/i18n/da"
-import { dict as de } from "@/i18n/de"
-import { dict as en } from "@/i18n/en"
-import { dict as es } from "@/i18n/es"
-import { dict as fr } from "@/i18n/fr"
-import { dict as ja } from "@/i18n/ja"
-import { dict as ko } from "@/i18n/ko"
-import { dict as no } from "@/i18n/no"
-import { dict as pl } from "@/i18n/pl"
-import { dict as ru } from "@/i18n/ru"
-import { dict as th } from "@/i18n/th"
-import { dict as tr } from "@/i18n/tr"
-import { dict as zh } from "@/i18n/zh"
-import { dict as zht } from "@/i18n/zht"
+const template = "Terminal {{number}}"
-const numbered = Array.from(
- new Set([
- en["terminal.title.numbered"],
- ar["terminal.title.numbered"],
- br["terminal.title.numbered"],
- bs["terminal.title.numbered"],
- da["terminal.title.numbered"],
- de["terminal.title.numbered"],
- es["terminal.title.numbered"],
- fr["terminal.title.numbered"],
- ja["terminal.title.numbered"],
- ko["terminal.title.numbered"],
- no["terminal.title.numbered"],
- pl["terminal.title.numbered"],
- ru["terminal.title.numbered"],
- th["terminal.title.numbered"],
- tr["terminal.title.numbered"],
- zh["terminal.title.numbered"],
- zht["terminal.title.numbered"],
- ]),
-)
+const numbered = [
+ template,
+ "محطة طرفية {{number}}",
+ "Терминал {{number}}",
+ "ターミナル {{number}}",
+ "터미널 {{number}}",
+ "เทอร์มินัล {{number}}",
+ "终端 {{number}}",
+ "終端機 {{number}}",
+]
export function defaultTitle(number: number) {
- return en["terminal.title.numbered"].replace("{{number}}", String(number))
+ return template.replace("{{number}}", String(number))
}
export function isDefaultTitle(title: string, number: number) {