561 lines
18 KiB
TypeScript
561 lines
18 KiB
TypeScript
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 } from "@/context/server-sdk"
|
|
import { ServerSyncProvider } 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 } 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 { 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 { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
|
|
|
import { SessionPage, TargetSessionRouteContent } 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 <SessionPage />
|
|
}
|
|
|
|
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 (
|
|
// Owns the server-identity remount. Session changes must NOT remount this
|
|
// subtree (SessionRouteErrorBoundary resets and createSessionLineage
|
|
// re-resolves reactively instead); both rely on this key for server changes.
|
|
<Show when={requireServerKey(params.serverKey)} keyed>
|
|
<ServerSDKProvider server={conn}>
|
|
<ServerSyncProvider server={conn}>
|
|
<TargetSessionRouteContent />
|
|
</ServerSyncProvider>
|
|
</ServerSDKProvider>
|
|
</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}>
|
|
<DraftServerScopedProviders directory={directory}>
|
|
<SDKProvider directory={directory}>
|
|
<DirectoryDataProvider directory={directory} server={serverKey}>
|
|
<DraftProviders>
|
|
<NewSession />
|
|
</DraftProviders>
|
|
</DirectoryDataProvider>
|
|
</SDKProvider>
|
|
</DraftServerScopedProviders>
|
|
</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 DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
|
|
return (
|
|
<PermissionProvider directory={props.directory}>
|
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
|
</PermissionProvider>
|
|
)
|
|
}
|
|
|
|
// 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>
|
|
)
|
|
}
|