Browse Source

Reapply "fix(app): startup efficiency"

This reverts commit 898456a25cf2edbfc4ae4961b37424f633419dd6.
Adam 3 weeks ago
parent
commit
1041ae91d1
40 changed files with 1106 additions and 682 deletions
  1. 4 4
      packages/app/src/app.tsx
  2. 38 12
      packages/app/src/components/dialog-connect-provider.tsx
  3. 1 0
      packages/app/src/components/prompt-input.tsx
  4. 38 12
      packages/app/src/components/settings-general.tsx
  5. 19 4
      packages/app/src/components/status-popover.tsx
  6. 4 1
      packages/app/src/components/terminal.tsx
  7. 1 1
      packages/app/src/components/titlebar.tsx
  8. 28 30
      packages/app/src/context/global-sync.tsx
  9. 206 131
      packages/app/src/context/global-sync/bootstrap.ts
  10. 66 78
      packages/app/src/context/language.tsx
  11. 3 3
      packages/app/src/context/notification.tsx
  12. 12 1
      packages/app/src/context/settings.tsx
  13. 4 3
      packages/app/src/context/sync.tsx
  14. 12 39
      packages/app/src/context/terminal-title.ts
  15. 8 3
      packages/app/src/entry.tsx
  16. 1 1
      packages/app/src/hooks/use-providers.ts
  17. 1 0
      packages/app/src/index.ts
  18. 29 41
      packages/app/src/pages/directory-layout.tsx
  19. 8 0
      packages/app/src/pages/home.tsx
  20. 41 21
      packages/app/src/pages/layout.tsx
  21. 4 3
      packages/app/src/pages/session.tsx
  22. 18 0
      packages/app/src/pages/session/use-session-hash-scroll.ts
  23. 23 1
      packages/app/src/utils/server-health.ts
  24. 81 96
      packages/app/src/utils/sound.ts
  25. 12 0
      packages/app/vite.js
  26. 17 3
      packages/desktop-electron/src/renderer/index.tsx
  27. 17 2
      packages/desktop/src/index.tsx
  28. 11 4
      packages/opencode/src/server/server.ts
  29. 1 0
      packages/ui/package.json
  30. 3 0
      packages/ui/src/assets/icons/provider/alibaba-coding-plan-cn.svg
  31. 3 0
      packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg
  32. 24 0
      packages/ui/src/assets/icons/provider/clarifai.svg
  33. 1 0
      packages/ui/src/assets/icons/provider/dinference.svg
  34. 8 0
      packages/ui/src/assets/icons/provider/drun.svg
  35. 3 0
      packages/ui/src/assets/icons/provider/perplexity-agent.svg
  36. 5 0
      packages/ui/src/assets/icons/provider/tencent-coding-plan.svg
  37. 0 1
      packages/ui/src/assets/icons/provider/zenmux.svg
  38. 3 116
      packages/ui/src/components/font.tsx
  39. 133 0
      packages/ui/src/font-loader.ts
  40. 215 71
      packages/ui/src/theme/context.tsx

+ 4 - 4
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 { File } from "@opencode-ai/ui/file"
 import { Font } from "@opencode-ai/ui/font"
 import { Font } from "@opencode-ai/ui/font"
 import { Splash } from "@opencode-ai/ui/logo"
 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 { MetaProvider } from "@solidjs/meta"
 import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
 import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
 import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
 import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
@@ -32,7 +32,7 @@ import { FileProvider } from "@/context/file"
 import { GlobalSDKProvider } from "@/context/global-sdk"
 import { GlobalSDKProvider } from "@/context/global-sdk"
 import { GlobalSyncProvider } from "@/context/global-sync"
 import { GlobalSyncProvider } from "@/context/global-sync"
 import { HighlightsProvider } from "@/context/highlights"
 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 { LayoutProvider } from "@/context/layout"
 import { ModelsProvider } from "@/context/models"
 import { ModelsProvider } from "@/context/models"
 import { NotificationProvider } from "@/context/notification"
 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 (
   return (
     <MetaProvider>
     <MetaProvider>
       <Font />
       <Font />
@@ -139,7 +139,7 @@ export function AppBaseProviders(props: ParentProps) {
           void window.api?.setTitlebar?.({ mode })
           void window.api?.setTitlebar?.({ mode })
         }}
         }}
       >
       >
-        <LanguageProvider>
+        <LanguageProvider locale={props.locale}>
           <UiI18nBridge>
           <UiI18nBridge>
             <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
             <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
               <QueryProvider>
               <QueryProvider>

+ 38 - 12
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 { Button } from "@opencode-ai/ui/button"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/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 { Spinner } from "@opencode-ai/ui/spinner"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@opencode-ai/ui/toast"
 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 { createStore, produce } from "solid-js/store"
 import { Link } from "@/components/link"
 import { Link } from "@/components/link"
 import { useGlobalSDK } from "@/context/global-sdk"
 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 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({
   const [store, setStore] = createStore({
     methodIndex: undefined as undefined | number,
     methodIndex: undefined as undefined | number,
     authorization: undefined as undefined | ProviderAuthAuthorization,
     authorization: undefined as undefined | ProviderAuthAuthorization,
@@ -177,7 +187,11 @@ export function DialogConnectProvider(props: { provider: string }) {
       index: 0,
       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>) => {
     const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
       if (!prompt.when) return true
       if (!prompt.when) return true
       const actual = value[prompt.when.key]
       const actual = value[prompt.when.key]
@@ -296,8 +310,12 @@ export function DialogConnectProvider(props: { provider: string }) {
     listRef?.onKeyDown(e)
     listRef?.onKeyDown(e)
   }
   }
 
 
-  onMount(() => {
+  let auto = false
+  createEffect(() => {
+    if (auto) return
+    if (loading()) return
     if (methods().length === 1) {
     if (methods().length === 1) {
+      auto = true
       selectMethod(0)
       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 class="px-2.5 pb-10 flex flex-col gap-6">
           <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
           <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
             <Switch>
             <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}>
               <Match when={store.methodIndex === undefined}>
                 <MethodSelection />
                 <MethodSelection />
               </Match>
               </Match>

+ 1 - 0
packages/app/src/components/prompt-input.tsx

@@ -572,6 +572,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const open = recent()
       const open = recent()
       const seen = new Set(open)
       const seen = new Set(open)
       const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
       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 paths = await files.searchFilesAndDirectories(query)
       const fileOptions: AtOption[] = paths
       const fileOptions: AtOption[] = paths
         .filter((path) => !seen.has(path))
         .filter((path) => !seen.has(path))

+ 38 - 12
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 { createStore } from "solid-js/store"
 import { Button } from "@opencode-ai/ui/button"
 import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Select } from "@opencode-ai/ui/select"
 import { Select } from "@opencode-ai/ui/select"
 import { Switch } from "@opencode-ai/ui/switch"
 import { Switch } from "@opencode-ai/ui/switch"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 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 { showToast } from "@opencode-ai/ui/toast"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { usePlatform } from "@/context/platform"
 import { useSettings, monoFontFamily } from "@/context/settings"
 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 { Link } from "./link"
 import { SettingsList } from "./settings-list"
 import { SettingsList } from "./settings-list"
 
 
 let demoSoundState = {
 let demoSoundState = {
   cleanup: undefined as (() => void) | undefined,
   cleanup: undefined as (() => void) | undefined,
   timeout: undefined as NodeJS.Timeout | 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,
 // 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.
 // delay the playback by 100ms during quick selection changes and pause existing sounds.
 const stopDemoSound = () => {
 const stopDemoSound = () => {
+  demoSoundState.run += 1
   if (demoSoundState.cleanup) {
   if (demoSoundState.cleanup) {
     demoSoundState.cleanup()
     demoSoundState.cleanup()
   }
   }
@@ -29,12 +43,19 @@ const stopDemoSound = () => {
   demoSoundState.cleanup = undefined
   demoSoundState.cleanup = undefined
 }
 }
 
 
-const playDemoSound = (src: string | undefined) => {
+const playDemoSound = (id: string | undefined) => {
   stopDemoSound()
   stopDemoSound()
-  if (!src) return
+  if (!id) return
 
 
+  const run = ++demoSoundState.run
   demoSoundState.timeout = setTimeout(() => {
   demoSoundState.timeout = setTimeout(() => {
-    demoSoundState.cleanup = playSound(src)
+    void playSoundById(id).then((cleanup) => {
+      if (demoSoundState.run !== run) {
+        cleanup?.()
+        return
+      }
+      demoSoundState.cleanup = cleanup
+    })
   }, 100)
   }, 100)
 }
 }
 
 
@@ -44,6 +65,10 @@ export const SettingsGeneral: Component = () => {
   const platform = usePlatform()
   const platform = usePlatform()
   const settings = useSettings()
   const settings = useSettings()
 
 
+  onMount(() => {
+    void theme.loadThemes()
+  })
+
   const [store, setStore] = createStore({
   const [store, setStore] = createStore({
     checking: false,
     checking: false,
   })
   })
@@ -104,9 +129,7 @@ export const SettingsGeneral: Component = () => {
       .finally(() => setStore("checking", false))
       .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 }[] => [
   const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
     { value: "system", label: language.t("theme.scheme.system") },
     { value: "system", label: language.t("theme.scheme.system") },
@@ -143,7 +166,7 @@ export const SettingsGeneral: Component = () => {
   ] as const
   ] as const
   const fontOptionsList = [...fontOptions]
   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 soundOptions = [noneSound, ...SOUND_OPTIONS]
 
 
   const soundSelectProps = (
   const soundSelectProps = (
@@ -158,7 +181,7 @@ export const SettingsGeneral: Component = () => {
     label: (o: (typeof soundOptions)[number]) => language.t(o.label),
     label: (o: (typeof soundOptions)[number]) => language.t(o.label),
     onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
     onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
       if (!option) return
       if (!option) return
-      playDemoSound(option.src)
+      playDemoSound(option.id === "none" ? undefined : option.id)
     },
     },
     onSelect: (option: (typeof soundOptions)[number] | undefined) => {
     onSelect: (option: (typeof soundOptions)[number] | undefined) => {
       if (!option) return
       if (!option) return
@@ -169,7 +192,7 @@ export const SettingsGeneral: Component = () => {
       }
       }
       setEnabled(true)
       setEnabled(true)
       set(option.id)
       set(option.id)
-      playDemoSound(option.src)
+      playDemoSound(option.id)
     },
     },
     variant: "secondary" as const,
     variant: "secondary" as const,
     size: "small" as const,
     size: "small" as const,
@@ -321,6 +344,9 @@ export const SettingsGeneral: Component = () => {
             current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
             current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
             value={(o) => o.value}
             value={(o) => o.value}
             label={(o) => language.t(o.label)}
             label={(o) => language.t(o.label)}
+            onHighlight={(option) => {
+              void loadFont().then((x) => x.ensureMonoFont(option?.value))
+            }}
             onSelect={(option) => option && settings.appearance.setFont(option.value)}
             onSelect={(option) => option && settings.appearance.setFont(option.value)}
             variant="secondary"
             variant="secondary"
             size="small"
             size="small"

+ 19 - 4
packages/app/src/components/status-popover.tsx

@@ -16,7 +16,6 @@ import { useSDK } from "@/context/sdk"
 import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
 import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
 import { useSync } from "@/context/sync"
 import { useSync } from "@/context/sync"
 import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
 import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
-import { DialogSelectServer } from "./dialog-select-server"
 
 
 const pollMs = 10_000
 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 checkServerHealth = useCheckServerHealth()
   const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
   const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
 
 
   createEffect(() => {
   createEffect(() => {
+    if (!enabled()) {
+      setStatus(reconcile({}))
+      return
+    }
     const list = servers()
     const list = servers()
     let dead = false
     let dead = false
 
 
@@ -162,6 +165,12 @@ export function StatusPopover() {
   const navigate = useNavigate()
   const navigate = useNavigate()
 
 
   const [shown, setShown] = createSignal(false)
   const [shown, setShown] = createSignal(false)
+  let dialogRun = 0
+  let dialogDead = false
+  onCleanup(() => {
+    dialogDead = true
+    dialogRun += 1
+  })
   const servers = createMemo(() => {
   const servers = createMemo(() => {
     const current = server.current
     const current = server.current
     const list = server.list
     const list = server.list
@@ -169,7 +178,7 @@ export function StatusPopover() {
     if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
     if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
     return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
     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 sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
   const toggleMcp = useMcpToggleMutation()
   const toggleMcp = useMcpToggleMutation()
   const defaultServer = useDefaultServerKey(platform.getDefaultServer)
   const defaultServer = useDefaultServerKey(platform.getDefaultServer)
@@ -300,7 +309,13 @@ export function StatusPopover() {
                 <Button
                 <Button
                   variant="secondary"
                   variant="secondary"
                   class="mt-3 self-start h-8 px-3 py-1.5"
                   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")}
                   {language.t("status.popover.action.manageServers")}
                 </Button>
                 </Button>

+ 4 - 1
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 { showToast } from "@opencode-ai/ui/toast"
 import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
 import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
 import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
 import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"

+ 1 - 1
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 { Icon } from "@opencode-ai/ui/icon"
 import { Button } from "@opencode-ai/ui/button"
 import { Button } from "@opencode-ai/ui/button"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 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 { useLayout } from "@/context/layout"
 import { usePlatform } from "@/context/platform"
 import { usePlatform } from "@/context/platform"

+ 28 - 30
packages/app/src/context/global-sync.tsx

@@ -9,17 +9,7 @@ import type {
 } from "@opencode-ai/sdk/v2/client"
 } from "@opencode-ai/sdk/v2/client"
 import { showToast } from "@opencode-ai/ui/toast"
 import { showToast } from "@opencode-ai/ui/toast"
 import { getFilename } from "@opencode-ai/util/path"
 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 { createStore, produce, reconcile } from "solid-js/store"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { Persist, persisted } from "@/utils/persist"
 import { Persist, persisted } from "@/utils/persist"
@@ -80,6 +70,8 @@ function createGlobalSync() {
 
 
   let active = true
   let active = true
   let projectWritten = false
   let projectWritten = false
+  let bootedAt = 0
+  let bootingRoot = false
 
 
   onCleanup(() => {
   onCleanup(() => {
     active = false
     active = false
@@ -258,6 +250,11 @@ function createGlobalSync() {
       const sdk = sdkFor(directory)
       const sdk = sdkFor(directory)
       await bootstrapDirectory({
       await bootstrapDirectory({
         directory,
         directory,
+        global: {
+          config: globalStore.config,
+          project: globalStore.project,
+          provider: globalStore.provider,
+        },
         sdk,
         sdk,
         store: child[0],
         store: child[0],
         setStore: child[1],
         setStore: child[1],
@@ -278,15 +275,20 @@ function createGlobalSync() {
   const unsub = globalSDK.event.listen((e) => {
   const unsub = globalSDK.event.listen((e) => {
     const directory = e.name
     const directory = e.name
     const event = e.details
     const event = e.details
+    const recent = bootingRoot || Date.now() - bootedAt < 1500
 
 
     if (directory === "global") {
     if (directory === "global") {
       applyGlobalEvent({
       applyGlobalEvent({
         event,
         event,
         project: globalStore.project,
         project: globalStore.project,
-        refresh: queue.refresh,
+        refresh: () => {
+          if (recent) return
+          queue.refresh()
+        },
         setGlobalProject: setProjects,
         setGlobalProject: setProjects,
       })
       })
       if (event.type === "server.connected" || event.type === "global.disposed") {
       if (event.type === "server.connected" || event.type === "global.disposed") {
+        if (recent) return
         for (const directory of Object.keys(children.children)) {
         for (const directory of Object.keys(children.children)) {
           queue.push(directory)
           queue.push(directory)
         }
         }
@@ -325,17 +327,19 @@ function createGlobalSync() {
   })
   })
 
 
   async function bootstrap() {
   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(() => {
   onMount(() => {
@@ -392,13 +396,7 @@ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
 
 
 export function GlobalSyncProvider(props: ParentProps) {
 export function GlobalSyncProvider(props: ParentProps) {
   const value = createGlobalSync()
   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() {
 export function useGlobalSync() {

+ 206 - 131
packages/app/src/context/global-sync/bootstrap.ts

@@ -31,73 +31,102 @@ type GlobalStore = {
   reload: undefined | "pending" | "complete"
   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: {
 export async function bootstrapGlobal(input: {
   globalSDK: OpencodeClient
   globalSDK: OpencodeClient
-  connectErrorTitle: string
-  connectErrorDescription: string
   requestFailedTitle: string
   requestFailedTitle: string
   translate: (key: string, vars?: Record<string, string | number>) => string
   translate: (key: string, vars?: Record<string, string | number>) => string
   formatMoreCount: (count: number) => string
   formatMoreCount: (count: number) => string
   setGlobalStore: SetStoreFunction<GlobalStore>
   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)
   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: {
 export async function bootstrapDirectory(input: {
   directory: string
   directory: string
   sdk: OpencodeClient
   sdk: OpencodeClient
@@ -119,88 +152,130 @@ export async function bootstrapDirectory(input: {
   vcsCache: VcsCache
   vcsCache: VcsCache
   loadSessions: (directory: string) => Promise<void> | void
   loadSessions: (directory: string) => Promise<void> | void
   translate: (key: string, vars?: Record<string, string | number>) => string
   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)
     const project = getFilename(input.directory)
     showToast({
     showToast({
       variant: "error",
       variant: "error",
       title: input.translate("toast.project.reloadFailed.title", { project }),
       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")
 }
 }

+ 66 - 78
packages/app/src/context/language.tsx

@@ -1,42 +1,10 @@
 import * as i18n from "@solid-primitives/i18n"
 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 { createStore } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { Persist, persisted } from "@/utils/persist"
 import { Persist, persisted } from "@/utils/persist"
 import { dict as en } from "@/i18n/en"
 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 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 =
 export type Locale =
   | "en"
   | "en"
@@ -59,6 +27,7 @@ export type Locale =
 
 
 type RawDictionary = typeof en & typeof uiEn
 type RawDictionary = typeof en & typeof uiEn
 type Dictionary = i18n.Flatten<RawDictionary>
 type Dictionary = i18n.Flatten<RawDictionary>
+type Source = { dict: Record<string, string> }
 
 
 function cookie(locale: Locale) {
 function cookie(locale: Locale) {
   return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
   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 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 }> = [
 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") },
   { 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 {
 function detectLocale(): Locale {
   if (typeof navigator !== "object") return "en"
   if (typeof navigator !== "object") return "en"
 
 
@@ -203,27 +170,48 @@ function detectLocale(): Locale {
   return "en"
   return "en"
 }
 }
 
 
-function normalizeLocale(value: string): Locale {
+export function normalizeLocale(value: string): Locale {
   return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
   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({
 export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
   name: "Language",
   name: "Language",
-  init: () => {
+  init: (props: { locale?: Locale }) => {
+    const initial = props.locale ?? readStoredLocale() ?? detectLocale()
     const [store, setStore, _, ready] = persisted(
     const [store, setStore, _, ready] = persisted(
       Persist.global("language", ["language.v1"]),
       Persist.global("language", ["language.v1"]),
       createStore({
       createStore({
-        locale: detectLocale() as Locale,
+        locale: initial,
       }),
       }),
     )
     )
 
 
     const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
     const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
-    console.log("locale", locale())
     const intl = createMemo(() => INTL[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])
     const label = (value: Locale) => t(LABEL_KEY[value])
 
 

+ 3 - 3
packages/app/src/context/notification.tsx

@@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
 import { decode64 } from "@/utils/base64"
 import { decode64 } from "@/utils/base64"
 import { EventSessionError } from "@opencode-ai/sdk/v2"
 import { EventSessionError } from "@opencode-ai/sdk/v2"
 import { Persist, persisted } from "@/utils/persist"
 import { Persist, persisted } from "@/utils/persist"
-import { playSound, soundSrc } from "@/utils/sound"
+import { playSoundById } from "@/utils/sound"
 
 
 type NotificationBase = {
 type NotificationBase = {
   directory?: string
   directory?: string
@@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
         if (session.parentID) return
         if (session.parentID) return
 
 
         if (settings.sounds.agentEnabled()) {
         if (settings.sounds.agentEnabled()) {
-          playSound(soundSrc(settings.sounds.agent()))
+          void playSoundById(settings.sounds.agent())
         }
         }
 
 
         append({
         append({
@@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
         if (session?.parentID) return
         if (session?.parentID) return
 
 
         if (settings.sounds.errorsEnabled()) {
         if (settings.sounds.errorsEnabled()) {
-          playSound(soundSrc(settings.sounds.errors()))
+          void playSoundById(settings.sounds.errors())
         }
         }
 
 
         const error = "error" in event.properties ? event.properties.error : undefined
         const error = "error" in event.properties ? event.properties.error : undefined

+ 12 - 1
packages/app/src/context/settings.tsx

@@ -104,6 +104,13 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
   return createMemo(() => read() ?? fallback)
   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({
 export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
   name: "Settings",
   name: "Settings",
   init: () => {
   init: () => {
@@ -111,7 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
 
 
     createEffect(() => {
     createEffect(() => {
       if (typeof document === "undefined") return
       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 {
     return {

+ 4 - 3
packages/app/src/context/sync.tsx

@@ -180,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       return globalSync.child(directory)
       return globalSync.child(directory)
     }
     }
     const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
     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 inflight = new Map<string, Promise<void>>()
     const inflightDiff = new Map<string, Promise<void>>()
     const inflightDiff = new Map<string, Promise<void>>()
     const inflightTodo = 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
             const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
             if (cached && hasSession && !opts?.force) return
             if (cached && hasSession && !opts?.force) return
 
 
-            const limit = meta.limit[key] ?? messagePageSize
+            const limit = meta.limit[key] ?? initialMessagePageSize
             const sessionReq =
             const sessionReq =
               hasSession && !opts?.force
               hasSession && !opts?.force
                 ? Promise.resolve()
                 ? Promise.resolve()
@@ -560,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             const [, setStore] = globalSync.child(directory)
             const [, setStore] = globalSync.child(directory)
             touch(directory, setStore, sessionID)
             touch(directory, setStore, sessionID)
             const key = keyFor(directory, sessionID)
             const key = keyFor(directory, sessionID)
-            const step = count ?? messagePageSize
+            const step = count ?? historyMessagePageSize
             if (meta.loading[key]) return
             if (meta.loading[key]) return
             if (meta.complete[key]) return
             if (meta.complete[key]) return
             const before = meta.cursor[key]
             const before = meta.cursor[key]

+ 12 - 39
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) {
 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) {
 export function isDefaultTitle(title: string, number: number) {

+ 8 - 3
packages/app/src/entry.tsx

@@ -97,10 +97,15 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
   throw new Error(getRootNotFoundError())
   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 = () => {
 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
   return location.origin
 }
 }
 
 

+ 1 - 1
packages/app/src/hooks/use-providers.ts

@@ -22,7 +22,7 @@ export function useProviders() {
   const providers = () => {
   const providers = () => {
     if (dir()) {
     if (dir()) {
       const [projectStore] = globalSync.child(dir())
       const [projectStore] = globalSync.child(dir())
-      return projectStore.provider
+      if (projectStore.provider.all.length > 0) return projectStore.provider
     }
     }
     return globalSync.data.provider
     return globalSync.data.provider
   }
   }

+ 1 - 0
packages/app/src/index.ts

@@ -1,6 +1,7 @@
 export { AppBaseProviders, AppInterface } from "./app"
 export { AppBaseProviders, AppInterface } from "./app"
 export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
 export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
 export { useCommand } from "./context/command"
 export { useCommand } from "./context/command"
+export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
 export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
 export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
 export { ServerConnection } from "./context/server"
 export { ServerConnection } from "./context/server"
 export { handleNotificationClick } from "./utils/notification-click"
 export { handleNotificationClick } from "./utils/notification-click"

+ 29 - 41
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 { showToast } from "@opencode-ai/ui/toast"
 import { base64Encode } from "@opencode-ai/util/encode"
 import { base64Encode } from "@opencode-ai/util/encode"
 import { useLocation, useNavigate, useParams } from "@solidjs/router"
 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 { useLanguage } from "@/context/language"
 import { LocalProvider } from "@/context/local"
 import { LocalProvider } from "@/context/local"
 import { SDKProvider } from "@/context/sdk"
 import { SDKProvider } from "@/context/sdk"
@@ -11,10 +10,18 @@ import { SyncProvider, useSync } from "@/context/sync"
 import { decode64 } from "@/utils/base64"
 import { decode64 } from "@/utils/base64"
 
 
 function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
 function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
+  const location = useLocation()
   const navigate = useNavigate()
   const navigate = useNavigate()
   const sync = useSync()
   const sync = useSync()
   const slug = createMemo(() => base64Encode(props.directory))
   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 (
   return (
     <DataProvider
     <DataProvider
       data={sync.data}
       data={sync.data}
@@ -29,50 +36,31 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
 
 
 export default function Layout(props: ParentProps) {
 export default function Layout(props: ParentProps) {
   const params = useParams()
   const params = useParams()
-  const location = useLocation()
   const language = useLanguage()
   const language = useLanguage()
-  const globalSDK = useGlobalSDK()
   const navigate = useNavigate()
   const navigate = useNavigate()
   let invalid = ""
   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 (
   return (
     <Show when={resolved()} keyed>
     <Show when={resolved()} keyed>

+ 8 - 0
packages/app/src/pages/home.tsx

@@ -113,6 +113,14 @@ export default function Home() {
             </ul>
             </ul>
           </div>
           </div>
         </Match>
         </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}>
         <Match when={true}>
           <div class="mt-30 mx-auto flex flex-col items-center gap-3">
           <div class="mt-30 mx-auto flex flex-col items-center gap-3">
             <Icon name="folder-add-left" size="large" />
             <Icon name="folder-add-left" size="large" />

+ 41 - 21
packages/app/src/pages/layout.tsx

@@ -49,21 +49,16 @@ import { useNotification } from "@/context/notification"
 import { usePermission } from "@/context/permission"
 import { usePermission } from "@/context/permission"
 import { Binary } from "@opencode-ai/util/binary"
 import { Binary } from "@opencode-ai/util/binary"
 import { retry } from "@opencode-ai/util/retry"
 import { retry } from "@opencode-ai/util/retry"
-import { playSound, soundSrc } from "@/utils/sound"
+import { playSoundById } from "@/utils/sound"
 import { createAim } from "@/utils/aim"
 import { createAim } from "@/utils/aim"
 import { setNavigate } from "@/utils/notification-click"
 import { setNavigate } from "@/utils/notification-click"
 import { Worktree as WorktreeState } from "@/utils/worktree"
 import { Worktree as WorktreeState } from "@/utils/worktree"
 import { setSessionHandoff } from "@/pages/session/handoff"
 import { setSessionHandoff } from "@/pages/session/handoff"
 
 
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 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 { useCommand, type CommandOption } from "@/context/command"
 import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
 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 { DebugBar } from "@/components/debug-bar"
 import { Titlebar } from "@/components/titlebar"
 import { Titlebar } from "@/components/titlebar"
 import { useServer } from "@/context/server"
 import { useServer } from "@/context/server"
@@ -110,6 +105,8 @@ export default function Layout(props: ParentProps) {
   const pageReady = createMemo(() => ready())
   const pageReady = createMemo(() => ready())
 
 
   let scrollContainerRef: HTMLDivElement | undefined
   let scrollContainerRef: HTMLDivElement | undefined
+  let dialogRun = 0
+  let dialogDead = false
 
 
   const params = useParams()
   const params = useParams()
   const globalSDK = useGlobalSDK()
   const globalSDK = useGlobalSDK()
@@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) {
       dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
       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 colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
   const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
   const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
     system: "theme.scheme.system",
     system: "theme.scheme.system",
@@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) {
   })
   })
 
 
   onCleanup(() => {
   onCleanup(() => {
+    dialogDead = true
+    dialogRun += 1
     if (navLeave.current !== undefined) clearTimeout(navLeave.current)
     if (navLeave.current !== undefined) clearTimeout(navLeave.current)
     clearTimeout(sortNowTimeout)
     clearTimeout(sortNowTimeout)
     if (sortNowInterval) clearInterval(sortNowInterval)
     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 nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
     const nextThemeId = ids[nextIndex]
     const nextThemeId = ids[nextIndex]
     theme.setTheme(nextThemeId)
     theme.setTheme(nextThemeId)
-    const nextTheme = theme.themes()[nextThemeId]
     showToast({
     showToast({
       title: language.t("toast.theme.title"),
       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 (e.details.type === "permission.asked") {
           if (settings.sounds.permissionsEnabled()) {
           if (settings.sounds.permissionsEnabled()) {
-            playSound(soundSrc(settings.sounds.permissions()))
+            void playSoundById(settings.sounds.permissions())
           }
           }
           if (settings.notifications.permissions()) {
           if (settings.notifications.permissions()) {
             void platform.notify(title, description, href)
             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({
       commands.push({
         id: `theme.set.${id}`,
         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"),
         category: language.t("command.category.theme"),
         onSelect: () => theme.commitPreview(),
         onSelect: () => theme.commitPreview(),
         onHighlight: () => {
         onHighlight: () => {
@@ -1208,15 +1206,27 @@ export default function Layout(props: ParentProps) {
   })
   })
 
 
   function connectProvider() {
   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() {
   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() {
   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) {
   function projectRoot(directory: string) {
@@ -1443,7 +1453,13 @@ export default function Layout(props: ParentProps) {
     layout.sidebar.toggleWorkspaces(project.worktree)
     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() {
   async function chooseProject() {
     function resolve(result: string | string[] | null) {
     function resolve(result: string | string[] | null) {
@@ -1464,10 +1480,14 @@ export default function Layout(props: ParentProps) {
       })
       })
       resolve(result)
       resolve(result)
     } else {
     } 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),
+        )
+      })
     }
     }
   }
   }
 
 

+ 4 - 3
packages/app/src/pages/session.tsx

@@ -1184,8 +1184,6 @@ export default function Page() {
     on(
     on(
       () => sdk.directory,
       () => sdk.directory,
       () => {
       () => {
-        void file.tree.list("")
-
         const tab = activeFileTab()
         const tab = activeFileTab()
         if (!tab) return
         if (!tab) return
         const path = file.pathFromTab(tab)
         const path = file.pathFromTab(tab)
@@ -1640,6 +1638,9 @@ export default function Page() {
     sessionID: () => params.id,
     sessionID: () => params.id,
     messagesReady,
     messagesReady,
     visibleUserMessages,
     visibleUserMessages,
+    historyMore,
+    historyLoading,
+    loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
     turnStart: historyWindow.turnStart,
     turnStart: historyWindow.turnStart,
     currentMessageId: () => store.messageId,
     currentMessageId: () => store.messageId,
     pendingMessage: () => ui.pendingMessage,
     pendingMessage: () => ui.pendingMessage,
@@ -1711,7 +1712,7 @@ export default function Page() {
           <div class="flex-1 min-h-0 overflow-hidden">
           <div class="flex-1 min-h-0 overflow-hidden">
             <Switch>
             <Switch>
               <Match when={params.id}>
               <Match when={params.id}>
-                <Show when={lastUserMessage()}>
+                <Show when={messagesReady()}>
                   <MessageTimeline
                   <MessageTimeline
                     mobileChanges={mobileChanges()}
                     mobileChanges={mobileChanges()}
                     mobileFallback={reviewContent({
                     mobileFallback={reviewContent({

+ 18 - 0
packages/app/src/pages/session/use-session-hash-scroll.ts

@@ -8,6 +8,9 @@ export const useSessionHashScroll = (input: {
   sessionID: () => string | undefined
   sessionID: () => string | undefined
   messagesReady: () => boolean
   messagesReady: () => boolean
   visibleUserMessages: () => UserMessage[]
   visibleUserMessages: () => UserMessage[]
+  historyMore: () => boolean
+  historyLoading: () => boolean
+  loadMore: (sessionID: string) => Promise<void>
   turnStart: () => number
   turnStart: () => number
   currentMessageId: () => string | undefined
   currentMessageId: () => string | undefined
   pendingMessage: () => string | undefined
   pendingMessage: () => string | undefined
@@ -181,6 +184,21 @@ export const useSessionHashScroll = (input: {
     queue(() => scrollToMessage(msg, "auto"))
     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(() => {
   onMount(() => {
     if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
     if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
       window.history.scrollRestoration = "manual"
       window.history.scrollRestoration = "manual"

+ 23 - 1
packages/app/src/utils/server-health.ts

@@ -14,6 +14,15 @@ interface CheckServerHealthOptions {
 const defaultTimeoutMs = 3000
 const defaultTimeoutMs = 3000
 const defaultRetryCount = 2
 const defaultRetryCount = 2
 const defaultRetryDelayMs = 100
 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) {
 function timeoutSignal(timeoutMs: number) {
   const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
   const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
@@ -87,5 +96,18 @@ export function useCheckServerHealth() {
   const platform = usePlatform()
   const platform = usePlatform()
   const fetcher = platform.fetch ?? globalThis.fetch
   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
+  }
 }
 }

+ 81 - 96
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 = [
 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
 ] as const
 
 
 export type SoundOption = (typeof SOUND_OPTIONS)[number]
 export type SoundOption = (typeof SOUND_OPTIONS)[number]
 export type SoundID = SoundOption["id"]
 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) {
 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) {
 export function playSound(src: string | undefined) {
@@ -108,10 +91,12 @@ export function playSound(src: string | undefined) {
   if (!src) return
   if (!src) return
   const audio = new Audio(src)
   const audio = new Audio(src)
   audio.play().catch(() => undefined)
   audio.play().catch(() => undefined)
-
-  // Return a cleanup function to pause the sound.
   return () => {
   return () => {
     audio.pause()
     audio.pause()
     audio.currentTime = 0
     audio.currentTime = 0
   }
   }
 }
 }
+
+export function playSoundById(id: string | undefined) {
+  return soundSrc(id).then((src) => playSound(src))
+}

+ 12 - 0
packages/app/vite.js

@@ -1,7 +1,10 @@
+import { readFileSync } from "node:fs"
 import solidPlugin from "vite-plugin-solid"
 import solidPlugin from "vite-plugin-solid"
 import tailwindcss from "@tailwindcss/vite"
 import tailwindcss from "@tailwindcss/vite"
 import { fileURLToPath } from "url"
 import { fileURLToPath } from "url"
 
 
+const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
+
 /**
 /**
  * @type {import("vite").PluginOption}
  * @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(),
   tailwindcss(),
   solidPlugin(),
   solidPlugin(),
 ]
 ]

+ 17 - 3
packages/desktop-electron/src/renderer/index.tsx

@@ -6,6 +6,9 @@ import {
   AppBaseProviders,
   AppBaseProviders,
   AppInterface,
   AppInterface,
   handleNotificationClick,
   handleNotificationClick,
+  loadLocaleDict,
+  normalizeLocale,
+  type Locale,
   type Platform,
   type Platform,
   PlatformProvider,
   PlatformProvider,
   ServerConnection,
   ServerConnection,
@@ -246,6 +249,17 @@ listenForDeepLinks()
 
 
 render(() => {
 render(() => {
   const platform = createPlatform()
   const platform = createPlatform()
+  const loadLocale = async () => {
+    const current = await platform.storage?.("opencode.global.dat").getItem("language")
+    const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
+    const raw = current ?? legacy
+    if (!raw) return
+    const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
+    if (!locale) return
+    const next = normalizeLocale(locale)
+    if (next !== "en") await loadLocaleDict(next)
+    return next satisfies Locale
+  }
 
 
   const [windowCount] = createResource(() => window.api.getWindowCount())
   const [windowCount] = createResource(() => window.api.getWindowCount())
 
 
@@ -257,6 +271,7 @@ render(() => {
       if (url) return ServerConnection.key({ type: "http", http: { url } })
       if (url) return ServerConnection.key({ type: "http", http: { url } })
     }),
     }),
   )
   )
+  const [locale] = createResource(loadLocale)
 
 
   const servers = () => {
   const servers = () => {
     const data = sidecar()
     const data = sidecar()
@@ -309,15 +324,14 @@ render(() => {
 
 
   return (
   return (
     <PlatformProvider value={platform}>
     <PlatformProvider value={platform}>
-      <AppBaseProviders>
-        <Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading}>
+      <AppBaseProviders locale={locale.latest}>
+        <Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading}>
           {(_) => {
           {(_) => {
             return (
             return (
               <AppInterface
               <AppInterface
                 defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
                 defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
                 servers={servers()}
                 servers={servers()}
                 router={MemoryRouter}
                 router={MemoryRouter}
-                disableHealthCheck={(windowCount() ?? 0) > 1}
               >
               >
                 <Inner />
                 <Inner />
               </AppInterface>
               </AppInterface>

+ 17 - 2
packages/desktop/src/index.tsx

@@ -6,6 +6,9 @@ import {
   AppBaseProviders,
   AppBaseProviders,
   AppInterface,
   AppInterface,
   handleNotificationClick,
   handleNotificationClick,
+  loadLocaleDict,
+  normalizeLocale,
+  type Locale,
   type Platform,
   type Platform,
   PlatformProvider,
   PlatformProvider,
   ServerConnection,
   ServerConnection,
@@ -414,6 +417,17 @@ void listenForDeepLinks()
 
 
 render(() => {
 render(() => {
   const platform = createPlatform()
   const platform = createPlatform()
+  const loadLocale = async () => {
+    const current = await platform.storage?.("opencode.global.dat").getItem("language")
+    const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
+    const raw = current ?? legacy
+    if (!raw) return
+    const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
+    if (!locale) return
+    const next = normalizeLocale(locale)
+    if (next !== "en") await loadLocaleDict(next)
+    return next satisfies Locale
+  }
 
 
   // Fetch sidecar credentials from Rust (available immediately, before health check)
   // Fetch sidecar credentials from Rust (available immediately, before health check)
   const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
   const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
@@ -423,6 +437,7 @@ render(() => {
       if (url) return ServerConnection.key({ type: "http", http: { url } })
       if (url) return ServerConnection.key({ type: "http", http: { url } })
     }),
     }),
   )
   )
+  const [locale] = createResource(loadLocale)
 
 
   // Build the sidecar server connection once credentials arrive
   // Build the sidecar server connection once credentials arrive
   const servers = () => {
   const servers = () => {
@@ -465,8 +480,8 @@ render(() => {
 
 
   return (
   return (
     <PlatformProvider value={platform}>
     <PlatformProvider value={platform}>
-      <AppBaseProviders>
-        <Show when={!defaultServer.loading && !sidecar.loading}>
+      <AppBaseProviders locale={locale.latest}>
+        <Show when={!defaultServer.loading && !sidecar.loading && !locale.loading}>
           {(_) => {
           {(_) => {
             return (
             return (
               <AppInterface
               <AppInterface

+ 11 - 4
packages/opencode/src/server/server.ts

@@ -1,3 +1,4 @@
+import { createHash } from "node:crypto"
 import { Log } from "../util/log"
 import { Log } from "../util/log"
 import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
 import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
 import { Hono } from "hono"
 import { Hono } from "hono"
@@ -47,6 +48,9 @@ import { lazy } from "@/util/lazy"
 // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
 // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
 globalThis.AI_SDK_LOG_WARNINGS = false
 globalThis.AI_SDK_LOG_WARNINGS = false
 
 
+const csp = (hash = "") =>
+  `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
+
 export namespace Server {
 export namespace Server {
   const log = Log.create({ service: "server" })
   const log = Log.create({ service: "server" })
 
 
@@ -506,10 +510,13 @@ export namespace Server {
             host: "app.opencode.ai",
             host: "app.opencode.ai",
           },
           },
         })
         })
-        response.headers.set(
-          "Content-Security-Policy",
-          "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:",
-        )
+        const match = response.headers.get("content-type")?.includes("text/html")
+          ? (await response.clone().text()).match(
+              /<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
+            )
+          : undefined
+        const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
+        response.headers.set("Content-Security-Policy", csp(hash))
         return response
         return response
       })
       })
   }
   }

+ 1 - 0
packages/ui/package.json

@@ -12,6 +12,7 @@
     "./hooks": "./src/hooks/index.ts",
     "./hooks": "./src/hooks/index.ts",
     "./context": "./src/context/index.ts",
     "./context": "./src/context/index.ts",
     "./context/*": "./src/context/*.tsx",
     "./context/*": "./src/context/*.tsx",
+    "./font-loader": "./src/font-loader.ts",
     "./styles": "./src/styles/index.css",
     "./styles": "./src/styles/index.css",
     "./styles/tailwind": "./src/styles/tailwind/index.css",
     "./styles/tailwind": "./src/styles/tailwind/index.css",
     "./theme": "./src/theme/index.ts",
     "./theme": "./src/theme/index.ts",

+ 3 - 0
packages/ui/src/assets/icons/provider/alibaba-coding-plan-cn.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
+<path d="M37.9998 23.021C33.7998 25.2889 29.5698 27.3649 24.8614 28.3069C23.8114 28.5154 22.6474 28.5154 21.5809 28.3714C20.5639 28.2439 20.0554 27.3484 20.4169 26.4064C20.7619 25.5289 21.2209 24.635 21.8119 23.9C23.0899 22.3025 24.5329 20.849 25.8289 19.268C26.6203 18.2991 27.3335 17.2689 27.9618 16.187C28.4208 15.4205 28.2078 14.4935 27.4038 14.111C26.0584 13.4556 24.6154 12.9936 23.1889 12.4986C23.0239 12.4341 22.7779 12.6096 22.4509 12.7221C22.8604 13.0881 23.1559 13.3596 23.5654 13.727C19.3339 14.447 15.3305 15.467 11.4455 16.874C11.4275 16.9535 11.396 17.0165 11.411 17.0495C11.9855 17.927 11.723 18.5975 10.886 19.1405C10.5611 19.3531 10.2732 19.6176 10.034 19.9235C12.593 20.6735 14.873 20.243 17.0539 18.821C16.9234 18.6305 16.7914 18.455 16.6609 18.263C17.4799 18.407 17.9719 18.854 18.0379 19.556C18.0544 19.7165 17.9569 19.8755 17.9074 20.036C17.7919 19.907 17.6449 19.781 17.5474 19.6355C17.4799 19.5395 17.4634 19.4285 17.4154 19.268C14.8235 20.993 12.035 21.425 8.96751 20.531C8.96751 21.137 8.93451 21.6485 8.98401 22.1435C9.01701 22.574 8.83701 22.766 8.44401 22.9895C7.55752 23.5325 6.63803 24.092 5.90003 24.8105C5.01504 25.6879 5.34354 26.7589 6.54053 27.2059C7.90102 27.7159 9.329 27.7309 10.7555 27.5569C12.4445 27.3484 14.1005 27.0769 15.9394 26.8219C13.79 27.8269 11.6735 28.5319 9.4445 28.8169C7.88452 29.0269 6.32753 29.1379 4.78554 28.6909C2.57156 28.0684 1.58607 26.4394 2.16057 24.251C2.70206 22.2065 4.01455 20.5775 5.42454 19.076C10.133 14.078 16.0864 11.5401 22.9744 11.0286C24.5824 10.9176 26.2069 11.1246 27.7143 11.7951C29.8308 12.7536 30.7173 14.78 29.6838 16.826C29.0118 18.1835 28.0758 19.4285 27.1413 20.6585C26.2234 21.872 25.1899 22.9895 24.2224 24.155C23.9434 24.506 23.6809 24.875 23.4679 25.2724C23.0569 26.0224 23.3359 26.5174 24.2059 26.4394C26.0254 26.2624 27.8808 26.1199 29.6358 25.6729C32.2098 25.0174 34.7193 24.092 37.2618 23.2775C37.5243 23.213 37.7703 23.117 37.9998 23.0225V23.021Z" fill="currentColor"/>
+</svg>

+ 3 - 0
packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
+<path d="M37.9998 23.021C33.7998 25.2889 29.5698 27.3649 24.8614 28.3069C23.8114 28.5154 22.6474 28.5154 21.5809 28.3714C20.5639 28.2439 20.0554 27.3484 20.4169 26.4064C20.7619 25.5289 21.2209 24.635 21.8119 23.9C23.0899 22.3025 24.5329 20.849 25.8289 19.268C26.6203 18.2991 27.3335 17.2689 27.9618 16.187C28.4208 15.4205 28.2078 14.4935 27.4038 14.111C26.0584 13.4556 24.6154 12.9936 23.1889 12.4986C23.0239 12.4341 22.7779 12.6096 22.4509 12.7221C22.8604 13.0881 23.1559 13.3596 23.5654 13.727C19.3339 14.447 15.3305 15.467 11.4455 16.874C11.4275 16.9535 11.396 17.0165 11.411 17.0495C11.9855 17.927 11.723 18.5975 10.886 19.1405C10.5611 19.3531 10.2732 19.6176 10.034 19.9235C12.593 20.6735 14.873 20.243 17.0539 18.821C16.9234 18.6305 16.7914 18.455 16.6609 18.263C17.4799 18.407 17.9719 18.854 18.0379 19.556C18.0544 19.7165 17.9569 19.8755 17.9074 20.036C17.7919 19.907 17.6449 19.781 17.5474 19.6355C17.4799 19.5395 17.4634 19.4285 17.4154 19.268C14.8235 20.993 12.035 21.425 8.96751 20.531C8.96751 21.137 8.93451 21.6485 8.98401 22.1435C9.01701 22.574 8.83701 22.766 8.44401 22.9895C7.55752 23.5325 6.63803 24.092 5.90003 24.8105C5.01504 25.6879 5.34354 26.7589 6.54053 27.2059C7.90102 27.7159 9.329 27.7309 10.7555 27.5569C12.4445 27.3484 14.1005 27.0769 15.9394 26.8219C13.79 27.8269 11.6735 28.5319 9.4445 28.8169C7.88452 29.0269 6.32753 29.1379 4.78554 28.6909C2.57156 28.0684 1.58607 26.4394 2.16057 24.251C2.70206 22.2065 4.01455 20.5775 5.42454 19.076C10.133 14.078 16.0864 11.5401 22.9744 11.0286C24.5824 10.9176 26.2069 11.1246 27.7143 11.7951C29.8308 12.7536 30.7173 14.78 29.6838 16.826C29.0118 18.1835 28.0758 19.4285 27.1413 20.6585C26.2234 21.872 25.1899 22.9895 24.2224 24.155C23.9434 24.506 23.6809 24.875 23.4679 25.2724C23.0569 26.0224 23.3359 26.5174 24.2059 26.4394C26.0254 26.2624 27.8808 26.1199 29.6358 25.6729C32.2098 25.0174 34.7193 24.092 37.2618 23.2775C37.5243 23.213 37.7703 23.117 37.9998 23.0225V23.021Z" fill="currentColor"/>
+</svg>

+ 24 - 0
packages/ui/src/assets/icons/provider/clarifai.svg

@@ -0,0 +1,24 @@
+<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path
+    shape-rendering="geometricPrecision"
+    d="M9.8132 15.9038L9 18.75L8.1868 15.9038C7.75968 14.4089 6.59112 13.2403 5.09619 12.8132L2.25 12L5.09619 11.1868C6.59113 10.7597 7.75968 9.59112 8.1868 8.09619L9 5.25L9.8132 8.09619C10.2403 9.59113 11.4089 10.7597 12.9038 11.1868L15.75 12L12.9038 12.8132C11.4089 13.2403 10.2403 14.4089 9.8132 15.9038Z"
+    stroke="currentColor"
+    stroke-width="1.5"
+    stroke-linecap="round"
+    stroke-linejoin="round"
+  />
+  <path
+    d="M18.2589 8.71454L18 9.75L17.7411 8.71454C17.4388 7.50533 16.4947 6.56117 15.2855 6.25887L14.25 6L15.2855 5.74113C16.4947 5.43883 17.4388 4.49467 17.7411 3.28546L18 2.25L18.2589 3.28546C18.5612 4.49467 19.5053 5.43883 20.7145 5.74113L21.75 6L20.7145 6.25887C19.5053 6.56117 18.5612 7.50533 18.2589 8.71454Z"
+    stroke="currentColor"
+    stroke-width="1.5"
+    stroke-linecap="round"
+    stroke-linejoin="round"
+  />
+  <path
+    d="M16.8942 20.5673L16.5 21.75L16.1058 20.5673C15.8818 19.8954 15.3546 19.3682 14.6827 19.1442L13.5 18.75L14.6827 18.3558C15.3546 18.1318 15.8818 17.6046 16.1058 16.9327L16.5 15.75L16.8942 16.9327C17.1182 17.6046 17.6454 18.1318 18.3173 18.3558L19.5 18.75L18.3173 19.1442C17.6454 19.3682 17.1182 19.8954 16.8942 20.5673Z"
+    stroke="currentColor"
+    stroke-width="1.5"
+    stroke-linecap="round"
+    stroke-linejoin="round"
+  />
+</svg>

+ 1 - 0
packages/ui/src/assets/icons/provider/dinference.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-terminal h-5 w-5 text-primary"><path d="m4 17 6-6-6-6M12 19h8"/></svg>

+ 8 - 0
packages/ui/src/assets/icons/provider/drun.svg

@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 400">
+  <path fill="currentColor" d="M394.3,154.15v19.4c6.2-3.8,12.7-6.8,19.7-9.1,8.8-2.7,17.8-4.1,27.1-4.1h3.3v42.4h-3.3c-12.9,0-24,4.6-33.1,13.7-9.1,9.1-13.7,20.2-13.7,33.1v80.4h-42.4V154.15h42.4Z"/>
+  <path fill="currentColor" d="M464.5,264.65c-.3-2.9-.5-5.7-.5-8.4v-101.7h41.3v102.2c0,1.1,.1,2.3,.3,3.4,.1,1,.3,2.2,.5,3.4,1,4.5,2.9,8.5,5.7,12,2.6,3.5,5.9,6.5,9.9,8.7,3.5,2.1,7.4,3.4,11.5,3.9,4,.6,8,.4,12-.5,2.7-.7,5.3-1.7,7.7-2.9,2.4-1.3,4.6-2.8,6.5-4.6,2.4-2.1,4.4-4.5,6-7.2,1.6-2.6,2.8-5.4,3.6-8.2v-110.2h42.8v175.1h-42.9v-6.7c-1.5,.8-3,1.5-4.6,2.1-3.2,1.3-6.5,2.2-9.8,2.9-7.8,1.8-15.6,2.4-23.5,1.7-7.9-.7-15.5-2.6-22.8-5.8-10.7-4.7-19.8-11.5-27.3-20.4-7.4-8.9-12.5-19-15.1-30.4-.5-2.8-1-5.6-1.3-8.4Z"/>
+  <path fill="currentColor" d="M228,149.85v33.9c-5.5-3.3-11.3-6-17.5-8.1-7.8-2.4-15.8-3.6-24.2-3.6-22,0-40.6,7.7-56.1,23.2-15.5,15.5-23.3,34.3-23.3,56.2s7.8,40.6,23.3,56.1c15.4,15.5,34.1,23.3,56.1,23.3,8.3,0,16.4-1.3,24.2-3.8,6.2-1.9,12-4.6,17.5-8.1v10.6h37.7V119.95l-37.7,29.9Zm-.5,91.2l-3.7,30.2-17.9,13.5-19.6,14.7-19.8-14.8-18-13.4-3.6-30.2-1.8-16.5,21.9-9.5,21.2-9.2,25.7,11.2,17.6,7.5-2,16.5Z"/>
+  <path fill="currentColor" d="M792,329.95h-45v-107.2c0-11.9-4.3-31.9-33.4-31.9-12.3,0-32.8,4.1-32.8,31.3v107.8h-45v-107.8c0-23.3,8.2-42.9,23.6-56.9,13.9-12.5,33.1-19.4,54.2-19.4,46.1,0,78.4,31.6,78.4,76.9v107.2Z"/>
+  <rect fill="currentColor" x="293.59" y="293.67" width="29.3" height="29.3" transform="translate(-127.73 308.26) rotate(-45)"/>
+  <polygon fill="currentColor" points="229.7 89.95 226.4 137.75 264.7 108.85 270.4 62.55 229.7 89.95"/>
+</svg>

+ 3 - 0
packages/ui/src/assets/icons/provider/perplexity-agent.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M17.2642 2.8689L12.042 8.0961M17.2642 2.8689V8.0961H12.042M17.2642 2.8689V4.30027M12.042 8.0961L6.81809 2.8689V8.0961H12.042ZM12.042 8.0961L17.2642 13.3225V20.8159L12.042 15.5887M12.042 8.0961V15.5887M12.042 8.0961L6.81892 13.3225M12.0296 2.1V21.9M12.042 15.5887L6.81892 20.8159V13.3225M6.81892 13.3225L6.81809 15.559H4.57739V8.09527H12.0412L6.81892 13.3225ZM11.9859 8.09527L17.2081 13.3225V15.559H19.4497V8.09527H11.9859Z" stroke="currentColor" stroke-width="0.825" stroke-miterlimit="10"/>
+</svg>

+ 5 - 0
packages/ui/src/assets/icons/provider/tencent-coding-plan.svg

@@ -0,0 +1,5 @@
+<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+  <path d="M20.0483 17.1416C19.6945 17.4914 18.987 18.0161 17.7488 18.0161C17.2182 18.0161 16.5991 18.0161 16.3338 18.0161C15.98 18.0161 13.3268 18.0161 10.143 18.0161C12.4424 15.8298 14.3881 13.9932 14.565 13.8183C14.7419 13.6434 15.1841 13.2061 15.6263 12.8563C16.5107 12.0692 17.2182 11.9817 17.8373 11.9817C18.7217 11.9817 19.4292 12.3316 20.0483 12.8563C21.2864 13.9932 21.2864 16.0047 20.0483 17.1416ZM21.5518 11.457C20.6674 10.495 19.3408 9.88281 17.9257 9.88281C16.6875 9.88281 15.6263 10.3201 14.6534 11.0197C14.2997 11.3695 13.769 11.7194 13.3268 12.2441C12.9731 12.5939 5.36719 19.9401 5.36719 19.9401C5.80939 20.0276 6.34003 20.0276 6.78223 20.0276C7.22443 20.0276 16.0685 20.0276 16.4222 20.0276C17.1298 20.0276 17.6604 20.0276 18.191 19.9401C19.3408 19.8527 20.4905 19.4154 21.4633 18.5409C23.4975 16.6168 23.4975 13.381 21.5518 11.457Z" fill="currentColor"/>
+  <path d="M9.1701 10.9323C8.19726 10.2326 7.22442 9.88281 6.07469 9.88281C4.65965 9.88281 3.33304 10.495 2.44864 11.457C0.502952 13.4685 0.502952 16.6168 2.53708 18.6283C3.42148 19.4154 4.30589 19.8527 5.36717 19.9401L7.4013 18.0161C7.04754 18.0161 6.60533 18.0161 6.25157 18.0161C5.10185 17.9287 4.39433 17.5789 3.95212 17.1416C2.71396 15.9172 2.71396 13.9932 3.86368 12.7688C4.48277 12.1566 5.19029 11.8943 6.07469 11.8943C6.60533 11.8943 7.4013 11.9817 8.19726 12.7688C8.55102 13.1186 9.52386 13.8183 9.87763 14.1681H9.96607L11.2927 12.8563V12.7688C10.6736 12.1566 9.70075 11.3695 9.1701 10.9323Z" fill="currentColor"/>
+  <path d="M18.4564 8.74536C17.4836 6.12171 14.9188 4.28516 12.0003 4.28516C8.5511 4.28516 5.80945 6.82135 5.27881 9.96973C5.54413 9.96973 5.80945 9.88228 6.16321 9.88228C6.51697 9.88228 6.95917 9.96973 7.31294 9.96973C7.75514 7.78336 9.70082 6.20917 12.0003 6.20917C13.946 6.20917 15.6263 7.34608 16.4223 9.00773C16.4223 9.00773 16.5107 9.09518 16.5107 9.00773C17.1298 8.92027 17.8373 8.74536 18.4564 8.74536C18.4564 8.83282 18.4564 8.83282 18.4564 8.74536Z" fill="currentColor"/>
+</svg>

File diff suppressed because it is too large
+ 0 - 1
packages/ui/src/assets/icons/provider/zenmux.svg


+ 3 - 116
packages/ui/src/components/font.tsx

@@ -1,121 +1,9 @@
+import { Link, Style } from "@solidjs/meta"
 import { Show } from "solid-js"
 import { Show } from "solid-js"
-import { Style, Link } from "@solidjs/meta"
 import inter from "../assets/fonts/inter.woff2"
 import inter from "../assets/fonts/inter.woff2"
-import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2"
-import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2"
 import ibmPlexMonoBold from "../assets/fonts/ibm-plex-mono-bold.woff2"
 import ibmPlexMonoBold from "../assets/fonts/ibm-plex-mono-bold.woff2"
-
-import cascadiaCode from "../assets/fonts/cascadia-code-nerd-font.woff2"
-import cascadiaCodeBold from "../assets/fonts/cascadia-code-nerd-font-bold.woff2"
-import firaCode from "../assets/fonts/fira-code-nerd-font.woff2"
-import firaCodeBold from "../assets/fonts/fira-code-nerd-font-bold.woff2"
-import hack from "../assets/fonts/hack-nerd-font.woff2"
-import hackBold from "../assets/fonts/hack-nerd-font-bold.woff2"
-import inconsolata from "../assets/fonts/inconsolata-nerd-font.woff2"
-import inconsolataBold from "../assets/fonts/inconsolata-nerd-font-bold.woff2"
-import intelOneMono from "../assets/fonts/intel-one-mono-nerd-font.woff2"
-import intelOneMonoBold from "../assets/fonts/intel-one-mono-nerd-font-bold.woff2"
-import jetbrainsMono from "../assets/fonts/jetbrains-mono-nerd-font.woff2"
-import jetbrainsMonoBold from "../assets/fonts/jetbrains-mono-nerd-font-bold.woff2"
-import mesloLgs from "../assets/fonts/meslo-lgs-nerd-font.woff2"
-import mesloLgsBold from "../assets/fonts/meslo-lgs-nerd-font-bold.woff2"
-import robotoMono from "../assets/fonts/roboto-mono-nerd-font.woff2"
-import robotoMonoBold from "../assets/fonts/roboto-mono-nerd-font-bold.woff2"
-import sourceCodePro from "../assets/fonts/source-code-pro-nerd-font.woff2"
-import sourceCodeProBold from "../assets/fonts/source-code-pro-nerd-font-bold.woff2"
-import ubuntuMono from "../assets/fonts/ubuntu-mono-nerd-font.woff2"
-import ubuntuMonoBold from "../assets/fonts/ubuntu-mono-nerd-font-bold.woff2"
-import iosevka from "../assets/fonts/iosevka-nerd-font.woff2"
-import iosevkaBold from "../assets/fonts/iosevka-nerd-font-bold.woff2"
-import geistMono from "../assets/fonts/GeistMonoNerdFontMono-Regular.woff2"
-import geistMonoBold from "../assets/fonts/GeistMonoNerdFontMono-Bold.woff2"
-
-type MonoFont = {
-  family: string
-  regular: string
-  bold: string
-}
-
-export const MONO_NERD_FONTS = [
-  {
-    family: "JetBrains Mono Nerd Font",
-    regular: jetbrainsMono,
-    bold: jetbrainsMonoBold,
-  },
-  {
-    family: "Fira Code Nerd Font",
-    regular: firaCode,
-    bold: firaCodeBold,
-  },
-  {
-    family: "Cascadia Code Nerd Font",
-    regular: cascadiaCode,
-    bold: cascadiaCodeBold,
-  },
-  {
-    family: "Hack Nerd Font",
-    regular: hack,
-    bold: hackBold,
-  },
-  {
-    family: "Source Code Pro Nerd Font",
-    regular: sourceCodePro,
-    bold: sourceCodeProBold,
-  },
-  {
-    family: "Inconsolata Nerd Font",
-    regular: inconsolata,
-    bold: inconsolataBold,
-  },
-  {
-    family: "Roboto Mono Nerd Font",
-    regular: robotoMono,
-    bold: robotoMonoBold,
-  },
-  {
-    family: "Ubuntu Mono Nerd Font",
-    regular: ubuntuMono,
-    bold: ubuntuMonoBold,
-  },
-  {
-    family: "Intel One Mono Nerd Font",
-    regular: intelOneMono,
-    bold: intelOneMonoBold,
-  },
-  {
-    family: "Meslo LGS Nerd Font",
-    regular: mesloLgs,
-    bold: mesloLgsBold,
-  },
-  {
-    family: "Iosevka Nerd Font",
-    regular: iosevka,
-    bold: iosevkaBold,
-  },
-  {
-    family: "GeistMono Nerd Font",
-    regular: geistMono,
-    bold: geistMonoBold,
-  },
-] satisfies MonoFont[]
-
-const monoNerdCss = MONO_NERD_FONTS.map(
-  (font) => `
-        @font-face {
-          font-family: "${font.family}";
-          src: url("${font.regular}") format("woff2");
-          font-display: swap;
-          font-style: normal;
-          font-weight: 400;
-        }
-        @font-face {
-          font-family: "${font.family}";
-          src: url("${font.bold}") format("woff2");
-          font-display: swap;
-          font-style: normal;
-          font-weight: 700;
-        }`,
-).join("")
+import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2"
+import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2"
 
 
 export const Font = () => {
 export const Font = () => {
   return (
   return (
@@ -165,7 +53,6 @@ export const Font = () => {
           descent-override: 25%;
           descent-override: 25%;
           line-gap-override: 1%;
           line-gap-override: 1%;
         }
         }
-${monoNerdCss}
       `}</Style>
       `}</Style>
       <Show when={typeof location === "undefined" || location.protocol !== "file:"}>
       <Show when={typeof location === "undefined" || location.protocol !== "file:"}>
         <Link rel="preload" href={inter} as="font" type="font/woff2" crossorigin="anonymous" />
         <Link rel="preload" href={inter} as="font" type="font/woff2" crossorigin="anonymous" />

+ 133 - 0
packages/ui/src/font-loader.ts

@@ -0,0 +1,133 @@
+type MonoFont = {
+  id: string
+  family: string
+  regular: string
+  bold: string
+}
+
+let files: Record<string, () => Promise<string>> | undefined
+
+function getFiles() {
+  if (files) return files
+  files = import.meta.glob("./assets/fonts/*.woff2", { import: "default" }) as Record<string, () => Promise<string>>
+  return files
+}
+
+export const MONO_NERD_FONTS = [
+  {
+    id: "jetbrains-mono",
+    family: "JetBrains Mono Nerd Font",
+    regular: "./assets/fonts/jetbrains-mono-nerd-font.woff2",
+    bold: "./assets/fonts/jetbrains-mono-nerd-font-bold.woff2",
+  },
+  {
+    id: "fira-code",
+    family: "Fira Code Nerd Font",
+    regular: "./assets/fonts/fira-code-nerd-font.woff2",
+    bold: "./assets/fonts/fira-code-nerd-font-bold.woff2",
+  },
+  {
+    id: "cascadia-code",
+    family: "Cascadia Code Nerd Font",
+    regular: "./assets/fonts/cascadia-code-nerd-font.woff2",
+    bold: "./assets/fonts/cascadia-code-nerd-font-bold.woff2",
+  },
+  {
+    id: "hack",
+    family: "Hack Nerd Font",
+    regular: "./assets/fonts/hack-nerd-font.woff2",
+    bold: "./assets/fonts/hack-nerd-font-bold.woff2",
+  },
+  {
+    id: "source-code-pro",
+    family: "Source Code Pro Nerd Font",
+    regular: "./assets/fonts/source-code-pro-nerd-font.woff2",
+    bold: "./assets/fonts/source-code-pro-nerd-font-bold.woff2",
+  },
+  {
+    id: "inconsolata",
+    family: "Inconsolata Nerd Font",
+    regular: "./assets/fonts/inconsolata-nerd-font.woff2",
+    bold: "./assets/fonts/inconsolata-nerd-font-bold.woff2",
+  },
+  {
+    id: "roboto-mono",
+    family: "Roboto Mono Nerd Font",
+    regular: "./assets/fonts/roboto-mono-nerd-font.woff2",
+    bold: "./assets/fonts/roboto-mono-nerd-font-bold.woff2",
+  },
+  {
+    id: "ubuntu-mono",
+    family: "Ubuntu Mono Nerd Font",
+    regular: "./assets/fonts/ubuntu-mono-nerd-font.woff2",
+    bold: "./assets/fonts/ubuntu-mono-nerd-font-bold.woff2",
+  },
+  {
+    id: "intel-one-mono",
+    family: "Intel One Mono Nerd Font",
+    regular: "./assets/fonts/intel-one-mono-nerd-font.woff2",
+    bold: "./assets/fonts/intel-one-mono-nerd-font-bold.woff2",
+  },
+  {
+    id: "meslo-lgs",
+    family: "Meslo LGS Nerd Font",
+    regular: "./assets/fonts/meslo-lgs-nerd-font.woff2",
+    bold: "./assets/fonts/meslo-lgs-nerd-font-bold.woff2",
+  },
+  {
+    id: "iosevka",
+    family: "Iosevka Nerd Font",
+    regular: "./assets/fonts/iosevka-nerd-font.woff2",
+    bold: "./assets/fonts/iosevka-nerd-font-bold.woff2",
+  },
+  {
+    id: "geist-mono",
+    family: "GeistMono Nerd Font",
+    regular: "./assets/fonts/GeistMonoNerdFontMono-Regular.woff2",
+    bold: "./assets/fonts/GeistMonoNerdFontMono-Bold.woff2",
+  },
+] satisfies MonoFont[]
+
+const mono = Object.fromEntries(MONO_NERD_FONTS.map((font) => [font.id, font])) as Record<string, MonoFont>
+const loads = new Map<string, Promise<void>>()
+
+function css(font: { family: string; regular: string; bold: string }) {
+  return `
+    @font-face {
+      font-family: "${font.family}";
+      src: url("${font.regular}") format("woff2");
+      font-display: swap;
+      font-style: normal;
+      font-weight: 400;
+    }
+    @font-face {
+      font-family: "${font.family}";
+      src: url("${font.bold}") format("woff2");
+      font-display: swap;
+      font-style: normal;
+      font-weight: 700;
+    }
+  `
+}
+
+export function ensureMonoFont(id: string | undefined) {
+  if (!id || id === "ibm-plex-mono") return Promise.resolve()
+  if (typeof document !== "object") return Promise.resolve()
+  const font = mono[id]
+  if (!font) return Promise.resolve()
+  const styleId = `oc-font-${font.id}`
+  if (document.getElementById(styleId)) return Promise.resolve()
+  const hit = loads.get(font.id)
+  if (hit) return hit
+  const files = getFiles()
+  const load = Promise.all([files[font.regular]?.(), files[font.bold]?.()]).then(([regular, bold]) => {
+    if (!regular || !bold) return
+    if (document.getElementById(styleId)) return
+    const style = document.createElement("style")
+    style.id = styleId
+    style.textContent = css({ family: font.family, regular, bold })
+    document.head.appendChild(style)
+  })
+  loads.set(font.id, load)
+  return load
+}

+ 215 - 71
packages/ui/src/theme/context.tsx

@@ -1,7 +1,7 @@
 import { createEffect, onCleanup, onMount } from "solid-js"
 import { createEffect, onCleanup, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
 import { createSimpleContext } from "../context/helper"
 import { createSimpleContext } from "../context/helper"
-import { DEFAULT_THEMES } from "./default-themes"
+import oc2ThemeJson from "./themes/oc-2.json"
 import { resolveThemeVariant, themeToCss } from "./resolve"
 import { resolveThemeVariant, themeToCss } from "./resolve"
 import type { DesktopTheme } from "./types"
 import type { DesktopTheme } from "./types"
 
 
@@ -15,14 +15,101 @@ const STORAGE_KEYS = {
 } as const
 } as const
 
 
 const THEME_STYLE_ID = "oc-theme"
 const THEME_STYLE_ID = "oc-theme"
+let files: Record<string, () => Promise<{ default: DesktopTheme }>> | undefined
+let ids: string[] | undefined
+let known: Set<string> | undefined
+
+function getFiles() {
+  if (files) return files
+  files = import.meta.glob<{ default: DesktopTheme }>("./themes/*.json")
+  return files
+}
+
+function themeIDs() {
+  if (ids) return ids
+  ids = Object.keys(getFiles())
+    .map((path) => path.slice("./themes/".length, -".json".length))
+    .sort()
+  return ids
+}
+
+function knownThemes() {
+  if (known) return known
+  known = new Set(themeIDs())
+  return known
+}
+
+const names: Record<string, string> = {
+  "oc-2": "OC-2",
+  amoled: "AMOLED",
+  aura: "Aura",
+  ayu: "Ayu",
+  carbonfox: "Carbonfox",
+  catppuccin: "Catppuccin",
+  "catppuccin-frappe": "Catppuccin Frappe",
+  "catppuccin-macchiato": "Catppuccin Macchiato",
+  cobalt2: "Cobalt2",
+  cursor: "Cursor",
+  dracula: "Dracula",
+  everforest: "Everforest",
+  flexoki: "Flexoki",
+  github: "GitHub",
+  gruvbox: "Gruvbox",
+  kanagawa: "Kanagawa",
+  "lucent-orng": "Lucent Orng",
+  material: "Material",
+  matrix: "Matrix",
+  mercury: "Mercury",
+  monokai: "Monokai",
+  nightowl: "Night Owl",
+  nord: "Nord",
+  "one-dark": "One Dark",
+  onedarkpro: "One Dark Pro",
+  opencode: "OpenCode",
+  orng: "Orng",
+  "osaka-jade": "Osaka Jade",
+  palenight: "Palenight",
+  rosepine: "Rose Pine",
+  shadesofpurple: "Shades of Purple",
+  solarized: "Solarized",
+  synthwave84: "Synthwave '84",
+  tokyonight: "Tokyonight",
+  vercel: "Vercel",
+  vesper: "Vesper",
+  zenburn: "Zenburn",
+}
+const oc2Theme = oc2ThemeJson as DesktopTheme
 
 
 function normalize(id: string | null | undefined) {
 function normalize(id: string | null | undefined) {
   return id === "oc-1" ? "oc-2" : id
   return id === "oc-1" ? "oc-2" : id
 }
 }
 
 
+function read(key: string) {
+  if (typeof localStorage !== "object") return null
+  try {
+    return localStorage.getItem(key)
+  } catch {
+    return null
+  }
+}
+
+function write(key: string, value: string) {
+  if (typeof localStorage !== "object") return
+  try {
+    localStorage.setItem(key, value)
+  } catch {}
+}
+
+function drop(key: string) {
+  if (typeof localStorage !== "object") return
+  try {
+    localStorage.removeItem(key)
+  } catch {}
+}
+
 function clear() {
 function clear() {
-  localStorage.removeItem(STORAGE_KEYS.THEME_CSS_LIGHT)
-  localStorage.removeItem(STORAGE_KEYS.THEME_CSS_DARK)
+  drop(STORAGE_KEYS.THEME_CSS_LIGHT)
+  drop(STORAGE_KEYS.THEME_CSS_DARK)
 }
 }
 
 
 function ensureThemeStyleElement(): HTMLStyleElement {
 function ensureThemeStyleElement(): HTMLStyleElement {
@@ -35,6 +122,7 @@ function ensureThemeStyleElement(): HTMLStyleElement {
 }
 }
 
 
 function getSystemMode(): "light" | "dark" {
 function getSystemMode(): "light" | "dark" {
+  if (typeof window !== "object") return "light"
   return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
   return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
 }
 }
 
 
@@ -45,9 +133,7 @@ function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "da
   const css = themeToCss(tokens)
   const css = themeToCss(tokens)
 
 
   if (themeId !== "oc-2") {
   if (themeId !== "oc-2") {
-    try {
-      localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css)
-    } catch {}
+    write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css)
   }
   }
 
 
   const fullCss = `:root {
   const fullCss = `:root {
@@ -69,74 +155,122 @@ function cacheThemeVariants(theme: DesktopTheme, themeId: string) {
     const variant = isDark ? theme.dark : theme.light
     const variant = isDark ? theme.dark : theme.light
     const tokens = resolveThemeVariant(variant, isDark)
     const tokens = resolveThemeVariant(variant, isDark)
     const css = themeToCss(tokens)
     const css = themeToCss(tokens)
-    try {
-      localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css)
-    } catch {}
+    write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css)
   }
   }
 }
 }
 
 
 export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
 export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
   name: "Theme",
   name: "Theme",
   init: (props: { defaultTheme?: string; onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void }) => {
   init: (props: { defaultTheme?: string; onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void }) => {
+    const themeId = normalize(read(STORAGE_KEYS.THEME_ID) ?? props.defaultTheme) ?? "oc-2"
+    const colorScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system"
+    const mode = colorScheme === "system" ? getSystemMode() : colorScheme
     const [store, setStore] = createStore({
     const [store, setStore] = createStore({
-      themes: DEFAULT_THEMES as Record<string, DesktopTheme>,
-      themeId: normalize(props.defaultTheme) ?? "oc-2",
-      colorScheme: "system" as ColorScheme,
-      mode: getSystemMode(),
+      themes: {
+        "oc-2": oc2Theme,
+      } as Record<string, DesktopTheme>,
+      themeId,
+      colorScheme,
+      mode,
       previewThemeId: null as string | null,
       previewThemeId: null as string | null,
       previewScheme: null as ColorScheme | null,
       previewScheme: null as ColorScheme | null,
     })
     })
 
 
-    window.addEventListener("storage", (e) => {
-      if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) setStore("themeId", e.newValue)
+    const loads = new Map<string, Promise<DesktopTheme | undefined>>()
+
+    const load = (id: string) => {
+      const next = normalize(id)
+      if (!next) return Promise.resolve(undefined)
+      const hit = store.themes[next]
+      if (hit) return Promise.resolve(hit)
+      const pending = loads.get(next)
+      if (pending) return pending
+      const file = getFiles()[`./themes/${next}.json`]
+      if (!file) return Promise.resolve(undefined)
+      const task = file()
+        .then((mod) => {
+          const theme = mod.default
+          setStore("themes", next, theme)
+          return theme
+        })
+        .finally(() => {
+          loads.delete(next)
+        })
+      loads.set(next, task)
+      return task
+    }
+
+    const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark") => {
+      applyThemeCss(theme, themeId, mode)
+      props.onThemeApplied?.(theme, mode)
+    }
+
+    const ids = () => {
+      const extra = Object.keys(store.themes)
+        .filter((id) => !knownThemes().has(id))
+        .sort()
+      const all = themeIDs()
+      if (extra.length === 0) return all
+      return [...all, ...extra]
+    }
+
+    const loadThemes = () => Promise.all(themeIDs().map(load)).then(() => store.themes)
+
+    const onStorage = (e: StorageEvent) => {
+      if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) {
+        const next = normalize(e.newValue)
+        if (!next) return
+        if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return
+        setStore("themeId", next)
+        if (next === "oc-2") {
+          clear()
+          return
+        }
+        void load(next).then((theme) => {
+          if (!theme || store.themeId !== next) return
+          cacheThemeVariants(theme, next)
+        })
+      }
       if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) {
       if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) {
         setStore("colorScheme", e.newValue as ColorScheme)
         setStore("colorScheme", e.newValue as ColorScheme)
-        setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as any))
+        setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as "light" | "dark"))
       }
       }
-    })
+    }
+
+    if (typeof window === "object") {
+      window.addEventListener("storage", onStorage)
+      onCleanup(() => window.removeEventListener("storage", onStorage))
+    }
 
 
     onMount(() => {
     onMount(() => {
       const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
       const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
-      const handler = () => {
-        if (store.colorScheme === "system") {
-          setStore("mode", getSystemMode())
-        }
-      }
-      mediaQuery.addEventListener("change", handler)
-      onCleanup(() => mediaQuery.removeEventListener("change", handler))
-
-      const savedTheme = localStorage.getItem(STORAGE_KEYS.THEME_ID)
-      const themeId = normalize(savedTheme)
-      const savedScheme = localStorage.getItem(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null
-      if (themeId && store.themes[themeId]) {
-        setStore("themeId", themeId)
+      const onMedia = () => {
+        if (store.colorScheme !== "system") return
+        setStore("mode", getSystemMode())
       }
       }
-      if (savedTheme && themeId && savedTheme !== themeId) {
-        localStorage.setItem(STORAGE_KEYS.THEME_ID, themeId)
+      mediaQuery.addEventListener("change", onMedia)
+      onCleanup(() => mediaQuery.removeEventListener("change", onMedia))
+
+      const rawTheme = read(STORAGE_KEYS.THEME_ID)
+      const savedTheme = normalize(rawTheme ?? props.defaultTheme) ?? "oc-2"
+      const savedScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system"
+      if (rawTheme && rawTheme !== savedTheme) {
+        write(STORAGE_KEYS.THEME_ID, savedTheme)
         clear()
         clear()
       }
       }
-      if (savedScheme) {
-        setStore("colorScheme", savedScheme)
-        if (savedScheme !== "system") {
-          setStore("mode", savedScheme)
-        }
-      }
-      const currentTheme = store.themes[store.themeId]
-      if (currentTheme) {
-        cacheThemeVariants(currentTheme, store.themeId)
-      }
+      if (savedTheme !== store.themeId) setStore("themeId", savedTheme)
+      if (savedScheme !== store.colorScheme) setStore("colorScheme", savedScheme)
+      setStore("mode", savedScheme === "system" ? getSystemMode() : savedScheme)
+      void load(savedTheme).then((theme) => {
+        if (!theme || store.themeId !== savedTheme) return
+        cacheThemeVariants(theme, savedTheme)
+      })
     })
     })
 
 
-    const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark") => {
-      applyThemeCss(theme, themeId, mode)
-      props.onThemeApplied?.(theme, mode)
-    }
-
     createEffect(() => {
     createEffect(() => {
       const theme = store.themes[store.themeId]
       const theme = store.themes[store.themeId]
-      if (theme) {
-        applyTheme(theme, store.themeId, store.mode)
-      }
+      if (!theme) return
+      applyTheme(theme, store.themeId, store.mode)
     })
     })
 
 
     const setTheme = (id: string) => {
     const setTheme = (id: string) => {
@@ -145,23 +279,26 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
         console.warn(`Theme "${id}" not found`)
         console.warn(`Theme "${id}" not found`)
         return
         return
       }
       }
-      const theme = store.themes[next]
-      if (!theme) {
+      if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) {
         console.warn(`Theme "${id}" not found`)
         console.warn(`Theme "${id}" not found`)
         return
         return
       }
       }
       setStore("themeId", next)
       setStore("themeId", next)
-      localStorage.setItem(STORAGE_KEYS.THEME_ID, next)
       if (next === "oc-2") {
       if (next === "oc-2") {
+        write(STORAGE_KEYS.THEME_ID, next)
         clear()
         clear()
         return
         return
       }
       }
-      cacheThemeVariants(theme, next)
+      void load(next).then((theme) => {
+        if (!theme || store.themeId !== next) return
+        cacheThemeVariants(theme, next)
+        write(STORAGE_KEYS.THEME_ID, next)
+      })
     }
     }
 
 
     const setColorScheme = (scheme: ColorScheme) => {
     const setColorScheme = (scheme: ColorScheme) => {
       setStore("colorScheme", scheme)
       setStore("colorScheme", scheme)
-      localStorage.setItem(STORAGE_KEYS.COLOR_SCHEME, scheme)
+      write(STORAGE_KEYS.COLOR_SCHEME, scheme)
       setStore("mode", scheme === "system" ? getSystemMode() : scheme)
       setStore("mode", scheme === "system" ? getSystemMode() : scheme)
     }
     }
 
 
@@ -169,6 +306,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
       themeId: () => store.themeId,
       themeId: () => store.themeId,
       colorScheme: () => store.colorScheme,
       colorScheme: () => store.colorScheme,
       mode: () => store.mode,
       mode: () => store.mode,
+      ids,
+      name: (id: string) => store.themes[id]?.name ?? names[id] ?? id,
+      loadThemes,
       themes: () => store.themes,
       themes: () => store.themes,
       setTheme,
       setTheme,
       setColorScheme,
       setColorScheme,
@@ -176,24 +316,28 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
       previewTheme: (id: string) => {
       previewTheme: (id: string) => {
         const next = normalize(id)
         const next = normalize(id)
         if (!next) return
         if (!next) return
-        const theme = store.themes[next]
-        if (!theme) return
+        if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return
         setStore("previewThemeId", next)
         setStore("previewThemeId", next)
-        const previewMode = store.previewScheme
-          ? store.previewScheme === "system"
-            ? getSystemMode()
-            : store.previewScheme
-          : store.mode
-        applyTheme(theme, next, previewMode)
+        void load(next).then((theme) => {
+          if (!theme || store.previewThemeId !== next) return
+          const mode = store.previewScheme
+            ? store.previewScheme === "system"
+              ? getSystemMode()
+              : store.previewScheme
+            : store.mode
+          applyTheme(theme, next, mode)
+        })
       },
       },
       previewColorScheme: (scheme: ColorScheme) => {
       previewColorScheme: (scheme: ColorScheme) => {
         setStore("previewScheme", scheme)
         setStore("previewScheme", scheme)
-        const previewMode = scheme === "system" ? getSystemMode() : scheme
+        const mode = scheme === "system" ? getSystemMode() : scheme
         const id = store.previewThemeId ?? store.themeId
         const id = store.previewThemeId ?? store.themeId
-        const theme = store.themes[id]
-        if (theme) {
-          applyTheme(theme, id, previewMode)
-        }
+        void load(id).then((theme) => {
+          if (!theme) return
+          if ((store.previewThemeId ?? store.themeId) !== id) return
+          if (store.previewScheme !== scheme) return
+          applyTheme(theme, id, mode)
+        })
       },
       },
       commitPreview: () => {
       commitPreview: () => {
         if (store.previewThemeId) {
         if (store.previewThemeId) {
@@ -208,10 +352,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
       cancelPreview: () => {
       cancelPreview: () => {
         setStore("previewThemeId", null)
         setStore("previewThemeId", null)
         setStore("previewScheme", null)
         setStore("previewScheme", null)
-        const theme = store.themes[store.themeId]
-        if (theme) {
+        void load(store.themeId).then((theme) => {
+          if (!theme) return
           applyTheme(theme, store.themeId, store.mode)
           applyTheme(theme, store.themeId, store.mode)
-        }
+        })
       },
       },
     }
     }
   },
   },

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