Skip to content

Commit b09f262

Browse files
authored
fix(ui): content-shaped loading skeletons for dead-letter/maintainer/miner panels (#6178) (#6452)
1 parent 0d69db2 commit b09f262

6 files changed

Lines changed: 152 additions & 0 deletions

File tree

apps/loopover-ui/src/components/site/app-panels/maintainer-panel.test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,19 @@ describe("MaintainerPanel role gate", () => {
3737
render(<MaintainerPanel />);
3838
expect(screen.queryByText(/Maintainer access required/i)).toBeNull();
3939
});
40+
41+
it("shows a content-shaped skeleton (not the generic spinner) while the dashboard loads (#793)", () => {
42+
// Default mock: an admitted maintainer whose dashboard resource is still loading.
43+
useSession.mockReturnValue({
44+
session: { login: "maint", roles: ["maintainer"] },
45+
hydrated: true,
46+
});
47+
const { container } = render(<MaintainerPanel />);
48+
// The custom skeleton replaces the generic LoadingState spinner while the payload is in flight.
49+
expect(screen.queryByRole("status")).toBeNull();
50+
expect(screen.queryByText("Loading maintainer context…")).toBeNull();
51+
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(1);
52+
});
4053
});
4154

4255
const emptyGateOutcomeBreakdown = {

apps/loopover-ui/src/components/site/app-panels/maintainer-panel.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { TableScroll } from "@/components/site/data-table";
3838
import { StatCard } from "@/components/site/primitives";
3939
import { RefreshMeta } from "@/components/site/refresh-meta";
4040
import { EmptyState, LoadingState, StateBoundary } from "@/components/site/state-views";
41+
import { Skeleton } from "@/components/ui/skeleton";
4142
import { apiFetch } from "@/lib/api/request";
4243
import { getApiOrigin } from "@/lib/api/origin";
4344
import { useApiResource } from "@/lib/api/use-api-resource";
@@ -210,6 +211,30 @@ export function MaintainerPanel({
210211
return <MaintainerDashboardView initialRepoFullName={initialRepoFullName} />;
211212
}
212213

214+
/** Content-shaped loading placeholder mirroring the maintainer dashboard's top-level layout (refresh
215+
* line, onboarding preview, metric grid, the two-column install/settings row, and the reviewability
216+
* table) so the console doesn't jump once the dashboard payload arrives (#793). */
217+
function MaintainerDashboardSkeleton() {
218+
return (
219+
<div className="space-y-6" aria-hidden>
220+
<div className="flex items-center justify-end">
221+
<Skeleton className="h-6 w-40 rounded-token" />
222+
</div>
223+
<Skeleton className="h-24 w-full rounded-token" />
224+
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
225+
{Array.from({ length: 4 }, (_, index) => (
226+
<Skeleton key={index} className="h-24 w-full rounded-token" />
227+
))}
228+
</section>
229+
<section className="grid gap-6 lg:grid-cols-2">
230+
<Skeleton className="h-64 w-full rounded-token" />
231+
<Skeleton className="h-64 w-full rounded-token" />
232+
</section>
233+
<Skeleton className="h-72 w-full rounded-token" />
234+
</div>
235+
);
236+
}
237+
213238
function MaintainerDashboardView({
214239
initialRepoFullName,
215240
}: {
@@ -229,6 +254,7 @@ function MaintainerDashboardView({
229254
onRetry={dashboard.reload}
230255
onRefresh={dashboard.reload}
231256
loadingTitle="Loading maintainer context…"
257+
loadingSkeleton={<MaintainerDashboardSkeleton />}
232258
emptyTitle="No maintainer data yet"
233259
emptyDescription="Install health, reviewability, and surface previews appear after repository data is available."
234260
>
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
4+
// Stub the data hook + session so the panel renders without touching the network, and neuter the
5+
// router Link / MCP badge that the miner dashboard pulls in.
6+
const { useApiResource, useSession } = vi.hoisted(() => ({
7+
useApiResource: vi.fn(),
8+
useSession: vi.fn(),
9+
}));
10+
vi.mock("@/lib/api/use-api-resource", () => ({
11+
useApiResource: (...args: unknown[]) => useApiResource(...args),
12+
}));
13+
vi.mock("@/lib/api/session", () => ({ useSession: () => useSession() }));
14+
vi.mock("@/components/site/mcp-version-badge", () => ({
15+
McpVersionBadge: () => <span>mcp</span>,
16+
}));
17+
vi.mock("@tanstack/react-router", () => ({
18+
Link: ({ children, ...props }: { children: React.ReactNode; to?: string }) => (
19+
<a href={props.to ?? "#"}>{children}</a>
20+
),
21+
}));
22+
23+
import { MinerPanel } from "@/components/site/app-panels/miner-panel";
24+
25+
describe("MinerPanel loading skeleton (#793)", () => {
26+
it("shows a content-shaped skeleton (not the generic spinner) while the decision pack loads", () => {
27+
useSession.mockReturnValue({
28+
session: { login: "miner", roles: ["miner"] },
29+
hydrated: true,
30+
});
31+
useApiResource.mockReturnValue({
32+
status: "loading",
33+
data: null,
34+
error: null,
35+
loadedAt: null,
36+
reload: () => {},
37+
});
38+
39+
const { container } = render(<MinerPanel />);
40+
// The custom skeleton replaces the generic LoadingState — neither its title nor its spinner shows.
41+
// (A distinct always-present sr-only status live-region in the action bar rules out a role query.)
42+
expect(screen.queryByText("Loading miner signals…")).toBeNull();
43+
expect(container.querySelector(".animate-spin")).toBeNull();
44+
// The placeholder renders animate-pulse blocks approximating the dashboard's metric + card grid.
45+
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(1);
46+
});
47+
});

apps/loopover-ui/src/components/site/app-panels/miner-panel.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
DialogHeader,
1515
DialogTitle,
1616
} from "@/components/ui/dialog";
17+
import { Skeleton } from "@/components/ui/skeleton";
1718
import { apiFetch } from "@/lib/api/request";
1819
import { getApiOrigin } from "@/lib/api/origin";
1920
import { useApiResource } from "@/lib/api/use-api-resource";
@@ -86,6 +87,32 @@ type MinerDashboard = {
8687
mcp?: { snapshot?: string | null; drift?: string | null; lastRun?: string | null };
8788
};
8889

90+
/** Content-shaped loading placeholder mirroring the miner dashboard's layout (refresh line, metric
91+
* grid, the next-actions/scoreability two-column row, and the blockers/repo-fit row) so the panel
92+
* doesn't jump once the decision pack arrives (#793). */
93+
function MinerDashboardSkeleton() {
94+
return (
95+
<div className="space-y-6" aria-hidden>
96+
<div className="flex items-center justify-end">
97+
<Skeleton className="h-6 w-40 rounded-token" />
98+
</div>
99+
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
100+
{Array.from({ length: 4 }, (_, index) => (
101+
<Skeleton key={index} className="h-24 w-full rounded-token" />
102+
))}
103+
</section>
104+
<section className="grid gap-6 lg:grid-cols-[1.4fr_1fr]">
105+
<Skeleton className="h-80 w-full rounded-token" />
106+
<Skeleton className="h-80 w-full rounded-token" />
107+
</section>
108+
<section className="grid gap-6 lg:grid-cols-2">
109+
<Skeleton className="h-64 w-full rounded-token" />
110+
<Skeleton className="h-64 w-full rounded-token" />
111+
</section>
112+
</div>
113+
);
114+
}
115+
89116
export function MinerPanel() {
90117
const { session } = useSession();
91118
const login = session?.login ?? "";
@@ -166,6 +193,7 @@ export function MinerPanel() {
166193
onRetry={dashboard.reload}
167194
onRefresh={dashboard.reload}
168195
loadingTitle="Loading miner signals…"
196+
loadingSkeleton={<MinerDashboardSkeleton />}
169197
emptyTitle="No miner actions yet"
170198
emptyDescription="Once a decision pack or branch analysis exists, ranked next actions and blockers will appear here."
171199
>

apps/loopover-ui/src/components/site/dead-letter-queue-panel.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,17 @@ describe("DeadLetterQueuePanel", () => {
198198
await screen.findByText("github-webhook");
199199
expect(screen.getByRole("link", { name: /next/i }).getAttribute("aria-disabled")).toBe("true");
200200
});
201+
202+
it("shows a content-shaped skeleton (not the generic spinner) while the queue is loading (#793)", () => {
203+
// Keep the request in flight so the boundary stays in its loading branch.
204+
apiFetch.mockReturnValue(new Promise<never>(() => {}));
205+
const { container } = render(<DeadLetterQueuePanel />);
206+
// The custom skeleton replaces the generic LoadingState, so neither its status role nor its title show.
207+
expect(screen.queryByRole("status")).toBeNull();
208+
expect(screen.queryByText("Loading dead-letter queue…")).toBeNull();
209+
// The placeholder renders animate-pulse skeleton blocks approximating the table's rows.
210+
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(1);
211+
});
201212
});
202213

203214
describe("DeadLetterQueuePanel row actions and purge", () => {

apps/loopover-ui/src/components/site/dead-letter-queue-panel.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
PaginationNext,
3131
PaginationPrevious,
3232
} from "@/components/ui/pagination";
33+
import { Skeleton } from "@/components/ui/skeleton";
3334
import { getApiOrigin } from "@/lib/api/origin";
3435
import { apiFetch } from "@/lib/api/request";
3536
import { useApiResource } from "@/lib/api/use-api-resource";
@@ -123,6 +124,7 @@ export function DeadLetterQueuePanel() {
123124
onRefresh={resource.reload}
124125
loadingTitle="Loading dead-letter queue…"
125126
loadingDescription="Fetching failed jobs from the self-host queue backend."
127+
loadingSkeleton={<DeadLetterQueueSkeleton />}
126128
emptyTitle="No dead-letter jobs"
127129
emptyDescription="Jobs that exhaust their retry budget will appear here."
128130
errorTitle="Couldn't load the dead-letter queue"
@@ -141,6 +143,31 @@ export function DeadLetterQueuePanel() {
141143
);
142144
}
143145

146+
/** Content-shaped loading placeholder for the dead-letter table (bordered table container + a row of
147+
* pagination controls) so the panel doesn't jump once the first page of failed jobs arrives (#793). */
148+
function DeadLetterQueueSkeleton() {
149+
return (
150+
<div className="space-y-3" aria-hidden>
151+
<div className="overflow-x-auto rounded-token border border-border bg-transparent">
152+
<div className="border-b border-border px-4 py-3">
153+
<Skeleton className="h-4 w-40" />
154+
</div>
155+
<div className="divide-y divide-border/60">
156+
{Array.from({ length: 5 }, (_, index) => (
157+
<div key={index} className="px-4 py-3">
158+
<Skeleton className="h-5 w-full" />
159+
</div>
160+
))}
161+
</div>
162+
</div>
163+
<div className="flex flex-wrap items-center justify-between gap-3">
164+
<Skeleton className="h-4 w-32" />
165+
<Skeleton className="h-8 w-40 rounded-token" />
166+
</div>
167+
</div>
168+
);
169+
}
170+
144171
function DeadLetterQueueTable({
145172
page,
146173
onPageChange,

0 commit comments

Comments
 (0)