From cafb8202279a3a871236ee3dcc8bca38b454fb9e Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 18 May 2026 18:10:01 -0300 Subject: [PATCH 1/5] feat(custom-ui): ai citation showcase over metadata (SD-3208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customer-facing citation demo built on the metadata.* contract from SD-3104. Customer-facing counterpart to the developer-shaped SD-3199 demo (#3370, draft); both lean on the same anchored-metadata model. What this shows: - Generate draft with sources — mocked stand-in for a chat-driven RAG pipeline. Inserts a pre-canned paragraph and attaches citations to specific phrases. - Sources sidebar grouped by sourceId, showing source title, type, provider, deep link, and the cited locations within the document. - Hover popover with displayText, locator, excerpt, provider, confidence — the verification surface so a lawyer can trust the output without leaving the doc. - Highlight overlay on cited spans using getRect.rects, so line-wrapped citations get clean per-line underlines visible through the text. - Scroll-to, Edit, Remove per citation. All six metadata.* methods exercised. What this does not show: - The button is a stand-in for a chat-driven AI workflow, not the recommended product UX. A real integration plugs a chat panel + Insert into document action. - Generation is mocked. No LLM is wired. - Verification status is a render-time concern; external verification signals can change over time, so it is not persisted in the payload. --- demos/custom-ui/src/App.tsx | 77 ++++-- .../src/components/CitationHighlights.tsx | 98 ++++++++ .../src/components/CitationPopover.tsx | 128 ++++++++++ .../src/components/CitationsPanel.tsx | 190 ++++++++++++++ .../src/components/GenerateDraftButton.tsx | 109 ++++++++ .../src/components/citations-types.ts | 143 +++++++++++ demos/custom-ui/src/components/mockDraft.ts | 127 ++++++++++ .../custom-ui/src/components/useCitations.ts | 129 ++++++++++ demos/custom-ui/src/styles.css | 234 ++++++++++++++++++ 9 files changed, 1208 insertions(+), 27 deletions(-) create mode 100644 demos/custom-ui/src/components/CitationHighlights.tsx create mode 100644 demos/custom-ui/src/components/CitationPopover.tsx create mode 100644 demos/custom-ui/src/components/CitationsPanel.tsx create mode 100644 demos/custom-ui/src/components/GenerateDraftButton.tsx create mode 100644 demos/custom-ui/src/components/citations-types.ts create mode 100644 demos/custom-ui/src/components/mockDraft.ts create mode 100644 demos/custom-ui/src/components/useCitations.ts diff --git a/demos/custom-ui/src/App.tsx b/demos/custom-ui/src/App.tsx index 691e3812e0..a064acdf18 100644 --- a/demos/custom-ui/src/App.tsx +++ b/demos/custom-ui/src/App.tsx @@ -7,6 +7,9 @@ import { SelectionPopover } from './components/SelectionPopover'; import { ContextMenu } from './components/ContextMenu'; import { ContextMenuRegistrations } from './components/ContextMenuRegistrations'; import { useDecidedChanges } from './components/useDecidedChanges'; +import { CitationsPanel } from './components/CitationsPanel'; +import { CitationHighlights } from './components/CitationHighlights'; +import { CitationPopover } from './components/CitationPopover'; export function App() { return ( @@ -29,6 +32,12 @@ function AppInner() { // the simplest path; a real product might dispatch through a state // store, but the example keeps the wiring obvious. const [composeOpen, setComposeOpen] = useState(false); + // Sidebar tab selection. Activity is the default; Sources is the + // citation/RAG panel built on editor.doc.metadata.* (SD-3208). No + // creation flow lives in the selection popover — citations arrive + // via "Generate draft with sources," which is how Harvey-class + // legal-AI products surface citations. + const [activeTab, setActiveTab] = useState<'activity' | 'sources'>('activity'); // Shared decided-changes store. Both ActivitySidebar (per-card // accept/reject buttons) and the right-click context menu route // through `decided.decideChange` so the Resolved audit row shows @@ -47,36 +56,50 @@ function AppInner() { return (
-

Contract Review Workspace

- Memorandum · review pending -
+

Contract Review Workspace

+ Memorandum · review pending + -
-
-
- +
+
+
+ +
+
+
+
-
-
- -
-
- - - -
+
+ + + + + +
- -
+ +
); } diff --git a/demos/custom-ui/src/components/CitationHighlights.tsx b/demos/custom-ui/src/components/CitationHighlights.tsx new file mode 100644 index 0000000000..7138893ecc --- /dev/null +++ b/demos/custom-ui/src/components/CitationHighlights.tsx @@ -0,0 +1,98 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { ViewportRect } from 'superdoc/ui'; +import { useSuperDocContentControls, useSuperDocUI } from 'superdoc/ui/react'; +import { useCitations } from './useCitations'; + +/** + * Renders absolute-positioned overlay rectangles on every cited span. + * + * Two-step lookup. `editor.doc.metadata.*` keys by the metadata id + * (which is the SDT's `w:tag`); `ui.contentControls.getRect({ id })` + * keys by the SDT's PM node id (which the painter stamps as + * `data-sdt-id`). These are different identifiers. The contentControls + * slice surfaces both per item (`target.nodeId` + `properties.tag`), + * so we build a tag → nodeId map and translate at measure time. + * + * `getRect` returns `rects[]` — one ViewportRect per painted line of a + * wrapped span — so line-wrapped citations get clean per-line + * underlines without spilling across the page margin. + * + * Scroll and resize trigger a re-measure so the highlights stay glued. + */ +type HighlightEntry = { metadataId: string; tooltip: string; rects: ViewportRect[] }; + +type CCItem = { target?: { nodeId?: string }; properties?: { tag?: string } }; + +export function CitationHighlights() { + const ui = useSuperDocUI(); + const { citations } = useCitations(); + const cc = useSuperDocContentControls(); + const [entries, setEntries] = useState([]); + + // tag (= metadata id) → PM node id. Refreshes whenever the slice + // items array reference changes. + const tagToNodeId = useMemo(() => { + const map = new Map(); + for (const item of (cc.items ?? []) as unknown as CCItem[]) { + const tag = item.properties?.tag; + const nodeId = item.target?.nodeId; + if (typeof tag === 'string' && typeof nodeId === 'string') { + map.set(tag, nodeId); + } + } + return map; + }, [cc.items]); + + useEffect(() => { + if (!ui) { + setEntries([]); + return; + } + + const remeasure = () => { + const next: HighlightEntry[] = []; + for (const c of citations) { + const nodeId = tagToNodeId.get(c.id); + if (!nodeId) continue; + const result = ui.contentControls.getRect({ id: nodeId }); + if (!result.success) continue; + next.push({ + metadataId: c.id, + tooltip: `${c.payload.displayText} (${c.payload.citationId})`, + rects: result.rects, + }); + } + setEntries(next); + }; + + remeasure(); + window.addEventListener('scroll', remeasure, true); + window.addEventListener('resize', remeasure); + return () => { + window.removeEventListener('scroll', remeasure, true); + window.removeEventListener('resize', remeasure); + }; + }, [ui, citations, tagToNodeId]); + + return ( +
+ {entries.flatMap((entry) => + entry.rects.map((rect, i) => ( +
+ )), + )} +
+ ); +} diff --git a/demos/custom-ui/src/components/CitationPopover.tsx b/demos/custom-ui/src/components/CitationPopover.tsx new file mode 100644 index 0000000000..b20501769c --- /dev/null +++ b/demos/custom-ui/src/components/CitationPopover.tsx @@ -0,0 +1,128 @@ +import { useEffect, useState } from 'react'; +import { useSuperDocUI, useSuperDocHost } from 'superdoc/ui/react'; +import { CITATIONS_NAMESPACE, isCitationPayload, type CitationPayload, type MetadataDocApi } from './citations-types'; + +const HOVER_DEBOUNCE_MS = 60; + +type HoverState = { id: string; payload: CitationPayload; x: number; y: number } | null; + +function getMetadataApi(host: ReturnType): MetadataDocApi | null { + if (!host) return null; + const editor = (host as unknown as { activeEditor?: { doc?: { metadata?: MetadataDocApi } } }).activeEditor; + return editor?.doc?.metadata ?? null; +} + +/** + * Hover-driven popover for cited spans. Composes existing primitives: + * + * - `mousemove` listener (throttled) over the editor area + * - `ui.viewport.entityAt({ x, y })` → ordered list of hits, innermost first + * - Take the first `type: 'contentControl'` hit's `tag` (the metadata id) + * - `editor.doc.metadata.get({ id })` → payload + * - Filter on namespace + payload-shape guard to ignore non-citation SDTs + * + * This is exactly the surface the SD-3104 PR claims is "composable + * from existing primitives." Building it here proves that claim or + * surfaces the friction. If it turns out to be awkward, that's the + * evidence to file a first-class `ui.metadata({ namespace }).observe(...)` + * convenience handle. + */ +export function CitationPopover() { + const ui = useSuperDocUI(); + const host = useSuperDocHost(); + const [hover, setHover] = useState(null); + + useEffect(() => { + if (!ui) return; + let raf: number | null = null; + let lastFire = 0; + + const onMove = (e: MouseEvent) => { + const now = performance.now(); + if (now - lastFire < HOVER_DEBOUNCE_MS) return; + lastFire = now; + + if (raf != null) cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => { + raf = null; + // `ui` is typed `any` here because the published superdoc/ui + // declaration file isn't picked up by the demo's tsconfig + // (pre-existing debt). Annotating `h` keeps this file free of + // implicit-any errors at the demo TS check. + type ViewportHit = { type: string; id?: string; tag?: string; scope?: 'block' | 'inline' }; + type ContentControlHit = { type: 'contentControl'; id: string; tag?: string; scope?: 'block' | 'inline' }; + const hits: ViewportHit[] = ui.viewport.entityAt({ x: e.clientX, y: e.clientY }); + // Innermost first per the entityAt contract; the first + // contentControl hit is the one the cursor is currently over. + const ccHit = hits.find((h: ViewportHit): h is ContentControlHit => + h.type === 'contentControl' && typeof h.id === 'string', + ); + if (!ccHit || !ccHit.tag) { + setHover(null); + return; + } + // ccHit.tag IS the metadata id when the SDT was attached via + // metadata.attach (the adapter sets w:tag = metadataId). + const api = getMetadataApi(host); + if (!api) { + setHover(null); + return; + } + const info = api.get({ id: ccHit.tag }); + if (!info || info.namespace !== CITATIONS_NAMESPACE || !isCitationPayload(info.payload)) { + setHover(null); + return; + } + setHover({ id: info.id, payload: info.payload, x: e.clientX, y: e.clientY }); + }); + }; + + const onLeave = () => { + if (raf != null) cancelAnimationFrame(raf); + raf = null; + setHover(null); + }; + + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseleave', onLeave); + return () => { + if (raf != null) cancelAnimationFrame(raf); + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseleave', onLeave); + }; + }, [ui, host]); + + if (!hover) return null; + + const p = hover.payload; + + return ( +
+
{p.displayText}
+ {p.locator &&
{p.locator}
} + {p.excerpt && ( +
+ “{p.excerpt}” +
+ )} +
+ {p.provider} + {typeof p.confidence === 'number' && ( + <> + {' · '} + conf {p.confidence.toFixed(2)} + + )} +
+
+ ); +} diff --git a/demos/custom-ui/src/components/CitationsPanel.tsx b/demos/custom-ui/src/components/CitationsPanel.tsx new file mode 100644 index 0000000000..27e821f307 --- /dev/null +++ b/demos/custom-ui/src/components/CitationsPanel.tsx @@ -0,0 +1,190 @@ +import { useMemo, useState } from 'react'; +import { useSuperDocUI } from 'superdoc/ui/react'; +import { selectionTargetToTextTarget, type CitationInfo } from './citations-types'; +import { useCitations } from './useCitations'; +import { GenerateDraftButton } from './GenerateDraftButton'; + +/** + * References panel. Renders citations grouped by `sourceId` — the + * pattern Harvey / CoCounsel / Lexis+ use for the side panel beside + * AI-generated output. Each source group shows the metadata and links + * back to every cited span in the body. No manual composer; citations + * arrive via `metadata.attach` from the mocked generation pipeline. + */ +export function CitationsPanel() { + const ui = useSuperDocUI(); + const { citations, resolve, remove, loading } = useCitations(); + const [editingId, setEditingId] = useState(null); + + const groups = useMemo(() => groupBySource(citations), [citations]); + + const scrollTo = async (id: string) => { + if (!ui) return; + const selectionTarget = resolve(id); + const textTarget = selectionTargetToTextTarget(selectionTarget); + if (!textTarget) return; + await ui.viewport.scrollIntoView({ target: textTarget }); + }; + + return ( +
+ + + {loading &&
Loading\u2026
} + {!loading && citations.length === 0 && ( +
+ No sources cited yet. Click Generate draft with sources to insert AI-generated + text with citations pre-attached. +
+ )} + +
+ {groups.map((group) => ( +
+
+ {group.displayText} + {group.sourceType} +
+
+ {group.provider} + {group.deepLink && ( + <> + {' \u00b7 '} + + Open source + + + )} +
+
    + {group.citations.map((c) => ( +
  • +
    + {c.payload.citationId} + {c.payload.locator && ( + {c.payload.locator} + )} + {typeof c.payload.confidence === 'number' && ( + conf {c.payload.confidence.toFixed(2)} + )} +
    + {editingId === c.id ? ( + setEditingId(null)} /> + ) : ( +
    + + + +
    + )} +
  • + ))} +
+
+ ))} +
+
+ ); +} + +type ReferenceGroup = { + sourceId: string; + sourceType: CitationInfo['payload']['sourceType']; + provider: string; + displayText: string; + deepLink?: string; + citations: CitationInfo[]; +}; + +function groupBySource(citations: CitationInfo[]): ReferenceGroup[] { + const bySourceId = new Map(); + for (const c of citations) { + const p = c.payload; + const existing = bySourceId.get(p.sourceId); + if (existing) { + existing.citations.push(c); + } else { + bySourceId.set(p.sourceId, { + sourceId: p.sourceId, + sourceType: p.sourceType, + provider: p.provider, + displayText: p.displayText, + deepLink: p.deepLink, + citations: [c], + }); + } + } + return Array.from(bySourceId.values()); +} + +/** + * Inline edit form. Exercises `metadata.update` — the lawyer can fix + * displayText, locator, or excerpt without re-running the generation + * pipeline. `citationId`, `sourceId`, `sourceType`, and `provider` are + * locked here because changing those would mean a different citation, + * not an edit of this one. A real product would offer "replace this + * citation with a different source" as a separate flow. + */ +function CitationEditor({ citation, onClose }: { citation: CitationInfo; onClose(): void }) { + const { update } = useCitations(); + const [displayText, setDisplayText] = useState(citation.payload.displayText); + const [locator, setLocator] = useState(citation.payload.locator ?? ''); + const [excerpt, setExcerpt] = useState(citation.payload.excerpt); + const [error, setError] = useState(null); + + const save = () => { + setError(null); + const result = update(citation.id, { + ...citation.payload, + displayText: displayText.trim(), + locator: locator.trim() || undefined, + excerpt: excerpt.trim(), + }); + if (result.error) { + setError(result.error); + return; + } + onClose(); + }; + + return ( +
+ setDisplayText(e.target.value)} + placeholder="Display text" + onKeyDown={(e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') save(); + if (e.key === 'Escape') onClose(); + }} + /> + setLocator(e.target.value)} + placeholder="Locator (optional)" + onKeyDown={(e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') save(); + if (e.key === 'Escape') onClose(); + }} + /> +