summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorBrendan Allan <[email protected]>2026-04-16 14:10:23 +0800
committerGitHub <[email protected]>2026-04-16 06:10:23 +0000
commit97918500d4020a7b44f3636f23daabc8c477008b (patch)
tree158d312230d0522fa702272fafcca9af7e1df544 /packages
parente2c08039624ac7c799ea5ba6ce94e3c671a8ed7c (diff)
downloadopencode-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.tsx23
-rw-r--r--packages/app/src/components/prompt-input.tsx203
-rw-r--r--packages/app/src/context/global-sync.tsx91
-rw-r--r--packages/app/src/context/global-sync/bootstrap.ts269
-rw-r--r--packages/app/src/context/global-sync/child-store.ts1
-rw-r--r--packages/app/src/context/global-sync/types.ts1
-rw-r--r--packages/app/src/pages/layout.tsx10
-rw-r--r--packages/app/src/pages/layout/sidebar-workspace.tsx8
-rw-r--r--packages/app/src/pages/session.tsx16
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}>