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,