atlas-iac/dockerfiles/hermes-webui-hux/onboarding/OnboardingSuggestions.tsx
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

118 lines
4.3 KiB
TypeScript

/** Restrained, accessible HUX-09 suggestion card. No action runs implicitly. */
import {useEffect, useState} from "react";
import {buildActionIntent, onboardingEnabled} from "./model.ts";
import type {
OnboardingActionIntent,
OnboardingClient,
SuggestionDecision,
SuggestionEvaluation,
} from "./types.ts";
interface OnboardingSuggestionsProps {
flags?: Iterable<string>;
client: OnboardingClient;
evaluation: SuggestionEvaluation | null;
disabled?: boolean;
onAction(intent: OnboardingActionIntent): void | Promise<void>;
onResolved?(decision: SuggestionDecision): void;
}
const CONTEXT_LABELS = {
first_session: "Shown because this is a new workspace session.",
empty_project: "Shown because this project has no conversations yet.",
after_artifact: "Shown because you just created an artifact.",
after_research: "Shown because a research task just finished.",
after_approval: "Shown because an approved task just finished.",
idle: "Shown after this conversation became idle.",
} as const;
function actionLabel(evaluation: SuggestionEvaluation): string | null {
const {action} = evaluation.suggestion;
if (action.type === "open_mode") return "Choose a mode";
if (action.type === "create_project") return "Add to a project";
if (action.type === "open_memory") return "Review memory";
if (action.type === "open_artifacts") return "View artifacts";
if (action.type === "start_workflow") {
return action.payload?.intent === "schedule_weekly" ? "Run weekly" : "Save workflow";
}
return null;
}
/** Render nothing until the four HUX dependencies and an eligible claim exist. */
export function OnboardingSuggestions(props: OnboardingSuggestionsProps) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [resolved, setResolved] = useState(false);
const enabled = onboardingEnabled(props.flags) && props.client?.enabled();
const evaluation = props.evaluation;
useEffect(() => {
setBusy(false);
setError("");
setResolved(false);
}, [evaluation?.claimId]);
if (!enabled || !evaluation || evaluation.privacy.noStore || resolved) return null;
if (evaluation.suggestion.action.type === "open_memory" &&
!evaluation.privacy.memorySuggestionsAllowed) return null;
const label = actionLabel(evaluation);
async function resolve(decision: SuggestionDecision): Promise<void> {
if (busy || props.disabled) return;
setBusy(true);
setError("");
try {
if (decision === "acted") {
const intent = buildActionIntent(evaluation);
if (!intent) throw new Error("This suggestion has no safe action");
await props.onAction(intent);
}
await props.client.decide(evaluation, decision);
setResolved(true);
props.onResolved?.(decision);
} catch (_) {
setError("That choice could not be saved. Nothing else was changed.");
} finally {
setBusy(false);
}
}
return (
<aside className="hux-onboarding" aria-labelledby={`hux-onboarding-${evaluation.suggestion.id}`}>
<div className="hux-onboarding-copy">
<span className="hux-onboarding-kicker">Optional next step</span>
<h2 id={`hux-onboarding-${evaluation.suggestion.id}`}>
{evaluation.suggestion.title}
</h2>
<p>{evaluation.suggestion.body}</p>
<details className="hux-onboarding-why">
<summary>Why am I seeing this?</summary>
<p>{CONTEXT_LABELS[evaluation.suggestion.trigger.context]}</p>
</details>
</div>
<div className="hux-onboarding-controls" aria-busy={busy}>
{label && (
<button type="button" className="hux-onboarding-primary"
disabled={busy || props.disabled} onClick={() => void resolve("acted")}>
{label}
</button>
)}
<button type="button" disabled={busy || props.disabled}
onClick={() => void resolve("dismiss")}>
Not now
</button>
<button type="button" className="hux-onboarding-never"
disabled={busy || props.disabled} onClick={() => void resolve("never_again")}>
Don&apos;t suggest this again
</button>
</div>
<div className="hux-onboarding-status" role="status" aria-live="polite">
{busy ? "Saving your choice…" : ""}
</div>
{error && <p className="hux-onboarding-error" role="alert">{error}</p>}
</aside>
);
}