feat(kb-ui): bulk review — select many claims, confirm/reject in a batch
ClaimsReviewQueue gains selection when onBulkConfirm/onBulkReject are provided:
per-card checkboxes, a "Select all" with an indeterminate state, and a floating
action bar with a lightweight inline confirmation ("Confirm N claims?") before
it runs — a batch action still asks first. Selection is pruned when the claim
set changes after a refresh, so a just-actioned claim can't ride into the next
batch. Conflicts are deliberately excluded — picking a winner between two
disagreeing facts is a per-case human call, never a bulk toggle. The app owns
the batch loop; the queue owns the selection UI.
Verified live: seeded 6 agent-asserted claims, bulk-confirmed 3, bulk-rejected 3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,8 @@
|
|||||||
// unreviewed claims. Tiers surfaced raw, never scored. Tombstone re-open
|
// unreviewed claims. Tiers surfaced raw, never scored. Tombstone re-open
|
||||||
// via the general claims list. Props in, callbacks out.
|
// via the general claims list. Props in, callbacks out.
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
import { type FC, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useState, type FC, type ReactNode } from "react";
|
||||||
import { AlertTriangle, ArrowUpRight, Check, Inbox, RotateCcw, X } from "lucide-react";
|
import { AlertTriangle, ArrowUpRight, Check, Inbox, Loader2, RotateCcw, X } from "lucide-react";
|
||||||
import type { Claim } from "./types";
|
import type { Claim } from "./types";
|
||||||
import { Badge, TierBadge, cn, formatDate, renderValue } from "./_internal";
|
import { Badge, TierBadge, cn, formatDate, renderValue } from "./_internal";
|
||||||
|
|
||||||
@@ -20,38 +20,72 @@ export interface ClaimActions {
|
|||||||
export interface ClaimCardProps extends ClaimActions {
|
export interface ClaimCardProps extends ClaimActions {
|
||||||
claim: Claim;
|
claim: Claim;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
|
/** Show a selection checkbox (bulk review). */
|
||||||
|
selectable?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
onToggleSelect?: (id: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ClaimCard: FC<ClaimCardProps> = ({ claim, busy, className, onConfirm, onReject, onReopen, onOpenSource }) => {
|
export const ClaimCard: FC<ClaimCardProps> = ({
|
||||||
|
claim,
|
||||||
|
busy,
|
||||||
|
selectable,
|
||||||
|
selected,
|
||||||
|
onToggleSelect,
|
||||||
|
className,
|
||||||
|
onConfirm,
|
||||||
|
onReject,
|
||||||
|
onReopen,
|
||||||
|
onOpenSource,
|
||||||
|
}) => {
|
||||||
const rejected = claim.review_state === "rejected";
|
const rejected = claim.review_state === "rejected";
|
||||||
return (
|
return (
|
||||||
<div className={cn("rounded-xl border border-border bg-card p-4", className)}>
|
<div
|
||||||
<ClaimHead claim={claim} onOpenSource={onOpenSource} />
|
className={cn(
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
"rounded-xl border bg-card p-4 transition",
|
||||||
{rejected ? (
|
selected ? "border-primary/50 ring-1 ring-primary/30" : "border-border",
|
||||||
<>
|
className,
|
||||||
<Badge tone="bg-muted text-muted-foreground">Rejected {formatDate(claim.updated_at)}</Badge>
|
)}
|
||||||
{onReopen && (
|
>
|
||||||
<ActionBtn action="knowledge-claim-reopen" onClick={() => onReopen(claim.claim_id)} busy={busy} icon={<RotateCcw className="size-3.5" />}>
|
<div className="flex items-start gap-3">
|
||||||
Re-open
|
{selectable && (
|
||||||
</ActionBtn>
|
<input
|
||||||
)}
|
type="checkbox"
|
||||||
</>
|
checked={!!selected}
|
||||||
) : (
|
onChange={() => onToggleSelect?.(claim.claim_id)}
|
||||||
<>
|
aria-label={`Select claim: ${claim.subject} ${claim.predicate}`}
|
||||||
{onConfirm && (
|
className="mt-1 size-4 shrink-0 accent-[var(--primary)]"
|
||||||
<ActionBtn action="knowledge-claim-confirm" primary onClick={() => onConfirm(claim.claim_id)} busy={busy} icon={<Check className="size-3.5" />}>
|
/>
|
||||||
{claim.status === "proposed" ? "Confirm" : "Mark reviewed"}
|
|
||||||
</ActionBtn>
|
|
||||||
)}
|
|
||||||
{onReject && (
|
|
||||||
<ActionBtn action="knowledge-claim-reject" onClick={() => onReject(claim.claim_id)} busy={busy} icon={<X className="size-3.5" />}>
|
|
||||||
Reject
|
|
||||||
</ActionBtn>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<ClaimHead claim={claim} onOpenSource={onOpenSource} />
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
{rejected ? (
|
||||||
|
<>
|
||||||
|
<Badge tone="bg-muted text-muted-foreground">Rejected {formatDate(claim.updated_at)}</Badge>
|
||||||
|
{onReopen && (
|
||||||
|
<ActionBtn action="knowledge-claim-reopen" onClick={() => onReopen(claim.claim_id)} busy={busy} icon={<RotateCcw className="size-3.5" />}>
|
||||||
|
Re-open
|
||||||
|
</ActionBtn>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{onConfirm && (
|
||||||
|
<ActionBtn action="knowledge-claim-confirm" primary onClick={() => onConfirm(claim.claim_id)} busy={busy} icon={<Check className="size-3.5" />}>
|
||||||
|
{claim.status === "proposed" ? "Confirm" : "Mark reviewed"}
|
||||||
|
</ActionBtn>
|
||||||
|
)}
|
||||||
|
{onReject && (
|
||||||
|
<ActionBtn action="knowledge-claim-reject" onClick={() => onReject(claim.claim_id)} busy={busy} icon={<X className="size-3.5" />}>
|
||||||
|
Reject
|
||||||
|
</ActionBtn>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -178,6 +212,11 @@ export interface ClaimsReviewQueueProps {
|
|||||||
onKeep?: (winnerId: string, supersedeIds: string[]) => void;
|
onKeep?: (winnerId: string, supersedeIds: string[]) => void;
|
||||||
onDismissConflict?: (id: string) => void;
|
onDismissConflict?: (id: string) => void;
|
||||||
onOpenSource?: ClaimActions["onOpenSource"];
|
onOpenSource?: ClaimActions["onOpenSource"];
|
||||||
|
/** Providing either enables bulk selection on non-conflict claims. The queue
|
||||||
|
* owns the selection UI; the app runs the batch and refreshes. Conflicts are
|
||||||
|
* never bulk-actionable — picking a winner is a per-case human call. */
|
||||||
|
onBulkConfirm?: (ids: string[]) => void | Promise<void>;
|
||||||
|
onBulkReject?: (ids: string[]) => void | Promise<void>;
|
||||||
emptyState?: ReactNode;
|
emptyState?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -192,6 +231,8 @@ export const ClaimsReviewQueue: FC<ClaimsReviewQueueProps> = ({
|
|||||||
onKeep,
|
onKeep,
|
||||||
onDismissConflict,
|
onDismissConflict,
|
||||||
onOpenSource,
|
onOpenSource,
|
||||||
|
onBulkConfirm,
|
||||||
|
onBulkReject,
|
||||||
emptyState,
|
emptyState,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -207,19 +248,73 @@ export const ClaimsReviewQueue: FC<ClaimsReviewQueueProps> = ({
|
|||||||
// (a singleton group, not rendered as a pair) rendered nowhere at all.
|
// (a singleton group, not rendered as a pair) rendered nowhere at all.
|
||||||
const rest = claims.filter((c) => c.status !== "proposed" && !inConflict.has(c.claim_id));
|
const rest = claims.filter((c) => c.status !== "proposed" && !inConflict.has(c.claim_id));
|
||||||
|
|
||||||
|
// Bulk selection covers non-conflict, non-rejected claims only.
|
||||||
|
const bulkEnabled = !!(onBulkConfirm || onBulkReject);
|
||||||
|
const selectableIds = useMemo(
|
||||||
|
() => [...proposed, ...rest.filter((c) => c.review_state !== "rejected")].map((c) => c.claim_id),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[claims],
|
||||||
|
);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [pending, setPending] = useState<null | "confirm" | "reject">(null);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
|
||||||
|
// Drop ids that no longer exist after a refresh, so a stale selection can't
|
||||||
|
// carry a just-actioned claim into the next batch.
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const live = new Set(selectableIds);
|
||||||
|
const next = new Set([...prev].filter((id) => live.has(id)));
|
||||||
|
return next.size === prev.size ? prev : next;
|
||||||
|
});
|
||||||
|
setPending(null)
|
||||||
|
}, [selectableIds]);
|
||||||
|
|
||||||
|
const toggle = (id: string) =>
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selected.has(id));
|
||||||
|
const someSelected = selected.size > 0 && !allSelected;
|
||||||
|
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(selectableIds));
|
||||||
|
|
||||||
|
async function runBulk(kind: "confirm" | "reject") {
|
||||||
|
const ids = [...selected];
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
setRunning(true);
|
||||||
|
try {
|
||||||
|
await (kind === "confirm" ? onBulkConfirm : onBulkReject)?.(ids);
|
||||||
|
setSelected(new Set());
|
||||||
|
setPending(null);
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (claims.length === 0) {
|
if (claims.length === 0) {
|
||||||
return <>{emptyState ?? <Empty />}</>;
|
return <>{emptyState ?? <Empty />}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectProps = (c: Claim) =>
|
||||||
|
bulkEnabled && c.review_state !== "rejected"
|
||||||
|
? { selectable: true, selected: selected.has(c.claim_id), onToggleSelect: toggle }
|
||||||
|
: {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-6", className)}>
|
<div className={cn("flex flex-col gap-6", bulkEnabled && "pb-24", className)}>
|
||||||
|
{bulkEnabled && selectableIds.length > 0 && (
|
||||||
|
<SelectAll checked={allSelected} indeterminate={someSelected} count={selectableIds.length} onToggle={toggleAll} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Section title="Proposed" count={proposed.length} hint="An agent noted these — confirm or reject.">
|
<Section title="Proposed" count={proposed.length} hint="An agent noted these — confirm or reject.">
|
||||||
{proposed.map((c) => (
|
{proposed.map((c) => (
|
||||||
<ClaimCard key={c.claim_id} claim={c} busy={busyId === c.claim_id} onConfirm={onConfirm} onReject={onReject} onOpenSource={onOpenSource} />
|
<ClaimCard key={c.claim_id} claim={c} busy={busyId === c.claim_id} onConfirm={onConfirm} onReject={onReject} onOpenSource={onOpenSource} {...selectProps(c)} />
|
||||||
))}
|
))}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Conflicts" count={conflictGroups.length} hint="Two facts disagree — you decide.">
|
<Section title="Conflicts" count={conflictGroups.length} hint="Two facts disagree — you decide, one at a time.">
|
||||||
{conflictGroups.map((group) => (
|
{conflictGroups.map((group) => (
|
||||||
<ConflictPair
|
<ConflictPair
|
||||||
key={group.map((c) => c.claim_id).join("+")}
|
key={group.map((c) => c.claim_id).join("+")}
|
||||||
@@ -235,13 +330,130 @@ export const ClaimsReviewQueue: FC<ClaimsReviewQueueProps> = ({
|
|||||||
|
|
||||||
<Section title="Awaiting review" count={rest.length} hint="Visible already, labelled by tier — review at your pace.">
|
<Section title="Awaiting review" count={rest.length} hint="Visible already, labelled by tier — review at your pace.">
|
||||||
{rest.map((c) => (
|
{rest.map((c) => (
|
||||||
<ClaimCard key={c.claim_id} claim={c} busy={busyId === c.claim_id} onConfirm={onConfirm} onReject={onReject} onReopen={onReopen} onOpenSource={onOpenSource} />
|
<ClaimCard key={c.claim_id} claim={c} busy={busyId === c.claim_id} onConfirm={onConfirm} onReject={onReject} onReopen={onReopen} onOpenSource={onOpenSource} {...selectProps(c)} />
|
||||||
))}
|
))}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{bulkEnabled && selected.size > 0 && (
|
||||||
|
<BulkBar
|
||||||
|
count={selected.size}
|
||||||
|
pending={pending}
|
||||||
|
running={running}
|
||||||
|
canConfirm={!!onBulkConfirm}
|
||||||
|
canReject={!!onBulkReject}
|
||||||
|
onAsk={setPending}
|
||||||
|
onCancel={() => setPending(null)}
|
||||||
|
onRun={runBulk}
|
||||||
|
onClear={() => setSelected(new Set())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SelectAll: FC<{ checked: boolean; indeterminate: boolean; count: number; onToggle: () => void }> = ({
|
||||||
|
checked,
|
||||||
|
indeterminate,
|
||||||
|
count,
|
||||||
|
onToggle,
|
||||||
|
}) => {
|
||||||
|
const ref = useRef<HTMLInputElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) ref.current.indeterminate = indeterminate;
|
||||||
|
}, [indeterminate]);
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onToggle}
|
||||||
|
data-action="knowledge-claims-select-all"
|
||||||
|
className="size-4 accent-[var(--primary)]"
|
||||||
|
/>
|
||||||
|
Select all <span className="text-xs">({count})</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BulkBar: FC<{
|
||||||
|
count: number;
|
||||||
|
pending: null | "confirm" | "reject";
|
||||||
|
running: boolean;
|
||||||
|
canConfirm: boolean;
|
||||||
|
canReject: boolean;
|
||||||
|
onAsk: (kind: "confirm" | "reject") => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onRun: (kind: "confirm" | "reject") => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}> = ({ count, pending, running, canConfirm, canReject, onAsk, onCancel, onRun, onClear }) => (
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 bottom-6 z-40 flex justify-center px-4">
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="Bulk review actions"
|
||||||
|
className="pointer-events-auto flex items-center gap-2 rounded-xl border border-border bg-popover px-3 py-2 shadow-e4"
|
||||||
|
>
|
||||||
|
{pending ? (
|
||||||
|
<>
|
||||||
|
<span className="px-1 text-sm text-foreground">
|
||||||
|
{pending === "confirm" ? "Confirm" : "Reject"} {count} claim{count === 1 ? "" : "s"}?
|
||||||
|
</span>
|
||||||
|
<BarBtn primary={pending === "confirm"} danger={pending === "reject"} disabled={running} onClick={() => onRun(pending)}>
|
||||||
|
{running ? <Loader2 className="size-3.5 animate-spin" /> : pending === "confirm" ? <Check className="size-3.5" /> : <X className="size-3.5" />}
|
||||||
|
{running ? "Working…" : "Yes"}
|
||||||
|
</BarBtn>
|
||||||
|
<BarBtn disabled={running} onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</BarBtn>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="px-1 text-sm text-foreground">
|
||||||
|
<span className="font-semibold">{count}</span> selected
|
||||||
|
</span>
|
||||||
|
{canConfirm && (
|
||||||
|
<BarBtn primary onClick={() => onAsk("confirm")} data-action="knowledge-claims-bulk-confirm">
|
||||||
|
<Check className="size-3.5" /> Confirm
|
||||||
|
</BarBtn>
|
||||||
|
)}
|
||||||
|
{canReject && (
|
||||||
|
<BarBtn onClick={() => onAsk("reject")} data-action="knowledge-claims-bulk-reject">
|
||||||
|
<X className="size-3.5" /> Reject
|
||||||
|
</BarBtn>
|
||||||
|
)}
|
||||||
|
<BarBtn onClick={onClear}>Clear</BarBtn>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const BarBtn: FC<{
|
||||||
|
children: ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
primary?: boolean;
|
||||||
|
danger?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
"data-action"?: string;
|
||||||
|
}> = ({ children, onClick, primary, danger, disabled, ...rest }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
{...rest}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium transition disabled:opacity-50",
|
||||||
|
primary
|
||||||
|
? "bg-primary text-primary-foreground hover:opacity-90"
|
||||||
|
: danger
|
||||||
|
? "bg-destructive text-destructive-foreground hover:opacity-90"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
function groupConflicts(claims: Claim[], byId: Map<string, Claim>): Claim[][] {
|
function groupConflicts(claims: Claim[], byId: Map<string, Claim>): Claim[][] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const groups: Claim[][] = [];
|
const groups: Claim[][] = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user