diff options
| author | Brendan Allan <[email protected]> | 2026-04-16 14:10:23 +0800 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-16 06:10:23 +0000 |
| commit | 97918500d4020a7b44f3636f23daabc8c477008b (patch) | |
| tree | 158d312230d0522fa702272fafcca9af7e1df544 /packages | |
| parent | e2c08039624ac7c799ea5ba6ce94e3c671a8ed7c (diff) | |
| download | opencode-97918500d4020a7b44f3636f23daabc8c477008b.tar.gz opencode-97918500d4020a7b44f3636f23daabc8c477008b.zip | |
app: start migrating bootstrap data fetching to TanStack Query (#22756)
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/app/src/app.tsx | 23 | ||||
| -rw-r--r-- | packages/app/src/components/prompt-input.tsx | 203 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync.tsx | 91 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync/bootstrap.ts | 269 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync/child-store.ts | 1 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync/types.ts | 1 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 10 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-workspace.tsx | 8 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 16 |
9 files changed, 351 insertions, 271 deletions
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index a2a746c05..dbe107448 100644 --- a/packages/app/src/app.tsx +++ b/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> ) } diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 534215022..156b0b3a4 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/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> diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 57b76a96f..6ff60f161 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/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 { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 2f9147498..17fe726f9 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/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), }) + })() } diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 3fe67e4fb..6788e8cc5 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/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) diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index e3ec83c5e..28b3705d1 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/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 = { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 8fad0bafe..12a2bf763 100644 --- a/packages/app/src/pages/layout.tsx +++ b/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"> diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 9d74651b9..c1836fa8a 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/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} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 1aba1bb08..c4d642bf8 100644 --- a/packages/app/src/pages/session.tsx +++ b/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}> |
