diff --git a/app/src/App.tsx b/app/src/App.tsx index 9867c62..8402993 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -26,14 +26,15 @@ import { SkeletonList } from "./components/SkeletonCard"; import { EmptyState } from "./components/EmptyState"; import { StateFilter } from "./components/StateFilter"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { BoardTable } from "./components/BoardTable"; +import { BoardPreviewRail } from "./components/BoardPreviewRail"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import type { LucideIcon } from "lucide-react"; import { Search, LayoutGrid, - Rows3, + Table2, FileText, Eye, Rocket, @@ -44,8 +45,11 @@ import { buildBoardItems } from "./lib/stack-tree"; import { isFastLane } from "./lib/attention"; import { collectFleetReviews } from "./lib/fleet"; import { agentActionLine } from "./lib/agent-event"; -import type { CardDensity } from "./components/ReviewCard"; +import { useMediaQuery } from "./hooks/useMediaQuery"; +import type { BoardDensity } from "./lib/board-density"; +import { loadDensity, saveDensity } from "./lib/board-density"; import type { GateState } from "./bindings/GateState"; +import type { DiffSide } from "./bindings/DiffSide"; import type { Review } from "./bindings/Review"; import type { Project } from "./bindings/Project"; import type { AgentMode } from "./bindings/AgentMode"; @@ -83,14 +87,13 @@ interface AgentEventEnvelope { type ReviewTab = "my-prs" | "review-requests" | "all"; const SIDEBAR_COLLAPSED_KEY = "cockpit-sidebar-collapsed"; -const PRS_DENSITY_KEY = "cockpit-prs-density"; -/** Read the persisted board density, defaulting to the roomy card layout. */ -function loadDensity(): CardDensity { - return localStorage.getItem(PRS_DENSITY_KEY) === "compact" - ? "compact" - : "cards"; -} +/** + * Minimum window width (px) at which the board has room for the preview rail. + * Below this the rail is not rendered; selection still works and Enter / + * double-click opens the review directly. + */ +const RAIL_MIN_WIDTH_QUERY = "(min-width: 1280px)"; function assertNever(x: never): never { throw new Error(`unreachable: ${String(x)}`); @@ -276,13 +279,19 @@ function App() { // the same pending request never double-notifies (one notification per id). const notifiedPermissionIds = useRef>(new Set()); - const [prsDensity, setPrsDensity] = useState(loadDensity); + const [density, setBoardDensity] = useState(loadDensity); - const setDensity = useCallback((density: CardDensity) => { - setPrsDensity(density); - localStorage.setItem(PRS_DENSITY_KEY, density); + const setDensity = useCallback((next: BoardDensity) => { + setBoardDensity(next); + saveDensity(next); }, []); + // Triage-table selection (drives the preview rail), by PR ref. + const [selectedPr, setSelectedPr] = useState(null); + const requestDiffJump = useAppStore((s) => s.requestDiffJump); + // Only render the preview rail when the window is wide enough for it. + const railFits = useMediaQuery(RAIL_MIN_WIDTH_QUERY); + const toggleSidebar = useCallback(() => { setSidebarCollapsed((prev) => { const next = !prev; @@ -630,6 +639,29 @@ function App() { [restackPr], ); + // Open a review's workspace directly (row double-click / Enter, rail footer). + // The Briefing-vs-Diff landing decision lives in the workspace itself. + const handleOpenReview = useCallback( + (pr: string) => { + void navigateToDiff(pr); + }, + [navigateToDiff], + ); + + const clearSelection = useCallback(() => { + setSelectedPr(null); + }, []); + + // Reveal a rail finding in the review's Diff tab: publish the jump, then open + // the review (DiffView consumes the pending jump for its own PR). + const handleRailJump = useCallback( + (pr: string, path: string, side: DiffSide, line: number) => { + requestDiffJump(pr, path, side, line); + void navigateToDiff(pr); + }, + [requestDiffJump, navigateToDiff], + ); + // Opening a project always routes to its plan gate. When the project has no // plan yet, PlanView surfaces a "Generate plan" affordance. const handleOpenProject = useCallback( @@ -696,6 +728,7 @@ function App() { setStateFilter(null); setShowStale(false); setSearchQuery(""); + setSelectedPr(null); if (tab === "my-prs") void fetchAuthoredPrs(); if (tab === "review-requests") void fetchReviewRequests(); if (tab === "all") void fetchReviews(); @@ -731,13 +764,15 @@ function App() { ({group.reviews.length}) -
+ {/* Cards flow into a responsive multi-column grid so wide windows carry + several columns instead of one centered stripe. */} +
{buildBoardItems(group.reviews).map((item) => item.kind === "single" ? ( @@ -746,7 +781,7 @@ function App() { key={item.root.review.id} root={item.root} nodes={item.nodes} - density={prsDensity} + density="cards" onAction={handleReviewAction} onRestack={handleRestack} /> @@ -761,7 +796,7 @@ function App() { {fastLane.length > 0 && ( @@ -771,32 +806,114 @@ function App() { ); } - function renderPrsContent(items: readonly Review[], emptyIcon: LucideIcon, emptyTitle: string, emptyDescription: string) { - if (prFetchLoading && items.length === 0) { - return ; + /** The empty-state card for the active tab. */ + function renderEmpty() { + switch (reviewTab) { + case "my-prs": + return ( + + ); + case "review-requests": + return ( + + ); + case "all": + return ( + + ); + default: + return assertNever(reviewTab); } - const grouped = renderProjectGroupedList(items); - if (grouped === null) { + } + + /** + * The board body for the active tab, in the current density. The `table` + * density is a flat, keyboard-navigable triage list paired with a preview + * rail (wide windows only); the `cards` density keeps the fast-lane shelf and + * project-grouped responsive card grid. + */ + function renderBoardBody() { + const items = reviewsForTab; + const loadingNow = reviewTab === "all" ? loading : prFetchLoading; + + if (loadingNow && items.length === 0) { return ( - +
+ +
+ ); + } + + if (density === "table") { + const filtered = filterReviews(items); + const selectedReview = + filtered.find((r) => r.pr === selectedPr) ?? null; + return ( + <> +
+ {filtered.length === 0 ? ( +
{renderEmpty()}
+ ) : ( + + )} +
+ {railFits && selectedReview !== null && ( + + )} + ); } - return grouped; + + // Cards density: fast-lane shelf + project-grouped responsive grid. + const grouped = renderProjectGroupedList(items); + return ( +
+ {grouped ?? renderEmpty()} +
+ ); } function renderContent() { switch (view.kind) { case "prs": return ( -
- {errorBanner} - - -
+
+ {/* Header: tabs + refresh, then search + filters + density. Fixed at + the top so the board fills the rest of the window full-width. */} + + {errorBanner} +
Mine @@ -841,7 +958,7 @@ function App() {
-
+
- {/* Density toggle: roomy cards vs. dense telemetry rows. */} + {/* Density toggle: roomy card grid vs. dense triage table. */}
{ setDensity("cards"); }} - aria-pressed={prsDensity === "cards"} + aria-pressed={density === "cards"} title="Card view" className={cn( "rounded-md p-1.5 transition-colors", - prsDensity === "cards" + density === "cards" ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground", )} @@ -890,56 +1007,25 @@ function App() {
- - - {renderPrsContent( - authoredPrs, - FileText, - "No open PRs", - "Click Refresh to fetch your open PRs from GitHub. Make sure your repo path is configured in Settings.", - )} - - - - {renderPrsContent( - reviewRequests, - Eye, - "No review requests", - "No PRs are waiting for your review. Click Refresh to check again.", - )} - - - - {loading && reviews.length === 0 ? ( - - ) : ( - (renderProjectGroupedList(reviews) ?? ( - - )) - )} - + + {/* Body fills the remaining height; table + rail scroll independently. */} +
{renderBoardBody()}
); diff --git a/app/src/components/BoardPreviewRail.test.tsx b/app/src/components/BoardPreviewRail.test.tsx new file mode 100644 index 0000000..046cd36 --- /dev/null +++ b/app/src/components/BoardPreviewRail.test.tsx @@ -0,0 +1,96 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { BoardPreviewRail } from "./BoardPreviewRail"; +import { makeReview, makeFinding } from "../test/fixtures"; + +function renderRail( + overrides: Partial[0]> = {}, +) { + const props = { + review: makeReview({ + branch: "alejandro/feature", + gate_state: "InReview" as const, + distilled_intent: "Streams the parser instead of buffering it.", + }), + onOpen: vi.fn<(pr: string) => void>(), + onJumpTo: vi.fn(), + ...overrides, + }; + render(); + return props; +} + +describe("BoardPreviewRail — header", () => { + it("renders the branch and a gate pill", () => { + renderRail(); + expect(screen.getByText("alejandro/feature")).toBeInTheDocument(); + expect(screen.getByText("In Review")).toBeInTheDocument(); + }); + + it("renders the agent's read when an intent is present", () => { + renderRail(); + expect(screen.getByText("AGENT'S READ")).toBeInTheDocument(); + expect( + screen.getByText("Streams the parser instead of buffering it."), + ).toBeInTheDocument(); + }); +}); + +describe("BoardPreviewRail — findings", () => { + it("caps the findings preview and shows an overflow count", () => { + const findings = Array.from({ length: 5 }, (_, i) => + makeFinding({ id: `f${String(i)}`, severity: "Warning", range: [0, 0] }), + ); + renderRail({ + review: makeReview({ + distilled_intent: null, + review_findings: findings, + }), + }); + expect(screen.getByText(/\+2 more finding/)).toBeInTheDocument(); + }); +}); + +describe("BoardPreviewRail — footer", () => { + it("opens the review from the footer button", async () => { + const user = userEvent.setup(); + const props = renderRail({ + review: makeReview({ + pr: "https://github.com/o/r/pull/9", + distilled_intent: null, + }), + }); + await user.click(screen.getByRole("button", { name: /Open review/ })); + expect(props.onOpen).toHaveBeenCalledWith("https://github.com/o/r/pull/9"); + }); + + it("shows a fresh recap line when the recap sha matches the head", () => { + renderRail({ + review: makeReview({ + distilled_intent: null, + head_sha: "abcdef1234", + recap_sha: "abcdef1234", + }), + }); + expect(screen.getByText(/briefing fresh/)).toBeInTheDocument(); + }); + + it("shows a stale recap line when the recap sha differs from the head", () => { + renderRail({ + review: makeReview({ + distilled_intent: null, + head_sha: "abcdef1234", + recap_sha: "999999aaaa", + }), + }); + expect(screen.getByText(/briefing stale/)).toBeInTheDocument(); + }); + + it("omits the recap line entirely when no recap has been generated", () => { + renderRail({ + review: makeReview({ distilled_intent: null, recap_sha: null }), + }); + expect(screen.queryByText(/briefing/)).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/components/BoardPreviewRail.tsx b/app/src/components/BoardPreviewRail.tsx new file mode 100644 index 0000000..f6d630a --- /dev/null +++ b/app/src/components/BoardPreviewRail.tsx @@ -0,0 +1,187 @@ +/** + * The board's briefing preview rail (mockup D1). + * + * A right-hand rail that previews the currently selected triage row without + * leaving the board: gate header, the agent's read, a severity + size + CI chip + * strip, the top few findings, and a footer that opens the full review. Every + * datum comes from the `Review` already in the store — no fetch — including recap + * freshness, which is derivable from `recap_sha` vs. the head. The caller decides + * whether there is room to render it (below ~1280px it is omitted); this + * component just renders the panel for a given review. + */ + +import { ArrowRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { Review } from "../bindings/Review"; +import type { DiffSide } from "../bindings/DiffSide"; +import { GatePill } from "./GatePill"; +import { StatusLed } from "./StatusLed"; +import { IntentHero } from "./IntentHero"; +import { BriefingFindings } from "./BriefingFindings"; +import { parsePrDisplay } from "../lib/pr-ref"; +import { findingCounts, severityMeta } from "../lib/finding-severity"; +import { diffTotals, sizeClass, sizeClassLabel } from "../lib/diff-signals"; +import { compactNumber } from "../lib/format-number"; +import { ciState } from "../lib/ci"; +import { recapStale, shortSha } from "../lib/recap"; + +/** How many findings the rail previews before deferring to the full review. */ +const RAIL_FINDING_LIMIT = 3; + +interface BoardPreviewRailProps { + /** The selected review to preview. */ + readonly review: Review; + /** Open the full review workspace. */ + readonly onOpen: (pr: string) => void; + /** Reveal a finding in the review's Diff tab (opens the review, then jumps). */ + readonly onJumpTo: ( + pr: string, + path: string, + side: DiffSide, + line: number, + ) => void; +} + +/** The severity + size + CI chip strip. Renders nothing when it has no signal. */ +function SignalStrip({ review }: { readonly review: Review }) { + const totals = diffTotals(review.diff.raw); + const counts = findingCounts(review.review_findings); + const ci = review.ci_summary; + const hasDiff = totals.additions > 0 || totals.deletions > 0; + + const severities = [ + { count: counts.critical, meta: severityMeta("Critical") }, + { count: counts.warning, meta: severityMeta("Warning") }, + { count: counts.info, meta: severityMeta("Info") }, + ].filter((s) => s.count > 0); + + if (severities.length === 0 && !hasDiff && (ci === null || ci.total === 0)) { + return null; + } + + return ( +
+ {severities.map((s) => ( + + + ))} + + {hasDiff && ( + + + {sizeClassLabel(sizeClass(totals.additions, totals.deletions))} + + +{compactNumber(totals.additions)} + −{compactNumber(totals.deletions)} + + )} + + {ci !== null && ci.total > 0 && ( + + CI {String(ci.passed)}/{String(ci.total)} + + )} +
+ ); +} + +/** The recap freshness line, or null when no recap has been generated. */ +function RecapFreshness({ review }: { readonly review: Review }) { + if (review.recap_sha === null) return null; + const stale = recapStale(review.recap_sha, review.head_sha); + return ( +

+ {stale ? "briefing stale" : "briefing fresh"} · {shortSha(review.recap_sha)} +

+ ); +} + +/** The briefing preview rail for a selected review. */ +export function BoardPreviewRail({ + review, + onOpen, + onJumpTo, +}: BoardPreviewRailProps) { + const { repo, number: prNumber } = parsePrDisplay(review.pr); + const ci = review.ci_summary; + + return ( +
+ {/* Header */} +
+
+ + + {review.branch} + + +
+
+ {repo !== "" && ( + + {repo}#{prNumber} + + )} + {review.issue} + {ci !== null && ci.total > 0 && ( + · CI {ciState(ci)} + )} +
+
+ + {/* Agent's read */} + {review.distilled_intent !== null && ( + + )} + + {/* Severity + size + CI chips */} + + + {/* Top findings */} +
+ { + onJumpTo(review.pr, path, side, line); + }} + /> +
+ + {/* Footer */} +
+ + +
+
+ ); +} diff --git a/app/src/components/BoardTable.test.tsx b/app/src/components/BoardTable.test.tsx new file mode 100644 index 0000000..aefc8d4 --- /dev/null +++ b/app/src/components/BoardTable.test.tsx @@ -0,0 +1,122 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { BoardTable } from "./BoardTable"; +import { makeReview } from "../test/fixtures"; + +/** A diff adding `n` lines, for the signals column. */ +function addLines(path: string, n: number): string { + let s = `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -0,0 +1,${String(n)} @@\n`; + for (let i = 0; i < n; i++) s += `+row ${String(i)}\n`; + return s; +} + +const reviews = [ + makeReview({ + id: "rev-1", + pr: "https://github.com/o/r/pull/1", + branch: "alejandro/first", + gate_state: "InReview", + diff: { raw: addLines("a.txt", 10) }, + }), + makeReview({ + id: "rev-2", + pr: "https://github.com/o/r/pull/2", + branch: "alejandro/second", + gate_state: "Approved", + }), +]; + +function renderTable(overrides: Partial[0]> = {}) { + const props = { + reviews, + selectedPr: null, + onSelect: vi.fn<(pr: string) => void>(), + onOpen: vi.fn<(pr: string) => void>(), + onAction: vi.fn<(pr: string) => void>(), + onClearSelection: vi.fn<() => void>(), + ...overrides, + }; + render(); + return props; +} + +describe("BoardTable — rows", () => { + it("renders a row per review with the branch and its per-state action", () => { + renderTable(); + expect(screen.getByText("alejandro/first")).toBeInTheDocument(); + expect(screen.getByText("alejandro/second")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Review" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "View" })).toBeInTheDocument(); + }); + + it("renders a listbox of option rows", () => { + renderTable(); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + expect(screen.getAllByRole("option")).toHaveLength(2); + }); +}); + +describe("BoardTable — selection", () => { + it("selects a row when its body is clicked", async () => { + const user = userEvent.setup(); + const props = renderTable(); + await user.click(screen.getByText("alejandro/first")); + expect(props.onSelect).toHaveBeenCalledWith("https://github.com/o/r/pull/1"); + }); + + it("marks the selected row aria-selected", () => { + renderTable({ selectedPr: "https://github.com/o/r/pull/2" }); + const options = screen.getAllByRole("option"); + const selected = options.filter( + (o) => o.getAttribute("aria-selected") === "true", + ); + expect(selected).toHaveLength(1); + expect(selected[0]).toHaveTextContent("alejandro/second"); + }); + + it("opens (not selects) on double click", async () => { + const user = userEvent.setup(); + const props = renderTable(); + await user.dblClick(screen.getByText("alejandro/first")); + expect(props.onOpen).toHaveBeenCalledWith("https://github.com/o/r/pull/1"); + }); +}); + +describe("BoardTable — action button", () => { + it("runs the action without selecting the row", async () => { + const user = userEvent.setup(); + const props = renderTable(); + await user.click(screen.getByRole("button", { name: "Review" })); + expect(props.onAction).toHaveBeenCalledWith( + "https://github.com/o/r/pull/1", + ); + expect(props.onSelect).not.toHaveBeenCalled(); + }); +}); + +describe("BoardTable — keyboard", () => { + it("moves the selection down with ArrowDown", async () => { + const user = userEvent.setup(); + const props = renderTable({ selectedPr: "https://github.com/o/r/pull/1" }); + screen.getByRole("listbox").focus(); + await user.keyboard("{ArrowDown}"); + expect(props.onSelect).toHaveBeenCalledWith("https://github.com/o/r/pull/2"); + }); + + it("opens the selected review on Enter", async () => { + const user = userEvent.setup(); + const props = renderTable({ selectedPr: "https://github.com/o/r/pull/2" }); + screen.getByRole("listbox").focus(); + await user.keyboard("{Enter}"); + expect(props.onOpen).toHaveBeenCalledWith("https://github.com/o/r/pull/2"); + }); + + it("clears the selection on Escape", async () => { + const user = userEvent.setup(); + const props = renderTable({ selectedPr: "https://github.com/o/r/pull/1" }); + screen.getByRole("listbox").focus(); + await user.keyboard("{Escape}"); + expect(props.onClearSelection).toHaveBeenCalledOnce(); + }); +}); diff --git a/app/src/components/BoardTable.tsx b/app/src/components/BoardTable.tsx new file mode 100644 index 0000000..cd81312 --- /dev/null +++ b/app/src/components/BoardTable.tsx @@ -0,0 +1,352 @@ +/** + * The dense triage table density for the PRs board (mockup D1). + * + * A full-width, CSS-grid list (not a ``) built for scanning many reviews + * at once: each two-line row carries the gate LED, branch + reason, the agent's + * one-line read, a compact signals cluster, and the state's primary action. The + * whole row selects (feeding the preview rail); the action button acts without + * selecting; double-click or Enter opens the review. Keyboard: ↑/↓ move the + * selection, Enter opens, Escape clears. Implemented as a `listbox` of `option` + * rows with roving `aria-activedescendant` so it is single-tab-stop and + * screen-reader navigable. + * + * Presentation is composed from the shared board helpers (LED, reason tone, + * severity + size + CI conventions) so a row reads identically to a card. + */ + +import { useEffect, useMemo, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { Review } from "../bindings/Review"; +import type { CiSummary } from "../bindings/CiSummary"; +import { StatusLed } from "./StatusLed"; +import { cardSignal, toneTextClass } from "../lib/card-signal"; +import { actionConfigForState } from "../lib/review-action"; +import { parsePrDisplay } from "../lib/pr-ref"; +import { stripMarkdown } from "../lib/markdown-text"; +import { findingCounts, severityMeta } from "../lib/finding-severity"; +import { ciState } from "../lib/ci"; +import { diffTotals, sizeClass, sizeClassLabel } from "../lib/diff-signals"; +import { compactNumber } from "../lib/format-number"; +import { moveSelection } from "../lib/board-selection"; + +/** + * Shared grid template for the header and every row, kept content-independent + * (fixed + `fr`/`minmax`) so columns line up across rows without a `
` or + * `subgrid`: LED | branch+reason | agent's read | signals | action. + */ +const GRID_TEMPLATE = + "1.5rem minmax(200px, 1.3fr) minmax(0, 2fr) minmax(210px, 1fr) 7.5rem"; + +function assertNever(x: never): never { + throw new Error(`unreachable: ${String(x)}`); +} + +/** A DOM id for a row, stable per PR ref, for `aria-activedescendant`. */ +function rowId(pr: string): string { + return `board-row-${pr.replace(/[^a-zA-Z0-9]/g, "-")}`; +} + +/** Dot color for the CI chip, from the rolled-up summary's overall state. */ +function ciDotClass(ci: CiSummary): string { + const state = ciState(ci); + switch (state) { + case "pass": + return "bg-success"; + case "fail": + return "bg-danger"; + case "pending": + return "bg-warning"; + case "none": + return "bg-muted-foreground"; + default: + return assertNever(state); + } +} + +interface BoardTableProps { + /** The reviews to show, already filtered and in display order. */ + readonly reviews: readonly Review[]; + /** The currently selected PR ref, or null. */ + readonly selectedPr: string | null; + /** Select a row (drives the preview rail). */ + readonly onSelect: (pr: string) => void; + /** Open a review's workspace (double-click / Enter). */ + readonly onOpen: (pr: string) => void; + /** Run the state's primary action for a review. */ + readonly onAction: (pr: string) => void; + /** Clear the current selection (Escape). */ + readonly onClearSelection: () => void; +} + +/** The compact signals cluster for a row: CI, size + adds/dels, severity. */ +function RowSignals({ review }: { readonly review: Review }) { + const totals = useMemo(() => diffTotals(review.diff.raw), [review.diff.raw]); + const counts = useMemo( + () => findingCounts(review.review_findings), + [review.review_findings], + ); + const hasDiff = totals.additions > 0 || totals.deletions > 0; + const ci = review.ci_summary; + + const severities = [ + { count: counts.critical, letter: "C", meta: severityMeta("Critical") }, + { count: counts.warning, letter: "W", meta: severityMeta("Warning") }, + { count: counts.info, letter: "I", meta: severityMeta("Info") }, + ].filter((s) => s.count > 0); + + return ( +
+ {ci !== null && ci.total > 0 && ( + + + )} + + {hasDiff && ( + + + {sizeClassLabel(sizeClass(totals.additions, totals.deletions))} + + +{compactNumber(totals.additions)} + −{compactNumber(totals.deletions)} + + )} + + {severities.length > 0 && ( + + {severities.map((s) => ( + + + ))} + + )} +
+ ); +} + +/** A single triage row. */ +function BoardRow({ + review, + selected, + onSelect, + onOpen, + onAction, +}: { + readonly review: Review; + readonly selected: boolean; + readonly onSelect: (pr: string) => void; + readonly onOpen: (pr: string) => void; + readonly onAction: (pr: string) => void; +}) { + const signal = cardSignal(review); + const action = actionConfigForState(review.gate_state); + const { number: prNumber } = parsePrDisplay(review.pr); + const intent = useMemo( + () => + review.distilled_intent !== null + ? stripMarkdown(review.distilled_intent) + : null, + [review.distilled_intent], + ); + + return ( +
{ + onSelect(review.pr); + }} + onDoubleClick={() => { + onOpen(review.pr); + }} + style={{ gridTemplateColumns: GRID_TEMPLATE }} + className={cn( + "grid min-h-[3rem] cursor-pointer items-center gap-3 border-b border-border/60 px-4 py-2 transition-colors", + selected ? "bg-muted/70" : "hover:bg-muted/50", + )} + > + + + {/* Branch + reason */} +
+
+ {review.branch} +
+
+ {signal.tone === "danger" && ( +
+
+ + {/* Agent's read */} + {intent !== null ? ( +

+ {intent} +

+ ) : ( + + )} + + {/* Signals */} + + + {/* Action */} +
+ +
+
+ ); +} + +/** + * The triage table: a keyboard-navigable, single-select `listbox` of review + * rows. Selection is owned by the caller (so the preview rail can read it); + * this renders the rows and translates arrow/Enter/Escape into callbacks. + */ +export function BoardTable({ + reviews, + selectedPr, + onSelect, + onOpen, + onAction, + onClearSelection, +}: BoardTableProps) { + const containerRef = useRef(null); + const order = useMemo(() => reviews.map((r) => r.pr), [reviews]); + + // Keep the selected row visible as the keyboard moves the cursor. + useEffect(() => { + if (selectedPr === null) return; + const el = document.getElementById(rowId(selectedPr)); + // Guarded: scrollIntoView is a convenience and is absent under jsdom. + if (el !== null && typeof el.scrollIntoView === "function") { + el.scrollIntoView({ block: "nearest" }); + } + }, [selectedPr]); + + const selectAndFocus = (pr: string): void => { + onSelect(pr); + // Keep keyboard control on the list after a mouse selection. + containerRef.current?.focus({ preventScroll: true }); + }; + + const handleKeyDown = (e: React.KeyboardEvent): void => { + switch (e.key) { + case "ArrowDown": { + e.preventDefault(); + const next = moveSelection(order, selectedPr, "next"); + if (next !== null) onSelect(next); + break; + } + case "ArrowUp": { + e.preventDefault(); + const prev = moveSelection(order, selectedPr, "prev"); + if (prev !== null) onSelect(prev); + break; + } + case "Enter": { + if (selectedPr !== null) { + e.preventDefault(); + onOpen(selectedPr); + } + break; + } + case "Escape": { + e.preventDefault(); + onClearSelection(); + break; + } + default: + break; + } + }; + + return ( +
+ {/* Column header */} +
+
+ +
+ {reviews.map((review) => ( + + ))} +
+
+ ); +} diff --git a/app/src/components/BriefingFindings.tsx b/app/src/components/BriefingFindings.tsx index 5c93fd0..48f87e3 100644 --- a/app/src/components/BriefingFindings.tsx +++ b/app/src/components/BriefingFindings.tsx @@ -26,6 +26,13 @@ interface BriefingFindingsProps { readonly findings: readonly ReviewFinding[]; /** Reveal a finding's line in the Diff tab, side-aware. */ readonly onJumpTo: (path: string, side: DiffSide, line: number) => void; + /** + * Cap the number of rendered findings (highest-severity first). When more + * findings exist than the cap, a muted "+N more" line follows the list. Used + * by the board preview rail, which shows only the top few; omit for the full + * Briefing tab list. + */ + readonly limit?: number; } // --------------------------------------------------------------------------- @@ -123,9 +130,15 @@ function FindingRow({ * The severity-ranked findings list for the Briefing tab. Renders nothing when * there are no findings. */ -export function BriefingFindings({ findings, onJumpTo }: BriefingFindingsProps) { +export function BriefingFindings({ + findings, + onJumpTo, + limit, +}: BriefingFindingsProps) { if (findings.length === 0) return null; const ranked = sortFindingsBySeverity(findings); + const shown = limit !== undefined ? ranked.slice(0, limit) : ranked; + const hidden = ranked.length - shown.length; return (
@@ -139,7 +152,7 @@ export function BriefingFindings({ findings, onJumpTo }: BriefingFindingsProps)
    - {ranked.map((finding) => ( + {shown.map((finding) => ( ))}
+ {hidden > 0 && ( +

+ +{String(hidden)} more finding{hidden === 1 ? "" : "s"} +

+ )}
); } diff --git a/app/src/components/ReviewCard.tsx b/app/src/components/ReviewCard.tsx index 154ecdc..710fa48 100644 --- a/app/src/components/ReviewCard.tsx +++ b/app/src/components/ReviewCard.tsx @@ -3,12 +3,9 @@ import { Button } from "@/components/ui/button"; import { Layers, ShieldAlert } from "lucide-react"; import { cn } from "@/lib/utils"; import type { Review } from "../bindings/Review"; -import type { GateState } from "../bindings/GateState"; -import type { SizeClass } from "../bindings/SizeClass"; import type { RiskFlag } from "../bindings/RiskFlag"; import type { CiSummary } from "../bindings/CiSummary"; -import { cardSignal } from "../lib/card-signal"; -import type { SignalTone } from "../lib/card-signal"; +import { cardSignal, toneTextClass } from "../lib/card-signal"; import { findingCounts } from "../lib/finding-severity"; import { stripMarkdown } from "../lib/markdown-text"; import { diffStats } from "../diff-parser"; @@ -16,9 +13,13 @@ import { ciState } from "../lib/ci"; import { diffTotals, sizeClass, + sizeClassLabel, sensitiveFlags, touchesTests, } from "../lib/diff-signals"; +import { actionConfigForState } from "../lib/review-action"; +import { parsePrDisplay } from "../lib/pr-ref"; +import { StatusLed } from "./StatusLed"; /** Presentation density for the board. */ export type CardDensity = "cards" | "compact"; @@ -45,94 +46,6 @@ function assertNever(x: never): never { throw new Error(`unreachable: ${String(x)}`); } -/** Status-LED color for a gate state, using the `--color-state-*` tokens. */ -function ledColorClass(state: GateState): string { - switch (state) { - case "Pending": - return "bg-state-pending"; - case "InReview": - return "bg-state-in-review"; - case "Dispatched": - return "bg-state-dispatched"; - case "Reworked": - return "bg-state-reworked"; - case "Approved": - return "bg-state-approved"; - case "Merged": - return "bg-state-approved"; - default: - return assertNever(state); - } -} - -/** Text color for the reason line, keyed off its semantic tone. */ -function toneTextClass(tone: SignalTone): string { - switch (tone) { - case "attention": - return "text-state-in-review"; - case "running": - return "text-state-dispatched"; - case "warning": - return "text-warning"; - case "danger": - return "text-danger"; - case "done": - return "text-state-approved"; - case "neutral": - return "text-muted-foreground"; - default: - return assertNever(tone); - } -} - -/** Button variant type matching the Button component's variant prop. */ -type ButtonVariant = "default" | "outline" | "ghost"; - -interface ActionConfig { - readonly label: string; - readonly variant: ButtonVariant; - readonly muted: boolean; -} - -/** Determines the action button label, variant, and muted state from the gate state. */ -function actionConfigForState(state: GateState): ActionConfig { - switch (state) { - case "Pending": - return { label: "Review", variant: "default", muted: false }; - case "InReview": - return { label: "Review", variant: "default", muted: false }; - case "Dispatched": - return { label: "Watch", variant: "outline", muted: true }; - case "Reworked": - return { label: "Re-review", variant: "default", muted: false }; - case "Approved": - return { label: "View", variant: "ghost", muted: false }; - case "Merged": - return { label: "View", variant: "ghost", muted: false }; - default: - return assertNever(state); - } -} - -function parsePrDisplay(pr: string): { repo: string; number: string } { - const match = /github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/.exec(pr); - if (match !== null) { - const [, repo, num] = match; - if (repo !== undefined && num !== undefined) { - return { repo, number: num }; - } - } - return { repo: "", number: pr }; -} - -/** - * Whether the LED should pulse. Only non-terminal running work (an actively - * dispatched agent) pulses; everything else is steady. - */ -function ledPulses(review: Review): boolean { - return review.gate_state === "Dispatched" && !review.stale; -} - /** * Whether the whole card is de-emphasized. Approved and stale reviews are * settled or blocked, so they dim to let attention items stand out. @@ -157,28 +70,6 @@ function stackRelation(review: Review): string | null { return parts.length > 0 ? parts.join(" · ") : null; } -/** LED dot; pulses for running work. */ -function StatusLed({ review }: { readonly review: Review }) { - return ( -