summaryrefslogtreecommitdiffhomepage
path: root/packages/app
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
parent898456a25cf2edbfc4ae4961b37424f633419dd6 (diff)
downloadopencode-1041ae91d1a39401fe099747e3bc093bdcdaa079.tar.gz
opencode-1041ae91d1a39401fe099747e3bc093bdcdaa079.zip
Reapply "fix(app): startup efficiency"
This reverts commit 898456a25cf2edbfc4ae4961b37424f633419dd6.
Diffstat (limited to 'packages/app')
-rw-r--r--packages/app/src/app.tsx8
-rw-r--r--packages/app/src/components/dialog-connect-provider.tsx50
-rw-r--r--packages/app/src/components/prompt-input.tsx1
-rw-r--r--packages/app/src/components/settings-general.tsx50
-rw-r--r--packages/app/src/components/status-popover.tsx23
-rw-r--r--packages/app/src/components/terminal.tsx5
-rw-r--r--packages/app/src/components/titlebar.tsx2
-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
-rw-r--r--packages/app/src/entry.tsx11
-rw-r--r--packages/app/src/hooks/use-providers.ts2
-rw-r--r--packages/app/src/index.ts1
-rw-r--r--packages/app/src/pages/directory-layout.tsx70
-rw-r--r--packages/app/src/pages/home.tsx8
-rw-r--r--packages/app/src/pages/layout.tsx62
-rw-r--r--packages/app/src/pages/session.tsx7
-rw-r--r--packages/app/src/pages/session/use-session-hash-scroll.ts18
-rw-r--r--packages/app/src/utils/server-health.ts24
-rw-r--r--packages/app/src/utils/sound.ts177
-rw-r--r--packages/app/vite.js12
25 files changed, 662 insertions, 485 deletions
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 5247c951d..0eb5b4e9e 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -6,7 +6,7 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/ui/file"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
-import { ThemeProvider } from "@opencode-ai/ui/theme"
+import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
@@ -32,7 +32,7 @@ import { FileProvider } from "@/context/file"
import { GlobalSDKProvider } from "@/context/global-sdk"
import { GlobalSyncProvider } from "@/context/global-sync"
import { HighlightsProvider } from "@/context/highlights"
-import { LanguageProvider, useLanguage } from "@/context/language"
+import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
@@ -130,7 +130,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
)
}
-export function AppBaseProviders(props: ParentProps) {
+export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
return (
<MetaProvider>
<Font />
@@ -139,7 +139,7 @@ export function AppBaseProviders(props: ParentProps) {
void window.api?.setTitlebar?.({ mode })
}}
>
- <LanguageProvider>
+ <LanguageProvider locale={props.locale}>
<UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<QueryProvider>
diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx
index 734958dd5..e7eaa1fb2 100644
--- a/packages/app/src/components/dialog-connect-provider.tsx
+++ b/packages/app/src/components/dialog-connect-provider.tsx
@@ -1,4 +1,4 @@
-import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
+import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
@@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
-import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
+import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useGlobalSDK } from "@/context/global-sdk"
@@ -34,15 +34,25 @@ export function DialogConnectProvider(props: { provider: string }) {
})
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
- const methods = createMemo(
- () =>
- globalSync.data.provider_auth[props.provider] ?? [
- {
- type: "api",
- label: language.t("provider.connect.method.apiKey"),
- },
- ],
+ const fallback = createMemo<ProviderAuthMethod[]>(() => [
+ {
+ type: "api" as const,
+ label: language.t("provider.connect.method.apiKey"),
+ },
+ ])
+ const [auth] = createResource(
+ () => props.provider,
+ async () => {
+ const cached = globalSync.data.provider_auth[props.provider]
+ if (cached) return cached
+ const res = await globalSDK.client.provider.auth()
+ if (!alive.value) return fallback()
+ globalSync.set("provider_auth", res.data ?? {})
+ return res.data?.[props.provider] ?? fallback()
+ },
)
+ const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
+ const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization,
@@ -177,7 +187,11 @@ export function DialogConnectProvider(props: { provider: string }) {
index: 0,
})
- const prompts = createMemo(() => method()?.prompts ?? [])
+ const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
+ const value = method()
+ if (value?.type !== "oauth") return []
+ return value.prompts ?? []
+ })
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true
const actual = value[prompt.when.key]
@@ -296,8 +310,12 @@ export function DialogConnectProvider(props: { provider: string }) {
listRef?.onKeyDown(e)
}
- onMount(() => {
+ let auto = false
+ createEffect(() => {
+ if (auto) return
+ if (loading()) return
if (methods().length === 1) {
+ auto = true
selectMethod(0)
}
})
@@ -573,6 +591,14 @@ export function DialogConnectProvider(props: { provider: string }) {
<div class="px-2.5 pb-10 flex flex-col gap-6">
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
<Switch>
+ <Match when={loading()}>
+ <div class="text-14-regular text-text-base">
+ <div class="flex items-center gap-x-2">
+ <Spinner />
+ <span>{language.t("provider.connect.status.inProgress")}</span>
+ </div>
+ </div>
+ </Match>
<Match when={store.methodIndex === undefined}>
<MethodSelection />
</Match>
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index f523671ec..ee98e68cd 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -572,6 +572,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const open = recent()
const seen = new Set(open)
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
+ if (!query.trim()) return [...agents, ...pinned]
const paths = await files.searchFilesAndDirectories(query)
const fileOptions: AtOption[] = paths
.filter((path) => !seen.has(path))
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx
index b768bafcc..f4b8198e7 100644
--- a/packages/app/src/components/settings-general.tsx
+++ b/packages/app/src/components/settings-general.tsx
@@ -1,27 +1,41 @@
-import { Component, Show, createMemo, createResource, type JSX } from "solid-js"
+import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
+import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings, monoFontFamily } from "@/context/settings"
-import { playSound, SOUND_OPTIONS } from "@/utils/sound"
+import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
import { SettingsList } from "./settings-list"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
timeout: undefined as NodeJS.Timeout | undefined,
+ run: 0,
+}
+
+type ThemeOption = {
+ id: string
+ name: string
+}
+
+let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
+
+function loadFont() {
+ font ??= import("@opencode-ai/ui/font-loader")
+ return font
}
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds.
const stopDemoSound = () => {
+ demoSoundState.run += 1
if (demoSoundState.cleanup) {
demoSoundState.cleanup()
}
@@ -29,12 +43,19 @@ const stopDemoSound = () => {
demoSoundState.cleanup = undefined
}
-const playDemoSound = (src: string | undefined) => {
+const playDemoSound = (id: string | undefined) => {
stopDemoSound()
- if (!src) return
+ if (!id) return
+ const run = ++demoSoundState.run
demoSoundState.timeout = setTimeout(() => {
- demoSoundState.cleanup = playSound(src)
+ void playSoundById(id).then((cleanup) => {
+ if (demoSoundState.run !== run) {
+ cleanup?.()
+ return
+ }
+ demoSoundState.cleanup = cleanup
+ })
}, 100)
}
@@ -44,6 +65,10 @@ export const SettingsGeneral: Component = () => {
const platform = usePlatform()
const settings = useSettings()
+ onMount(() => {
+ void theme.loadThemes()
+ })
+
const [store, setStore] = createStore({
checking: false,
})
@@ -104,9 +129,7 @@ export const SettingsGeneral: Component = () => {
.finally(() => setStore("checking", false))
}
- const themeOptions = createMemo(() =>
- Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
- )
+ const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
@@ -143,7 +166,7 @@ export const SettingsGeneral: Component = () => {
] as const
const fontOptionsList = [...fontOptions]
- const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const
+ const noneSound = { id: "none", label: "sound.option.none" } as const
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const soundSelectProps = (
@@ -158,7 +181,7 @@ export const SettingsGeneral: Component = () => {
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
- playDemoSound(option.src)
+ playDemoSound(option.id === "none" ? undefined : option.id)
},
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
@@ -169,7 +192,7 @@ export const SettingsGeneral: Component = () => {
}
setEnabled(true)
set(option.id)
- playDemoSound(option.src)
+ playDemoSound(option.id)
},
variant: "secondary" as const,
size: "small" as const,
@@ -321,6 +344,9 @@ export const SettingsGeneral: Component = () => {
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
value={(o) => o.value}
label={(o) => language.t(o.label)}
+ onHighlight={(option) => {
+ void loadFont().then((x) => x.ensureMonoFont(option?.value))
+ }}
onSelect={(option) => option && settings.appearance.setFont(option.value)}
variant="secondary"
size="small"
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx
index 464522443..8d5ecac39 100644
--- a/packages/app/src/components/status-popover.tsx
+++ b/packages/app/src/components/status-popover.tsx
@@ -16,7 +16,6 @@ import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
-import { DialogSelectServer } from "./dialog-select-server"
const pollMs = 10_000
@@ -54,11 +53,15 @@ const listServersByHealth = (
})
}
-const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
+const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
const checkServerHealth = useCheckServerHealth()
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => {
+ if (!enabled()) {
+ setStatus(reconcile({}))
+ return
+ }
const list = servers()
let dead = false
@@ -162,6 +165,12 @@ export function StatusPopover() {
const navigate = useNavigate()
const [shown, setShown] = createSignal(false)
+ let dialogRun = 0
+ let dialogDead = false
+ onCleanup(() => {
+ dialogDead = true
+ dialogRun += 1
+ })
const servers = createMemo(() => {
const current = server.current
const list = server.list
@@ -169,7 +178,7 @@ export function StatusPopover() {
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
})
- const health = useServerHealth(servers)
+ const health = useServerHealth(servers, shown)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const toggleMcp = useMcpToggleMutation()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
@@ -300,7 +309,13 @@ export function StatusPopover() {
<Button
variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5"
- onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
+ onClick={() => {
+ const run = ++dialogRun
+ void import("./dialog-select-server").then((x) => {
+ if (dialogDead || dialogRun !== run) return
+ dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
+ })
+ }}
>
{language.t("status.popover.action.manageServers")}
</Button>
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index aed46f126..0a5a7d2d3 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -1,4 +1,7 @@
-import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
+import { withAlpha } from "@opencode-ai/ui/theme/color"
+import { useTheme } from "@opencode-ai/ui/theme/context"
+import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
+import type { HexColor } from "@opencode-ai/ui/theme/types"
import { showToast } from "@opencode-ai/ui/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx
index 77de1a73c..0a41f3119 100644
--- a/packages/app/src/components/titlebar.tsx
+++ b/packages/app/src/components/titlebar.tsx
@@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
-import { useTheme } from "@opencode-ai/ui/theme"
+import { useTheme } from "@opencode-ai/ui/theme/context"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
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) {
diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx
index b5cbed6e7..da22c5552 100644
--- a/packages/app/src/entry.tsx
+++ b/packages/app/src/entry.tsx
@@ -97,10 +97,15 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
throw new Error(getRootNotFoundError())
}
+const localUrl = () =>
+ `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
+
+const isLocalHost = () => ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname)
+
const getCurrentUrl = () => {
- if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
- if (import.meta.env.DEV)
- return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
+ if (location.hostname.includes("opencode.ai")) return localUrl()
+ if (import.meta.env.DEV) return localUrl()
+ if (isLocalHost()) return localUrl()
return location.origin
}
diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts
index a25f8b4b2..a8f2360bb 100644
--- a/packages/app/src/hooks/use-providers.ts
+++ b/packages/app/src/hooks/use-providers.ts
@@ -22,7 +22,7 @@ export function useProviders() {
const providers = () => {
if (dir()) {
const [projectStore] = globalSync.child(dir())
- return projectStore.provider
+ if (projectStore.provider.all.length > 0) return projectStore.provider
}
return globalSync.data.provider
}
diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts
index 53063f48f..d80e9fffb 100644
--- a/packages/app/src/index.ts
+++ b/packages/app/src/index.ts
@@ -1,6 +1,7 @@
export { AppBaseProviders, AppInterface } from "./app"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command"
+export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click"
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
index cd5e079a6..6d3b04be9 100644
--- a/packages/app/src/pages/directory-layout.tsx
+++ b/packages/app/src/pages/directory-layout.tsx
@@ -2,8 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
-import { createMemo, createResource, type ParentProps, Show } from "solid-js"
-import { useGlobalSDK } from "@/context/global-sdk"
+import { createEffect, createMemo, type ParentProps, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk"
@@ -11,10 +10,18 @@ import { SyncProvider, useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
+ const location = useLocation()
const navigate = useNavigate()
const sync = useSync()
const slug = createMemo(() => base64Encode(props.directory))
+ createEffect(() => {
+ const next = sync.data.path.directory
+ if (!next || next === props.directory) return
+ const path = location.pathname.slice(slug().length + 1)
+ navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
+ })
+
return (
<DataProvider
data={sync.data}
@@ -29,50 +36,31 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
export default function Layout(props: ParentProps) {
const params = useParams()
- const location = useLocation()
const language = useLanguage()
- const globalSDK = useGlobalSDK()
const navigate = useNavigate()
let invalid = ""
- const [resolved] = createResource(
- () => {
- if (params.dir) return [location.pathname, params.dir] as const
- },
- async ([pathname, b64Dir]) => {
- const directory = decode64(b64Dir)
+ const resolved = createMemo(() => {
+ if (!params.dir) return ""
+ return decode64(params.dir) ?? ""
+ })
- if (!directory) {
- if (invalid === params.dir) return
- invalid = b64Dir
- showToast({
- variant: "error",
- title: language.t("common.requestFailed"),
- description: language.t("directory.error.invalidUrl"),
- })
- navigate("/", { replace: true })
- return
- }
-
- return await globalSDK
- .createClient({
- directory,
- throwOnError: true,
- })
- .path.get()
- .then((x) => {
- const next = x.data?.directory ?? directory
- invalid = ""
- if (next === directory) return next
- const path = pathname.slice(b64Dir.length + 1)
- navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
- })
- .catch(() => {
- invalid = ""
- return directory
- })
- },
- )
+ createEffect(() => {
+ const dir = params.dir
+ if (!dir) return
+ if (resolved()) {
+ invalid = ""
+ return
+ }
+ if (invalid === dir) return
+ invalid = dir
+ showToast({
+ variant: "error",
+ title: language.t("common.requestFailed"),
+ description: language.t("directory.error.invalidUrl"),
+ })
+ navigate("/", { replace: true })
+ })
return (
<Show when={resolved()} keyed>
diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx
index ba3a2b942..4c795b968 100644
--- a/packages/app/src/pages/home.tsx
+++ b/packages/app/src/pages/home.tsx
@@ -113,6 +113,14 @@ export default function Home() {
</ul>
</div>
</Match>
+ <Match when={!sync.ready}>
+ <div class="mt-30 mx-auto flex flex-col items-center gap-3">
+ <div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
+ <Button class="px-3" onClick={chooseProject}>
+ {language.t("command.project.open")}
+ </Button>
+ </div>
+ </Match>
<Match when={true}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<Icon name="folder-add-left" size="large" />
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 01e151605..b5a96110f 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -49,21 +49,16 @@ import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
-import { playSound, soundSrc } from "@/utils/sound"
+import { playSoundById } from "@/utils/sound"
import { createAim } from "@/utils/aim"
import { setNavigate } from "@/utils/notification-click"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
-import { DialogSelectProvider } from "@/components/dialog-select-provider"
-import { DialogSelectServer } from "@/components/dialog-select-server"
-import { DialogSettings } from "@/components/dialog-settings"
+import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
-import { DialogSelectDirectory } from "@/components/dialog-select-directory"
-import { DialogEditProject } from "@/components/dialog-edit-project"
import { DebugBar } from "@/components/debug-bar"
import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
@@ -110,6 +105,8 @@ export default function Layout(props: ParentProps) {
const pageReady = createMemo(() => ready())
let scrollContainerRef: HTMLDivElement | undefined
+ let dialogRun = 0
+ let dialogDead = false
const params = useParams()
const globalSDK = useGlobalSDK()
@@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) {
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
}
})
- const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
+ const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
system: "theme.scheme.system",
@@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) {
})
onCleanup(() => {
+ dialogDead = true
+ dialogRun += 1
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
clearTimeout(sortNowTimeout)
if (sortNowInterval) clearInterval(sortNowInterval)
@@ -336,10 +335,9 @@ export default function Layout(props: ParentProps) {
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
const nextThemeId = ids[nextIndex]
theme.setTheme(nextThemeId)
- const nextTheme = theme.themes()[nextThemeId]
showToast({
title: language.t("toast.theme.title"),
- description: nextTheme?.name ?? nextThemeId,
+ description: theme.name(nextThemeId),
})
}
@@ -494,7 +492,7 @@ export default function Layout(props: ParentProps) {
if (e.details.type === "permission.asked") {
if (settings.sounds.permissionsEnabled()) {
- playSound(soundSrc(settings.sounds.permissions()))
+ void playSoundById(settings.sounds.permissions())
}
if (settings.notifications.permissions()) {
void platform.notify(title, description, href)
@@ -1154,10 +1152,10 @@ export default function Layout(props: ParentProps) {
},
]
- for (const [id, definition] of availableThemeEntries()) {
+ for (const [id] of availableThemeEntries()) {
commands.push({
id: `theme.set.${id}`,
- title: language.t("command.theme.set", { theme: definition.name ?? id }),
+ title: language.t("command.theme.set", { theme: theme.name(id) }),
category: language.t("command.category.theme"),
onSelect: () => theme.commitPreview(),
onHighlight: () => {
@@ -1208,15 +1206,27 @@ export default function Layout(props: ParentProps) {
})
function connectProvider() {
- dialog.show(() => <DialogSelectProvider />)
+ const run = ++dialogRun
+ void import("@/components/dialog-select-provider").then((x) => {
+ if (dialogDead || dialogRun !== run) return
+ dialog.show(() => <x.DialogSelectProvider />)
+ })
}
function openServer() {
- dialog.show(() => <DialogSelectServer />)
+ const run = ++dialogRun
+ void import("@/components/dialog-select-server").then((x) => {
+ if (dialogDead || dialogRun !== run) return
+ dialog.show(() => <x.DialogSelectServer />)
+ })
}
function openSettings() {
- dialog.show(() => <DialogSettings />)
+ const run = ++dialogRun
+ void import("@/components/dialog-settings").then((x) => {
+ if (dialogDead || dialogRun !== run) return
+ dialog.show(() => <x.DialogSettings />)
+ })
}
function projectRoot(directory: string) {
@@ -1443,7 +1453,13 @@ export default function Layout(props: ParentProps) {
layout.sidebar.toggleWorkspaces(project.worktree)
}
- const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />)
+ const showEditProjectDialog = (project: LocalProject) => {
+ const run = ++dialogRun
+ void import("@/components/dialog-edit-project").then((x) => {
+ if (dialogDead || dialogRun !== run) return
+ dialog.show(() => <x.DialogEditProject project={project} />)
+ })
+ }
async function chooseProject() {
function resolve(result: string | string[] | null) {
@@ -1464,10 +1480,14 @@ export default function Layout(props: ParentProps) {
})
resolve(result)
} else {
- dialog.show(
- () => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
- () => resolve(null),
- )
+ const run = ++dialogRun
+ void import("@/components/dialog-select-directory").then((x) => {
+ if (dialogDead || dialogRun !== run) return
+ dialog.show(
+ () => <x.DialogSelectDirectory multiple={true} onSelect={resolve} />,
+ () => resolve(null),
+ )
+ })
}
}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 7a3b476e8..2d3e31355 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -1184,8 +1184,6 @@ export default function Page() {
on(
() => sdk.directory,
() => {
- void file.tree.list("")
-
const tab = activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
@@ -1640,6 +1638,9 @@ export default function Page() {
sessionID: () => params.id,
messagesReady,
visibleUserMessages,
+ historyMore,
+ historyLoading,
+ loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
turnStart: historyWindow.turnStart,
currentMessageId: () => store.messageId,
pendingMessage: () => ui.pendingMessage,
@@ -1711,7 +1712,7 @@ export default function Page() {
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id}>
- <Show when={lastUserMessage()}>
+ <Show when={messagesReady()}>
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts
index 5fadb1f22..c582749d1 100644
--- a/packages/app/src/pages/session/use-session-hash-scroll.ts
+++ b/packages/app/src/pages/session/use-session-hash-scroll.ts
@@ -8,6 +8,9 @@ export const useSessionHashScroll = (input: {
sessionID: () => string | undefined
messagesReady: () => boolean
visibleUserMessages: () => UserMessage[]
+ historyMore: () => boolean
+ historyLoading: () => boolean
+ loadMore: (sessionID: string) => Promise<void>
turnStart: () => number
currentMessageId: () => string | undefined
pendingMessage: () => string | undefined
@@ -181,6 +184,21 @@ export const useSessionHashScroll = (input: {
queue(() => scrollToMessage(msg, "auto"))
})
+ createEffect(() => {
+ const sessionID = input.sessionID()
+ if (!sessionID || !input.messagesReady()) return
+
+ visibleUserMessages()
+
+ let targetId = input.pendingMessage()
+ if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
+ if (!targetId) return
+ if (messageById().has(targetId)) return
+ if (!input.historyMore() || input.historyLoading()) return
+
+ void input.loadMore(sessionID)
+ })
+
onMount(() => {
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual"
diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts
index 45a323c7b..a13fd34ef 100644
--- a/packages/app/src/utils/server-health.ts
+++ b/packages/app/src/utils/server-health.ts
@@ -14,6 +14,15 @@ interface CheckServerHealthOptions {
const defaultTimeoutMs = 3000
const defaultRetryCount = 2
const defaultRetryDelayMs = 100
+const cacheMs = 750
+const healthCache = new Map<
+ string,
+ { at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise<ServerHealth> }
+>()
+
+function cacheKey(server: ServerConnection.HttpBase) {
+ return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}`
+}
function timeoutSignal(timeoutMs: number) {
const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
@@ -87,5 +96,18 @@ export function useCheckServerHealth() {
const platform = usePlatform()
const fetcher = platform.fetch ?? globalThis.fetch
- return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher)
+ return (http: ServerConnection.HttpBase) => {
+ const key = cacheKey(http)
+ const hit = healthCache.get(key)
+ const now = Date.now()
+ if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise
+ const promise = checkServerHealth(http, fetcher).finally(() => {
+ const next = healthCache.get(key)
+ if (!next || next.promise !== promise) return
+ next.done = true
+ next.at = Date.now()
+ })
+ healthCache.set(key, { at: now, done: false, fetch: fetcher, promise })
+ return promise
+ }
}
diff --git a/packages/app/src/utils/sound.ts b/packages/app/src/utils/sound.ts
index 6dea812ec..78e5a0c56 100644
--- a/packages/app/src/utils/sound.ts
+++ b/packages/app/src/utils/sound.ts
@@ -1,106 +1,89 @@
-import alert01 from "@opencode-ai/ui/audio/alert-01.aac"
-import alert02 from "@opencode-ai/ui/audio/alert-02.aac"
-import alert03 from "@opencode-ai/ui/audio/alert-03.aac"
-import alert04 from "@opencode-ai/ui/audio/alert-04.aac"
-import alert05 from "@opencode-ai/ui/audio/alert-05.aac"
-import alert06 from "@opencode-ai/ui/audio/alert-06.aac"
-import alert07 from "@opencode-ai/ui/audio/alert-07.aac"
-import alert08 from "@opencode-ai/ui/audio/alert-08.aac"
-import alert09 from "@opencode-ai/ui/audio/alert-09.aac"
-import alert10 from "@opencode-ai/ui/audio/alert-10.aac"
-import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac"
-import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac"
-import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac"
-import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac"
-import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac"
-import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac"
-import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac"
-import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac"
-import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac"
-import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac"
-import nope01 from "@opencode-ai/ui/audio/nope-01.aac"
-import nope02 from "@opencode-ai/ui/audio/nope-02.aac"
-import nope03 from "@opencode-ai/ui/audio/nope-03.aac"
-import nope04 from "@opencode-ai/ui/audio/nope-04.aac"
-import nope05 from "@opencode-ai/ui/audio/nope-05.aac"
-import nope06 from "@opencode-ai/ui/audio/nope-06.aac"
-import nope07 from "@opencode-ai/ui/audio/nope-07.aac"
-import nope08 from "@opencode-ai/ui/audio/nope-08.aac"
-import nope09 from "@opencode-ai/ui/audio/nope-09.aac"
-import nope10 from "@opencode-ai/ui/audio/nope-10.aac"
-import nope11 from "@opencode-ai/ui/audio/nope-11.aac"
-import nope12 from "@opencode-ai/ui/audio/nope-12.aac"
-import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac"
-import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac"
-import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac"
-import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac"
-import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac"
-import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac"
-import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac"
-import yup01 from "@opencode-ai/ui/audio/yup-01.aac"
-import yup02 from "@opencode-ai/ui/audio/yup-02.aac"
-import yup03 from "@opencode-ai/ui/audio/yup-03.aac"
-import yup04 from "@opencode-ai/ui/audio/yup-04.aac"
-import yup05 from "@opencode-ai/ui/audio/yup-05.aac"
-import yup06 from "@opencode-ai/ui/audio/yup-06.aac"
+let files: Record<string, () => Promise<string>> | undefined
+let loads: Record<SoundID, () => Promise<string>> | undefined
+
+function getFiles() {
+ if (files) return files
+ files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record<
+ string,
+ () => Promise<string>
+ >
+ return files
+}
export const SOUND_OPTIONS = [
- { id: "alert-01", label: "sound.option.alert01", src: alert01 },
- { id: "alert-02", label: "sound.option.alert02", src: alert02 },
- { id: "alert-03", label: "sound.option.alert03", src: alert03 },
- { id: "alert-04", label: "sound.option.alert04", src: alert04 },
- { id: "alert-05", label: "sound.option.alert05", src: alert05 },
- { id: "alert-06", label: "sound.option.alert06", src: alert06 },
- { id: "alert-07", label: "sound.option.alert07", src: alert07 },
- { id: "alert-08", label: "sound.option.alert08", src: alert08 },
- { id: "alert-09", label: "sound.option.alert09", src: alert09 },
- { id: "alert-10", label: "sound.option.alert10", src: alert10 },
- { id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 },
- { id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 },
- { id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 },
- { id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 },
- { id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 },
- { id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 },
- { id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 },
- { id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 },
- { id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 },
- { id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 },
- { id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 },
- { id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 },
- { id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 },
- { id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 },
- { id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 },
- { id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 },
- { id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 },
- { id: "nope-01", label: "sound.option.nope01", src: nope01 },
- { id: "nope-02", label: "sound.option.nope02", src: nope02 },
- { id: "nope-03", label: "sound.option.nope03", src: nope03 },
- { id: "nope-04", label: "sound.option.nope04", src: nope04 },
- { id: "nope-05", label: "sound.option.nope05", src: nope05 },
- { id: "nope-06", label: "sound.option.nope06", src: nope06 },
- { id: "nope-07", label: "sound.option.nope07", src: nope07 },
- { id: "nope-08", label: "sound.option.nope08", src: nope08 },
- { id: "nope-09", label: "sound.option.nope09", src: nope09 },
- { id: "nope-10", label: "sound.option.nope10", src: nope10 },
- { id: "nope-11", label: "sound.option.nope11", src: nope11 },
- { id: "nope-12", label: "sound.option.nope12", src: nope12 },
- { id: "yup-01", label: "sound.option.yup01", src: yup01 },
- { id: "yup-02", label: "sound.option.yup02", src: yup02 },
- { id: "yup-03", label: "sound.option.yup03", src: yup03 },
- { id: "yup-04", label: "sound.option.yup04", src: yup04 },
- { id: "yup-05", label: "sound.option.yup05", src: yup05 },
- { id: "yup-06", label: "sound.option.yup06", src: yup06 },
+ { id: "alert-01", label: "sound.option.alert01" },
+ { id: "alert-02", label: "sound.option.alert02" },
+ { id: "alert-03", label: "sound.option.alert03" },
+ { id: "alert-04", label: "sound.option.alert04" },
+ { id: "alert-05", label: "sound.option.alert05" },
+ { id: "alert-06", label: "sound.option.alert06" },
+ { id: "alert-07", label: "sound.option.alert07" },
+ { id: "alert-08", label: "sound.option.alert08" },
+ { id: "alert-09", label: "sound.option.alert09" },
+ { id: "alert-10", label: "sound.option.alert10" },
+ { id: "bip-bop-01", label: "sound.option.bipbop01" },
+ { id: "bip-bop-02", label: "sound.option.bipbop02" },
+ { id: "bip-bop-03", label: "sound.option.bipbop03" },
+ { id: "bip-bop-04", label: "sound.option.bipbop04" },
+ { id: "bip-bop-05", label: "sound.option.bipbop05" },
+ { id: "bip-bop-06", label: "sound.option.bipbop06" },
+ { id: "bip-bop-07", label: "sound.option.bipbop07" },
+ { id: "bip-bop-08", label: "sound.option.bipbop08" },
+ { id: "bip-bop-09", label: "sound.option.bipbop09" },
+ { id: "bip-bop-10", label: "sound.option.bipbop10" },
+ { id: "staplebops-01", label: "sound.option.staplebops01" },
+ { id: "staplebops-02", label: "sound.option.staplebops02" },
+ { id: "staplebops-03", label: "sound.option.staplebops03" },
+ { id: "staplebops-04", label: "sound.option.staplebops04" },
+ { id: "staplebops-05", label: "sound.option.staplebops05" },
+ { id: "staplebops-06", label: "sound.option.staplebops06" },
+ { id: "staplebops-07", label: "sound.option.staplebops07" },
+ { id: "nope-01", label: "sound.option.nope01" },
+ { id: "nope-02", label: "sound.option.nope02" },
+ { id: "nope-03", label: "sound.option.nope03" },
+ { id: "nope-04", label: "sound.option.nope04" },
+ { id: "nope-05", label: "sound.option.nope05" },
+ { id: "nope-06", label: "sound.option.nope06" },
+ { id: "nope-07", label: "sound.option.nope07" },
+ { id: "nope-08", label: "sound.option.nope08" },
+ { id: "nope-09", label: "sound.option.nope09" },
+ { id: "nope-10", label: "sound.option.nope10" },
+ { id: "nope-11", label: "sound.option.nope11" },
+ { id: "nope-12", label: "sound.option.nope12" },
+ { id: "yup-01", label: "sound.option.yup01" },
+ { id: "yup-02", label: "sound.option.yup02" },
+ { id: "yup-03", label: "sound.option.yup03" },
+ { id: "yup-04", label: "sound.option.yup04" },
+ { id: "yup-05", label: "sound.option.yup05" },
+ { id: "yup-06", label: "sound.option.yup06" },
] as const
export type SoundOption = (typeof SOUND_OPTIONS)[number]
export type SoundID = SoundOption["id"]
-const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record<SoundID, string>
+function getLoads() {
+ if (loads) return loads
+ loads = Object.fromEntries(
+ Object.entries(getFiles()).flatMap(([path, load]) => {
+ const file = path.split("/").at(-1)
+ if (!file) return []
+ return [[file.replace(/\.aac$/, ""), load] as const]
+ }),
+ ) as Record<SoundID, () => Promise<string>>
+ return loads
+}
+
+const cache = new Map<SoundID, Promise<string | undefined>>()
export function soundSrc(id: string | undefined) {
- if (!id) return
- if (!(id in soundById)) return
- return soundById[id as SoundID]
+ const loads = getLoads()
+ if (!id || !(id in loads)) return Promise.resolve(undefined)
+ const key = id as SoundID
+ const hit = cache.get(key)
+ if (hit) return hit
+ const next = loads[key]().catch(() => undefined)
+ cache.set(key, next)
+ return next
}
export function playSound(src: string | undefined) {
@@ -108,10 +91,12 @@ export function playSound(src: string | undefined) {
if (!src) return
const audio = new Audio(src)
audio.play().catch(() => undefined)
-
- // Return a cleanup function to pause the sound.
return () => {
audio.pause()
audio.currentTime = 0
}
}
+
+export function playSoundById(id: string | undefined) {
+ return soundSrc(id).then((src) => playSound(src))
+}
diff --git a/packages/app/vite.js b/packages/app/vite.js
index 6b8fd6137..f65a68a1c 100644
--- a/packages/app/vite.js
+++ b/packages/app/vite.js
@@ -1,7 +1,10 @@
+import { readFileSync } from "node:fs"
import solidPlugin from "vite-plugin-solid"
import tailwindcss from "@tailwindcss/vite"
import { fileURLToPath } from "url"
+const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
+
/**
* @type {import("vite").PluginOption}
*/
@@ -21,6 +24,15 @@ export default [
}
},
},
+ {
+ name: "opencode-desktop:theme-preload",
+ transformIndexHtml(html) {
+ return html.replace(
+ '<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>',
+ `<script id="oc-theme-preload-script">${readFileSync(theme, "utf8")}</script>`,
+ )
+ },
+ },
tailwindcss(),
solidPlugin(),
]