W4: object viewer

components-object: ObjectList, ObjectViewer (CatalogCard + ProvenancePanel +
SupersessionBanner + OutlineNav + TextReader with citation highlight/scroll +
load-more, image render, vault notice, action bar), CitationLink + parseCite.
Demo browses collections → objects → viewer. Typechecks clean.

Co-Authored-By: Claude Fable 5 (build) <noreply@anthropic.com>
This commit is contained in:
jules
2026-07-07 10:01:33 +10:00
parent d2829d6348
commit 9dd162d815
2 changed files with 537 additions and 25 deletions

View File

@@ -6,18 +6,30 @@ import { useEffect, useState } from "react";
import { import {
CollectionForm, CollectionForm,
CollectionList, CollectionList,
ObjectList,
ObjectViewer,
MockKnowledgeTransport, MockKnowledgeTransport,
type Collection, type Collection,
type CollectionInput, type CollectionInput,
type CollectionPatch, type CollectionPatch,
type KnowledgeTransport, type KnowledgeTransport,
type ObjectOutline,
type ObjectSummary,
} from "../src/index"; } from "../src/index";
const transport: KnowledgeTransport = new MockKnowledgeTransport(); const transport: KnowledgeTransport = new MockKnowledgeTransport();
type View =
| { kind: "collections" }
| { kind: "collection"; slug: string }
| { kind: "object"; id: string };
export default function KnowledgeDemo() { export default function KnowledgeDemo() {
const [view, setView] = useState<View>({ kind: "collections" });
const [collections, setCollections] = useState<Collection[]>([]); const [collections, setCollections] = useState<Collection[]>([]);
const [pending, setPending] = useState<Record<string, number>>({}); const [pending, setPending] = useState<Record<string, number>>({});
const [objects, setObjects] = useState<ObjectSummary[]>([]);
const [outline, setOutline] = useState<ObjectOutline | null>(null);
const [creating, setCreating] = useState<null | "account" | "tenant">(null); const [creating, setCreating] = useState<null | "account" | "tenant">(null);
const [editing, setEditing] = useState<Collection | null>(null); const [editing, setEditing] = useState<Collection | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -35,6 +47,11 @@ export default function KnowledgeDemo() {
void refresh(); void refresh();
}, []); }, []);
useEffect(() => {
if (view.kind === "collection") void transport.listObjects(view.slug).then((p) => setObjects(p.objects));
if (view.kind === "object") void transport.outline(view.id).then(setOutline);
}, [view]);
async function onCreate(value: CollectionInput | CollectionPatch) { async function onCreate(value: CollectionInput | CollectionPatch) {
setBusy(true); setBusy(true);
await transport.createCollection(value as CollectionInput); await transport.createCollection(value as CollectionInput);
@@ -42,7 +59,6 @@ export default function KnowledgeDemo() {
setCreating(null); setCreating(null);
void refresh(); void refresh();
} }
async function onEdit(value: CollectionInput | CollectionPatch) { async function onEdit(value: CollectionInput | CollectionPatch) {
if (!editing) return; if (!editing) return;
setBusy(true); setBusy(true);
@@ -54,33 +70,61 @@ export default function KnowledgeDemo() {
return ( return (
<div className="mx-auto max-w-5xl p-6"> <div className="mx-auto max-w-5xl p-6">
<header className="mb-6"> <button
<h1 className="text-xl font-semibold text-foreground">Knowledge</h1> type="button"
<p className="text-sm text-muted-foreground">Your corpuses browse, organise, and review.</p> onClick={() => setView({ kind: "collections" })}
</header> className="mb-4 text-sm text-muted-foreground hover:text-foreground"
>
Knowledge{view.kind !== "collections" ? " / …" : ""}
</button>
{creating && ( {view.kind === "collections" && (
<div className="mb-6 rounded-xl border border-border bg-card p-4"> <>
<h2 className="mb-3 font-medium">New corpus</h2> {creating && (
<CollectionForm mode="create" ownerType={creating} orgName="Acme" canManageTenant busy={busy} onSubmit={onCreate} onCancel={() => setCreating(null)} /> <div className="mb-6 rounded-xl border border-border bg-card p-4">
</div> <h2 className="mb-3 font-medium">New corpus</h2>
<CollectionForm mode="create" ownerType={creating} orgName="Acme" canManageTenant busy={busy} onSubmit={onCreate} onCancel={() => setCreating(null)} />
</div>
)}
{editing && (
<div className="mb-6 rounded-xl border border-border bg-card p-4">
<h2 className="mb-3 font-medium">Corpus settings</h2>
<CollectionForm mode="edit" initial={editing} orgName="Acme" canManageTenant busy={busy} onSubmit={onEdit} onCancel={() => setEditing(null)} />
</div>
)}
<CollectionList
collections={collections}
pendingCounts={pending}
orgName="Acme"
canManageTenant
onCreate={(owner) => setCreating(owner)}
onOpen={(c) => setView({ kind: "collection", slug: c.slug })}
/>
</>
)} )}
{editing && ( {view.kind === "collection" && (
<div className="mb-6 rounded-xl border border-border bg-card p-4"> <>
<h2 className="mb-3 font-medium">Corpus settings</h2> <div className="mb-3 flex items-center justify-between">
<CollectionForm mode="edit" initial={editing} orgName="Acme" canManageTenant busy={busy} onSubmit={onEdit} onCancel={() => setEditing(null)} /> <h1 className="text-lg font-semibold">{view.slug}</h1>
</div> <button className="text-sm text-primary" onClick={() => setEditing(collections.find((c) => c.slug === view.slug) ?? null)}>Settings</button>
</div>
<ObjectList objects={objects} onOpen={(o) => setView({ kind: "object", id: o.object_id })} />
</>
)} )}
<CollectionList {view.kind === "object" && outline && (
collections={collections} <ObjectViewer
pendingCounts={pending} outline={outline}
orgName="Acme" readText={(sel) => transport.readText(outline.object_id, sel)}
canManageTenant resolveBlobUrl={() => transport.blobUrl(outline.object_id)}
onCreate={(owner) => setCreating(owner)} onOpenObject={(id) => setView({ kind: "object", id })}
onOpen={(c) => setEditing(c)} actions={{
/> onVerify: () => transport.updateObject(outline.object_id, { verified_at: new Date().toISOString() }),
onDownload: () => transport.blobUrl(outline.object_id).then((u) => window.open(u)),
}}
/>
)}
</div> </div>
); );
} }

View File

@@ -1,2 +1,470 @@
// Placeholder — filled in its workstream (W4/W5/W6). // PURPOSE: Object viewer (spec §3.4) — catalog card + provenance + supersession
export {}; // + outline-driven text reader with citation highlighting, image render,
// vault notice, and object actions. Plus ObjectList + CitationLink.
// Pure: text is loaded via an injected readText callback, not fetched.
// ===========================================================================
import { useCallback, useEffect, useRef, useState, type FC, type ReactNode, type RefObject } from "react";
import {
ArrowUpRight,
Check,
Download,
FileText,
Image as ImageIcon,
Loader2,
Lock,
Sparkles,
Tag,
Trash2,
} from "lucide-react";
import type { ObjectOutline, ObjectSummary, OutlineSection, TextSlice } from "./types";
import {
Badge,
SensitivityBadge,
StatusBadge,
cn,
formatBytes,
formatDate,
isImageMime,
} from "./_internal";
// ---- ObjectList -----------------------------------------------------------
export interface ObjectListProps {
objects: ObjectSummary[];
onOpen?: (object: ObjectSummary) => void;
emptyState?: ReactNode;
className?: string;
}
export const ObjectList: FC<ObjectListProps> = ({ objects, onOpen, emptyState, className }) => {
if (objects.length === 0 && emptyState) return <>{emptyState}</>;
return (
<ul className={cn("flex flex-col divide-y divide-border rounded-xl border border-border bg-card", className)}>
{objects.map((o) => (
<li key={o.object_id}>
<button
type="button"
data-action="knowledge-open-object"
data-object-id={o.object_id}
onClick={() => onOpen?.(o)}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition hover:bg-muted/50 focus:outline-none focus-visible:bg-muted/50"
>
<span className="text-muted-foreground">{o.kind === "claim" ? <Sparkles className="size-4" /> : <FileText className="size-4" />}</span>
<span className="min-w-0 flex-1">
<span className={cn("block truncate text-sm font-medium", o.status === "superseded" && "text-muted-foreground line-through")}>{o.title}</span>
<span className="text-xs text-muted-foreground">{o.effective_at ? `effective ${formatDate(o.effective_at)}` : `updated ${formatDate(o.updated_at)}`}</span>
</span>
<StatusBadge status={o.status} />
<SensitivityBadge level={o.sensitivity} />
</button>
</li>
))}
</ul>
);
};
// ---- CitationLink ---------------------------------------------------------
export function parseCite(cite: string): { objectId: string; span?: { start: number; end: number } } | null {
const m = /^kb:\/\/([^#]+)(?:#(\d+)-(\d+))?$/.exec(cite);
if (!m) return null;
return { objectId: m[1], span: m[2] ? { start: Number(m[2]), end: Number(m[3]) } : undefined };
}
/** Renders a `kb://<id>#s-e` handle as a click-through the app routes to the
* object viewer with ?start=&end=. `onOpen` receives (objectId, span). */
export interface CitationLinkProps {
cite: string;
label?: string;
onOpen?: (objectId: string, span?: { start: number; end: number }) => void;
className?: string;
}
export const CitationLink: FC<CitationLinkProps> = ({ cite, label, onOpen, className }) => {
const parsed = parseCite(cite);
return (
<button
type="button"
data-action="knowledge-citation"
onClick={() => parsed && onOpen?.(parsed.objectId, parsed.span)}
className={cn(
"inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground transition hover:text-foreground",
className,
)}
title={cite}
>
<span className="truncate">{label ?? cite}</span>
<ArrowUpRight className="size-3 shrink-0" />
</button>
);
};
// ---- CatalogCard ----------------------------------------------------------
export const CatalogCard: FC<{ outline: ObjectOutline; className?: string }> = ({ outline, className }) => (
<div className={cn("flex flex-col gap-3", className)}>
<div className="flex flex-wrap items-center gap-1.5">
<StatusBadge status={outline.status} />
<SensitivityBadge level={outline.sensitivity} />
{outline.language && <Badge tone="bg-muted text-muted-foreground">{outline.language.toUpperCase()}</Badge>}
{outline.mime && <Badge tone="bg-muted text-muted-foreground">{outline.mime}</Badge>}
{outline.byte_size != null && <span className="text-xs text-muted-foreground">{formatBytes(outline.byte_size)}</span>}
</div>
<h1 className="text-lg font-semibold text-foreground">{outline.title}</h1>
{outline.summary && <p className="text-sm leading-relaxed text-muted-foreground">{outline.summary}</p>}
{(outline.tags.length > 0 || outline.entities.length > 0) && (
<div className="flex flex-wrap gap-1.5">
{outline.tags.map((t) => (
<Badge key={t} tone="bg-muted text-muted-foreground" icon={<Tag className="size-3" />}>
{t}
</Badge>
))}
{outline.entities.map((e) => (
<Badge key={e.name} tone="bg-primary/10 text-primary" title={e.type}>
{e.name}
</Badge>
))}
</div>
)}
</div>
);
// ---- ProvenancePanel ------------------------------------------------------
export const ProvenancePanel: FC<{ outline: ObjectOutline; className?: string }> = ({ outline, className }) => {
const p = outline.provenance;
const rows: [string, ReactNode][] = [
["Source", p.source_ref ? `${p.source_type} · ${p.source_ref}` : p.source_type],
["Added by", p.created_by ?? "—"],
["Added", formatDate(p.inserted_at)],
["Effective", p.effective_at ? formatDate(p.effective_at) : "—"],
["Last verified", p.verified_at ? formatDate(p.verified_at) : "Not confirmed"],
["Card model", p.card_model ?? "— (mechanical)"],
["Extraction model", p.extraction_model ?? "— (native text)"],
];
return (
<div className={cn("rounded-xl border border-border bg-card p-4", className)}>
<h3 className="mb-3 text-sm font-semibold text-foreground">Provenance</h3>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
{rows.map(([k, v]) => (
<div key={k} className="contents">
<dt className="text-muted-foreground">{k}</dt>
<dd className="break-words text-foreground">{v}</dd>
</div>
))}
</dl>
</div>
);
};
// ---- SupersessionBanner ---------------------------------------------------
export const SupersessionBanner: FC<{
outline: ObjectOutline;
onOpen?: (objectId: string) => void;
className?: string;
}> = ({ outline, onOpen, className }) => {
if (!outline.superseded_by && !outline.supersedes) return null;
return (
<div className={cn("flex flex-col gap-2", className)}>
{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">
<span className="text-[var(--warning)]">A newer version of this document exists.</span>
<button
type="button"
data-action="knowledge-open-newer"
onClick={() => onOpen?.(outline.superseded_by!)}
className="ml-auto inline-flex items-center gap-1 font-medium text-primary hover:underline"
>
Open newer <ArrowUpRight className="size-3.5" />
</button>
</div>
)}
{outline.supersedes && (
<button
type="button"
onClick={() => onOpen?.(outline.supersedes!)}
className="inline-flex items-center gap-1 self-start text-xs text-muted-foreground hover:text-foreground"
>
Replaces an earlier version <ArrowUpRight className="size-3" />
</button>
)}
</div>
);
};
// ---- OutlineNav -----------------------------------------------------------
export const OutlineNav: FC<{
sections: OutlineSection[];
activeId?: string;
onSelect?: (section: OutlineSection) => void;
className?: string;
}> = ({ sections, activeId, onSelect, className }) => {
if (sections.length === 0) return null;
return (
<nav className={cn("flex flex-col gap-0.5", className)} aria-label="Document outline">
{sections.map((s) => (
<button
key={s.id}
type="button"
data-action="knowledge-outline-section"
onClick={() => onSelect?.(s)}
className={cn(
"truncate rounded-md px-2 py-1 text-left text-sm transition hover:bg-muted",
s.level > 1 && "pl-4 text-xs",
activeId === s.id ? "bg-muted font-medium text-foreground" : "text-muted-foreground",
)}
>
{s.heading}
</button>
))}
</nav>
);
};
// ---- TextReader -----------------------------------------------------------
export interface TextReaderProps {
outline: ObjectOutline;
readText: (sel?: { section?: string; start?: number; end?: number }) => Promise<TextSlice>;
/** A span to load, mark, and scroll to (citation resolver, spec §3.4). */
highlight?: { start: number; end: number };
activeSection?: string;
className?: string;
}
export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight, activeSection, className }) => {
const [slice, setSlice] = useState<TextSlice | null>(null);
const [loading, setLoading] = useState(false);
const markRef = useRef<HTMLSpanElement | null>(null);
const load = useCallback(
async (sel?: { section?: string; start?: number; end?: number }) => {
setLoading(true);
try {
setSlice(await readText(sel));
} finally {
setLoading(false);
}
},
[readText],
);
// Initial + reactive loads: prefer an explicit highlight span, then an active
// section, else the head of the document.
useEffect(() => {
if (highlight) {
const sec = outline.outline.find((s) => highlight.start >= s.start && highlight.end <= s.end);
void load(sec ? { section: sec.id } : { start: Math.max(0, highlight.start - 200), end: highlight.end + 200 });
} else if (activeSection) {
void load({ section: activeSection });
} else {
void load();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [highlight?.start, highlight?.end, activeSection, outline.object_id]);
useEffect(() => {
if (markRef.current) markRef.current.scrollIntoView({ block: "center", behavior: "smooth" });
}, [slice]);
async function loadMore() {
if (!slice?.range) return;
const next = await readText({ start: slice.range.end });
setSlice((prev) =>
prev && prev.range && next.range
? { ...next, text: (prev.text ?? "") + (next.text ?? ""), range: { start: prev.range.start, end: next.range.end } }
: next,
);
}
if (loading && !slice) {
return (
<div className={cn("flex items-center gap-2 py-8 text-sm text-muted-foreground", className)}>
<Loader2 className="size-4 animate-spin" /> Loading
</div>
);
}
if (!slice) return null;
const canReadMore = outline.extracted_chars != null && slice.range != null && slice.range.end < outline.extracted_chars;
return (
<div className={className}>
<article className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground">
{renderWithHighlight(slice, highlight, markRef)}
</article>
{canReadMore && (
<button
type="button"
data-action="knowledge-read-more"
onClick={() => void loadMore()}
className="mt-3 rounded-lg border border-border px-3 py-1.5 text-sm text-muted-foreground transition hover:bg-muted"
>
Read more
</button>
)}
</div>
);
};
function renderWithHighlight(
slice: TextSlice,
highlight: { start: number; end: number } | undefined,
markRef: RefObject<HTMLSpanElement | null>,
): ReactNode {
const text = slice.text ?? "";
const base = slice.range?.start ?? 0;
if (!highlight) return text;
const from = highlight.start - base;
const to = highlight.end - base;
if (from < 0 || from >= text.length || to <= from) return text;
return (
<>
{text.slice(0, from)}
<span ref={markRef} className="rounded bg-[color-mix(in_oklab,var(--warning)_28%,transparent)] px-0.5">
{text.slice(from, to)}
</span>
{text.slice(to)}
</>
);
}
// ---- ObjectActions --------------------------------------------------------
export interface ObjectActions {
onEditMeta?: () => void;
onVerify?: () => void;
onArchive?: () => void;
onDownload?: () => void;
onSupersede?: () => void;
onExtractClaims?: () => void;
}
const ActionButton: FC<{ onClick?: () => void; icon: ReactNode; children: ReactNode; action: string; danger?: boolean }> = ({
onClick,
icon,
children,
action,
danger,
}) =>
onClick ? (
<button
type="button"
data-action={action}
onClick={onClick}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition",
danger
? "border-destructive/30 text-destructive hover:bg-destructive/10"
: "border-border text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{icon}
{children}
</button>
) : null;
// ---- ObjectViewer (composite) --------------------------------------------
export interface ObjectViewerProps {
outline: ObjectOutline;
readText: TextReaderProps["readText"];
/** Resolve a browser-openable blob URL (for images + download). */
resolveBlobUrl?: () => Promise<string>;
highlight?: { start: number; end: number };
onOpenObject?: (objectId: string, span?: { start: number; end: number }) => void;
actions?: ObjectActions;
className?: string;
}
export const ObjectViewer: FC<ObjectViewerProps> = ({
outline,
readText,
resolveBlobUrl,
highlight,
onOpenObject,
actions,
className,
}) => {
const [activeSection, setActiveSection] = useState<string | undefined>();
const [blob, setBlob] = useState<string | null>(null);
const isImage = isImageMime(outline.mime);
const isVault = outline.sensitivity === "vault";
const hasActions =
!!actions &&
(actions.onEditMeta || actions.onVerify || actions.onDownload || actions.onArchive || actions.onSupersede || actions.onExtractClaims);
useEffect(() => {
let live = true;
if (isImage && outline.has_blob && resolveBlobUrl) {
void resolveBlobUrl().then((u) => {
if (live) setBlob(u);
});
}
return () => {
live = false;
};
}, [isImage, outline.has_blob, outline.object_id, resolveBlobUrl]);
return (
<div className={cn("flex flex-col gap-5", className)}>
<SupersessionBanner outline={outline} onOpen={onOpenObject} />
<CatalogCard outline={outline} />
{hasActions && (
<div className="flex flex-wrap gap-2">
<ActionButton action="knowledge-edit-meta" onClick={actions?.onEditMeta} icon={<Tag className="size-3.5" />}>Edit title &amp; tags</ActionButton>
<ActionButton action="knowledge-verify" onClick={actions?.onVerify} icon={<Check className="size-3.5" />}>Confirm still current</ActionButton>
{outline.has_blob && <ActionButton action="knowledge-download" onClick={actions?.onDownload} icon={<Download className="size-3.5" />}>Download original</ActionButton>}
<ActionButton action="knowledge-supersede" onClick={actions?.onSupersede} icon={<ArrowUpRight className="size-3.5" />}>Replace with newer</ActionButton>
{!isVault && <ActionButton action="knowledge-extract-claims" onClick={actions?.onExtractClaims} icon={<Sparkles className="size-3.5" />}>Extract claims</ActionButton>}
<ActionButton action="knowledge-archive" onClick={actions?.onArchive} icon={<Trash2 className="size-3.5" />} danger>Archive</ActionButton>
</div>
)}
{isVault && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-[color-mix(in_oklab,var(--destructive)_8%,transparent)] px-3 py-2 text-sm">
<Lock className="mt-0.5 size-4 shrink-0 text-destructive" />
<span className="text-foreground">
<span className="font-medium">Vault.</span> Never sent to AI models the catalog is file metadata only. You can still read and download it here.
</span>
</div>
)}
{isImage && outline.has_blob && (
<figure className="overflow-hidden rounded-xl border border-border bg-muted/30">
{blob ? (
<img src={blob} alt={outline.title} className="max-h-[480px] w-full object-contain" />
) : (
<div className="flex h-40 items-center justify-center text-muted-foreground">
<ImageIcon className="size-6" />
</div>
)}
</figure>
)}
<div className="grid gap-5 lg:grid-cols-[1fr_260px]">
<div className="min-w-0 rounded-xl border border-border bg-card p-4">
{outline.extracted_chars && outline.extracted_chars > 0 ? (
<TextReader outline={outline} readText={readText} highlight={highlight} activeSection={activeSection} />
) : (
<p className="text-sm text-muted-foreground">
{isImage ? "This image's description is its searchable text — see the summary above." : "No extracted text for this object."}
</p>
)}
</div>
<aside className="flex flex-col gap-4">
{outline.outline.length > 0 && (
<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>
<OutlineNav sections={outline.outline} activeId={activeSection} onSelect={(s) => setActiveSection(s.id)} />
</div>
)}
<ProvenancePanel outline={outline} />
</aside>
</div>
</div>
);
};