diff --git a/apps/loopover-miner-ui/src/lib/demo-data.test.ts b/apps/loopover-miner-ui/src/lib/demo-data.test.ts index 3339a2165f..c6de071f08 100644 --- a/apps/loopover-miner-ui/src/lib/demo-data.test.ts +++ b/apps/loopover-miner-ui/src/lib/demo-data.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { DEMO_LEDGERS_SUMMARY, DEMO_PORTFOLIO_QUEUE_SUMMARY, + DEMO_RANKED_CANDIDATES, DEMO_RUN_STATES, getDemoGovernorState, getDemoPortfolioQueueItems, @@ -39,6 +40,16 @@ describe("demo fixtures shape (#5963)", () => { } }); + it("DEMO_RANKED_CANDIDATES is non-empty and every row's scores fall in the valid 0..1 fraction range", () => { + expect(DEMO_RANKED_CANDIDATES.length).toBeGreaterThan(0); + for (const row of DEMO_RANKED_CANDIDATES) { + for (const score of [row.rankScore, row.laneFit, row.freshness, row.potential, row.feasibility, row.dupRisk]) { + expect(score).toBeGreaterThanOrEqual(0); + expect(score).toBeLessThanOrEqual(1); + } + } + }); + it("DEMO_LEDGERS_SUMMARY's claim byStatus counts sum to its total", () => { const { total, byStatus } = DEMO_LEDGERS_SUMMARY.claims; expect(byStatus.active + byStatus.released + byStatus.expired).toBe(total); diff --git a/apps/loopover-miner-ui/src/lib/demo-data.ts b/apps/loopover-miner-ui/src/lib/demo-data.ts index 4e30d57ff2..393a67146e 100644 --- a/apps/loopover-miner-ui/src/lib/demo-data.ts +++ b/apps/loopover-miner-ui/src/lib/demo-data.ts @@ -5,10 +5,11 @@ // build-time constant, so the "off" branch (the real fetch calls) is dead-code-eliminated from a demo bundle and // vice versa -- a production self-host build never carries this module's data. // -// Scope: the five REST fetchers backing the three main dashboard routes (run-history, ledgers, portfolio + -// its queue actions, governor). discover/attempt/chat are NOT covered here -- those trigger a real coding-agent -// iteration or ground against a live MCP connection, and fabricating a convincing multi-minute agent run is a -// separate, much larger content-design task than tabular summary data (tracked as follow-up work, not this PR). +// Scope: the six REST fetchers backing the four main dashboard routes (run-history, ledgers, portfolio + its +// queue actions, governor, ranked-candidates (#7675)). discover/attempt/chat are NOT covered here -- those +// trigger a real coding-agent iteration or ground against a live MCP connection, and fabricating a convincing +// multi-minute agent run is a separate, much larger content-design task than tabular summary data (tracked as +// follow-up work, not this PR). // // Every value below is entirely synthetic -- no real repo, run, ledger entry, or account referenced anywhere. @@ -17,6 +18,7 @@ import type { LedgersSummary } from "./ledgers"; import type { PortfolioQueueSummary } from "./portfolio-queue"; import type { PortfolioQueueActionItem } from "./portfolio-queue-actions"; import type { GovernorPauseState } from "./governor"; +import type { RankedCandidateRow } from "./ranked-candidates"; export function isDemoMode(): boolean { return import.meta.env.VITE_DEMO_MODE === "1"; @@ -49,6 +51,48 @@ export const DEMO_RUN_STATES: RunStateRow[] = [ }, ]; +export const DEMO_RANKED_CANDIDATES: RankedCandidateRow[] = [ + { + repoFullName: "acme/widgets", + issueNumber: 214, + title: "Add retry helper for flaky forge requests", + htmlUrl: "https://github.com/acme/widgets/issues/214", + rankScore: 0.87, + laneFit: 0.92, + freshness: 0.81, + potential: 0.88, + feasibility: 0.79, + dupRisk: 0.05, + rankedAt: "2026-07-18T14:05:00.000Z", + }, + { + repoFullName: "acme/api-gateway", + issueNumber: 58, + title: "Cache the OpenAPI schema between requests", + htmlUrl: "https://github.com/acme/api-gateway/issues/58", + rankScore: 0.74, + laneFit: 0.7, + freshness: 0.66, + potential: 0.8, + feasibility: 0.72, + dupRisk: 0.12, + rankedAt: "2026-07-18T13:50:00.000Z", + }, + { + repoFullName: "northwind/inventory", + issueNumber: 133, + title: "Fix pagination cursor drift on the audit feed", + htmlUrl: null, + rankScore: 0.52, + laneFit: 0.48, + freshness: 0.55, + potential: 0.6, + feasibility: 0.5, + dupRisk: 0.35, + rankedAt: "2026-07-18T12:20:00.000Z", + }, +]; + export const DEMO_LEDGERS_SUMMARY: LedgersSummary = { claims: { total: 18, byStatus: { active: 3, released: 12, expired: 3 } }, events: { diff --git a/apps/loopover-miner-ui/src/lib/ranked-candidates.ts b/apps/loopover-miner-ui/src/lib/ranked-candidates.ts new file mode 100644 index 0000000000..0dc71f9321 --- /dev/null +++ b/apps/loopover-miner-ui/src/lib/ranked-candidates.ts @@ -0,0 +1,78 @@ +// Read-only client for the local ranked-candidates API (#7675). The dashboard is a browser app and the miner's +// discovery-ranking store is a `node:sqlite` file on disk, so the view never touches SQL — it fetches the dev +// server's local read-only endpoint (see `vite-ranked-candidates-api.ts`), which itself calls into +// `packages/loopover-miner/lib/ranked-candidates.js`'s existing exports. Mirrors `lib/run-history.ts`'s shape. +// +// This surfaces the SAME per-issue discovery breakdown (laneFit/freshness/potential/feasibility/dupRisk) the +// browser extension's opportunity badge already reads from this endpoint (#4859 prerequisite) — no ranking +// logic duplicated here, strictly a read-only view of already-existing data. + +import { DEMO_RANKED_CANDIDATES, isDemoMode } from "./demo-data"; + +export const RANKED_CANDIDATES_API_PATH = "/api/ranked-candidates"; + +/** One ranked-candidate row as served by the local API — mirrors `ranked-candidates.js`'s row shape. */ +export type RankedCandidateRow = { + repoFullName: string; + issueNumber: number; + title: string; + htmlUrl: string | null; + rankScore: number; + laneFit: number; + freshness: number; + potential: number; + feasibility: number; + dupRisk: number; + rankedAt: string; +}; + +export type RankedCandidatesResult = { ok: true; candidates: RankedCandidateRow[] } | { ok: false; error: string }; + +function isRankedCandidateRow(value: unknown): value is RankedCandidateRow { + if (typeof value !== "object" || value === null) return false; + const row = value as Record; + return ( + typeof row.repoFullName === "string" && + typeof row.issueNumber === "number" && + typeof row.title === "string" && + (row.htmlUrl === null || typeof row.htmlUrl === "string") && + typeof row.rankScore === "number" && + typeof row.laneFit === "number" && + typeof row.freshness === "number" && + typeof row.potential === "number" && + typeof row.feasibility === "number" && + typeof row.dupRisk === "number" && + typeof row.rankedAt === "string" + ); +} + +/** Stable React key / identity for a ranked-candidate row. */ +export function rankedCandidateRowKey(row: Pick): string { + return `${row.repoFullName}#${row.issueNumber}`; +} + +/** Render a 0..1 score fraction as a whole-percent string ("0.81" -> "81%"). */ +export function formatScorePercent(score: number): string { + return `${Math.round(score * 100)}%`; +} + +/** Fetch the local ranked-candidates rows. Failures (server down, malformed payload) surface as a typed error + * result — the view renders them as a message, never a crash. `fetchImpl` is injectable for tests. */ +export async function fetchRankedCandidates(fetchImpl: typeof fetch = fetch): Promise { + if (isDemoMode()) return { ok: true, candidates: DEMO_RANKED_CANDIDATES }; + try { + const response = await fetchImpl(RANKED_CANDIDATES_API_PATH); + if (!response.ok) return { ok: false, error: `local ranked-candidates API responded ${response.status}` }; + const payload: unknown = await response.json(); + const candidates = (payload as { candidates?: unknown }).candidates; + if (!Array.isArray(candidates) || !candidates.every(isRankedCandidateRow)) { + return { ok: false, error: "local ranked-candidates API returned an unexpected payload shape" }; + } + return { ok: true, candidates }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : "failed to reach the local ranked-candidates API", + }; + } +} diff --git a/apps/loopover-miner-ui/src/ranked-candidates.test.tsx b/apps/loopover-miner-ui/src/ranked-candidates.test.tsx new file mode 100644 index 0000000000..3b8dcf7a72 --- /dev/null +++ b/apps/loopover-miner-ui/src/ranked-candidates.test.tsx @@ -0,0 +1,225 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + fetchRankedCandidates, + formatScorePercent, + RANKED_CANDIDATES_API_PATH, + rankedCandidateRowKey, + type RankedCandidateRow, + type RankedCandidatesResult, +} from "./lib/ranked-candidates"; +import { RankedCandidatesPage, RankedCandidatesView } from "./routes/ranked-candidates"; + +const fixtureRows: RankedCandidateRow[] = [ + { + repoFullName: "acme/widgets", + issueNumber: 214, + title: "Add retry helper", + htmlUrl: "https://github.com/acme/widgets/issues/214", + rankScore: 0.81, + laneFit: 0.9, + freshness: 0.7, + potential: 0.85, + feasibility: 0.6, + dupRisk: 0.1, + rankedAt: "2026-07-13T12:00:00.000Z", + }, + { + repoFullName: "acme/gadgets", + issueNumber: 9, + title: "Fix flaky pagination test", + htmlUrl: null, + rankScore: 0.42, + laneFit: 0.4, + freshness: 0.3, + potential: 0.5, + feasibility: 0.45, + dupRisk: 0.6, + rankedAt: "2026-07-13T11:30:00.000Z", + }, +]; + +function manyRows(count: number): RankedCandidateRow[] { + return Array.from({ length: count }, (_, index) => ({ + repoFullName: `acme/repo-${index}`, + issueNumber: index, + title: `Issue ${index}`, + htmlUrl: null, + rankScore: 0.5, + laneFit: 0.5, + freshness: 0.5, + potential: 0.5, + feasibility: 0.5, + dupRisk: 0.5, + rankedAt: "2026-07-13T11:30:00.000Z", + })); +} + +describe("RankedCandidatesView (#7675)", () => { + it("renders one table row per candidate fixture row with the score breakdown columns", () => { + render(); + expect(screen.getByRole("columnheader", { name: "Issue" })).toBeTruthy(); + expect(screen.getByRole("columnheader", { name: "Rank score" })).toBeTruthy(); + expect(screen.getByRole("columnheader", { name: "Lane fit" })).toBeTruthy(); + expect(screen.getByRole("columnheader", { name: "Freshness" })).toBeTruthy(); + expect(screen.getByRole("columnheader", { name: "Potential" })).toBeTruthy(); + expect(screen.getByRole("columnheader", { name: "Feasibility" })).toBeTruthy(); + expect(screen.getByRole("columnheader", { name: "Dup risk" })).toBeTruthy(); + expect(screen.getByRole("columnheader", { name: "Ranked at" })).toBeTruthy(); + expect(screen.getByText("acme/widgets#214 Add retry helper")).toBeTruthy(); + expect(screen.getByText("acme/gadgets#9 Fix flaky pagination test")).toBeTruthy(); + expect(screen.getByText("81%")).toBeTruthy(); // rankScore + expect(screen.getByText("90%")).toBeTruthy(); // laneFit + expect(screen.getAllByRole("row")).toHaveLength(3); // header + 2 fixture rows + }); + + it("links the issue title to htmlUrl when present, but renders plain text when htmlUrl is null", () => { + render(); + const link = screen.getByRole("link", { name: "acme/widgets#214 Add retry helper" }); + expect(link.getAttribute("href")).toBe("https://github.com/acme/widgets/issues/214"); + expect(screen.queryByRole("link", { name: "acme/gadgets#9 Fix flaky pagination test" })).toBeNull(); + expect(screen.getByText("acme/gadgets#9 Fix flaky pagination test").tagName).toBe("SPAN"); + }); + + it("renders a content-shaped loading skeleton (role=status), not a flat loading message", () => { + render(); + expect(screen.getByRole("status", { name: /loading ranked candidates/i })).toBeTruthy(); + }); + + it("renders the shared StateBoundary error surface on an unreachable API", () => { + render(); + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.getByText(/Couldn't read ranked candidates/i)).toBeTruthy(); + }); + + it("renders the empty state via StateBoundary when there are no ranked candidates yet", () => { + render(); + expect(screen.getByText(/No ranked candidates yet/i)).toBeTruthy(); + expect(screen.queryByRole("table")).toBeNull(); + }); + + it("does not paginate at or below 20 rows — full table, no controls", () => { + render(); + expect(screen.queryByRole("navigation", { name: /pagination/i })).toBeNull(); + expect(screen.getAllByRole("row")).toHaveLength(21); // header + all 20 rows shown + }); + + it("paginates client-side above 20 rows, paging without any refetch", () => { + render(); + expect(screen.getByRole("navigation", { name: /pagination/i })).toBeTruthy(); + expect(screen.getAllByRole("row")).toHaveLength(21); + expect(screen.getByText(/Issue 0$/)).toBeTruthy(); + expect(screen.queryByText(/Issue 20$/)).toBeNull(); + fireEvent.click(screen.getByRole("link", { name: "2" })); + expect(screen.getByText(/Issue 20$/)).toBeTruthy(); + expect(screen.queryByText(/Issue 0$/)).toBeNull(); + fireEvent.click(screen.getByRole("link", { name: "3" })); + expect(screen.getAllByRole("row")).toHaveLength(6); // header + remaining 5 rows + }); +}); + +describe("RankedCandidatesPage (#7675)", () => { + it("loads candidates through the injected loader and renders them", async () => { + const loadRankedCandidates = async (): Promise => ({ + ok: true, + candidates: fixtureRows, + }); + render(); + expect(screen.getByRole("heading", { name: "Ranked candidates" })).toBeTruthy(); + await waitFor(() => expect(screen.getByText("acme/widgets#214 Add retry helper")).toBeTruthy()); + }); + + describe("live refresh", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("polls the injected loader again on the configured interval, without a manual page reload", async () => { + vi.useFakeTimers(); + const loadRankedCandidates = vi.fn(async (): Promise => ({ + ok: true, + candidates: fixtureRows, + })); + render(); + + await vi.waitFor(() => expect(loadRankedCandidates).toHaveBeenCalledTimes(1)); + await vi.advanceTimersByTimeAsync(1000); + await vi.waitFor(() => expect(loadRankedCandidates).toHaveBeenCalledTimes(2)); + }); + }); +}); + +describe("fetchRankedCandidates (#7675)", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + const jsonResponse = (status: number, payload: unknown) => + ({ ok: status >= 200 && status < 300, status, json: async () => payload }) as unknown as Response; + + it("returns typed candidates from a well-formed payload, requesting the local API path", async () => { + let requested: string | undefined; + const result = await fetchRankedCandidates(async (input) => { + requested = String(input); + return jsonResponse(200, { candidates: fixtureRows }); + }); + expect(requested).toBe(RANKED_CANDIDATES_API_PATH); + expect(result).toEqual({ ok: true, candidates: fixtureRows }); + }); + + it("surfaces a non-2xx response as a typed error", async () => { + const result = await fetchRankedCandidates(async () => jsonResponse(500, { error: "boom" })); + expect(result).toEqual({ ok: false, error: "local ranked-candidates API responded 500" }); + }); + + it("rejects a malformed payload shape (missing candidates / bad row fields)", async () => { + expect(await fetchRankedCandidates(async () => jsonResponse(200, { candidates: "nope" }))).toMatchObject({ + ok: false, + }); + expect( + await fetchRankedCandidates(async () => + jsonResponse(200, { candidates: [{ repoFullName: 1, issueNumber: "x" }] }), + ), + ).toMatchObject({ ok: false }); + // htmlUrl must be string or null -- anything else is rejected. + expect( + await fetchRankedCandidates(async () => + jsonResponse(200, { + candidates: [{ ...fixtureRows[0], htmlUrl: 42 }], + }), + ), + ).toMatchObject({ ok: false }); + }); + + it("surfaces a thrown fetch (server not running) as a typed error, never a crash", async () => { + const result = await fetchRankedCandidates(async () => { + throw new Error("connection refused"); + }); + expect(result).toEqual({ ok: false, error: "connection refused" }); + }); + + it("in demo mode, returns canned candidates without ever calling fetch", async () => { + vi.stubEnv("VITE_DEMO_MODE", "1"); + let called = false; + const result = await fetchRankedCandidates(async () => { + called = true; + return jsonResponse(200, { candidates: [] }); + }); + expect(called).toBe(false); + expect(result.ok).toBe(true); + }); +}); + +describe("formatScorePercent / rankedCandidateRowKey (#7675)", () => { + it("formats a 0..1 score fraction as a rounded whole-percent string", () => { + expect(formatScorePercent(0.81)).toBe("81%"); + expect(formatScorePercent(0)).toBe("0%"); + expect(formatScorePercent(1)).toBe("100%"); + expect(formatScorePercent(0.005)).toBe("1%"); // rounds up + }); + + it("builds a stable composite row key from repoFullName + issueNumber", () => { + expect(rankedCandidateRowKey({ repoFullName: "acme/widgets", issueNumber: 214 })).toBe("acme/widgets#214"); + }); +}); diff --git a/apps/loopover-miner-ui/src/routeTree.gen.ts b/apps/loopover-miner-ui/src/routeTree.gen.ts index 1b8ed3ea68..5d7d3f5059 100644 --- a/apps/loopover-miner-ui/src/routeTree.gen.ts +++ b/apps/loopover-miner-ui/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as RunHistoryRouteImport } from './routes/run-history' +import { Route as RankedCandidatesRouteImport } from './routes/ranked-candidates' import { Route as PortfolioRouteImport } from './routes/portfolio' import { Route as LedgersRouteImport } from './routes/ledgers' import { Route as EarningsRouteImport } from './routes/earnings' @@ -20,6 +21,11 @@ const RunHistoryRoute = RunHistoryRouteImport.update({ path: '/run-history', getParentRoute: () => rootRouteImport, } as any) +const RankedCandidatesRoute = RankedCandidatesRouteImport.update({ + id: '/ranked-candidates', + path: '/ranked-candidates', + getParentRoute: () => rootRouteImport, +} as any) const PortfolioRoute = PortfolioRouteImport.update({ id: '/portfolio', path: '/portfolio', @@ -46,6 +52,7 @@ export interface FileRoutesByFullPath { '/earnings': typeof EarningsRoute '/ledgers': typeof LedgersRoute '/portfolio': typeof PortfolioRoute + '/ranked-candidates': typeof RankedCandidatesRoute '/run-history': typeof RunHistoryRoute } export interface FileRoutesByTo { @@ -53,6 +60,7 @@ export interface FileRoutesByTo { '/earnings': typeof EarningsRoute '/ledgers': typeof LedgersRoute '/portfolio': typeof PortfolioRoute + '/ranked-candidates': typeof RankedCandidatesRoute '/run-history': typeof RunHistoryRoute } export interface FileRoutesById { @@ -61,15 +69,34 @@ export interface FileRoutesById { '/earnings': typeof EarningsRoute '/ledgers': typeof LedgersRoute '/portfolio': typeof PortfolioRoute + '/ranked-candidates': typeof RankedCandidatesRoute '/run-history': typeof RunHistoryRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/earnings' | '/ledgers' | '/portfolio' | '/run-history' + fullPaths: + | '/' + | '/earnings' + | '/ledgers' + | '/portfolio' + | '/ranked-candidates' + | '/run-history' fileRoutesByTo: FileRoutesByTo - to: '/' | '/earnings' | '/ledgers' | '/portfolio' | '/run-history' + to: + | '/' + | '/earnings' + | '/ledgers' + | '/portfolio' + | '/ranked-candidates' + | '/run-history' id: - '__root__' | '/' | '/earnings' | '/ledgers' | '/portfolio' | '/run-history' + | '__root__' + | '/' + | '/earnings' + | '/ledgers' + | '/portfolio' + | '/ranked-candidates' + | '/run-history' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -77,6 +104,7 @@ export interface RootRouteChildren { EarningsRoute: typeof EarningsRoute LedgersRoute: typeof LedgersRoute PortfolioRoute: typeof PortfolioRoute + RankedCandidatesRoute: typeof RankedCandidatesRoute RunHistoryRoute: typeof RunHistoryRoute } @@ -89,6 +117,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RunHistoryRouteImport parentRoute: typeof rootRouteImport } + '/ranked-candidates': { + id: '/ranked-candidates' + path: '/ranked-candidates' + fullPath: '/ranked-candidates' + preLoaderRoute: typeof RankedCandidatesRouteImport + parentRoute: typeof rootRouteImport + } '/portfolio': { id: '/portfolio' path: '/portfolio' @@ -125,6 +160,7 @@ const rootRouteChildren: RootRouteChildren = { EarningsRoute: EarningsRoute, LedgersRoute: LedgersRoute, PortfolioRoute: PortfolioRoute, + RankedCandidatesRoute: RankedCandidatesRoute, RunHistoryRoute: RunHistoryRoute, } export const routeTree = rootRouteImport diff --git a/apps/loopover-miner-ui/src/routes/__root.tsx b/apps/loopover-miner-ui/src/routes/__root.tsx index 187cc37f11..4a906dfb6c 100644 --- a/apps/loopover-miner-ui/src/routes/__root.tsx +++ b/apps/loopover-miner-ui/src/routes/__root.tsx @@ -20,6 +20,7 @@ function RootComponent() { const NAV_ITEMS = [ { to: "/", label: "Overview", exact: true }, { to: "/run-history", label: "Run history" }, + { to: "/ranked-candidates", label: "Ranked candidates" }, { to: "/portfolio", label: "Portfolio" }, { to: "/ledgers", label: "Ledgers" }, // #7673: layout reservation only — the route is an empty placeholder until settlement data exists. diff --git a/apps/loopover-miner-ui/src/routes/ranked-candidates.tsx b/apps/loopover-miner-ui/src/routes/ranked-candidates.tsx new file mode 100644 index 0000000000..2865062337 --- /dev/null +++ b/apps/loopover-miner-ui/src/routes/ranked-candidates.tsx @@ -0,0 +1,215 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; + +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"; +import { + fetchRankedCandidates, + formatScorePercent, + rankedCandidateRowKey, + type RankedCandidateRow, + type RankedCandidatesResult, +} from "../lib/ranked-candidates"; + +export const Route = createFileRoute("/ranked-candidates")({ + component: RankedCandidatesPage, +}); + +// Read-only ranked-candidates table (#7675): mirrors run-history.tsx's exact data-fetching, loading-state, and +// layout conventions (usePolledFetch + StateBoundary + a content-shaped Skeleton + client-side Pagination above +// PAGE_SIZE rows). `/api/ranked-candidates` already exposes the browser extension's opportunity-badge data -- +// the last discover run's full per-issue discovery breakdown (laneFit/freshness/potential/feasibility/dupRisk) +// -- this route is the first miner-ui dashboard consumer of it. Purely presentational: `lib/ranked-candidates.ts`'s +// fetch/poll and `vite-ranked-candidates-api.ts`'s ranking data are both untouched. + +/** Rows per page once the ranked-candidates table grows past this; below it the full table renders unpaginated. + * Same threshold as run-history / ledgers / portfolio (#6510/#6832/#6511). */ +const PAGE_SIZE = 20; + +const TABLE_COLUMNS = [ + "Issue", + "Rank score", + "Lane fit", + "Freshness", + "Potential", + "Feasibility", + "Dup risk", + "Ranked at", +] as const; + +function RankedCandidatesTableHeader() { + 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 (mirrors run-history's RunHistorySkeleton). + * `role="status"` keeps the loading state announced to assistive tech. */ +function RankedCandidatesSkeleton({ rows = 5 }: { rows?: number }) { + return ( +
+ + + + {Array.from({ length: rows }).map((_, index) => ( + + {TABLE_COLUMNS.map((column) => ( + + + + ))} + + ))} + +
+
+ ); +} + +function IssueCell({ row }: { row: RankedCandidateRow }) { + const label = `${row.repoFullName}#${row.issueNumber} ${row.title}`; + if (row.htmlUrl === null) { + return {label}; + } + return ( + + {label} + + ); +} + +function RankedCandidatesTable({ rows }: { rows: RankedCandidateRow[] }) { + return ( + + + + {rows.map((row) => ( + + + + + {formatScorePercent(row.rankScore)} + {formatScorePercent(row.laneFit)} + {formatScorePercent(row.freshness)} + {formatScorePercent(row.potential)} + {formatScorePercent(row.feasibility)} + {formatScorePercent(row.dupRisk)} + {row.rankedAt} + + ))} + +
+ ); +} + +export function RankedCandidatesView({ result }: { result: RankedCandidatesResult | null }) { + const [page, setPage] = useState(0); + const rows = result?.ok ? result.candidates : []; + 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 ranked candidates" + errorDescription="The local ranked-candidates API didn't respond. This refreshes automatically on the next poll." + emptyTitle="No ranked candidates yet" + emptyDescription="This fills in once the miner's discover step ranks its next batch of issues." + > + + {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 RankedCandidatesPage({ + loadRankedCandidates = fetchRankedCandidates, + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, +}: { + loadRankedCandidates?: () => Promise; + pollIntervalMs?: number; +}) { + const { result } = usePolledFetch(loadRankedCandidates, pollIntervalMs); + + return ( + + +

Ranked candidates

+

+ Local, read-only view over the miner's last discover run's per-issue ranking breakdown. +

+
+ + + +
+ ); +}