From 98b3340ceeb6928d0d57898d02665d763ef1ea9c Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:23:41 -0500 Subject: [PATCH] fix(app): more startup efficiency (#18985) --- packages/app/src/components/prompt-input.tsx | 1 + .../app/src/context/global-sync/bootstrap.ts | 308 +++++++++++------- packages/app/src/context/settings.tsx | 7 +- packages/app/src/context/sync.tsx | 7 +- packages/app/src/pages/home.tsx | 8 + packages/app/src/pages/session.tsx | 7 +- .../pages/session/use-session-hash-scroll.ts | 18 + packages/app/vite.js | 12 + 8 files changed, 240 insertions(+), 128 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f523671ec9..ee98e68cd5 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -572,6 +572,7 @@ export const PromptInput: Component = (props) => { const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) + if (!query.trim()) return [...agents, ...pinned] const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index c795ab471c..47be3abcb3 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -31,6 +31,47 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } +function waitForPaint() { + return new Promise((resolve) => { + let done = false + const finish = () => { + if (done) return + done = true + resolve() + } + const timer = setTimeout(finish, 50) + if (typeof requestAnimationFrame !== "function") return + requestAnimationFrame(() => { + clearTimeout(timer) + finish() + }) + }) +} + +function errors(list: PromiseSettledResult[]) { + return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason) +} + +function runAll(list: Array<() => Promise>) { + return Promise.allSettled(list.map((item) => item())) +} + +function showErrors(input: { + errors: unknown[] + title: string + translate: (key: string, vars?: Record) => string + formatMoreCount: (count: number) => string +}) { + if (input.errors.length === 0) return + const message = formatServerError(input.errors[0], input.translate) + const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : "" + showToast({ + variant: "error", + title: input.title, + description: message + more, + }) +} + export async function bootstrapGlobal(input: { globalSDK: OpencodeClient requestFailedTitle: string @@ -38,45 +79,54 @@ export async function bootstrapGlobal(input: { formatMoreCount: (count: number) => string setGlobalStore: SetStoreFunction }) { - const tasks = [ - 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!) - }), - ), - retry(() => - input.globalSDK.project.list().then((x) => { - const projects = (x.data ?? []) - .filter((p) => !!p?.id) - .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) - .slice() - .sort((a, b) => cmp(a.id, b.id)) - input.setGlobalStore("project", projects) - }), - ), - retry(() => - input.globalSDK.provider.list().then((x) => { - input.setGlobalStore("provider", normalizeProviderList(x.data!)) - }), - ), + 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!) + }), + ), + () => + retry(() => + input.globalSDK.provider.list().then((x) => { + input.setGlobalStore("provider", normalizeProviderList(x.data!)) + }), + ), ] - const results = await Promise.allSettled(tasks) - const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason) - if (errors.length) { - const message = formatServerError(errors[0], input.translate) - const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : "" - showToast({ - variant: "error", - title: input.requestFailedTitle, - description: message + more, - }) - } + const slow = [ + () => + retry(() => + input.globalSDK.project.list().then((x) => { + const projects = (x.data ?? []) + .filter((p) => !!p?.id) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .slice() + .sort((a, b) => cmp(a.id, b.id)) + input.setGlobalStore("project", projects) + }), + ), + ] + + showErrors({ + errors: errors(await runAll(fast)), + title: input.requestFailedTitle, + translate: input.translate, + formatMoreCount: input.formatMoreCount, + }) + await waitForPaint() + showErrors({ + errors: errors(await runAll(slow)), + title: input.requestFailedTitle, + translate: input.translate, + formatMoreCount: input.formatMoreCount, + }) input.setGlobalStore("ready", true) } @@ -119,95 +169,113 @@ export async function bootstrapDirectory(input: { } if (loading) input.setStore("status", "partial") - const results = await Promise.allSettled([ - seededProject - ? Promise.resolve() - : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), - retry(() => - input.sdk.provider.list().then((x) => { - input.setStore("provider", normalizeProviderList(x.data!)) - }), - ), - retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))), - retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), - retry(() => - input.sdk.path.get().then((x) => { - input.setStore("path", x.data!) - const next = projectID(x.data?.directory ?? input.directory, input.global.project) - if (next) input.setStore("project", next) - }), - ), - retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), - retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), - input.loadSessions(input.directory), - retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))), - retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))), - retry(() => - input.sdk.vcs.get().then((x) => { - const next = x.data ?? input.store.vcs - input.setStore("vcs", next) - if (next?.branch) input.vcsCache.setStore("value", next) - }), - ), - retry(() => - input.sdk.permission.list().then((x) => { - const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), - ) - batch(() => { - for (const sessionID of Object.keys(input.store.permission)) { - if (grouped[sessionID]) continue - input.setStore("permission", sessionID, []) - } - for (const [sessionID, permissions] of Object.entries(grouped)) { - input.setStore( - "permission", - sessionID, - reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - ), - retry(() => - input.sdk.question.list().then((x) => { - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) - batch(() => { - for (const sessionID of Object.keys(input.store.question)) { - if (grouped[sessionID]) continue - input.setStore("question", sessionID, []) - } - for (const [sessionID, questions] of Object.entries(grouped)) { - input.setStore( - "question", - sessionID, - reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - ), - ]) + const fast = [ + () => + 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", x.data ?? []))), + () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), + () => + retry(() => + input.sdk.path.get().then((x) => { + input.setStore("path", x.data!) + const next = projectID(x.data?.directory ?? input.directory, input.global.project) + 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) => { + const next = x.data ?? input.store.vcs + input.setStore("vcs", next) + if (next?.branch) input.vcsCache.setStore("value", next) + }), + ), + () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), + () => + retry(() => + input.sdk.permission.list().then((x) => { + const grouped = groupBySession( + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + ) + batch(() => { + for (const sessionID of Object.keys(input.store.permission)) { + if (grouped[sessionID]) continue + input.setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + input.setStore( + "permission", + sessionID, + reconcile( + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + () => + retry(() => + input.sdk.question.list().then((x) => { + const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + batch(() => { + for (const sessionID of Object.keys(input.store.question)) { + if (grouped[sessionID]) continue + input.setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + input.setStore( + "question", + sessionID, + reconcile( + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + ] - const errors = results - .filter((item): item is PromiseRejectedResult => item.status === "rejected") - .map((item) => item.reason) - if (errors.length > 0) { - console.error("Failed to bootstrap instance", errors[0]) + const slow = [ + () => + retry(() => + input.sdk.provider.list().then((x) => { + input.setStore("provider", normalizeProviderList(x.data!)) + }), + ), + () => Promise.resolve(input.loadSessions(input.directory)), + () => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))), + () => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))), + ] + + const errs = errors(await runAll(fast)) + if (errs.length > 0) { + console.error("Failed to bootstrap instance", errs[0]) const project = getFilename(input.directory) showToast({ variant: "error", title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(errors[0], input.translate), + description: formatServerError(errs[0], input.translate), }) - return } - if (loading) input.setStore("status", "complete") + await waitForPaint() + const slowErrs = errors(await runAll(slow)) + if (slowErrs.length > 0) { + console.error("Failed to finish bootstrap instance", slowErrs[0]) + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(slowErrs[0], input.translate), + }) + } + + if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete") } diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 247d36dd36..eddd752eb4 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -118,8 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont createEffect(() => { if (typeof document === "undefined") return - void loadFont().then((x) => x.ensureMonoFont(store.appearance?.font)) - document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font)) + const id = store.appearance?.font ?? defaultSettings.appearance.font + if (id !== defaultSettings.appearance.font) { + void loadFont().then((x) => x.ensureMonoFont(id)) + } + document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id)) }) return { diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 66b889e2ad..bbf4fc5ec4 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -180,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return globalSync.child(directory) } const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") - const messagePageSize = 200 + const initialMessagePageSize = 80 + const historyMessagePageSize = 200 const inflight = new Map>() const inflightDiff = new Map>() const inflightTodo = new Map>() @@ -463,7 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined if (cached && hasSession && !opts?.force) return - const limit = meta.limit[key] ?? messagePageSize + const limit = meta.limit[key] ?? initialMessagePageSize const sessionReq = hasSession && !opts?.force ? Promise.resolve() @@ -560,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const [, setStore] = globalSync.child(directory) touch(directory, setStore, sessionID) const key = keyFor(directory, sessionID) - const step = count ?? messagePageSize + const step = count ?? historyMessagePageSize if (meta.loading[key]) return if (meta.complete[key]) return const before = meta.cursor[key] diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index ba3a2b9427..4c795b9683 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -113,6 +113,14 @@ export default function Home() { + +
+
{language.t("common.loading")}
+ +
+
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 19dcba58ee..722a688bba 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1179,8 +1179,6 @@ export default function Page() { on( () => sdk.directory, () => { - void file.tree.list("") - const tab = activeFileTab() if (!tab) return const path = file.pathFromTab(tab) @@ -1635,6 +1633,9 @@ export default function Page() { sessionID: () => params.id, messagesReady, visibleUserMessages, + historyMore, + historyLoading, + loadMore: (sessionID) => sync.session.history.loadMore(sessionID), turnStart: historyWindow.turnStart, currentMessageId: () => store.messageId, pendingMessage: () => ui.pendingMessage, @@ -1706,7 +1707,7 @@ export default function Page() {
- + string | undefined messagesReady: () => boolean visibleUserMessages: () => UserMessage[] + historyMore: () => boolean + historyLoading: () => boolean + loadMore: (sessionID: string) => Promise turnStart: () => number currentMessageId: () => string | undefined pendingMessage: () => string | undefined @@ -181,6 +184,21 @@ export const useSessionHashScroll = (input: { queue(() => scrollToMessage(msg, "auto")) }) + createEffect(() => { + const sessionID = input.sessionID() + if (!sessionID || !input.messagesReady()) return + + visibleUserMessages() + + let targetId = input.pendingMessage() + if (!targetId && !clearing) targetId = messageIdFromHash(location.hash) + if (!targetId) return + if (messageById().has(targetId)) return + if (!input.historyMore() || input.historyLoading()) return + + void input.loadMore(sessionID) + }) + onMount(() => { if (typeof window !== "undefined" && "scrollRestoration" in window.history) { window.history.scrollRestoration = "manual" diff --git a/packages/app/vite.js b/packages/app/vite.js index 6b8fd61376..f65a68a1cb 100644 --- a/packages/app/vite.js +++ b/packages/app/vite.js @@ -1,7 +1,10 @@ +import { readFileSync } from "node:fs" import solidPlugin from "vite-plugin-solid" import tailwindcss from "@tailwindcss/vite" import { fileURLToPath } from "url" +const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url)) + /** * @type {import("vite").PluginOption} */ @@ -21,6 +24,15 @@ export default [ } }, }, + { + name: "opencode-desktop:theme-preload", + transformIndexHtml(html) { + return html.replace( + '', + ``, + ) + }, + }, tailwindcss(), solidPlugin(), ]