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>
|
||
|
|
);
|
||
|
|
}
|