human hand off

This commit is contained in:
debu-2 2026-10-06 07:12:55 +05:30
parent d0fee5c8f7
commit 7af776196a
34 changed files with 2941 additions and 195 deletions

View file

@ -145,6 +145,23 @@ export const deleteHandoffRule = (ruleId, token) =>
request(`/agent/handoff/${encodeURIComponent(ruleId)}`, { method: "DELETE", token });
export const testHandoffRule = (payload, token) =>
request("/agent/handoff/test", { method: "POST", payload, token });
export const reorderHandoffRules = (ruleIds, token) =>
request("/agent/handoff/reorder", {
method: "PUT",
payload: { rule_ids: ruleIds },
token,
});
export const listHandoffConversations = (mailbox, token) =>
request(`/agent/handoff/conversations?mailbox=${encodeURIComponent(mailbox)}`, { token });
export const getHandoffConversation = (handoffId, token) =>
request(`/agent/handoff/conversations/${encodeURIComponent(handoffId)}`, { token });
export const getHandoffConversationCounts = (token) =>
request("/agent/handoff/conversations/counts", { token });
export const retryHandoffDelivery = (jobId, token) =>
request(`/agent/handoff/deliveries/${encodeURIComponent(jobId)}/retry`, {
method: "POST",
token,
});
export const listStickyNotes = (token) =>
request("/sticky-notes", { token });

View file

@ -115,6 +115,7 @@ export default function DashboardPage() {
const [mailRefreshKey, setMailRefreshKey] = useState(0);
const [lens, setLens] = useState("all");
const [agentNav, setAgentNav] = useState(null);
const [openThreadId, setOpenThreadId] = useState(null);
const [labelsExpanded, setLabelsExpanded] = useState(false);
const [companyActive, setCompanyActive] = useState(null);
const [mustChangePassword, setMustChangePassword] = useState(false);
@ -211,6 +212,14 @@ export default function DashboardPage() {
triggerOpenCompose(context);
}
function openExternalThread(threadId) {
setCompanyActive(null);
setActive("Inbox");
setLens("all");
setAgentNav(null);
setOpenThreadId(threadId);
}
function emailSent() {
setActive("Outbox");
setMailRefreshKey((key) => key + 1);
@ -413,7 +422,7 @@ export default function DashboardPage() {
</div>
)}
{!companyActive && active === "Inbox" && agentNav === "handoff" && (
<HumanHandoff account={account} />
<HumanHandoff account={account} onOpenExternal={openExternalThread} />
)}
{!companyActive && active === "Inbox" && agentNav !== "handoff" && (
<MailboxView
@ -423,6 +432,7 @@ export default function DashboardPage() {
account={account}
lens={lens}
agentNav={agentNav}
initialThreadId={openThreadId}
onLogout={logout}
onOpenSettings={() => selectMailItem("Settings")}
onCompose={openCompose}

View file

@ -1,6 +1,6 @@
import { ArrowLeft } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { getHandoffConversation } from "../api";
import { getHandoffConversation, retryHandoffDelivery } from "../api";
function HandoffMessageBody({ message }) {
const [showQuoted, setShowQuoted] = useState(false);
@ -33,11 +33,12 @@ function formatTime(dateValue) {
});
}
export default function HandoffConversation({ handoffId, onBack }) {
export default function HandoffConversation({ handoffId, onBack, onOpenExternal }) {
const token = sessionStorage.getItem("agmail_access_token");
const [conversation, setConversation] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [retryingId, setRetryingId] = useState(null);
const load = useCallback(async () => {
setLoading(true);
@ -56,6 +57,19 @@ export default function HandoffConversation({ handoffId, onBack }) {
load();
}, [load]);
async function retryDelivery(jobId) {
setRetryingId(jobId);
setError("");
try {
await retryHandoffDelivery(jobId, token);
await load();
} catch (err) {
setError(err.message);
} finally {
setRetryingId(null);
}
}
if (loading) {
return <p className="people-table-empty">Loading conversation…</p>;
}
@ -71,11 +85,20 @@ export default function HandoffConversation({ handoffId, onBack }) {
</button>
<header className="handoff-conversation-head">
<h2>{conversation.external_subject || "(No subject)"}</h2>
<h2>{conversation.rule_name} · {conversation.external_subject || "(No subject)"}</h2>
<div className="handoff-linked-external">
<span>Linked conversation:</span>
<strong>{conversation.linked_external.sender}</strong>
<em>&ldquo;{conversation.linked_external.subject}&rdquo;</em>
{onOpenExternal && (
<button
type="button"
className="handoff-btn subtle"
onClick={() => onOpenExternal(conversation.linked_external.thread_id)}
>
Open external thread
</button>
)}
</div>
</header>
@ -86,23 +109,38 @@ export default function HandoffConversation({ handoffId, onBack }) {
<strong>
{message.role === "human"
? "HUMAN OWNER"
: message.role === "customer"
? "CUSTOMER"
: "AGENT"}
: "GENTIC → HUMAN"}
</strong>
<time>{formatTime(message.event_at)}</time>
</header>
<HandoffMessageBody message={message} />
{message.role === "human" && message.relay_status === "SENT" && (
<p className="handoff-relay-status handoff-relay-status-sent">
✓ Sent to customer
Sent through Gentic
</p>
)}
{message.role === "human" && message.relay_status === "FAILED" && (
{message.role === "human"
&& ["FAILED", "BLOCKED"].includes(message.relay_status) && (
<p className="handoff-relay-status handoff-relay-status-failed">
⚠ Could not send to customer
Could not safely relay this reply
</p>
)}
{message.delivery_status === "AMBIGUOUS" && (
<p className="handoff-relay-status handoff-relay-status-failed">
Delivery acknowledgement was lost — not retried to prevent duplicates
</p>
)}
{["FAILED", "AMBIGUOUS"].includes(message.delivery_status)
&& message.delivery_job_id && (
<button
type="button"
className="handoff-btn subtle"
disabled={retryingId === message.delivery_job_id}
onClick={() => retryDelivery(message.delivery_job_id)}
>
{retryingId === message.delivery_job_id ? "Retrying…" : "Retry delivery"}
</button>
)}
</article>
))}
</div>

View file

@ -17,7 +17,7 @@ function formatRelativeTime(dateValue) {
return date.toLocaleDateString();
}
export default function HandoffInbox({ onSelect }) {
export default function HandoffInbox({ mailbox = "handoff_inbox", onSelect }) {
const token = sessionStorage.getItem("agmail_access_token");
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
@ -27,14 +27,14 @@ export default function HandoffInbox({ onSelect }) {
setLoading(true);
setError("");
try {
const data = await listHandoffConversations(token);
const data = await listHandoffConversations(mailbox, token);
setItems(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [token]);
}, [mailbox, token]);
useEffect(() => {
load();
@ -42,7 +42,11 @@ export default function HandoffInbox({ onSelect }) {
return (
<div className="handoff-inbox">
<p className="handoff-inbox-lead">Agent ↔ Human Owner conversations</p>
<p className="handoff-inbox-lead">
{mailbox === "handoff_inbox"
? "Replies received from Human Owners"
: "Messages mirrored from Gentic to Human Owners"}
</p>
{error && <p className="form-error" role="alert">{error}</p>}
{loading ? (
<p className="people-table-empty">Loading handoffs…</p>
@ -51,7 +55,7 @@ export default function HandoffInbox({ onSelect }) {
<div className="human-handoff-empty-icon" aria-hidden="true">
<ArrowLeftRight size={24} strokeWidth={1.75} />
</div>
<h2>No handoff conversations yet</h2>
<h2>{mailbox === "handoff_inbox" ? "No Human replies yet" : "No mirrored messages yet"}</h2>
<p>
When the Agent hands off a customer conversation, it appears here as a private
Agent ↔ Human thread — separate from your normal Inbox.
@ -67,8 +71,10 @@ export default function HandoffInbox({ onSelect }) {
onClick={() => onSelect(item.id)}
>
<div className="handoff-inbox-row-main">
<strong>{item.external_subject || "(No subject)"}</strong>
<span className="handoff-inbox-row-party">Human Owner</span>
<strong>{item.rule_name} · {item.external_subject || "(No subject)"}</strong>
<span className="handoff-inbox-row-party">
{item.external_sender} ↔ {item.human_email}
</span>
{item.snippet && <p className="handoff-inbox-row-snippet">&ldquo;{item.snippet}&rdquo;</p>}
</div>
<div className="handoff-inbox-row-meta">

View file

@ -1,9 +1,10 @@
import { Info, MoreHorizontal, Plus, UserRound } from "lucide-react";
import { ArrowDown, ArrowUp, MoreHorizontal, Plus, UserRound } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
createHandoffRule,
deleteHandoffRule,
listHandoffRules,
reorderHandoffRules,
testHandoffRule,
updateHandoffRule,
} from "../api";
@ -93,6 +94,22 @@ export default function HandoffSettings() {
}
}
async function moveRule(ruleId, offset) {
const index = rules.findIndex((rule) => rule.id === ruleId);
const target = index + offset;
if (index < 0 || target < 0 || target >= rules.length) return;
const reordered = [...rules];
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
setRules(reordered);
try {
const updated = await reorderHandoffRules(reordered.map((rule) => rule.id), token);
setRules(updated);
} catch (err) {
setError(err.message);
await loadRules();
}
}
function openTest(draft) {
setTestDraft(draft);
setTestSubject("");
@ -128,14 +145,6 @@ export default function HandoffSettings() {
return (
<div className="handoff-settings">
<div className="handoff-settings-notice">
<Info size={14} strokeWidth={2} className="handoff-settings-notice-icon" aria-hidden="true" />
<div>
<strong>Configuration only</strong>
<p>Handoff rules are saved as configuration only. Email routing remains unchanged.</p>
</div>
</div>
<div className="handoff-settings-toolbar">
<button type="button" className="handoff-btn primary" onClick={openCreate}>
<Plus size={16} strokeWidth={1.75} /> Add handoff rule
@ -166,12 +175,32 @@ export default function HandoffSettings() {
<span className="human-handoff-card-avatar" aria-hidden="true">
<UserRound size={17} strokeWidth={1.75} />
</span>
<strong className="human-handoff-card-name">{rule.owner_name}</strong>
<strong className="human-handoff-card-name">{rule.name}</strong>
<span className={`human-handoff-status${rule.active ? " active" : ""}`}>
{rule.active ? "Active" : "Paused"}
</span>
<small className="human-handoff-card-email">{rule.owner_email}</small>
<small className="human-handoff-card-email">
Priority {rule.priority} · {rule.owner_name} · {rule.owner_email}
</small>
<div className="human-handoff-card-actions">
<button
type="button"
className="handoff-btn icon quiet"
aria-label="Move rule up"
disabled={rules[0]?.id === rule.id}
onClick={() => moveRule(rule.id, -1)}
>
<ArrowUp size={15} strokeWidth={1.75} />
</button>
<button
type="button"
className="handoff-btn icon quiet"
aria-label="Move rule down"
disabled={rules[rules.length - 1]?.id === rule.id}
onClick={() => moveRule(rule.id, 1)}
>
<ArrowDown size={15} strokeWidth={1.75} />
</button>
<button type="button" className="handoff-btn subtle" onClick={() => openEdit(rule)}>
Edit
</button>

View file

@ -1,15 +1,62 @@
import { useState } from "react";
import HandoffConversation from "./HandoffConversation";
import HandoffInbox from "./HandoffInbox";
import HandoffSettings from "./HandoffSettings";
export default function HumanHandoff() {
export default function HumanHandoff({ onOpenExternal }) {
const [tab, setTab] = useState("inbox");
const [selectedHandoffId, setSelectedHandoffId] = useState(null);
function selectTab(value) {
setTab(value);
setSelectedHandoffId(null);
}
return (
<section className="human-handoff-page">
<header className="human-handoff-head">
<div>
<h1>Human Handoff</h1>
<p>Configure when your Agent should involve a Human Owner.</p>
<p>Private mirrored conversations between your Agent and Human Owners.</p>
</div>
</header>
<HandoffSettings />
<nav className="human-handoff-tabs" aria-label="Human Handoff">
<button
type="button"
className={tab === "inbox" ? "active" : ""}
onClick={() => selectTab("inbox")}
>
Inbox
</button>
<button
type="button"
className={tab === "outbox" ? "active" : ""}
onClick={() => selectTab("outbox")}
>
Outbox
</button>
<button
type="button"
className={tab === "rules" ? "active" : ""}
onClick={() => selectTab("rules")}
>
Rules
</button>
</nav>
{tab === "rules" ? (
<HandoffSettings />
) : selectedHandoffId ? (
<HandoffConversation
handoffId={selectedHandoffId}
onBack={() => setSelectedHandoffId(null)}
onOpenExternal={onOpenExternal}
/>
) : (
<HandoffInbox
mailbox={tab === "inbox" ? "handoff_inbox" : "handoff_outbox"}
onSelect={setSelectedHandoffId}
/>
)}
</section>
);
}

View file

@ -28,23 +28,25 @@ export default function HumanHandoffDrawer({
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 [replyAs, setReplyAs] = useState("agent");
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);
setReplyAs(rule?.reply_as || "agent");
setActive(rule?.active ?? true);
setLabelQuery("");
}, [open, rule]);
@ -124,6 +126,30 @@ export default function HumanHandoffDrawer({
</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>
@ -244,31 +270,9 @@ export default function HumanHandoffDrawer({
onChange={setAllowReplies}
/>
<p className="handoff-helper">
Saved for future use. Human reply handling is not active yet — all email uses the normal Gentic inbox and outbox.
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>
{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&apos;s email address</span>
</label>
</div>
)}
</section>
<section className="handoff-form-section">
@ -294,14 +298,16 @@ export default function HumanHandoffDrawer({
<button
type="button"
className="handoff-btn primary"
disabled={saving || !ownerEmail.trim()}
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: replyAs,
reply_as: "agent",
active,
})}
>

View file

@ -103,6 +103,7 @@ export default function MailboxView({
account,
lens = "all",
agentNav = null,
initialThreadId = null,
onLogout,
onOpenSettings,
onCompose,
@ -163,6 +164,34 @@ export default function MailboxView({
return () => window.clearTimeout(timeout);
}, [load, refreshKey]);
useEffect(() => {
if (!initialThreadId) return undefined;
let cancelled = false;
setSelected(initialThreadId);
setDetailStatus({ loading: true, error: "", busy: false });
getThread(initialThreadId, token)
.then((thread) => {
if (cancelled) return;
const messages = thread.messages || [];
const latest = messages[messages.length - 1];
setThreadDetail(thread);
setDetail(latest || {
id: thread.latest_message_id,
thread_id: initialThreadId,
subject: thread.subject,
snippet: thread.snippet,
});
setDetailStatus({ loading: false, error: "", busy: false });
})
.catch((error) => {
if (cancelled) return;
setDetailStatus({ loading: false, error: error.message, busy: false });
});
return () => {
cancelled = true;
};
}, [initialThreadId, token]);
useEffect(() => {
function openPalette(event) {
const target = event.target;

View file

@ -534,6 +534,7 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.human-handoff-tabs { margin-top:16px; display:inline-flex; gap:6px; padding:4px; border:1px solid #ece9f2; border-radius:10px; background:#faf9fc; }
.human-handoff-tabs button { height:32px; padding:0 14px; border:0; border-radius:8px; background:transparent; color:#6f6b7c; font-size:12px; font-weight:600; }
.human-handoff-tabs button.active { background:#fff; color:#171424; box-shadow:0 1px 4px rgba(20,18,30,0.08); }
.handoff-inbox, .handoff-settings, .handoff-conversation { margin-top:16px; }
.handoff-inbox-lead { margin:0 0 14px; color:#77758a; font-size:12px; }
.handoff-inbox-list { display:grid; gap:10px; }
.handoff-inbox-row { width:100%; padding:16px 18px; display:grid; grid-template-columns:1fr auto; gap:16px; align-items:start; border:1px solid #ece9f2; border-radius:12px; background:#fff; text-align:left; cursor:pointer; box-shadow:0 6px 20px rgba(20,18,30,0.03); }

View file

@ -95,6 +95,7 @@ export function buildRuleCardChips(rule) {
} else {
chips.push("All types");
}
if (rule.name) chips.unshift(rule.name);
chips.push(rule.allow_replies ? "Replies enabled" : "View only");
return chips;
}