Compare commits
5 Commits
2c921a911c
...
5d149e51dd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d149e51dd | ||
|
|
e7376001e7 | ||
|
|
7997dbb3f3 | ||
|
|
efc2c18a91 | ||
|
|
ac7c294753 |
@@ -4,8 +4,8 @@
|
|||||||
// dep) so a fresh consumer needs only this lib's alias.
|
// dep) so a fresh consumer needs only this lib's alias.
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
import type { FC, ReactNode } from "react";
|
import type { FC, ReactNode } from "react";
|
||||||
import { Lock, ShieldAlert, Globe, Sparkles, UserCheck, FileText } from "lucide-react";
|
import { Lock, ShieldAlert, Globe, Bot, UserCheck, FileText } from "lucide-react";
|
||||||
import type { AssertionTier, Curation, ObjectStatus, Sensitivity } from "./types";
|
import type { AssertionTier, Curation, ObjectStatus, PrincipalRef, Sensitivity } from "./types";
|
||||||
|
|
||||||
export function cn(...parts: (string | false | null | undefined)[]): string {
|
export function cn(...parts: (string | false | null | undefined)[]): string {
|
||||||
return parts.filter(Boolean).join(" ");
|
return parts.filter(Boolean).join(" ");
|
||||||
@@ -58,6 +58,14 @@ function rel(n: number, unit: string): string {
|
|||||||
return n <= 0 ? `${abs} ${u} ago` : `in ${abs} ${u}`;
|
return n <= 0 ? `${abs} ${u} ago` : `in ${abs} ${u}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Render a principal (`"account:you"` or the service's `{type, id}` ref) as display text. */
|
||||||
|
export function formatPrincipal(p?: string | PrincipalRef | null): string | null {
|
||||||
|
if (p == null) return null;
|
||||||
|
if (typeof p === "string") return p;
|
||||||
|
const id = typeof p.id === "string" && p.id.length > 12 ? `${p.id.slice(0, 8)}…` : String(p.id ?? "");
|
||||||
|
return id ? `${p.type} · ${id}` : p.type;
|
||||||
|
}
|
||||||
|
|
||||||
/** Render a claim/JSON value compactly for cards and one-liners. */
|
/** Render a claim/JSON value compactly for cards and one-liners. */
|
||||||
export function renderValue(value: unknown): string {
|
export function renderValue(value: unknown): string {
|
||||||
if (value == null) return "—";
|
if (value == null) return "—";
|
||||||
@@ -82,15 +90,33 @@ export function isImageMime(mime?: string | null): boolean {
|
|||||||
const badgeBase =
|
const badgeBase =
|
||||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium leading-none";
|
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium leading-none";
|
||||||
|
|
||||||
export const Badge: FC<{ tone?: string; icon?: ReactNode; children: ReactNode; className?: string; title?: string }> = ({
|
// Semantic badge tones. The text mixes the semantic colour toward --foreground
|
||||||
tone,
|
// so it clears WCAG AA (4.5:1) on the subtle tint in BOTH light and dark mode:
|
||||||
icon,
|
// in light mode --foreground is near-black (darkens the text), in dark mode it's
|
||||||
children,
|
// near-white (lightens it). A static black mix would fail dark mode.
|
||||||
className,
|
export const successTone =
|
||||||
title,
|
"bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-[color-mix(in_oklab,var(--success),var(--foreground)_22%)]";
|
||||||
}) => (
|
export const warningTone =
|
||||||
<span className={cn(badgeBase, tone, className)} title={title}>
|
"bg-[color-mix(in_oklab,var(--warning)_18%,transparent)] text-[color-mix(in_oklab,var(--warning),var(--foreground)_34%)]";
|
||||||
{icon}
|
export const infoTone =
|
||||||
|
"bg-[color-mix(in_oklab,var(--info,var(--primary))_16%,transparent)] text-[color-mix(in_oklab,var(--info,var(--primary)),var(--foreground)_16%)]";
|
||||||
|
export const dangerTone =
|
||||||
|
"bg-[color-mix(in_oklab,var(--destructive)_15%,transparent)] text-[color-mix(in_oklab,var(--destructive),var(--foreground)_16%)]";
|
||||||
|
|
||||||
|
export const Badge: FC<{
|
||||||
|
tone?: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
/** Mouse-hover explanation. */
|
||||||
|
title?: string;
|
||||||
|
/** Accessible name for assistive tech; falls back to `title`. Set this so a
|
||||||
|
* screen reader announces the meaning ("Open — may go to any AI model"),
|
||||||
|
* not just the terse visible label. */
|
||||||
|
ariaLabel?: string;
|
||||||
|
}> = ({ tone, icon, children, className, title, ariaLabel }) => (
|
||||||
|
<span className={cn(badgeBase, tone, className)} title={title} aria-label={ariaLabel ?? title}>
|
||||||
|
{icon != null && <span aria-hidden="true" className="inline-flex">{icon}</span>}
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -104,31 +130,27 @@ const SENSITIVITY: Record<Sensitivity, { label: string; tone: string; icon: Reac
|
|||||||
},
|
},
|
||||||
restricted: {
|
restricted: {
|
||||||
label: "Restricted",
|
label: "Restricted",
|
||||||
tone: "bg-[color-mix(in_oklab,var(--warning)_18%,transparent)] text-[var(--warning)]",
|
tone: warningTone,
|
||||||
icon: <ShieldAlert className="size-3" />,
|
icon: <ShieldAlert className="size-3" />,
|
||||||
title: "Restricted — amounts and personal detail; approved AI destinations only, via redaction.",
|
title: "Restricted — amounts and personal detail; approved AI destinations only, via redaction.",
|
||||||
},
|
},
|
||||||
vault: {
|
vault: {
|
||||||
label: "Vault",
|
label: "Vault",
|
||||||
tone: "bg-[color-mix(in_oklab,var(--destructive)_15%,transparent)] text-destructive",
|
tone: dangerTone,
|
||||||
icon: <Lock className="size-3" />,
|
icon: <Lock className="size-3" />,
|
||||||
title: "Vault — identity/medical grade; never sent to any AI model.",
|
title: "Vault — identity/medical grade; never sent to any AI model.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SensitivityBadge: FC<{ level: Sensitivity; className?: string }> = ({ level, className }) => {
|
export const SensitivityBadge: FC<{ level: Sensitivity; className?: string }> = ({ level, className }) => {
|
||||||
// A row can arrive without a sensitivity (e.g. a claim/note, or older data);
|
// A row can arrive without a sensitivity: objects inherit it from their
|
||||||
// fall back to a neutral badge rather than crash — and never default an
|
// collection, and claims/notes carry none. Render NOTHING in that case — a
|
||||||
// unknown value to "Open", which would misstate its exposure.
|
// "—" pill reads as broken data. Never default an unknown value to "Open",
|
||||||
const s =
|
// which would misstate its exposure.
|
||||||
SENSITIVITY[level] ?? {
|
const s = SENSITIVITY[level];
|
||||||
label: level ?? "—",
|
if (!s) return null;
|
||||||
tone: "bg-muted text-muted-foreground",
|
|
||||||
icon: null,
|
|
||||||
title: "Sensitivity not set.",
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<Badge tone={s.tone} icon={s.icon} className={className} title={s.title}>
|
<Badge tone={s.tone} icon={s.icon} className={className} title={s.title} ariaLabel={`Sensitivity: ${s.title}`}>
|
||||||
{s.label}
|
{s.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
@@ -136,11 +158,15 @@ export const SensitivityBadge: FC<{ level: Sensitivity; className?: string }> =
|
|||||||
|
|
||||||
export const CurationBadge: FC<{ curation: Curation; className?: string }> = ({ curation, className }) => {
|
export const CurationBadge: FC<{ curation: Curation; className?: string }> = ({ curation, className }) => {
|
||||||
const gated = curation === "gated";
|
const gated = curation === "gated";
|
||||||
|
const title = gated
|
||||||
|
? "Gated — an agent's claims stay hidden until you confirm them."
|
||||||
|
: "Live — an agent's claims appear immediately, labelled by tier.";
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
tone={gated ? "bg-[color-mix(in_oklab,var(--info,var(--primary))_16%,transparent)] text-[var(--info,var(--primary))]" : "bg-muted text-muted-foreground"}
|
tone={gated ? infoTone : "bg-muted text-muted-foreground"}
|
||||||
className={className}
|
className={className}
|
||||||
title={gated ? "Gated — an agent's claims stay hidden until you confirm them." : "Live — an agent's claims appear immediately, labelled by tier."}
|
title={title}
|
||||||
|
ariaLabel={`Claim curation: ${title}`}
|
||||||
>
|
>
|
||||||
{gated ? "Gated" : "Live"}
|
{gated ? "Gated" : "Live"}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -148,12 +174,12 @@ export const CurationBadge: FC<{ curation: Curation; className?: string }> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const STATUS: Record<ObjectStatus, { label: string; tone: string }> = {
|
const STATUS: Record<ObjectStatus, { label: string; tone: string }> = {
|
||||||
active: { label: "Active", tone: "bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-[var(--success)]" },
|
active: { label: "Active", tone: successTone },
|
||||||
ingesting: { label: "Processing", tone: "bg-muted text-muted-foreground" },
|
ingesting: { label: "Processing", tone: "bg-muted text-muted-foreground" },
|
||||||
proposed: { label: "Proposed", tone: "bg-[color-mix(in_oklab,var(--warning)_18%,transparent)] text-[var(--warning)]" },
|
proposed: { label: "Proposed", tone: warningTone },
|
||||||
superseded: { label: "Superseded", tone: "bg-muted text-muted-foreground" },
|
superseded: { label: "Superseded", tone: "bg-muted text-muted-foreground" },
|
||||||
archived: { label: "Archived", tone: "bg-muted text-muted-foreground" },
|
archived: { label: "Archived", tone: "bg-muted text-muted-foreground" },
|
||||||
failed: { label: "Failed", tone: "bg-[color-mix(in_oklab,var(--destructive)_15%,transparent)] text-destructive" },
|
failed: { label: "Failed", tone: dangerTone },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StatusBadge: FC<{ status: ObjectStatus; className?: string }> = ({ status, className }) => {
|
export const StatusBadge: FC<{ status: ObjectStatus; className?: string }> = ({ status, className }) => {
|
||||||
@@ -174,13 +200,13 @@ const TIER: Record<AssertionTier, { label: string; tone: string; icon: ReactNode
|
|||||||
},
|
},
|
||||||
agent_asserted: {
|
agent_asserted: {
|
||||||
label: "Agent-asserted",
|
label: "Agent-asserted",
|
||||||
tone: "bg-[color-mix(in_oklab,var(--info,var(--primary))_16%,transparent)] text-[var(--info,var(--primary))]",
|
tone: infoTone,
|
||||||
icon: <Sparkles className="size-3" />,
|
icon: <Bot className="size-3" />,
|
||||||
title: "Asserted by an agent — not yet confirmed by a person.",
|
title: "Asserted by an agent — not yet confirmed by a person.",
|
||||||
},
|
},
|
||||||
human_confirmed: {
|
human_confirmed: {
|
||||||
label: "You confirmed",
|
label: "You confirmed",
|
||||||
tone: "bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-[var(--success)]",
|
tone: successTone,
|
||||||
icon: <UserCheck className="size-3" />,
|
icon: <UserCheck className="size-3" />,
|
||||||
title: "Confirmed by a person.",
|
title: "Confirmed by a person.",
|
||||||
},
|
},
|
||||||
@@ -188,9 +214,9 @@ const TIER: Record<AssertionTier, { label: string; tone: string; icon: ReactNode
|
|||||||
|
|
||||||
/** The trust surface — categorical, never a score ([[feedback_trust_is_human]]). */
|
/** The trust surface — categorical, never a score ([[feedback_trust_is_human]]). */
|
||||||
export const TierBadge: FC<{ tier: AssertionTier; className?: string }> = ({ tier, className }) => {
|
export const TierBadge: FC<{ tier: AssertionTier; className?: string }> = ({ tier, className }) => {
|
||||||
const t = TIER[tier];
|
const t = TIER[tier] ?? TIER.extracted;
|
||||||
return (
|
return (
|
||||||
<Badge tone={t.tone} icon={t.icon} className={className} title={t.title}>
|
<Badge tone={t.tone} icon={t.icon} className={className} title={t.title} ariaLabel={`Trust: ${t.title}`}>
|
||||||
{t.label}
|
{t.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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, RotateCcw, Sparkles, 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>
|
||||||
);
|
);
|
||||||
@@ -114,7 +148,7 @@ export const ConflictPair: FC<ConflictPairProps> = ({ claims, busy, onKeep, onDi
|
|||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{claims.map((c, i) => (
|
{claims.map((c, i) => (
|
||||||
<div key={c.claim_id} className="flex flex-col gap-2 rounded-lg border border-border bg-card p-3">
|
<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)]")}>
|
<p className={cn("text-sm font-medium", values.filter((v) => v === values[i]).length === 1 && "text-[color-mix(in_oklab,var(--warning),var(--foreground)_34%)]")}>
|
||||||
{values[i]}
|
{values[i]}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
@@ -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[][] = [];
|
||||||
@@ -277,7 +489,7 @@ const Section: FC<{ title: string; count: number; hint: string; children: ReactN
|
|||||||
|
|
||||||
const Empty: FC = () => (
|
const Empty: FC = () => (
|
||||||
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border py-12 text-center">
|
<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" />
|
<Inbox className="size-6 text-muted-foreground" aria-hidden="true" />
|
||||||
<p className="text-sm font-medium text-foreground">Nothing to review</p>
|
<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>
|
<p className="text-xs text-muted-foreground">Your agents' claims will appear here when there's something to confirm.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
// owner — "Your corpuses" (account) then each organisation (tenant).
|
// owner — "Your corpuses" (account) then each organisation (tenant).
|
||||||
// Props in, callbacks out; the app owns fetching + routing.
|
// Props in, callbacks out; the app owns fetching + routing.
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
import { useState, type FC, type ReactNode } from "react";
|
import { cloneElement, isValidElement, useId, useState, type FC, type ReactElement, type ReactNode } from "react";
|
||||||
import { Plus, Users, User, ChevronRight, Loader2 } from "lucide-react";
|
import { Plus, Users, User, ChevronRight, Loader2 } from "lucide-react";
|
||||||
import type { Collection, CollectionInput, CollectionPatch, Curation, Sensitivity } from "./types";
|
import type { Collection, CollectionInput, CollectionPatch, Curation, Sensitivity } from "./types";
|
||||||
import { Badge, CurationBadge, SensitivityBadge, cn } from "./_internal";
|
import { Badge, CurationBadge, SensitivityBadge, cn, warningTone } from "./_internal";
|
||||||
|
|
||||||
const SENS_ORDER: Sensitivity[] = ["open", "restricted", "vault"];
|
const SENS_ORDER: Sensitivity[] = ["open", "restricted", "vault"];
|
||||||
|
|
||||||
@@ -42,7 +42,11 @@ export const CollectionCard: FC<CollectionCardProps> = ({ collection, pendingCou
|
|||||||
<SensitivityBadge level={collection.sensitivity} />
|
<SensitivityBadge level={collection.sensitivity} />
|
||||||
<CurationBadge curation={collection.curation} />
|
<CurationBadge curation={collection.curation} />
|
||||||
{collection.claim_extraction === "auto" && (
|
{collection.claim_extraction === "auto" && (
|
||||||
<Badge tone="bg-muted text-muted-foreground" title="New documents are scanned for claims automatically.">
|
<Badge
|
||||||
|
tone="bg-muted text-muted-foreground"
|
||||||
|
title="New documents are scanned for claims automatically."
|
||||||
|
ariaLabel="Auto-claims: new documents are scanned for claims automatically."
|
||||||
|
>
|
||||||
Auto-claims
|
Auto-claims
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -50,7 +54,11 @@ export const CollectionCard: FC<CollectionCardProps> = ({ collection, pendingCou
|
|||||||
{collection.object_count ?? 0} {collection.object_count === 1 ? "item" : "items"}
|
{collection.object_count ?? 0} {collection.object_count === 1 ? "item" : "items"}
|
||||||
</span>
|
</span>
|
||||||
{pendingCount != null && pendingCount > 0 && (
|
{pendingCount != null && pendingCount > 0 && (
|
||||||
<Badge tone="bg-[color-mix(in_oklab,var(--warning)_18%,transparent)] text-[var(--warning)]" title="Claims awaiting your review.">
|
<Badge
|
||||||
|
tone={warningTone}
|
||||||
|
title="Claims awaiting your review."
|
||||||
|
ariaLabel={`${pendingCount} claim${pendingCount === 1 ? "" : "s"} awaiting your review.`}
|
||||||
|
>
|
||||||
{pendingCount} to review
|
{pendingCount} to review
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -211,8 +219,12 @@ export const CollectionForm: FC<CollectionFormProps> = ({
|
|||||||
const readOnly = isEdit && initial?.owner_type === "tenant" && !canManageTenant;
|
const readOnly = isEdit && initial?.owner_type === "tenant" && !canManageTenant;
|
||||||
|
|
||||||
const [slug, setSlug] = useState(initial?.slug ?? "");
|
const [slug, setSlug] = useState(initial?.slug ?? "");
|
||||||
|
const [slugTouched, setSlugTouched] = useState(false);
|
||||||
const [name, setName] = useState(initial?.name ?? "");
|
const [name, setName] = useState(initial?.name ?? "");
|
||||||
const [description, setDescription] = useState(initial?.description ?? "");
|
const [description, setDescription] = useState(initial?.description ?? "");
|
||||||
|
|
||||||
|
const slugify = (s: string) =>
|
||||||
|
s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||||
const [sensitivity, setSensitivity] = useState<Sensitivity>(initial?.sensitivity ?? "open");
|
const [sensitivity, setSensitivity] = useState<Sensitivity>(initial?.sensitivity ?? "open");
|
||||||
const [curation, setCuration] = useState<Curation>(initial?.curation ?? "live");
|
const [curation, setCuration] = useState<Curation>(initial?.curation ?? "live");
|
||||||
const [claimAuto, setClaimAuto] = useState((initial?.claim_extraction ?? "off") === "auto");
|
const [claimAuto, setClaimAuto] = useState((initial?.claim_extraction ?? "off") === "auto");
|
||||||
@@ -262,23 +274,41 @@ export const CollectionForm: FC<CollectionFormProps> = ({
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Field label="Name" hint="What you'll call this corpus — e.g. “Supplier documents”.">
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value);
|
||||||
|
// Auto-fill the identifier from the name until the user edits it by hand.
|
||||||
|
if (!isEdit && !slugTouched) setSlug(slugify(e.target.value));
|
||||||
|
}}
|
||||||
|
disabled={readOnly}
|
||||||
|
placeholder="Supplier documents"
|
||||||
|
autoFocus={!isEdit}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
{!isEdit && (
|
{!isEdit && (
|
||||||
<Field label="Slug" hint="Lower-case, hyphenated. Can't be changed later.">
|
<Field
|
||||||
|
label="Identifier"
|
||||||
|
hint="Used in links and by agents. Filled in from the name — edit if you like, but it can't be changed later."
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
data-action="knowledge-corpus-slug"
|
data-action="knowledge-corpus-slug"
|
||||||
className={inputCls}
|
className={cn(inputCls, "font-mono")}
|
||||||
value={slug}
|
value={slug}
|
||||||
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-"))}
|
onChange={(e) => {
|
||||||
placeholder="supplier-docs"
|
setSlugTouched(true);
|
||||||
|
setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-"));
|
||||||
|
}}
|
||||||
|
placeholder="supplier-documents"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Field label="Name">
|
|
||||||
<input className={inputCls} value={name} onChange={(e) => setName(e.target.value)} disabled={readOnly} required />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Description" hint="What belongs in this corpus?">
|
<Field label="Description" hint="What belongs in this corpus?">
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(inputCls, "min-h-[64px] resize-y")}
|
className={cn(inputCls, "min-h-[64px] resize-y")}
|
||||||
@@ -345,20 +375,39 @@ export const CollectionForm: FC<CollectionFormProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const inputCls =
|
const inputCls =
|
||||||
"w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition focus:border-primary/50 focus:ring-2 focus:ring-primary/30 disabled:opacity-60";
|
"w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus:border-primary/50 focus:ring-2 focus:ring-primary/30 disabled:opacity-60";
|
||||||
|
|
||||||
const Field: FC<{ label: string; hint?: string; children: ReactNode }> = ({ label, hint, children }) => (
|
const Field: FC<{ label: string; hint?: string; children: ReactNode }> = ({ label, hint, children }) => {
|
||||||
<div className="flex flex-col gap-1.5">
|
const id = useId();
|
||||||
<label className="text-sm font-medium text-foreground">{label}</label>
|
const hintId = `${id}-hint`;
|
||||||
{children}
|
// Link the label (and any hint) to the single control child for screen readers.
|
||||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
const control =
|
||||||
</div>
|
isValidElement(children) && (children.type === "input" || children.type === "select" || children.type === "textarea")
|
||||||
);
|
? cloneElement(children as ReactElement<Record<string, unknown>>, {
|
||||||
|
id,
|
||||||
|
"aria-describedby": hint ? hintId : undefined,
|
||||||
|
})
|
||||||
|
: children;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor={id} className="text-sm font-medium text-foreground">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{control}
|
||||||
|
{hint && (
|
||||||
|
<p id={hintId} className="text-xs text-muted-foreground">
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const OwnerToggle: FC<{ active: boolean; onClick: () => void; icon: ReactNode; label: string }> = ({ active, onClick, icon, label }) => (
|
const OwnerToggle: FC<{ active: boolean; onClick: () => void; icon: ReactNode; label: string }> = ({ active, onClick, icon, label }) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
aria-pressed={active}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition",
|
"inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition",
|
||||||
active ? "border-primary bg-primary/10 text-primary" : "border-border text-muted-foreground hover:bg-muted",
|
active ? "border-primary bg-primary/10 text-primary" : "border-border text-muted-foreground hover:bg-muted",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ObjectOutline, ObjectSummary, OutlineSection, TextSlice } from "./types";
|
import type { ObjectOutline, ObjectSummary, OutlineSection, PrincipalRef, TextSlice } from "./types";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
SensitivityBadge,
|
SensitivityBadge,
|
||||||
@@ -24,9 +24,20 @@ import {
|
|||||||
cn,
|
cn,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatDate,
|
formatDate,
|
||||||
|
formatPrincipal,
|
||||||
isImageMime,
|
isImageMime,
|
||||||
} from "./_internal";
|
} from "./_internal";
|
||||||
|
|
||||||
|
// The reader loads a bounded window and reveals more on demand — a long
|
||||||
|
// document (this one is ~445k chars) must never render as a single wall.
|
||||||
|
const INITIAL_CHARS = 8000;
|
||||||
|
const MORE_CHARS = 12000;
|
||||||
|
|
||||||
|
/** A section's display label — real heading, else a mechanical "Part N". */
|
||||||
|
function sectionLabel(s: OutlineSection, index: number): string {
|
||||||
|
return s.heading?.trim() || `Part ${index + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- ObjectList -----------------------------------------------------------
|
// ---- ObjectList -----------------------------------------------------------
|
||||||
|
|
||||||
export interface ObjectListProps {
|
export interface ObjectListProps {
|
||||||
@@ -149,11 +160,20 @@ export const CatalogCard: FC<{ outline: ObjectOutline; className?: string }> = (
|
|||||||
|
|
||||||
// ---- ProvenancePanel ------------------------------------------------------
|
// ---- ProvenancePanel ------------------------------------------------------
|
||||||
|
|
||||||
export const ProvenancePanel: FC<{ outline: ObjectOutline; className?: string }> = ({ outline, className }) => {
|
export const ProvenancePanel: FC<{
|
||||||
|
outline: ObjectOutline;
|
||||||
|
/** Turn a principal ref into a human label ("You", an agent's name, …). The
|
||||||
|
* lib can't know who "you" is, so the app supplies this; falls back to the
|
||||||
|
* raw type·id when it returns undefined. */
|
||||||
|
resolvePrincipal?: (p: string | PrincipalRef) => string | undefined;
|
||||||
|
className?: string;
|
||||||
|
}> = ({ outline, resolvePrincipal, className }) => {
|
||||||
const p = outline.provenance;
|
const p = outline.provenance;
|
||||||
|
const addedBy =
|
||||||
|
(p.created_by != null ? resolvePrincipal?.(p.created_by) : undefined) ?? formatPrincipal(p.created_by) ?? "—";
|
||||||
const rows: [string, ReactNode][] = [
|
const rows: [string, ReactNode][] = [
|
||||||
["Source", p.source_ref ? `${p.source_type} · ${p.source_ref}` : p.source_type],
|
["Source", p.source_ref ? `${p.source_type} · ${p.source_ref}` : p.source_type],
|
||||||
["Added by", p.created_by ?? "—"],
|
["Added by", addedBy],
|
||||||
["Added", formatDate(p.inserted_at)],
|
["Added", formatDate(p.inserted_at)],
|
||||||
["Effective", p.effective_at ? formatDate(p.effective_at) : "—"],
|
["Effective", p.effective_at ? formatDate(p.effective_at) : "—"],
|
||||||
["Last verified", p.verified_at ? formatDate(p.verified_at) : "Not confirmed"],
|
["Last verified", p.verified_at ? formatDate(p.verified_at) : "Not confirmed"],
|
||||||
@@ -187,7 +207,7 @@ export const SupersessionBanner: FC<{
|
|||||||
<div className={cn("flex flex-col gap-2", className)}>
|
<div className={cn("flex flex-col gap-2", className)}>
|
||||||
{outline.superseded_by && (
|
{outline.superseded_by && (
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-[var(--warning)]/40 bg-[color-mix(in_oklab,var(--warning)_10%,transparent)] px-3 py-2 text-sm">
|
<div className="flex items-center gap-2 rounded-lg border border-[var(--warning)]/40 bg-[color-mix(in_oklab,var(--warning)_10%,transparent)] px-3 py-2 text-sm">
|
||||||
<span className="text-[var(--warning)]">A newer version of this document exists.</span>
|
<span className="text-[color-mix(in_oklab,var(--warning),var(--foreground)_34%)]">A newer version of this document exists.</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-action="knowledge-open-newer"
|
data-action="knowledge-open-newer"
|
||||||
@@ -221,20 +241,21 @@ export const OutlineNav: FC<{
|
|||||||
}> = ({ sections, activeId, onSelect, className }) => {
|
}> = ({ sections, activeId, onSelect, className }) => {
|
||||||
if (sections.length === 0) return null;
|
if (sections.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<nav className={cn("flex flex-col gap-0.5", className)} aria-label="Document outline">
|
<nav className={cn("flex flex-col gap-0.5", className)} aria-label="Document sections">
|
||||||
{sections.map((s) => (
|
{sections.map((s, i) => (
|
||||||
<button
|
<button
|
||||||
key={s.id}
|
key={s.id}
|
||||||
type="button"
|
type="button"
|
||||||
data-action="knowledge-outline-section"
|
data-action="knowledge-outline-section"
|
||||||
onClick={() => onSelect?.(s)}
|
onClick={() => onSelect?.(s)}
|
||||||
|
aria-current={activeId === s.id ? "true" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"truncate rounded-md px-2 py-1 text-left text-sm transition hover:bg-muted",
|
"truncate rounded-md px-2 py-1 text-left text-sm transition hover:bg-muted",
|
||||||
s.level > 1 && "pl-4 text-xs",
|
s.level > 1 && "pl-4 text-xs",
|
||||||
activeId === s.id ? "bg-muted font-medium text-foreground" : "text-muted-foreground",
|
activeId === s.id ? "bg-muted font-medium text-foreground" : "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{s.heading}
|
{sectionLabel(s, i)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -281,17 +302,21 @@ export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight,
|
|||||||
[readText],
|
[readText],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initial + reactive loads: an explicit outline selection wins (so clicking a
|
// Initial + reactive loads, always bounded to a window so a long document
|
||||||
// section works even on a citation-opened view), then the highlight span,
|
// never renders as one wall. An explicit section selection wins (jump to its
|
||||||
// else the head of the document.
|
// start), then a citation highlight span, else the head of the document.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSection) {
|
if (activeSection) {
|
||||||
void load({ section: activeSection });
|
const sec = outline.outline.find((s) => s.id === activeSection);
|
||||||
|
void load(
|
||||||
|
sec
|
||||||
|
? { start: sec.start, end: Math.min(sec.end, sec.start + INITIAL_CHARS) }
|
||||||
|
: { section: activeSection },
|
||||||
|
);
|
||||||
} else if (highlight) {
|
} else if (highlight) {
|
||||||
const sec = outline.outline.find((s) => highlight.start >= s.start && highlight.end <= s.end);
|
void load({ start: Math.max(0, highlight.start - 400), end: highlight.end + 1200 });
|
||||||
void load(sec ? { section: sec.id } : { start: Math.max(0, highlight.start - 200), end: highlight.end + 200 });
|
|
||||||
} else {
|
} else {
|
||||||
void load();
|
void load({ start: 0, end: INITIAL_CHARS });
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [highlight?.start, highlight?.end, activeSection, outline.object_id]);
|
}, [highlight?.start, highlight?.end, activeSection, outline.object_id]);
|
||||||
@@ -304,7 +329,7 @@ export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight,
|
|||||||
if (loadingMore || !slice?.range) return;
|
if (loadingMore || !slice?.range) return;
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
try {
|
try {
|
||||||
const next = await readText({ start: slice.range.end });
|
const next = await readText({ start: slice.range.end, end: slice.range.end + MORE_CHARS });
|
||||||
setSlice((prev) =>
|
setSlice((prev) =>
|
||||||
prev && prev.range && next.range
|
prev && prev.range && next.range
|
||||||
? { ...next, text: (prev.text ?? "") + (next.text ?? ""), range: { start: prev.range.start, end: next.range.end } }
|
? { ...next, text: (prev.text ?? "") + (next.text ?? ""), range: { start: prev.range.start, end: next.range.end } }
|
||||||
@@ -345,8 +370,15 @@ export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<article className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground">
|
<article className="max-w-[68ch] break-words text-sm leading-relaxed text-foreground">
|
||||||
{renderWithHighlight(slice, highlight, markRef)}
|
{highlight ? (
|
||||||
|
// Citation landing: exact character offsets must be preserved so the
|
||||||
|
// mark lands on the cited span, so render the raw slice verbatim.
|
||||||
|
<div className="whitespace-pre-wrap break-words">{renderWithHighlight(slice, highlight, markRef)}</div>
|
||||||
|
) : (
|
||||||
|
// Reading: reflow the soft-wrapped source into real paragraphs.
|
||||||
|
renderReadable(slice.text ?? "")
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
|
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
|
||||||
{canReadMore && (
|
{canReadMore && (
|
||||||
@@ -365,6 +397,53 @@ export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight,
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Reflow extracted text (markdown / PDF dumps) into readable blocks. The source
|
||||||
|
// hard-wraps prose with single newlines and separates paragraphs with blank
|
||||||
|
// lines; rendered verbatim that's a ragged one-line-per-line wall. Here: drop
|
||||||
|
// page-break form-feeds, split on blank lines, join soft-wrapped lines within a
|
||||||
|
// block, and lift markdown headings / bullets. Offset-independent, so it's only
|
||||||
|
// used off the citation path (which needs verbatim offsets).
|
||||||
|
const HEADING_RE = /^(#{1,6})\s+(.*)$/;
|
||||||
|
const BULLET_RE = /^\s*([-*•]|\d+[.)])\s+/;
|
||||||
|
|
||||||
|
function renderReadable(raw: string): ReactNode {
|
||||||
|
const text = raw.replace(/\r\n?/g, "\n").replace(/\f/g, "\n\n");
|
||||||
|
const blocks = text.split(/\n[ \t]*\n+/);
|
||||||
|
const out: ReactNode[] = [];
|
||||||
|
blocks.forEach((block, bi) => {
|
||||||
|
const lines = block.split("\n").map((l) => l.trimEnd()).filter((l) => l.trim() !== "");
|
||||||
|
if (lines.length === 0) return;
|
||||||
|
|
||||||
|
const h = lines.length === 1 ? HEADING_RE.exec(lines[0]) : null;
|
||||||
|
if (h) {
|
||||||
|
const level = h[1].length;
|
||||||
|
out.push(
|
||||||
|
<p key={bi} className={cn("mb-1 mt-4 font-semibold text-foreground first:mt-0", level <= 2 ? "text-base" : "text-sm")}>
|
||||||
|
{h[2]}
|
||||||
|
</p>,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real list needs 2+ marker lines; a lone "1. Introduction" is a heading
|
||||||
|
// or TOC line, not a one-item bullet.
|
||||||
|
if (lines.length >= 2 && lines.every((l) => BULLET_RE.test(l))) {
|
||||||
|
out.push(
|
||||||
|
<ul key={bi} className="my-2 list-disc space-y-1 pl-5">
|
||||||
|
{lines.map((l, li) => (
|
||||||
|
<li key={li}>{l.replace(BULLET_RE, "")}</li>
|
||||||
|
))}
|
||||||
|
</ul>,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const para = lines.join(" ").replace(/[ \t]+/g, " ").trim();
|
||||||
|
if (para) out.push(<p key={bi} className="my-2 first:mt-0">{para}</p>);
|
||||||
|
});
|
||||||
|
return out.length > 0 ? out : raw;
|
||||||
|
}
|
||||||
|
|
||||||
function renderWithHighlight(
|
function renderWithHighlight(
|
||||||
slice: TextSlice,
|
slice: TextSlice,
|
||||||
highlight: { start: number; end: number } | undefined,
|
highlight: { start: number; end: number } | undefined,
|
||||||
@@ -429,6 +508,8 @@ export interface ObjectViewerProps {
|
|||||||
readText: TextReaderProps["readText"];
|
readText: TextReaderProps["readText"];
|
||||||
/** Resolve a browser-openable blob URL (for images + download). */
|
/** Resolve a browser-openable blob URL (for images + download). */
|
||||||
resolveBlobUrl?: () => Promise<string>;
|
resolveBlobUrl?: () => Promise<string>;
|
||||||
|
/** Humanise the "Added by" principal (see ProvenancePanel). */
|
||||||
|
resolvePrincipal?: (p: string | PrincipalRef) => string | undefined;
|
||||||
highlight?: { start: number; end: number };
|
highlight?: { start: number; end: number };
|
||||||
onOpenObject?: (objectId: string, span?: { start: number; end: number }) => void;
|
onOpenObject?: (objectId: string, span?: { start: number; end: number }) => void;
|
||||||
actions?: ObjectActions;
|
actions?: ObjectActions;
|
||||||
@@ -439,6 +520,7 @@ export const ObjectViewer: FC<ObjectViewerProps> = ({
|
|||||||
outline,
|
outline,
|
||||||
readText,
|
readText,
|
||||||
resolveBlobUrl,
|
resolveBlobUrl,
|
||||||
|
resolvePrincipal,
|
||||||
highlight,
|
highlight,
|
||||||
onOpenObject,
|
onOpenObject,
|
||||||
actions,
|
actions,
|
||||||
@@ -514,7 +596,9 @@ export const ObjectViewer: FC<ObjectViewerProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-5 lg:grid-cols-[1fr_260px]">
|
<div className="grid gap-5 lg:grid-cols-[1fr_260px]">
|
||||||
<div className="min-w-0 rounded-xl border border-border bg-card p-4">
|
{/* Opaque reading surface: long-form text sits calm, not over the theme's
|
||||||
|
background wash. The side panels stay glass. */}
|
||||||
|
<div className="min-w-0 rounded-xl border border-border bg-background p-4 sm:p-6">
|
||||||
{outline.extracted_chars && outline.extracted_chars > 0 ? (
|
{outline.extracted_chars && outline.extracted_chars > 0 ? (
|
||||||
<TextReader outline={outline} readText={readText} highlight={highlight} activeSection={activeSection} />
|
<TextReader outline={outline} readText={readText} highlight={highlight} activeSection={activeSection} />
|
||||||
) : (
|
) : (
|
||||||
@@ -526,11 +610,13 @@ export const ObjectViewer: FC<ObjectViewerProps> = ({
|
|||||||
<aside className="flex flex-col gap-4">
|
<aside className="flex flex-col gap-4">
|
||||||
{outline.outline.length > 0 && (
|
{outline.outline.length > 0 && (
|
||||||
<div className="rounded-xl border border-border bg-card p-3">
|
<div className="rounded-xl border border-border bg-card p-3">
|
||||||
<h3 className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Outline</h3>
|
<h3 className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{outline.outline.some((s) => s.heading?.trim()) ? "Outline" : "Jump to"}
|
||||||
|
</h3>
|
||||||
<OutlineNav sections={outline.outline} activeId={activeSection} onSelect={(s) => setActiveSection(s.id)} />
|
<OutlineNav sections={outline.outline} activeId={activeSection} onSelect={(s) => setActiveSection(s.id)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ProvenancePanel outline={outline} />
|
<ProvenancePanel outline={outline} resolvePrincipal={resolvePrincipal} />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
// EXPORTS
|
// EXPORTS
|
||||||
// Types + transport: all of ./types, KnowledgeTransport, MockKnowledgeTransport
|
// Types + transport: all of ./types, KnowledgeTransport, MockKnowledgeTransport
|
||||||
// Badges/formatters: SensitivityBadge, CurationBadge, StatusBadge, TierBadge,
|
// Badges/formatters: SensitivityBadge, CurationBadge, StatusBadge, TierBadge,
|
||||||
// Badge, Spinner, cn, formatBytes, formatDate, formatRelative,
|
// Badge, Spinner, cn, formatBytes, formatDate, formatPrincipal,
|
||||||
// renderValue, isImageMime
|
// formatRelative, renderValue, isImageMime
|
||||||
// Collections (W3): CollectionList, CollectionCard, CollectionForm
|
// Collections (W3): CollectionList, CollectionCard, CollectionForm
|
||||||
// Object viewer (W4):ObjectList, ObjectViewer, CatalogCard, ProvenancePanel,
|
// Object viewer (W4):ObjectList, ObjectViewer, CatalogCard, ProvenancePanel,
|
||||||
// SupersessionBanner, OutlineNav, TextReader, CitationLink
|
// SupersessionBanner, OutlineNav, TextReader, CitationLink
|
||||||
@@ -27,6 +27,7 @@ export {
|
|||||||
TierBadge,
|
TierBadge,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatDate,
|
formatDate,
|
||||||
|
formatPrincipal,
|
||||||
formatRelative,
|
formatRelative,
|
||||||
renderValue,
|
renderValue,
|
||||||
isImageMime,
|
isImageMime,
|
||||||
|
|||||||
12
src/types.ts
12
src/types.ts
@@ -94,10 +94,16 @@ export interface Entity {
|
|||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A principal reference as the service emits it: `{type: "account"|"app"|"agent"|…, id}`. */
|
||||||
|
export interface PrincipalRef {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Provenance {
|
export interface Provenance {
|
||||||
source_type: string;
|
source_type: string;
|
||||||
source_ref?: string | null;
|
source_ref?: string | null;
|
||||||
created_by?: string | null;
|
created_by?: string | PrincipalRef | null;
|
||||||
inserted_at?: string;
|
inserted_at?: string;
|
||||||
effective_at?: string | null;
|
effective_at?: string | null;
|
||||||
verified_at?: string | null;
|
verified_at?: string | null;
|
||||||
@@ -210,8 +216,8 @@ export interface Claim {
|
|||||||
sensitivity: Sensitivity;
|
sensitivity: Sensitivity;
|
||||||
effective_at?: string | null;
|
effective_at?: string | null;
|
||||||
verified_at?: string | null;
|
verified_at?: string | null;
|
||||||
verified_by?: string | null;
|
verified_by?: string | PrincipalRef | null;
|
||||||
created_by?: string | null;
|
created_by?: string | PrincipalRef | null;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
cite?: string;
|
cite?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user