292 lines
11 KiB
JavaScript
292 lines
11 KiB
JavaScript
import { Mail, UserRound, X } from "lucide-react";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import AgentSpark from "./AgentSpark";
|
|
import {
|
|
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 [ownerEmail, setOwnerEmail] = useState("");
|
|
const [labels, setLabels] = useState([]);
|
|
const [emailTypes, setEmailTypes] = useState([]);
|
|
const [instructions, setInstructions] = useState("");
|
|
const [allowReplies, setAllowReplies] = useState(true);
|
|
const [replyAs, setReplyAs] = useState("agent");
|
|
const [active, setActive] = useState(true);
|
|
const [labelQuery, setLabelQuery] = useState("");
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setOwnerEmail(rule?.owner_email || "");
|
|
setLabels(rule?.labels || []);
|
|
setEmailTypes(rule?.email_types || []);
|
|
setInstructions(rule?.instructions || "");
|
|
setAllowReplies(rule?.allow_replies ?? true);
|
|
setReplyAs(rule?.reply_as || "agent");
|
|
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) {
|
|
setLabels((current) => (
|
|
current.includes(label)
|
|
? current.filter((item) => item !== label)
|
|
: [...current, label]
|
|
));
|
|
}
|
|
|
|
function toggleEmailType(typeId) {
|
|
setEmailTypes((current) => (
|
|
current.includes(typeId)
|
|
? current.filter((item) => item !== typeId)
|
|
: [...current, 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>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">An email matching any selected label will be eligible for handoff.</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([...HANDOFF_LABELS])}>Select all</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">Emails matching any selected type can be considered for handoff.</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">
|
|
When you reply to a handed-off email from your regular inbox, AGMail will recognize your response and continue the original conversation through the agent's email address.
|
|
</p>
|
|
{allowReplies && (
|
|
<div className="handoff-reply-info">
|
|
<strong>How it works</strong>
|
|
<p>Email arrives → Agent handles it. If human attention is required → Human Handoff → Your inbox. You reply normally → AGMail → Original conversation.</p>
|
|
</div>
|
|
)}
|
|
{allowReplies && (
|
|
<div className="handoff-reply-as">
|
|
<strong>Replies will be sent as</strong>
|
|
<label>
|
|
<input
|
|
type="radio"
|
|
name="reply-as"
|
|
checked={replyAs === "agent"}
|
|
onChange={() => setReplyAs("agent")}
|
|
/>
|
|
<span>Original AGMail address</span>
|
|
</label>
|
|
<label>
|
|
<input
|
|
type="radio"
|
|
name="reply-as"
|
|
checked={replyAs === "human"}
|
|
onChange={() => setReplyAs("human")}
|
|
/>
|
|
<span>Human's email address</span>
|
|
</label>
|
|
</div>
|
|
)}
|
|
</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 || !ownerEmail.trim()}
|
|
onClick={() => onSave({
|
|
owner_email: ownerEmail.trim(),
|
|
labels,
|
|
email_types: emailTypes,
|
|
instructions: instructions.trim(),
|
|
allow_replies: allowReplies,
|
|
reply_as: replyAs,
|
|
active,
|
|
})}
|
|
>
|
|
{rule ? "Save Human Handoff" : "Create handoff"}
|
|
</button>
|
|
</footer>
|
|
</aside>
|
|
</div>
|
|
);
|
|
}
|