app: better loading (#23489)

This commit is contained in:
Brendan Allan 2026-04-20 13:17:37 +08:00 committed by GitHub
parent 84e322b0fd
commit 687b758882
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 142 additions and 107 deletions

View File

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

View File

@ -9,7 +9,7 @@ import type {
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/shared/util/path" 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 { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@ -223,16 +223,18 @@ function createGlobalSync() {
limit, limit,
permission: store.permission, permission: store.permission,
}) })
setStore( batch(() => {
"sessionTotal", setStore(
estimateRootSessionTotal({ "sessionTotal",
count: nonArchived.length, estimateRootSessionTotal({
limit: x.limit, count: nonArchived.length,
limited: x.limited, limit: x.limit,
}), limited: x.limited,
) }),
setStore("session", reconcile(sessions, { key: "id" })) )
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo) setStore("session", reconcile(sessions, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
})
sessionMeta.set(directory, { limit }) sessionMeta.set(directory, { limit })
}) })
.catch((err) => { .catch((err) => {

View File

@ -19,7 +19,6 @@ import type { State, VcsCache } from "./types"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions, skipToken } from "@tanstack/solid-query" import { QueryClient, queryOptions, skipToken } from "@tanstack/solid-query"
import { loadSessionsQuery } from "../global-sync"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@ -82,6 +81,9 @@ export async function bootstrapGlobal(input: {
input.setGlobalStore("config", x.data!) input.setGlobalStore("config", x.data!)
}), }),
), ),
]
const slow = [
() => () =>
input.queryClient.fetchQuery({ input.queryClient.fetchQuery({
...loadProvidersQuery(null), ...loadProvidersQuery(null),
@ -93,9 +95,6 @@ export async function bootstrapGlobal(input: {
}), }),
), ),
}), }),
]
const slow = [
() => () =>
retry(() => retry(() =>
input.globalSDK.path.get().then((x) => { input.globalSDK.path.get().then((x) => {
@ -183,8 +182,43 @@ function warmSessions(input: {
export const loadProvidersQuery = (directory: string | null) => export const loadProvidersQuery = (directory: string | null) =>
queryOptions<null>({ queryKey: [directory, "providers"], queryFn: skipToken }) queryOptions<null>({ queryKey: [directory, "providers"], queryFn: skipToken })
export const loadAgentsQuery = (directory: string | null) => export const loadAgentsQuery = (
queryOptions<null>({ queryKey: [directory, "agents"], queryFn: skipToken }) 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: { export async function bootstrapDirectory(input: {
directory: string directory: string
@ -222,45 +256,27 @@ export async function bootstrapDirectory(input: {
input.setStore("lsp", []) input.setStore("lsp", [])
if (loading) input.setStore("status", "partial") if (loading) input.setStore("status", "partial")
const fast = [() => Promise.resolve(input.loadSessions(input.directory))] const rev = (providerRev.get(input.directory) ?? 0) + 1
providerRev.set(input.directory, rev)
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),
})
}
;(async () => { ;(async () => {
const slow = [ const slow = [
() => Promise.resolve(input.loadSessions(input.directory)),
() => () =>
input.queryClient.ensureQueryData({ input.queryClient.ensureQueryData(
...loadAgentsQuery(input.directory), loadAgentsQuery(input.directory, input.sdk, (x) => input.setStore("agent", normalizeAgentList(x.data))),
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.config.get().then((x) => input.setStore("config", x.data!))),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
() => !seededProject &&
seededProject (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
? Promise.resolve() !seededPath &&
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), (() =>
() => input.queryClient.ensureQueryData(
seededPath loadPathQuery(input.directory, input.sdk, (x) => {
? Promise.resolve() const next = projectID(x.data?.directory ?? input.directory, input.global.project)
: retry(() => if (next) input.setStore("project", next)
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(() => retry(() =>
input.sdk.vcs.get().then((x) => { input.sdk.vcs.get().then((x) => {
@ -330,7 +346,28 @@ export async function bootstrapDirectory(input: {
input.setStore("mcp_ready", true) 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() await waitForPaint()
const slowErrs = errors(await runAll(slow)) 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") if (loading && 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),
})
})() })()
} }

View File

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

View File

@ -185,9 +185,9 @@ function createPromptSession(dir: string, id: string | undefined) {
return { return {
ready, ready,
current: createMemo(() => store.prompt), current: () => store.prompt,
cursor: createMemo(() => store.cursor), cursor: createMemo(() => store.cursor),
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
context: { context: {
items: createMemo(() => store.context.items), items: createMemo(() => store.context.items),
add(item: ContextItem) { 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()) const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session())
return { return {
ready: () => session().ready(), ready: () => session().ready,
current: () => session().current(), current: () => session().current(),
cursor: () => session().cursor(), cursor: () => session().cursor(),
dirty: () => session().dirty(), dirty: () => session().dirty(),

View File

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

View File

@ -2,7 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/shared/util/encode" import { base64Encode } from "@opencode-ai/shared/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router" 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 { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local" import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk" 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 }) navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
}) })
createEffect(() => { createResource(
const id = params.id () => params.id,
if (!id) return (id) => sync.session.sync(id),
void sync.session.sync(id) )
})
return ( return (
<DataProvider <DataProvider

View File

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

View File

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