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
136 lines
7.7 KiB
TypeScript
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>;
|
|
}
|