Browse Source

Apply PR #23061: app: move more to tanstack query + suspend on more promises

opencode-agent[bot] 14 hours ago
parent
commit
7df43ed2fd

+ 22 - 19
packages/app/src/components/prompt-input.tsx

@@ -1,6 +1,6 @@
 import { useFilteredList } from "@opencode-ai/ui/hooks"
 import { useSpring } from "@opencode-ai/ui/motion-spring"
-import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js"
+import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal, createResource } from "solid-js"
 import { createStore } from "solid-js/store"
 import { useLocal } from "@/context/local"
 import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
@@ -54,7 +54,7 @@ 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 { useQueries, useQuery } from "@tanstack/solid-query"
 import { loadAgentsQuery, loadProvidersQuery } from "@/context/global-sync/bootstrap"
 
 interface PromptInputProps {
@@ -1252,16 +1252,21 @@ 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 [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({
+    queries: [loadAgentsQuery(sdk.directory), loadProvidersQuery(null), loadProvidersQuery(sdk.directory)],
+  }))
 
+  const agentsLoading = () => agentsQuery.isLoading
   const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading
 
+  const [promptReady] = createResource(
+    () => prompt.ready().promise,
+    (p) => p,
+  )
+
   return (
     <div class="relative size-full _max-h-[320px] flex flex-col gap-0">
+      {(promptReady(), null)}
       <PromptPopover
         popover={store.popover}
         setSlashPopoverRef={(el) => (slashPopoverRef = el)}
@@ -1358,15 +1363,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               }}
               style={{ "padding-bottom": space }}
             />
-            <Show when={!prompt.dirty()}>
-              <div
-                class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
-                classList={{ "font-mono!": store.mode === "shell" }}
-                style={{ "padding-bottom": space }}
-              >
-                {placeholder()}
-              </div>
-            </Show>
+            <div
+              class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
+              classList={{ "font-mono!": store.mode === "shell" }}
+              style={{ "padding-bottom": space, display: prompt.dirty() ? "none" : undefined }}
+            >
+              {placeholder()}
+            </div>
           </div>
 
           <div
@@ -1457,7 +1460,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               </div>
               <div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
                 <Show when={!agentsLoading()}>
-                  <div data-component="prompt-agent-control">
+                  <div data-component="prompt-agent-control" style={{ animation: "fade-in 0.3s" }}>
                     <TooltipKeybind
                       placement="top"
                       gutter={4}
@@ -1483,7 +1486,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 </Show>
                 <Show when={!providersLoading()}>
                   <Show when={store.mode !== "shell"}>
-                    <div data-component="prompt-model-control">
+                    <div data-component="prompt-model-control" style={{ animation: "fade-in 0.3s" }}>
                       <Show
                         when={providers.paid().length > 0}
                         fallback={
@@ -1554,7 +1557,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                         </TooltipKeybind>
                       </Show>
                     </div>
-                    <div data-component="prompt-variant-control">
+                    <div data-component="prompt-variant-control" style={{ animation: "fade-in 0.3s" }}>
                       <TooltipKeybind
                         placement="top"
                         gutter={4}

+ 13 - 11
packages/app/src/context/global-sync.tsx

@@ -9,7 +9,7 @@ import type {
 } from "@opencode-ai/sdk/v2/client"
 import { showToast } from "@opencode-ai/ui/toast"
 import { getFilename } from "@opencode-ai/shared/util/path"
-import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
+import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { useLanguage } from "@/context/language"
 import { Persist, persisted } from "@/utils/persist"
@@ -223,16 +223,18 @@ function createGlobalSync() {
                 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)
+              batch(() => {
+                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) => {

+ 79 - 65
packages/app/src/context/global-sync/bootstrap.ts

@@ -19,7 +19,6 @@ 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
@@ -82,6 +81,9 @@ export async function bootstrapGlobal(input: {
           input.setGlobalStore("config", x.data!)
         }),
       ),
+  ]
+
+  const slow = [
     () =>
       input.queryClient.fetchQuery({
         ...loadProvidersQuery(null),
@@ -93,9 +95,6 @@ export async function bootstrapGlobal(input: {
             }),
           ),
       }),
-  ]
-
-  const slow = [
     () =>
       retry(() =>
         input.globalSDK.path.get().then((x) => {
@@ -183,8 +182,43 @@ function warmSessions(input: {
 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 const loadAgentsQuery = (
+  directory: string | null,
+  sdk?: OpencodeClient,
+  transform?: (x: Awaited<ReturnType<OpencodeClient["app"]["agents"]>>) => void,
+) =>
+  queryOptions<null>({
+    queryKey: [directory, "agents"],
+    queryFn:
+      sdk && transform
+        ? () =>
+            retry(() =>
+              sdk.app
+                .agents()
+                .then(transform)
+                .then(() => null),
+            )
+        : skipToken,
+  })
+
+export const loadPathQuery = (
+  directory: string | null,
+  sdk?: OpencodeClient,
+  transform?: (x: Awaited<ReturnType<OpencodeClient["path"]["get"]>>) => void,
+) =>
+  queryOptions<Path>({
+    queryKey: [directory, "path"],
+    queryFn:
+      sdk && transform
+        ? () =>
+            retry(() =>
+              sdk.path.get().then(async (x) => {
+                transform(x)
+                return x.data!
+              }),
+            )
+        : skipToken,
+  })
 
 export async function bootstrapDirectory(input: {
   directory: string
@@ -222,45 +256,27 @@ export async function bootstrapDirectory(input: {
   input.setStore("lsp", [])
   if (loading) input.setStore("status", "partial")
 
-  const fast = [() => Promise.resolve(input.loadSessions(input.directory))]
-
-  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(errs[0], input.translate),
-    })
-  }
-
+  const rev = (providerRev.get(input.directory) ?? 0) + 1
+  providerRev.set(input.directory, rev)
   ;(async () => {
     const slow = [
+      () => Promise.resolve(input.loadSessions(input.directory)),
       () =>
-        input.queryClient.ensureQueryData({
-          ...loadAgentsQuery(input.directory),
-          queryFn: () =>
-            retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))).then(
-              () => null,
-            ),
-        }),
+        input.queryClient.ensureQueryData(
+          loadAgentsQuery(input.directory, input.sdk, (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!))),
-      () =>
-        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)
-              }),
-            ),
+      !seededProject &&
+        (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
+      !seededPath &&
+        (() =>
+          input.queryClient.ensureQueryData(
+            loadPathQuery(input.directory, input.sdk, (x) => {
+              const next = projectID(x.data?.directory ?? input.directory, input.global.project)
+              if (next) input.setStore("project", next)
+            }),
+          )),
       () =>
         retry(() =>
           input.sdk.vcs.get().then((x) => {
@@ -330,7 +346,28 @@ export async function bootstrapDirectory(input: {
             input.setStore("mcp_ready", true)
           }),
         ),
-    ]
+      () =>
+        input.queryClient.ensureQueryData({
+          ...loadProvidersQuery(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),
+        }),
+    ].filter(Boolean) as (() => Promise<any>)[]
 
     await waitForPaint()
     const slowErrs = errors(await runAll(slow))
@@ -344,29 +381,6 @@ export async function bootstrapDirectory(input: {
       })
     }
 
-    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),
-    })
+    if (loading && slowErrs.length === 0) input.setStore("status", "complete")
   })()
 }

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

@@ -14,6 +14,8 @@ import {
   type VcsCache,
 } from "./types"
 import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
+import { useQuery } from "@tanstack/solid-query"
+import { loadPathQuery } from "./bootstrap"
 
 export function createChildStoreManager(input: {
   owner: Owner
@@ -156,6 +158,8 @@ export function createChildStoreManager(input: {
         createRoot((dispose) => {
           const initialMeta = meta[0].value
           const initialIcon = icon[0].value
+
+          const pathQuery = useQuery(() => loadPathQuery(directory))
           const child = createStore<State>({
             project: "",
             projectMeta: initialMeta,
@@ -163,7 +167,11 @@ export function createChildStoreManager(input: {
             provider_ready: false,
             provider: { all: [], connected: [], default: {} },
             config: {},
-            path: { state: "", config: "", worktree: "", directory: "", home: "" },
+            get path() {
+              if (pathQuery.isLoading || !pathQuery.data)
+                return { state: "", config: "", worktree: "", directory: "", home: "" }
+              return pathQuery.data
+            },
             status: "loading" as const,
             agent: [],
             command: [],

+ 3 - 3
packages/app/src/context/prompt.tsx

@@ -185,9 +185,9 @@ function createPromptSession(dir: string, id: string | undefined) {
 
   return {
     ready,
-    current: createMemo(() => store.prompt),
+    current: () => store.prompt,
     cursor: createMemo(() => store.cursor),
-    dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
+    dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
     context: {
       items: createMemo(() => store.context.items),
       add(item: ContextItem) {
@@ -277,7 +277,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
     const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session())
 
     return {
-      ready: () => session().ready(),
+      ready: () => session().ready,
       current: () => session().current(),
       cursor: () => session().cursor(),
       dirty: () => session().dirty(),

+ 9 - 0
packages/app/src/index.css

@@ -66,4 +66,13 @@
       width: auto;
     }
   }
+
+  @keyframes fade-in {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
 }

+ 5 - 6
packages/app/src/pages/directory-layout.tsx

@@ -2,7 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context"
 import { showToast } from "@opencode-ai/ui/toast"
 import { base64Encode } from "@opencode-ai/shared/util/encode"
 import { useLocation, useNavigate, useParams } from "@solidjs/router"
-import { createEffect, createMemo, type ParentProps, Show } from "solid-js"
+import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js"
 import { useLanguage } from "@/context/language"
 import { LocalProvider } from "@/context/local"
 import { SDKProvider } from "@/context/sdk"
@@ -23,11 +23,10 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
     navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
   })
 
-  createEffect(() => {
-    const id = params.id
-    if (!id) return
-    void sync.session.sync(id)
-  })
+  createResource(
+    () => params.id,
+    (id) => sync.session.sync(id),
+  )
 
   return (
     <DataProvider

+ 1 - 1
packages/app/src/pages/layout/helpers.ts

@@ -31,7 +31,7 @@ function sortSessions(now: number) {
 const isRootVisibleSession = (session: Session, directory: string) =>
   workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
 
-const roots = (store: SessionStore) =>
+export const roots = (store: SessionStore) =>
   (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
 
 export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now))

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

@@ -321,7 +321,7 @@ export const SortableWorkspace = (props: {
   const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
   const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
   const busy = createMemo(() => props.ctx.isBusy(props.directory))
-  const loading = () => query.isLoading
+  const loading = () => query.isLoading && count() === 0
   const touch = createMediaQuery("(hover: none)")
   const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
   const loadMore = async () => {