320 lines
12 KiB
JavaScript
320 lines
12 KiB
JavaScript
import { Mail, UserRound, X } from "lucide-react";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import AgentSpark from "./AgentSpark";
|
|
import {
|
|
ALL_MAIL_LABEL,
|
|
ALL_TYPES_ID,
|
|
buildHandoffSummary,
|
|
EMAIL_TYPE_OPTIONS,
|
|
HANDOFF_LABELS,
|
|
INSTRUCTION_EXAMPLES,
|
|
} from "../utils/handoffUtils";
|
|
|
|
function ToggleSwitch({ checked, onChange, label }) {
|
|
return (
|
|
<label className="handoff-switch">
|
|
<span>{label}</span>
|
|
<input type="checkbox" checked={checked} onChange={(event) => onChange(event.target.checked)} />
|
|
<span className="handoff-switch-track" aria-hidden="true" />
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export default function HumanHandoffDrawer({
|
|
open,
|
|
rule,
|
|
saving,
|
|
onClose,
|
|
onSave,
|
|
onTest,
|
|
}) {
|
|
const [ruleName, setRuleName] = useState("");
|
|
const [priority, setPriority] = useState(0);
|
|
const [ownerEmail, setOwnerEmail] = useState("");
|
|
const [labels, setLabels] = useState([]);
|
|
const [emailTypes, setEmailTypes] = useState([]);
|
|
const [instructions, setInstructions] = useState("");
|
|
const [allowReplies, setAllowReplies] = useState(true);
|
|
const [active, setActive] = useState(true);
|
|
const [labelQuery, setLabelQuery] = useState("");
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setRuleName(rule?.name || "");
|
|
setPriority(rule?.priority ?? 0);
|
|
setOwnerEmail(rule?.owner_email || "");
|
|
setLabels(rule?.labels || []);
|
|
setEmailTypes(rule?.email_types || []);
|
|
setInstructions(rule?.instructions || "");
|
|
setAllowReplies(rule?.allow_replies ?? true);
|
|
setActive(rule?.active ?? true);
|
|
setLabelQuery("");
|
|
}, [open, rule]);
|
|
|
|
const summary = useMemo(
|
|
() => buildHandoffSummary({
|
|
ownerEmail,
|
|
labels,
|
|
emailTypes,
|
|
instructions,
|
|
}),
|
|
[ownerEmail, labels, emailTypes, instructions],
|
|
);
|
|
|
|
const filteredLabels = HANDOFF_LABELS.filter((label) =>
|
|
label.toLowerCase().includes(labelQuery.trim().toLowerCase()),
|
|
);
|
|
|
|
function toggleLabel(label) {
|
|
if (label === ALL_MAIL_LABEL) {
|
|
setLabels((current) => (
|
|
current.includes(ALL_MAIL_LABEL) ? [] : [ALL_MAIL_LABEL]
|
|
));
|
|
return;
|
|
}
|
|
setLabels((current) => {
|
|
const withoutAll = current.filter((item) => item !== ALL_MAIL_LABEL);
|
|
return withoutAll.includes(label)
|
|
? withoutAll.filter((item) => item !== label)
|
|
: [...withoutAll, label];
|
|
});
|
|
}
|
|
|
|
function toggleEmailType(typeId) {
|
|
if (typeId === ALL_TYPES_ID) {
|
|
setEmailTypes((current) => (
|
|
current.includes(ALL_TYPES_ID) ? [] : [ALL_TYPES_ID]
|
|
));
|
|
return;
|
|
}
|
|
setEmailTypes((current) => {
|
|
const withoutAll = current.filter((item) => item !== ALL_TYPES_ID);
|
|
return withoutAll.includes(typeId)
|
|
? withoutAll.filter((item) => item !== typeId)
|
|
: [...withoutAll, typeId];
|
|
});
|
|
}
|
|
|
|
function insertExample(example) {
|
|
setInstructions((current) => {
|
|
const trimmed = current.trim();
|
|
const addition = example.toLowerCase();
|
|
if (!trimmed) return `Hand off when there is a ${addition}.`;
|
|
if (trimmed.toLowerCase().includes(addition)) return current;
|
|
return `${trimmed.replace(/\.$/, "")}, or when there is a ${addition}.`;
|
|
});
|
|
}
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div className="handoff-drawer-backdrop" role="presentation" onClick={onClose}>
|
|
<aside
|
|
className="handoff-drawer"
|
|
role="dialog"
|
|
aria-labelledby="handoff-drawer-title"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<header className="handoff-drawer-head">
|
|
<div>
|
|
<h2 id="handoff-drawer-title">{rule ? "Edit Human Handoff" : "Add Human Handoff"}</h2>
|
|
<p>Choose who should receive emails that need human attention and define when AGMail should hand them off.</p>
|
|
</div>
|
|
<button type="button" className="handoff-drawer-close" aria-label="Close" onClick={onClose}>
|
|
<X size={18} strokeWidth={1.75} />
|
|
</button>
|
|
</header>
|
|
|
|
<div className="handoff-drawer-body">
|
|
<section className="handoff-form-section">
|
|
<h3>Rule</h3>
|
|
<label className="handoff-field-label" htmlFor="handoff-rule-name">Rule name</label>
|
|
<input
|
|
id="handoff-rule-name"
|
|
className="handoff-text-input"
|
|
value={ruleName}
|
|
maxLength={120}
|
|
placeholder="Finance approvals"
|
|
onChange={(event) => setRuleName(event.target.value)}
|
|
/>
|
|
<label className="handoff-field-label" htmlFor="handoff-rule-priority">Priority</label>
|
|
<input
|
|
id="handoff-rule-priority"
|
|
className="handoff-text-input"
|
|
type="number"
|
|
min="0"
|
|
max="10000"
|
|
value={priority}
|
|
onChange={(event) => setPriority(Number(event.target.value) || 0)}
|
|
/>
|
|
<p className="handoff-helper">When multiple rules match, the lowest priority number wins.</p>
|
|
</section>
|
|
|
|
<section className="handoff-form-section">
|
|
<h3>Human owner</h3>
|
|
<label className="handoff-field-label" htmlFor="handoff-owner-email">Owner email address</label>
|
|
<div className="handoff-email-input">
|
|
<Mail size={16} strokeWidth={1.75} />
|
|
<input
|
|
id="handoff-owner-email"
|
|
type="email"
|
|
value={ownerEmail}
|
|
placeholder="you@company.com"
|
|
onChange={(event) => setOwnerEmail(event.target.value)}
|
|
/>
|
|
</div>
|
|
<p className="handoff-helper">This is the human owner behind this agent. Emails matching your handoff rules will be sent here.</p>
|
|
{ownerEmail.includes("@") && (
|
|
<div className="handoff-owner-preview">
|
|
<span className="handoff-owner-avatar" aria-hidden="true">
|
|
<UserRound size={16} strokeWidth={1.75} />
|
|
</span>
|
|
<div>
|
|
<strong>{ownerEmail.split("@")[0].replace(/[._-]/g, " ")}</strong>
|
|
<small>{ownerEmail}</small>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="handoff-form-section">
|
|
<h3>Hand off emails with these labels</h3>
|
|
<p className="handoff-helper">
|
|
These are content-based handoff categories, not inbox tabs. Gentic infers them from
|
|
subject and body text. Choose <strong>All mail</strong> to hand off every incoming email.
|
|
</p>
|
|
<div className="handoff-label-toolbar">
|
|
<input
|
|
type="search"
|
|
placeholder="Search labels…"
|
|
value={labelQuery}
|
|
onChange={(event) => setLabelQuery(event.target.value)}
|
|
/>
|
|
<button type="button" onClick={() => setLabels([ALL_MAIL_LABEL])}>All mail</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setLabels(HANDOFF_LABELS.filter((label) => label !== ALL_MAIL_LABEL))}
|
|
>
|
|
Select categories
|
|
</button>
|
|
</div>
|
|
<div className="handoff-label-grid">
|
|
{filteredLabels.map((label) => (
|
|
<label key={label} className="handoff-check-pill">
|
|
<input
|
|
type="checkbox"
|
|
checked={labels.includes(label)}
|
|
onChange={() => toggleLabel(label)}
|
|
/>
|
|
<span>{label}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="handoff-form-section">
|
|
<h3>Hand off these types of email</h3>
|
|
<p className="handoff-helper">
|
|
Types are also inferred from email content. Choose <strong>All types</strong> if you
|
|
do not want to restrict by category.
|
|
</p>
|
|
<div className="handoff-type-grid">
|
|
{EMAIL_TYPE_OPTIONS.map((type) => (
|
|
<label key={type.id} className={`handoff-type-card${emailTypes.includes(type.id) ? " selected" : ""}`}>
|
|
<input
|
|
type="checkbox"
|
|
checked={emailTypes.includes(type.id)}
|
|
onChange={() => toggleEmailType(type.id)}
|
|
/>
|
|
<strong>{type.title}</strong>
|
|
<span>{type.description}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="handoff-form-section">
|
|
<h3>When should the agent hand off?</h3>
|
|
<p className="handoff-helper">Write this naturally, just as you would instruct your agent.</p>
|
|
<textarea
|
|
className="handoff-instructions"
|
|
rows={5}
|
|
value={instructions}
|
|
placeholder="e.g. Hand off when a sender asks to speak with me, when a payment or approval requires my decision, or whenever the agent is not confident it should respond on my behalf."
|
|
onChange={(event) => setInstructions(event.target.value)}
|
|
/>
|
|
<p className="handoff-helper">Be specific about intent, urgency, topics, amounts, approvals or anything that should require your attention.</p>
|
|
<div className="handoff-examples">
|
|
<span>Examples:</span>
|
|
{INSTRUCTION_EXAMPLES.map((example) => (
|
|
<button key={example} type="button" onClick={() => insertExample(example)}>
|
|
{example}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="handoff-summary-card">
|
|
<div className="handoff-summary-title">
|
|
<AgentSpark />
|
|
<span>Handoff behavior</span>
|
|
</div>
|
|
<p>{summary}</p>
|
|
</section>
|
|
|
|
<section className="handoff-form-section">
|
|
<h3>Continue the conversation from my inbox</h3>
|
|
<ToggleSwitch
|
|
label="Allow human replies"
|
|
checked={allowReplies}
|
|
onChange={setAllowReplies}
|
|
/>
|
|
<p className="handoff-helper">
|
|
A reply from this Human Owner is stored in Handoff Inbox and relayed through the
|
|
original Gentic address. Their private address is never exposed.
|
|
</p>
|
|
</section>
|
|
|
|
<section className="handoff-form-section">
|
|
<ToggleSwitch label="Human Handoff active" checked={active} onChange={setActive} />
|
|
<p className="handoff-helper">When disabled, your agent will continue receiving email but will no longer hand new conversations to the human owner.</p>
|
|
</section>
|
|
</div>
|
|
|
|
<footer className="handoff-drawer-foot">
|
|
<button type="button" className="handoff-btn subtle" onClick={onClose}>Cancel</button>
|
|
<button
|
|
type="button"
|
|
className="handoff-btn subtle"
|
|
onClick={() => onTest?.({
|
|
owner_email: ownerEmail,
|
|
labels,
|
|
email_types: emailTypes,
|
|
instructions,
|
|
})}
|
|
>
|
|
<AgentSpark /> Test Handoff
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="handoff-btn primary"
|
|
disabled={saving || !ruleName.trim() || !ownerEmail.trim()}
|
|
onClick={() => onSave({
|
|
name: ruleName.trim(),
|
|
priority,
|
|
owner_email: ownerEmail.trim(),
|
|
labels,
|
|
email_types: emailTypes,
|
|
instructions: instructions.trim(),
|
|
allow_replies: allowReplies,
|
|
reply_as: "agent",
|
|
active,
|
|
})}
|
|
>
|
|
{rule ? "Save Human Handoff" : "Create handoff"}
|
|
</button>
|
|
</footer>
|
|
</aside>
|
|
</div>
|
|
);
|
|
}
|