diff options
| author | Adam <[email protected]> | 2026-01-06 08:18:17 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-06 08:19:17 -0600 |
| commit | b88bcd49fdea0955f2efc8f09a3614c188d22107 (patch) | |
| tree | 8ab04d7a9d2b892cd884eab5ab3bb5da9187191a | |
| parent | 3f463bc9168abd907be9ae582e161ff89c3a27c9 (diff) | |
| download | opencode-b88bcd49fdea0955f2efc8f09a3614c188d22107.tar.gz opencode-b88bcd49fdea0955f2efc8f09a3614c188d22107.zip | |
fix(app): code splitting for web load perf gains
| -rw-r--r-- | packages/app/src/app.tsx | 21 | ||||
| -rw-r--r-- | packages/app/src/components/session/session-header.tsx | 7 | ||||
| -rw-r--r-- | packages/app/src/components/terminal.tsx | 9 | ||||
| -rw-r--r-- | packages/app/src/context/command.tsx | 13 | ||||
| -rw-r--r-- | packages/app/src/context/server.tsx | 62 | ||||
| -rw-r--r-- | packages/ui/src/components/list.tsx | 5 | ||||
| -rw-r--r-- | packages/ui/src/components/markdown.tsx | 3 | ||||
| -rw-r--r-- | packages/ui/src/context/dialog.tsx | 109 | ||||
| -rw-r--r-- | packages/ui/src/hooks/use-filtered-list.tsx | 6 |
9 files changed, 151 insertions, 84 deletions
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index e41575e7a..a2f1aa401 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,5 +1,5 @@ import "@/index.css" -import { ErrorBoundary, Show, type ParentProps } from "solid-js" +import { ErrorBoundary, Show, Suspense, lazy, type ParentProps } from "solid-js" import { Router, Route, Navigate } from "@solidjs/router" import { MetaProvider } from "@solidjs/meta" import { Font } from "@opencode-ai/ui/font" @@ -21,12 +21,14 @@ import { NotificationProvider } from "@/context/notification" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { CommandProvider } from "@/context/command" import Layout from "@/pages/layout" -import Home from "@/pages/home" import DirectoryLayout from "@/pages/directory-layout" -import Session from "@/pages/session" import { ErrorPage } from "./pages/error" import { iife } from "@opencode-ai/util/iife" +const Home = lazy(() => import("@/pages/home")) +const Session = lazy(() => import("@/pages/session")) +const Loading = () => <div class="size-full flex items-center justify-center text-text-weak">Loading...</div> + declare global { interface Window { __OPENCODE__?: { updaterEnabled?: boolean; port?: number } @@ -81,7 +83,14 @@ export function App() { </PermissionProvider> )} > - <Route path="/" component={Home} /> + <Route + path="/" + component={() => ( + <Suspense fallback={<Loading />}> + <Home /> + </Suspense> + )} + /> <Route path="/:dir" component={DirectoryLayout}> <Route path="/" component={() => <Navigate href="session" />} /> <Route @@ -91,7 +100,9 @@ export function App() { <TerminalProvider> <FileProvider> <PromptProvider> - <Session /> + <Suspense fallback={<Loading />}> + <Session /> + </Suspense> </PromptProvider> </FileProvider> </TerminalProvider> diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index e70e0790c..4958ad2c3 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -244,8 +244,13 @@ export function SessionHeader() { } return shareURL }, + { initialValue: "" }, + ) + return ( + <Show when={url.latest}> + {(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />} + </Show> ) - return <Show when={url()}>{(url) => <TextField value={url()} readOnly copyable class="w-72" />}</Show> })} </Popover> </Show> diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index a298e3f76..18c77653e 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,4 +1,4 @@ -import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" +import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" import { useSDK } from "@/context/sdk" import { SerializeAddon } from "@/addons/serialize" @@ -106,14 +106,15 @@ export const Terminal = (props: TerminalProps) => { } onMount(async () => { - ghostty = await Ghostty.load() + const mod = await import("ghostty-web") + ghostty = await mod.Ghostty.load() const socket = new WebSocket( sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`, ) ws = socket - const t = new Term({ + const t = new mod.Terminal({ cursorBlink: true, fontSize: 14, fontFamily: "IBM Plex Mono, monospace", @@ -142,7 +143,7 @@ export const Terminal = (props: TerminalProps) => { return false }) - fitAddon = new FitAddon() + fitAddon = new mod.FitAddon() serializeAddon = new SerializeAddon() t.loadAddon(serializeAddon) t.loadAddon(fitAddon) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index efd83bec8..7f88b74c8 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -177,8 +177,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const dialog = useDialog() const options = createMemo(() => { - const all = registrations().flatMap((x) => x()) + const seen = new Set<string>() + const all: CommandOption[] = [] + + for (const reg of registrations()) { + for (const opt of reg()) { + if (seen.has(opt.id)) continue + seen.add(opt.id) + all.push(opt) + } + } + const suggested = all.filter((x) => x.suggested && !x.disabled) + return [ ...suggested.map((x) => ({ ...x, diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index beb00be87..48e7e99cc 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -1,6 +1,6 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" -import { batch, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js" +import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { usePlatform } from "@/context/platform" import { persisted } from "@/utils/persist" @@ -91,27 +91,49 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const isReady = createMemo(() => ready() && !!active()) - const [healthy, { refetch }] = createResource( - () => active() || undefined, - async (url) => { - if (!url) return - - const sdk = createOpencodeClient({ - baseUrl: url, - fetch: platform.fetch, - signal: AbortSignal.timeout(3000), - }) - return sdk.global - .health() - .then((x) => x.data?.healthy === true) - .catch(() => false) - }, - ) + const [healthy, setHealthy] = createSignal<boolean | undefined>(undefined) + + const check = (url: string) => { + const sdk = createOpencodeClient({ + baseUrl: url, + fetch: platform.fetch, + signal: AbortSignal.timeout(3000), + }) + return sdk.global + .health() + .then((x) => x.data?.healthy === true) + .catch(() => false) + } createEffect(() => { - if (!active()) return - const interval = setInterval(() => refetch(), 10_000) - onCleanup(() => clearInterval(interval)) + const url = active() + if (!url) return + + setHealthy(undefined) + + let alive = true + let busy = false + + const run = () => { + if (busy) return + busy = true + void check(url) + .then((next) => { + if (!alive) return + setHealthy(next) + }) + .finally(() => { + busy = false + }) + } + + run() + const interval = setInterval(run, 10_000) + + onCleanup(() => { + alive = false + clearInterval(interval) + }) }) const origin = createMemo(() => projectsKey(active())) diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index b94405c81..60161f6dc 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -175,12 +175,13 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) fallback={ <div data-slot="list-empty-state"> <div data-slot="list-message"> - {props.emptyMessage ?? "No results"} for <span data-slot="list-filter">"{filter()}"</span> + {props.emptyMessage ?? (grouped.loading ? "Loading" : "No results")} for{" "} + <span data-slot="list-filter">"{filter()}"</span> </div> </div> } > - <For each={grouped()}> + <For each={grouped.latest}> {(group) => ( <div data-slot="list-group"> <Show when={group.category}> diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 7615d1737..6e40b700a 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -15,6 +15,7 @@ export function Markdown( async (markdown) => { return marked.parse(markdown) }, + { initialValue: "" }, ) return ( <div @@ -23,7 +24,7 @@ export function Markdown( ...(local.classList ?? {}), [local.class ?? ""]: !!local.class, }} - innerHTML={html()} + innerHTML={html.latest} {...others} /> ) diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index f85eb48df..8e770750a 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -1,11 +1,11 @@ import { createContext, + createRoot, createSignal, getOwner, - Owner, - ParentProps, + type Owner, + type ParentProps, runWithOwner, - Show, useContext, type JSX, } from "solid-js" @@ -13,58 +13,66 @@ import { Dialog as Kobalte } from "@kobalte/core/dialog" type DialogElement = () => JSX.Element +type Active = { + id: string + node: JSX.Element + dispose: () => void + owner: Owner + onClose?: () => void +} + const Context = createContext<ReturnType<typeof init>>() function init() { - const [active, setActive] = createSignal< - | { - id: string - element: DialogElement - onClose?: () => void - owner: Owner - } - | undefined - >() + const [active, setActive] = createSignal<Active | undefined>() + + const close = () => { + const current = active() + if (!current) return + current.onClose?.() + current.dispose() + setActive(undefined) + } + + const show = (element: DialogElement, owner: Owner, onClose?: () => void) => { + close() - const result = { + const id = Math.random().toString(36).slice(2) + let dispose: (() => void) | undefined + + const node = runWithOwner(owner, () => + createRoot((d) => { + dispose = d + return ( + <Kobalte + modal + open={true} + onOpenChange={(open) => { + if (open) return + close() + }} + > + <Kobalte.Portal> + <Kobalte.Overlay data-component="dialog-overlay" /> + {element()} + </Kobalte.Portal> + </Kobalte> + ) + }), + ) + + if (!dispose) return + + setActive({ id, node, dispose, owner, onClose }) + } + + return { get active() { return active() }, - close() { - active()?.onClose?.() - setActive(undefined) - }, - show(element: DialogElement, owner: Owner, onClose?: () => void) { - active()?.onClose?.() - const id = Math.random().toString(36).slice(2) - setActive({ - id, - element: () => - runWithOwner(owner, () => ( - <Show when={active()?.id === id}> - <Kobalte - modal - open={true} - onOpenChange={(open) => { - if (!open) { - result.close() - } - }} - > - <Kobalte.Portal> - <Kobalte.Overlay data-component="dialog-overlay" /> - {element()} - </Kobalte.Portal> - </Kobalte> - </Show> - )), - onClose, - owner, - }) - }, + close, + show, } - - return result } export function DialogProvider(props: ParentProps) { @@ -72,7 +80,7 @@ export function DialogProvider(props: ParentProps) { return ( <Context.Provider value={ctx}> {props.children} - <div data-component="dialog-stack">{ctx.active?.element?.()}</div> + <div data-component="dialog-stack">{ctx.active?.node}</div> </Context.Provider> ) } @@ -80,18 +88,21 @@ export function DialogProvider(props: ParentProps) { export function useDialog() { const ctx = useContext(Context) const owner = getOwner() + if (!owner) { throw new Error("useDialog must be used within a DialogProvider") } if (!ctx) { throw new Error("useDialog must be used within a DialogProvider") } + return { get active() { return ctx.active }, show(element: DialogElement, onClose?: () => void) { - ctx.show(element, owner, onClose) + const base = ctx.active?.owner ?? owner + ctx.show(element, base, onClose) }, close() { ctx.close() diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index 416f030ef..94099d786 100644 --- a/packages/ui/src/hooks/use-filtered-list.tsx +++ b/packages/ui/src/hooks/use-filtered-list.tsx @@ -18,6 +18,9 @@ export interface FilteredListProps<T> { export function useFilteredList<T>(props: FilteredListProps<T>) { const [store, setStore] = createStore<{ filter: string }>({ filter: "" }) + type Group = { category: string; items: [T, ...T[]] } + const empty: Group[] = [] + const [grouped, { refetch }] = createResource( () => ({ filter: store.filter, @@ -42,11 +45,12 @@ export function useFilteredList<T>(props: FilteredListProps<T>) { ) return result }, + { initialValue: empty }, ) const flat = createMemo(() => { return pipe( - grouped() || [], + grouped.latest || [], flatMap((x) => x.items), ) }) |
