diff --git a/apps/app/.ladle/settings-story-fixtures.tsx b/apps/app/.ladle/settings-story-fixtures.tsx index d81d02fd2e..9050f66815 100644 --- a/apps/app/.ladle/settings-story-fixtures.tsx +++ b/apps/app/.ladle/settings-story-fixtures.tsx @@ -219,7 +219,7 @@ function createSettingsStoryQueryClient() { hostProviderCliStatusQueryKey(HOST_IDS.remote), remoteProviderStatus, ); - queryClient.setQueryData(pluginListQueryKey(true), { plugins: [] }); + queryClient.setQueryData(pluginListQueryKey(true), []); return queryClient; } diff --git a/apps/app/src/components/plugin/management/AddPluginDialog.test.tsx b/apps/app/src/components/plugin/management/AddPluginDialog.test.tsx index d084c44070..34a203e4fc 100644 --- a/apps/app/src/components/plugin/management/AddPluginDialog.test.tsx +++ b/apps/app/src/components/plugin/management/AddPluginDialog.test.tsx @@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; -import type { PluginListResult } from "@/hooks/queries/plugin-settings-queries"; +import type { InstalledPlugin } from "@bb/server-contract"; import { pluginCatalogSearchQueryKey, pluginListQueryKey, @@ -332,9 +332,7 @@ describe("AddPluginDialog", () => { stubFetch(); const onInstalled = vi.fn(); const { wrapper, queryClient } = createQueryClientTestHarness(); - queryClient.setQueryData(pluginListQueryKey(true), { - plugins: [], - }); + queryClient.setQueryData(pluginListQueryKey(true), []); render( { onInstalled(plugin); expect( queryClient - .getQueryData(pluginListQueryKey(true)) - ?.plugins.some((candidate) => candidate.id === plugin.id), + .getQueryData(pluginListQueryKey(true)) + ?.some((candidate) => candidate.id === plugin.id), ).toBe(true); }} />, diff --git a/apps/app/src/hooks/cache-owners/plugin-cache-owner.ts b/apps/app/src/hooks/cache-owners/plugin-cache-owner.ts index d5b367f9ae..64ff1c7f26 100644 --- a/apps/app/src/hooks/cache-owners/plugin-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/plugin-cache-owner.ts @@ -1,9 +1,5 @@ import type { QueryClient } from "@tanstack/react-query"; -import { - toPluginListItem, - type PluginListResult, - type PluginSettingsView, -} from "../queries/plugin-settings-queries"; +import { type PluginSettingsView } from "../queries/plugin-settings-queries"; import type { InstalledPlugin } from "@bb/server-contract"; import { allPluginCatalogSearchQueryKeyPrefix, @@ -28,22 +24,19 @@ export function applyInstalledPlugin(args: { queryClient: QueryClient; plugin: InstalledPlugin; }): void { - const installed = toPluginListItem(args.plugin); - args.queryClient.setQueryData( + args.queryClient.setQueryData( pluginListQueryKey(true), (current) => { - const plugins = current?.plugins ?? []; + const plugins = current ?? []; const existingIndex = plugins.findIndex( - (candidate) => candidate.id === installed.id, + (candidate) => candidate.id === args.plugin.id, ); if (existingIndex === -1) { - return { plugins: [...plugins, installed] }; + return [...plugins, args.plugin]; } - return { - plugins: plugins.map((candidate, index) => - index === existingIndex ? installed : candidate, - ), - }; + return plugins.map((candidate, index) => + index === existingIndex ? args.plugin : candidate, + ); }, ); } diff --git a/apps/app/src/hooks/queries/plugin-settings-queries.test.ts b/apps/app/src/hooks/queries/plugin-settings-queries.test.ts index 21449baf21..a4c456da85 100644 --- a/apps/app/src/hooks/queries/plugin-settings-queries.test.ts +++ b/apps/app/src/hooks/queries/plugin-settings-queries.test.ts @@ -1,5 +1,12 @@ -import { describe, expect, it } from "vitest"; -import { fetchPluginList, removePlugin } from "./plugin-settings-queries"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; +import { fetchFrontendCandidates } from "@/lib/plugin-frontend"; +import { pluginListQueryKey } from "./query-keys"; +import { + fetchInstalledPlugins, + fetchPluginList, + removePlugin, +} from "./plugin-settings-queries"; function fetchReturning(body: unknown, status = 200): typeof fetch { return async () => @@ -52,7 +59,41 @@ const ROW = { logoDarkUrl: null, }; +afterEach(() => { + vi.unstubAllGlobals(); +}); + describe("fetchPluginList envelope", () => { + it("lets the frontend loader reuse the app plugin list", async () => { + const plugin = { + ...ROW, + app: { + hasApp: true, + bundle: { + jsUrl: "/api/v1/plugins/linear/assets/app.js?h=abc", + cssUrl: null, + jsBytes: 1_000, + hash: "abc", + sdkMajor: 0, + sdkVersion: "0.4.27", + compatible: true, + }, + }, + }; + const queryClient = new QueryClient(); + queryClient.setQueryData( + pluginListQueryKey(true), + await fetchInstalledPlugins(fetchReturning({ plugins: [plugin] })), + ); + const networkFetch = vi.fn(fetchReturning({ plugins: [plugin] })); + vi.stubGlobal("fetch", networkFetch); + + await expect(fetchFrontendCandidates(queryClient)).resolves.toEqual([ + expect.objectContaining({ pluginId: "linear" }), + ]); + expect(networkFetch).not.toHaveBeenCalled(); + }); + it("binds browser fetch before the SDK invokes it", async () => { const result = await fetchPluginList( receiverSensitiveFetch({ plugins: [ROW] }), diff --git a/apps/app/src/hooks/queries/plugin-settings-queries.ts b/apps/app/src/hooks/queries/plugin-settings-queries.ts index c02f0a4308..2456b750a3 100644 --- a/apps/app/src/hooks/queries/plugin-settings-queries.ts +++ b/apps/app/src/hooks/queries/plugin-settings-queries.ts @@ -4,7 +4,7 @@ import type { PluginSettingsResponse, } from "@bb/server-contract"; import { pluginSettingsUpdateRequestSchema } from "@bb/server-contract"; -import { useQuery } from "@tanstack/react-query"; +import { queryOptions, useQuery } from "@tanstack/react-query"; import { createPluginsClient } from "./plugin-client"; import { pluginListQueryKey, pluginSettingsViewQueryKey } from "./query-keys"; @@ -131,9 +131,17 @@ export function toPluginListItem(plugin: InstalledPlugin): PluginListItem { export async function fetchPluginList( fetchImpl: FetchLike, + signal?: AbortSignal, ): Promise { - const result = await createPluginsClient(fetchImpl).list(); - return { plugins: result.plugins.map(toPluginListItem) }; + const plugins = await fetchInstalledPlugins(fetchImpl, signal); + return { plugins: plugins.map(toPluginListItem) }; +} + +export async function fetchInstalledPlugins( + fetchImpl: FetchLike, + signal?: AbortSignal, +): Promise { + return (await createPluginsClient(fetchImpl).list({ signal })).plugins; } export type PluginSettingFieldDescriptor = PluginSettingDescriptor; @@ -194,15 +202,24 @@ export async function removePlugin( await createPluginsClient(fetchImpl).remove({ pluginId }); } -export function usePluginList(args: { enabled: boolean }) { - return useQuery({ +export function pluginListQueryOptions(args: { enabled: boolean }) { + return queryOptions({ queryKey: pluginListQueryKey(args.enabled), - queryFn: () => fetchPluginList(fetch), + queryFn: ({ signal }) => fetchInstalledPlugins(fetch, signal), enabled: args.enabled, staleTime: 30_000, }); } +export function usePluginList(args: { enabled: boolean }) { + return useQuery({ + ...pluginListQueryOptions(args), + select: (plugins): PluginListResult => ({ + plugins: plugins.map(toPluginListItem), + }), + }); +} + export function usePluginSettingsView( pluginId: string, options: { enabled: boolean }, diff --git a/apps/app/src/hooks/queries/system-queries.ts b/apps/app/src/hooks/queries/system-queries.ts index 8f62c385a0..104e40d887 100644 --- a/apps/app/src/hooks/queries/system-queries.ts +++ b/apps/app/src/hooks/queries/system-queries.ts @@ -1,4 +1,9 @@ -import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + queryOptions, + useQueries, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import type { QueryKey } from "@tanstack/react-query"; import type { PermissionMode, @@ -10,7 +15,6 @@ import { permissionModeValues } from "@bb/domain"; import { toRecord } from "@bb/core-ui"; import type { SystemCliSkillsStatusResponse, - SystemConfigResponse, SystemExecutionOptionsResponse, SystemProvidersQuery, SystemProviderStatesResponse, @@ -319,15 +323,21 @@ export function useSystemExecutionOptions( }); } +export function systemConfigQueryOptions() { + return queryOptions({ + queryKey: systemConfigQueryKey(), + queryFn: ({ signal }) => sdk.system.config({ signal }), + staleTime: 60_000, + }); +} + export function useSystemConfig(options?: QueryOptions) { const enabled = options?.enabled ?? true; useSystemRealtimeSubscription({ enabled }); - return useQuery({ - queryKey: systemConfigQueryKey(), - queryFn: ({ signal }) => sdk.system.config({ signal }), + return useQuery({ + ...systemConfigQueryOptions(), enabled, - staleTime: 60_000, }); } diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index 5bd35ee099..ea281eb785 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -18,6 +18,7 @@ import { sidebarNavigationQueryKey, threadDetailBootstrapQueryKey, threadHostFilePreviewQueryKey, + threadPendingInteractionsQueryKey, threadQueuedMessagesQueryKey, threadQueryKey, threadTimelineQueryKey, @@ -31,6 +32,7 @@ import { useThreadDetailBootstrap, useThreadHostFilePreview, useThreadMentionCandidates, + useThreadPendingInteractions, useThreadQueuedMessages, useThreadStorageLocation, useThreadTimeline, @@ -50,6 +52,7 @@ vi.mock("@/lib/sdk", () => ({ get: vi.fn(), list: vi.fn(), queuedMessages: { list: vi.fn() }, + interactions: { list: vi.fn() }, storageLocation: vi.fn(), timeline: vi.fn(), }, @@ -148,6 +151,7 @@ beforeEach(() => { vi.mocked(sdk.threads.get).mockResolvedValue(THREAD_WITH_INCLUDES); vi.mocked(sdk.threads.list).mockResolvedValue([]); vi.mocked(sdk.threads.queuedMessages.list).mockResolvedValue([]); + vi.mocked(sdk.threads.interactions.list).mockResolvedValue([]); vi.mocked(sdk.threads.storageLocation).mockResolvedValue({ hostId: "host-1", storageRootPath: "/tmp/thread-storage/thread-1", @@ -443,6 +447,52 @@ describe("useThreadQueuedMessages", () => { }); }); +describe("useThreadPendingInteractions", () => { + it("reuses the first owner's fresh baseline when a second owner mounts", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + const first = renderHook(() => useThreadPendingInteractions("thread-1"), { + wrapper, + }); + await waitFor(() => { + expect(first.result.current.isSuccess).toBe(true); + }); + queryClient.setQueryData( + threadPendingInteractionsQueryKey("thread-1"), + [], + { updatedAt: Date.now() - 1_000 }, + ); + + renderHook(() => useThreadPendingInteractions("thread-1"), { wrapper }); + await act(async () => { + await Promise.resolve(); + }); + + expect(sdk.threads.interactions.list).toHaveBeenCalledTimes(1); + }); + + it("refetches the interaction baseline when a stale owner remounts", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + const first = renderHook(() => useThreadPendingInteractions("thread-1"), { + wrapper, + }); + await waitFor(() => { + expect(first.result.current.isSuccess).toBe(true); + }); + first.unmount(); + queryClient.setQueryData( + threadPendingInteractionsQueryKey("thread-1"), + [], + { updatedAt: Date.now() - 2_500 }, + ); + + renderHook(() => useThreadPendingInteractions("thread-1"), { wrapper }); + + await waitFor(() => { + expect(sdk.threads.interactions.list).toHaveBeenCalledTimes(2); + }); + }); +}); + describe("useThreadHostFilePreview", () => { it("refetches stale host file previews on focus and reconnect", async () => { const { queryClient, wrapper } = createQueryClientTestHarness(); diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index 50d44aa759..81cddb0c13 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -750,7 +750,9 @@ export function useThreadPendingInteractions( enabled, refetchOnMount: options?.refetchOnMount ?? true, ...REALTIME_OWNED_NO_FOCUS_QUERY_POLICY, - staleTime: options?.staleTime, + ...(options?.staleTime === undefined + ? {} + : { staleTime: options.staleTime }), }); } diff --git a/apps/app/src/lib/app-query-client.ts b/apps/app/src/lib/app-query-client.ts new file mode 100644 index 0000000000..b89d3be745 --- /dev/null +++ b/apps/app/src/lib/app-query-client.ts @@ -0,0 +1,7 @@ +import { createAppQueryClient } from "./query-client"; +import { wsManager } from "./ws"; + +export const appQueryClient = createAppQueryClient({ + shouldRefetchOnWindowFocus: () => + wsManager.getConnectionState() !== "connected", +}); diff --git a/apps/app/src/lib/plugin-frontend-reload.test.ts b/apps/app/src/lib/plugin-frontend-reload.test.ts index 32c32d0e30..9f2a87ac4d 100644 --- a/apps/app/src/lib/plugin-frontend-reload.test.ts +++ b/apps/app/src/lib/plugin-frontend-reload.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import type { PluginComposerThreadRowStatus } from "@get-bb/plugin-sdk"; +import { QueryClient } from "@tanstack/react-query"; import { createElement } from "react"; import { createRoot } from "react-dom/client"; import { MemoryRouter, Route, Routes } from "react-router-dom"; @@ -16,6 +17,7 @@ import { createPluginFrontendReconcileScheduler, createPluginFrontendReconcileState, disposePluginFrontends, + fetchFrontendCandidates, reconcilePluginFrontends, type PluginFrontendCandidate, type PluginFrontendReconcileDeps, @@ -76,6 +78,7 @@ function contentScriptModule( } afterEach(() => { + vi.unstubAllGlobals(); resetPluginThreadRowStatusesForTest(); resetPluginSlotStoreForTest(); resetPluginCssForTest(); @@ -410,6 +413,33 @@ describe("reconcilePluginFrontends", () => { expect(state.appliedHashes.has("hello")).toBe(false); }); + it("preserves active frontends when the plugin inventory request fails", async () => { + const state = createPluginFrontendReconcileState(); + const deps = makeDeps([candidate("hello", "v1")]); + await reconcilePluginFrontends(state, deps); + deps.removeRegistrations.mockClear(); + vi.mocked(deps.applyCss).mockClear(); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new TypeError("offline"); + }), + ); + deps.fetchCandidates = vi.fn(() => fetchFrontendCandidates(queryClient)); + + await expect(reconcilePluginFrontends(state, deps)).rejects.toThrow( + "offline", + ); + expect(state.records.get("hello")?.status).toBe("loaded"); + expect(state.appliedHashes.get("hello")).toBe("v1"); + expect(deps.removeRegistrations).not.toHaveBeenCalled(); + expect(deps.applyCss).not.toHaveBeenCalled(); + }); + it("deactivates stale UI when a replacement import fails or needs an SDK update", async () => { const state = createPluginFrontendReconcileState(); const deps = makeDeps([candidate("hello", "v1")]); diff --git a/apps/app/src/lib/plugin-frontend.ts b/apps/app/src/lib/plugin-frontend.ts index a982e499eb..4342eb3126 100644 --- a/apps/app/src/lib/plugin-frontend.ts +++ b/apps/app/src/lib/plugin-frontend.ts @@ -21,6 +21,9 @@ import * as tailwindMerge from "tailwind-merge"; import * as classVarianceAuthority from "class-variance-authority"; import * as sharedUiIcon from "@bb/shared-ui/icon"; import { createDebouncedCallbackScheduler } from "@bb/domain"; +import type { QueryClient } from "@tanstack/react-query"; +import { pluginListQueryOptions } from "@/hooks/queries/plugin-settings-queries"; +import { appQueryClient } from "./app-query-client"; import type { PluginContentScriptDisposer, PluginContentScriptRegistration, @@ -227,67 +230,29 @@ export function installPluginRuntime(): void { }; } -function isFrontendBundle(value: unknown): value is PluginFrontendBundle { - if (typeof value !== "object" || value === null) return false; - const bundle = value as Record; - return ( - typeof bundle.jsUrl === "string" && - (bundle.cssUrl === null || typeof bundle.cssUrl === "string") && - typeof bundle.jsBytes === "number" && - typeof bundle.hash === "string" && - typeof bundle.sdkMajor === "number" && - typeof bundle.sdkVersion === "string" && - typeof bundle.compatible === "boolean" +export async function fetchFrontendCandidates( + queryClient: QueryClient = appQueryClient, +): Promise { + const plugins = await queryClient.fetchQuery( + pluginListQueryOptions({ enabled: true }), ); -} - -async function fetchFrontendCandidates(): Promise { - const response = await fetch("/api/v1/plugins"); - if (!response.ok) return []; - const body = (await response.json()) as { plugins?: unknown }; - if (!Array.isArray(body.plugins)) return []; const candidates: PluginFrontendCandidate[] = []; const logoUrls = new Map(); - for (const entry of body.plugins) { - const typed = entry as { - id?: unknown; - name?: unknown; - icon?: unknown; - status?: unknown; - logoUrl?: unknown; - logoDarkUrl?: unknown; - iconUrl?: unknown; - icons?: unknown; - app?: { bundle?: unknown }; - } | null; - if (typeof typed?.id !== "string") continue; - const logoUrl = typeof typed.logoUrl === "string" ? typed.logoUrl : null; - const logoDarkUrl = - typeof typed.logoDarkUrl === "string" ? typed.logoDarkUrl : null; - const compactIconUrl = - typeof typed.iconUrl === "string" ? typed.iconUrl : null; - const icon = typeof typed.icon === "string" ? typed.icon : null; - const displayName = typeof typed.name === "string" ? typed.name : null; - const icons = new Map(); - if (typeof typed.icons === "object" && typed.icons !== null) { - for (const [name, url] of Object.entries(typed.icons)) { - if (typeof url === "string") icons.set(name, url); - } - } - logoUrls.set(typed.id, { - displayName, - icon, - compactIconUrl, - logoUrl, - logoDarkUrl, - icons, + for (const plugin of plugins) { + logoUrls.set(plugin.id, { + displayName: plugin.name, + icon: plugin.icon, + compactIconUrl: plugin.iconUrl, + logoUrl: plugin.logoUrl, + logoDarkUrl: plugin.logoDarkUrl, + icons: new Map(Object.entries(plugin.icons)), }); - if (typed.status !== "running") { + if (plugin.status !== "running") { continue; } - const bundle = typed.app?.bundle; - if (!isFrontendBundle(bundle)) continue; - candidates.push({ pluginId: typed.id, bundle }); + const bundle = plugin.app.bundle; + if (bundle === null) continue; + candidates.push({ pluginId: plugin.id, bundle }); } setPluginLogoUrls(logoUrls); return candidates; diff --git a/apps/app/src/lib/system-config-atoms.local-access.test.ts b/apps/app/src/lib/system-config-atoms.local-access.test.ts index 1377582431..6ca3ee610f 100644 --- a/apps/app/src/lib/system-config-atoms.local-access.test.ts +++ b/apps/app/src/lib/system-config-atoms.local-access.test.ts @@ -3,6 +3,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ fetchHostStatus: vi.fn(), + onConnected: vi.fn( + ( + _listener: (event: { + reconnected: boolean; + disconnectedAt?: number; + }) => void, + ) => + () => {}, + ), + fetchSdkSystemConfig: vi.fn(async () => ({ + hostDaemonPort: 38_887, + localHelperPorts: [38_887, 38_888], + })), fetchSystemConfig: vi.fn(async () => ({ ok: true, json: async () => ({ @@ -27,14 +40,23 @@ vi.mock("./api-host-daemon", () => ({ fetchWorkspaceOpenTargets: vi.fn(async () => []), })); +vi.mock("./sdk", () => ({ + sdk: { + system: { + config: mocks.fetchSdkSystemConfig, + }, + }, +})); + vi.mock("./bb-desktop", () => ({ getBbDesktopInfo: () => null, })); vi.mock("./ws", () => ({ wsManager: { + getConnectionState: () => "connected", onChanged: () => () => {}, - onConnected: () => () => {}, + onConnected: mocks.onConnected, }, })); @@ -44,9 +66,16 @@ import { localHostStatusAtom, requestLocalHostDaemonAccessAtom, } from "./system-config-atoms"; +import { appQueryClient } from "./app-query-client"; +import { sdk } from "./sdk"; +import { systemConfigQueryKey } from "@/hooks/queries/query-keys"; beforeEach(() => { + appQueryClient.clear(); mocks.fetchHostStatus.mockReset(); + mocks.onConnected.mockClear(); + mocks.fetchSdkSystemConfig.mockClear(); + mocks.fetchSystemConfig.mockClear(); vi.stubGlobal("window", { location: { hostname: "remote.getbb.app", @@ -62,11 +91,83 @@ beforeEach(() => { }); afterEach(() => { + appQueryClient.clear(); vi.useRealTimers(); vi.unstubAllGlobals(); }); describe("local host daemon access atoms", () => { + it("shares the system config request with the app query owner", async () => { + const store = createStore(); + const query = appQueryClient.fetchQuery({ + queryKey: systemConfigQueryKey(), + queryFn: ({ signal }) => sdk.system.config({ signal }), + staleTime: 60_000, + }); + + await Promise.all([query, store.get(localHostDaemonAccessStateAtom)]); + + expect( + mocks.fetchSdkSystemConfig.mock.calls.length + + mocks.fetchSystemConfig.mock.calls.length, + ).toBe(1); + }); + + it("does not restart status discovery on the initial server connection", async () => { + vi.stubGlobal("navigator", { + permissions: { + query: vi.fn(async () => ({ state: "granted" })), + }, + userAgent: "test", + }); + mocks.fetchHostStatus.mockResolvedValue({ + connected: true, + hostId: "host-local", + serverUrl: "https://remote.getbb.app", + }); + const store = createStore(); + const unsubscribe = store.sub(localHostStatusAtom, () => {}); + + await expect(store.get(localHostStatusAtom)).resolves.toMatchObject({ + hostId: "host-local", + }); + for (const [listener] of mocks.onConnected.mock.calls) { + listener({ reconnected: false }); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mocks.fetchHostStatus).toHaveBeenCalledTimes(2); + unsubscribe(); + }); + + it("refreshes status discovery after a server reconnection", async () => { + vi.stubGlobal("navigator", { + permissions: { + query: vi.fn(async () => ({ state: "granted" })), + }, + userAgent: "test", + }); + mocks.fetchHostStatus.mockResolvedValue({ + connected: true, + hostId: "host-local", + serverUrl: "https://remote.getbb.app", + }); + const store = createStore(); + const unsubscribe = store.sub(localHostStatusAtom, () => {}); + + await expect(store.get(localHostStatusAtom)).resolves.toMatchObject({ + hostId: "host-local", + }); + for (const [listener] of mocks.onConnected.mock.calls) { + listener({ reconnected: true, disconnectedAt: Date.now() }); + } + + await vi.waitFor(() => { + expect(mocks.fetchHostStatus).toHaveBeenCalledTimes(4); + }); + unsubscribe(); + }); + it("does not probe loopback while a remote page is in prompt state", async () => { const store = createStore(); diff --git a/apps/app/src/lib/system-config-atoms.ts b/apps/app/src/lib/system-config-atoms.ts index 5734f04879..7c752c9e3b 100644 --- a/apps/app/src/lib/system-config-atoms.ts +++ b/apps/app/src/lib/system-config-atoms.ts @@ -4,7 +4,8 @@ import { defaultAppSettings, defaultAppTheme } from "@bb/domain"; import type { WorkspaceOpenTarget } from "@bb/host-daemon-contract"; import type { HostDaemonStatusSnapshot } from "./api-host-daemon"; import type { SystemConfigResponse } from "@bb/server-contract"; -import { apiClient } from "./api-server"; +import { systemConfigQueryOptions } from "@/hooks/queries/system-queries"; +import { appQueryClient } from "./app-query-client"; import { fetchHostStatus, fetchWorkspaceOpenTargets } from "./api-host-daemon"; import { getBbDesktopInfo } from "./bb-desktop"; import { @@ -78,15 +79,17 @@ function didLastSystemConfigLoadFail(): boolean { return lastSystemConfigLoadStatus === "failed"; } -async function loadSystemConfig(): Promise { +async function loadSystemConfig( + refresh: boolean, +): Promise { try { - const res = await apiClient.system.config.$get(); - if (!res.ok) { - markSystemConfigLoadFailed(); - return unavailableSystemConfig; - } + const options = systemConfigQueryOptions(); + const config = await appQueryClient.fetchQuery({ + ...options, + staleTime: refresh ? 0 : options.staleTime, + }); markSystemConfigLoadSucceeded(); - return (await res.json()) as SystemConfigResponse; + return config; } catch { markSystemConfigLoadFailed(); return unavailableSystemConfig; @@ -184,30 +187,11 @@ systemConfigRefreshTickAtom.onMount = (setRefreshTick) => { }; const systemConfigAtom = atom(async (get) => { - get(systemConfigRefreshTickAtom); - return loadSystemConfig(); + const refreshTick = get(systemConfigRefreshTickAtom); + return loadSystemConfig(refreshTick > 0); }); const localHostStatusRefreshTickAtom = atom(0); -localHostStatusRefreshTickAtom.onMount = (setRefreshTick) => { - const refresh = () => { - setRefreshTick((count) => count + 1); - }; - - const unsubscribeConnected = wsManager.onConnected(() => { - refresh(); - }); - const unsubscribeChanged = wsManager.onChanged((message) => { - if (message.entity === "host") { - refresh(); - } - }); - - return () => { - unsubscribeConnected(); - unsubscribeChanged(); - }; -}; const localHostDaemonAccessRefreshTickAtom = atom(0); const localHostDaemonSessionAccessGrantedAtom = atom(false); diff --git a/apps/app/src/main.tsx b/apps/app/src/main.tsx index 99c35cff79..a06424f578 100644 --- a/apps/app/src/main.tsx +++ b/apps/app/src/main.tsx @@ -10,24 +10,17 @@ import { registerProviderCliInstallQueryClient } from "./components/provider-cli import { initializePreferredTheme } from "./hooks/useTheme"; import { initializeFavicon } from "./lib/favicon-color-preference"; import { installForeignDomMutationGuard } from "./lib/foreign-dom-mutation-guard"; -import { - createAppQueryClient, - installAppQueryClientBrowserEvents, -} from "./lib/query-client"; +import { installAppQueryClientBrowserEvents } from "./lib/query-client"; +import { appQueryClient } from "./lib/app-query-client"; import { applyCachedAppThemeCss } from "./lib/themes"; -import { wsManager } from "./lib/ws"; import "./app.css"; installForeignDomMutationGuard(); Error.stackTraceLimit = 50; -const queryClient = createAppQueryClient({ - shouldRefetchOnWindowFocus: () => - wsManager.getConnectionState() !== "connected", -}); -installAppQueryClientBrowserEvents(queryClient); -registerProviderCliInstallQueryClient(queryClient); +installAppQueryClientBrowserEvents(appQueryClient); +registerProviderCliInstallQueryClient(appQueryClient); initializePreferredTheme(); applyCachedAppThemeCss(); @@ -46,7 +39,7 @@ createRoot(document.getElementById("root")!, { {} - + diff --git a/apps/host-daemon/test/command/host-branches-dispatch.test.ts b/apps/host-daemon/test/command/host-branches-dispatch.test.ts index cb91684523..e4198a7bd9 100644 --- a/apps/host-daemon/test/command/host-branches-dispatch.test.ts +++ b/apps/host-daemon/test/command/host-branches-dispatch.test.ts @@ -388,8 +388,12 @@ describe("host.inspect_git_source dispatch", () => { ); const harness = createHarness(); - const branchListing = dispatchOnlineRpcCommand( - { type: "host.list_branches", path: repoPath, limit: 50 }, + const sourceInspection = dispatchOnlineRpcCommand( + { + type: "host.inspect_git_source", + path: repoPath, + remoteRefresh: "blocking", + }, harness.dispatchOptions(), ); let provisioning: Promise | undefined; @@ -414,13 +418,13 @@ describe("host.inspect_git_source dispatch", () => { await expect(fs.access(targetedFetchMarker)).rejects.toThrow(); await fs.writeFile(releaseRefreshPath, "release\n", "utf8"); - await branchListing; + await sourceInspection; const workspace = await provisioning; expect(workspace.path).toBe(targetPath); await expect(fs.access(targetedFetchMarker)).resolves.toBeUndefined(); } finally { await fs.writeFile(releaseRefreshPath, "release\n", "utf8"); - await Promise.allSettled([branchListing]); + await Promise.allSettled([sourceInspection]); const provisionResult = await Promise.allSettled( provisioning ? [provisioning] : [], );