summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-25 06:23:25 -0500
committerAdam <[email protected]>2026-03-25 06:25:05 -0500
commit898456a25cf2edbfc4ae4961b37424f633419dd6 (patch)
tree626207d26b6c338136b069bbaaa09d67a45ddde3 /packages/app/src
parent53d0b58ebf3468bd161dcfcdc67cd66b6508e9f8 (diff)
downloadopencode-898456a25cf2edbfc4ae4961b37424f633419dd6.tar.gz
opencode-898456a25cf2edbfc4ae4961b37424f633419dd6.zip
Revert "fix(app): startup efficiency"
Diffstat (limited to 'packages/app/src')
-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
24 files changed, 485 insertions, 650 deletions
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 0eb5b4e9e..5247c951d 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/context"
+import { ThemeProvider } from "@opencode-ai/ui/theme"
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, type Locale, useLanguage } from "@/context/language"
+import { LanguageProvider, 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<{ locale?: Locale }>) {
+export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
<Font />
@@ -139,7 +139,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
void window.api?.setTitlebar?.({ mode })
}}
>
- <LanguageProvider locale={props.locale}>
+ <LanguageProvider>
<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 e7eaa1fb2..734958dd5 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, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
+import type { ProviderAuthAuthorization } 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 { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
+import { createMemo, 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,25 +34,15 @@ export function DialogConnectProvider(props: { provider: string }) {
})
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
- 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 methods = createMemo(
+ () =>
+ globalSync.data.provider_auth[props.provider] ?? [
+ {
+ type: "api",
+ label: language.t("provider.connect.method.apiKey"),
+ },
+ ],
)
- 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,
@@ -187,11 +177,7 @@ export function DialogConnectProvider(props: { provider: string }) {
index: 0,
})
- const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
- const value = method()
- if (value?.type !== "oauth") return []
- return value.prompts ?? []
- })
+ const prompts = createMemo(() => method()?.prompts ?? [])
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true
const actual = value[prompt.when.key]
@@ -310,12 +296,8 @@ export function DialogConnectProvider(props: { provider: string }) {
listRef?.onKeyDown(e)
}
- let auto = false
- createEffect(() => {
- if (auto) return
- if (loading()) return
+ onMount(() => {
if (methods().length === 1) {
- auto = true
selectMethod(0)
}
})
@@ -591,14 +573,6 @@ 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 ee98e68cd..f523671ec 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -572,7 +572,6 @@ 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 f4b8198e7..b768bafcc 100644
--- a/packages/app/src/components/settings-general.tsx
+++ b/packages/app/src/components/settings-general.tsx
@@ -1,41 +1,27 @@
-import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
+import { Component, Show, createMemo, createResource, 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/context"
+import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings, monoFontFamily } from "@/context/settings"
-import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
+import { playSound, 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()
}
@@ -43,19 +29,12 @@ const stopDemoSound = () => {
demoSoundState.cleanup = undefined
}
-const playDemoSound = (id: string | undefined) => {
+const playDemoSound = (src: string | undefined) => {
stopDemoSound()
- if (!id) return
+ if (!src) return
- const run = ++demoSoundState.run
demoSoundState.timeout = setTimeout(() => {
- void playSoundById(id).then((cleanup) => {
- if (demoSoundState.run !== run) {
- cleanup?.()
- return
- }
- demoSoundState.cleanup = cleanup
- })
+ demoSoundState.cleanup = playSound(src)
}, 100)
}
@@ -65,10 +44,6 @@ export const SettingsGeneral: Component = () => {
const platform = usePlatform()
const settings = useSettings()
- onMount(() => {
- void theme.loadThemes()
- })
-
const [store, setStore] = createStore({
checking: false,
})
@@ -129,7 +104,9 @@ export const SettingsGeneral: Component = () => {
.finally(() => setStore("checking", false))
}
- const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
+ const themeOptions = createMemo(() =>
+ Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
+ )
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
@@ -166,7 +143,7 @@ export const SettingsGeneral: Component = () => {
] as const
const fontOptionsList = [...fontOptions]
- const noneSound = { id: "none", label: "sound.option.none" } as const
+ const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const soundSelectProps = (
@@ -181,7 +158,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.id === "none" ? undefined : option.id)
+ playDemoSound(option.src)
},
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
@@ -192,7 +169,7 @@ export const SettingsGeneral: Component = () => {
}
setEnabled(true)
set(option.id)
- playDemoSound(option.id)
+ playDemoSound(option.src)
},
variant: "secondary" as const,
size: "small" as const,
@@ -344,9 +321,6 @@ 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 8d5ecac39..464522443 100644
--- a/packages/app/src/components/status-popover.tsx
+++ b/packages/app/src/components/status-popover.tsx
@@ -16,6 +16,7 @@ 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
@@ -53,15 +54,11 @@ const listServersByHealth = (
})
}
-const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
+const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
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
@@ -165,12 +162,6 @@ 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
@@ -178,7 +169,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, shown)
+ const health = useServerHealth(servers)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const toggleMcp = useMcpToggleMutation()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
@@ -309,13 +300,7 @@ export function StatusPopover() {
<Button
variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5"
- onClick={() => {
- const run = ++dialogRun
- void import("./dialog-select-server").then((x) => {
- if (dialogDead || dialogRun !== run) return
- dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
- })
- }}
+ onClick={() => dialog.show(() => <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 0a5a7d2d3..aed46f126 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -1,7 +1,4 @@
-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 { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
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 0a41f3119..77de1a73c 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/context"
+import { useTheme } from "@opencode-ai/ui/theme"
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 cbd08e99f..2d1e50135 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -9,7 +9,17 @@ 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, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
+import {
+ createContext,
+ getOwner,
+ Match,
+ onCleanup,
+ onMount,
+ type ParentProps,
+ Switch,
+ untrack,
+ useContext,
+} from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist"
@@ -70,8 +80,6 @@ function createGlobalSync() {
let active = true
let projectWritten = false
- let bootedAt = 0
- let bootingRoot = false
onCleanup(() => {
active = false
@@ -250,11 +258,6 @@ 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],
@@ -275,20 +278,15 @@ 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: () => {
- if (recent) return
- queue.refresh()
- },
+ refresh: 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)
}
@@ -327,19 +325,17 @@ function createGlobalSync() {
})
async function bootstrap() {
- 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
- }
+ 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,
+ })
}
onMount(() => {
@@ -396,7 +392,13 @@ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
export function GlobalSyncProvider(props: ParentProps) {
const value = createGlobalSync()
- return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
+ return (
+ <Switch>
+ <Match when={value.ready}>
+ <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
+ </Match>
+ </Switch>
+ )
}
export function useGlobalSync() {
diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts
index 47be3abcb..13494b7ad 100644
--- a/packages/app/src/context/global-sync/bootstrap.ts
+++ b/packages/app/src/context/global-sync/bootstrap.ts
@@ -31,102 +31,73 @@ 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 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 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 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 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 ?? {})
+ }),
+ ),
]
- 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,
- })
+ 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,
+ })
+ }
input.setGlobalStore("ready", true)
}
@@ -140,10 +111,6 @@ 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
@@ -152,130 +119,88 @@ 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
- }
}) {
- 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),
- })
+ 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!)),
}
- await waitForPaint()
- const slowErrs = errors(await runAll(slow))
- if (slowErrs.length > 0) {
- console.error("Failed to finish bootstrap instance", slowErrs[0])
+ try {
+ await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
+ } catch (err) {
+ console.error("Failed to bootstrap instance", err)
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
- description: formatServerError(slowErrs[0], input.translate),
+ description: formatServerError(err, input.translate),
})
+ input.setStore("status", "partial")
+ return
}
- if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
+ 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")
+ })
}
diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx
index 51dc09cd7..b1edd541c 100644
--- a/packages/app/src/context/language.tsx
+++ b/packages/app/src/context/language.tsx
@@ -1,10 +1,42 @@
import * as i18n from "@solid-primitives/i18n"
-import { createEffect, createMemo, createResource } from "solid-js"
+import { createEffect, createMemo } 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"
@@ -27,7 +59,6 @@ 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`
@@ -94,43 +125,24 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
}
const base = i18n.flatten({ ...en, ...uiEn })
-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 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 localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
@@ -156,6 +168,27 @@ 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"
@@ -170,48 +203,27 @@ function detectLocale(): Locale {
return "en"
}
-export function normalizeLocale(value: string): Locale {
+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: (props: { locale?: Locale }) => {
- const initial = props.locale ?? readStoredLocale() ?? detectLocale()
+ init: () => {
const [store, setStore, _, ready] = persisted(
Persist.global("language", ["language.v1"]),
createStore({
- locale: initial,
+ locale: detectLocale() as Locale,
}),
)
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
+ console.log("locale", locale())
const intl = createMemo(() => INTL[locale()])
- const [dict] = createResource(locale, loadDict, {
- initialValue: dicts.get(initial) ?? base,
- })
+ const dict = createMemo<Dictionary>(() => DICT[locale()])
- const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
- key: keyof Dictionary,
- params?: Record<string, string | number | boolean>,
- ) => string
+ const t = i18n.translator(dict, i18n.resolveTemplate)
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 281a1ef33..04bc2fdaa 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 { playSoundById } from "@/utils/sound"
+import { playSound, soundSrc } 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()) {
- void playSoundById(settings.sounds.agent())
+ playSound(soundSrc(settings.sounds.agent()))
}
append({
@@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (session?.parentID) return
if (settings.sounds.errorsEnabled()) {
- void playSoundById(settings.sounds.errors())
+ playSound(soundSrc(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 eddd752eb..48788fe8e 100644
--- a/packages/app/src/context/settings.tsx
+++ b/packages/app/src/context/settings.tsx
@@ -104,13 +104,6 @@ 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: () => {
@@ -118,11 +111,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => {
if (typeof document === "undefined") return
- 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))
+ document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
})
return {
diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx
index bbf4fc5ec..66b889e2a 100644
--- a/packages/app/src/context/sync.tsx
+++ b/packages/app/src/context/sync.tsx
@@ -180,8 +180,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
- const initialMessagePageSize = 80
- const historyMessagePageSize = 200
+ const messagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
@@ -464,7 +463,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] ?? initialMessagePageSize
+ const limit = meta.limit[key] ?? messagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
@@ -561,7 +560,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 ?? historyMessagePageSize
+ const step = count ?? messagePageSize
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 c8b18f421..3e8fa9af2 100644
--- a/packages/app/src/context/terminal-title.ts
+++ b/packages/app/src/context/terminal-title.ts
@@ -1,18 +1,45 @@
-const template = "Terminal {{number}}"
+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 numbered = [
- template,
- "محطة طرفية {{number}}",
- "Терминал {{number}}",
- "ターミナル {{number}}",
- "터미널 {{number}}",
- "เทอร์มินัล {{number}}",
- "终端 {{number}}",
- "終端機 {{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"],
+ ]),
+)
export function defaultTitle(number: number) {
- return template.replace("{{number}}", String(number))
+ return en["terminal.title.numbered"].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 da22c5552..b5cbed6e7 100644
--- a/packages/app/src/entry.tsx
+++ b/packages/app/src/entry.tsx
@@ -97,15 +97,10 @@ 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 localUrl()
- if (import.meta.env.DEV) return localUrl()
- if (isLocalHost()) return localUrl()
+ 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"}`
return location.origin
}
diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts
index a8f2360bb..a25f8b4b2 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())
- if (projectStore.provider.all.length > 0) return projectStore.provider
+ return projectStore.provider
}
return globalSync.data.provider
}
diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts
index d80e9fffb..53063f48f 100644
--- a/packages/app/src/index.ts
+++ b/packages/app/src/index.ts
@@ -1,7 +1,6 @@
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 6d3b04be9..cd5e079a6 100644
--- a/packages/app/src/pages/directory-layout.tsx
+++ b/packages/app/src/pages/directory-layout.tsx
@@ -2,7 +2,8 @@ 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 { createEffect, createMemo, type ParentProps, Show } from "solid-js"
+import { createMemo, createResource, type ParentProps, Show } from "solid-js"
+import { useGlobalSDK } from "@/context/global-sdk"
import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk"
@@ -10,18 +11,10 @@ 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}
@@ -36,31 +29,50 @@ 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 = createMemo(() => {
- if (!params.dir) return ""
- return decode64(params.dir) ?? ""
- })
+ const [resolved] = createResource(
+ () => {
+ if (params.dir) return [location.pathname, params.dir] as const
+ },
+ async ([pathname, b64Dir]) => {
+ const directory = decode64(b64Dir)
- 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 })
- })
+ 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
+ })
+ },
+ )
return (
<Show when={resolved()} keyed>
diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx
index 4c795b968..ba3a2b942 100644
--- a/packages/app/src/pages/home.tsx
+++ b/packages/app/src/pages/home.tsx
@@ -113,14 +113,6 @@ 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 b5a96110f..01e151605 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -49,16 +49,21 @@ 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 { playSoundById } from "@/utils/sound"
+import { playSound, soundSrc } 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/context"
+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 { 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"
@@ -105,8 +110,6 @@ 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()
@@ -136,7 +139,7 @@ export default function Layout(props: ParentProps) {
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
}
})
- const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
+ const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
system: "theme.scheme.system",
@@ -198,8 +201,6 @@ export default function Layout(props: ParentProps) {
})
onCleanup(() => {
- dialogDead = true
- dialogRun += 1
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
clearTimeout(sortNowTimeout)
if (sortNowInterval) clearInterval(sortNowInterval)
@@ -335,9 +336,10 @@ 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: theme.name(nextThemeId),
+ description: nextTheme?.name ?? nextThemeId,
})
}
@@ -492,7 +494,7 @@ export default function Layout(props: ParentProps) {
if (e.details.type === "permission.asked") {
if (settings.sounds.permissionsEnabled()) {
- void playSoundById(settings.sounds.permissions())
+ playSound(soundSrc(settings.sounds.permissions()))
}
if (settings.notifications.permissions()) {
void platform.notify(title, description, href)
@@ -1152,10 +1154,10 @@ export default function Layout(props: ParentProps) {
},
]
- for (const [id] of availableThemeEntries()) {
+ for (const [id, definition] of availableThemeEntries()) {
commands.push({
id: `theme.set.${id}`,
- title: language.t("command.theme.set", { theme: theme.name(id) }),
+ title: language.t("command.theme.set", { theme: definition.name ?? id }),
category: language.t("command.category.theme"),
onSelect: () => theme.commitPreview(),
onHighlight: () => {
@@ -1206,27 +1208,15 @@ export default function Layout(props: ParentProps) {
})
function connectProvider() {
- const run = ++dialogRun
- void import("@/components/dialog-select-provider").then((x) => {
- if (dialogDead || dialogRun !== run) return
- dialog.show(() => <x.DialogSelectProvider />)
- })
+ dialog.show(() => <DialogSelectProvider />)
}
function openServer() {
- const run = ++dialogRun
- void import("@/components/dialog-select-server").then((x) => {
- if (dialogDead || dialogRun !== run) return
- dialog.show(() => <x.DialogSelectServer />)
- })
+ dialog.show(() => <DialogSelectServer />)
}
function openSettings() {
- const run = ++dialogRun
- void import("@/components/dialog-settings").then((x) => {
- if (dialogDead || dialogRun !== run) return
- dialog.show(() => <x.DialogSettings />)
- })
+ dialog.show(() => <DialogSettings />)
}
function projectRoot(directory: string) {
@@ -1453,13 +1443,7 @@ export default function Layout(props: ParentProps) {
layout.sidebar.toggleWorkspaces(project.worktree)
}
- 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} />)
- })
- }
+ const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />)
async function chooseProject() {
function resolve(result: string | string[] | null) {
@@ -1480,14 +1464,10 @@ export default function Layout(props: ParentProps) {
})
resolve(result)
} else {
- 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),
- )
- })
+ dialog.show(
+ () => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
+ () => resolve(null),
+ )
}
}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 2d3e31355..7a3b476e8 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -1184,6 +1184,8 @@ export default function Page() {
on(
() => sdk.directory,
() => {
+ void file.tree.list("")
+
const tab = activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
@@ -1638,9 +1640,6 @@ 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,
@@ -1712,7 +1711,7 @@ export default function Page() {
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id}>
- <Show when={messagesReady()}>
+ <Show when={lastUserMessage()}>
<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 c582749d1..5fadb1f22 100644
--- a/packages/app/src/pages/session/use-session-hash-scroll.ts
+++ b/packages/app/src/pages/session/use-session-hash-scroll.ts
@@ -8,9 +8,6 @@ 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
@@ -184,21 +181,6 @@ 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 a13fd34ef..45a323c7b 100644
--- a/packages/app/src/utils/server-health.ts
+++ b/packages/app/src/utils/server-health.ts
@@ -14,15 +14,6 @@ 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
@@ -96,18 +87,5 @@ export function useCheckServerHealth() {
const platform = usePlatform()
const fetcher = platform.fetch ?? globalThis.fetch
- 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
- }
+ return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher)
}
diff --git a/packages/app/src/utils/sound.ts b/packages/app/src/utils/sound.ts
index 78e5a0c56..6dea812ec 100644
--- a/packages/app/src/utils/sound.ts
+++ b/packages/app/src/utils/sound.ts
@@ -1,89 +1,106 @@
-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
-}
+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"
export const SOUND_OPTIONS = [
- { 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" },
+ { 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 },
] as const
export type SoundOption = (typeof SOUND_OPTIONS)[number]
export type SoundID = SoundOption["id"]
-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>>()
+const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record<SoundID, string>
export function soundSrc(id: string | undefined) {
- 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
+ if (!id) return
+ if (!(id in soundById)) return
+ return soundById[id as SoundID]
}
export function playSound(src: string | undefined) {
@@ -91,12 +108,10 @@ 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))
-}