From 40d2f94795733373a6ad852b70870dc057b1346b Mon Sep 17 00:00:00 2001 From: jules Date: Tue, 7 Jul 2026 10:03:54 +1000 Subject: [PATCH] W5: claims review queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit components-claims: ClaimsReviewQueue (proposed → conflicts → unreviewed per claims-spec §2.3), ClaimCard (confirm/reject/reopen tombstone), ConflictPair (both sides raw, three resolutions: keep-with-supersede, dismiss-both, reject-newcomer; human_confirmed can't be rejected). Tiers raw, no scores. Demo Review tab wired. Typechecks clean. Co-Authored-By: Claude Fable 5 (build) --- demo/knowledge.tsx | 74 +++++++-- src/components-claims.tsx | 306 +++++++++++++++++++++++++++++++++++++- 2 files changed, 368 insertions(+), 12 deletions(-) diff --git a/demo/knowledge.tsx b/demo/knowledge.tsx index 5e7ca2f..3d6a863 100644 --- a/demo/knowledge.tsx +++ b/demo/knowledge.tsx @@ -4,11 +4,13 @@ // =========================================================================== import { useEffect, useState } from "react"; import { + ClaimsReviewQueue, CollectionForm, CollectionList, ObjectList, ObjectViewer, MockKnowledgeTransport, + type Claim, type Collection, type CollectionInput, type CollectionPatch, @@ -25,11 +27,14 @@ type View = | { kind: "object"; id: string }; export default function KnowledgeDemo() { + const [tab, setTab] = useState<"browse" | "review">("browse"); const [view, setView] = useState({ kind: "collections" }); const [collections, setCollections] = useState([]); const [pending, setPending] = useState>({}); const [objects, setObjects] = useState([]); const [outline, setOutline] = useState(null); + const [claims, setClaims] = useState([]); + const [busyId, setBusyId] = useState(); const [creating, setCreating] = useState(null); const [editing, setEditing] = useState(null); const [busy, setBusy] = useState(false); @@ -38,11 +43,19 @@ export default function KnowledgeDemo() { const page = await transport.listCollections(); setCollections(page.collections); const queue = await transport.reviewQueue(); + setClaims(queue); const counts: Record = {}; for (const c of queue) counts[c.collection] = (counts[c.collection] ?? 0) + 1; setPending(counts); } + async function afterClaim(id: string, fn: () => Promise) { + setBusyId(id); + await fn(); + setBusyId(undefined); + void refresh(); + } + useEffect(() => { void refresh(); }, []); @@ -68,17 +81,43 @@ export default function KnowledgeDemo() { void refresh(); } + const reviewCount = claims.length; + return (
- +
+ setTab("browse")}>Browse + setTab("review")}> + Review{reviewCount > 0 ? ` (${reviewCount})` : ""} + +
- {view.kind === "collections" && ( + {tab === "review" && ( + afterClaim(id, () => transport.confirmClaim(id))} + onReject={(id) => afterClaim(id, () => transport.rejectClaim(id))} + onKeep={(winner, supersede) => afterClaim(winner, () => transport.confirmClaim(winner, { supersede }))} + onDismissConflict={(id) => afterClaim(id, () => transport.dismissConflict(id))} + onOpenSource={(objectId) => { + setTab("browse"); + setView({ kind: "object", id: objectId }); + }} + /> + )} + + {tab === "browse" && view.kind !== "collections" && ( + + )} + + {tab === "browse" && view.kind === "collections" && ( <> {creating && (
@@ -103,7 +142,7 @@ export default function KnowledgeDemo() { )} - {view.kind === "collection" && ( + {tab === "browse" && view.kind === "collection" && ( <>

{view.slug}

@@ -113,7 +152,7 @@ export default function KnowledgeDemo() { )} - {view.kind === "object" && outline && ( + {tab === "browse" && view.kind === "object" && outline && ( transport.readText(outline.object_id, sel)} @@ -128,3 +167,18 @@ export default function KnowledgeDemo() {
); } + +function TabBtn({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { + return ( + + ); +} diff --git a/src/components-claims.tsx b/src/components-claims.tsx index 20f8e6e..1ef414e 100644 --- a/src/components-claims.tsx +++ b/src/components-claims.tsx @@ -1,2 +1,304 @@ -// Placeholder — filled in its workstream (W4/W5/W6). -export {}; +// PURPOSE: Claims review queue (spec §3.4, claims-spec §2.3) — proposed claims, +// open conflicts (both sides raw, three human resolutions), and +// unreviewed claims. Tiers surfaced raw, never scored. Tombstone re-open +// via the general claims list. Props in, callbacks out. +// =========================================================================== +import { type FC, type ReactNode } from "react"; +import { AlertTriangle, ArrowUpRight, Check, RotateCcw, Sparkles, X } from "lucide-react"; +import type { Claim } from "./types"; +import { Badge, TierBadge, cn, formatDate, renderValue } from "./_internal"; + +// ---- ClaimCard ------------------------------------------------------------ + +export interface ClaimActions { + onConfirm?: (id: string) => void; + onReject?: (id: string) => void; + onReopen?: (id: string) => void; + onOpenSource?: (objectId: string, span?: { start: number; end: number }) => void; +} + +export interface ClaimCardProps extends ClaimActions { + claim: Claim; + busy?: boolean; + className?: string; +} + +export const ClaimCard: FC = ({ claim, busy, className, onConfirm, onReject, onReopen, onOpenSource }) => { + const rejected = claim.review_state === "rejected"; + return ( +
+ +
+ {rejected ? ( + <> + Rejected {formatDate(claim.updated_at)} + {onReopen && ( + onReopen(claim.claim_id)} busy={busy} icon={}> + Re-open + + )} + + ) : ( + <> + {onConfirm && ( + onConfirm(claim.claim_id)} busy={busy} icon={}> + {claim.status === "proposed" ? "Confirm" : "Mark reviewed"} + + )} + {onReject && ( + onReject(claim.claim_id)} busy={busy} icon={}> + Reject + + )} + + )} +
+
+ ); +}; + +const ClaimHead: FC<{ claim: Claim; onOpenSource?: ClaimActions["onOpenSource"] }> = ({ claim, onOpenSource }) => ( +
+
+
+ {claim.subject} + · + {claim.predicate} +
+

{renderValue(claim.value)}

+
+ + {claim.effective_at && effective {formatDate(claim.effective_at)}} + {claim.source?.object_id && ( + + )} +
+
+
+); + +// ---- ConflictPair --------------------------------------------------------- + +export interface ConflictPairProps { + /** All claims in the conflict group (same subject+predicate, differing values). */ + claims: Claim[]; + busy?: boolean; + /** Confirm `winnerId` and supersede the rest (confirm-with-supersede). */ + onKeep?: (winnerId: string, supersedeIds: string[]) => void; + /** Both true — dismiss the conflict (multi-valued predicate). */ + onDismiss?: (id: string) => void; + onReject?: (id: string) => void; + onOpenSource?: ClaimActions["onOpenSource"]; + className?: string; +} + +export const ConflictPair: FC = ({ claims, busy, onKeep, onDismiss, onReject, onOpenSource, className }) => { + const values = claims.map((c) => renderValue(c.value)); + return ( +
+
+ + These disagree + + {claims[0]?.subject} · {claims[0]?.predicate} + +
+
+ {claims.map((c, i) => ( +
+

v === values[i]).length === 1 && "text-[var(--warning)]")}> + {values[i]} +

+
+ + {c.effective_at && effective {formatDate(c.effective_at)}} + {c.source?.object_id && ( + + )} +
+
+ {onKeep && ( + onKeep(c.claim_id, claims.filter((o) => o.claim_id !== c.claim_id).map((o) => o.claim_id))} + icon={} + > + Keep this + + )} + {onReject && c.assertion_tier !== "human_confirmed" && ( + onReject(c.claim_id)} icon={}> + Reject + + )} +
+
+ ))} +
+ {onDismiss && ( + + )} +
+ ); +}; + +// ---- ClaimsReviewQueue ---------------------------------------------------- + +export interface ClaimsReviewQueueProps { + claims: Claim[]; + busyId?: string; + onConfirm?: (id: string) => void; + onReject?: (id: string) => void; + onReopen?: (id: string) => void; + /** Confirm winner + supersede the rest of a conflict group. */ + onKeep?: (winnerId: string, supersedeIds: string[]) => void; + onDismissConflict?: (id: string) => void; + onOpenSource?: ClaimActions["onOpenSource"]; + emptyState?: ReactNode; + className?: string; +} + +/** Groups: proposed → open conflicts → unreviewed (claims-spec §2.3 order). */ +export const ClaimsReviewQueue: FC = ({ + claims, + busyId, + onConfirm, + onReject, + onReopen, + onKeep, + onDismissConflict, + onOpenSource, + emptyState, + className, +}) => { + const byId = new Map(claims.map((c) => [c.claim_id, c])); + const conflictGroups = groupConflicts(claims, byId); + const inConflict = new Set(conflictGroups.flat().map((c) => c.claim_id)); + + const proposed = claims.filter((c) => c.status === "proposed" && !inConflict.has(c.claim_id)); + const unreviewed = claims.filter( + (c) => c.status === "active" && c.review_state === "unreviewed" && !inConflict.has(c.claim_id), + ); + + if (claims.length === 0) { + return <>{emptyState ?? }; + } + + return ( +
+
+ {proposed.map((c) => ( + + ))} +
+ +
+ {conflictGroups.map((group) => ( + c.claim_id).join("+")} + claims={group} + busy={group.some((c) => busyId === c.claim_id)} + onKeep={onKeep} + onDismiss={onDismissConflict} + onReject={onReject} + onOpenSource={onOpenSource} + /> + ))} +
+ +
+ {unreviewed.map((c) => ( + + ))} +
+
+ ); +}; + +function groupConflicts(claims: Claim[], byId: Map): Claim[][] { + const seen = new Set(); + const groups: Claim[][] = []; + for (const c of claims) { + if (c.conflict_state !== "open" || seen.has(c.claim_id)) continue; + const group: Claim[] = [c]; + seen.add(c.claim_id); + for (const otherId of c.conflicts_with ?? []) { + const other = byId.get(otherId); + if (other && !seen.has(otherId)) { + group.push(other); + seen.add(otherId); + } + } + if (group.length > 1) groups.push(group); + } + return groups; +} + +const Section: FC<{ title: string; count: number; hint: string; children: ReactNode }> = ({ title, count, hint, children }) => { + if (count === 0) return null; + return ( +
+
+

{title}

+ ({count}) + — {hint} +
+
{children}
+
+ ); +}; + +const Empty: FC = () => ( +
+ +

Nothing to review

+

Your agents' claims will appear here when there's something to confirm.

+
+); + +const ActionBtn: FC<{ + action: string; + onClick: () => void; + icon: ReactNode; + children: ReactNode; + primary?: boolean; + busy?: boolean; +}> = ({ action, onClick, icon, children, primary, busy }) => ( + +);