jenkins b66c762f5d hermes(webui): add HUX card UI models with contract-locked suites
Standalone per-card browser model/security/view modules for HUX-01..10
plus node+pytest suites that read the hux.v1 contract schemas directly.
Reconciled drift found on integration: the activity model now accepts
all 32 hux.event.v1 kinds (delegation.*, memory.suppressed,
memory.retrieval_removed, budget.exhausted, side_effect.*), the
autonomy model carries the external_side_effect capability, and the
foundation boundary test now asserts the shipped static HUX surface
exists on disk and that images never bake activated HUX_FLAGS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:12:03 -03:00

136 lines
7.7 KiB
TypeScript

/** Accessible research UX. All supporting text renders as text, never executable markup. */
import { AlertCircle, BookOpen, CheckCircle2, ExternalLink, FileSearch, HelpCircle } from "lucide-react";
import { useMemo, useState } from "react";
import { normalizeResearchPage, researchEnabled } from "./model.ts";
import type {
CitationView, NotebookView, PassageView, ResearchPage, ResearchWorkspaceProps, SourceView,
} from "./types.ts";
const SUPPORT_LABEL = {
supports: "Supports claim",
partially_supports: "Partially supports",
contradicts: "Contradicts claim",
unverified: "Unverified",
} as const;
function SourceMetadata({source}: {source: SourceView}) {
return <article className="hux-research-source" data-classification={source.classification}>
<header><span>{source.classification} source</span><h4>{source.title}</h4></header>
<dl>
<div><dt>Publisher</dt><dd>{source.publisher || "Not provided"}</dd></div>
<div><dt>Published</dt><dd>{source.publishedAt ?
<time dateTime={source.publishedAt}>{new Date(source.publishedAt).toLocaleDateString()}</time> : "Not provided"}</dd></div>
<div><dt>Retrieved</dt><dd><time dateTime={source.retrievedAt}>
{new Date(source.retrievedAt).toLocaleString()}</time></dd></div>
<div><dt>Kind</dt><dd>{source.kind.replace("_", " ")}</dd></div>
{source.provenance && <div><dt>Collected by</dt><dd>
{source.provenance.actorType} on {source.provenance.surface}
</dd></div>}
</dl>
{source.uri && <a href={source.uri} target="_blank" rel="noopener noreferrer">
Open source <ExternalLink aria-hidden />
</a>}
</article>;
}
function Passage({passage, source}: {passage: PassageView; source: SourceView}) {
return <figure className="hux-research-passage">
<blockquote>{passage.text}</blockquote>
<figcaption>{source.title}{passage.locator ? ` · ${passage.locator}` : ""}</figcaption>
</figure>;
}
function CitationCard({citation, page}: {citation: CitationView; page: ResearchPage}) {
const [expanded, setExpanded] = useState(false);
const passages = citation.passageIds.map((id) => page.passages.find((item) => item.id === id))
.filter((item): item is PassageView => Boolean(item));
const sourceIds = [...new Set(passages.map((item) => item.sourceId))];
const sources = sourceIds.map((id) => page.sources.find((item) => item.id === id))
.filter((item): item is SourceView => Boolean(item));
const panelId = `hux-citation-${citation.id}`;
const Icon = citation.support === "supports" ? CheckCircle2 :
citation.support === "unverified" ? HelpCircle : AlertCircle;
return <li className="hux-research-citation" data-support={citation.support}>
<button type="button" aria-expanded={expanded} aria-controls={panelId}
onClick={() => setExpanded((value) => !value)}>
<Icon aria-hidden /><span><strong>{citation.claim}</strong>
<small>{SUPPORT_LABEL[citation.support]} · {sources.length} source{sources.length === 1 ? "" : "s"}</small></span>
</button>
{expanded && <section id={panelId} aria-label={`Evidence for ${citation.claim}`}>
{citation.note && <p><strong>Research note:</strong> {citation.note}</p>}
<div className="hux-research-passages">{passages.map((item) => {
const source = page.sources.find((candidate) => candidate.id === item.sourceId)!;
return <Passage key={item.id} passage={item} source={source} />;
})}</div>
<div className="hux-research-sources">{sources.map((item) =>
<SourceMetadata key={item.id} source={item} />)}</div>
</section>}
</li>;
}
function CitationStrip({page, messageId}: {page: ResearchPage; messageId?: string}) {
const citations = messageId ? page.citations.filter((item) => item.messageId === messageId) : page.citations;
return <section className="hux-research-strip" aria-labelledby="hux-citation-strip-title">
<header><h2 id="hux-citation-strip-title"><FileSearch aria-hidden /> Sources and citations</h2>
<p>{citations.length} claim{citations.length === 1 ? "" : "s"} with retained evidence.</p></header>
<ul>{citations.map((citation) => <CitationCard key={citation.id} citation={citation} page={page} />)}</ul>
{!citations.length && <p>No citations are attached to this answer.</p>}
</section>;
}
function ItemList({title, values, empty}: {title: string; values: readonly string[]; empty: string}) {
return <section><h4>{title}</h4>{values.length ? <ul>{values.map((value, index) =>
<li key={`${index}-${value}`}>{value}</li>)}</ul> : <p>{empty}</p>}</section>;
}
function Notebook({notebook, page}: {notebook: NotebookView; page: ResearchPage}) {
const sources = notebook.sourceIds.map((id) => page.sources.find((item) => item.id === id))
.filter((item): item is SourceView => Boolean(item));
return <article className="hux-research-notebook">
<header><div><span>{notebook.status}</span><h3>{notebook.question}</h3></div>
<time dateTime={notebook.updatedAt}>Updated {new Date(notebook.updatedAt).toLocaleString()}</time></header>
<section className="hux-research-notebook-grid">
<section><h4>Collected sources</h4>{sources.length ? <ol>{sources.map((source) => <li key={source.id}>
<strong>{source.title}</strong><span>{source.classification} · {source.publisher || source.kind}</span>
</li>)}</ol> : <p>No sources collected.</p>}</section>
<ItemList title="Research notes" values={notebook.notes} empty="No notes recorded." />
<ItemList title="Assumptions" values={notebook.assumptions} empty="No assumptions recorded." />
<ItemList title="Unresolved questions" values={notebook.unresolvedQuestions} empty="No unresolved questions." />
</section>
</article>;
}
/** Render nothing unless foundation, friendly modes, and research are negotiated. */
export function ResearchWorkspace(props: ResearchWorkspaceProps) {
const enabled = researchEnabled(props.flags);
const page = useMemo(() => enabled ? normalizeResearchPage(props.page, props.identity, props.scope) : null,
[enabled, props.identity, props.page, props.scope]);
const [selectedId, setSelectedId] = useState<string | null>(null);
if (!enabled || !page) return null;
const selected = page.notebooks.find((item) => item.id === selectedId) || page.notebooks[0] || null;
return <section className="hux-research" aria-label="Research and citations" aria-busy={props.busy}>
{props.error && <p className="hux-research-error" role="alert">{props.error}</p>}
{page.rejected > 0 && <p className="hux-research-warning" role="status">
Some evidence was withheld because its scope, provenance, URL, or references were invalid.
</p>}
<CitationStrip page={page} messageId={props.messageId} />
<section className="hux-research-view" aria-labelledby="hux-research-view-title">
<header><h2 id="hux-research-view-title"><BookOpen aria-hidden /> Research notebooks</h2>
<p>Sources, notes, assumptions, and open questions stay together.</p></header>
<div className="hux-research-layout">
<nav aria-label="Research notebooks"><ul>{page.notebooks.map((item) => <li key={item.id}>
<button type="button" aria-current={selected?.id === item.id ? "page" : undefined}
onClick={() => setSelectedId(item.id)}>{item.question}<small>{item.status}</small></button>
</li>)}</ul>{!page.notebooks.length && <p>No research notebooks yet.</p>}</nav>
<main>{selected ? <Notebook key={selected.id} notebook={selected} page={page} /> :
<p>Select a notebook to inspect retained research.</p>}</main>
</div>
</section>
<p className="sr-only" aria-live="polite" role="status">
{`${page.citations.length} citations and ${page.notebooks.length} notebooks available`}
</p>
</section>;
}