human handoffs

This commit is contained in:
debu-2 2026-10-06 01:51:23 +05:30
parent 79188e0f59
commit 36a55930f9
35 changed files with 4039 additions and 139 deletions

View file

@ -0,0 +1,294 @@
import { MoreHorizontal, Plus, UserRound } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
createHandoffRule,
deleteHandoffRule,
listHandoffRules,
testHandoffRule,
updateHandoffRule,
} from "../api";
import AgentSpark from "./AgentSpark";
import HumanHandoffDrawer from "./HumanHandoffDrawer";
import { buildRuleCardSummary, EMAIL_TYPE_OPTIONS } from "../utils/handoffUtils";
function typeLabel(id) {
return EMAIL_TYPE_OPTIONS.find((item) => item.id === id)?.title || id;
}
export default function HandoffSettings() {
const token = sessionStorage.getItem("agmail_access_token");
const [rules, setRules] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [drawerOpen, setDrawerOpen] = useState(false);
const [editingRule, setEditingRule] = useState(null);
const [menuOpenId, setMenuOpenId] = useState(null);
const [testOpen, setTestOpen] = useState(false);
const [testDraft, setTestDraft] = useState(null);
const [testSubject, setTestSubject] = useState("");
const [testBody, setTestBody] = useState("");
const [testResult, setTestResult] = useState(null);
const [testing, setTesting] = useState(false);
const loadRules = useCallback(async () => {
setLoading(true);
setError("");
try {
const data = await listHandoffRules(token);
setRules(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
loadRules();
}, [loadRules]);
function openCreate() {
setEditingRule(null);
setDrawerOpen(true);
}
function openEdit(rule) {
setEditingRule(rule);
setDrawerOpen(true);
setMenuOpenId(null);
}
async function handleSave(payload) {
setSaving(true);
setError("");
try {
if (editingRule) {
await updateHandoffRule(editingRule.id, payload, token);
setNotice("Human Handoff updated.");
} else {
await createHandoffRule(payload, token);
setNotice("Human Handoff created.");
}
setDrawerOpen(false);
setEditingRule(null);
await loadRules();
window.setTimeout(() => setNotice(""), 2500);
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
}
async function handleDelete(rule) {
if (!window.confirm("Delete this handoff rule?")) return;
try {
await deleteHandoffRule(rule.id, token);
setMenuOpenId(null);
await loadRules();
} catch (err) {
setError(err.message);
}
}
function openTest(draft) {
setTestDraft(draft);
setTestSubject("");
setTestBody("");
setTestResult(null);
setTestOpen(true);
}
async function runTest() {
if (!testDraft) return;
setTesting(true);
setError("");
try {
const result = await testHandoffRule({
rule_id: editingRule?.id,
owner_email: testDraft.owner_email,
labels: testDraft.labels,
email_types: testDraft.email_types,
instructions: testDraft.instructions,
email: {
subject: testSubject,
snippet: testBody,
body_text: testBody,
},
}, token);
setTestResult(result);
} catch (err) {
setError(err.message);
} finally {
setTesting(false);
}
}
return (
<div className="handoff-settings">
<div className="handoff-settings-intro">
<p>
<strong>Your Agent handles email. You step in when needed.</strong>
</p>
<p>
Human Handoff sends selected conversations to the Agent&apos;s Human Owner. The owner can
reply from their normal inbox, and Gentic carries that response back to the original sender
without exposing or mixing the private Agent ↔ Human conversation.
</p>
<div className="handoff-flow-diagram" aria-hidden="true">
<span>External Sender</span>
<span className="handoff-flow-arrow">↕</span>
<span>Gentic Agent</span>
<span className="handoff-flow-arrow">↕</span>
<span>Human Owner</span>
</div>
<small>
The external conversation and Human Handoff remain separate and independently threaded.
</small>
</div>
<div className="handoff-settings-toolbar">
<button type="button" className="handoff-btn primary" onClick={openCreate}>
<Plus size={16} strokeWidth={1.75} /> Add handoff rule
</button>
</div>
{error && <p className="form-error" role="alert">{error}</p>}
{notice && <p className="security-notice" role="status">{notice}</p>}
{loading ? (
<p className="people-table-empty">Loading settings…</p>
) : rules.length === 0 ? (
<div className="human-handoff-empty">
<div className="human-handoff-empty-icon" aria-hidden="true">
<UserRound size={24} strokeWidth={1.75} />
</div>
<h2>No handoff rules yet</h2>
<p>
Configure when your Agent should hand conversations to the Human Owner.
Handoff transport messages stay out of your normal Inbox and Sent folders.
</p>
<button type="button" className="handoff-btn primary" onClick={openCreate}>
<Plus size={16} strokeWidth={1.75} /> Add handoff rule
</button>
</div>
) : (
<div className="human-handoff-list">
{rules.map((rule) => (
<article key={rule.id} className="human-handoff-card">
<div className="human-handoff-card-top">
<span className="human-handoff-card-avatar" aria-hidden="true">
<UserRound size={18} strokeWidth={1.75} />
</span>
<div className="human-handoff-card-meta">
<strong>{rule.owner_name}</strong>
<small>{rule.owner_email}</small>
</div>
<span className={`human-handoff-status${rule.active ? " active" : ""}`}>
{rule.active ? "Active" : "Paused"}
</span>
</div>
<p className="human-handoff-card-summary">{buildRuleCardSummary(rule)}</p>
{rule.instructions && (
<blockquote className="human-handoff-card-quote">&ldquo;{rule.instructions}&rdquo;</blockquote>
)}
<div className="human-handoff-card-actions">
<button type="button" className="handoff-btn subtle" onClick={() => openEdit(rule)}>Edit</button>
<div className="handoff-card-menu-wrap">
<button
type="button"
className="handoff-btn icon"
aria-label="More actions"
onClick={() => setMenuOpenId((current) => (current === rule.id ? null : rule.id))}
>
<MoreHorizontal size={16} strokeWidth={1.75} />
</button>
{menuOpenId === rule.id && (
<div className="handoff-card-menu">
<button type="button" onClick={() => openTest({
owner_email: rule.owner_email,
labels: rule.labels,
email_types: rule.email_types,
instructions: rule.instructions,
})}>Test with an email</button>
<button type="button" onClick={() => handleDelete(rule)}>Delete</button>
</div>
)}
</div>
</div>
</article>
))}
</div>
)}
<HumanHandoffDrawer
open={drawerOpen}
rule={editingRule}
saving={saving}
onClose={() => { setDrawerOpen(false); setEditingRule(null); }}
onSave={handleSave}
onTest={(draft) => openTest(draft)}
/>
{testOpen && (
<div className="handoff-drawer-backdrop" role="presentation" onClick={() => setTestOpen(false)}>
<aside className="handoff-test-modal" role="dialog" aria-labelledby="handoff-test-title" onClick={(e) => e.stopPropagation()}>
<header className="handoff-drawer-head">
<div>
<h2 id="handoff-test-title">Test Human Handoff</h2>
<p>Check whether your current rules would cause a particular email to be handed to you.</p>
</div>
</header>
<div className="handoff-drawer-body">
<label className="handoff-field-label" htmlFor="handoff-test-subject">Subject</label>
<input
id="handoff-test-subject"
className="handoff-text-input"
value={testSubject}
onChange={(event) => setTestSubject(event.target.value)}
placeholder="Payment approval required"
/>
<label className="handoff-field-label" htmlFor="handoff-test-body">Email content</label>
<textarea
id="handoff-test-body"
className="handoff-instructions"
rows={6}
value={testBody}
onChange={(event) => setTestBody(event.target.value)}
placeholder="Paste or describe the email you want to test…"
/>
{testResult && (
<div className="handoff-test-result">
<h3>Handoff test</h3>
<ul>
<li className={testResult.label_matched ? "pass" : "fail"}>
{testResult.label_matched ? "✓" : "✕"} Label matched
{testResult.matched_labels?.length ? ` — ${testResult.matched_labels.join(", ")}` : ""}
</li>
<li className={testResult.type_matched ? "pass" : "fail"}>
{testResult.type_matched ? "✓" : "✕"} Type matched
{testResult.matched_types?.length ? ` — ${testResult.matched_types.map(typeLabel).join(", ")}` : ""}
</li>
<li className={testResult.instruction_matched ? "pass" : "fail"}>
{testResult.instruction_matched ? "✓" : "✕"} Handoff instruction matched
</li>
</ul>
<p><strong>Result:</strong> {testResult.result_path}</p>
<p><strong>Reason:</strong> {testResult.reason}</p>
</div>
)}
</div>
<footer className="handoff-drawer-foot">
<button type="button" className="handoff-btn subtle" onClick={() => setTestOpen(false)}>Close</button>
<button type="button" className="handoff-btn primary" disabled={testing} onClick={runTest}>
<AgentSpark /> {testing ? "Testing…" : "Run test"}
</button>
</footer>
</aside>
</div>
)}
</div>
);
}