app.tsx 11 KB

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