| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657 |
- import "@/index.css"
- import * as Sentry from "@sentry/solid"
- import { I18nProvider } from "@opencode-ai/ui/context"
- import { DialogProvider } from "@opencode-ai/ui/context/dialog"
- import { FileComponentProvider } from "@opencode-ai/ui/context/file"
- import { MarkedProvider } from "@opencode-ai/ui/context/marked"
- import { File } from "@opencode-ai/session-ui/file"
- import { Font } from "@opencode-ai/ui/font"
- import { Splash } from "@opencode-ai/ui/logo"
- import { ThemeProvider } from "@opencode-ai/ui/theme/context"
- import { MetaProvider } from "@solidjs/meta"
- import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
- import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
- import { Effect } from "effect"
- import {
- type Component,
- createEffect,
- createMemo,
- createRenderEffect,
- createResource,
- createSignal,
- ErrorBoundary,
- For,
- type JSX,
- lazy,
- onCleanup,
- type ParentProps,
- Show,
- } from "solid-js"
- import { Dynamic } from "solid-js/web"
- import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
- import { CommentsProvider } from "@/context/comments"
- import { FileProvider } from "@/context/file"
- import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
- import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
- import { GlobalProvider, useGlobal } from "@/context/global"
- import { HighlightsProvider } from "@/context/highlights"
- import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
- import { LayoutProvider } from "@/context/layout"
- import { ModelsProvider } from "@/context/models"
- import { NotificationProvider, useNotification } from "@/context/notification"
- import { PermissionProvider } from "@/context/permission"
- import { usePlatform } from "@/context/platform"
- import { PromptProvider } from "@/context/prompt"
- import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
- import { SettingsProvider, useSettings } from "@/context/settings"
- import { TerminalProvider } from "@/context/terminal"
- import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
- import { SDKProvider, useSDK } from "@/context/sdk"
- import { WslServersProvider } from "@/wsl/context"
- import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
- import LegacyLayout from "@/pages/layout"
- import NewLayout from "@/pages/layout-new"
- import { ErrorPage } from "./pages/error"
- import { useCheckServerHealth } from "./utils/server-health"
- import {
- legacySessionHref,
- legacySessionServer,
- requireServerKey,
- selectSessionLineage,
- sessionHref,
- } from "./utils/session-route"
- import { isSessionNotFoundError } from "./utils/server-errors"
- import Session from "@/pages/session"
- import { NewHome, LegacyHome } from "@/pages/home"
- const NewSession = lazy(() => import("@/pages/new-session"))
- const SessionRoute = () => {
- const settings = useSettings()
- const params = useParams()
- const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
- const sdk = useSDK()
- const server = useServer()
- const tabs = useTabs()
- if (params.id && settings.general.newLayoutDesigns()) {
- const sessionID = params.id
- return (
- <Show when={tabs.ready()}>
- {(_) => {
- const persisted = tabs.store.filter((item) => item.type === "session")
- return <Navigate href={sessionHref(legacySessionServer(persisted, sessionID, server.key), sessionID)} />
- }}
- </Show>
- )
- }
- // When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
- // is replaced by a draft at /new-session?draftId=…
- createEffect(() => {
- if (!settings.general.newLayoutDesigns()) return
- if (params.id || search.draftId) return
- if (!tabs.ready() || !sdk().directory) return
- tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
- })
- return (
- <SessionProviders>
- <Session />
- </SessionProviders>
- )
- }
- const TargetSessionRoute = () => {
- const params = useParams<{ serverKey: string; id: string }>()
- const global = useGlobal()
- const conn = createMemo(() => {
- const key = requireServerKey(params.serverKey)
- return global.servers.list().find((item) => ServerConnection.key(item) === key)
- })
- return (
- <Show when={requireServerKey(params.serverKey)} keyed>
- <ServerSDKProvider server={conn}>
- <ServerSyncProvider server={conn}>
- <ResolvedTargetSessionRoute />
- </ServerSyncProvider>
- </ServerSDKProvider>
- </Show>
- )
- }
- function ResolvedTargetSessionRoute() {
- const params = useParams<{ serverKey: string; id: string }>()
- const settings = useSettings()
- const tabs = useTabs()
- const sync = useServerSync()
- const serverKey = createMemo(() => requireServerKey(params.serverKey))
- const cached = createMemo(() => sync().session.lineage.peek(params.id))
- const [resolved] = createResource(
- () => {
- if (cached()) return
- return { id: params.id, server: serverKey(), sync: sync() }
- },
- ({ id, server, sync }) =>
- sync.session.lineage.resolve(id).catch((error) => {
- if (isSessionNotFoundError(error, id)) tabs.removeSessionTab({ server, sessionId: id })
- throw error
- }),
- )
- const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
- const directory = createMemo(() => current()?.session.directory)
- const targetDirectory = () => directory()!
- createEffect(() => {
- const session = current()
- if (!session) return
- tabs.addSessionTab({
- server: serverKey(),
- sessionId: session.root.id,
- })
- })
- return (
- <TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
- <Show when={!!current() || resolved.state !== "errored"} fallback={<ErrorPage error={resolved.error} />}>
- <Show when={directory()}>
- <Show
- when={settings.general.newLayoutDesigns()}
- fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
- >
- <SDKProvider directory={targetDirectory}>
- <DirectoryDataProvider directory={targetDirectory} server={serverKey}>
- <TargetSessionPage />
- </DirectoryDataProvider>
- </SDKProvider>
- </Show>
- </Show>
- </Show>
- </TargetServerScopedProviders>
- )
- }
- function TargetSessionPage() {
- const sdk = useSDK()
- const serverSDK = useServerSDK()
- return (
- <Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
- <SessionProviders>
- <Session />
- </SessionProviders>
- </Show>
- )
- }
- // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
- // server via ServerKey, then provide the server-scoped shell (Permission/Layout/
- // Notification/Models + the visual Layout) for that server.
- function SelectedServerProviders(props: ParentProps) {
- return (
- <ServerKey>
- <ServerSDKProvider>
- <ServerSyncProvider>{props.children}</ServerSyncProvider>
- </ServerSDKProvider>
- </ServerKey>
- )
- }
- function LegacyServerLayout(props: ParentProps) {
- return (
- <SelectedServerProviders>
- <LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
- </SelectedServerProviders>
- )
- }
- function DraftRoute() {
- const [search] = useSearchParams<{ draftId?: string }>()
- const tabs = useTabs()
- return (
- <Show when={tabs.ready()}>
- <Show
- when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
- keyed
- fallback={<Navigate href="/" />}
- >
- {(draft) => <ResolvedDraftRoute draft={draft} />}
- </Show>
- </Show>
- )
- }
- function ResolvedDraftRoute(props: { draft: DraftTab }) {
- const global = useGlobal()
- const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
- const directory = () => props.draft.directory
- const serverKey = () => props.draft.server
- return (
- <Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
- <ServerSDKProvider server={conn}>
- <ServerSyncProvider server={conn}>
- <TargetServerScopedProviders directory={directory}>
- <SDKProvider directory={directory}>
- <DirectoryDataProvider directory={directory} server={serverKey}>
- <DraftProviders>
- <NewSession />
- </DraftProviders>
- </DirectoryDataProvider>
- </SDKProvider>
- </TargetServerScopedProviders>
- </ServerSyncProvider>
- </ServerSDKProvider>
- </Show>
- )
- }
- function UiI18nBridge(props: ParentProps) {
- const language = useLanguage()
- return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
- }
- declare global {
- interface Window {
- __OPENCODE__?: {
- deepLinks?: string[]
- }
- api?: {
- setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
- exportDebugLogs?: () => Promise<string>
- }
- }
- }
- function QueryProvider(props: ParentProps) {
- const client = new QueryClient({
- defaultOptions: {
- queries: {
- refetchOnReconnect: false,
- refetchOnMount: false,
- refetchOnWindowFocus: false,
- },
- },
- })
- return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
- }
- function BodyDesignClass() {
- const settings = useSettings()
- createRenderEffect(() => {
- if (typeof document === "undefined") return
- const enabled = settings.general.newLayoutDesigns()
- document.body.toggleAttribute("data-new-layout", enabled)
- document.body.classList.toggle("text-12-regular", !enabled)
- document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
- document.body.classList.toggle("text-[13px]", enabled)
- document.body.classList.toggle("font-[440]", enabled)
- })
- return null
- }
- // Server-agnostic providers shared across every route. These live in the shared
- // shell (router root) so they stay mounted regardless of the active server/route.
- function SharedProviders(props: ParentProps) {
- return (
- <>
- <BodyDesignClass />
- <CommandProvider>
- <DesktopCommands />
- <HighlightsProvider>{props.children}</HighlightsProvider>
- </CommandProvider>
- </>
- )
- }
- function DesktopCommands() {
- const command = useCommand()
- const language = useLanguage()
- const platform = usePlatform()
- command.register("desktop", () => {
- const commands: CommandOption[] = []
- if (platform.platform === "desktop" && platform.exportDebugLogs) {
- commands.push({
- id: "logs.export",
- title: "Export logs",
- category: language.t("command.category.settings"),
- onSelect: () => {
- void platform.exportDebugLogs?.()
- },
- })
- }
- return commands
- })
- return null
- }
- // Server-scoped providers shared by the legacy shell and the top-level new shell.
- type ServerScopedShellProps = ParentProps<{
- directory?: () => string | undefined
- sessionID?: () => string | undefined
- }>
- function ServerScopedProviders(props: ServerScopedShellProps) {
- return (
- <PermissionProvider directory={props.directory}>
- <LayoutProvider>
- <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
- </LayoutProvider>
- </PermissionProvider>
- )
- }
- function LegacyServerScopedShell(props: ServerScopedShellProps) {
- return (
- <ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
- <LegacyLayout>{props.children}</LegacyLayout>
- </ServerScopedProviders>
- )
- }
- function NewAppLayout(props: ParentProps) {
- return (
- <SelectedServerProviders>
- <ServerScopedProviders>
- <NewLayout>{props.children}</NewLayout>
- </ServerScopedProviders>
- </SelectedServerProviders>
- )
- }
- function TargetServerScopedProviders(props: ServerScopedShellProps) {
- return (
- <PermissionProvider directory={props.directory}>
- <MarkSessionNotificationsViewed sessionID={props.sessionID} />
- <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
- </PermissionProvider>
- )
- }
- function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
- const notification = useNotification()
- createEffect(() => {
- const sessionID = props.sessionID?.()
- if (!notification.ready() || !sessionID) return
- if (notification.session.unseenCount(sessionID) === 0) return
- notification.session.markViewed(sessionID)
- })
- return null
- }
- function SessionProviders(props: ParentProps) {
- return (
- <TerminalProvider>
- <FileProvider>
- <PromptProvider>
- <CommentsProvider>{props.children}</CommentsProvider>
- </PromptProvider>
- </FileProvider>
- </TerminalProvider>
- )
- }
- // The draft page only renders the prompt composer, so it drops TerminalProvider.
- // FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
- function DraftProviders(props: ParentProps) {
- return (
- <FileProvider>
- <PromptProvider>
- <CommentsProvider>{props.children}</CommentsProvider>
- </PromptProvider>
- </FileProvider>
- )
- }
- export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
- return (
- <MetaProvider>
- <Font />
- <ThemeProvider
- onThemeApplied={(_, mode) => {
- void window.api?.setTitlebar?.({ mode })
- }}
- >
- <LanguageProvider locale={props.locale}>
- <UiI18nBridge>
- <ErrorBoundary
- fallback={(error) => {
- Sentry.captureException(error)
- return <ErrorPage error={error} />
- }}
- >
- <QueryProvider>
- <WslServersProvider>
- <DialogProvider>
- <MarkedProvider>
- <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
- </MarkedProvider>
- </DialogProvider>
- </WslServersProvider>
- </QueryProvider>
- </ErrorBoundary>
- </UiI18nBridge>
- </LanguageProvider>
- </ThemeProvider>
- </MetaProvider>
- )
- }
- function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
- const server = useServer()
- const checkServerHealth = useCheckServerHealth()
- const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
- // performs repeated health check with a grace period for
- // non-http connections, otherwise fails instantly
- const [startupHealthCheck, healthCheckActions] = createResource(() =>
- props.disableHealthCheck
- ? true
- : Effect.gen(function* () {
- if (!server.current) return true
- const { http, type } = server.current
- while (true) {
- const res = yield* Effect.promise(() => checkServerHealth(http))
- if (res.healthy) return true
- if (checkMode() === "background" || type === "http") return false
- }
- }).pipe(
- Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
- Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
- Effect.runPromise,
- ),
- )
- const checking = createMemo(
- () => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
- )
- return (
- <Show
- when={!checking()}
- fallback={
- <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
- <Splash class="w-16 h-20 opacity-50 animate-pulse" />
- </div>
- }
- >
- <Show
- when={startupHealthCheck.latest}
- fallback={
- <ConnectionError
- onRetry={() => {
- if (checkMode() === "background") void healthCheckActions.refetch()
- }}
- onServerSelected={(key) => {
- setCheckMode("blocking")
- server.setActive(key)
- void healthCheckActions.refetch()
- }}
- />
- }
- >
- {props.children}
- </Show>
- </Show>
- )
- }
- function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
- const language = useLanguage()
- const server = useServer()
- const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
- const name = createMemo(() => server.name || server.key)
- const serverToken = "\u0000server\u0000"
- const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
- const timer = setInterval(() => props.onRetry?.(), 1000)
- onCleanup(() => clearInterval(timer))
- return (
- <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
- <div class="flex flex-col items-center max-w-md text-center">
- <Splash class="w-12 h-15 mb-4" />
- <p class="text-14-regular text-text-base">
- {unreachable()[0]}
- <span class="text-text-strong font-medium">{name()}</span>
- {unreachable()[1]}
- </p>
- <p class="mt-1 text-12-regular text-text-weak">{language.t("app.server.retrying")}</p>
- </div>
- <Show when={others().length > 0}>
- <div class="flex flex-col gap-2 w-full max-w-sm">
- <span class="text-12-regular text-text-base text-center">{language.t("app.server.otherServers")}</span>
- <div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
- <For each={others()}>
- {(conn) => {
- const key = ServerConnection.key(conn)
- return (
- <button
- type="button"
- class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
- onClick={() => props.onServerSelected?.(key)}
- >
- <span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
- </button>
- )
- }}
- </For>
- </div>
- </div>
- </Show>
- </div>
- )
- }
- function ServerKey(props: ParentProps) {
- const server = useServer()
- return (
- <Show when={server.key} keyed>
- {props.children}
- </Show>
- )
- }
- export function AppInterface(props: {
- children?: JSX.Element
- defaultServer: ServerConnection.Key
- canonicalLocalServer?: ServerConnection.Key
- servers?: Array<ServerConnection.Any>
- router?: Component<BaseRouterProps>
- disableHealthCheck?: boolean
- }) {
- // The visual new layout lives in the router root so it remains mounted across
- // route changes. Draft and session routes override only their server-bound data
- // providers beneath it.
- const ServerShell = (shellProps: ParentProps) => (
- <QueryProvider>
- <SharedProviders>
- {props.children}
- {shellProps.children}
- </SharedProviders>
- </QueryProvider>
- )
- return (
- <ServerProvider
- defaultServer={props.defaultServer}
- canonicalLocalServer={props.canonicalLocalServer}
- servers={props.servers}
- >
- <GlobalProvider>
- <SettingsProvider>
- <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
- <Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
- <Dynamic
- component={props.router ?? Router}
- root={(routerProps) => (
- <TabsProvider>
- <NotificationProvider>
- <ServerShell>
- <Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
- <NewAppLayout>{routerProps.children}</NewAppLayout>
- </Show>
- </ServerShell>
- </NotificationProvider>
- </TabsProvider>
- )}
- >
- <Routes />
- </Dynamic>
- </Show>
- </ConnectionGate>
- </SettingsProvider>
- </GlobalProvider>
- </ServerProvider>
- )
- }
- function Routes() {
- const settings = useSettings()
- return (
- <>
- <Route component={LegacyServerLayout}>
- <Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
- <Route path="/:dir" component={DirectoryLayout}>
- <Route path="/" component={() => <Navigate href="session" />} />
- <Route path="/session/:id?" component={SessionRoute} />
- </Route>
- </Route>
- <Show when={settings.general.newLayoutDesigns()}>
- <Route path="/" component={NewHome} />
- <Route path="/:dir/session/:id" component={LegacyTargetSessionRoute} />
- </Show>
- <Route path="/new-session" component={DraftRoute} />
- <Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
- </>
- )
- }
- function LegacyTargetSessionRoute() {
- const server = useServer()
- const tabs = useTabs()
- const params = useParams<{ id: string }>()
- return (
- <Show when={tabs.ready()}>
- <Navigate
- href={sessionHref(
- legacySessionServer(
- tabs.store.filter((item) => item.type === "session"),
- params.id,
- server.key,
- ),
- params.id,
- )}
- />
- </Show>
- )
- }
|