فهرست منبع

app: start migrating bootstrap data fetching to TanStack Query (#22756)

Brendan Allan 2 روز پیش
والد
کامیت
97918500d4

+ 4 - 0
bun.lock

@@ -75,6 +75,7 @@
         "@types/luxon": "catalog:",
         "@types/node": "catalog:",
         "@typescript/native-preview": "catalog:",
+        "tw-animate-css": "1.4.0",
         "typescript": "catalog:",
         "vite": "catalog:",
         "vite-plugin-icons-spritesheet": "3.0.1",
@@ -597,6 +598,7 @@
         "solid-js": "catalog:",
         "solid-list": "catalog:",
         "strip-ansi": "7.1.2",
+        "tw-animate-css": "1.4.0",
         "virtua": "catalog:",
       },
       "devDependencies": {
@@ -4859,6 +4861,8 @@
 
     "turndown": ["[email protected]", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
 
+    "tw-animate-css": ["[email protected]", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
+
     "tw-to-css": ["[email protected]", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
 
     "type-fest": ["[email protected]", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],

+ 16 - 7
packages/app/src/app.tsx

@@ -121,10 +121,10 @@ function SessionProviders(props: ParentProps) {
 function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
   return (
     <AppShellProviders>
-      <Suspense fallback={<Loading />}>
-        {props.appChildren}
-        {props.children}
-      </Suspense>
+      {/*<Suspense fallback={<Loading />}>*/}
+      {props.appChildren}
+      {props.children}
+      {/*</Suspense>*/}
     </AppShellProviders>
   )
 }
@@ -184,14 +184,22 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
   )
 
   return (
-    <Show
-      when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
+    <Suspense
       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={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
+        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>
+        }
+      >*/}
+      {checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest}
       <Show
         when={startupHealthCheck()}
         fallback={
@@ -209,7 +217,8 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
       >
         {props.children}
       </Show>
-    </Show>
+      {/*</Show>*/}
+    </Suspense>
   )
 }
 

+ 109 - 94
packages/app/src/components/prompt-input.tsx

@@ -54,6 +54,8 @@ import { PromptImageAttachments } from "./prompt-input/image-attachments"
 import { PromptDragOverlay } from "./prompt-input/drag-overlay"
 import { promptPlaceholder } from "./prompt-input/placeholder"
 import { ImagePreview } from "@opencode-ai/ui/image-preview"
+import { useQuery } from "@tanstack/solid-query"
+import { loadAgentsQuery, loadProvidersQuery } from "@/context/global-sync/bootstrap"
 
 interface PromptInputProps {
   class?: string
@@ -100,6 +102,7 @@ const NON_EMPTY_TEXT = /[^\s\u200B]/
 
 export const PromptInput: Component<PromptInputProps> = (props) => {
   const sdk = useSDK()
+
   const sync = useSync()
   const local = useLocal()
   const files = useFile()
@@ -1249,6 +1252,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
   }
 
+  const agentsQuery = useQuery(() => loadAgentsQuery(sdk.directory))
+  const agentsLoading = () => agentsQuery.isLoading
+
+  const globalProvidersQuery = useQuery(() => loadProvidersQuery(null))
+  const providersQuery = useQuery(() => loadProvidersQuery(sdk.directory))
+
+  const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading
+
   return (
     <div class="relative size-full _max-h-[320px] flex flex-col gap-0">
       <PromptPopover
@@ -1444,53 +1455,89 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 <span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
                 <div class="size-4 shrink-0" />
               </div>
-              <div class="flex items-center gap-1.5 min-w-0 flex-1">
-                <div data-component="prompt-agent-control">
-                  <TooltipKeybind
-                    placement="top"
-                    gutter={4}
-                    title={language.t("command.agent.cycle")}
-                    keybind={command.keybind("agent.cycle")}
-                  >
-                    <Select
-                      size="normal"
-                      options={agentNames()}
-                      current={local.agent.current()?.name ?? ""}
-                      onSelect={(value) => {
-                        local.agent.set(value)
-                        restoreFocus()
-                      }}
-                      class="capitalize max-w-[160px] text-text-base"
-                      valueClass="truncate text-13-regular text-text-base"
-                      triggerStyle={control()}
-                      triggerProps={{ "data-action": "prompt-agent" }}
-                      variant="ghost"
-                    />
-                  </TooltipKeybind>
-                </div>
-                <Show when={store.mode !== "shell"}>
-                  <div data-component="prompt-model-control">
-                    <Show
-                      when={providers.paid().length > 0}
-                      fallback={
+              <div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
+                <Show when={!agentsLoading()}>
+                  <div data-component="prompt-agent-control">
+                    <TooltipKeybind
+                      placement="top"
+                      gutter={4}
+                      title={language.t("command.agent.cycle")}
+                      keybind={command.keybind("agent.cycle")}
+                    >
+                      <Select
+                        size="normal"
+                        options={agentNames()}
+                        current={local.agent.current()?.name ?? ""}
+                        onSelect={(value) => {
+                          local.agent.set(value)
+                          restoreFocus()
+                        }}
+                        class="capitalize max-w-[160px] text-text-base"
+                        valueClass="truncate text-13-regular text-text-base"
+                        triggerStyle={control()}
+                        triggerProps={{ "data-action": "prompt-agent" }}
+                        variant="ghost"
+                      />
+                    </TooltipKeybind>
+                  </div>
+                </Show>
+                <Show when={!providersLoading()}>
+                  <Show when={store.mode !== "shell"}>
+                    <div data-component="prompt-model-control">
+                      <Show
+                        when={providers.paid().length > 0}
+                        fallback={
+                          <TooltipKeybind
+                            placement="top"
+                            gutter={4}
+                            title={language.t("command.model.choose")}
+                            keybind={command.keybind("model.choose")}
+                          >
+                            <Button
+                              data-action="prompt-model"
+                              as="div"
+                              variant="ghost"
+                              size="normal"
+                              class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
+                              style={control()}
+                              onClick={() => {
+                                void import("@/components/dialog-select-model-unpaid").then((x) => {
+                                  dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
+                                })
+                              }}
+                            >
+                              <Show when={local.model.current()?.provider?.id}>
+                                <ProviderIcon
+                                  id={local.model.current()?.provider?.id ?? ""}
+                                  class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
+                                  style={{ "will-change": "opacity", transform: "translateZ(0)" }}
+                                />
+                              </Show>
+                              <span class="truncate">
+                                {local.model.current()?.name ?? language.t("dialog.model.select.title")}
+                              </span>
+                              <Icon name="chevron-down" size="small" class="shrink-0" />
+                            </Button>
+                          </TooltipKeybind>
+                        }
+                      >
                         <TooltipKeybind
                           placement="top"
                           gutter={4}
                           title={language.t("command.model.choose")}
                           keybind={command.keybind("model.choose")}
                         >
-                          <Button
-                            data-action="prompt-model"
-                            as="div"
-                            variant="ghost"
-                            size="normal"
-                            class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
-                            style={control()}
-                            onClick={() => {
-                              void import("@/components/dialog-select-model-unpaid").then((x) => {
-                                dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
-                              })
+                          <ModelSelectorPopover
+                            model={local.model}
+                            triggerAs={Button}
+                            triggerProps={{
+                              variant: "ghost",
+                              size: "normal",
+                              style: control(),
+                              class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
+                              "data-action": "prompt-model",
                             }}
+                            onClose={restoreFocus}
                           >
                             <Show when={local.model.current()?.provider?.id}>
                               <ProviderIcon
@@ -1503,67 +1550,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                               {local.model.current()?.name ?? language.t("dialog.model.select.title")}
                             </span>
                             <Icon name="chevron-down" size="small" class="shrink-0" />
-                          </Button>
+                          </ModelSelectorPopover>
                         </TooltipKeybind>
-                      }
-                    >
+                      </Show>
+                    </div>
+                    <div data-component="prompt-variant-control">
                       <TooltipKeybind
                         placement="top"
                         gutter={4}
-                        title={language.t("command.model.choose")}
-                        keybind={command.keybind("model.choose")}
+                        title={language.t("command.model.variant.cycle")}
+                        keybind={command.keybind("model.variant.cycle")}
                       >
-                        <ModelSelectorPopover
-                          model={local.model}
-                          triggerAs={Button}
-                          triggerProps={{
-                            variant: "ghost",
-                            size: "normal",
-                            style: control(),
-                            class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
-                            "data-action": "prompt-model",
+                        <Select
+                          size="normal"
+                          options={variants()}
+                          current={local.model.variant.current() ?? "default"}
+                          label={(x) => (x === "default" ? language.t("common.default") : x)}
+                          onSelect={(value) => {
+                            local.model.variant.set(value === "default" ? undefined : value)
+                            restoreFocus()
                           }}
-                          onClose={restoreFocus}
-                        >
-                          <Show when={local.model.current()?.provider?.id}>
-                            <ProviderIcon
-                              id={local.model.current()?.provider?.id ?? ""}
-                              class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
-                              style={{ "will-change": "opacity", transform: "translateZ(0)" }}
-                            />
-                          </Show>
-                          <span class="truncate">
-                            {local.model.current()?.name ?? language.t("dialog.model.select.title")}
-                          </span>
-                          <Icon name="chevron-down" size="small" class="shrink-0" />
-                        </ModelSelectorPopover>
+                          class="capitalize max-w-[160px] text-text-base"
+                          valueClass="truncate text-13-regular text-text-base"
+                          triggerStyle={control()}
+                          triggerProps={{ "data-action": "prompt-model-variant" }}
+                          variant="ghost"
+                        />
                       </TooltipKeybind>
-                    </Show>
-                  </div>
-                  <div data-component="prompt-variant-control">
-                    <TooltipKeybind
-                      placement="top"
-                      gutter={4}
-                      title={language.t("command.model.variant.cycle")}
-                      keybind={command.keybind("model.variant.cycle")}
-                    >
-                      <Select
-                        size="normal"
-                        options={variants()}
-                        current={local.model.variant.current() ?? "default"}
-                        label={(x) => (x === "default" ? language.t("common.default") : x)}
-                        onSelect={(value) => {
-                          local.model.variant.set(value === "default" ? undefined : value)
-                          restoreFocus()
-                        }}
-                        class="capitalize max-w-[160px] text-text-base"
-                        valueClass="truncate text-13-regular text-text-base"
-                        triggerStyle={control()}
-                        triggerProps={{ "data-action": "prompt-model-variant" }}
-                        variant="ghost"
-                      />
-                    </TooltipKeybind>
-                  </div>
+                    </div>
+                  </Show>
                 </Show>
               </div>
             </div>

+ 53 - 38
packages/app/src/context/global-sync.tsx

@@ -26,6 +26,7 @@ import type { ProjectMeta } from "./global-sync/types"
 import { SESSION_RECENT_LIMIT } from "./global-sync/types"
 import { sanitizeProject } from "./global-sync/utils"
 import { formatServerError } from "@/utils/server-errors"
+import { queryOptions, skipToken, useQueryClient } from "@tanstack/solid-query"
 
 type GlobalStore = {
   ready: boolean
@@ -41,6 +42,9 @@ type GlobalStore = {
   reload: undefined | "pending" | "complete"
 }
 
+export const loadSessionsQuery = (directory: string) =>
+  queryOptions<null>({ queryKey: [directory, "loadSessions"], queryFn: skipToken })
+
 function createGlobalSync() {
   const globalSDK = useGlobalSDK()
   const language = useLanguage()
@@ -67,6 +71,7 @@ function createGlobalSync() {
     config: {},
     reload: undefined,
   })
+  const queryClient = useQueryClient()
 
   let active = true
   let projectWritten = false
@@ -198,43 +203,50 @@ function createGlobalSync() {
     }
 
     const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
-    const promise = loadRootSessionsWithFallback({
-      directory,
-      limit,
-      list: (query) => globalSDK.client.session.list(query),
-    })
-      .then((x) => {
-        const nonArchived = (x.data ?? [])
-          .filter((s) => !!s?.id)
-          .filter((s) => !s.time?.archived)
-          .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
-        const limit = store.limit
-        const childSessions = store.session.filter((s) => !!s.parentID)
-        const sessions = trimSessions([...nonArchived, ...childSessions], {
-          limit,
-          permission: store.permission,
-        })
-        setStore(
-          "sessionTotal",
-          estimateRootSessionTotal({
-            count: nonArchived.length,
-            limit: x.limit,
-            limited: x.limited,
-          }),
-        )
-        setStore("session", reconcile(sessions, { key: "id" }))
-        cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
-        sessionMeta.set(directory, { limit })
-      })
-      .catch((err) => {
-        console.error("Failed to load sessions", err)
-        const project = getFilename(directory)
-        showToast({
-          variant: "error",
-          title: language.t("toast.session.listFailed.title", { project }),
-          description: formatServerError(err, language.t),
-        })
+    const promise = queryClient
+      .ensureQueryData({
+        ...loadSessionsQuery(directory),
+        queryFn: () =>
+          loadRootSessionsWithFallback({
+            directory,
+            limit,
+            list: (query) => globalSDK.client.session.list(query),
+          })
+            .then((x) => {
+              const nonArchived = (x.data ?? [])
+                .filter((s) => !!s?.id)
+                .filter((s) => !s.time?.archived)
+                .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+              const limit = store.limit
+              const childSessions = store.session.filter((s) => !!s.parentID)
+              const sessions = trimSessions([...nonArchived, ...childSessions], {
+                limit,
+                permission: store.permission,
+              })
+              setStore(
+                "sessionTotal",
+                estimateRootSessionTotal({
+                  count: nonArchived.length,
+                  limit: x.limit,
+                  limited: x.limited,
+                }),
+              )
+              setStore("session", reconcile(sessions, { key: "id" }))
+              cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
+              sessionMeta.set(directory, { limit })
+            })
+            .catch((err) => {
+              console.error("Failed to load sessions", err)
+              const project = getFilename(directory)
+              showToast({
+                variant: "error",
+                title: language.t("toast.session.listFailed.title", { project }),
+                description: formatServerError(err, language.t),
+              })
+            })
+            .then(() => null),
       })
+      .then(() => {})
 
     sessionLoads.set(directory, promise)
     void promise.finally(() => {
@@ -250,8 +262,9 @@ function createGlobalSync() {
     if (pending) return pending
 
     children.pin(directory)
-    const promise = (async () => {
+    const promise = Promise.resolve().then(async () => {
       const child = children.ensureChild(directory)
+      child[1]("bootstrapPromise", promise!)
       const cache = children.vcsCache.get(directory)
       if (!cache) return
       const sdk = sdkFor(directory)
@@ -269,8 +282,9 @@ function createGlobalSync() {
         vcsCache: cache,
         loadSessions,
         translate: language.t,
+        queryClient,
       })
-    })()
+    })
 
     booting.set(directory, promise)
     void promise.finally(() => {
@@ -346,6 +360,7 @@ function createGlobalSync() {
         translate: language.t,
         formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
         setGlobalStore: setBootStore,
+        queryClient,
       })
       bootedAt = Date.now()
     } finally {

+ 148 - 121
packages/app/src/context/global-sync/bootstrap.ts

@@ -18,6 +18,8 @@ import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
 import type { State, VcsCache } from "./types"
 import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
 import { formatServerError } from "@/utils/server-errors"
+import { QueryClient, queryOptions, skipToken } from "@tanstack/solid-query"
+import { loadSessionsQuery } from "../global-sync"
 
 type GlobalStore = {
   ready: boolean
@@ -71,6 +73,7 @@ export async function bootstrapGlobal(input: {
   translate: (key: string, vars?: Record<string, string | number>) => string
   formatMoreCount: (count: number) => string
   setGlobalStore: SetStoreFunction<GlobalStore>
+  queryClient: QueryClient
 }) {
   const fast = [
     () =>
@@ -80,11 +83,16 @@ export async function bootstrapGlobal(input: {
         }),
       ),
     () =>
-      retry(() =>
-        input.globalSDK.provider.list().then((x) => {
-          input.setGlobalStore("provider", normalizeProviderList(x.data!))
-        }),
-      ),
+      input.queryClient.fetchQuery({
+        ...loadProvidersQuery(null),
+        queryFn: () =>
+          retry(() =>
+            input.globalSDK.provider.list().then((x) => {
+              input.setGlobalStore("provider", normalizeProviderList(x.data!))
+              return null
+            }),
+          ),
+      }),
   ]
 
   const slow = [
@@ -172,6 +180,12 @@ function warmSessions(input: {
   ).then(() => undefined)
 }
 
+export const loadProvidersQuery = (directory: string | null) =>
+  queryOptions<null>({ queryKey: [directory, "providers"], queryFn: skipToken })
+
+export const loadAgentsQuery = (directory: string | null) =>
+  queryOptions<null>({ queryKey: [directory, "agents"], queryFn: skipToken })
+
 export async function bootstrapDirectory(input: {
   directory: string
   sdk: OpencodeClient
@@ -186,6 +200,7 @@ export async function bootstrapDirectory(input: {
     project: Project[]
     provider: ProviderListResponse
   }
+  queryClient: QueryClient
 }) {
   const loading = input.store.status !== "complete"
   const seededProject = projectID(input.directory, input.global.project)
@@ -207,97 +222,7 @@ export async function bootstrapDirectory(input: {
   input.setStore("lsp", [])
   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)),
-    () =>
-      seededPath
-        ? Promise.resolve()
-        : 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.vcs.get().then((x) => {
-          const next = x.data ?? input.store.vcs
-          input.setStore("vcs", next)
-          if (next) 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 ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
-          const grouped = groupBySession(
-            (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
-          )
-          return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
-            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 ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
-          const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
-          return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
-            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" },
-                  ),
-                )
-              }
-            }),
-          )
-        }),
-      ),
-    () => Promise.resolve(input.loadSessions(input.directory)),
-    () =>
-      retry(() =>
-        input.sdk.mcp.status().then((x) => {
-          input.setStore("mcp", x.data!)
-          input.setStore("mcp_ready", true)
-        }),
-      ),
-  ]
+  const fast = [() => Promise.resolve(input.loadSessions(input.directory))]
 
   const errs = errors(await runAll(fast))
   if (errs.length > 0) {
@@ -310,36 +235,138 @@ export async function bootstrapDirectory(input: {
     })
   }
 
-  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")
+  ;(async () => {
+    const slow = [
+      () =>
+        input.queryClient.ensureQueryData({
+          ...loadAgentsQuery(input.directory),
+          queryFn: () =>
+            retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))).then(
+              () => null,
+            ),
+        }),
+      () => 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!))),
+      () =>
+        seededProject
+          ? Promise.resolve()
+          : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
+      () =>
+        seededPath
+          ? Promise.resolve()
+          : 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.vcs.get().then((x) => {
+            const next = x.data ?? input.store.vcs
+            input.setStore("vcs", next)
+            if (next) 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 ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
+            const grouped = groupBySession(
+              (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
+            )
+            return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
+              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 ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
+            const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
+            return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
+              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" },
+                    ),
+                  )
+                }
+              }),
+            )
+          }),
+        ),
+      () => Promise.resolve(input.loadSessions(input.directory)),
+      () =>
+        retry(() =>
+          input.sdk.mcp.status().then((x) => {
+            input.setStore("mcp", x.data!)
+            input.setStore("mcp_ready", true)
+          }),
+        ),
+    ]
 
-  const rev = (providerRev.get(input.directory) ?? 0) + 1
-  providerRev.set(input.directory, rev)
-  void retry(() => input.sdk.provider.list())
-    .then((x) => {
-      if (providerRev.get(input.directory) !== rev) return
-      input.setStore("provider", normalizeProviderList(x.data!))
-      input.setStore("provider_ready", true)
-    })
-    .catch((err) => {
-      if (providerRev.get(input.directory) !== rev) return
-      console.error("Failed to refresh provider list", err)
+    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(err, input.translate),
+        description: formatServerError(slowErrs[0], input.translate),
       })
+    }
+
+    if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
+
+    const rev = (providerRev.get(input.directory) ?? 0) + 1
+    providerRev.set(input.directory, rev)
+    void input.queryClient.ensureQueryData({
+      ...loadSessionsQuery(input.directory),
+      queryFn: () =>
+        retry(() => input.sdk.provider.list())
+          .then((x) => {
+            if (providerRev.get(input.directory) !== rev) return
+            input.setStore("provider", normalizeProviderList(x.data!))
+            input.setStore("provider_ready", true)
+          })
+          .catch((err) => {
+            if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err)
+            const project = getFilename(input.directory)
+            showToast({
+              variant: "error",
+              title: input.translate("toast.project.reloadFailed.title", { project }),
+              description: formatServerError(err, input.translate),
+            })
+          })
+          .then(() => null),
     })
+  })()
 }

+ 1 - 0
packages/app/src/context/global-sync/child-store.ts

@@ -182,6 +182,7 @@ export function createChildStoreManager(input: {
             limit: 5,
             message: {},
             part: {},
+            bootstrapPromise: Promise.resolve(),
           })
           children[directory] = child
           disposers.set(directory, dispose)

+ 1 - 0
packages/app/src/context/global-sync/types.ts

@@ -72,6 +72,7 @@ export type State = {
   part: {
     [messageID: string]: Part[]
   }
+  bootstrapPromise: Promise<void>
 }
 
 export type VcsCache = {

+ 9 - 1
packages/app/src/pages/layout.tsx

@@ -132,9 +132,11 @@ export default function Layout(props: ParentProps) {
     if (!slug) return { slug, dir: "" }
     const dir = decode64(slug)
     if (!dir) return { slug, dir: "" }
+    const store = globalSync.peek(dir, { bootstrap: false })
     return {
       slug,
-      dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
+      store,
+      dir: store[0].path.directory || dir,
     }
   })
   const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
@@ -2353,8 +2355,14 @@ export default function Layout(props: ParentProps) {
     />
   )
 
+  const [loading] = createResource(
+    () => route()?.store?.[0]?.bootstrapPromise,
+    (p) => p,
+  )
+
   return (
     <div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
+      {(autoselecting(), loading()) ?? ""}
       <Titlebar />
       <div class="flex-1 min-h-0 min-w-0 flex">
         <div class="flex-1 min-h-0 relative">

+ 5 - 3
packages/app/src/pages/layout/sidebar-workspace.tsx

@@ -14,10 +14,11 @@ import { Spinner } from "@opencode-ai/ui/spinner"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { type Session } from "@opencode-ai/sdk/v2/client"
 import { type LocalProject } from "@/context/layout"
-import { useGlobalSync } from "@/context/global-sync"
+import { loadSessionsQuery, useGlobalSync } from "@/context/global-sync"
 import { useLanguage } from "@/context/language"
 import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
 import { sortedRootSessions, workspaceKey } from "./helpers"
+import { useQuery } from "@tanstack/solid-query"
 
 type InlineEditorComponent = (props: {
   id: string
@@ -454,7 +455,8 @@ export const LocalWorkspace = (props: {
   const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
   const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
   const count = createMemo(() => sessions()?.length ?? 0)
-  const loading = createMemo(() => !booted() && count() === 0)
+  const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
+  const loading = createMemo(() => query.isPending && count() === 0)
   const hasMore = createMemo(() => workspace().store.sessionTotal > count())
   const loadMore = async () => {
     workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
@@ -471,7 +473,7 @@ export const LocalWorkspace = (props: {
         mobile={props.mobile}
         ctx={props.ctx}
         showNew={() => false}
-        loading={loading}
+        loading={() => query.isLoading}
         sessions={sessions}
         hasMore={hasMore}
         loadMore={loadMore}

+ 9 - 7
packages/app/src/pages/session.tsx

@@ -13,6 +13,7 @@ import {
   on,
   onMount,
   untrack,
+  createResource,
 } from "solid-js"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { createMediaQuery } from "@solid-primitives/media"
@@ -804,8 +805,9 @@ export default function Page() {
 
   const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
 
-  createEffect(
-    on([() => sdk.directory, () => params.id] as const, ([, id]) => {
+  const [sessionSync] = createResource(
+    () => [sdk.directory, params.id] as const,
+    ([directory, id]) => {
       if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
       if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
       refreshFrame = undefined
@@ -816,13 +818,10 @@ export default function Page() {
       const stale = !cached
         ? false
         : (() => {
-            const info = getSessionPrefetch(sdk.directory, id)
+            const info = getSessionPrefetch(directory, id)
             if (!info) return true
             return Date.now() - info.at > SESSION_PREFETCH_TTL
           })()
-      untrack(() => {
-        void sync.session.sync(id)
-      })
 
       refreshFrame = requestAnimationFrame(() => {
         refreshFrame = undefined
@@ -834,7 +833,9 @@ export default function Page() {
           })
         }, 0)
       })
-    }),
+
+      return sync.session.sync(id)
+    },
   )
 
   createEffect(
@@ -1881,6 +1882,7 @@ export default function Page() {
 
   return (
     <div class="relative bg-background-base size-full overflow-hidden flex flex-col">
+      {sessionSync() ?? ""}
       <SessionHeader />
       <div class="flex-1 min-h-0 flex flex-col md:flex-row">
         <Show when={!isDesktop() && !!params.id}>