app.tsx 10 KB

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