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

166 lines
5.9 KiB
TypeScript

/** Accessible HUX-10 privacy status and explicit conversation controls. */
import {useEffect, useState} from "react";
import {privacyControls, privacyEnabled} from "./model.ts";
import type {
PrivacyClient,
PrivacyControl,
PrivacySnapshot,
} from "./types.ts";
interface PrivacyCenterProps {
flags?: Iterable<string>;
client: PrivacyClient;
snapshot: PrivacySnapshot | null;
disabled?: boolean;
onUpdated(snapshot: PrivacySnapshot, action: PrivacyControl): void;
}
const CONTROL_LABELS: Readonly<Record<PrivacyControl, string>> = {
enable_no_store: "Use no-store here",
disable_no_store: "Use standard retention",
forget_this_conversation: "Forget this conversation",
switch_to_private: "Switch to Private mode",
disable_memory_here: "Disable memory here",
dismiss: "Dismiss notice",
};
function readableTime(timestamp: string): string {
return timestamp.replace("T", " ").replace("Z", " UTC");
}
/** Render nothing unless both HUX-11 and HUX-10 are explicitly enabled. */
export function PrivacyCenter(props: PrivacyCenterProps) {
const [busy, setBusy] = useState<PrivacyControl | null>(null);
const [error, setError] = useState("");
const [confirmForget, setConfirmForget] = useState(false);
const enabled = privacyEnabled(props.flags) && props.client?.enabled();
const snapshot = props.snapshot;
useEffect(() => {
setBusy(null);
setError("");
setConfirmForget(false);
}, [snapshot?.revision]);
if (!enabled || !snapshot) return null;
const controls = privacyControls(snapshot);
const ordinaryControls = controls.filter((control) =>
control !== "forget_this_conversation" && control !== "dismiss");
async function apply(action: PrivacyControl): Promise<void> {
if (busy || props.disabled) return;
setBusy(action);
setError("");
try {
const updated = await props.client.control(snapshot, action);
props.onUpdated(updated, action);
} catch (_) {
setError("The privacy change could not be verified. Nothing was changed in this view.");
} finally {
setBusy(null);
setConfirmForget(false);
}
}
return (
<section className="hux-privacy" aria-labelledby="hux-privacy-heading">
<header className="hux-privacy-heading">
<div>
<span className="hux-privacy-kicker">Conversation privacy</span>
<h2 id="hux-privacy-heading">What Hermes keeps</h2>
</div>
<span className={`hux-privacy-state ${snapshot.noStore ? "is-private" : ""}`}
aria-live="polite">
{snapshot.noStore ? "No-store active" : "Standard retention"}
</span>
</header>
{snapshot.notice && (
<aside className="hux-privacy-notice" aria-labelledby="hux-privacy-notice-title">
<h3 id="hux-privacy-notice-title">Sensitive-topic protection</h3>
<p>{snapshot.notice.text}</p>
{snapshot.notice.controls.includes("dismiss") && (
<button type="button" disabled={Boolean(busy) || props.disabled}
onClick={() => void apply("dismiss")}>Dismiss notice</button>
)}
</aside>
)}
<dl className="hux-privacy-retention" aria-label="Effective retention">
<div>
<dt>Conversation</dt>
<dd>{snapshot.noStore ? "Session only; not retained afterward" :
"Retained under the workspace policy"}</dd>
</div>
<div>
<dt>Memory</dt>
<dd>{snapshot.memoryEnabled ? "Ask before saving sensitive facts" :
"Memory writes disabled here"}</dd>
</div>
<div>
<dt>Sensitive context</dt>
<dd>{snapshot.retention.topicContextDecayDays === null ?
"Not retained beyond this session" :
`Minimized within ${snapshot.retention.topicContextDecayDays} days`}</dd>
</div>
<div>
<dt>After the topic changes</dt>
<dd>Previous sensitive details are not repeated</dd>
</div>
<div>
<dt>Across surfaces</dt>
<dd>{snapshot.retention.crossSurfaceSharing === "never" ?
"Never shared" : "Same owner only"}</dd>
</div>
<div>
<dt>Retention audit</dt>
<dd>Every {snapshot.retention.auditIntervalDays} days; next{" "}
<time dateTime={snapshot.retention.nextAuditAt}>
{readableTime(snapshot.retention.nextAuditAt)}
</time>
</dd>
</div>
</dl>
<div className="hux-privacy-controls" aria-busy={Boolean(busy)}>
{ordinaryControls.map((control) => (
<button key={control} type="button" disabled={Boolean(busy) || props.disabled}
onClick={() => void apply(control)}>
{CONTROL_LABELS[control]}
</button>
))}
{controls.includes("forget_this_conversation") && !confirmForget && (
<button type="button" className="hux-privacy-danger"
disabled={Boolean(busy) || props.disabled} onClick={() => setConfirmForget(true)}>
Forget this conversation
</button>
)}
</div>
{confirmForget && (
<div className="hux-privacy-confirm" role="group"
aria-labelledby="hux-privacy-confirm-title">
<strong id="hux-privacy-confirm-title">Remove this conversation and its saved context?</strong>
<span>This cannot be undone.</span>
<div>
<button type="button" className="hux-privacy-danger"
disabled={Boolean(busy) || props.disabled}
onClick={() => void apply("forget_this_conversation")}>
Confirm forget
</button>
<button type="button" disabled={Boolean(busy)}
onClick={() => setConfirmForget(false)}>Cancel</button>
</div>
</div>
)}
<div className="hux-privacy-status" role="status" aria-live="polite">
{busy ? "Applying privacy control…" : ""}
</div>
{error && <p className="hux-privacy-error" role="alert">{error}</p>}
</section>
);
}