diff --git a/apps/loopover-miner-ui/src/routes/run-history.tsx b/apps/loopover-miner-ui/src/routes/run-history.tsx index c0aab92cd5..0530dfa93f 100644 --- a/apps/loopover-miner-ui/src/routes/run-history.tsx +++ b/apps/loopover-miner-ui/src/routes/run-history.tsx @@ -1,7 +1,18 @@ import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; import { Badge } from "@loopover/ui-kit/components/badge"; import { Card, CardContent, CardHeader } from "@loopover/ui-kit/components/card"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@loopover/ui-kit/components/pagination"; +import { Skeleton } from "@loopover/ui-kit/components/skeleton"; +import { StateBoundary } from "@loopover/ui-kit/components/state-views"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@loopover/ui-kit/components/table"; import { DEFAULT_POLL_INTERVAL_MS, usePolledFetch } from "../lib/use-polled-fetch"; @@ -12,8 +23,13 @@ export const Route = createFileRoute("/run-history")({ }); // Read-only run-history table (#4305): one row per repo from the local `miner_run_state` store (repo, state, -// last-updated), served by the dev server's local API. No writes, no new state — a fresh install renders the -// empty state, an unreachable API renders an error message. +// last-updated), served by the dev server's local API. No writes, no new state. +// +// #6510: the hand-rolled loading/error/empty `

` branches are replaced by the shared @loopover/ui-kit +// `StateBoundary`, with a content-shaped `Skeleton` table for the loading state (so the layout doesn't jump when +// the poll resolves), and the table paginates client-side once it exceeds PAGE_SIZE rows via the kit's +// `Pagination`. Purely presentational — `lib/run-history.ts`'s fetch/poll is untouched, and the +// Repository/State/Last-updated columns + data shown are unchanged. const STATE_BADGE_VARIANT: Record = { idle: "secondary", @@ -22,35 +38,57 @@ const STATE_BADGE_VARIANT: Record preparing: "outline", }; -export function RunHistoryView({ result }: { result: RunHistoryResult | null }) { - if (result === null) { - return

Loading local run state…

; - } - if (!result.ok) { - return ( -

- Could not read local run state: {result.error} -

- ); - } - if (result.rows.length === 0) { - return ( -

- No local run state yet — the table fills in once the miner records its first repo run. -

- ); - } +/** Rows per page once the run-state table grows past this; below it the full table renders unpaginated. */ +const PAGE_SIZE = 20; + +const TABLE_COLUMNS = ["Repository", "State", "Last updated"] as const; + +function RunHistoryTableHeader() { + return ( + + + {TABLE_COLUMNS.map((column) => ( + {column} + ))} + + + ); +} + +/** Table-shaped loading placeholder: header + `rows` shimmer rows matching the real column layout, so the table + * keeps its shape and the content doesn't jump once the poll resolves. `role="status"` keeps the loading state + * announced to assistive tech (as the flat "Loading…" text it replaces was). */ +function RunHistorySkeleton({ rows = 5 }: { rows?: number }) { + return ( +
+ + + + {Array.from({ length: rows }).map((_, index) => ( + + + + + + + + + + + + ))} + +
+
+ ); +} + +function RunStateTable({ rows }: { rows: RunStateRow[] }) { return ( - - - Repository - State - Last updated - - + - {result.rows.map((row) => ( + {rows.map((row) => ( {row.repoFullName} @@ -64,6 +102,70 @@ export function RunHistoryView({ result }: { result: RunHistoryResult | null }) ); } +export function RunHistoryView({ result }: { result: RunHistoryResult | null }) { + const [page, setPage] = useState(0); + const rows = result?.ok ? result.rows : []; + const pageCount = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); + const isPaginated = rows.length > PAGE_SIZE; + const safePage = Math.min(page, pageCount - 1); + const visibleRows = isPaginated ? rows.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE) : rows; + + return ( + } + errorTitle="Couldn't read local run state" + errorDescription="The local run-state API didn't respond. This refreshes automatically on the next poll." + emptyTitle="No local run state yet" + emptyDescription="The table fills in once the miner records its first repo run." + > + + {isPaginated && ( + + + + { + event.preventDefault(); + setPage((current) => Math.max(0, current - 1)); + }} + /> + + {Array.from({ length: pageCount }).map((_, index) => ( + + { + event.preventDefault(); + setPage(index); + }} + > + {index + 1} + + + ))} + + = pageCount - 1} + onClick={(event) => { + event.preventDefault(); + setPage((current) => Math.min(pageCount - 1, current + 1)); + }} + /> + + + + )} + + ); +} + export function RunHistoryPage({ loadRunStates = fetchRunStates, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, diff --git a/apps/loopover-miner-ui/src/run-history.test.tsx b/apps/loopover-miner-ui/src/run-history.test.tsx index d91db704cc..8c865d160a 100644 --- a/apps/loopover-miner-ui/src/run-history.test.tsx +++ b/apps/loopover-miner-ui/src/run-history.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { fetchRunStates, RUN_STATE_API_PATH, type RunHistoryResult, type RunStateRow } from "./lib/run-history"; @@ -9,7 +9,15 @@ const fixtureRows: RunStateRow[] = [ { repoFullName: "acme/gadgets", state: "idle", updatedAt: "2026-07-10T05:00:00.000Z" }, ]; -describe("RunHistoryView (#4305)", () => { +function manyRows(count: number): RunStateRow[] { + return Array.from({ length: count }, (_, index) => ({ + repoFullName: `acme/repo-${index}`, + state: "idle" as const, + updatedAt: "2026-07-10T05:00:00.000Z", + })); +} + +describe("RunHistoryView (#4305, redesigned #6510)", () => { it("renders one table row per run-state fixture row with repo, state badge, and last-updated", () => { render(); expect(screen.getByRole("columnheader", { name: "Repository" })).toBeTruthy(); @@ -20,20 +28,44 @@ describe("RunHistoryView (#4305)", () => { expect(screen.getAllByRole("row")).toHaveLength(3); // header + 2 fixture rows }); - it("renders the fresh-install empty state without erroring", () => { + it("renders a content-shaped loading skeleton (role=status), not the old flat loading text (#6510)", () => { + render(); + expect(screen.getByRole("status", { name: /loading local run state/i })).toBeTruthy(); + expect(screen.queryByText("Loading local run state…")).toBeNull(); // the pre-#6510 sentence is gone + }); + + it("renders the shared StateBoundary error surface on an unreachable API (#6510)", () => { + render(); + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.getByText(/Couldn't read local run state/i)).toBeTruthy(); + }); + + it("renders the empty state via StateBoundary when there are no tracked repos (#6510)", () => { render(); expect(screen.getByText(/No local run state yet/i)).toBeTruthy(); expect(screen.queryByRole("table")).toBeNull(); }); - it("renders an error message when the local API is unreachable", () => { - render(); - expect(screen.getByRole("alert").textContent).toContain("connection refused"); + it("does not paginate at or below 20 rows — full table, no controls (#6510)", () => { + render(); + expect(screen.queryByRole("navigation", { name: /pagination/i })).toBeNull(); + expect(screen.getAllByRole("row")).toHaveLength(21); // header + all 20 rows shown }); - it("renders the loading state before the first result arrives", () => { - render(); - expect(screen.getByText(/Loading local run state/i)).toBeTruthy(); + it("paginates client-side above 20 rows, paging without any refetch (#6510)", () => { + render(); + expect(screen.getByRole("navigation", { name: /pagination/i })).toBeTruthy(); + // page 1: first 20 rows only + expect(screen.getAllByRole("row")).toHaveLength(21); + expect(screen.getByText("acme/repo-0")).toBeTruthy(); + expect(screen.queryByText("acme/repo-20")).toBeNull(); + // page 2 + fireEvent.click(screen.getByRole("link", { name: "2" })); + expect(screen.getByText("acme/repo-20")).toBeTruthy(); + expect(screen.queryByText("acme/repo-0")).toBeNull(); + // page 3 holds the remaining 5 rows (header + 5) + fireEvent.click(screen.getByRole("link", { name: "3" })); + expect(screen.getAllByRole("row")).toHaveLength(6); }); });