Spaces:
Sleeping
Sleeping
| import { useState } from "react"; | |
| import { Check, HelpCircle, X } from "lucide-react"; | |
| import type { GatedResult } from "../types"; | |
| const BUTTONS = [ | |
| { label: "Approve", human: "APPROVE", cls: "bg-emerald-600 hover:bg-emerald-700", Icon: Check }, | |
| { label: "Reject", human: "REJECT", cls: "bg-rose-600 hover:bg-rose-700", Icon: X }, | |
| { label: "Request info", human: "ESCALATE", cls: "bg-amber-500 hover:bg-amber-600", Icon: HelpCircle }, | |
| ]; | |
| const MOD_KEY = "amana.moderator"; | |
| export function HumanControls({ | |
| result, | |
| onDecide, | |
| }: { | |
| result: GatedResult; | |
| onDecide: (human: string, moderator: string, reason: string) => Promise<void>; | |
| }) { | |
| const [moderator, setModerator] = useState(() => localStorage.getItem(MOD_KEY) ?? ""); | |
| const [reason, setReason] = useState(""); | |
| const [msg, setMsg] = useState<{ kind: "ok" | "warn"; text: string } | null>(null); | |
| const [busy, setBusy] = useState(false); | |
| const aiRec = result.decision.recommendation; | |
| const click = async (human: string) => { | |
| const name = moderator.trim(); | |
| if (!name) { | |
| setMsg({ kind: "warn", text: "Add your name first — a human owns every decision." }); | |
| return; | |
| } | |
| const isOverride = human !== aiRec; | |
| if (isOverride && !reason.trim()) { | |
| setMsg({ kind: "warn", text: `Overriding the AI's ${aiRec} with ${human} needs a written reason.` }); | |
| return; | |
| } | |
| setBusy(true); | |
| setMsg(null); | |
| try { | |
| await onDecide(human, name, reason.trim()); | |
| localStorage.setItem(MOD_KEY, name); | |
| setMsg({ | |
| kind: "ok", | |
| text: isOverride | |
| ? `Recorded — ${name} overrode the AI's ${aiRec} (reason logged).` | |
| : `Recorded: ${human} by ${name} (agreed with the AI).`, | |
| }); | |
| setReason(""); | |
| } catch (e) { | |
| setMsg({ kind: "warn", text: (e as Error).message }); | |
| } finally { | |
| setBusy(false); | |
| } | |
| }; | |
| return ( | |
| <div className="border-t border-line pt-4"> | |
| <div className="text-sm font-semibold text-heading"> | |
| Your decision <span className="font-normal text-faint">— final; the AI does not decide</span> | |
| </div> | |
| <div className="mt-3 grid grid-cols-1 sm:grid-cols-[auto,1fr] sm:items-center gap-2"> | |
| <label className="text-xs font-medium uppercase tracking-wide text-faint sm:pr-1"> | |
| Moderator | |
| </label> | |
| <input | |
| value={moderator} | |
| onChange={(e) => setModerator(e.target.value)} | |
| placeholder="Your name (recorded with the decision)" | |
| className="w-full text-sm border border-line rounded-lg px-3 py-2 bg-surface text-body focus:outline-none focus:ring-2 focus:ring-accent/40 focus:border-accent" | |
| /> | |
| </div> | |
| <textarea | |
| value={reason} | |
| onChange={(e) => setReason(e.target.value)} | |
| rows={2} | |
| placeholder="Reason / notes — required to override the AI's recommendation" | |
| className="mt-2 w-full text-sm border border-line rounded-lg p-2.5 bg-surface text-body focus:outline-none focus:ring-2 focus:ring-accent/40 focus:border-accent" | |
| /> | |
| <div className="grid grid-cols-3 gap-2 mt-2"> | |
| {BUTTONS.map(({ label, human, cls, Icon }) => ( | |
| <button | |
| key={human} | |
| onClick={() => click(human)} | |
| disabled={busy} | |
| className={`flex items-center justify-center gap-1.5 text-white rounded-lg py-2.5 text-sm font-medium shadow-sm transition disabled:opacity-60 ${cls}`} | |
| > | |
| <Icon size={15} /> {label} | |
| </button> | |
| ))} | |
| </div> | |
| {msg && ( | |
| <div | |
| className={`mt-2 text-sm px-3 py-2 rounded-lg ${ | |
| msg.kind === "ok" | |
| ? "bg-emerald-50 text-emerald-800 dark:bg-emerald-500/10 dark:text-emerald-300" | |
| : "bg-amber-50 text-amber-800 dark:bg-amber-500/10 dark:text-amber-300" | |
| }`} | |
| > | |
| {msg.text} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |