human hand off
This commit is contained in:
parent
d0fee5c8f7
commit
7af776196a
34 changed files with 2941 additions and 195 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>“{conversation.linked_external.subject}”</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>
|
||||
|
|
|
|||
|
|
@ -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">“{item.snippet}”</p>}
|
||||
</div>
|
||||
<div className="handoff-inbox-row-meta">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'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,
|
||||
})}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue