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
118 lines
4.3 KiB
TypeScript
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'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>
|
|
);
|
|
}
|