app.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. import "@/index.css"
  2. import { I18nProvider } from "@opencode-ai/ui/context"
  3. import { DialogProvider } from "@opencode-ai/ui/context/dialog"
  4. import { FileComponentProvider } from "@opencode-ai/ui/context/file"
  5. import { MarkedProvider } from "@opencode-ai/ui/context/marked"
  6. import { File } from "@opencode-ai/ui/file"
  7. import { Font } from "@opencode-ai/ui/font"
  8. import { Splash } from "@opencode-ai/ui/logo"
  9. import { ThemeProvider } from "@opencode-ai/ui/theme/context"
  10. import { MetaProvider } from "@solidjs/meta"
  11. import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
  12. import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
  13. import { type Duration, Effect } from "effect"
  14. import {
  15. type Component,
  16. createMemo,
  17. createResource,
  18. createSignal,
  19. ErrorBoundary,
  20. For,
  21. type JSX,
  22. lazy,
  23. onCleanup,
  24. type ParentProps,
  25. Show,
  26. Suspense,
  27. } from "solid-js"
  28. import { Dynamic } from "solid-js/web"
  29. import { CommandProvider } from "@/context/command"
  30. import { CommentsProvider } from "@/context/comments"
  31. import { FileProvider } from "@/context/file"
  32. import { GlobalSDKProvider } from "@/context/global-sdk"
  33. import { GlobalSyncProvider } from "@/context/global-sync"
  34. import { HighlightsProvider } from "@/context/highlights"
  35. import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
  36. import { LayoutProvider } from "@/context/layout"
  37. import { ModelsProvider } from "@/context/models"
  38. import { NotificationProvider } from "@/context/notification"
  39. import { PermissionProvider } from "@/context/permission"
  40. import { usePlatform } from "@/context/platform"
  41. import { PromptProvider } from "@/context/prompt"
  42. import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
  43. import { SettingsProvider } from "@/context/settings"
  44. import { TerminalProvider } from "@/context/terminal"
  45. import DirectoryLayout from "@/pages/directory-layout"
  46. import Layout from "@/pages/layout"
  47. import { ErrorPage } from "./pages/error"
  48. import { useCheckServerHealth } from "./utils/server-health"
  49. const HomeRoute = lazy(() => import("@/pages/home"))
  50. const Session = lazy(() => import("@/pages/session"))
  51. const Loading = () => <div class="size-full" />
  52. const SessionRoute = () => (
  53. <SessionProviders>
  54. <Session />
  55. </SessionProviders>
  56. )
  57. const SessionIndexRoute = () => <Navigate href="session" />
  58. function UiI18nBridge(props: ParentProps) {
  59. const language = useLanguage()
  60. return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
  61. }
  62. declare global {
  63. interface Window {
  64. __OPENCODE__?: {
  65. updaterEnabled?: boolean
  66. deepLinks?: string[]
  67. wsl?: boolean
  68. }
  69. api?: {
  70. setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
  71. }
  72. }
  73. }
  74. function MarkedProviderWithNativeParser(props: ParentProps) {
  75. const platform = usePlatform()
  76. return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
  77. }
  78. function QueryProvider(props: ParentProps) {
  79. const client = new QueryClient()
  80. return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
  81. }
  82. function AppShellProviders(props: ParentProps) {
  83. return (
  84. <SettingsProvider>
  85. <PermissionProvider>
  86. <LayoutProvider>
  87. <NotificationProvider>
  88. <ModelsProvider>
  89. <CommandProvider>
  90. <HighlightsProvider>
  91. <Layout>{props.children}</Layout>
  92. </HighlightsProvider>
  93. </CommandProvider>
  94. </ModelsProvider>
  95. </NotificationProvider>
  96. </LayoutProvider>
  97. </PermissionProvider>
  98. </SettingsProvider>
  99. )
  100. }
  101. function SessionProviders(props: ParentProps) {
  102. return (
  103. <TerminalProvider>
  104. <FileProvider>
  105. <PromptProvider>
  106. <CommentsProvider>{props.children}</CommentsProvider>
  107. </PromptProvider>
  108. </FileProvider>
  109. </TerminalProvider>
  110. )
  111. }
  112. function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
  113. return (
  114. <AppShellProviders>
  115. <Suspense fallback={<Loading />}>
  116. {props.appChildren}
  117. {props.children}
  118. </Suspense>
  119. </AppShellProviders>
  120. )
  121. }
  122. export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
  123. return (
  124. <MetaProvider>
  125. <Font />
  126. <ThemeProvider
  127. onThemeApplied={(_, mode) => {
  128. void window.api?.setTitlebar?.({ mode })
  129. }}
  130. >
  131. <LanguageProvider locale={props.locale}>
  132. <UiI18nBridge>
  133. <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
  134. <QueryProvider>
  135. <DialogProvider>
  136. <MarkedProviderWithNativeParser>
  137. <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
  138. </MarkedProviderWithNativeParser>
  139. </DialogProvider>
  140. </QueryProvider>
  141. </ErrorBoundary>
  142. </UiI18nBridge>
  143. </LanguageProvider>
  144. </ThemeProvider>
  145. </MetaProvider>
  146. )
  147. }
  148. const effectMinDuration =
  149. (duration: Duration.Input) =>
  150. <A, E, R>(e: Effect.Effect<A, E, R>) =>
  151. Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
  152. function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
  153. const server = useServer()
  154. const checkServerHealth = useCheckServerHealth()
  155. const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
  156. // performs repeated health check with a grace period for
  157. // non-http connections, otherwise fails instantly
  158. const [startupHealthCheck, healthCheckActions] = createResource(() =>
  159. props.disableHealthCheck
  160. ? true
  161. : Effect.gen(function* () {
  162. if (!server.current) return true
  163. const { http, type } = server.current
  164. while (true) {
  165. const res = yield* Effect.promise(() => checkServerHealth(http))
  166. if (res.healthy) return true
  167. if (checkMode() === "background" || type === "http") return false
  168. }
  169. }).pipe(
  170. effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
  171. Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
  172. Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
  173. Effect.runPromise,
  174. ),
  175. )
  176. return (
  177. <Show
  178. when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
  179. fallback={
  180. <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
  181. <Splash class="w-16 h-20 opacity-50 animate-pulse" />
  182. </div>
  183. }
  184. >
  185. <Show
  186. when={startupHealthCheck()}
  187. fallback={
  188. <ConnectionError
  189. onRetry={() => {
  190. if (checkMode() === "background") healthCheckActions.refetch()
  191. }}
  192. onServerSelected={(key) => {
  193. setCheckMode("blocking")
  194. server.setActive(key)
  195. healthCheckActions.refetch()
  196. }}
  197. />
  198. }
  199. >
  200. {props.children}
  201. </Show>
  202. </Show>
  203. )
  204. }
  205. function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
  206. const language = useLanguage()
  207. const server = useServer()
  208. const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
  209. const name = createMemo(() => server.name || server.key)
  210. const serverToken = "\u0000server\u0000"
  211. const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
  212. const timer = setInterval(() => props.onRetry?.(), 1000)
  213. onCleanup(() => clearInterval(timer))
  214. return (
  215. <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
  216. <div class="flex flex-col items-center max-w-md text-center">
  217. <Splash class="w-12 h-15 mb-4" />
  218. <p class="text-14-regular text-text-base">
  219. {unreachable()[0]}
  220. <span class="text-text-strong font-medium">{name()}</span>
  221. {unreachable()[1]}
  222. </p>
  223. <p class="mt-1 text-12-regular text-text-weak">{language.t("app.server.retrying")}</p>
  224. </div>
  225. <Show when={others().length > 0}>
  226. <div class="flex flex-col gap-2 w-full max-w-sm">
  227. <span class="text-12-regular text-text-base text-center">{language.t("app.server.otherServers")}</span>
  228. <div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
  229. <For each={others()}>
  230. {(conn) => {
  231. const key = ServerConnection.key(conn)
  232. return (
  233. <button
  234. type="button"
  235. class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
  236. onClick={() => props.onServerSelected?.(key)}
  237. >
  238. <span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
  239. </button>
  240. )
  241. }}
  242. </For>
  243. </div>
  244. </div>
  245. </Show>
  246. </div>
  247. )
  248. }
  249. function ServerKey(props: ParentProps) {
  250. const server = useServer()
  251. return (
  252. <Show when={server.key} keyed>
  253. {props.children}
  254. </Show>
  255. )
  256. }
  257. export function AppInterface(props: {
  258. children?: JSX.Element
  259. defaultServer: ServerConnection.Key
  260. servers?: Array<ServerConnection.Any>
  261. router?: Component<BaseRouterProps>
  262. disableHealthCheck?: boolean
  263. }) {
  264. return (
  265. <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
  266. <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
  267. <ServerKey>
  268. <GlobalSDKProvider>
  269. <GlobalSyncProvider>
  270. <Dynamic
  271. component={props.router ?? Router}
  272. root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
  273. >
  274. <Route path="/" component={HomeRoute} />
  275. <Route path="/:dir" component={DirectoryLayout}>
  276. <Route path="/" component={SessionIndexRoute} />
  277. <Route path="/session/:id?" component={SessionRoute} />
  278. </Route>
  279. </Dynamic>
  280. </GlobalSyncProvider>
  281. </GlobalSDKProvider>
  282. </ServerKey>
  283. </ConnectionGate>
  284. </ServerProvider>
  285. )
  286. }