@@ -4,9 +4,17 @@ import { useEffect, useState } from "react";
44import { Button } from "@loopover/ui-kit/components/button" ;
55import { Card , CardContent , CardHeader } from "@loopover/ui-kit/components/card" ;
66import { Input } from "@loopover/ui-kit/components/input" ;
7+ import { Skeleton } from "@loopover/ui-kit/components/skeleton" ;
8+ import { StateBoundary } from "@loopover/ui-kit/components/state-views" ;
79import { Table , TableBody , TableCell , TableHead , TableHeader , TableRow } from "@loopover/ui-kit/components/table" ;
810
9- import { CLAIM_STATUSES , fetchLedgers , type ClaimStatus , type LedgersResult } from "../lib/ledgers" ;
11+ import {
12+ CLAIM_STATUSES ,
13+ fetchLedgers ,
14+ type ClaimStatus ,
15+ type LedgersResult ,
16+ type LedgersSummary ,
17+ } from "../lib/ledgers" ;
1018import { fetchGovernorPauseState , pauseGovernor , resumeGovernor , type GovernorPauseStateResult } from "../lib/governor" ;
1119
1220export const Route = createFileRoute ( "/ledgers" ) ( {
@@ -15,8 +23,15 @@ export const Route = createFileRoute("/ledgers")({
1523
1624// Read-only views over the miner's local claim / event / governor ledgers (#4855). All three are aggregated
1725// server-side (see vite-ledgers-api.ts) to status/type counts plus a small feed of SAFE columns — raw payloads
18- // and the free-text claim note never reach this component. Same 4-state pattern as the portfolio/run-history
19- // views (loading / error / fresh-install empty / populated).
26+ // and the free-text claim note never reach this component.
27+ //
28+ // #6512: the two hand-rolled loading/error/empty `<p>` blocks (the read-only ledger summary, and the SEPARATE
29+ // governor control section) are each replaced by the shared @loopover/ui-kit `StateBoundary`, with a
30+ // content-shaped `Skeleton` placeholder for the loading state so the layout doesn't jump when the poll resolves.
31+ // The two flows keep their OWN independent boundary — a governor-state fetch failure must not blank the ledger
32+ // summary, and vice-versa. Purely presentational: `lib/ledgers.ts`/`lib/governor.ts`, the two fetch loops, and
33+ // the pause/resume Button wiring are untouched; only the loading/error chrome and the governor-control layout
34+ // change.
2035//
2136// The governor control section below is a SEPARATE fetch/action loop from the read-only ledger summary above
2237// (#4857, the governor half): it reads/writes the governor's pause state via vite-governor-api.ts, the
@@ -57,81 +72,54 @@ function CountTable({ counts, keyLabel }: { counts: Record<string, number>; keyL
5772 ) ;
5873}
5974
60- export function GovernorControlSection ( {
61- result,
62- pending,
63- onPause,
64- onResume,
65- } : {
66- result : GovernorPauseStateResult | null ;
67- pending : boolean ;
68- onPause : ( reason ?: string ) => void ;
69- onResume : ( ) => void ;
70- } ) {
71- // Optional pause reason, mirroring the CLI's `governor pause [--reason <text>]`; an empty field
72- // is passed through as `undefined` so it matches the CLI's own optional-flag behavior.
73- const [ reason , setReason ] = useState ( "" ) ;
75+ /** Card+table-shaped loading placeholder for the ledger summary: mirrors the 3 status cards and the stacked
76+ * count/feed tables below them, so the summary keeps its shape while the first fetch resolves. `role="status"`
77+ * keeps the loading state announced to assistive tech (as the flat "Loading local ledgers…" text it replaces
78+ * was). */
79+ function LedgerSummarySkeleton ( ) {
7480 return (
75- < section className = "grid gap-3" >
76- < h3 className = "font-display text-token-base font-semibold" > Governor control</ h3 >
77- { result === null ? (
78- < p className = "text-token-sm text-muted-foreground" > Loading governor state…</ p >
79- ) : ! result . ok ? (
80- < p role = "alert" className = "text-token-sm text-[var(--danger)]" >
81- Could not read the local governor state: { result . error }
82- </ p >
83- ) : (
84- < div className = "flex flex-wrap items-center gap-3" >
85- < p className = "text-token-sm text-muted-foreground" >
86- { result . pauseState . paused
87- ? `Paused since ${ result . pauseState . pausedAt } ${ result . pauseState . reason ? ` (${ result . pauseState . reason } )` : "" } `
88- : "Not paused" }
89- </ p >
90- { result . pauseState . paused ? (
91- < Button size = "sm" variant = "outline" disabled = { pending } onClick = { onResume } >
92- Resume governor
93- </ Button >
94- ) : (
95- < >
96- < Input
97- type = "text"
98- value = { reason }
99- onChange = { ( event ) => setReason ( event . target . value ) }
100- disabled = { pending }
101- placeholder = "Reason (optional)"
102- aria-label = "Pause reason"
103- className = "w-auto flex-1 min-w-[12rem]"
104- />
105- < Button size = "sm" variant = "destructive" disabled = { pending } onClick = { ( ) => onPause ( reason || undefined ) } >
106- Pause governor
107- </ Button >
108- </ >
109- ) }
81+ < div className = "grid gap-6" role = "status" aria-label = "Loading local ledgers" >
82+ < section className = "grid gap-3" >
83+ < Skeleton className = "h-5 w-28" />
84+ < div className = "grid gap-4 sm:grid-cols-3" >
85+ { Array . from ( { length : 3 } ) . map ( ( _ , index ) => (
86+ < Card key = { index } >
87+ < CardContent className = "p-4" >
88+ < Skeleton className = "h-3 w-16" />
89+ < Skeleton className = "mt-2 h-8 w-12" />
90+ </ CardContent >
91+ </ Card >
92+ ) ) }
11093 </ div >
111- ) }
112- </ section >
94+ </ section >
95+ { Array . from ( { length : 2 } ) . map ( ( _ , index ) => (
96+ < section key = { index } className = "grid gap-3" >
97+ < Skeleton className = "h-5 w-40" />
98+ < Skeleton className = "h-24 w-full" />
99+ </ section >
100+ ) ) }
101+ </ div >
113102 ) ;
114103}
115104
116- export function LedgersView ( { result } : { result : LedgersResult | null } ) {
117- if ( result === null ) {
118- return < p className = "text-token-sm text-muted-foreground" > Loading local ledgers…</ p > ;
119- }
120- if ( ! result . ok ) {
121- return (
122- < p role = "alert" className = "text-token-sm text-[var(--danger)]" >
123- Could not read the local ledgers: { result . error }
124- </ p >
125- ) ;
126- }
127- const { claims, events, governor } = result . summary ;
128- if ( claims . total === 0 && events . total === 0 && governor . total === 0 ) {
129- return (
130- < p className = "text-token-sm text-muted-foreground" >
131- No ledger activity yet — claims, events, and governor entries appear here once the miner starts working.
132- </ p >
133- ) ;
134- }
105+ /** Row-shaped loading placeholder for the governor control section: a status line plus an action-sized block,
106+ * matching the "Not paused / Resume" row it stands in for. Its own `role="status"` announces this flow
107+ * independently of the ledger-summary skeleton above. */
108+ function GovernorControlSkeleton ( ) {
109+ return (
110+ < div
111+ className = "flex flex-wrap items-center gap-3 rounded-token-sm bg-muted/40 p-3"
112+ role = "status"
113+ aria-label = "Loading governor state"
114+ >
115+ < Skeleton className = "h-5 w-40" />
116+ < Skeleton className = "h-9 w-28" />
117+ </ div >
118+ ) ;
119+ }
120+
121+ function LedgersSummaryContent ( { summary } : { summary : LedgersSummary } ) {
122+ const { claims, events, governor } = summary ;
135123 return (
136124 < div className = "grid gap-6" >
137125 < section className = "grid gap-3" >
@@ -199,6 +187,86 @@ export function LedgersView({ result }: { result: LedgersResult | null }) {
199187 ) ;
200188}
201189
190+ export function GovernorControlSection ( {
191+ result,
192+ pending,
193+ onPause,
194+ onResume,
195+ } : {
196+ result : GovernorPauseStateResult | null ;
197+ pending : boolean ;
198+ onPause : ( reason ?: string ) => void ;
199+ onResume : ( ) => void ;
200+ } ) {
201+ // Optional pause reason, mirroring the CLI's `governor pause [--reason <text>]`; an empty field
202+ // is passed through as `undefined` so it matches the CLI's own optional-flag behavior.
203+ const [ reason , setReason ] = useState ( "" ) ;
204+ const errorText = result !== null && ! result . ok ? result . error : undefined ;
205+ return (
206+ < section className = "grid gap-3" >
207+ < h3 className = "font-display text-token-base font-semibold" > Governor control</ h3 >
208+ < StateBoundary
209+ isLoading = { result === null }
210+ isError = { result !== null && ! result . ok }
211+ loadingSkeleton = { < GovernorControlSkeleton /> }
212+ errorTitle = "Couldn't read the local governor state"
213+ errorDescription = { errorText }
214+ >
215+ { result ?. ok && (
216+ < div className = "flex flex-wrap items-center gap-3 rounded-token-sm bg-muted/40 p-3" >
217+ < p className = "text-token-sm text-muted-foreground" >
218+ { result . pauseState . paused
219+ ? `Paused since ${ result . pauseState . pausedAt } ${ result . pauseState . reason ? ` (${ result . pauseState . reason } )` : "" } `
220+ : "Not paused" }
221+ </ p >
222+ { result . pauseState . paused ? (
223+ < Button size = "sm" variant = "outline" disabled = { pending } onClick = { onResume } className = "ml-auto" >
224+ Resume governor
225+ </ Button >
226+ ) : (
227+ < div className = "ml-auto flex flex-wrap items-center gap-3" >
228+ < Input
229+ type = "text"
230+ value = { reason }
231+ onChange = { ( event ) => setReason ( event . target . value ) }
232+ disabled = { pending }
233+ placeholder = "Reason (optional)"
234+ aria-label = "Pause reason"
235+ className = "w-auto flex-1 min-w-[12rem]"
236+ />
237+ < Button size = "sm" variant = "destructive" disabled = { pending } onClick = { ( ) => onPause ( reason || undefined ) } >
238+ Pause governor
239+ </ Button >
240+ </ div >
241+ ) }
242+ </ div >
243+ ) }
244+ </ StateBoundary >
245+ </ section >
246+ ) ;
247+ }
248+
249+ export function LedgersView ( { result } : { result : LedgersResult | null } ) {
250+ const summary = result ?. ok ? result . summary : null ;
251+ const isEmpty =
252+ summary !== null && summary . claims . total === 0 && summary . events . total === 0 && summary . governor . total === 0 ;
253+ const errorText = result !== null && ! result . ok ? result . error : undefined ;
254+ return (
255+ < StateBoundary
256+ isLoading = { result === null }
257+ isError = { result !== null && ! result . ok }
258+ isEmpty = { isEmpty }
259+ loadingSkeleton = { < LedgerSummarySkeleton /> }
260+ errorTitle = "Couldn't read the local ledgers"
261+ errorDescription = { errorText }
262+ emptyTitle = "No ledger activity yet"
263+ emptyDescription = "Claims, events, and governor entries appear here once the miner starts working."
264+ >
265+ { summary && < LedgersSummaryContent summary = { summary } /> }
266+ </ StateBoundary >
267+ ) ;
268+ }
269+
202270export function LedgersPage ( {
203271 loadLedgers = fetchLedgers ,
204272 loadGovernorPauseState = fetchGovernorPauseState ,
0 commit comments