8 Commits

Author SHA1 Message Date
jules
5d149e51dd 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>
2026-07-11 09:37:25 +10:00
jules
e7376001e7 fix(kb-ui): opaque reading surface for the object viewer
Long-form text sat on the theme's translucent glass card, so a busy background
wash read through behind the prose. Give the reader column an opaque surface
(the side outline/provenance panels stay glass). Verified live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:17:19 +10:00
jules
7997dbb3f3 feat(kb-ui): humanise provenance "Added by" via an injected resolver
ProvenancePanel/ObjectViewer gain an optional resolvePrincipal(p) prop so the
consumer can turn a {type,id} principal ref into a human label — the lib can't
know who "you" is. Falls back to the raw type·id when the resolver returns
undefined, so existing callers are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:03:35 +10:00
jules
efc2c18a91 fix(kb-ui): make the object reader readable — reflow, window, jump-to
The reader dumped the whole server slice verbatim: a ~445k-char doc rendered
as a 16,558px wall of one-source-line-per-line ragged text, and the outline
panel showed 8 blank rows (the API's sections carry heading:null — they're
mechanical 60k-char chunks, not headings).

- Reflow: off the citation path, parse the extracted markdown/PDF text into
  real blocks — drop form-feeds, split on blank lines, join soft-wrapped
  lines into paragraphs, lift markdown headings + multi-line bullet lists.
  Cap prose width at 68ch. The citation-highlight path still renders the raw
  slice verbatim so character offsets stay exact.
- Window every load: initial 8k chars, "Read more" +12k, section jump loads a
  bounded window from the section's start (server honours start/end precisely).
  Object height 16,558 → ~5,315px; Read more/citations verified.
- Outline: blank heading:null rows now render as "Part N" jump points and the
  panel is titled "Jump to" (vs "Outline") when there are no real headings;
  aria-current on the active row.

Verified live on dev-knowledge.sky-ai.com.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 08:51:50 +10:00
jules
ac7c294753 fix(kb-ui): P0 provenance crash + AA badges + a11y + first-run polish
Provenance/claims carried created_by/verified_by as {type,id} principal
refs from the service, but the types said string|null and ProvenancePanel
rendered them raw → React #31 crash on every object view. Widen to
string|PrincipalRef|null and add formatPrincipal() (barrel-exported).

Badges: mode-aware semantic tones (mix toward --foreground) so success/
warning/info/danger text clears WCAG AA on the subtle tint in BOTH light
and dark; Active badge 2.81→5.25/5.67:1. SensitivityBadge renders nothing
for an unset level (objects inherit from their collection) instead of a
misleading "—" pill. TierBadge tolerates an unknown tier.

Accessibility: Badge gains ariaLabel (screen readers announce the meaning,
not just the terse label); Field links label↔control via htmlFor + useId
and wires aria-describedby to the hint; OwnerToggle gets aria-pressed;
icons aria-hidden.

First-run: CollectionForm leads with Name and auto-derives the Identifier
(was slug-first); placeholder text uses --muted-foreground. Drop the
Sparkles "AI-magic" icons from the review empty state and the agent tier
badge (Inbox / Bot instead), per the no-magic-framing principle.

Verified live on dev-knowledge.sky-ai.com.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 08:40:24 +10:00
jules
2c921a911c feat(kb-ui): MemoryReviewQueue — free-text note curation (spec §9)
The curation base for a per-person gated `memory` collection of FREE-TEXT notes
(kind: note), distinct from ClaimsReviewQueue's subject·predicate·value triples.
Groups proposed (specialist-noted, awaiting confirmation) vs remembered (active),
with confirm/forget callbacks, source attribution, and tags — matching the KB UI
house style. Collection-agnostic (props in, callbacks out): the same base powers
the KB `memory` collection and a consumer's /memory page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:10:18 +10:00
jules
03443590a2 fix(kb-ui): SensitivityBadge tolerates an unset/unknown level
A kb.list row can arrive without a sensitivity (a claim/note, or older data);
SENSITIVITY[level] was then undefined and reading .tone crashed the whole
object list. Fall back to a neutral badge — never defaulting an unknown value
to "Open", which would misstate exposure. (Surfaced building the standalone
webapp against a real corpus.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 13:38:22 +10:00
jules
c3dc237aa4 feat(kb-ui): lift search + collection load-more into the lib
Both consumers (embedded + the coming standalone) need the same search UI and
paginated lists; keep them from diverging by owning both here.

- KnowledgeSearch (components-search.tsx): the search input + paginated results
  panel, over an injected onSearch. Owns query/results/loading/error/clear and a
  "Load more" for search results; renders the caller's `browse` content when
  there is no active search. Replaces each app hand-rolling hit rendering.
- CollectionList: onLoadMore/hasMore/loadingMore (mirrors ObjectList), so a
  paginated corpus list gets a load-more affordance.

Stacked on fix/kb-ui-p0 (ObjectList already gained load-more there). tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:05:07 +10:00
8 changed files with 907 additions and 103 deletions

View File

@@ -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,22 +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: objects inherit it from their
// collection, and claims/notes carry none. Render NOTHING in that case — a
// "—" pill reads as broken data. Never default an unknown value to "Open",
// which would misstate its exposure.
const s = SENSITIVITY[level]; const s = SENSITIVITY[level];
if (!s) return null;
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>
); );
@@ -127,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>
@@ -139,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 }) => {
@@ -165,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.",
}, },
@@ -179,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>
); );

View File

@@ -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,13 +20,45 @@ 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
className={cn(
"rounded-xl border bg-card p-4 transition",
selected ? "border-primary/50 ring-1 ring-primary/30" : "border-border",
className,
)}
>
<div className="flex items-start gap-3">
{selectable && (
<input
type="checkbox"
checked={!!selected}
onChange={() => onToggleSelect?.(claim.claim_id)}
aria-label={`Select claim: ${claim.subject} ${claim.predicate}`}
className="mt-1 size-4 shrink-0 accent-[var(--primary)]"
/>
)}
<div className="min-w-0 flex-1">
<ClaimHead claim={claim} onOpenSource={onOpenSource} /> <ClaimHead claim={claim} onOpenSource={onOpenSource} />
<div className="mt-3 flex flex-wrap items-center gap-2"> <div className="mt-3 flex flex-wrap items-center gap-2">
{rejected ? ( {rejected ? (
@@ -54,6 +86,8 @@ export const ClaimCard: FC<ClaimCardProps> = ({ claim, busy, className, onConfir
)} )}
</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>

View File

@@ -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>
)} )}
@@ -70,6 +78,10 @@ export interface CollectionListProps {
onCreate?: (ownerType: "account" | "tenant") => void; onCreate?: (ownerType: "account" | "tenant") => void;
canManageTenant?: boolean; canManageTenant?: boolean;
onOpen?: (collection: Collection) => void; onOpen?: (collection: Collection) => void;
/** When set, renders a "Load more" affordance (the list is paginated). */
onLoadMore?: () => void;
hasMore?: boolean;
loadingMore?: boolean;
emptyState?: ReactNode; emptyState?: ReactNode;
className?: string; className?: string;
} }
@@ -81,6 +93,9 @@ export const CollectionList: FC<CollectionListProps> = ({
onCreate, onCreate,
canManageTenant = false, canManageTenant = false,
onOpen, onOpen,
onLoadMore,
hasMore,
loadingMore,
emptyState, emptyState,
className, className,
}) => { }) => {
@@ -112,6 +127,18 @@ export const CollectionList: FC<CollectionListProps> = ({
emptyHint={org.length === 0 ? "No shared corpuses yet." : undefined} emptyHint={org.length === 0 ? "No shared corpuses yet." : undefined}
/> />
)} )}
{hasMore && onLoadMore && (
<button
type="button"
data-action="knowledge-load-more-collections"
onClick={onLoadMore}
disabled={loadingMore}
className="inline-flex items-center justify-center gap-1.5 self-center rounded-lg border border-border px-3 py-1.5 text-sm text-muted-foreground transition hover:bg-muted disabled:opacity-50"
>
{loadingMore && <Loader2 className="size-3.5 animate-spin" />}
{loadingMore ? "Loading…" : "Load more"}
</button>
)}
</div> </div>
); );
}; };
@@ -192,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");
@@ -243,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")}
@@ -326,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 }) => {
const id = useId();
const hintId = `${id}-hint`;
// Link the label (and any hint) to the single control child for screen readers.
const control =
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"> <div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-foreground">{label}</label> <label htmlFor={id} className="text-sm font-medium text-foreground">
{children} {label}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>} </label>
{control}
{hint && (
<p id={hintId} className="text-xs text-muted-foreground">
{hint}
</p>
)}
</div> </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",

243
src/components-memory.tsx Normal file
View File

@@ -0,0 +1,243 @@
// PURPOSE: Memory review queue — the curation surface for a per-person, gated
// `memory` collection of FREE-TEXT notes (kind: note), distinct from the
// claim triples of ClaimsReviewQueue. The owner reviews what the agent
// and its delegated specialists remembered: confirms a specialist's
// `proposed` note (trust-is-human) or forgets anything wrong. Collection-
// agnostic — props in, callbacks out; the same base powers the KB
// `memory` collection and a consumer's /memory page.
// ===========================================================================
import { type FC, type ReactNode } from "react";
import { BrainCircuit, Check, Sparkles, Trash2, User } from "lucide-react";
import { Badge, cn, formatRelative } from "./_internal";
export type MemoryStatus = "active" | "proposed";
export interface MemoryItem {
id: string;
/** The remembered statement (free text). */
content: string;
/** Short head shown above the content; optional. */
subject?: string | null;
/** fact | preference | event | note — a free label, surfaced as a tag. */
kind?: string | null;
tags?: string[];
status: MemoryStatus;
/** "user" | "assistant" | "app:<slug>" — attribution, rendered via `sourceLabel`. */
source?: string | null;
updated_at?: string | null;
}
export interface MemoryActions {
/** Confirm a proposed memory (→ active). */
onConfirm?: (id: string) => void;
/** Forget a memory (archive / soft-delete). */
onForget?: (id: string) => void;
}
/** Default attribution copy; override via `MemoryReviewQueueProps.sourceLabel`. */
export function defaultSourceLabel(source?: string | null): string {
if (source === "user") return "You added this";
if (source === "assistant") return "Your assistant noted this";
if (source && source.startsWith("app:")) {
const slug = source.slice(4);
return `Suggested by ${slug.charAt(0).toUpperCase()}${slug.slice(1)}`;
}
return "Remembered";
}
export interface MemoryCardProps extends MemoryActions {
item: MemoryItem;
busy?: boolean;
sourceLabel?: (source?: string | null) => string;
className?: string;
}
export const MemoryCard: FC<MemoryCardProps> = ({
item,
busy,
sourceLabel = defaultSourceLabel,
className,
onConfirm,
onForget,
}) => {
const proposed = item.status === "proposed";
return (
<div
className={cn(
"rounded-xl border bg-card p-4",
proposed ? "border-primary/40" : "border-border",
className,
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
{item.subject && (
<p className="text-xs font-medium text-muted-foreground">{item.subject}</p>
)}
<p className="mt-0.5 text-sm text-foreground">{item.content}</p>
</div>
{proposed && (
<Badge tone="bg-primary/10 text-primary" icon={<Sparkles className="size-3" />}>
Proposed
</Badge>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<Badge tone="bg-muted text-muted-foreground" icon={sourceIcon(item.source)}>
{sourceLabel(item.source)}
</Badge>
{item.kind && item.kind !== "note" && (
<span className="text-xs text-muted-foreground">{item.kind}</span>
)}
{(item.tags ?? []).map((t) => (
<span key={t} className="rounded-md bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{t}
</span>
))}
{item.updated_at && (
<span className="text-xs text-muted-foreground">{formatRelative(item.updated_at)}</span>
)}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{proposed && onConfirm && (
<ActionBtn
action="memory-confirm"
primary
onClick={() => onConfirm(item.id)}
busy={busy}
icon={<Check className="size-3.5" />}
>
Confirm
</ActionBtn>
)}
{onForget && (
<ActionBtn
action="memory-forget"
onClick={() => onForget(item.id)}
busy={busy}
icon={<Trash2 className="size-3.5" />}
>
Forget
</ActionBtn>
)}
</div>
</div>
);
};
export interface MemoryReviewQueueProps extends MemoryActions {
items: MemoryItem[];
busyId?: string;
sourceLabel?: (source?: string | null) => string;
emptyState?: ReactNode;
className?: string;
}
/** Groups: Proposed (awaiting confirmation) → Remembered (active). */
export const MemoryReviewQueue: FC<MemoryReviewQueueProps> = ({
items,
busyId,
sourceLabel,
emptyState,
className,
onConfirm,
onForget,
}) => {
if (items.length === 0) return <>{emptyState ?? <Empty />}</>;
const proposed = items.filter((m) => m.status === "proposed");
const active = items.filter((m) => m.status !== "proposed");
return (
<div className={cn("flex flex-col gap-6", className)}>
<Section title="Proposed" count={proposed.length} hint="A specialist noted these — confirm or forget.">
{proposed.map((m) => (
<MemoryCard
key={m.id}
item={m}
busy={busyId === m.id}
sourceLabel={sourceLabel}
onConfirm={onConfirm}
onForget={onForget}
/>
))}
</Section>
<Section title="Remembered" count={active.length} hint="What your assistant knows about you.">
{active.map((m) => (
<MemoryCard
key={m.id}
item={m}
busy={busyId === m.id}
sourceLabel={sourceLabel}
onForget={onForget}
/>
))}
</Section>
</div>
);
};
// ---- local helpers --------------------------------------------------------
function sourceIcon(source?: string | null): ReactNode {
if (source === "user") return <User className="size-3" />;
if (source && source.startsWith("app:")) return <Sparkles className="size-3" />;
return <BrainCircuit className="size-3" />;
}
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">
<BrainCircuit className="size-6 text-muted-foreground" />
<p className="text-sm font-medium text-foreground">Nothing remembered yet</p>
<p className="text-xs text-muted-foreground">
Things your assistant learns about you will show up here.
</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>
);

View File

@@ -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>

151
src/components-search.tsx Normal file
View File

@@ -0,0 +1,151 @@
// PURPOSE: Corpus search (spec §5) — the input + results panel, lifted out of
// the app so every consumer (embedded + standalone) renders search the
// same way instead of hand-rolling hit rendering. Owns query state,
// paginated results, loading/error/clear; shows the caller's `browse`
// content when there is no active search. Pure: the actual search runs
// through an injected `onSearch` callback.
// ===========================================================================
import { useState, type FC, type FormEvent, type ReactNode } from "react";
import { Loader2, Search, X } from "lucide-react";
import type { ObjectSummary, SearchHit, SearchPage } from "./types";
import { ObjectList } from "./components-object";
function hitToSummary(h: SearchHit): ObjectSummary {
return {
object_id: h.object_id,
kind: h.kind,
title: h.title,
status: "active",
updated_at: h.updated_at ?? "",
effective_at: h.effective_at ?? null,
sensitivity: h.sensitivity,
};
}
export interface KnowledgeSearchProps {
/** Run a search and return a page; `cursor` drives "Load more". */
onSearch: (query: string, cursor?: string) => Promise<SearchPage>;
onOpen?: (objectId: string) => void;
placeholder?: string;
/** Shown below the input while there is no active search (the browse list). */
browse?: ReactNode;
className?: string;
}
export const KnowledgeSearch: FC<KnowledgeSearchProps> = ({
onSearch,
onOpen,
placeholder,
browse,
className,
}) => {
const [query, setQuery] = useState("");
const [hits, setHits] = useState<SearchHit[] | null>(null);
const [cursor, setCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
function clear() {
setHits(null);
setCursor(null);
setQuery("");
setError(null);
}
async function run(e?: FormEvent) {
e?.preventDefault();
const q = query.trim();
if (!q) {
clear();
return;
}
setLoading(true);
setError(null);
try {
const page = await onSearch(q);
setHits(page.hits);
setCursor(page.next_cursor ?? null);
} catch {
setError("Search failed. Try again.");
} finally {
setLoading(false);
}
}
async function loadMore() {
if (!cursor || loadingMore) return;
setLoadingMore(true);
try {
const page = await onSearch(query.trim(), cursor);
setHits((prev) => [...(prev ?? []), ...page.hits]);
setCursor(page.next_cursor ?? null);
} catch {
setError("Couldn't load more results. Try again.");
} finally {
setLoadingMore(false);
}
}
return (
<div className={className}>
<form onSubmit={run} className="mb-4 flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
data-action="knowledge-search"
className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-9 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/30"
placeholder={placeholder ?? "Search…"}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{query && (
<button
type="button"
onClick={clear}
aria-label="Clear search"
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground transition-colors hover:text-foreground"
>
<X className="size-4" />
</button>
)}
</div>
</form>
{loading ? (
<p className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" /> Searching
</p>
) : error ? (
<p className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{error}
</p>
) : hits ? (
<div>
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
<span>
{hits.length} result{hits.length === 1 ? "" : "s"}
</span>
<button type="button" className="transition-colors hover:text-foreground" onClick={clear}>
Clear
</button>
</div>
<ObjectList
objects={hits.map(hitToSummary)}
onOpen={(o) => onOpen?.(o.object_id)}
onLoadMore={loadMore}
hasMore={!!cursor}
loadingMore={loadingMore}
emptyState={
<div className="rounded-xl border border-dashed border-border py-12 text-center text-sm text-muted-foreground">
No matches.
</div>
}
/>
</div>
) : (
(browse ?? null)
)}
</div>
);
};

View File

@@ -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,
@@ -34,4 +35,6 @@ export {
export * from "./components-collections"; export * from "./components-collections";
export * from "./components-object"; export * from "./components-object";
export * from "./components-claims"; export * from "./components-claims";
export * from "./components-memory";
export * from "./components-export"; export * from "./components-export";
export * from "./components-search";

View File

@@ -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;
} }