diff --git a/apps/loopover-ui/src/components/site/app-panels/maintainer-panel.test.tsx b/apps/loopover-ui/src/components/site/app-panels/maintainer-panel.test.tsx index de90d825ea..cdeccad914 100644 --- a/apps/loopover-ui/src/components/site/app-panels/maintainer-panel.test.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/maintainer-panel.test.tsx @@ -37,6 +37,19 @@ describe("MaintainerPanel role gate", () => { render(); expect(screen.queryByText(/Maintainer access required/i)).toBeNull(); }); + + it("shows a content-shaped skeleton (not the generic spinner) while the dashboard loads (#793)", () => { + // Default mock: an admitted maintainer whose dashboard resource is still loading. + useSession.mockReturnValue({ + session: { login: "maint", roles: ["maintainer"] }, + hydrated: true, + }); + const { container } = render(); + // The custom skeleton replaces the generic LoadingState spinner while the payload is in flight. + expect(screen.queryByRole("status")).toBeNull(); + expect(screen.queryByText("Loading maintainer context…")).toBeNull(); + expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(1); + }); }); const emptyGateOutcomeBreakdown = { diff --git a/apps/loopover-ui/src/components/site/app-panels/maintainer-panel.tsx b/apps/loopover-ui/src/components/site/app-panels/maintainer-panel.tsx index f79ed8c1ff..dcd0bc59be 100644 --- a/apps/loopover-ui/src/components/site/app-panels/maintainer-panel.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/maintainer-panel.tsx @@ -38,6 +38,7 @@ import { TableScroll } from "@/components/site/data-table"; import { StatCard } from "@/components/site/primitives"; import { RefreshMeta } from "@/components/site/refresh-meta"; import { EmptyState, LoadingState, StateBoundary } from "@/components/site/state-views"; +import { Skeleton } from "@/components/ui/skeleton"; import { apiFetch } from "@/lib/api/request"; import { getApiOrigin } from "@/lib/api/origin"; import { useApiResource } from "@/lib/api/use-api-resource"; @@ -210,6 +211,30 @@ export function MaintainerPanel({ return ; } +/** Content-shaped loading placeholder mirroring the maintainer dashboard's top-level layout (refresh + * line, onboarding preview, metric grid, the two-column install/settings row, and the reviewability + * table) so the console doesn't jump once the dashboard payload arrives (#793). */ +function MaintainerDashboardSkeleton() { + return ( +
+
+ +
+ +
+ {Array.from({ length: 4 }, (_, index) => ( + + ))} +
+
+ + +
+ +
+ ); +} + function MaintainerDashboardView({ initialRepoFullName, }: { @@ -229,6 +254,7 @@ function MaintainerDashboardView({ onRetry={dashboard.reload} onRefresh={dashboard.reload} loadingTitle="Loading maintainer context…" + loadingSkeleton={} emptyTitle="No maintainer data yet" emptyDescription="Install health, reviewability, and surface previews appear after repository data is available." > diff --git a/apps/loopover-ui/src/components/site/app-panels/miner-panel.test.tsx b/apps/loopover-ui/src/components/site/app-panels/miner-panel.test.tsx new file mode 100644 index 0000000000..7c1b48ad4f --- /dev/null +++ b/apps/loopover-ui/src/components/site/app-panels/miner-panel.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +// Stub the data hook + session so the panel renders without touching the network, and neuter the +// router Link / MCP badge that the miner dashboard pulls in. +const { useApiResource, useSession } = vi.hoisted(() => ({ + useApiResource: vi.fn(), + useSession: vi.fn(), +})); +vi.mock("@/lib/api/use-api-resource", () => ({ + useApiResource: (...args: unknown[]) => useApiResource(...args), +})); +vi.mock("@/lib/api/session", () => ({ useSession: () => useSession() })); +vi.mock("@/components/site/mcp-version-badge", () => ({ + McpVersionBadge: () => mcp, +})); +vi.mock("@tanstack/react-router", () => ({ + Link: ({ children, ...props }: { children: React.ReactNode; to?: string }) => ( + {children} + ), +})); + +import { MinerPanel } from "@/components/site/app-panels/miner-panel"; + +describe("MinerPanel loading skeleton (#793)", () => { + it("shows a content-shaped skeleton (not the generic spinner) while the decision pack loads", () => { + useSession.mockReturnValue({ + session: { login: "miner", roles: ["miner"] }, + hydrated: true, + }); + useApiResource.mockReturnValue({ + status: "loading", + data: null, + error: null, + loadedAt: null, + reload: () => {}, + }); + + const { container } = render(); + // The custom skeleton replaces the generic LoadingState — neither its title nor its spinner shows. + // (A distinct always-present sr-only status live-region in the action bar rules out a role query.) + expect(screen.queryByText("Loading miner signals…")).toBeNull(); + expect(container.querySelector(".animate-spin")).toBeNull(); + // The placeholder renders animate-pulse blocks approximating the dashboard's metric + card grid. + expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(1); + }); +}); diff --git a/apps/loopover-ui/src/components/site/app-panels/miner-panel.tsx b/apps/loopover-ui/src/components/site/app-panels/miner-panel.tsx index c826be34d5..6c85ff00f4 100644 --- a/apps/loopover-ui/src/components/site/app-panels/miner-panel.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/miner-panel.tsx @@ -14,6 +14,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Skeleton } from "@/components/ui/skeleton"; import { apiFetch } from "@/lib/api/request"; import { getApiOrigin } from "@/lib/api/origin"; import { useApiResource } from "@/lib/api/use-api-resource"; @@ -86,6 +87,32 @@ type MinerDashboard = { mcp?: { snapshot?: string | null; drift?: string | null; lastRun?: string | null }; }; +/** Content-shaped loading placeholder mirroring the miner dashboard's layout (refresh line, metric + * grid, the next-actions/scoreability two-column row, and the blockers/repo-fit row) so the panel + * doesn't jump once the decision pack arrives (#793). */ +function MinerDashboardSkeleton() { + return ( +
+
+ +
+
+ {Array.from({ length: 4 }, (_, index) => ( + + ))} +
+
+ + +
+
+ + +
+
+ ); +} + export function MinerPanel() { const { session } = useSession(); const login = session?.login ?? ""; @@ -166,6 +193,7 @@ export function MinerPanel() { onRetry={dashboard.reload} onRefresh={dashboard.reload} loadingTitle="Loading miner signals…" + loadingSkeleton={} emptyTitle="No miner actions yet" emptyDescription="Once a decision pack or branch analysis exists, ranked next actions and blockers will appear here." > diff --git a/apps/loopover-ui/src/components/site/dead-letter-queue-panel.test.tsx b/apps/loopover-ui/src/components/site/dead-letter-queue-panel.test.tsx index eded9cabed..4ac173c5db 100644 --- a/apps/loopover-ui/src/components/site/dead-letter-queue-panel.test.tsx +++ b/apps/loopover-ui/src/components/site/dead-letter-queue-panel.test.tsx @@ -198,6 +198,17 @@ describe("DeadLetterQueuePanel", () => { await screen.findByText("github-webhook"); expect(screen.getByRole("link", { name: /next/i }).getAttribute("aria-disabled")).toBe("true"); }); + + it("shows a content-shaped skeleton (not the generic spinner) while the queue is loading (#793)", () => { + // Keep the request in flight so the boundary stays in its loading branch. + apiFetch.mockReturnValue(new Promise(() => {})); + const { container } = render(); + // The custom skeleton replaces the generic LoadingState, so neither its status role nor its title show. + expect(screen.queryByRole("status")).toBeNull(); + expect(screen.queryByText("Loading dead-letter queue…")).toBeNull(); + // The placeholder renders animate-pulse skeleton blocks approximating the table's rows. + expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(1); + }); }); describe("DeadLetterQueuePanel row actions and purge", () => { diff --git a/apps/loopover-ui/src/components/site/dead-letter-queue-panel.tsx b/apps/loopover-ui/src/components/site/dead-letter-queue-panel.tsx index 4230151856..56069e3459 100644 --- a/apps/loopover-ui/src/components/site/dead-letter-queue-panel.tsx +++ b/apps/loopover-ui/src/components/site/dead-letter-queue-panel.tsx @@ -30,6 +30,7 @@ import { PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; +import { Skeleton } from "@/components/ui/skeleton"; import { getApiOrigin } from "@/lib/api/origin"; import { apiFetch } from "@/lib/api/request"; import { useApiResource } from "@/lib/api/use-api-resource"; @@ -123,6 +124,7 @@ export function DeadLetterQueuePanel() { onRefresh={resource.reload} loadingTitle="Loading dead-letter queue…" loadingDescription="Fetching failed jobs from the self-host queue backend." + loadingSkeleton={} emptyTitle="No dead-letter jobs" emptyDescription="Jobs that exhaust their retry budget will appear here." errorTitle="Couldn't load the dead-letter queue" @@ -141,6 +143,31 @@ export function DeadLetterQueuePanel() { ); } +/** Content-shaped loading placeholder for the dead-letter table (bordered table container + a row of + * pagination controls) so the panel doesn't jump once the first page of failed jobs arrives (#793). */ +function DeadLetterQueueSkeleton() { + return ( +
+
+
+ +
+
+ {Array.from({ length: 5 }, (_, index) => ( +
+ +
+ ))} +
+
+
+ + +
+
+ ); +} + function DeadLetterQueueTable({ page, onPageChange,