Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 137 additions & 10 deletions src/app/admin/reviews/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@

import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useRouter } from "next/navigation"
import { ArrowLeft, ArrowRightLeft, GitMerge, Layers, Network, Pencil, PlusSquare, Search, Share2, Trash2, X } from "lucide-react"
import { ArrowLeft, ArrowRightLeft, GitMerge, Layers, Network, Pencil, PlusSquare, Search, Share2, Trash2, Users, X } from "lucide-react"
import { useDebounce } from "@/hooks/use-debounce"
import { Input } from "@/components/ui/input"
import type { LucideIcon } from "lucide-react"
import { useReviewStore } from "@/stores/review-store"
import { useSchemaStore } from "@/stores/schema-store"
import { approveReview, dismissReview, listReviews, getReviewNodeTypeCounts } from "@/lib/graph-api"
import { approveReview, dismissReview, listReviews, getReviewNodeTypeCounts, triggerMergeWorkflow } from "@/lib/graph-api"
import type { Review, ReviewStatus } from "@/lib/graph-api"
import { ReviewRow, getApproveVerb } from "@/components/admin/review-row"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { SelectCustom } from "@/components/ui/select-custom"
import { computeRangeSelection } from "@/lib/review-selection"
import { cn } from "@/lib/utils"

const STATUS_TABS: { label: string; value: ReviewStatus | "" }[] = [
Expand Down Expand Up @@ -83,15 +84,27 @@ export default function ReviewsPage() {
const debouncedSearch = useDebounce(searchQuery, 300)

const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [bulkRunning, setBulkRunning] = useState<null | "approve" | "dismiss">(null)
const [bulkRunning, setBulkRunning] = useState<null | "approve" | "dismiss" | "human_review">(null)
const [bulkError, setBulkError] = useState<string | null>(null)

// Rows whose human review is already sent (reported up by each row), plus the
// ids a bulk dispatch just fired and a token the rows use to adopt that run.
const [humanReviewSentIds, setHumanReviewSentIds] = useState<Set<string>>(new Set())
const [humanReviewDispatch, setHumanReviewDispatch] = useState<{
token: number
ids: Set<string>
}>({ token: 0, ids: new Set() })

const [nodeTypeFilter, setNodeTypeFilter] = useState("")
const [nodeTypeCounts, setNodeTypeCounts] = useState<Record<string, number>>({})
const [truncatedCounts, setTruncatedCounts] = useState(false)

const abortRef = useRef<AbortController | null>(null)
const nodeTypeCountsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Last row whose checkbox was clicked without shift — the anchor a shift-click
// extends from. Held as a ref_id so it survives a refetch and is simply ignored
// when the row is no longer in the list.
const selectionAnchorRef = useRef<string | null>(null)

const fetchReviews = useCallback(
async (currentSkip = 0, options?: { silent?: boolean }) => {
Expand Down Expand Up @@ -126,7 +139,10 @@ export default function ReviewsPage() {
setReviews(res.reviews)
setTotal(res.total)
setSkip(currentSkip)
if (!options?.silent) setSelectedIds(new Set())
if (!options?.silent) {
setSelectedIds(new Set())
selectionAnchorRef.current = null
}
} catch (err: unknown) {
if ((err as { name?: string })?.name !== "AbortError") {
setError("Failed to load reviews")
Expand Down Expand Up @@ -179,6 +195,13 @@ export default function ReviewsPage() {
refreshPendingCount()
}, [refreshPendingCount])

// Every count on the page — the pending tab badge and the node-type chips —
// goes stale the moment a review is decided, so they refresh together.
const refreshCounts = useCallback(() => {
refreshPendingCount()
fetchNodeTypeCounts()
}, [refreshPendingCount, fetchNodeTypeCounts])

// ── Selection helpers ──────────────────────────────────────────────────────

const selectableReviews = useMemo(
Expand Down Expand Up @@ -209,7 +232,24 @@ export default function ReviewsPage() {
eligibleForSelectAll.length > 0 &&
eligibleForSelectAll.every((r) => selectedIds.has(r.ref_id))

function toggleRow(refId: string, selected: boolean) {
function toggleRow(refId: string, selected: boolean, shiftKey: boolean) {
if (shiftKey && selectionAnchorRef.current !== null) {
// Shift-clicking drags a text selection across the rows it spans; clear it
// so the range highlight is the only thing the operator sees.
window.getSelection()?.removeAllRanges()
setSelectedIds((prev) =>
computeRangeSelection(
reviews,
selectionAnchorRef.current,
refId,
selected,
prev,
lockedActionName
)
)
return
}
selectionAnchorRef.current = refId
setSelectedIds((prev) => {
const next = new Set(prev)
if (selected) next.add(refId)
Expand All @@ -219,13 +259,44 @@ export default function ReviewsPage() {
}

function toggleSelectAll() {
selectionAnchorRef.current = null
if (allEligibleSelected) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(eligibleForSelectAll.map((r) => r.ref_id)))
}
}

function clearSelection() {
selectionAnchorRef.current = null
setSelectedIds(new Set())
}

// ── Human review state, reported up by each eligible row ───────────────────

const handleHumanReviewStateChange = useCallback((refId: string, sent: boolean) => {
setHumanReviewSentIds((prev) => {
if (prev.has(refId) === sent) return prev
const next = new Set(prev)
if (sent) next.add(refId)
else next.delete(refId)
return next
})
}, [])

// Only merge_nodes rows can go to human review, and a row that already has a
// run must not be dispatched twice.
const humanReviewCandidates = useMemo(
() =>
selectedReviews.filter(
(r) =>
r.action_name === "merge_nodes" &&
r.status === "pending" &&
!humanReviewSentIds.has(r.ref_id)
),
[selectedReviews, humanReviewSentIds]
)

// ── Bulk handlers ──────────────────────────────────────────────────────────

async function runBulk(kind: "approve" | "dismiss") {
Expand All @@ -246,8 +317,38 @@ export default function ReviewsPage() {
await fetchReviews(skip, { silent: true })
// The silent refetch intentionally skips the selection reset, so clear the
// now-stale selection here — decided rows have left the pending list.
setSelectedIds(new Set())
refreshPendingCount()
clearSelection()
refreshCounts()
}

// Human review dispatches a workflow but leaves the reviews pending, so unlike
// approve/dismiss there is nothing to refetch — the rows adopt their new run
// through the dispatch token and take over the polling from there.
async function runBulkHumanReview() {
if (humanReviewCandidates.length === 0) return
setBulkRunning("human_review")
setBulkError(null)
const results = await Promise.allSettled(
humanReviewCandidates.map((r) => triggerMergeWorkflow(r.ref_id))
)
const dispatchedIds = humanReviewCandidates
.filter((_, i) => results[i].status === "fulfilled")
.map((r) => r.ref_id)
const failures = results.length - dispatchedIds.length
setBulkRunning(null)
if (failures > 0) {
setBulkError(`${failures} of ${results.length} human review dispatches failed`)
}
if (dispatchedIds.length > 0) {
setHumanReviewDispatch((prev) => ({
token: prev.token + 1,
ids: new Set(dispatchedIds),
}))
}
// On a partial failure the selection stays so the error stays on screen and
// the button re-offers exactly the rows that did not get through — the ones
// that did are already excluded as sent.
if (failures === 0) clearSelection()
}

const totalPages = Math.ceil(total / PAGE_SIZE)
Expand Down Expand Up @@ -474,9 +575,29 @@ export default function ReviewsPage() {
>
{bulkRunning === "dismiss" ? "Dismissing…" : `Dismiss ${selectedIds.size}`}
</Button>
{lockedActionName === "merge_nodes" && (
<Button
size="sm"
variant="outline"
data-testid="bulk-human-review-btn"
disabled={bulkRunning !== null || humanReviewCandidates.length === 0}
onClick={runBulkHumanReview}
title={
humanReviewCandidates.length === 0
? "All selected merges have already been sent for human review"
: "Send the selected merges for human review"
}
className="h-7 gap-1 border-sky-500/30 bg-sky-500/5 px-3 text-xs text-sky-400 hover:border-sky-500/60 hover:bg-sky-500/10 hover:text-sky-300"
>
<Users className="h-3 w-3" />
{bulkRunning === "human_review"
? "Sending…"
: `Human Review ${humanReviewCandidates.length}`}
</Button>
)}
<button
type="button"
onClick={() => setSelectedIds(new Set())}
onClick={clearSelection}
className="rounded p-1 text-muted-foreground hover:bg-muted/50 hover:text-foreground"
aria-label="Clear selection"
>
Expand Down Expand Up @@ -522,10 +643,16 @@ export default function ReviewsPage() {
review={review}
schemas={schemas}
onRefresh={() => fetchReviews(skip, { silent: true })}
onCountRefresh={refreshPendingCount}
onCountRefresh={refreshCounts}
selectable={review.status === "pending"}
selected={selectedIds.has(review.ref_id)}
onSelectChange={(s) => toggleRow(review.ref_id, s)}
onSelectChange={(s, shiftKey) => toggleRow(review.ref_id, s, shiftKey)}
humanReviewDispatchToken={
humanReviewDispatch.ids.has(review.ref_id)
? humanReviewDispatch.token
: 0
}
onHumanReviewStateChange={handleHumanReviewStateChange}
selectionLocked={locked}
selectionLockedReason={
locked
Expand Down
37 changes: 35 additions & 2 deletions src/components/admin/review-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -535,11 +535,19 @@ export interface ReviewRowProps {
onRefresh: () => void
onCountRefresh?: () => void
selected?: boolean
onSelectChange?: (selected: boolean) => void
onSelectChange?: (selected: boolean, shiftKey: boolean) => void
selectable?: boolean
/** When true, the checkbox is rendered but disabled (e.g., locked by same-action rule). */
selectionLocked?: boolean
selectionLockedReason?: string
/**
* Bumped by the list page after a bulk "Human Review" dispatch that included
* this row, so the row adopts the run it never fired itself. Any increase is
* treated as one dispatch; 0 means this row was not part of one.
*/
humanReviewDispatchToken?: number
/** Reports whether this row's human review has been sent, so the page can exclude it from a bulk dispatch. */
onHumanReviewStateChange?: (refId: string, sent: boolean) => void
}

export function ReviewRow({
Expand All @@ -552,6 +560,8 @@ export function ReviewRow({
selectable = false,
selectionLocked = false,
selectionLockedReason,
humanReviewDispatchToken = 0,
onHumanReviewStateChange,
}: ReviewRowProps) {
const router = useRouter()
const setReturnTo = useGraphStore((s) => s.setReturnTo)
Expand All @@ -568,6 +578,7 @@ export function ReviewRow({
isInFlight: humanReviewInFlight,
trigger: triggerHumanReview,
triggering: humanReviewTriggering,
markTriggered: markHumanReviewTriggered,
} = useStakworkRunStatus(
isMergeReviewEligible ? review.ref_id : "__noop__",
"node_merge_review",
Expand All @@ -591,6 +602,22 @@ export function ReviewRow({
const humanReviewSent =
humanReviewStatus === "COMPLETED" || humanReviewInFlight

// Adopt a run the list page dispatched on this row's behalf. Guarded by a ref
// so a re-render (or StrictMode's double effect) never re-adopts the same one.
const adoptedDispatchRef = useRef(0)
useEffect(() => {
if (!isMergeReviewEligible) return
if (humanReviewDispatchToken <= adoptedDispatchRef.current) return
adoptedDispatchRef.current = humanReviewDispatchToken
markHumanReviewTriggered()
}, [humanReviewDispatchToken, isMergeReviewEligible, markHumanReviewTriggered])

// Let the page know this row is already sent so a bulk dispatch skips it.
useEffect(() => {
if (!isMergeReviewEligible) return
onHumanReviewStateChange?.(review.ref_id, humanReviewSent)
}, [isMergeReviewEligible, humanReviewSent, review.ref_id, onHumanReviewStateChange])

// ── Merge-specific interactive state ────────────────────────────────────────
const [checkedSources, setCheckedSources] = useState<Set<string>>(new Set())
const [canonicalId, setCanonicalId] = useState<string>("")
Expand Down Expand Up @@ -714,7 +741,13 @@ export function ReviewRow({
<span title={selectionLocked && !selected ? selectionLockedReason : undefined}>
<Checkbox
checked={selected}
onChange={onSelectChange}
onChange={(next, e) =>
onSelectChange(
next,
"shiftKey" in e.nativeEvent &&
(e.nativeEvent as MouseEvent).shiftKey === true
)
}
onClick={(e) => e.stopPropagation()}
disabled={selectionLocked && !selected}
ariaLabel={`Select review ${review.ref_id}`}
Expand Down
5 changes: 3 additions & 2 deletions src/components/ui/checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { cn } from "@/lib/utils"

interface CheckboxProps {
checked: boolean
onChange: (checked: boolean) => void
/** The change event is forwarded so callers can read modifier keys (shift-range select). */
onChange: (checked: boolean, event: React.ChangeEvent<HTMLInputElement>) => void
indeterminate?: boolean
disabled?: boolean
ariaLabel?: string
Expand Down Expand Up @@ -45,7 +46,7 @@ export function Checkbox({
checked={checked}
disabled={disabled}
onClick={onClick}
onChange={(e) => onChange(e.target.checked)}
onChange={(e) => onChange(e.target.checked, e)}
aria-label={ariaLabel}
className="absolute inset-0 z-10 m-0 h-full w-full cursor-pointer appearance-none opacity-0 disabled:cursor-not-allowed"
/>
Expand Down
Loading
Loading