11import { createFileRoute } from "@tanstack/react-router" ;
2+ import { useState } from "react" ;
23
34import { Badge } from "@loopover/ui-kit/components/badge" ;
45import { Card , CardContent , CardHeader } from "@loopover/ui-kit/components/card" ;
6+ import {
7+ Pagination ,
8+ PaginationContent ,
9+ PaginationItem ,
10+ PaginationLink ,
11+ PaginationNext ,
12+ PaginationPrevious ,
13+ } from "@loopover/ui-kit/components/pagination" ;
14+ import { Skeleton } from "@loopover/ui-kit/components/skeleton" ;
15+ import { StateBoundary } from "@loopover/ui-kit/components/state-views" ;
516import { Table , TableBody , TableCell , TableHead , TableHeader , TableRow } from "@loopover/ui-kit/components/table" ;
617
718import { DEFAULT_POLL_INTERVAL_MS , usePolledFetch } from "../lib/use-polled-fetch" ;
@@ -12,8 +23,13 @@ export const Route = createFileRoute("/run-history")({
1223} ) ;
1324
1425// Read-only run-history table (#4305): one row per repo from the local `miner_run_state` store (repo, state,
15- // last-updated), served by the dev server's local API. No writes, no new state — a fresh install renders the
16- // empty state, an unreachable API renders an error message.
26+ // last-updated), served by the dev server's local API. No writes, no new state.
27+ //
28+ // #6510: the hand-rolled loading/error/empty `<p>` branches are replaced by the shared @loopover/ui-kit
29+ // `StateBoundary`, with a content-shaped `Skeleton` table for the loading state (so the layout doesn't jump when
30+ // the poll resolves), and the table paginates client-side once it exceeds PAGE_SIZE rows via the kit's
31+ // `Pagination`. Purely presentational — `lib/run-history.ts`'s fetch/poll is untouched, and the
32+ // Repository/State/Last-updated columns + data shown are unchanged.
1733
1834const STATE_BADGE_VARIANT : Record < RunStateRow [ "state" ] , "secondary" | "outline" > = {
1935 idle : "secondary" ,
@@ -22,35 +38,57 @@ const STATE_BADGE_VARIANT: Record<RunStateRow["state"], "secondary" | "outline">
2238 preparing : "outline" ,
2339} ;
2440
25- export function RunHistoryView ( { result } : { result : RunHistoryResult | null } ) {
26- if ( result === null ) {
27- return < p className = "text-token-sm text-muted-foreground" > Loading local run state…</ p > ;
28- }
29- if ( ! result . ok ) {
30- return (
31- < p role = "alert" className = "text-token-sm text-[var(--danger)]" >
32- Could not read local run state: { result . error }
33- </ p >
34- ) ;
35- }
36- if ( result . rows . length === 0 ) {
37- return (
38- < p className = "text-token-sm text-muted-foreground" >
39- No local run state yet — the table fills in once the miner records its first repo run.
40- </ p >
41- ) ;
42- }
41+ /** Rows per page once the run-state table grows past this; below it the full table renders unpaginated. */
42+ const PAGE_SIZE = 20 ;
43+
44+ const TABLE_COLUMNS = [ "Repository" , "State" , "Last updated" ] as const ;
45+
46+ function RunHistoryTableHeader ( ) {
47+ return (
48+ < TableHeader >
49+ < TableRow >
50+ { TABLE_COLUMNS . map ( ( column ) => (
51+ < TableHead key = { column } > { column } </ TableHead >
52+ ) ) }
53+ </ TableRow >
54+ </ TableHeader >
55+ ) ;
56+ }
57+
58+ /** Table-shaped loading placeholder: header + `rows` shimmer rows matching the real column layout, so the table
59+ * keeps its shape and the content doesn't jump once the poll resolves. `role="status"` keeps the loading state
60+ * announced to assistive tech (as the flat "Loading…" text it replaces was). */
61+ function RunHistorySkeleton ( { rows = 5 } : { rows ?: number } ) {
62+ return (
63+ < div role = "status" aria-label = "Loading local run state" >
64+ < Table >
65+ < RunHistoryTableHeader />
66+ < TableBody >
67+ { Array . from ( { length : rows } ) . map ( ( _ , index ) => (
68+ < TableRow key = { index } >
69+ < TableCell >
70+ < Skeleton className = "h-4 w-48" />
71+ </ TableCell >
72+ < TableCell >
73+ < Skeleton className = "h-5 w-20" />
74+ </ TableCell >
75+ < TableCell >
76+ < Skeleton className = "h-4 w-32" />
77+ </ TableCell >
78+ </ TableRow >
79+ ) ) }
80+ </ TableBody >
81+ </ Table >
82+ </ div >
83+ ) ;
84+ }
85+
86+ function RunStateTable ( { rows } : { rows : RunStateRow [ ] } ) {
4387 return (
4488 < Table >
45- < TableHeader >
46- < TableRow >
47- < TableHead > Repository</ TableHead >
48- < TableHead > State</ TableHead >
49- < TableHead > Last updated</ TableHead >
50- </ TableRow >
51- </ TableHeader >
89+ < RunHistoryTableHeader />
5290 < TableBody >
53- { result . rows . map ( ( row ) => (
91+ { rows . map ( ( row ) => (
5492 < TableRow key = { row . repoFullName } >
5593 < TableCell className = "font-mono text-foreground" > { row . repoFullName } </ TableCell >
5694 < TableCell >
@@ -64,6 +102,70 @@ export function RunHistoryView({ result }: { result: RunHistoryResult | null })
64102 ) ;
65103}
66104
105+ export function RunHistoryView ( { result } : { result : RunHistoryResult | null } ) {
106+ const [ page , setPage ] = useState ( 0 ) ;
107+ const rows = result ?. ok ? result . rows : [ ] ;
108+ const pageCount = Math . max ( 1 , Math . ceil ( rows . length / PAGE_SIZE ) ) ;
109+ const isPaginated = rows . length > PAGE_SIZE ;
110+ const safePage = Math . min ( page , pageCount - 1 ) ;
111+ const visibleRows = isPaginated ? rows . slice ( safePage * PAGE_SIZE , safePage * PAGE_SIZE + PAGE_SIZE ) : rows ;
112+
113+ return (
114+ < StateBoundary
115+ isLoading = { result === null }
116+ isError = { result !== null && ! result . ok }
117+ isEmpty = { result !== null && result . ok && result . rows . length === 0 }
118+ loadingSkeleton = { < RunHistorySkeleton /> }
119+ errorTitle = "Couldn't read local run state"
120+ errorDescription = "The local run-state API didn't respond. This refreshes automatically on the next poll."
121+ emptyTitle = "No local run state yet"
122+ emptyDescription = "The table fills in once the miner records its first repo run."
123+ >
124+ < RunStateTable rows = { visibleRows } />
125+ { isPaginated && (
126+ < Pagination className = "mt-4" >
127+ < PaginationContent >
128+ < PaginationItem >
129+ < PaginationPrevious
130+ href = "#"
131+ aria-disabled = { safePage === 0 }
132+ onClick = { ( event ) => {
133+ event . preventDefault ( ) ;
134+ setPage ( ( current ) => Math . max ( 0 , current - 1 ) ) ;
135+ } }
136+ />
137+ </ PaginationItem >
138+ { Array . from ( { length : pageCount } ) . map ( ( _ , index ) => (
139+ < PaginationItem key = { index } >
140+ < PaginationLink
141+ href = "#"
142+ isActive = { index === safePage }
143+ onClick = { ( event ) => {
144+ event . preventDefault ( ) ;
145+ setPage ( index ) ;
146+ } }
147+ >
148+ { index + 1 }
149+ </ PaginationLink >
150+ </ PaginationItem >
151+ ) ) }
152+ < PaginationItem >
153+ < PaginationNext
154+ href = "#"
155+ aria-disabled = { safePage >= pageCount - 1 }
156+ onClick = { ( event ) => {
157+ event . preventDefault ( ) ;
158+ setPage ( ( current ) => Math . min ( pageCount - 1 , current + 1 ) ) ;
159+ } }
160+ />
161+ </ PaginationItem >
162+ </ PaginationContent >
163+ </ Pagination >
164+ ) }
165+ </ StateBoundary >
166+ ) ;
167+ }
168+
67169export function RunHistoryPage ( {
68170 loadRunStates = fetchRunStates ,
69171 pollIntervalMs = DEFAULT_POLL_INTERVAL_MS ,
0 commit comments