From 0befbe86a2db3db361078b998f51a9e42586fbe3 Mon Sep 17 00:00:00 2001
From: jaytbarimbao-collab
<300663773+jaytbarimbao-collab@users.noreply.github.com>
Date: Thu, 16 Jul 2026 12:18:08 -0400
Subject: [PATCH] feat(miner-ui): redesign run-history route with
StateBoundary, skeleton, pagination
Replace run-history.tsx's three hand-rolled loading/error/empty
branches
with the shared @loopover/ui-kit StateBoundary (matching the Overview
redesign, #6509): a content-shaped Skeleton table for the loading state so
the layout doesn't jump when the poll resolves, ErrorState for an unreachable
API, and EmptyState for a fresh install. The table paginates client-side via
the kit's Pagination once it exceeds 20 rows; at or below 20 it renders the
full table with no controls, exactly as before.
Purely presentational: lib/run-history.ts's fetch + 10s poll cadence is
untouched (byte-for-byte), and the Repository/State/Last-updated columns and
data shown are unchanged. Tests cover the skeleton loading state, the
StateBoundary error/empty surfaces, and pagination absent (<=20) vs present
and paging (>20) with no extra fetch.
Closes #6510
---
.../src/routes/run-history.tsx | 158 ++++++++++++++----
.../src/run-history.test.tsx | 50 +++++-
2 files changed, 171 insertions(+), 37 deletions(-)
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);
});
});