diff options
| author | Adam <[email protected]> | 2026-03-27 20:26:57 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-28 01:26:57 +0000 |
| commit | f736116967f5b57d89978e51961f2e78eedb443b (patch) | |
| tree | eac11218e8114d8d0f14c1b133b2c0594721f11c /packages/app/src | |
| parent | 82fc493520925cb71323964bff6939e768e6d83a (diff) | |
| download | opencode-f736116967f5b57d89978e51961f2e78eedb443b.tar.gz opencode-f736116967f5b57d89978e51961f2e78eedb443b.zip | |
fix(app): more startup efficiency (#19454)
Diffstat (limited to 'packages/app/src')
| -rw-r--r-- | packages/app/src/app.tsx | 13 | ||||
| -rw-r--r-- | packages/app/src/context/global-sdk.tsx | 142 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync.tsx | 20 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync/bootstrap.ts | 30 | ||||
| -rw-r--r-- | packages/app/src/context/layout.tsx | 24 | ||||
| -rw-r--r-- | packages/app/src/context/server.tsx | 10 | ||||
| -rw-r--r-- | packages/app/src/context/settings.tsx | 42 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 41 |
8 files changed, 216 insertions, 106 deletions
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index a248ebb94..eb3c582d2 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -47,9 +47,14 @@ import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" const HomeRoute = lazy(() => import("@/pages/home")) -const Session = lazy(() => import("@/pages/session")) +const loadSession = () => import("@/pages/session") +const Session = lazy(loadSession) const Loading = () => <div class="size-full" /> +if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) { + void loadSession() +} + const SessionRoute = () => ( <SessionProviders> <Session /> @@ -278,7 +283,11 @@ export function AppInterface(props: { disableHealthCheck?: boolean }) { return ( - <ServerProvider defaultServer={props.defaultServer} servers={props.servers}> + <ServerProvider + defaultServer={props.defaultServer} + disableHealthCheck={props.disableHealthCheck} + servers={props.servers} + > <ConnectionGate disableHealthCheck={props.disableHealthCheck}> <ServerKey> <GlobalSDKProvider> diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 60e9fd6d5..d240f9eef 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -105,6 +105,8 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo const aborted = (error: unknown) => abortError.safeParse(error).success let attempt: AbortController | undefined + let run: Promise<void> | undefined + let started = false const HEARTBEAT_TIMEOUT_MS = 15_000 let lastEventAt = Date.now() let heartbeat: ReturnType<typeof setTimeout> | undefined @@ -121,78 +123,93 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo heartbeat = undefined } - void (async () => { - while (!abort.signal.aborted) { - attempt = new AbortController() - lastEventAt = Date.now() - const onAbort = () => { - attempt?.abort() - } - abort.signal.addEventListener("abort", onAbort) - try { - const events = await eventSdk.global.event({ - signal: attempt.signal, - onSseError: (error) => { - if (aborted(error)) return - if (streamErrorLogged) return + const start = () => { + if (started) return run + started = true + run = (async () => { + while (!abort.signal.aborted && started) { + attempt = new AbortController() + lastEventAt = Date.now() + const onAbort = () => { + attempt?.abort() + } + abort.signal.addEventListener("abort", onAbort) + try { + const events = await eventSdk.global.event({ + signal: attempt.signal, + onSseError: (error) => { + if (aborted(error)) return + if (streamErrorLogged) return + streamErrorLogged = true + console.error("[global-sdk] event stream error", { + url: currentServer.http.url, + fetch: eventFetch ? "platform" : "webview", + error, + }) + }, + }) + let yielded = Date.now() + resetHeartbeat() + for await (const event of events.stream) { + resetHeartbeat() + streamErrorLogged = false + const directory = event.directory ?? "global" + const payload = event.payload + const k = key(directory, payload) + if (k) { + const i = coalesced.get(k) + if (i !== undefined) { + queue[i] = { directory, payload } + if (payload.type === "message.part.updated") { + const part = payload.properties.part + staleDeltas.add(deltaKey(directory, part.messageID, part.id)) + } + continue + } + coalesced.set(k, queue.length) + } + queue.push({ directory, payload }) + schedule() + + if (Date.now() - yielded < STREAM_YIELD_MS) continue + yielded = Date.now() + await wait(0) + } + } catch (error) { + if (!aborted(error) && !streamErrorLogged) { streamErrorLogged = true - console.error("[global-sdk] event stream error", { + console.error("[global-sdk] event stream failed", { url: currentServer.http.url, fetch: eventFetch ? "platform" : "webview", error, }) - }, - }) - let yielded = Date.now() - resetHeartbeat() - for await (const event of events.stream) { - resetHeartbeat() - streamErrorLogged = false - const directory = event.directory ?? "global" - const payload = event.payload - const k = key(directory, payload) - if (k) { - const i = coalesced.get(k) - if (i !== undefined) { - queue[i] = { directory, payload } - if (payload.type === "message.part.updated") { - const part = payload.properties.part - staleDeltas.add(deltaKey(directory, part.messageID, part.id)) - } - continue - } - coalesced.set(k, queue.length) } - queue.push({ directory, payload }) - schedule() - - if (Date.now() - yielded < STREAM_YIELD_MS) continue - yielded = Date.now() - await wait(0) - } - } catch (error) { - if (!aborted(error) && !streamErrorLogged) { - streamErrorLogged = true - console.error("[global-sdk] event stream failed", { - url: currentServer.http.url, - fetch: eventFetch ? "platform" : "webview", - error, - }) + } finally { + abort.signal.removeEventListener("abort", onAbort) + attempt = undefined + clearHeartbeat() } - } finally { - abort.signal.removeEventListener("abort", onAbort) - attempt = undefined - clearHeartbeat() + + if (abort.signal.aborted || !started) return + await wait(RECONNECT_DELAY_MS) } + })().finally(() => { + run = undefined + flush() + }) + return run + } - if (abort.signal.aborted) return - await wait(RECONNECT_DELAY_MS) - } - })().finally(flush) + const stop = () => { + started = false + attempt?.abort() + clearHeartbeat() + } const onVisibility = () => { if (typeof document === "undefined") return if (document.visibilityState !== "visible") return + if (!started) return if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return attempt?.abort() } @@ -204,6 +221,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo if (typeof document !== "undefined") { document.removeEventListener("visibilitychange", onVisibility) } + stop() abort.abort() flush() }) @@ -217,7 +235,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo return { url: currentServer.http.url, client: sdk, - event: emitter, + event: { + on: emitter.on.bind(emitter), + listen: emitter.listen.bind(emitter), + start, + }, createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) { const s = server.current if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable")) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 86ac9b45a..0cf3570a8 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -72,10 +72,16 @@ function createGlobalSync() { let projectWritten = false let bootedAt = 0 let bootingRoot = false + let eventFrame: number | undefined + let eventTimer: ReturnType<typeof setTimeout> | undefined onCleanup(() => { active = false }) + onCleanup(() => { + if (eventFrame !== undefined) cancelAnimationFrame(eventFrame) + if (eventTimer !== undefined) clearTimeout(eventTimer) + }) const cacheProjects = () => { setProjectCache( @@ -348,6 +354,20 @@ function createGlobalSync() { } onMount(() => { + if (typeof requestAnimationFrame === "function") { + eventFrame = requestAnimationFrame(() => { + eventFrame = undefined + eventTimer = setTimeout(() => { + eventTimer = undefined + globalSDK.event.start() + }, 0) + }) + } else { + eventTimer = setTimeout(() => { + eventTimer = undefined + globalSDK.event.start() + }, 0) + } void bootstrap() }) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 869f8b7ea..cf104ad97 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -43,8 +43,10 @@ function waitForPaint() { const timer = setTimeout(finish, 50) if (typeof requestAnimationFrame !== "function") return requestAnimationFrame(() => { - clearTimeout(timer) - finish() + setTimeout(() => { + clearTimeout(timer) + finish() + }, 0) }) }) } @@ -89,12 +91,6 @@ export async function bootstrapGlobal(input: { const fast = [ () => retry(() => - input.globalSDK.path.get().then((x) => { - input.setGlobalStore("path", x.data!) - }), - ), - () => - retry(() => input.globalSDK.global.config.get().then((x) => { input.setGlobalStore("config", x.data!) }), @@ -110,6 +106,12 @@ export async function bootstrapGlobal(input: { const slow = [ () => retry(() => + input.globalSDK.path.get().then((x) => { + input.setGlobalStore("path", x.data!) + }), + ), + () => + retry(() => input.globalSDK.project.list().then((x) => { const projects = (x.data ?? []) .filter((p) => !!p?.id) @@ -221,12 +223,16 @@ export async function bootstrapDirectory(input: { if (loading) input.setStore("status", "partial") const fast = [ + () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))), + () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), + () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), + ] + + const slow = [ () => seededProject ? Promise.resolve() : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), - () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))), - () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), () => seededPath ? Promise.resolve() @@ -237,7 +243,6 @@ export async function bootstrapDirectory(input: { if (next) input.setStore("project", next) }), ), - () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), () => retry(() => input.sdk.vcs.get().then((x) => { @@ -299,9 +304,6 @@ export async function bootstrapDirectory(input: { ) }), ), - ] - - const slow = [ () => Promise.resolve(input.loadSessions(input.directory)), () => retry(() => diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 640d5e02e..aafa4fb66 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -544,12 +544,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } }) + let sessionFrame: number | undefined + let sessionTimer: number | undefined + onMount(() => { - Promise.all( - server.projects.list().map((project) => { - return globalSync.project.loadSessions(project.worktree) - }), - ) + sessionFrame = requestAnimationFrame(() => { + sessionFrame = undefined + sessionTimer = window.setTimeout(() => { + sessionTimer = undefined + void Promise.all( + server.projects.list().map((project) => { + return globalSync.project.loadSessions(project.worktree) + }), + ) + }, 0) + }) + }) + + onCleanup(() => { + if (sessionFrame !== undefined) cancelAnimationFrame(sessionFrame) + if (sessionTimer !== undefined) window.clearTimeout(sessionTimer) }) return { diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 1171ca905..1204fba55 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -94,7 +94,11 @@ export namespace ServerConnection { export const { use: useServer, provider: ServerProvider } = createSimpleContext({ name: "Server", - init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => { + init: (props: { + defaultServer: ServerConnection.Key + disableHealthCheck?: boolean + servers?: Array<ServerConnection.Any> + }) => { const checkServerHealth = useCheckServerHealth() const [store, setStore, _, ready] = persisted( @@ -202,6 +206,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const current_ = current() if (!current_) return + if (props.disableHealthCheck) { + setState("healthy", true) + return + } setState("healthy", undefined) onCleanup(startHealthPolling(current_)) }) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 4402855a6..ae7768f71 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -32,8 +32,8 @@ export interface Settings { } appearance: { fontSize: number - font: string - uiFont: string + mono: string + sans: string } keybinds: Record<string, string> permissions: { @@ -43,20 +43,18 @@ export interface Settings { sounds: SoundSettings } -export const monoDefault = "IBM Plex Mono" -export const sansDefault = "Inter" +export const monoDefault = "System Mono" +export const sansDefault = "System Sans" const monoFallback = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' -const monoBase = `"${monoDefault}", "IBM Plex Mono Fallback", ${monoFallback}` -const sansBase = `"${sansDefault}", "Inter Fallback", ${sansFallback}` -const monoKey = "ibm-plex-mono" +const monoBase = monoFallback +const sansBase = sansFallback -function input(font: string | undefined, key?: string) { - if (!font || font === key || !font.trim()) return "" - return font +function input(font: string | undefined) { + return font ?? "" } function family(font: string) { @@ -64,14 +62,14 @@ function family(font: string) { return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` } -function stack(font: string | undefined, base: string, key?: string) { - const value = input(font, key).trim() +function stack(font: string | undefined, base: string) { + const value = font?.trim() ?? "" if (!value) return base return `${family(value)}, ${base}` } export function monoInput(font: string | undefined) { - return input(font, monoKey) + return input(font) } export function sansInput(font: string | undefined) { @@ -79,7 +77,7 @@ export function sansInput(font: string | undefined) { } export function monoFontFamily(font: string | undefined) { - return stack(font, monoBase, monoKey) + return stack(font, monoBase) } export function sansFontFamily(font: string | undefined) { @@ -100,8 +98,8 @@ const defaultSettings: Settings = { }, appearance: { fontSize: 14, - font: "", - uiFont: "", + mono: "", + sans: "", }, keybinds: {}, permissions: { @@ -134,8 +132,8 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont createEffect(() => { if (typeof document === "undefined") return const root = document.documentElement - root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font)) - root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.uiFont)) + root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono)) + root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans)) }) return { @@ -189,13 +187,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setFontSize(value: number) { setStore("appearance", "fontSize", value) }, - font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font), + font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono), setFont(value: string) { - setStore("appearance", "font", value.trim() ? value : "") + setStore("appearance", "mono", value.trim() ? value : "") }, - uiFont: withFallback(() => store.appearance?.uiFont, defaultSettings.appearance.uiFont), + uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans), setUIFont(value: string) { - setStore("appearance", "uiFont", value.trim() ? value : "") + setStore("appearance", "sans", value.trim() ? value : "") }, }, keybinds: { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 11e6375b3..917de35b1 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -544,6 +544,8 @@ export default function Page() { let reviewFrame: number | undefined let refreshFrame: number | undefined let refreshTimer: number | undefined + let todoFrame: number | undefined + let todoTimer: number | undefined let diffFrame: number | undefined let diffTimer: number | undefined @@ -718,7 +720,6 @@ export default function Page() { if (!info) return true return Date.now() - info.at > SESSION_PREFETCH_TTL })() - const todos = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined) untrack(() => { void sync.session.sync(id) }) @@ -730,7 +731,6 @@ export default function Page() { if (params.id !== id) return untrack(() => { if (stale) void sync.session.sync(id, { force: true }) - void sync.session.todo(id, todos ? { force: true } : undefined) }) }, 0) }) @@ -739,6 +739,41 @@ export default function Page() { createEffect( on( + () => { + const id = params.id + return [ + sdk.directory, + id, + id ? (sync.data.session_status[id]?.type ?? "idle") : "idle", + id ? composer.blocked() : false, + ] as const + }, + ([dir, id, status, blocked]) => { + if (todoFrame !== undefined) cancelAnimationFrame(todoFrame) + if (todoTimer !== undefined) window.clearTimeout(todoTimer) + todoFrame = undefined + todoTimer = undefined + if (!id) return + if (status === "idle" && !blocked) return + const cached = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined) + + todoFrame = requestAnimationFrame(() => { + todoFrame = undefined + todoTimer = window.setTimeout(() => { + todoTimer = undefined + if (sdk.directory !== dir || params.id !== id) return + untrack(() => { + void sync.session.todo(id, cached ? { force: true } : undefined) + }) + }, 0) + }) + }, + { defer: true }, + ), + ) + + createEffect( + on( () => visibleUserMessages().at(-1)?.id, (lastId, prevLastId) => { if (lastId && prevLastId && lastId > prevLastId) { @@ -1658,6 +1693,8 @@ export default function Page() { if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) + if (todoFrame !== undefined) cancelAnimationFrame(todoFrame) + if (todoTimer !== undefined) window.clearTimeout(todoTimer) if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) if (diffTimer !== undefined) window.clearTimeout(diffTimer) if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) |
