diff --git a/apps/dev-playground/client/src/main.tsx b/apps/dev-playground/client/src/main.tsx
index 5297b637a..997a14297 100644
--- a/apps/dev-playground/client/src/main.tsx
+++ b/apps/dev-playground/client/src/main.tsx
@@ -1,3 +1,7 @@
+import {
+ ResourceStatusIndicator,
+ ResourceStatusProvider,
+} from "@databricks/appkit-ui/react";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import React from "react";
import ReactDOM from "react-dom/client";
@@ -24,6 +28,9 @@ if (!rootElement) {
ReactDOM.createRoot(rootElement).render(
-
+
+
+
+
,
);
diff --git a/docs/docs/plugins/analytics.md b/docs/docs/plugins/analytics.md
index 9fd0b4a30..3198d4340 100644
--- a/docs/docs/plugins/analytics.md
+++ b/docs/docs/plugins/analytics.md
@@ -100,6 +100,7 @@ const { data, loading, error } = useAnalyticsQuery(queryKey, parameters, options
data: T | null; // query result (typed array for JSON, TypedArrowTable for ARROW)
loading: boolean; // true while the query is executing
error: string | null; // error message, or null on success
+ warehouseStatus: WarehouseStatus | null; // see "Warehouse readiness" below
}
```
@@ -111,6 +112,154 @@ const { data, loading, error } = useAnalyticsQuery(queryKey, parameters, options
| `maxParametersSize` | `number` | `102400` | Max serialized parameters size in bytes |
| `autoStart` | `boolean` | `true` | Start query on mount |
+### Warehouse readiness
+
+If the configured SQL warehouse is `STOPPED` or `STARTING` when a query is requested, the analytics plugin will:
+
+1. Auto-start the warehouse (when `STOPPED`).
+2. Poll the warehouse state and stream `warehouse_status` events over SSE until it reaches `RUNNING`.
+3. Execute the SQL statement.
+
+This means a cold start no longer freezes the UI on a stalled spinner. Render the new `warehouseStatus` field to give users feedback:
+
+```tsx
+import { useAnalyticsQuery } from "@databricks/appkit-ui/react";
+
+function SpendTable() {
+ const { data, loading, error, warehouseStatus } =
+ useAnalyticsQuery("spend_summary", params);
+
+ if (warehouseStatus && warehouseStatus.state !== "RUNNING") {
+ return
Warehouse is {warehouseStatus.state.toLowerCase()}…
;
+ }
+ if (loading) return Loading…
;
+ if (error) return {error}
;
+ return ;
+}
+```
+
+`warehouseStatus` is `null` until the first status event arrives. After the server has observed the warehouse `RUNNING` once, subsequent requests within ~30s skip the readiness check entirely and `warehouseStatus` stays `null`, so the steady-state hot path isn't taxed any extra round-trips.
+
+If the warehouse is `DELETED`/`DELETING` or fails to reach `RUNNING` within the configured timeout, the route emits an `error` event (surfaced via the `error` field).
+
+#### Global readiness indicator
+
+For dashboards with many charts a per-component spinner isn't enough — wiring the same "warehouse warming up" UI into every skeleton is repetitive. AppKit ships a small generic context (`ResourceStatusProvider`) + drop-in indicator (`ResourceStatusIndicator`) that any plugin can publish into; analytics warehouses are wired up automatically.
+
+The indicator surfaces the worst pending status as a [sonner](https://sonner.emilkowal.ski/) toast, so it inherits sonner's animations, theming, and stacking. The component mounts its own ` ` (top-right by default) and forwards its props (`position`, `theme`, `richColors`, …):
+
+```tsx
+import {
+ ResourceStatusIndicator,
+ ResourceStatusProvider,
+} from "@databricks/appkit-ui/react";
+
+export function AppShell({ children }) {
+ return (
+
+
+ {children}
+
+ );
+}
+```
+
+`useAnalyticsQuery` registers itself with the nearest provider, so no per-chart wiring is needed. The indicator renders only the ` ` mount point while every resource is healthy; it pops a single sticky toast — `toast.loading` for cold starts, `toast.error` for unrecoverable states — keyed by the worst kind, and dismisses it when they all settle. Because the same provider is shared across resource kinds (warehouse, lakebase, model serving, …), a single indicator covers every plugin.
+
+If you already render your own ` ` for unrelated app toasts, drop the indicator and call `useResourceStatusToaster()` instead so resource-status toasts share that single Toaster:
+
+```tsx
+import {
+ useResourceStatusToaster,
+ Toaster,
+} from "@databricks/appkit-ui/react";
+
+function App() {
+ useResourceStatusToaster();
+ return (
+ <>
+
+
+ >
+ );
+}
+```
+
+For a fully custom toast body, pass `render` (rendered through `toast.custom`):
+
+```tsx
+ (
+
+ {agg.worst?.kind} {agg.worst?.state.toLowerCase()} ({agg.activeCount} waiting)
+
+ )}
+/>
+```
+
+To override copy for a specific kind without rewriting the whole UI, pass `renderers`:
+
+```tsx
+ "Spinning up your data",
+ description: (_s, agg) =>
+ `${agg.affectedLabels.length} chart(s) waiting`,
+ },
+ }}
+/>
+```
+
+Or build your own UI from the aggregate with `useResourceStatus()`:
+
+```ts
+import { useResourceStatus } from "@databricks/appkit-ui/react";
+
+// Worst across all kinds
+const aggregate = useResourceStatus();
+// Just warehouses
+const warehouseOnly = useResourceStatus({ kind: "warehouse" });
+// { worst, byKind, affectedLabels, activeCount, elapsedMs }
+```
+
+The provider is optional. Apps that don't mount it still get the per-hook `warehouseStatus` field and the hook works exactly as before.
+
+##### Publishing your own resource status
+
+Plugins (or your own code) can hook into the same provider for non-analytics resources — e.g. a Lakebase Postgres connection warming up, a model-serving endpoint cold-starting:
+
+```ts
+import { useResourceStatusPublisher } from "@databricks/appkit-ui/react";
+import { useEffect, useId } from "react";
+
+function useLakebaseReadiness() {
+ const id = useId();
+ const { publish, unpublish } = useResourceStatusPublisher(
+ id,
+ "lakebase",
+ { kindHint: "lakebase" },
+ );
+
+ useEffect(() => {
+ publish({
+ kind: "lakebase",
+ state: "STARTING",
+ severity: "pending",
+ startedAt: Date.now(),
+ });
+ return () => unpublish();
+ }, [publish, unpublish]);
+}
+```
+
+**Server config (in `analytics({...})`):**
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `warehouseStartupTimeoutMs` | `number` | `300000` (5 min) | Maximum time to wait for the warehouse to reach `RUNNING` before failing the request |
+| `autoStartWarehouse` | `boolean` | `true` | When `true`, a `STOPPED` warehouse is auto-started on the first request. Set to `false` for cost-controlled deployments where billable warehouse starts must not be triggered by user requests; in that case `STOPPED` surfaces as a `ConfigurationError` |
+
**Example with loading/error/empty handling:**
```tsx
diff --git a/packages/appkit-ui/src/react/charts/index.ts b/packages/appkit-ui/src/react/charts/index.ts
index 50b874ea8..0f976f953 100644
--- a/packages/appkit-ui/src/react/charts/index.ts
+++ b/packages/appkit-ui/src/react/charts/index.ts
@@ -24,6 +24,7 @@ export {
} from "../hooks/use-chart-data";
export { BaseChart, type BaseChartProps } from "./base";
export { createChart } from "./create-chart";
+export { LoadingSkeleton, ResourceWaitingPlaceholder } from "./loading";
export { ChartWrapper, type ChartWrapperProps } from "./wrapper";
// ============================================================================
diff --git a/packages/appkit-ui/src/react/charts/loading.tsx b/packages/appkit-ui/src/react/charts/loading.tsx
index 3e9846a52..c24e07d31 100644
--- a/packages/appkit-ui/src/react/charts/loading.tsx
+++ b/packages/appkit-ui/src/react/charts/loading.tsx
@@ -1,3 +1,5 @@
+import { Loader2Icon } from "lucide-react";
+
export function LoadingSkeleton({
height = 300,
}: {
@@ -7,3 +9,30 @@ export function LoadingSkeleton({
);
}
+
+/**
+ * Non-shimmery placeholder for cold-starting backing resources. Use this
+ * over {@link LoadingSkeleton} when `useChartData` reports a non-null
+ * `warehouseStatus` — a shimmer during a 30s–2min wait is misleading.
+ * The global {@link ResourceStatusIndicator} surfaces the "why".
+ */
+export function ResourceWaitingPlaceholder({
+ height = 300,
+ message = "Waiting for warehouse…",
+}: {
+ height?: number | string;
+ message?: string;
+}) {
+ return (
+
+
+
+ {message}
+
+
+ );
+}
diff --git a/packages/appkit-ui/src/react/charts/wrapper.tsx b/packages/appkit-ui/src/react/charts/wrapper.tsx
index 2910ff9c8..ed4408add 100644
--- a/packages/appkit-ui/src/react/charts/wrapper.tsx
+++ b/packages/appkit-ui/src/react/charts/wrapper.tsx
@@ -1,12 +1,30 @@
import type { ReactNode } from "react";
+import type { WarehouseStatus } from "../hooks/types";
import { useChartData } from "../hooks/use-chart-data";
import { ChartErrorBoundary } from "./chart-error-boundary";
import { EmptyState } from "./empty";
import { ErrorState } from "./error";
-import { LoadingSkeleton } from "./loading";
+import { LoadingSkeleton, ResourceWaitingPlaceholder } from "./loading";
import type { ChartData, DataFormat } from "./types";
import { isArrowTable } from "./types";
+const WAREHOUSE_ERROR_STATES = new Set(["DELETED", "DELETING"]);
+
+function isWarehouseWaiting(
+ loading: boolean,
+ data: ChartData | null,
+ warehouseStatus: WarehouseStatus | null,
+): boolean {
+ if (!loading || data || !warehouseStatus) return false;
+ if (
+ warehouseStatus.state === "RUNNING" ||
+ WAREHOUSE_ERROR_STATES.has(warehouseStatus.state)
+ ) {
+ return false;
+ }
+ return true;
+}
+
// ============================================================================
// Props Types
// ============================================================================
@@ -65,13 +83,16 @@ function QueryModeContent({
testId,
children,
}: CommonProps & ChartWrapperQueryProps) {
- const { data, loading, error, isEmpty } = useChartData({
+ const { data, loading, error, isEmpty, warehouseStatus } = useChartData({
queryKey,
parameters,
format,
transformer,
});
+ if (isWarehouseWaiting(loading, data, warehouseStatus)) {
+ return ;
+ }
if (loading) return ;
if (error) return ;
if (isEmpty || !data) return ;
diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-analytics-query.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-analytics-query.test.ts
index cfe5d6ce9..b2aed2323 100644
--- a/packages/appkit-ui/src/react/hooks/__tests__/use-analytics-query.test.ts
+++ b/packages/appkit-ui/src/react/hooks/__tests__/use-analytics-query.test.ts
@@ -1,4 +1,4 @@
-import { renderHook } from "@testing-library/react";
+import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
// Mock connectSSE so the hook does not attempt a real network request.
@@ -90,4 +90,117 @@ describe("useAnalyticsQuery", () => {
expect(mockConnectSSE).toHaveBeenCalledTimes(1);
});
+
+ describe("warehouse_status", () => {
+ test("surfaces warehouseStatus while waiting and clears loading on result", async () => {
+ // Capture the connectSSE options so we can drive onMessage manually.
+ let capturedOnMessage:
+ | ((msg: { id: string; data: string }) => void)
+ | null = null;
+ mockConnectSSE.mockImplementationOnce((opts: any) => {
+ capturedOnMessage = opts.onMessage;
+ return new Promise(() => {});
+ });
+
+ const { result } = renderHook(() =>
+ // biome-ignore lint/suspicious/noExplicitAny: typed registry not available in tests
+ useAnalyticsQuery("test_query" as any),
+ );
+
+ // Initially: loading, no status, no data.
+ expect(result.current.loading).toBe(true);
+ expect(result.current.warehouseStatus).toBeNull();
+ expect(result.current.data).toBeNull();
+ expect(capturedOnMessage).toBeTruthy();
+
+ // Server emits a STARTING status — UI should show progress, still loading.
+ act(() => {
+ capturedOnMessage?.({
+ id: "1",
+ data: JSON.stringify({
+ type: "warehouse_status",
+ status: { state: "STARTING", elapsedMs: 1200 },
+ }),
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.warehouseStatus).toEqual({
+ state: "STARTING",
+ elapsedMs: 1200,
+ });
+ });
+ expect(result.current.loading).toBe(true);
+ expect(result.current.data).toBeNull();
+
+ // Then RUNNING.
+ act(() => {
+ capturedOnMessage?.({
+ id: "2",
+ data: JSON.stringify({
+ type: "warehouse_status",
+ status: { state: "RUNNING", elapsedMs: 4500 },
+ }),
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.warehouseStatus?.state).toBe("RUNNING");
+ });
+
+ // Finally the SQL result lands.
+ act(() => {
+ capturedOnMessage?.({
+ id: "3",
+ data: JSON.stringify({
+ type: "result",
+ data: [{ id: 1, name: "row1" }],
+ }),
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+ expect(result.current.data).toEqual([{ id: 1, name: "row1" }]);
+ expect(result.current.error).toBeNull();
+ // warehouseStatus is left at its last observed value (RUNNING) so
+ // consumers that gated on `state !== "RUNNING"` flip back to data.
+ expect(result.current.warehouseStatus?.state).toBe("RUNNING");
+ });
+
+ test("surfaces an error when a warehouse_status event has no status payload", async () => {
+ // A malformed frame must terminate the stream so the hook doesn't
+ // stay stuck in `loading: true` after a clean stream close.
+ let capturedOnMessage:
+ | ((msg: { id: string; data: string }) => void)
+ | null = null;
+ mockConnectSSE.mockImplementationOnce((opts: any) => {
+ capturedOnMessage = opts.onMessage;
+ return new Promise(() => {});
+ });
+
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+
+ const { result } = renderHook(() =>
+ // biome-ignore lint/suspicious/noExplicitAny: typed registry not available in tests
+ useAnalyticsQuery("test_query" as any),
+ );
+
+ act(() => {
+ capturedOnMessage?.({
+ id: "1",
+ data: JSON.stringify({ type: "warehouse_status" }),
+ });
+ });
+
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toMatch(/Unable to load data/);
+ expect(result.current.warehouseStatus).toBeNull();
+
+ consoleError.mockRestore();
+ });
+ });
});
diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-analytics-warehouse-status.test.tsx b/packages/appkit-ui/src/react/hooks/__tests__/use-analytics-warehouse-status.test.tsx
new file mode 100644
index 000000000..103904423
--- /dev/null
+++ b/packages/appkit-ui/src/react/hooks/__tests__/use-analytics-warehouse-status.test.tsx
@@ -0,0 +1,440 @@
+import { act, cleanup, render, waitFor } from "@testing-library/react";
+import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
+
+beforeAll(() => {
+ if (!window.matchMedia) {
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }),
+ });
+ }
+});
+
+const mockConnectSSE = vi.fn().mockImplementation((_opts: unknown) => {
+ return new Promise(() => {});
+});
+
+vi.mock("@/js", () => ({
+ ArrowClient: {
+ fetchArrow: vi.fn(),
+ processArrowBuffer: vi.fn(),
+ },
+ connectSSE: (...args: unknown[]) => mockConnectSSE(...args),
+}));
+
+vi.mock("../use-query-hmr", () => ({
+ useQueryHMR: () => {},
+}));
+
+import { ResourceStatusIndicator } from "../../resource-status-indicator";
+import { useAnalyticsQuery } from "../use-analytics-query";
+import {
+ ResourceStatusProvider,
+ useResourceStatus,
+ useResourceStatusPublisher,
+} from "../use-resource-status";
+
+function queryIndicatorToast(): HTMLElement | null {
+ return document.querySelector("[data-sonner-toast]");
+}
+
+/**
+ * These tests cover the analytics-warehouse adapter end-to-end: the publisher
+ * inside `useAnalyticsQuery` should map `WarehouseStatus` payloads onto the
+ * generic resource-status store with the correct kind, severity, and
+ * `kindHint` registration so kind-filtered consumers see the right shape.
+ *
+ * The generic store itself is exercised in `use-resource-status.test.tsx`;
+ * we don't re-test it here.
+ */
+describe("useAnalyticsQuery + ResourceStatusProvider integration", () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ test("does not show the indicator toast before a real warehouse status arrives", async () => {
+ mockConnectSSE.mockImplementationOnce(() => new Promise(() => {}));
+
+ function Chart() {
+ useAnalyticsQuery("chart_one" as any);
+ return null;
+ }
+
+ render(
+
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(queryIndicatorToast()).toBeNull();
+ });
+ });
+
+ test("does not show the indicator toast when the first warehouse status is RUNNING", async () => {
+ let onMessage: ((msg: { id: string; data: string }) => void) | null = null;
+ mockConnectSSE.mockImplementationOnce((opts: any) => {
+ onMessage = opts.onMessage;
+ return new Promise(() => {});
+ });
+
+ function Chart() {
+ useAnalyticsQuery("chart_one" as any);
+ return null;
+ }
+
+ render(
+
+
+
+ ,
+ );
+
+ act(() => {
+ onMessage?.({
+ id: "1",
+ data: JSON.stringify({
+ type: "warehouse_status",
+ status: { state: "RUNNING", elapsedMs: 0 },
+ }),
+ });
+ });
+
+ await waitFor(() => {
+ expect(queryIndicatorToast()).toBeNull();
+ });
+ });
+
+ test("registers a warehouse-kind slot on mount even before any status arrives", async () => {
+ mockConnectSSE.mockImplementationOnce(() => new Promise(() => {}));
+
+ function Chart() {
+ useAnalyticsQuery("chart_one" as any);
+ return null;
+ }
+
+ function Aggregate() {
+ const agg = useResourceStatus({ kind: "warehouse" });
+ return {agg.activeCount} ;
+ }
+
+ const { getByTestId } = render(
+
+
+
+ ,
+ );
+
+ // The hook publishes `null` on mount via the kindHint, so a kind-filtered
+ // consumer counts the slot before the first SSE event lands.
+ await waitFor(() => {
+ expect(getByTestId("active").textContent).toBe("1");
+ });
+ });
+
+ test("maps STARTING to a pending warehouse status and surfaces it as worst", async () => {
+ let onMessage: ((msg: { id: string; data: string }) => void) | null = null;
+ mockConnectSSE.mockImplementationOnce((opts: any) => {
+ onMessage = opts.onMessage;
+ return new Promise(() => {});
+ });
+
+ function Chart() {
+ useAnalyticsQuery("chart_one" as any);
+ return null;
+ }
+
+ function Aggregate() {
+ const agg = useResourceStatus({ kind: "warehouse" });
+ return (
+ <>
+ {agg.worst?.state ?? "null"}
+ {agg.worst?.severity ?? "null"}
+ {agg.affectedLabels.join(",")}
+ >
+ );
+ }
+
+ const { getByTestId } = render(
+
+
+
+ ,
+ );
+
+ act(() => {
+ onMessage?.({
+ id: "1",
+ data: JSON.stringify({
+ type: "warehouse_status",
+ status: { state: "STARTING", elapsedMs: 1200 },
+ }),
+ });
+ });
+
+ await waitFor(() => {
+ expect(getByTestId("state").textContent).toBe("STARTING");
+ });
+ expect(getByTestId("severity").textContent).toBe("pending");
+ expect(getByTestId("labels").textContent).toBe("chart_one");
+ });
+
+ test("maps DELETED to error severity", async () => {
+ let onMessage: ((msg: { id: string; data: string }) => void) | null = null;
+ mockConnectSSE.mockImplementationOnce((opts: any) => {
+ onMessage = opts.onMessage;
+ return new Promise(() => {});
+ });
+
+ function Chart() {
+ useAnalyticsQuery("chart_one" as any);
+ return null;
+ }
+
+ function Aggregate() {
+ const agg = useResourceStatus({ kind: "warehouse" });
+ return (
+ {agg.worst?.severity ?? "null"}
+ );
+ }
+
+ const { getByTestId } = render(
+
+
+
+ ,
+ );
+
+ act(() => {
+ onMessage?.({
+ id: "1",
+ data: JSON.stringify({
+ type: "warehouse_status",
+ status: { state: "DELETED", elapsedMs: 0 },
+ }),
+ });
+ });
+
+ await waitFor(() => {
+ expect(getByTestId("severity").textContent).toBe("error");
+ });
+ });
+
+ test("clears the slot's status when RUNNING arrives but keeps it registered", async () => {
+ let onMessage: ((msg: { id: string; data: string }) => void) | null = null;
+ mockConnectSSE.mockImplementationOnce((opts: any) => {
+ onMessage = opts.onMessage;
+ return new Promise(() => {});
+ });
+
+ function Chart() {
+ useAnalyticsQuery("chart_one" as any);
+ return null;
+ }
+
+ function Aggregate() {
+ const agg = useResourceStatus({ kind: "warehouse" });
+ return (
+ <>
+ {agg.activeCount}
+ {agg.worst?.state ?? "null"}
+ >
+ );
+ }
+
+ const { getByTestId } = render(
+
+
+
+ ,
+ );
+
+ act(() => {
+ onMessage?.({
+ id: "1",
+ data: JSON.stringify({
+ type: "warehouse_status",
+ status: { state: "STARTING", elapsedMs: 100 },
+ }),
+ });
+ });
+ await waitFor(() => {
+ expect(getByTestId("worst").textContent).toBe("STARTING");
+ });
+
+ act(() => {
+ onMessage?.({
+ id: "2",
+ data: JSON.stringify({
+ type: "warehouse_status",
+ status: { state: "RUNNING", elapsedMs: 4500 },
+ }),
+ });
+ });
+
+ // RUNNING clears the entry's status (no longer the worst) but the slot
+ // stays registered until the query completes or unmounts.
+ await waitFor(() => {
+ expect(getByTestId("worst").textContent).toBe("null");
+ });
+ expect(getByTestId("active").textContent).toBe("1");
+ });
+
+ test("anchors startedAt to the first non-null status so elapsed advances monotonically", async () => {
+ let onMessage: ((msg: { id: string; data: string }) => void) | null = null;
+ mockConnectSSE.mockImplementationOnce((opts: any) => {
+ onMessage = opts.onMessage;
+ return new Promise(() => {});
+ });
+
+ function Chart() {
+ useAnalyticsQuery("chart_one" as any);
+ return null;
+ }
+
+ const seen: number[] = [];
+ function Aggregate() {
+ const agg = useResourceStatus({ kind: "warehouse" });
+ const startedAt = agg.worst?.startedAt;
+ if (startedAt !== undefined && seen[seen.length - 1] !== startedAt) {
+ seen.push(startedAt);
+ }
+ return (
+ {agg.worst?.startedAt ?? "null"}
+ );
+ }
+
+ render(
+
+
+
+ ,
+ );
+
+ act(() => {
+ onMessage?.({
+ id: "1",
+ data: JSON.stringify({
+ type: "warehouse_status",
+ status: { state: "STARTING", elapsedMs: 1000 },
+ }),
+ });
+ });
+ await waitFor(() => expect(seen.length).toBe(1));
+
+ act(() => {
+ onMessage?.({
+ id: "2",
+ data: JSON.stringify({
+ type: "warehouse_status",
+ status: { state: "STARTING", elapsedMs: 4500 },
+ }),
+ });
+ });
+ act(() => {
+ onMessage?.({
+ id: "3",
+ data: JSON.stringify({
+ type: "warehouse_status",
+ status: { state: "STARTING", elapsedMs: 9000 },
+ }),
+ });
+ });
+
+ // All three events share the same anchored startedAt.
+ expect(seen).toHaveLength(1);
+ });
+
+ test("kindHint-only mutation notifies kind-filtered consumers via the version counter", async () => {
+ // A status-less publish (toggling kindHint) bumps `version` so
+ // kind-filtered consumers re-derive even when no aggregate field changes.
+ function Probe() {
+ const agg = useResourceStatus({ kind: "warehouse" });
+ return {agg.version} ;
+ }
+
+ const publisherStore: {
+ current: ((status: null) => void) | null;
+ } = { current: null };
+ function Publisher() {
+ const { publish } = useResourceStatusPublisher("p1", "label", {
+ kindHint: "warehouse",
+ });
+ publisherStore.current = publish as (status: null) => void;
+ return null;
+ }
+
+ const { getByTestId } = render(
+
+
+
+ ,
+ );
+
+ const v0 = Number(getByTestId("version").textContent);
+ act(() => publisherStore.current?.(null));
+ await waitFor(() => {
+ expect(Number(getByTestId("version").textContent)).toBeGreaterThan(v0);
+ });
+ });
+
+ test("unmounts release the slot from the kind-filtered aggregate", async () => {
+ let onMessage: ((msg: { id: string; data: string }) => void) | null = null;
+ mockConnectSSE.mockImplementationOnce((opts: any) => {
+ onMessage = opts.onMessage;
+ return new Promise(() => {});
+ });
+
+ function Chart() {
+ useAnalyticsQuery("chart_one" as any);
+ return null;
+ }
+
+ function Aggregate() {
+ const agg = useResourceStatus({ kind: "warehouse" });
+ return {agg.activeCount} ;
+ }
+
+ function App({ showChart }: { showChart: boolean }) {
+ return (
+
+ {showChart ? : null}
+
+
+ );
+ }
+
+ const { rerender, getByTestId } = render( );
+
+ act(() => {
+ onMessage?.({
+ id: "1",
+ data: JSON.stringify({
+ type: "warehouse_status",
+ status: { state: "STARTING", elapsedMs: 100 },
+ }),
+ });
+ });
+
+ await waitFor(() => {
+ expect(getByTestId("active").textContent).toBe("1");
+ });
+
+ rerender( );
+
+ await waitFor(() => {
+ expect(getByTestId("active").textContent).toBe("0");
+ });
+ });
+});
diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-resource-status.test.tsx b/packages/appkit-ui/src/react/hooks/__tests__/use-resource-status.test.tsx
new file mode 100644
index 000000000..a70ad8fa4
--- /dev/null
+++ b/packages/appkit-ui/src/react/hooks/__tests__/use-resource-status.test.tsx
@@ -0,0 +1,640 @@
+import {
+ act,
+ cleanup,
+ render,
+ renderHook,
+ waitFor,
+} from "@testing-library/react";
+import { toast } from "sonner";
+import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
+
+import { ResourceStatusIndicator } from "../../resource-status-indicator";
+
+// JSDOM doesn't implement window.matchMedia, which sonner reads on mount.
+beforeAll(() => {
+ if (!window.matchMedia) {
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }),
+ });
+ }
+});
+
+import {
+ type ResourceStatus,
+ ResourceStatusProvider,
+ useResourceStatus,
+ useResourceStatusPublisher,
+} from "../use-resource-status";
+
+afterEach(() => {
+ cleanup();
+ // Sonner's toast store is module-level; flush between tests.
+ toast.dismiss();
+});
+
+/** Find the (single) indicator toast in the document, or null. */
+function queryIndicatorToast(): HTMLElement | null {
+ return document.querySelector("[data-sonner-toast]");
+}
+
+/** Wait until exactly one indicator toast is mounted and return it. */
+async function findIndicatorToast(): Promise {
+ return waitFor(() => {
+ const node = queryIndicatorToast();
+ if (!node) throw new Error("indicator toast not mounted yet");
+ return node;
+ });
+}
+
+function makeStatus(
+ overrides: Partial & Pick,
+): ResourceStatus {
+ return {
+ severity: "pending",
+ startedAt: Date.now(),
+ ...overrides,
+ };
+}
+
+describe("useResourceStatus / useResourceStatusPublisher", () => {
+ test("returns the empty/idle aggregate when no provider is mounted", () => {
+ const { result } = renderHook(() => useResourceStatus());
+ expect(result.current).toEqual({
+ worst: null,
+ byKind: {},
+ affectedLabels: [],
+ activeCount: 0,
+ elapsedMs: 0,
+ version: 0,
+ });
+ });
+
+ test("aggregates statuses across kinds, picking the worst by severity", async () => {
+ function Publishers() {
+ const { publish: pubA } = useResourceStatusPublisher("a", "lakebase-a");
+ const { publish: pubB } = useResourceStatusPublisher("b", "warehouse-b");
+ const aggregate = useResourceStatus();
+ return (
+
+
+ {aggregate.worst?.kind ?? "none"}
+
+
+ {aggregate.worst?.state ?? "none"}
+
+ {aggregate.activeCount}
+ {aggregate.affectedLabels.join(",")}
+ {
+ pubA(
+ makeStatus({
+ kind: "lakebase",
+ state: "STARTING",
+ severity: "pending",
+ }),
+ );
+ }}
+ />
+ {
+ pubB(
+ makeStatus({
+ kind: "warehouse",
+ state: "DELETED",
+ severity: "error",
+ }),
+ );
+ }}
+ />
+
+ );
+ }
+
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ // a: lakebase pending
+ act(() => {
+ getByTestId("pub-pending-a").click();
+ });
+ await waitFor(() => {
+ expect(getByTestId("worst-kind").textContent).toBe("lakebase");
+ });
+ expect(getByTestId("worst-state").textContent).toBe("STARTING");
+ expect(getByTestId("active").textContent).toBe("1");
+ expect(getByTestId("labels").textContent).toBe("lakebase-a");
+
+ // b: warehouse error → outranks pending
+ act(() => {
+ getByTestId("pub-error-b").click();
+ });
+ await waitFor(() => {
+ expect(getByTestId("worst-kind").textContent).toBe("warehouse");
+ });
+ expect(getByTestId("worst-state").textContent).toBe("DELETED");
+ expect(getByTestId("active").textContent).toBe("2");
+ expect(getByTestId("labels").textContent).toBe("lakebase-a,warehouse-b");
+ });
+
+ test("filters the aggregate to a single kind", async () => {
+ function Publishers() {
+ const { publish: pubA } = useResourceStatusPublisher("a", "lakebase-a");
+ const { publish: pubB } = useResourceStatusPublisher("b", "warehouse-b");
+ const warehouseAgg = useResourceStatus({ kind: "warehouse" });
+ return (
+
+
+ {warehouseAgg.worst?.state ?? "none"}
+
+ {warehouseAgg.activeCount}
+ {
+ pubA(
+ makeStatus({
+ kind: "lakebase",
+ state: "STARTING",
+ severity: "pending",
+ }),
+ );
+ }}
+ />
+ {
+ pubB(
+ makeStatus({
+ kind: "warehouse",
+ state: "STOPPED",
+ severity: "pending",
+ }),
+ );
+ }}
+ />
+
+ );
+ }
+
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ // Lakebase publishes — warehouse-scoped aggregate stays empty.
+ act(() => {
+ getByTestId("pub-lakebase").click();
+ });
+ await waitFor(() => {
+ expect(getByTestId("warehouse-state").textContent).toBe("none");
+ });
+
+ // Warehouse publishes — warehouse-scoped aggregate lights up.
+ act(() => {
+ getByTestId("pub-warehouse").click();
+ });
+ await waitFor(() => {
+ expect(getByTestId("warehouse-state").textContent).toBe("STOPPED");
+ });
+ expect(getByTestId("warehouse-active").textContent).toBe("1");
+ });
+
+ test("kindHint keeps null-status slots associated with their kind", async () => {
+ function Publisher() {
+ const { publish } = useResourceStatusPublisher("a", "x", {
+ kindHint: "lakebase",
+ });
+ const lakebase = useResourceStatus({ kind: "lakebase" });
+ return (
+
+ {lakebase.activeCount}
+
+ {lakebase.worst?.state ?? "none"}
+
+ {
+ publish(null);
+ }}
+ />
+
+ );
+ }
+
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ act(() => {
+ getByTestId("register").click();
+ });
+ await waitFor(() => {
+ expect(getByTestId("lakebase-active").textContent).toBe("1");
+ expect(getByTestId("lakebase-state").textContent).toBe("none");
+ });
+ });
+
+ test("unpublish removes the entry from the aggregate", async () => {
+ function App() {
+ const { publish, unpublish } = useResourceStatusPublisher("a", "x");
+ const aggregate = useResourceStatus();
+ return (
+
+ {aggregate.activeCount}
+ {aggregate.worst?.state ?? "none"}
+ {
+ publish(
+ makeStatus({
+ kind: "warehouse",
+ state: "STOPPED",
+ severity: "pending",
+ }),
+ );
+ }}
+ />
+ {
+ unpublish();
+ }}
+ />
+
+ );
+ }
+
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ act(() => {
+ getByTestId("pub").click();
+ });
+ await waitFor(() => {
+ expect(getByTestId("state").textContent).toBe("STOPPED");
+ });
+
+ act(() => {
+ getByTestId("unpub").click();
+ });
+ await waitFor(() => {
+ expect(getByTestId("state").textContent).toBe("none");
+ });
+ expect(getByTestId("active").textContent).toBe("0");
+ });
+});
+
+describe("ResourceStatusIndicator", () => {
+ test("mounts no toast when the aggregate is empty", () => {
+ render(
+
+
+ ,
+ );
+ expect(queryIndicatorToast()).toBeNull();
+ });
+
+ test("renders kind-specific copy for known kinds (warehouse)", async () => {
+ function Trigger() {
+ const { publish } = useResourceStatusPublisher("a", "my_chart");
+ return (
+ {
+ publish(
+ makeStatus({
+ kind: "warehouse",
+ state: "STARTING",
+ severity: "pending",
+ }),
+ );
+ }}
+ />
+ );
+ }
+
+ const { getByTestId } = render(
+
+
+
+ ,
+ );
+
+ act(() => {
+ getByTestId("pub").click();
+ });
+
+ const node = await findIndicatorToast();
+ expect(node.getAttribute("data-type")).toBe("loading");
+ expect(node.textContent).toMatch(/warming up|warehouse/i);
+ });
+
+ test("falls back to a generic message for unknown kinds", async () => {
+ function Trigger() {
+ const { publish } = useResourceStatusPublisher("a", "my_thing");
+ return (
+ {
+ publish(
+ makeStatus({
+ kind: "model-endpoint",
+ state: "COLD_START",
+ severity: "pending",
+ }),
+ );
+ }}
+ />
+ );
+ }
+
+ const { getByTestId } = render(
+
+
+
+ ,
+ );
+
+ act(() => {
+ getByTestId("pub").click();
+ });
+
+ const node = await findIndicatorToast();
+ // Humanized kind name (Model Endpoint) appears in the title.
+ expect(node.textContent).toMatch(/Model Endpoint/i);
+ });
+
+ test("uses the error treatment for error severity", async () => {
+ function Trigger() {
+ const { publish } = useResourceStatusPublisher("a", "my_thing");
+ return (
+ {
+ publish(
+ makeStatus({
+ kind: "warehouse",
+ state: "DELETED",
+ severity: "error",
+ summary: "It is gone",
+ }),
+ );
+ }}
+ />
+ );
+ }
+
+ const { getByTestId } = render(
+
+
+
+ ,
+ );
+
+ act(() => {
+ getByTestId("pub").click();
+ });
+
+ const node = await findIndicatorToast();
+ expect(node.getAttribute("data-type")).toBe("error");
+ expect(node.textContent).toMatch(/unavailable|It is gone/i);
+ });
+
+ test("morphs from loading to error when severity flips within a kind", async () => {
+ function Trigger() {
+ const { publish } = useResourceStatusPublisher("a", "my_chart");
+ return (
+
+ {
+ publish(
+ makeStatus({
+ kind: "warehouse",
+ state: "STARTING",
+ severity: "pending",
+ }),
+ );
+ }}
+ />
+ {
+ publish(
+ makeStatus({
+ kind: "warehouse",
+ state: "DELETED",
+ severity: "error",
+ summary: "Gone",
+ }),
+ );
+ }}
+ />
+
+ );
+ }
+
+ const { getByTestId } = render(
+
+
+
+ ,
+ );
+
+ act(() => {
+ getByTestId("pub-pending").click();
+ });
+ const loading = await findIndicatorToast();
+ expect(loading.getAttribute("data-type")).toBe("loading");
+
+ act(() => {
+ getByTestId("pub-error").click();
+ });
+ await waitFor(() => {
+ expect(queryIndicatorToast()?.getAttribute("data-type")).toBe("error");
+ });
+ expect(queryIndicatorToast()?.textContent).toMatch(/unavailable|Gone/i);
+ });
+
+ test("kind prop scopes to a single kind", async () => {
+ function Trigger() {
+ const { publish: pubLake } = useResourceStatusPublisher("a", "x");
+ const { publish: pubWh } = useResourceStatusPublisher("b", "y");
+ return (
+
+ {
+ pubLake(
+ makeStatus({
+ kind: "lakebase",
+ state: "STARTING",
+ severity: "pending",
+ }),
+ );
+ }}
+ />
+ {
+ pubWh(
+ makeStatus({
+ kind: "warehouse",
+ state: "STARTING",
+ severity: "pending",
+ }),
+ );
+ }}
+ />
+
+ );
+ }
+
+ const { getByTestId } = render(
+
+
+
+ ,
+ );
+
+ // Lakebase pending — warehouse-scoped indicator stays silent.
+ act(() => {
+ getByTestId("pub-lake").click();
+ });
+ expect(queryIndicatorToast()).toBeNull();
+
+ // Warehouse pending — toast appears.
+ act(() => {
+ getByTestId("pub-wh").click();
+ });
+ const node = await findIndicatorToast();
+ expect(node.textContent).toMatch(/warehouse|warming/i);
+ });
+
+ test("supports a full custom render override", async () => {
+ function Trigger() {
+ const { publish } = useResourceStatusPublisher("a", "x");
+ return (
+ {
+ publish(
+ makeStatus({
+ kind: "warehouse",
+ state: "STARTING",
+ severity: "pending",
+ }),
+ );
+ }}
+ />
+ );
+ }
+
+ const { getByTestId, findByTestId } = render(
+
+
+ (
+
+ {agg.worst?.kind}:{agg.worst?.state}:{agg.activeCount}
+
+ )}
+ />
+ ,
+ );
+
+ act(() => {
+ getByTestId("pub").click();
+ });
+ const custom = await findByTestId("custom");
+ expect(custom.textContent).toBe("warehouse:STARTING:1");
+ });
+
+ test("ticks the elapsed counter at ~1Hz while a wait is active", async () => {
+ // Pin Date.now without fake timers — fake setInterval conflicts with
+ // sonner's rAF/setTimeout-driven mount lifecycle. The indicator's
+ // real setInterval re-issues toast updates against the moving clock.
+ const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000_000);
+
+ function Trigger() {
+ const { publish } = useResourceStatusPublisher("a", "my_chart");
+ return (
+ {
+ publish({
+ kind: "warehouse",
+ state: "STARTING",
+ severity: "pending",
+ startedAt: Date.now(),
+ });
+ }}
+ />
+ );
+ }
+
+ try {
+ const { getByTestId } = render(
+
+
+
+ ,
+ );
+
+ act(() => {
+ getByTestId("pub").click();
+ });
+
+ const initial = await findIndicatorToast();
+ expect(initial.textContent).toMatch(/0s/);
+
+ // Advance 3.5s; the indicator's ~1Hz tick re-issues toast.loading.
+ dateNowSpy.mockReturnValue(1_000_000 + 3_500);
+
+ await waitFor(
+ () => {
+ expect(queryIndicatorToast()?.textContent).toMatch(/3s/);
+ },
+ { timeout: 2_000 },
+ );
+ } finally {
+ dateNowSpy.mockRestore();
+ }
+ });
+});
diff --git a/packages/appkit-ui/src/react/hooks/index.ts b/packages/appkit-ui/src/react/hooks/index.ts
index e272616d2..63b639761 100644
--- a/packages/appkit-ui/src/react/hooks/index.ts
+++ b/packages/appkit-ui/src/react/hooks/index.ts
@@ -1,3 +1,10 @@
+export {
+ type ResourceKindRenderer,
+ ResourceStatusIndicator,
+ type ResourceStatusIndicatorProps,
+ type ResourceStatusToasterOptions,
+ useResourceStatusToaster,
+} from "../resource-status-indicator";
export type {
AnalyticsFormat,
InferResultByFormat,
@@ -12,6 +19,8 @@ export type {
TypedArrowTable,
UseAnalyticsQueryOptions,
UseAnalyticsQueryResult,
+ WarehouseState,
+ WarehouseStatus,
} from "./types";
export {
type AgentChatEvent,
@@ -27,6 +36,16 @@ export {
} from "./use-chart-data";
export { useIsMobile } from "./use-mobile";
export { usePluginClientConfig } from "./use-plugin-config";
+export {
+ type AggregatedResourceStatus,
+ type ResourceSeverity,
+ type ResourceStatus,
+ type ResourceStatusFilter,
+ ResourceStatusProvider,
+ type ResourceStatusProviderProps,
+ useResourceStatus,
+ useResourceStatusPublisher,
+} from "./use-resource-status";
export {
type UseServingInvokeOptions,
type UseServingInvokeResult,
diff --git a/packages/appkit-ui/src/react/hooks/types.ts b/packages/appkit-ui/src/react/hooks/types.ts
index 60fc5f63b..8d96b6174 100644
--- a/packages/appkit-ui/src/react/hooks/types.ts
+++ b/packages/appkit-ui/src/react/hooks/types.ts
@@ -57,6 +57,35 @@ export interface UseAnalyticsQueryOptions<
autoStart?: boolean;
}
+/**
+ * SQL warehouse lifecycle state surfaced by `useAnalyticsQuery`.
+ * Mirrors the states emitted by the analytics plugin (which mirror the
+ * Databricks SQL SDK `sql.State`).
+ */
+export type WarehouseState =
+ | "RUNNING"
+ | "STARTING"
+ | "STOPPED"
+ | "STOPPING"
+ | "DELETED"
+ | "DELETING";
+
+/**
+ * Snapshot of warehouse readiness streamed by the analytics route before the
+ * SQL result. Useful for rendering "warehouse starting…" affordances during
+ * cold starts instead of a frozen spinner.
+ *
+ * Note: the SDK's `health.summary` is intentionally NOT included on the wire
+ * — it's free-form operator-oriented diagnostic text (cluster IDs, capacity
+ * reasons, internal RPC errors) that must not reach end users; it stays in
+ * server-side telemetry only.
+ */
+export interface WarehouseStatus {
+ state: WarehouseState;
+ /** Milliseconds elapsed since the route began waiting for the warehouse. */
+ elapsedMs: number;
+}
+
/** Result state returned by useAnalyticsQuery */
export interface UseAnalyticsQueryResult {
/** Latest query result data */
@@ -65,6 +94,12 @@ export interface UseAnalyticsQueryResult {
loading: boolean;
/** Error state of the query */
error: string | null;
+ /**
+ * Latest warehouse status emitted by the server while waiting for the SQL
+ * warehouse to reach RUNNING. `null` until the first status event arrives;
+ * remains `null` for cache hits where the server skips the readiness check.
+ */
+ warehouseStatus: WarehouseStatus | null;
}
/**
diff --git a/packages/appkit-ui/src/react/hooks/use-analytics-query.ts b/packages/appkit-ui/src/react/hooks/use-analytics-query.ts
index 05a32ee02..de914a77c 100644
--- a/packages/appkit-ui/src/react/hooks/use-analytics-query.ts
+++ b/packages/appkit-ui/src/react/hooks/use-analytics-query.ts
@@ -1,4 +1,11 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ useCallback,
+ useEffect,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import { ArrowClient, connectSSE } from "@/js";
import type {
AnalyticsFormat,
@@ -7,17 +14,12 @@ import type {
QueryKey,
UseAnalyticsQueryOptions,
UseAnalyticsQueryResult,
+ WarehouseStatus,
} from "./types";
+import { useAnalyticsWarehousePublisher } from "./use-analytics-warehouse-status";
import { useQueryHMR } from "./use-query-hmr";
-/**
- * Shallow structural equality for analytics query parameter objects.
- *
- * Analytics query parameters are produced by the `sql.*` builders and are
- * always plain objects keyed to primitive values (string | number | boolean
- * | null | undefined), so shallow equality is sufficient and substantially
- * cheaper than a full deep-equal.
- */
+/** Shallow equality for plain-object query parameters (primitive values only). */
function shallowEqualParams(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true;
if (
@@ -45,12 +47,7 @@ function shallowEqualParams(a: unknown, b: unknown): boolean {
return true;
}
-/**
- * Stabilize a value's identity across renders when it is structurally equal
- * to the previous value. Used to make object-literal parameters safe to pass
- * directly to `useAnalyticsQuery` without forcing every consumer to wrap
- * params in `useMemo`.
- */
+/** Keep structurally-equal params referentially stable across renders. */
function useStableParams(value: T): T {
const ref = useRef(value);
if (!shallowEqualParams(ref.current, value)) {
@@ -59,18 +56,95 @@ function useStableParams(value: T): T {
return ref.current;
}
-function getDevMode() {
- const url = new URL(window.location.href);
- const searchParams = url.searchParams;
- const dev = searchParams.get("dev");
-
+function getDevMode(): string {
+ const dev = new URL(window.location.href).searchParams.get("dev");
return dev ? `?dev=${dev}` : "";
}
-function getArrowStreamUrl(id: string) {
+function getArrowStreamUrl(id: string): string {
return `/api/analytics/arrow-result/${id}`;
}
+const GENERIC_LOAD_ERROR = "Unable to load data, please try again";
+
+interface AnalyticsQuerySseContext {
+ setLoading: (loading: boolean) => void;
+ setError: (error: string | null) => void;
+ setData: (data: ResultType | null) => void;
+ setWarehouseStatus: (status: WarehouseStatus | null) => void;
+ publishWarehouseStatus: (status: WarehouseStatus | null) => void;
+ unpublishWarehouseStatus: () => void;
+}
+
+function isWarehouseStatusPayload(value: unknown): value is WarehouseStatus {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ typeof (value as WarehouseStatus).state === "string"
+ );
+}
+
+async function handleAnalyticsSseMessage(
+ parsed: Record,
+ ctx: AnalyticsQuerySseContext,
+): Promise {
+ if (parsed.type === "warehouse_status") {
+ if (!isWarehouseStatusPayload(parsed.status)) {
+ ctx.setLoading(false);
+ ctx.setError(GENERIC_LOAD_ERROR);
+ ctx.unpublishWarehouseStatus();
+ console.error(
+ "[useAnalyticsQuery] Malformed warehouse_status event",
+ parsed,
+ );
+ return;
+ }
+ ctx.setWarehouseStatus(parsed.status);
+ ctx.publishWarehouseStatus(parsed.status);
+ return;
+ }
+
+ if (parsed.type === "result") {
+ ctx.setLoading(false);
+ ctx.setData(parsed.data as ResultType);
+ ctx.unpublishWarehouseStatus();
+ return;
+ }
+
+ if (parsed.type === "arrow") {
+ try {
+ const arrowData = await ArrowClient.fetchArrow(
+ getArrowStreamUrl(parsed.statement_id as string),
+ );
+ const table = await ArrowClient.processArrowBuffer(arrowData);
+ ctx.setLoading(false);
+ ctx.setData(table as ResultType);
+ ctx.unpublishWarehouseStatus();
+ } catch (error) {
+ console.error("[useAnalyticsQuery] Failed to fetch Arrow data", error);
+ ctx.setLoading(false);
+ ctx.setError(GENERIC_LOAD_ERROR);
+ ctx.unpublishWarehouseStatus();
+ }
+ return;
+ }
+
+ if (parsed.type === "error" || parsed.error || parsed.code) {
+ const errorMsg =
+ (parsed.error as string | undefined) ||
+ (parsed.message as string | undefined) ||
+ "Unable to execute query";
+ ctx.setLoading(false);
+ ctx.setError(errorMsg);
+ ctx.unpublishWarehouseStatus();
+ if (parsed.code) {
+ console.error(
+ `[useAnalyticsQuery] Code: ${parsed.code}, Message: ${errorMsg}`,
+ );
+ }
+ }
+}
+
/**
* Subscribe to an analytics query over SSE and returns its latest result.
* Integration hook between client and analytics plugin.
@@ -120,18 +194,22 @@ export function useAnalyticsQuery<
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
+ const [warehouseStatus, setWarehouseStatus] =
+ useState(null);
const abortControllerRef = useRef(null);
+ const publisherId = useId();
+ const {
+ publish: publishWarehouseStatus,
+ unpublish: unpublishWarehouseStatus,
+ } = useAnalyticsWarehousePublisher(publisherId, queryKey);
+
if (!queryKey || queryKey.trim().length === 0) {
throw new Error(
"useAnalyticsQuery: 'queryKey' must be a non-empty string.",
);
}
- // Stabilize the parameters reference across renders. Without this, a fresh
- // object literal at the call site (e.g. `useAnalyticsQuery("k", { limit: 10 })`)
- // would change identity every render, invalidating the `payload` memo and
- // re-running `start` -> infinite refetch loop.
const stableParameters = useStableParams(parameters);
const payload = useMemo(() => {
@@ -160,70 +238,34 @@ export function useAnalyticsQuery<
return;
}
- // Abort previous request if exists
- if (abortControllerRef.current) {
- abortControllerRef.current.abort();
- }
+ abortControllerRef.current?.abort();
setLoading(true);
setError(null);
setData(null);
+ setWarehouseStatus(null);
+ publishWarehouseStatus(null);
const abortController = new AbortController();
abortControllerRef.current = abortController;
+ const sseContext: AnalyticsQuerySseContext = {
+ setLoading,
+ setError,
+ setData,
+ setWarehouseStatus,
+ publishWarehouseStatus,
+ unpublishWarehouseStatus,
+ };
+
connectSSE({
url: urlSuffix,
- payload: payload,
+ payload,
signal: abortController.signal,
onMessage: async (message) => {
try {
- const parsed = JSON.parse(message.data);
-
- // success - JSON format
- if (parsed.type === "result") {
- setLoading(false);
- setData(parsed.data as ResultType);
- return;
- }
-
- // success - Arrow format
- if (parsed.type === "arrow") {
- try {
- const arrowData = await ArrowClient.fetchArrow(
- getArrowStreamUrl(parsed.statement_id),
- );
- const table = await ArrowClient.processArrowBuffer(arrowData);
- setLoading(false);
- // Table is cast to TypedArrowTable with row type from QueryRegistry
- setData(table as ResultType);
- return;
- } catch (error) {
- console.error(
- "[useAnalyticsQuery] Failed to fetch Arrow data",
- error,
- );
- setLoading(false);
- setError("Unable to load data, please try again");
- return;
- }
- }
-
- // error
- if (parsed.type === "error" || parsed.error || parsed.code) {
- const errorMsg =
- parsed.error || parsed.message || "Unable to execute query";
-
- setLoading(false);
- setError(errorMsg);
-
- if (parsed.code) {
- console.error(
- `[useAnalyticsQuery] Code: ${parsed.code}, Message: ${errorMsg}`,
- );
- }
- return;
- }
+ const parsed = JSON.parse(message.data) as Record;
+ await handleAnalyticsSseMessage(parsed, sseContext);
} catch (error) {
console.warn("[useAnalyticsQuery] Malformed message received", error);
}
@@ -231,16 +273,15 @@ export function useAnalyticsQuery<
onError: (error) => {
if (abortController.signal.aborted) return;
setLoading(false);
+ unpublishWarehouseStatus();
- let userMessage = "Unable to load data, please try again";
-
+ let userMessage = GENERIC_LOAD_ERROR;
if (error instanceof Error) {
if (error.name === "AbortError") {
userMessage = "Request timed out, please try again";
} else if (error.message.includes("Failed to fetch")) {
userMessage = "Network error. Please check your connection.";
}
-
console.error("[useAnalyticsQuery] Error", {
queryKey,
error: error.message,
@@ -250,7 +291,13 @@ export function useAnalyticsQuery<
setError(userMessage);
},
});
- }, [queryKey, payload, urlSuffix]);
+ }, [
+ queryKey,
+ payload,
+ urlSuffix,
+ publishWarehouseStatus,
+ unpublishWarehouseStatus,
+ ]);
useEffect(() => {
if (autoStart) {
@@ -259,11 +306,11 @@ export function useAnalyticsQuery<
return () => {
abortControllerRef.current?.abort();
+ unpublishWarehouseStatus();
};
- }, [start, autoStart]);
+ }, [start, autoStart, unpublishWarehouseStatus]);
- // Enable HMR for query updates in dev mode
useQueryHMR(queryKey, start);
- return { data, loading, error };
+ return { data, loading, error, warehouseStatus };
}
diff --git a/packages/appkit-ui/src/react/hooks/use-analytics-warehouse-status.ts b/packages/appkit-ui/src/react/hooks/use-analytics-warehouse-status.ts
new file mode 100644
index 000000000..a45559f9f
--- /dev/null
+++ b/packages/appkit-ui/src/react/hooks/use-analytics-warehouse-status.ts
@@ -0,0 +1,74 @@
+import { useCallback, useRef } from "react";
+import type { WarehouseStatus } from "./types";
+import {
+ type ResourceSeverity,
+ useResourceStatusPublisher,
+} from "./use-resource-status";
+
+const ANALYTICS_WAREHOUSE_RESOURCE_KIND = "warehouse";
+
+/**
+ * - `RUNNING` → `null`; callers `unpublish` instead.
+ * - `STARTING` / `STOPPED` / `STOPPING` → `pending`.
+ * - `DELETED` / `DELETING` → `error` (config change required).
+ */
+function severityForWarehouseState(
+ state: WarehouseStatus["state"],
+): ResourceSeverity | null {
+ switch (state) {
+ case "RUNNING":
+ return null;
+ case "DELETED":
+ case "DELETING":
+ return "error";
+ default:
+ return "pending";
+ }
+}
+
+/**
+ * Internal hook used by `useAnalyticsQuery` to mirror its current warehouse
+ * status into the nearest provider. No-op when no provider is mounted.
+ */
+export function useAnalyticsWarehousePublisher(
+ id: string,
+ queryKey: string,
+): {
+ publish: (status: WarehouseStatus | null) => void;
+ unpublish: () => void;
+} {
+ const { publish: publishGeneric, unpublish } = useResourceStatusPublisher(
+ id,
+ queryKey,
+ { kindHint: ANALYTICS_WAREHOUSE_RESOURCE_KIND },
+ );
+
+ // Anchor `startedAt` to the first non-null status so `elapsedMs`
+ // advances monotonically across successive `warehouse_status` events.
+ const startedAtRef = useRef(null);
+
+ const publish = useCallback(
+ (status: WarehouseStatus | null) => {
+ // null covers "register with no status yet" *and* RUNNING — both
+ // keep the slot registered without contributing to the aggregate.
+ const severity = status && severityForWarehouseState(status.state);
+ if (!status || !severity) {
+ startedAtRef.current = null;
+ publishGeneric(null);
+ return;
+ }
+ if (startedAtRef.current === null) {
+ startedAtRef.current = Date.now() - Math.max(0, status.elapsedMs);
+ }
+ publishGeneric({
+ kind: ANALYTICS_WAREHOUSE_RESOURCE_KIND,
+ state: status.state,
+ severity,
+ startedAt: startedAtRef.current,
+ });
+ },
+ [publishGeneric],
+ );
+
+ return { publish, unpublish };
+}
diff --git a/packages/appkit-ui/src/react/hooks/use-chart-data.ts b/packages/appkit-ui/src/react/hooks/use-chart-data.ts
index a90481a2e..9d858d9b9 100644
--- a/packages/appkit-ui/src/react/hooks/use-chart-data.ts
+++ b/packages/appkit-ui/src/react/hooks/use-chart-data.ts
@@ -1,6 +1,7 @@
import type { Table } from "apache-arrow";
import { useMemo } from "react";
import type { ChartData, DataFormat } from "../charts/types";
+import type { WarehouseStatus } from "./types";
import { useAnalyticsQuery } from "./use-analytics-query";
/** Threshold for auto-selecting Arrow format (row count hint) */
@@ -38,6 +39,13 @@ export interface UseChartDataResult {
error: string | null;
/** Whether the data is empty */
isEmpty: boolean;
+ /**
+ * Latest warehouse readiness status from SSE. Retains the last value
+ * (including `RUNNING`) until the next `start()`; `null` only before
+ * the first event. Use with `loading` to distinguish warehouse warm-up
+ * from in-flight SQL fetch.
+ */
+ warehouseStatus: WarehouseStatus | null;
}
// ============================================================================
@@ -117,6 +125,7 @@ export function useChartData(options: UseChartDataOptions): UseChartDataResult {
data: rawData,
loading,
error,
+ warehouseStatus,
} = useAnalyticsQuery(queryKey, parameters, {
autoStart: true,
format: resolvedFormat,
@@ -179,5 +188,6 @@ export function useChartData(options: UseChartDataOptions): UseChartDataResult {
loading,
error,
isEmpty,
+ warehouseStatus,
};
}
diff --git a/packages/appkit-ui/src/react/hooks/use-resource-status.tsx b/packages/appkit-ui/src/react/hooks/use-resource-status.tsx
new file mode 100644
index 000000000..5f8514c95
--- /dev/null
+++ b/packages/appkit-ui/src/react/hooks/use-resource-status.tsx
@@ -0,0 +1,325 @@
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useSyncExternalStore,
+} from "react";
+
+const NOOP_SUBSCRIBE: (listener: () => void) => () => void = () => () => {};
+
+/**
+ * Cross-kind severity, ordered worst-first (`error > warning > pending`).
+ * Callers `unpublish` rather than publishing a status for ready resources.
+ */
+export type ResourceSeverity = "pending" | "warning" | "error";
+
+/**
+ * Readiness snapshot for a single resource (SQL warehouse, Lakebase
+ * connection, model-serving endpoint, …). Plugins publish these while a
+ * user-visible cold start / warm-up / unavailability is in flight.
+ */
+export interface ResourceStatus {
+ /** Resource family, conventionally lowercase-kebab (`"warehouse"`, `"lakebase"`). */
+ kind: string;
+ /** Resource-specific raw state, e.g. `"STARTING"`, `"DELETED"`. Opaque to the aggregator. */
+ state: string;
+ severity: ResourceSeverity;
+ /** Human-readable summary forwarded to the indicator UI. */
+ summary?: string;
+ /** Epoch ms when the publisher started waiting; drives `elapsedMs`. */
+ startedAt: number;
+}
+
+/** Aggregate view of every active publisher; returned by {@link useResourceStatus}. */
+export interface AggregatedResourceStatus {
+ /** Highest-severity status across all publishers, or `null` when nothing is pending. */
+ worst: ResourceStatus | null;
+ /** Worst status per `kind`. */
+ byKind: Record;
+ /** De-duped, sorted labels of every publisher with a non-null status. */
+ affectedLabels: string[];
+ /** Total registered publishers (including those whose status is `null`). */
+ activeCount: number;
+ /** Milliseconds since the worst entry's `startedAt`; `0` when nothing is pending. */
+ elapsedMs: number;
+ /** Monotonic counter bumped on every `publish`/`unpublish`. */
+ version: number;
+}
+
+/** Optional filter for {@link useResourceStatus}. */
+export interface ResourceStatusFilter {
+ /** Restrict the aggregate to a single resource kind. */
+ kind?: string;
+}
+
+const SEVERITY_RANK: Record = {
+ error: 0,
+ warning: 1,
+ pending: 2,
+};
+
+/**
+ * Internal registry record. `kindHint` keeps status-less slots associated
+ * with their kind so kind-scoped views can count "registered but not yet
+ * reporting" publishers (e.g. analytics charts before the first SSE event).
+ */
+interface RegistryEntry {
+ label: string;
+ status: ResourceStatus | null;
+ kindHint?: string;
+}
+
+const EMPTY_SNAPSHOT: AggregatedResourceStatus = {
+ worst: null,
+ byKind: {},
+ affectedLabels: [],
+ activeCount: 0,
+ elapsedMs: 0,
+ version: 0,
+};
+
+const GET_EMPTY_SNAPSHOT = (): AggregatedResourceStatus => EMPTY_SNAPSHOT;
+
+/** Flat per-publisher map exposed to React via `useSyncExternalStore`. */
+class ResourceStatusStore {
+ private entries = new Map();
+ private listeners = new Set<() => void>();
+ private snapshot: AggregatedResourceStatus = EMPTY_SNAPSHOT;
+ private version = 0;
+
+ publish(
+ id: string,
+ label: string,
+ status: ResourceStatus | null,
+ kindHint?: string,
+ ): void {
+ this.entries.set(id, { label, status, kindHint });
+ this.bump();
+ }
+
+ unpublish(id: string): void {
+ if (this.entries.delete(id)) this.bump();
+ }
+
+ subscribe(listener: () => void): () => void {
+ this.listeners.add(listener);
+ return () => {
+ this.listeners.delete(listener);
+ };
+ }
+
+ getSnapshot(): AggregatedResourceStatus {
+ return this.snapshot;
+ }
+
+ /** Exposed for the kind-filter path so it can pick up status-less `kindHint` slots. */
+ getEntries(): Map {
+ return this.entries;
+ }
+
+ private bump(): void {
+ this.version += 1;
+ this.snapshot = aggregate(this.entries, this.version);
+ for (const l of this.listeners) l();
+ }
+}
+
+function isWorse(a: ResourceStatus, b: ResourceStatus): boolean {
+ const aRank = SEVERITY_RANK[a.severity];
+ const bRank = SEVERITY_RANK[b.severity];
+ if (aRank !== bRank) return aRank < bRank;
+ // Same severity → longer-pending entry wins.
+ return a.startedAt < b.startedAt;
+}
+
+/** Pure derivation of the snapshot from `entries` at a given `version`. */
+function aggregate(
+ entries: Map,
+ version: number,
+): AggregatedResourceStatus {
+ if (entries.size === 0) return { ...EMPTY_SNAPSHOT, version };
+
+ let worst: ResourceStatus | null = null;
+ const byKind: Record = {};
+ const affectedLabels = new Set();
+
+ for (const entry of entries.values()) {
+ const status = entry.status;
+ if (!status) continue;
+ affectedLabels.add(entry.label);
+ const existing = byKind[status.kind];
+ if (!existing || isWorse(status, existing)) {
+ byKind[status.kind] = status;
+ }
+ if (!worst || isWorse(status, worst)) {
+ worst = status;
+ }
+ }
+
+ return {
+ worst,
+ byKind,
+ affectedLabels: [...affectedLabels].sort(),
+ activeCount: entries.size,
+ elapsedMs: worst ? Math.max(0, Date.now() - worst.startedAt) : 0,
+ version,
+ };
+}
+
+/** Kind-scoped aggregate; walks entries directly to include status-less `kindHint` slots. */
+function aggregateForKind(
+ entries: Map,
+ kind: string,
+ version: number,
+): AggregatedResourceStatus {
+ const affectedLabels = new Set();
+ let activeCount = 0;
+ let worst: ResourceStatus | null = null;
+
+ for (const entry of entries.values()) {
+ const entryKind = entry.status?.kind ?? entry.kindHint;
+ if (entryKind !== kind) continue;
+ activeCount++;
+ const status = entry.status;
+ if (!status) continue;
+ affectedLabels.add(entry.label);
+ if (!worst || isWorse(status, worst)) {
+ worst = status;
+ }
+ }
+
+ if (activeCount === 0) return { ...EMPTY_SNAPSHOT, version };
+
+ const byKind: Record = {};
+ if (worst) byKind[kind] = worst;
+
+ return {
+ worst,
+ byKind,
+ affectedLabels: [...affectedLabels].sort(),
+ activeCount,
+ elapsedMs: worst ? Math.max(0, Date.now() - worst.startedAt) : 0,
+ version,
+ };
+}
+
+interface ResourceStatusContextValue {
+ store: ResourceStatusStore;
+}
+
+const ResourceStatusContext = createContext(
+ null,
+);
+
+function useResourceStatusContext(): ResourceStatusContextValue | null {
+ return useContext(ResourceStatusContext);
+}
+
+export interface ResourceStatusProviderProps {
+ children: ReactNode;
+}
+
+/**
+ * Mount once near the root of your app to enable a global, cross-plugin
+ * readiness aggregate. Plugins publish {@link ResourceStatus} snapshots
+ * while a resource is warming up / unavailable; {@link useResourceStatus}
+ * exposes the worst across all of them.
+ *
+ * Without a provider, `useResourceStatus` and `useResourceStatusPublisher`
+ * fall back to no-ops, so plugins are safe to call them unconditionally.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ *
+ * ```
+ */
+export function ResourceStatusProvider({
+ children,
+}: ResourceStatusProviderProps) {
+ const storeRef = useRef(null);
+ if (storeRef.current === null) storeRef.current = new ResourceStatusStore();
+
+ const value = useMemo(
+ () => ({ store: storeRef.current as ResourceStatusStore }),
+ [],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Returns the aggregated resource-readiness snapshot across every active
+ * publisher under the nearest {@link ResourceStatusProvider}. Falls back
+ * to the empty/idle aggregate when no provider is mounted.
+ *
+ * @param filter Optional `{ kind }` to scope to a single resource kind.
+ */
+export function useResourceStatus(
+ filter?: ResourceStatusFilter,
+): AggregatedResourceStatus {
+ const ctx = useResourceStatusContext();
+ const store = ctx?.store;
+
+ const subscribe = useMemo(
+ () =>
+ store
+ ? (listener: () => void) => store.subscribe(listener)
+ : NOOP_SUBSCRIBE,
+ [store],
+ );
+ const getSnapshot = useMemo(
+ () => (store ? () => store.getSnapshot() : GET_EMPTY_SNAPSHOT),
+ [store],
+ );
+
+ const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+
+ return useMemo(() => {
+ if (!filter?.kind) return snapshot;
+ if (!store) return { ...EMPTY_SNAPSHOT, version: snapshot.version };
+ return aggregateForKind(store.getEntries(), filter.kind, snapshot.version);
+ }, [snapshot, store, filter?.kind]);
+}
+
+/**
+ * Register a publisher with the nearest {@link ResourceStatusProvider}.
+ * Safe to call without a provider — `publish`/`unpublish` are no-ops.
+ *
+ * @param id Stable identifier (e.g. a `useId()` value).
+ * @param label Human-readable label surfaced via `affectedLabels`.
+ */
+export function useResourceStatusPublisher(
+ id: string,
+ label: string,
+ options?: { kindHint?: string },
+): {
+ publish: (status: ResourceStatus | null) => void;
+ unpublish: () => void;
+} {
+ const ctx = useResourceStatusContext();
+ const ctxRef = useRef(ctx);
+ ctxRef.current = ctx;
+ const kindHint = options?.kindHint;
+
+ const publish = useCallback(
+ (status: ResourceStatus | null) => {
+ ctxRef.current?.store.publish(id, label, status, kindHint);
+ },
+ [id, label, kindHint],
+ );
+ const unpublish = useCallback(() => {
+ ctxRef.current?.store.unpublish(id);
+ }, [id]);
+
+ return { publish, unpublish };
+}
diff --git a/packages/appkit-ui/src/react/index.ts b/packages/appkit-ui/src/react/index.ts
index 25aea4a44..233089b63 100644
--- a/packages/appkit-ui/src/react/index.ts
+++ b/packages/appkit-ui/src/react/index.ts
@@ -4,5 +4,6 @@ export * from "./genie";
export * from "./hooks";
export * from "./lib/utils";
export * from "./portal-container-context";
+export * from "./resource-status-indicator";
export * from "./table";
export * from "./ui";
diff --git a/packages/appkit-ui/src/react/resource-status-indicator.tsx b/packages/appkit-ui/src/react/resource-status-indicator.tsx
new file mode 100644
index 000000000..3e7d15656
--- /dev/null
+++ b/packages/appkit-ui/src/react/resource-status-indicator.tsx
@@ -0,0 +1,233 @@
+import type { LucideIcon } from "lucide-react";
+import { useEffect, useId, useRef, useState } from "react";
+import { type ToasterProps, toast } from "sonner";
+import {
+ type AggregatedResourceStatus,
+ type ResourceStatus,
+ useResourceStatus,
+} from "./hooks/use-resource-status";
+import { Toaster } from "./ui/sonner";
+
+/** ~1Hz tick driving the elapsed-time display between store events. */
+const ELAPSED_TICK_MS = 1000;
+
+/** Per-kind copy + icon overrides for {@link ResourceStatusIndicator}. */
+export interface ResourceKindRenderer {
+ title: (status: ResourceStatus) => string;
+ description: (
+ status: ResourceStatus,
+ aggregate: AggregatedResourceStatus,
+ ) => string;
+ /** Defaults to sonner's built-in loading/error icon. */
+ icon?: LucideIcon;
+}
+
+/** Options shared by {@link ResourceStatusIndicator} and {@link useResourceStatusToaster}. */
+export interface ResourceStatusToasterOptions {
+ /** Restrict to a single resource kind. Otherwise shows the worst across all kinds. */
+ kind?: string;
+ /** Per-kind copy + icon overrides. */
+ renderers?: Record;
+ /** Class name applied to the toast (not the Toaster wrapper). */
+ toastClassName?: string;
+ /** Full custom render override, rendered inside `toast.custom`. */
+ render?: (aggregate: AggregatedResourceStatus) => React.ReactNode;
+}
+
+const DEFAULT_KIND_RENDERERS: Record = {
+ warehouse: {
+ title: (s) =>
+ s.severity === "error"
+ ? "SQL warehouse unavailable"
+ : "SQL warehouse warming up",
+ description: (s, agg) => {
+ if (s.severity === "error") {
+ return (
+ s.summary ??
+ "The configured SQL warehouse is unavailable. Update DATABRICKS_WAREHOUSE_ID and reload."
+ );
+ }
+ const labels = agg.affectedLabels.length;
+ if (labels === 0) {
+ return `Waiting for the warehouse to reach RUNNING · ${formatElapsed(agg.elapsedMs)}`;
+ }
+ return `${labels} ${labels === 1 ? "query" : "queries"} waiting · ${formatElapsed(agg.elapsedMs)}`;
+ },
+ },
+};
+
+/**
+ * Drives a sticky sonner toast that mirrors the worst pending resource
+ * status. Does not render anything — supply your own ` `. Most
+ * apps should prefer {@link ResourceStatusIndicator}; use this hook only
+ * to share an existing Toaster with unrelated app toasts.
+ */
+export function useResourceStatusToaster(
+ options: ResourceStatusToasterOptions = {},
+): void {
+ const { kind, renderers, toastClassName, render } = options;
+ const aggregate = useResourceStatus(kind ? { kind } : undefined);
+ const worst = aggregate.worst;
+ // Per-instance + per-kind toast id: instance scoping isolates multiple
+ // indicators in the same tree; kind scoping forces dismiss-and-recreate
+ // when severity flips between toast types (sonner can't morph
+ // jsx/description cleanly across `custom` ↔ `loading`/`error`).
+ const instanceId = useId();
+
+ // The store is event-driven, so re-render at ~1Hz to keep the elapsed
+ // counter advancing between status emissions.
+ const [, forceTick] = useState(0);
+ useEffect(() => {
+ if (!worst) return;
+ const id = setInterval(() => forceTick((n) => n + 1), ELAPSED_TICK_MS);
+ return () => clearInterval(id);
+ }, [worst]);
+
+ const liveIdRef = useRef(null);
+
+ // Runs every render: cheap because sonner patches the same id in place.
+ useEffect(() => {
+ if (!worst) {
+ if (liveIdRef.current) {
+ toast.dismiss(liveIdRef.current);
+ liveIdRef.current = null;
+ }
+ return;
+ }
+
+ // Live elapsed; the snapshot only refreshes on store events.
+ const liveAggregate: AggregatedResourceStatus = {
+ ...aggregate,
+ elapsedMs: Math.max(0, Date.now() - worst.startedAt),
+ };
+
+ const nextId = `appkit-resource:${instanceId}:${worst.kind}:${worst.severity}`;
+ if (liveIdRef.current && liveIdRef.current !== nextId) {
+ toast.dismiss(liveIdRef.current);
+ }
+ liveIdRef.current = nextId;
+
+ if (render) {
+ const node = render(liveAggregate);
+ toast.custom(() => <>{node}>, {
+ id: nextId,
+ duration: Number.POSITIVE_INFINITY,
+ className: toastClassName,
+ });
+ return;
+ }
+
+ const merged = { ...DEFAULT_KIND_RENDERERS, ...renderers };
+ const renderer = merged[worst.kind];
+ const title = renderer?.title(worst) ?? defaultTitle(worst);
+ const description =
+ renderer?.description(worst, liveAggregate) ?? defaultDescription(worst);
+ const Icon = renderer?.icon;
+ const opts = {
+ id: nextId,
+ description,
+ duration: Number.POSITIVE_INFINITY,
+ className: toastClassName,
+ ...(Icon ? { icon: } : {}),
+ };
+
+ if (worst.severity === "error") {
+ toast.error(title, opts);
+ } else {
+ toast.loading(title, opts);
+ }
+ });
+
+ // Dismiss on unmount so the toast doesn't outlive its mount point.
+ useEffect(() => {
+ return () => {
+ if (liveIdRef.current) {
+ toast.dismiss(liveIdRef.current);
+ liveIdRef.current = null;
+ }
+ };
+ }, []);
+}
+
+export interface ResourceStatusIndicatorProps
+ extends ResourceStatusToasterOptions,
+ Omit {
+ /** Class name applied to the Toaster wrapper. */
+ className?: string;
+}
+
+/**
+ * Drop-in indicator that mounts a ` ` and surfaces the worst
+ * pending {@link ResourceStatus} across every plugin/component publishing
+ * into the nearest {@link ResourceStatusProvider} as a sonner toast
+ * (`toast.loading` for cold starts, `toast.error` for unrecoverable
+ * states), keyed by the worst kind so only one indicator toast is on
+ * screen at a time.
+ *
+ * Forwards `Toaster` props (`position` defaults to `top-right`, plus
+ * `theme`, `richColors`, …). Apps that already mount their own
+ * ` ` should drop this component and call
+ * {@link useResourceStatusToaster} instead.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ *
+ * ```
+ *
+ * @example Custom render
+ * ```tsx
+ * (
+ *
+ * {agg.worst?.kind} {agg.worst?.state.toLowerCase()}
+ *
+ * )}
+ * />
+ * ```
+ */
+export function ResourceStatusIndicator({
+ kind,
+ renderers,
+ toastClassName,
+ render,
+ position = "top-right",
+ ...toasterProps
+}: ResourceStatusIndicatorProps = {}) {
+ useResourceStatusToaster({ kind, renderers, toastClassName, render });
+ return ;
+}
+
+function defaultTitle(status: ResourceStatus): string {
+ switch (status.severity) {
+ case "error":
+ return `${humanizeKind(status.kind)} unavailable`;
+ case "warning":
+ return `${humanizeKind(status.kind)} degraded`;
+ default:
+ return `${humanizeKind(status.kind)} not ready`;
+ }
+}
+
+function defaultDescription(status: ResourceStatus): string {
+ return status.summary ?? `Current state: ${status.state}.`;
+}
+
+function humanizeKind(kind: string): string {
+ if (!kind) return "Resource";
+ return kind
+ .split(/[-_]/g)
+ .filter(Boolean)
+ .map((part) => part[0].toUpperCase() + part.slice(1))
+ .join(" ");
+}
+
+function formatElapsed(ms: number): string {
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
+ if (totalSeconds < 60) return `${totalSeconds}s`;
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
+}
diff --git a/template/client/src/main.tsx b/template/client/src/main.tsx
index 35c59a58f..d8cb1032f 100644
--- a/template/client/src/main.tsx
+++ b/template/client/src/main.tsx
@@ -1,3 +1,7 @@
+import {
+ ResourceStatusIndicator,
+ ResourceStatusProvider,
+} from '@databricks/appkit-ui/react';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
@@ -7,7 +11,17 @@ import { ErrorBoundary } from './ErrorBoundary.tsx';
createRoot(document.getElementById('root')!).render(
-
+ {/*
+ * Surfaces resource readiness (e.g. SQL warehouse cold-starts) as a
+ * single sonner toast across the whole tree. Both are no-ops when
+ * nothing's pending; remove them to render the aggregate yourself
+ * via useResourceStatus(). Apps that already mount their own
+ * can swap the indicator for useResourceStatusToaster().
+ */}
+
+
+
+
);