Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ describe("MaintainerPanel role gate", () => {
render(<MaintainerPanel />);
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(<MaintainerPanel />);
// 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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -210,6 +211,30 @@ export function MaintainerPanel({
return <MaintainerDashboardView initialRepoFullName={initialRepoFullName} />;
}

/** 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 (
<div className="space-y-6" aria-hidden>
<div className="flex items-center justify-end">
<Skeleton className="h-6 w-40 rounded-token" />
</div>
<Skeleton className="h-24 w-full rounded-token" />
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton key={index} className="h-24 w-full rounded-token" />
))}
</section>
<section className="grid gap-6 lg:grid-cols-2">
<Skeleton className="h-64 w-full rounded-token" />
<Skeleton className="h-64 w-full rounded-token" />
</section>
<Skeleton className="h-72 w-full rounded-token" />
</div>
);
}

function MaintainerDashboardView({
initialRepoFullName,
}: {
Expand All @@ -229,6 +254,7 @@ function MaintainerDashboardView({
onRetry={dashboard.reload}
onRefresh={dashboard.reload}
loadingTitle="Loading maintainer context…"
loadingSkeleton={<MaintainerDashboardSkeleton />}
emptyTitle="No maintainer data yet"
emptyDescription="Install health, reviewability, and surface previews appear after repository data is available."
>
Expand Down
Original file line number Diff line number Diff line change
@@ -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: () => <span>mcp</span>,
}));
vi.mock("@tanstack/react-router", () => ({
Link: ({ children, ...props }: { children: React.ReactNode; to?: string }) => (
<a href={props.to ?? "#"}>{children}</a>
),
}));

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(<MinerPanel />);
// 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);
});
});
28 changes: 28 additions & 0 deletions apps/loopover-ui/src/components/site/app-panels/miner-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<div className="space-y-6" aria-hidden>
<div className="flex items-center justify-end">
<Skeleton className="h-6 w-40 rounded-token" />
</div>
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton key={index} className="h-24 w-full rounded-token" />
))}
</section>
<section className="grid gap-6 lg:grid-cols-[1.4fr_1fr]">
<Skeleton className="h-80 w-full rounded-token" />
<Skeleton className="h-80 w-full rounded-token" />
</section>
<section className="grid gap-6 lg:grid-cols-2">
<Skeleton className="h-64 w-full rounded-token" />
<Skeleton className="h-64 w-full rounded-token" />
</section>
</div>
);
}

export function MinerPanel() {
const { session } = useSession();
const login = session?.login ?? "";
Expand Down Expand Up @@ -166,6 +193,7 @@ export function MinerPanel() {
onRetry={dashboard.reload}
onRefresh={dashboard.reload}
loadingTitle="Loading miner signals…"
loadingSkeleton={<MinerDashboardSkeleton />}
emptyTitle="No miner actions yet"
emptyDescription="Once a decision pack or branch analysis exists, ranked next actions and blockers will appear here."
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<never>(() => {}));
const { container } = render(<DeadLetterQueuePanel />);
// 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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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={<DeadLetterQueueSkeleton />}
emptyTitle="No dead-letter jobs"
emptyDescription="Jobs that exhaust their retry budget will appear here."
errorTitle="Couldn't load the dead-letter queue"
Expand All @@ -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 (
<div className="space-y-3" aria-hidden>
<div className="overflow-x-auto rounded-token border border-border bg-transparent">
<div className="border-b border-border px-4 py-3">
<Skeleton className="h-4 w-40" />
</div>
<div className="divide-y divide-border/60">
{Array.from({ length: 5 }, (_, index) => (
<div key={index} className="px-4 py-3">
<Skeleton className="h-5 w-full" />
</div>
))}
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-8 w-40 rounded-token" />
</div>
</div>
);
}

function DeadLetterQueueTable({
page,
onPageChange,
Expand Down
Loading