human handoffs
This commit is contained in:
parent
79188e0f59
commit
36a55930f9
35 changed files with 4039 additions and 139 deletions
292
frontend/src/components/HumanHandoffDrawer.jsx
Normal file
292
frontend/src/components/HumanHandoffDrawer.jsx
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue