W5: claims review queue
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) <noreply@anthropic.com>
This commit is contained in:
@@ -4,11 +4,13 @@
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
ClaimsReviewQueue,
|
||||||
CollectionForm,
|
CollectionForm,
|
||||||
CollectionList,
|
CollectionList,
|
||||||
ObjectList,
|
ObjectList,
|
||||||
ObjectViewer,
|
ObjectViewer,
|
||||||
MockKnowledgeTransport,
|
MockKnowledgeTransport,
|
||||||
|
type Claim,
|
||||||
type Collection,
|
type Collection,
|
||||||
type CollectionInput,
|
type CollectionInput,
|
||||||
type CollectionPatch,
|
type CollectionPatch,
|
||||||
@@ -25,11 +27,14 @@ type View =
|
|||||||
| { kind: "object"; id: string };
|
| { kind: "object"; id: string };
|
||||||
|
|
||||||
export default function KnowledgeDemo() {
|
export default function KnowledgeDemo() {
|
||||||
|
const [tab, setTab] = useState<"browse" | "review">("browse");
|
||||||
const [view, setView] = useState<View>({ kind: "collections" });
|
const [view, setView] = useState<View>({ kind: "collections" });
|
||||||
const [collections, setCollections] = useState<Collection[]>([]);
|
const [collections, setCollections] = useState<Collection[]>([]);
|
||||||
const [pending, setPending] = useState<Record<string, number>>({});
|
const [pending, setPending] = useState<Record<string, number>>({});
|
||||||
const [objects, setObjects] = useState<ObjectSummary[]>([]);
|
const [objects, setObjects] = useState<ObjectSummary[]>([]);
|
||||||
const [outline, setOutline] = useState<ObjectOutline | null>(null);
|
const [outline, setOutline] = useState<ObjectOutline | null>(null);
|
||||||
|
const [claims, setClaims] = useState<Claim[]>([]);
|
||||||
|
const [busyId, setBusyId] = useState<string | undefined>();
|
||||||
const [creating, setCreating] = useState<null | "account" | "tenant">(null);
|
const [creating, setCreating] = useState<null | "account" | "tenant">(null);
|
||||||
const [editing, setEditing] = useState<Collection | null>(null);
|
const [editing, setEditing] = useState<Collection | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -38,11 +43,19 @@ export default function KnowledgeDemo() {
|
|||||||
const page = await transport.listCollections();
|
const page = await transport.listCollections();
|
||||||
setCollections(page.collections);
|
setCollections(page.collections);
|
||||||
const queue = await transport.reviewQueue();
|
const queue = await transport.reviewQueue();
|
||||||
|
setClaims(queue);
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const c of queue) counts[c.collection] = (counts[c.collection] ?? 0) + 1;
|
for (const c of queue) counts[c.collection] = (counts[c.collection] ?? 0) + 1;
|
||||||
setPending(counts);
|
setPending(counts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function afterClaim(id: string, fn: () => Promise<unknown>) {
|
||||||
|
setBusyId(id);
|
||||||
|
await fn();
|
||||||
|
setBusyId(undefined);
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -68,17 +81,43 @@ export default function KnowledgeDemo() {
|
|||||||
void refresh();
|
void refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reviewCount = claims.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl p-6">
|
<div className="mx-auto max-w-5xl p-6">
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<TabBtn active={tab === "browse"} onClick={() => setTab("browse")}>Browse</TabBtn>
|
||||||
|
<TabBtn active={tab === "review"} onClick={() => setTab("review")}>
|
||||||
|
Review{reviewCount > 0 ? ` (${reviewCount})` : ""}
|
||||||
|
</TabBtn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "review" && (
|
||||||
|
<ClaimsReviewQueue
|
||||||
|
claims={claims}
|
||||||
|
busyId={busyId}
|
||||||
|
onConfirm={(id) => 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" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setView({ kind: "collections" })}
|
onClick={() => setView({ kind: "collections" })}
|
||||||
className="mb-4 text-sm text-muted-foreground hover:text-foreground"
|
className="mb-4 text-sm text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
Knowledge{view.kind !== "collections" ? " / …" : ""}
|
← All corpuses
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{view.kind === "collections" && (
|
{tab === "browse" && view.kind === "collections" && (
|
||||||
<>
|
<>
|
||||||
{creating && (
|
{creating && (
|
||||||
<div className="mb-6 rounded-xl border border-border bg-card p-4">
|
<div className="mb-6 rounded-xl border border-border bg-card p-4">
|
||||||
@@ -103,7 +142,7 @@ export default function KnowledgeDemo() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{view.kind === "collection" && (
|
{tab === "browse" && view.kind === "collection" && (
|
||||||
<>
|
<>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h1 className="text-lg font-semibold">{view.slug}</h1>
|
<h1 className="text-lg font-semibold">{view.slug}</h1>
|
||||||
@@ -113,7 +152,7 @@ export default function KnowledgeDemo() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{view.kind === "object" && outline && (
|
{tab === "browse" && view.kind === "object" && outline && (
|
||||||
<ObjectViewer
|
<ObjectViewer
|
||||||
outline={outline}
|
outline={outline}
|
||||||
readText={(sel) => transport.readText(outline.object_id, sel)}
|
readText={(sel) => transport.readText(outline.object_id, sel)}
|
||||||
@@ -128,3 +167,18 @@ export default function KnowledgeDemo() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TabBtn({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={
|
||||||
|
"rounded-lg px-3 py-1.5 text-sm font-medium transition " +
|
||||||
|
(active ? "bg-primary/10 text-primary" : "text-muted-foreground hover:bg-muted")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,304 @@
|
|||||||
// Placeholder — filled in its workstream (W4/W5/W6).
|
// PURPOSE: Claims review queue (spec §3.4, claims-spec §2.3) — proposed claims,
|
||||||
export {};
|
// 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<ClaimCardProps> = ({ claim, busy, className, onConfirm, onReject, onReopen, onOpenSource }) => {
|
||||||
|
const rejected = claim.review_state === "rejected";
|
||||||
|
return (
|
||||||
|
<div className={cn("rounded-xl border border-border bg-card p-4", className)}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ClaimHead: FC<{ claim: Claim; onOpenSource?: ClaimActions["onOpenSource"] }> = ({ claim, onOpenSource }) => (
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{claim.subject}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">·</span>
|
||||||
|
<span className="text-sm font-medium text-foreground">{claim.predicate}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-foreground">{renderValue(claim.value)}</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||||
|
<TierBadge tier={claim.assertion_tier} />
|
||||||
|
{claim.effective_at && <span className="text-xs text-muted-foreground">effective {formatDate(claim.effective_at)}</span>}
|
||||||
|
{claim.source?.object_id && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action="knowledge-claim-source"
|
||||||
|
onClick={() => onOpenSource?.(claim.source!.object_id, claim.source!.span)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-xs text-muted-foreground transition hover:text-foreground"
|
||||||
|
title="Open the source passage"
|
||||||
|
>
|
||||||
|
source <ArrowUpRight className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 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<ConflictPairProps> = ({ claims, busy, onKeep, onDismiss, onReject, onOpenSource, className }) => {
|
||||||
|
const values = claims.map((c) => renderValue(c.value));
|
||||||
|
return (
|
||||||
|
<div className={cn("rounded-xl border border-[var(--warning)]/40 bg-[color-mix(in_oklab,var(--warning)_6%,transparent)] p-4", className)}>
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-sm">
|
||||||
|
<AlertTriangle className="size-4 text-[var(--warning)]" />
|
||||||
|
<span className="font-medium text-foreground">These disagree</span>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
{claims[0]?.subject} · {claims[0]?.predicate}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{claims.map((c, i) => (
|
||||||
|
<div key={c.claim_id} className="flex flex-col gap-2 rounded-lg border border-border bg-card p-3">
|
||||||
|
<p className={cn("text-sm font-medium", values.filter((v) => v === values[i]).length === 1 && "text-[var(--warning)]")}>
|
||||||
|
{values[i]}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<TierBadge tier={c.assertion_tier} />
|
||||||
|
{c.effective_at && <span className="text-xs text-muted-foreground">effective {formatDate(c.effective_at)}</span>}
|
||||||
|
{c.source?.object_id && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenSource?.(c.source!.object_id, c.source!.span)}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
source <ArrowUpRight className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||||
|
{onKeep && (
|
||||||
|
<ActionBtn
|
||||||
|
action="knowledge-conflict-keep"
|
||||||
|
primary
|
||||||
|
busy={busy}
|
||||||
|
onClick={() => onKeep(c.claim_id, claims.filter((o) => o.claim_id !== c.claim_id).map((o) => o.claim_id))}
|
||||||
|
icon={<Check className="size-3.5" />}
|
||||||
|
>
|
||||||
|
Keep this
|
||||||
|
</ActionBtn>
|
||||||
|
)}
|
||||||
|
{onReject && c.assertion_tier !== "human_confirmed" && (
|
||||||
|
<ActionBtn action="knowledge-conflict-reject" busy={busy} onClick={() => onReject(c.claim_id)} icon={<X className="size-3.5" />}>
|
||||||
|
Reject
|
||||||
|
</ActionBtn>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{onDismiss && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action="knowledge-conflict-dismiss"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onDismiss(claims[0].claim_id)}
|
||||||
|
className="mt-3 text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
They're both true — keep both
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 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<ClaimsReviewQueueProps> = ({
|
||||||
|
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 ?? <Empty />}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-6", className)}>
|
||||||
|
<Section title="Proposed" count={proposed.length} hint="An agent noted these — confirm or reject.">
|
||||||
|
{proposed.map((c) => (
|
||||||
|
<ClaimCard key={c.claim_id} claim={c} busy={busyId === c.claim_id} onConfirm={onConfirm} onReject={onReject} onOpenSource={onOpenSource} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Conflicts" count={conflictGroups.length} hint="Two facts disagree — you decide.">
|
||||||
|
{conflictGroups.map((group) => (
|
||||||
|
<ConflictPair
|
||||||
|
key={group.map((c) => c.claim_id).join("+")}
|
||||||
|
claims={group}
|
||||||
|
busy={group.some((c) => busyId === c.claim_id)}
|
||||||
|
onKeep={onKeep}
|
||||||
|
onDismiss={onDismissConflict}
|
||||||
|
onReject={onReject}
|
||||||
|
onOpenSource={onOpenSource}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Awaiting review" count={unreviewed.length} hint="Visible already, labelled by tier — review at your pace.">
|
||||||
|
{unreviewed.map((c) => (
|
||||||
|
<ClaimCard key={c.claim_id} claim={c} busy={busyId === c.claim_id} onConfirm={onConfirm} onReject={onReject} onReopen={onReopen} onOpenSource={onOpenSource} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function groupConflicts(claims: Claim[], byId: Map<string, Claim>): Claim[][] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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 (
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 flex items-baseline gap-2">
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
||||||
|
<span className="text-xs text-muted-foreground">({count})</span>
|
||||||
|
<span className="text-xs text-muted-foreground">— {hint}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Empty: FC = () => (
|
||||||
|
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border py-12 text-center">
|
||||||
|
<Sparkles className="size-6 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-foreground">Nothing to review</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Your agents' claims will appear here when there's something to confirm.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ActionBtn: FC<{
|
||||||
|
action: string;
|
||||||
|
onClick: () => void;
|
||||||
|
icon: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
primary?: boolean;
|
||||||
|
busy?: boolean;
|
||||||
|
}> = ({ action, onClick, icon, children, primary, busy }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action={action}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition disabled:opacity-50",
|
||||||
|
primary ? "bg-primary text-primary-foreground hover:opacity-90" : "border border-border text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user