human handoffs
This commit is contained in:
parent
79188e0f59
commit
36a55930f9
35 changed files with 4039 additions and 139 deletions
|
|
@ -101,6 +101,10 @@ function emailQuery(parameters = {}) {
|
|||
|
||||
export const listEmails = (parameters, token) =>
|
||||
request(`/emails${emailQuery(parameters)}`, { token });
|
||||
export const listThreads = (parameters, token) =>
|
||||
request(`/threads${emailQuery(parameters)}`, { token });
|
||||
export const getThread = (threadId, token) =>
|
||||
request(`/threads/${encodeURIComponent(threadId)}`, { token });
|
||||
export const getEmail = (emailId, token) =>
|
||||
request(`/emails/${encodeURIComponent(emailId)}`, { token });
|
||||
export const setEmailRead = (emailId, isRead, token) =>
|
||||
|
|
@ -131,6 +135,21 @@ export const permanentlyDeleteEmail = (emailId, token) =>
|
|||
token,
|
||||
});
|
||||
|
||||
export const listHandoffRules = (token) =>
|
||||
request("/agent/handoff", { token });
|
||||
export const createHandoffRule = (payload, token) =>
|
||||
request("/agent/handoff", { method: "POST", payload, token });
|
||||
export const updateHandoffRule = (ruleId, payload, token) =>
|
||||
request(`/agent/handoff/${encodeURIComponent(ruleId)}`, { method: "PATCH", payload, token });
|
||||
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 listHandoffConversations = (token) =>
|
||||
request("/agent/handoff/conversations", { token });
|
||||
export const getHandoffConversation = (handoffId, token) =>
|
||||
request(`/agent/handoff/conversations/${encodeURIComponent(handoffId)}`, { token });
|
||||
|
||||
export const listStickyNotes = (token) =>
|
||||
request("/sticky-notes", { token });
|
||||
export const createStickyNote = (payload, token) =>
|
||||
|
|
|
|||
|
|
@ -111,10 +111,24 @@ function emailContextActions(email) {
|
|||
return actions.slice(0, 3);
|
||||
}
|
||||
|
||||
function threadContextSummary(thread) {
|
||||
const messages = thread?.messages || [];
|
||||
if (messages.length <= 1) return null;
|
||||
const senders = [...new Set(messages.map((message) => message.sender?.name || message.sender?.address).filter(Boolean))];
|
||||
const latest = messages[messages.length - 1];
|
||||
const snippet = (latest?.body_text || latest?.snippet || "").trim();
|
||||
const preview = snippet.split(/[.!?\n]/).find((part) => part.trim().length > 8)?.trim();
|
||||
if (preview) {
|
||||
return `${senders.join(" and ")} exchanged ${messages.length} messages. Latest: “${preview.length > 100 ? `${preview.slice(0, 97)}…` : preview}.”`;
|
||||
}
|
||||
return `${senders.join(" and ")} exchanged ${messages.length} messages in this conversation.`;
|
||||
}
|
||||
|
||||
export default function AgAgentPanel({
|
||||
account,
|
||||
emails,
|
||||
contextEmail,
|
||||
contextThread,
|
||||
focus,
|
||||
insight,
|
||||
loading = false,
|
||||
|
|
@ -132,6 +146,7 @@ export default function AgAgentPanel({
|
|||
const allMetricsZero = metricTotal(stats) === 0;
|
||||
const hasQuery = agentQuery.trim().length > 0;
|
||||
const emailContextMode = Boolean(contextEmail) && !insight;
|
||||
const threadContextMode = Boolean(contextThread?.messages?.length > 1) && !insight;
|
||||
const showOnboarding = !insight && !loading && mailboxEmpty && isBrandNewMailbox;
|
||||
const showQuietEmpty = !insight && !loading && mailboxEmpty && !isBrandNewMailbox;
|
||||
const showFullPanel = !insight && !loading && !mailboxEmpty && !emailContextMode;
|
||||
|
|
@ -187,7 +202,7 @@ export default function AgAgentPanel({
|
|||
{emailContextMode && (
|
||||
<section className="agent-email-context">
|
||||
<h3>Summary</h3>
|
||||
<p>{emailContextSummary(contextEmail)}</p>
|
||||
<p>{threadContextMode ? threadContextSummary(contextThread) : emailContextSummary(contextEmail)}</p>
|
||||
<h3>Action items</h3>
|
||||
<ul className="agent-email-action-list">
|
||||
{emailContextActions(contextEmail).map((item) => (
|
||||
|
|
|
|||
|
|
@ -51,7 +51,27 @@ export default function ComposeDock({ account, onSent, agentOpen = true }) {
|
|||
focusRef.current = windowId;
|
||||
}, []);
|
||||
|
||||
const openCompose = useCallback(() => {
|
||||
const openCompose = useCallback((context = null) => {
|
||||
if (context) {
|
||||
const id = `compose-${Date.now()}`;
|
||||
focusRef.current = id;
|
||||
const draft = {
|
||||
to: context.to || [],
|
||||
cc: context.cc || [],
|
||||
bcc: context.bcc || [],
|
||||
subject: context.subject || "",
|
||||
bodyHtml: context.bodyHtml || "",
|
||||
replyToMessageId: context.replyToMessageId || null,
|
||||
replyAll: Boolean(context.replyAll),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
localStorage.setItem(`agmail_draft_${id}`, JSON.stringify(draft));
|
||||
setWindows((current) => [
|
||||
{ id, minimized: false, expanded: false, focused: true, lastActive: Date.now() },
|
||||
...current,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
setWindows((current) => {
|
||||
const blank = current.find((window) => !window.minimized && isDraftEmpty(window.id));
|
||||
if (blank) {
|
||||
|
|
@ -250,6 +270,6 @@ export default function ComposeDock({ account, onSent, agentOpen = true }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function triggerOpenCompose() {
|
||||
window.agmailOpenCompose?.();
|
||||
export function triggerOpenCompose(context = null) {
|
||||
window.agmailOpenCompose?.(context);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,6 +196,8 @@ export default function ComposeModal({
|
|||
const [warnings, setWarnings] = useState([]);
|
||||
const [showEmoji, setShowEmoji] = useState(false);
|
||||
const [status, setStatus] = useState({ sending: false, error: "" });
|
||||
const [replyToMessageId, setReplyToMessageId] = useState(null);
|
||||
const [replyAll, setReplyAll] = useState(false);
|
||||
|
||||
const orgDomain = account.company_domain || account.email.split("@")[1];
|
||||
const draftKey = draftStorageKey(windowId || account.id);
|
||||
|
|
@ -216,7 +218,9 @@ export default function ComposeModal({
|
|||
], [account, fromAddress.email, orgDomain]);
|
||||
|
||||
const saveDraft = useCallback(() => {
|
||||
const payload = { to, cc, bcc, subject, bodyHtml, updatedAt: Date.now() };
|
||||
const payload = {
|
||||
to, cc, bcc, subject, bodyHtml, replyToMessageId, replyAll, updatedAt: Date.now(),
|
||||
};
|
||||
const empty = !subject && !bodyHtml && !to.length && !cc.length && !bcc.length;
|
||||
if (empty) {
|
||||
clearDraft(windowId || account.id);
|
||||
|
|
@ -243,6 +247,8 @@ export default function ComposeModal({
|
|||
setBcc(draft.bcc || []);
|
||||
setSubject(draft.subject || "");
|
||||
setBodyHtml(draft.bodyHtml || "");
|
||||
setReplyToMessageId(draft.replyToMessageId || null);
|
||||
setReplyAll(Boolean(draft.replyAll));
|
||||
setShowCc(Boolean(draft.cc?.length));
|
||||
setShowBcc(Boolean(draft.bcc?.length));
|
||||
if (editorRef.current && draft.bodyHtml) {
|
||||
|
|
@ -385,24 +391,26 @@ export default function ComposeModal({
|
|||
}
|
||||
setStatus({ sending: true, error: "" });
|
||||
try {
|
||||
await sendEmail(
|
||||
{
|
||||
recipients: recipientsToPayload(to),
|
||||
cc: recipientsToPayload(cc),
|
||||
bcc: recipientsToPayload(bcc),
|
||||
subject,
|
||||
body_text: plainTextFromHtml(bodyHtml),
|
||||
body_html: bodyHtml || null,
|
||||
},
|
||||
sessionStorage.getItem("agmail_access_token"),
|
||||
);
|
||||
const payload = {
|
||||
recipients: recipientsToPayload(to),
|
||||
cc: recipientsToPayload(cc),
|
||||
bcc: recipientsToPayload(bcc),
|
||||
subject,
|
||||
body_text: plainTextFromHtml(bodyHtml),
|
||||
body_html: bodyHtml || null,
|
||||
};
|
||||
if (replyToMessageId) {
|
||||
payload.reply_to_message_id = replyToMessageId;
|
||||
payload.reply_all = replyAll;
|
||||
}
|
||||
await sendEmail(payload, sessionStorage.getItem("agmail_access_token"));
|
||||
clearDraft(windowId || account.id);
|
||||
flashNotice("Message sent. Undo available for 5 seconds.");
|
||||
onSent?.();
|
||||
} catch (error) {
|
||||
setStatus({ sending: false, error: error.message });
|
||||
}
|
||||
}, [to, cc, bcc, subject, bodyHtml, windowId, account.id, onSent]);
|
||||
}, [to, cc, bcc, subject, bodyHtml, replyToMessageId, replyAll, windowId, account.id, onSent]);
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
LogOut,
|
||||
Mail,
|
||||
Menu,
|
||||
HandHelping,
|
||||
Plug,
|
||||
Send,
|
||||
Settings,
|
||||
|
|
@ -38,6 +39,7 @@ import CompanyOverview from "./CompanyOverview";
|
|||
import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup";
|
||||
import ChangePasswordModal from "./ChangePasswordModal";
|
||||
import ComposeDock, { triggerOpenCompose } from "./ComposeDock";
|
||||
import HumanHandoff from "./HumanHandoff";
|
||||
import MailboxView from "./MailboxView";
|
||||
|
||||
const mailItems = [
|
||||
|
|
@ -63,6 +65,7 @@ const agentItems = [
|
|||
[Sparkles, "Ask Agent", "ask"],
|
||||
[ListChecks, "Action Items", "actions"],
|
||||
[Sparkles, "Summaries", "summaries"],
|
||||
[HandHelping, "Human Handoff", "handoff"],
|
||||
[Plug, "Integrations", "integrations"],
|
||||
];
|
||||
|
||||
|
|
@ -204,8 +207,8 @@ export default function DashboardPage() {
|
|||
setMenuOpen(false);
|
||||
}
|
||||
|
||||
function openCompose() {
|
||||
triggerOpenCompose();
|
||||
function openCompose(context = null) {
|
||||
triggerOpenCompose(context);
|
||||
}
|
||||
|
||||
function emailSent() {
|
||||
|
|
@ -409,7 +412,10 @@ export default function DashboardPage() {
|
|||
<span className="dashboard-account-pill">{workspaceLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{!companyActive && active === "Inbox" && (
|
||||
{!companyActive && active === "Inbox" && agentNav === "handoff" && (
|
||||
<HumanHandoff account={account} />
|
||||
)}
|
||||
{!companyActive && active === "Inbox" && agentNav !== "handoff" && (
|
||||
<MailboxView
|
||||
mailbox="inbox"
|
||||
title="Inbox"
|
||||
|
|
|
|||
74
frontend/src/components/HandoffConversation.jsx
Normal file
74
frontend/src/components/HandoffConversation.jsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { ArrowLeft } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { getHandoffConversation } from "../api";
|
||||
|
||||
function formatTime(dateValue) {
|
||||
if (!dateValue) return "";
|
||||
return new Date(dateValue).toLocaleString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default function HandoffConversation({ handoffId, onBack }) {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [conversation, setConversation] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await getHandoffConversation(handoffId, token);
|
||||
setConversation(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [handoffId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (loading) {
|
||||
return <p className="people-table-empty">Loading conversation…</p>;
|
||||
}
|
||||
if (error) {
|
||||
return <p className="form-error" role="alert">{error}</p>;
|
||||
}
|
||||
if (!conversation) return null;
|
||||
|
||||
return (
|
||||
<div className="handoff-conversation">
|
||||
<button type="button" className="handoff-back-btn" onClick={onBack}>
|
||||
<ArrowLeft size={16} strokeWidth={1.75} /> Back to Handoffs
|
||||
</button>
|
||||
|
||||
<header className="handoff-conversation-head">
|
||||
<h2>{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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="handoff-message-list">
|
||||
{conversation.messages.map((message) => (
|
||||
<article key={message.id} className={`handoff-message handoff-message-${message.role}`}>
|
||||
<header>
|
||||
<strong>{message.role === "human" ? "HUMAN OWNER" : "AGENT"}</strong>
|
||||
<time>{formatTime(message.event_at)}</time>
|
||||
</header>
|
||||
<div className="handoff-message-body">
|
||||
{message.body_text || message.snippet}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
frontend/src/components/HandoffInbox.jsx
Normal file
87
frontend/src/components/HandoffInbox.jsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { ArrowLeftRight } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { listHandoffConversations } from "../api";
|
||||
|
||||
function formatRelativeTime(dateValue) {
|
||||
if (!dateValue) return "";
|
||||
const date = new Date(dateValue);
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const minutes = Math.floor(diffMs / 60000);
|
||||
if (minutes < 1) return "Just now";
|
||||
if (minutes < 60) return `${minutes} min ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days === 1) return "Yesterday";
|
||||
if (days < 7) return `${days} days ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export default function HandoffInbox({ onSelect }) {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await listHandoffConversations(token);
|
||||
setItems(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="handoff-inbox">
|
||||
<p className="handoff-inbox-lead">Agent ↔ Human Owner conversations</p>
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
{loading ? (
|
||||
<p className="people-table-empty">Loading handoffs…</p>
|
||||
) : items.length === 0 ? (
|
||||
<div className="human-handoff-empty">
|
||||
<div className="human-handoff-empty-icon" aria-hidden="true">
|
||||
<ArrowLeftRight size={24} strokeWidth={1.75} />
|
||||
</div>
|
||||
<h2>No handoff conversations 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.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="handoff-inbox-list">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="handoff-inbox-row"
|
||||
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>
|
||||
{item.snippet && <p className="handoff-inbox-row-snippet">“{item.snippet}”</p>}
|
||||
</div>
|
||||
<div className="handoff-inbox-row-meta">
|
||||
<time>{formatRelativeTime(item.updated_at)}</time>
|
||||
<span>{item.message_count} message{item.message_count === 1 ? "" : "s"}</span>
|
||||
<span className={`handoff-inbox-status status-${item.status.toLowerCase()}`}>
|
||||
{item.status_label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
294
frontend/src/components/HandoffSettings.jsx
Normal file
294
frontend/src/components/HandoffSettings.jsx
Normal 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'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">“{rule.instructions}”</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>
|
||||
);
|
||||
}
|
||||
58
frontend/src/components/HumanHandoff.jsx
Normal file
58
frontend/src/components/HumanHandoff.jsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useState } from "react";
|
||||
import HandoffConversation from "./HandoffConversation";
|
||||
import HandoffInbox from "./HandoffInbox";
|
||||
import HandoffSettings from "./HandoffSettings";
|
||||
|
||||
export default function HumanHandoff() {
|
||||
const [tab, setTab] = useState("handoffs");
|
||||
const [selectedHandoffId, setSelectedHandoffId] = useState(null);
|
||||
|
||||
function switchTab(nextTab) {
|
||||
setTab(nextTab);
|
||||
if (nextTab !== "handoffs") {
|
||||
setSelectedHandoffId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="human-handoff-page">
|
||||
<header className="human-handoff-head">
|
||||
<div>
|
||||
<h1>Human Handoff</h1>
|
||||
<p>
|
||||
Private Agent ↔ Human Owner conversations, linked to external customer threads
|
||||
but kept on separate RFC threading planes.
|
||||
</p>
|
||||
<nav className="human-handoff-tabs" aria-label="Human Handoff sections">
|
||||
<button
|
||||
type="button"
|
||||
className={tab === "handoffs" ? "active" : ""}
|
||||
onClick={() => switchTab("handoffs")}
|
||||
>
|
||||
Handoffs
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={tab === "settings" ? "active" : ""}
|
||||
onClick={() => switchTab("settings")}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{tab === "handoffs" && (
|
||||
selectedHandoffId ? (
|
||||
<HandoffConversation
|
||||
handoffId={selectedHandoffId}
|
||||
onBack={() => setSelectedHandoffId(null)}
|
||||
/>
|
||||
) : (
|
||||
<HandoffInbox onSelect={setSelectedHandoffId} />
|
||||
)
|
||||
)}
|
||||
{tab === "settings" && <HandoffSettings />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -138,14 +138,123 @@ function MessageAction({ title, label, onClick, active, children }) {
|
|||
);
|
||||
}
|
||||
|
||||
function MessageBlock({
|
||||
message,
|
||||
viewerEmail,
|
||||
expanded,
|
||||
onToggle,
|
||||
isLatest,
|
||||
onToggleStar,
|
||||
onReply,
|
||||
onMove,
|
||||
}) {
|
||||
const [headersOpen, setHeadersOpen] = useState(false);
|
||||
const body = messageBody(message);
|
||||
const time = detailTime(message);
|
||||
const sender = senderIdentity(message);
|
||||
const attachments = message?.attachments || [];
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<button type="button" className="mail-thread-message-collapsed" onClick={onToggle}>
|
||||
<SenderAvatar email={message} />
|
||||
<span className="mail-thread-message-collapsed-main">
|
||||
<strong>{sender?.name}</strong>
|
||||
<span>{message.snippet || message.body_text || ""}</span>
|
||||
</span>
|
||||
<time>{time.absolute}</time>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mail-thread-message">
|
||||
<div className="mail-detail-message-header">
|
||||
<SenderAvatar email={message} />
|
||||
<div className="mail-detail-sender-meta">
|
||||
<div className="mail-detail-sender-line">
|
||||
<strong>{sender?.name}</strong>
|
||||
{sender?.address && (
|
||||
<span className="mail-detail-sender-email"><{sender.address}></span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`mail-detail-recipient-toggle${headersOpen ? " open" : ""}`}
|
||||
onClick={() => setHeadersOpen((open) => !open)}
|
||||
aria-expanded={headersOpen}
|
||||
>
|
||||
{recipientLabel(message, viewerEmail)}
|
||||
<ChevronDown size={12} strokeWidth={1.75} />
|
||||
</button>
|
||||
{headersOpen && (
|
||||
<div className="mail-detail-recipient-details">
|
||||
<small>From: {joinAddresses(message.sender) || sender?.display}</small>
|
||||
<small>To: {joinAddresses(message.recipients || message.to) || "Undisclosed"}</small>
|
||||
{joinAddresses(message.cc) ? <small>Cc: {joinAddresses(message.cc)}</small> : null}
|
||||
<small>Date: {time.absolute}{time.relative ? ` (${time.relative})` : ""}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mail-detail-message-header-actions">
|
||||
<time dateTime={message.created_at}>
|
||||
{time.absolute}
|
||||
{time.relative ? ` (${time.relative})` : ""}
|
||||
</time>
|
||||
<MessageAction
|
||||
title={message.is_starred ? "Unstar" : "Star"}
|
||||
active={message.is_starred}
|
||||
onClick={() => onToggleStar?.(!message.is_starred)}
|
||||
>
|
||||
<Star {...ACTION_ICON} />
|
||||
</MessageAction>
|
||||
{isLatest ? (
|
||||
<MessageAction title="Reply" onClick={() => onReply?.(false)}>
|
||||
<Reply {...ACTION_ICON} />
|
||||
</MessageAction>
|
||||
) : null}
|
||||
<MessageAction title="More actions" onClick={onMove}>
|
||||
<MoreHorizontal {...ACTION_ICON} />
|
||||
</MessageAction>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mail-detail-message-content${body?.mode === "html" ? " is-html" : ""}`}>
|
||||
<div className={`mail-detail-body${body?.mode === "html" ? " is-html" : " is-text"}`}>
|
||||
{body?.mode === "html" ? (
|
||||
<div className="mail-detail-body-html" dangerouslySetInnerHTML={{ __html: body.value }} />
|
||||
) : (
|
||||
<div className="mail-detail-body-text">{body?.value}</div>
|
||||
)}
|
||||
</div>
|
||||
{attachments.length > 0 && (
|
||||
<div className="mail-detail-attachments" aria-label="Attachments">
|
||||
{attachments.map((file) => (
|
||||
<div key={`${file.filename}-${file.size}`} className="mail-detail-attachment">
|
||||
<span className="mail-detail-attachment-icon" aria-hidden="true">
|
||||
<FileText size={18} strokeWidth={1.7} />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{file.filename}</strong>
|
||||
<small>{file.content_type || "File"} · {formatFileSize(file.size)}</small>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MailDetail({
|
||||
email,
|
||||
thread,
|
||||
messages,
|
||||
loading,
|
||||
error,
|
||||
mailbox,
|
||||
busy,
|
||||
viewerEmail,
|
||||
threadSize = 1,
|
||||
onClose,
|
||||
onToggleRead,
|
||||
onToggleStar,
|
||||
|
|
@ -162,14 +271,15 @@ export default function MailDetail({
|
|||
onSummarizeThread,
|
||||
onAnalyzeAttachment,
|
||||
}) {
|
||||
const [headersOpen, setHeadersOpen] = useState(false);
|
||||
const files = email ? attachmentCount(email) : 0;
|
||||
const body = email ? messageBody(email) : null;
|
||||
const time = email ? detailTime(email) : { absolute: "", relative: "" };
|
||||
const attachments = email?.attachments || [];
|
||||
const sender = email ? senderIdentity(email) : null;
|
||||
const summarizeLabel = threadSize > 1 ? "Summarize thread" : "Summarize";
|
||||
const showReplyAll = email ? hasMultipleRecipients(email) : false;
|
||||
const conversation = messages?.length ? messages : email ? [email] : [];
|
||||
const isThread = conversation.length > 1;
|
||||
const [expandedMessageId, setExpandedMessageId] = useState(null);
|
||||
const latestMessage = conversation[conversation.length - 1] || email;
|
||||
const files = latestMessage ? attachmentCount(latestMessage) : 0;
|
||||
const summarizeLabel = isThread ? "Summarize thread" : "Summarize";
|
||||
const showReplyAll = latestMessage ? hasMultipleRecipients(latestMessage) : false;
|
||||
const displaySubject = thread?.subject || email?.subject;
|
||||
const activeExpandedId = expandedMessageId || latestMessage?.id;
|
||||
|
||||
return (
|
||||
<article className={`mail-detail${email || loading ? " open" : ""}`} aria-live="polite">
|
||||
|
|
@ -244,10 +354,13 @@ export default function MailDetail({
|
|||
<div className="mail-detail-sheet">
|
||||
<div className="mail-detail-subject-row">
|
||||
<div className="mail-detail-subject-main">
|
||||
<h1>{email.subject?.trim() || "(No subject)"}</h1>
|
||||
<h1>{displaySubject?.trim() || "(No subject)"}</h1>
|
||||
<div className="mail-detail-labels">
|
||||
<span className="mail-detail-label-pill">{mailboxLabel(mailbox)}</span>
|
||||
{email.is_starred && <span className="mail-detail-label-pill starred">Starred</span>}
|
||||
{isThread && (
|
||||
<span className="mail-detail-label-pill">{conversation.length} messages</span>
|
||||
)}
|
||||
{email?.is_starred && <span className="mail-detail-label-pill starred">Starred</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mail-detail-ai-chips">
|
||||
|
|
@ -265,95 +378,30 @@ export default function MailDetail({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mail-detail-message-header">
|
||||
<SenderAvatar email={email} />
|
||||
<div className="mail-detail-sender-meta">
|
||||
<div className="mail-detail-sender-line">
|
||||
<strong>{sender?.name}</strong>
|
||||
{sender?.address && (
|
||||
<span className="mail-detail-sender-email"><{sender.address}></span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`mail-detail-recipient-toggle${headersOpen ? " open" : ""}`}
|
||||
onClick={() => setHeadersOpen((open) => !open)}
|
||||
aria-expanded={headersOpen}
|
||||
>
|
||||
{recipientLabel(email, viewerEmail)}
|
||||
<ChevronDown size={12} strokeWidth={1.75} />
|
||||
</button>
|
||||
{headersOpen && (
|
||||
<div className="mail-detail-recipient-details">
|
||||
<small>From: {joinAddresses(email.sender) || sender?.display}</small>
|
||||
<small>To: {joinAddresses(email.recipients || email.to) || "Undisclosed"}</small>
|
||||
{joinAddresses(email.cc) ? <small>Cc: {joinAddresses(email.cc)}</small> : null}
|
||||
{joinAddresses(email.bcc) ? <small>Bcc: {joinAddresses(email.bcc)}</small> : null}
|
||||
<small>Date: {time.absolute}{time.relative ? ` (${time.relative})` : ""}</small>
|
||||
<small>Subject: {email.subject?.trim() || "(No subject)"}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mail-detail-message-header-actions">
|
||||
<time dateTime={email.created_at}>
|
||||
{time.absolute}
|
||||
{time.relative ? ` (${time.relative})` : ""}
|
||||
</time>
|
||||
<MessageAction
|
||||
title={email.is_starred ? "Unstar" : "Star"}
|
||||
label={email.is_starred ? "Unstar" : "Star"}
|
||||
active={email.is_starred}
|
||||
onClick={() => onToggleStar?.(!email.is_starred)}
|
||||
>
|
||||
<Star {...ACTION_ICON} />
|
||||
</MessageAction>
|
||||
<MessageAction title="Reply" onClick={onReply}>
|
||||
<Reply {...ACTION_ICON} />
|
||||
</MessageAction>
|
||||
<MessageAction title="More actions" onClick={onMove}>
|
||||
<MoreHorizontal {...ACTION_ICON} />
|
||||
</MessageAction>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`mail-detail-message-content${body?.mode === "html" ? " is-html" : ""}`}>
|
||||
<div className={`mail-detail-body${body?.mode === "html" ? " is-html" : " is-text"}`}>
|
||||
{body?.mode === "html" ? (
|
||||
<div className="mail-detail-body-html" dangerouslySetInnerHTML={{ __html: body.value }} />
|
||||
) : (
|
||||
<div className="mail-detail-body-text">{body?.value}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="mail-detail-attachments" aria-label="Attachments">
|
||||
{attachments.map((file) => (
|
||||
<div key={`${file.filename}-${file.size}`} className="mail-detail-attachment">
|
||||
<span className="mail-detail-attachment-icon" aria-hidden="true">
|
||||
<FileText size={18} strokeWidth={1.7} />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{file.filename}</strong>
|
||||
<small>{file.content_type || "File"} · {formatFileSize(file.size)}</small>
|
||||
</div>
|
||||
<div className="mail-detail-attachment-actions">
|
||||
<button type="button">Preview</button>
|
||||
<button type="button">Download</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={`mail-thread-messages${isThread ? " is-thread" : ""}`}>
|
||||
{conversation.map((message, index) => (
|
||||
<MessageBlock
|
||||
key={message.id}
|
||||
message={message}
|
||||
viewerEmail={viewerEmail}
|
||||
expanded={!isThread || message.id === activeExpandedId}
|
||||
onToggle={() => setExpandedMessageId(message.id)}
|
||||
isLatest={index === conversation.length - 1}
|
||||
onToggleStar={onToggleStar}
|
||||
onReply={onReply}
|
||||
onMove={onMove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="mail-detail-message-footer">
|
||||
<div className="mail-detail-reply-bar">
|
||||
{showReplyAll ? (
|
||||
<button type="button" className="mail-detail-reply-btn" onClick={onReply}>
|
||||
<button type="button" className="mail-detail-reply-btn" onClick={() => onReply?.(true)}>
|
||||
<Reply {...REPLY_ICON} /> Reply all
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="mail-detail-reply-btn" onClick={onReply}>
|
||||
<button type="button" className="mail-detail-reply-btn" onClick={() => onReply?.(false)}>
|
||||
<Reply {...REPLY_ICON} /> Reply
|
||||
</button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export default function MailRow({
|
|||
}) {
|
||||
const unread = email.is_read === false;
|
||||
const starred = email.is_starred === true;
|
||||
const messageCount = email.message_count || 1;
|
||||
const subject = email.subject?.trim() || "(No subject)";
|
||||
const snippet = email.snippet || email.preview || email.body_text || "";
|
||||
const timestamp =
|
||||
|
|
@ -111,6 +112,7 @@ export default function MailRow({
|
|||
</span>
|
||||
<span className="mailbox-subject-line">
|
||||
<span className="mailbox-subject">{subject}</span>
|
||||
{messageCount > 1 && <span className="mailbox-thread-count">{messageCount}</span>}
|
||||
{snippet && (
|
||||
<>
|
||||
<span className="mailbox-preview-sep" aria-hidden="true"> — </span>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ import {
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
getEmail,
|
||||
listEmails,
|
||||
getThread,
|
||||
listThreads,
|
||||
permanentlyDeleteEmail,
|
||||
restoreEmail,
|
||||
setEmailRead,
|
||||
|
|
@ -62,6 +63,41 @@ function resultPage(result) {
|
|||
};
|
||||
}
|
||||
|
||||
function threadToRow(thread) {
|
||||
const primarySender = thread.senders?.[0] || thread.participants?.[0] || { name: "Unknown", address: "" };
|
||||
return {
|
||||
id: thread.latest_message_id,
|
||||
thread_id: thread.id,
|
||||
mailbox: thread.mailbox,
|
||||
sender: primarySender,
|
||||
recipients: thread.participants || [],
|
||||
subject: thread.subject,
|
||||
snippet: thread.snippet,
|
||||
created_at: thread.latest_at,
|
||||
is_read: thread.is_read,
|
||||
is_starred: thread.is_starred,
|
||||
attachments: thread.has_attachments ? [{ filename: "attachment" }] : [],
|
||||
message_count: thread.message_count,
|
||||
unread_count: thread.unread_count,
|
||||
};
|
||||
}
|
||||
|
||||
function buildReplyContext(message, replyAll = false) {
|
||||
const sender = message?.sender || { name: "", address: "" };
|
||||
const subject = (message?.subject || "").trim();
|
||||
const replySubject = /^re:/i.test(subject) ? subject : `Re: ${subject || "(No subject)"}`;
|
||||
const quote = (message?.body_text || message?.snippet || "").trim();
|
||||
return {
|
||||
replyToMessageId: message.id,
|
||||
replyAll,
|
||||
to: replyAll ? [] : [{ email: sender.address, name: sender.name }],
|
||||
subject: replySubject,
|
||||
bodyHtml: quote
|
||||
? `<p><br></p><blockquote>${quote.replace(/\n/g, "<br>")}</blockquote>`
|
||||
: "<p><br></p>",
|
||||
};
|
||||
}
|
||||
|
||||
export default function MailboxView({
|
||||
mailbox,
|
||||
title,
|
||||
|
|
@ -83,6 +119,7 @@ export default function MailboxView({
|
|||
const [checked, setChecked] = useState({});
|
||||
const [hidden, setHidden] = useState({});
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [threadDetail, setThreadDetail] = useState(null);
|
||||
const [status, setStatus] = useState({ loading: true, error: "" });
|
||||
const [detailStatus, setDetailStatus] = useState({ loading: false, error: "", busy: false });
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
|
@ -101,7 +138,7 @@ export default function MailboxView({
|
|||
const load = useCallback(async () => {
|
||||
setStatus({ loading: true, error: "" });
|
||||
try {
|
||||
const result = await listEmails(
|
||||
const result = await listThreads(
|
||||
{
|
||||
mailbox,
|
||||
limit: PAGE_SIZE,
|
||||
|
|
@ -115,7 +152,7 @@ export default function MailboxView({
|
|||
token,
|
||||
);
|
||||
const page = resultPage(result);
|
||||
setEmails(page.items);
|
||||
setEmails(page.items.map(threadToRow));
|
||||
setNextCursor(page.nextCursor);
|
||||
setStatus({ loading: false, error: "" });
|
||||
} catch (error) {
|
||||
|
|
@ -193,17 +230,26 @@ export default function MailboxView({
|
|||
}
|
||||
|
||||
async function openEmail(email) {
|
||||
setSelected(email.id);
|
||||
setSelected(email.thread_id || email.id);
|
||||
setDetail(email);
|
||||
setThreadDetail(null);
|
||||
setDetailStatus({ loading: true, error: "", busy: false });
|
||||
if (email.is_read === false) {
|
||||
setEmails((current) =>
|
||||
current.map((item) => item.id === email.id ? { ...item, is_read: true } : item),
|
||||
current.map((item) => (
|
||||
item.thread_id === email.thread_id || item.id === email.id
|
||||
? { ...item, is_read: true }
|
||||
: item
|
||||
)),
|
||||
);
|
||||
setDetail((current) => ({ ...current, is_read: true }));
|
||||
setEmailRead(email.id, true, token).catch(() => {
|
||||
setEmails((current) =>
|
||||
current.map((item) => item.id === email.id ? { ...item, is_read: false } : item),
|
||||
current.map((item) => (
|
||||
item.thread_id === email.thread_id || item.id === email.id
|
||||
? { ...item, is_read: false }
|
||||
: item
|
||||
)),
|
||||
);
|
||||
setDetail((current) =>
|
||||
current?.id === email.id ? { ...current, is_read: false } : current,
|
||||
|
|
@ -211,8 +257,24 @@ export default function MailboxView({
|
|||
});
|
||||
}
|
||||
try {
|
||||
const completeEmail = await getEmail(email.id, token);
|
||||
setDetail((current) => ({ ...current, ...completeEmail, is_read: current?.is_read ?? completeEmail.is_read }));
|
||||
if (email.thread_id) {
|
||||
const thread = await getThread(email.thread_id, token);
|
||||
const messages = thread.messages || [];
|
||||
const latest = messages[messages.length - 1] || email;
|
||||
setThreadDetail(thread);
|
||||
setDetail((current) => ({
|
||||
...current,
|
||||
...latest,
|
||||
is_read: current?.is_read ?? latest.is_read,
|
||||
}));
|
||||
} else {
|
||||
const completeEmail = await getEmail(email.id, token);
|
||||
setDetail((current) => ({
|
||||
...current,
|
||||
...completeEmail,
|
||||
is_read: current?.is_read ?? completeEmail.is_read,
|
||||
}));
|
||||
}
|
||||
setDetailStatus({ loading: false, error: "", busy: false });
|
||||
} catch (error) {
|
||||
setDetailStatus({ loading: false, error: error.message, busy: false });
|
||||
|
|
@ -694,13 +756,14 @@ export default function MailboxView({
|
|||
|
||||
<MailDetail
|
||||
email={detail}
|
||||
thread={threadDetail}
|
||||
messages={threadDetail?.messages}
|
||||
loading={detailStatus.loading}
|
||||
error={detailStatus.error}
|
||||
busy={detailStatus.busy}
|
||||
mailbox={mailbox}
|
||||
viewerEmail={account?.email}
|
||||
threadSize={1}
|
||||
onClose={() => { setSelected(null); setDetail(null); }}
|
||||
onClose={() => { setSelected(null); setDetail(null); setThreadDetail(null); }}
|
||||
onToggleRead={toggleRead}
|
||||
onToggleStar={(starred) => detail && toggleStar(detail, starred)}
|
||||
onTrash={() => mutateSelected(trashEmail)}
|
||||
|
|
@ -710,8 +773,17 @@ export default function MailboxView({
|
|||
onSpam={() => detail && hideForSession(detail, "Marked as spam in this view until you refresh.")}
|
||||
onSnooze={() => detail && hideForSession(detail, "Snoozed in this view until you refresh.")}
|
||||
onMove={() => setAgentFocus("Move and labels — coming soon.")}
|
||||
onReply={() => { onCompose?.(); setAgentFocus(detail?.subject ? `Reply: ${detail.subject}` : "Reply"); }}
|
||||
onForward={() => { onCompose?.(); setAgentFocus(detail?.subject ? `Forward: ${detail.subject}` : "Forward"); }}
|
||||
onReply={(replyAll = false) => {
|
||||
const target = threadDetail?.messages?.[threadDetail.messages.length - 1] || detail;
|
||||
onCompose?.(buildReplyContext(target, replyAll));
|
||||
}}
|
||||
onForward={() => {
|
||||
const target = threadDetail?.messages?.[threadDetail.messages.length - 1] || detail;
|
||||
onCompose?.({
|
||||
subject: `Fwd: ${target?.subject || ""}`,
|
||||
bodyHtml: `<p><br></p><blockquote>${(target?.body_text || target?.snippet || "").replace(/\n/g, "<br>")}</blockquote>`,
|
||||
});
|
||||
}}
|
||||
onExtractActions={() => detail && extractActions(detail)}
|
||||
onSummarizeThread={() => detail && summarizeThread(detail)}
|
||||
onAnalyzeAttachment={() => detail && analyzeAttachment(detail)}
|
||||
|
|
@ -725,6 +797,7 @@ export default function MailboxView({
|
|||
account={account}
|
||||
emails={listedEmails}
|
||||
contextEmail={detail}
|
||||
contextThread={threadDetail}
|
||||
focus={agentFocus}
|
||||
insight={insight}
|
||||
loading={status.loading}
|
||||
|
|
|
|||
|
|
@ -527,6 +527,127 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
|||
.security-toggle span { position:absolute; inset:0; border-radius:999px; background:#D9D5E0; transition:background .2s; cursor:pointer; }
|
||||
.security-toggle span::after { content:""; position:absolute; top:3px; left:3px; width:18px; height:18px; border-radius:50%; background:#fff; box-shadow:0 1px 3px rgba(20,18,30,0.18); transition:transform .2s; }
|
||||
.security-toggle input:checked + span { background:#7357F6; }
|
||||
.human-handoff-page { max-width:920px; margin:0 auto; padding:8px 4px 32px; }
|
||||
.human-handoff-head { margin-bottom:20px; display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }
|
||||
.human-handoff-head h1 { margin:0 0 8px; font:700 28px/1.15 "Manrope"; color:#171424; }
|
||||
.human-handoff-head p { margin:0; max-width:640px; color:#6f6b7c; font-size:13px; line-height:1.6; }
|
||||
.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-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); }
|
||||
.handoff-inbox-row:hover { border-color:#ddd4ff; background:#fcfbff; }
|
||||
.handoff-inbox-row-main strong { display:block; margin-bottom:4px; font:650 14px/1.3 "DM Sans"; color:#171424; }
|
||||
.handoff-inbox-row-party { color:#6d5bd0; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.3px; }
|
||||
.handoff-inbox-row-snippet { margin:8px 0 0; color:#5a5562; font-size:12px; line-height:1.45; }
|
||||
.handoff-inbox-row-meta { min-width:120px; display:grid; gap:4px; justify-items:end; color:#77758a; font-size:11px; }
|
||||
.handoff-inbox-status { height:22px; padding:0 8px; display:inline-flex; align-items:center; border-radius:999px; background:#f1eff3; color:#716d7a; font-size:10px; font-weight:600; }
|
||||
.handoff-inbox-status.status-waiting_for_human { background:#fff6e8; color:#b26a00; }
|
||||
.handoff-inbox-status.status-human_replied { background:#edf4ff; color:#2f67c5; }
|
||||
.handoff-inbox-status.status-agent_replied { background:#edf9f4; color:#11966a; }
|
||||
.handoff-back-btn { margin-bottom:14px; height:32px; padding:0 10px; display:inline-flex; align-items:center; gap:6px; border:0; border-radius:8px; background:transparent; color:#6d5bd0; font-size:12px; font-weight:600; }
|
||||
.handoff-back-btn:hover { background:#f6f2ff; }
|
||||
.handoff-conversation-head h2 { margin:0 0 10px; font:700 22px/1.2 "Manrope"; color:#171424; }
|
||||
.handoff-linked-external { padding:12px 14px; display:flex; flex-wrap:wrap; align-items:center; gap:8px; border:1px solid #ece9f2; border-radius:10px; background:#faf9fc; color:#5a5562; font-size:12px; }
|
||||
.handoff-linked-external strong { color:#171424; }
|
||||
.handoff-linked-external em { color:#77758a; }
|
||||
.handoff-message-list { margin-top:18px; display:grid; gap:14px; }
|
||||
.handoff-message { padding:16px 18px; border:1px solid #ece9f2; border-radius:12px; background:#fff; }
|
||||
.handoff-message header { margin-bottom:10px; display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.handoff-message header strong { font-size:10px; font-weight:700; letter-spacing:.35px; color:#6d5bd0; }
|
||||
.handoff-message-human header strong { color:#11966a; }
|
||||
.handoff-message header time { color:#9a96a5; font-size:11px; }
|
||||
.handoff-message-body { color:#292536; font-size:13px; line-height:1.6; white-space:pre-wrap; }
|
||||
.handoff-settings-intro { margin-bottom:18px; padding:18px 20px; border:1px solid #ece7ff; border-radius:14px; background:#fbf9ff; }
|
||||
.handoff-settings-intro p { margin:0 0 10px; color:#4a4652; font-size:12px; line-height:1.6; }
|
||||
.handoff-settings-intro p strong { color:#171424; }
|
||||
.handoff-settings-intro small { color:#77758a; font-size:11px; line-height:1.5; }
|
||||
.handoff-flow-diagram { margin:14px 0 10px; padding:14px; display:grid; justify-items:center; gap:4px; border:1px dashed #ddd4ff; border-radius:10px; background:#fff; color:#4a4652; font-size:12px; font-weight:600; }
|
||||
.handoff-flow-arrow { color:#9a96a5; font-weight:400; }
|
||||
.handoff-settings-toolbar { margin-bottom:14px; display:flex; justify-content:flex-end; }
|
||||
.human-handoff-empty,.human-handoff-list { margin-top:8px; }
|
||||
.human-handoff-empty { padding:40px 28px; display:grid; justify-items:center; text-align:center; border:1px solid #ece9f2; border-radius:16px; background:#fff; box-shadow:0 8px 28px rgba(20,18,30,0.04); }
|
||||
.human-handoff-empty-icon { width:52px; height:52px; margin-bottom:14px; display:grid; place-items:center; border-radius:14px; background:#f6f2ff; color:#7357f6; }
|
||||
.human-handoff-empty h2 { margin:0 0 8px; font:700 18px/1.2 "DM Sans"; }
|
||||
.human-handoff-empty p { margin:0 0 18px; max-width:420px; color:#77758a; font-size:12px; line-height:1.55; }
|
||||
.human-handoff-list { display:grid; gap:12px; }
|
||||
.human-handoff-card { padding:18px 20px; border:1px solid #ece9f2; border-radius:14px; background:#fff; box-shadow:0 8px 24px rgba(20,18,30,0.035); }
|
||||
.human-handoff-card-top { display:grid; grid-template-columns:auto 1fr auto; gap:12px; align-items:center; }
|
||||
.human-handoff-card-avatar { width:38px; height:38px; display:grid; place-items:center; border-radius:50%; background:#f6f2ff; color:#7357f6; }
|
||||
.human-handoff-card-meta strong { display:block; font:650 14px/1.3 "DM Sans"; color:#171424; }
|
||||
.human-handoff-card-meta small { color:#77758a; font-size:11px; }
|
||||
.human-handoff-status { height:22px; padding:0 8px; display:inline-flex; align-items:center; border-radius:999px; background:#f1eff3; color:#716d7a; font-size:10px; font-weight:600; }
|
||||
.human-handoff-status.active { background:#edf9f4; color:#11966a; }
|
||||
.human-handoff-card-summary { margin:12px 0 8px; color:#5a5562; font-size:12px; }
|
||||
.human-handoff-card-quote { margin:0 0 14px; padding-left:12px; border-left:2px solid #ece7ff; color:#4a4652; font-size:12px; line-height:1.55; }
|
||||
.human-handoff-card-actions { display:flex; align-items:center; justify-content:flex-end; gap:8px; }
|
||||
.handoff-card-menu-wrap { position:relative; }
|
||||
.handoff-card-menu { position:absolute; right:0; top:calc(100% + 4px); min-width:160px; padding:6px; display:grid; gap:2px; border:1px solid #ece9f2; border-radius:10px; background:#fff; box-shadow:0 8px 24px rgba(20,18,30,0.12); z-index:5; }
|
||||
.handoff-card-menu button { width:100%; min-height:34px; padding:0 10px; border:0; border-radius:8px; background:transparent; text-align:left; font-size:12px; font-weight:600; color:#4a4652; }
|
||||
.handoff-card-menu button:hover { background:#f7f5fa; }
|
||||
.handoff-btn { height:36px; padding:0 14px; display:inline-flex; align-items:center; justify-content:center; gap:7px; border:1px solid #dfdde5; border-radius:9px; background:#fff; color:#241f2b; font-size:12px; font-weight:600; }
|
||||
.handoff-btn.primary { border-color:#6d5bd0; background:#7357f6; color:#fff; }
|
||||
.handoff-btn.subtle { color:#5a5562; background:#fafafa; }
|
||||
.handoff-btn.icon { width:36px; padding:0; }
|
||||
.handoff-btn:disabled { opacity:.55; cursor:not-allowed; }
|
||||
.handoff-drawer-backdrop { position:fixed; inset:0; z-index:60; display:flex; justify-content:flex-end; background:rgba(20,18,30,0.28); }
|
||||
.handoff-drawer,.handoff-test-modal { width:min(560px,100vw); height:100%; display:flex; flex-direction:column; background:#fff; box-shadow:-12px 0 40px rgba(20,18,30,0.12); }
|
||||
.handoff-test-modal { height:auto; max-height:min(720px,92vh); margin:auto 0; border-radius:16px 0 0 16px; }
|
||||
.handoff-drawer-head { padding:22px 22px 12px; display:flex; align-items:flex-start; justify-content:space-between; gap:12px; border-bottom:1px solid #f0eef2; }
|
||||
.handoff-drawer-head h2 { margin:0 0 6px; font:700 20px/1.2 "DM Sans"; color:#171424; }
|
||||
.handoff-drawer-head p { margin:0; color:#77758a; font-size:12px; line-height:1.5; }
|
||||
.handoff-drawer-close { width:32px; height:32px; display:grid; place-items:center; border:0; border-radius:8px; background:transparent; color:#6f6b7c; }
|
||||
.handoff-drawer-close:hover { background:#f5f4f2; }
|
||||
.handoff-drawer-body { flex:1; overflow:auto; padding:18px 22px 24px; display:grid; gap:22px; }
|
||||
.handoff-drawer-foot { padding:14px 22px 18px; display:flex; justify-content:flex-end; gap:8px; border-top:1px solid #f0eef2; background:#fcfbfd; }
|
||||
.handoff-form-section h3 { margin:0 0 8px; font:700 13px/1.3 "DM Sans"; color:#171424; text-transform:none; letter-spacing:0; }
|
||||
.handoff-field-label { display:block; margin-bottom:6px; font-size:11px; font-weight:600; color:#5a5562; }
|
||||
.handoff-helper { margin:8px 0 0; color:#77758a; font-size:11px; line-height:1.5; }
|
||||
.handoff-email-input,.handoff-text-input { width:100%; min-height:42px; padding:0 12px; display:flex; align-items:center; gap:8px; border:1px solid #e4e1e8; border-radius:10px; background:#fff; }
|
||||
.handoff-email-input input,.handoff-text-input { width:100%; border:0; outline:0; background:transparent; font-size:13px; color:#171424; }
|
||||
.handoff-owner-preview { margin-top:10px; padding:10px 12px; display:flex; align-items:center; gap:10px; border:1px solid #ece9f2; border-radius:10px; background:#faf9fc; }
|
||||
.handoff-owner-avatar { width:34px; height:34px; display:grid; place-items:center; border-radius:50%; background:#f6f2ff; color:#7357f6; }
|
||||
.handoff-owner-preview strong { display:block; font-size:13px; }
|
||||
.handoff-owner-preview small { color:#77758a; font-size:11px; }
|
||||
.handoff-label-toolbar { margin:10px 0; display:flex; gap:8px; }
|
||||
.handoff-label-toolbar input { flex:1; min-height:34px; padding:0 10px; border:1px solid #e4e1e8; border-radius:8px; font-size:12px; }
|
||||
.handoff-label-toolbar button { height:34px; padding:0 10px; border:1px solid #e4e1e8; border-radius:8px; background:#fff; font-size:11px; font-weight:600; color:#5a5562; }
|
||||
.handoff-label-grid { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.handoff-check-pill { height:30px; padding:0 10px; display:inline-flex; align-items:center; gap:6px; border:1px solid #e4e1e8; border-radius:999px; background:#fff; font-size:11px; font-weight:600; color:#4a4652; cursor:pointer; }
|
||||
.handoff-check-pill input { accent-color:#7357f6; }
|
||||
.handoff-type-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
|
||||
.handoff-type-card { min-height:92px; padding:12px; display:grid; gap:4px; border:1px solid #e4e1e8; border-radius:12px; background:#fff; cursor:pointer; }
|
||||
.handoff-type-card.selected { border-color:#cfc5ff; background:#fbf9ff; box-shadow:inset 0 0 0 1px #ddd4ff; }
|
||||
.handoff-type-card input { position:absolute; opacity:0; }
|
||||
.handoff-type-card strong { font-size:12px; color:#171424; }
|
||||
.handoff-type-card span { color:#77758a; font-size:10.5px; line-height:1.45; }
|
||||
.handoff-instructions { width:100%; padding:12px; border:1px solid #e4e1e8; border-radius:12px; resize:vertical; font:400 13px/1.55 "DM Sans"; color:#292536; }
|
||||
.handoff-examples { margin-top:10px; display:flex; flex-wrap:wrap; align-items:center; gap:6px; }
|
||||
.handoff-examples span { color:#77758a; font-size:11px; }
|
||||
.handoff-examples button { height:28px; padding:0 10px; border:1px solid #ece7ff; border-radius:999px; background:#fbf9ff; color:#6d5bd0; font-size:10.5px; font-weight:600; }
|
||||
.handoff-summary-card { padding:14px; border:1px solid #ece7ff; border-radius:12px; background:#fbf9ff; }
|
||||
.handoff-summary-title { margin-bottom:8px; display:flex; align-items:center; gap:6px; color:#6d5bd0; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.35px; }
|
||||
.handoff-summary-card p { margin:0; color:#4a4652; font-size:12px; line-height:1.55; }
|
||||
.handoff-switch { display:flex; align-items:center; justify-content:space-between; gap:12px; font-size:13px; font-weight:600; color:#171424; }
|
||||
.handoff-switch input { position:absolute; opacity:0; width:0; height:0; }
|
||||
.handoff-switch-track { position:relative; width:44px; height:24px; border-radius:999px; background:#d9d5e0; }
|
||||
.handoff-switch-track::after { content:""; position:absolute; top:3px; left:3px; width:18px; height:18px; border-radius:50%; background:#fff; transition:transform .2s; box-shadow:0 1px 3px rgba(20,18,30,0.18); }
|
||||
.handoff-switch input:checked + .handoff-switch-track { background:#7357f6; }
|
||||
.handoff-switch input:checked + .handoff-switch-track::after { transform:translateX(20px); }
|
||||
.handoff-reply-info { margin-top:12px; padding:12px; border:1px solid #ece9f2; border-radius:10px; background:#faf9fc; }
|
||||
.handoff-reply-info strong { display:block; margin-bottom:6px; font-size:12px; }
|
||||
.handoff-reply-info p { margin:0; color:#5a5562; font-size:11px; line-height:1.5; }
|
||||
.handoff-reply-as { margin-top:12px; display:grid; gap:8px; }
|
||||
.handoff-reply-as strong { font-size:12px; color:#171424; }
|
||||
.handoff-reply-as label { display:flex; align-items:center; gap:8px; font-size:12px; color:#4a4652; }
|
||||
.handoff-test-result { margin-top:14px; padding:14px; border:1px solid #ece9f2; border-radius:12px; background:#faf9fc; }
|
||||
.handoff-test-result h3 { margin:0 0 10px; font-size:13px; }
|
||||
.handoff-test-result ul { margin:0 0 12px; padding:0; list-style:none; display:grid; gap:6px; }
|
||||
.handoff-test-result li { font-size:12px; }
|
||||
.handoff-test-result li.pass { color:#11966a; }
|
||||
.handoff-test-result li.fail { color:#c45a4a; }
|
||||
.handoff-test-result p { margin:0 0 6px; font-size:12px; line-height:1.5; color:#4a4652; }
|
||||
.security-toggle input:checked + span::after { transform:translateX(20px); }
|
||||
.security-toggle input:disabled + span { opacity:.55; cursor:not-allowed; }
|
||||
.security-timeout-select,.admin-role-select { min-width:140px; height:40px; padding:0 12px; border:1px solid #E1DFE7; border-radius:10px; background:#fff; font-size:13px; color:#4a4652; }
|
||||
|
|
@ -714,6 +835,17 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
|||
.mail-detail-reply-btn { height:36px; padding:0 14px; display:inline-flex; align-items:center; gap:7px; border:1px solid #dfdde5; border-radius:8px; background:#fff; color:#241f2b; font-size:11.5px; font-weight:600; }
|
||||
.mail-detail-reply-btn:hover { background:#f8f7f5; border-color:#d5d2da; }
|
||||
.mail-detail-reply-btn.subtle { color:#5a5562; background:#fafafa; }
|
||||
.mail-thread-messages.is-thread { padding:0 var(--reader-x0); }
|
||||
.mail-thread-message { padding:18px 0; border-top:1px solid #f0eef2; }
|
||||
.mail-thread-message:first-child { border-top:0; }
|
||||
.mail-thread-message-collapsed { width:100%; padding:12px 0; display:grid; grid-template-columns:38px minmax(0,1fr) auto; gap:12px; align-items:center; border:0; border-top:1px solid #f0eef2; background:transparent; text-align:left; }
|
||||
.mail-thread-message-collapsed:first-child { border-top:0; }
|
||||
.mail-thread-message-collapsed:hover { background:#faf9fc; }
|
||||
.mail-thread-message-collapsed-main { min-width:0; display:grid; gap:2px; }
|
||||
.mail-thread-message-collapsed-main strong { font:600 12px/1.3 "DM Sans"; color:#171424; }
|
||||
.mail-thread-message-collapsed-main span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#77758a; font-size:11px; }
|
||||
.mail-thread-message-collapsed time { color:#77758a; font-size:11px; white-space:nowrap; }
|
||||
.mailbox-thread-count { min-width:18px; height:18px; padding:0 6px; display:inline-grid; place-items:center; border-radius:999px; background:#f1eff3; color:#716d7a; font-size:10px; font-weight:600; }
|
||||
.agent-panel.email-context .agent-stat-grid,
|
||||
.agent-panel.email-context .agent-context-block,
|
||||
.agent-panel.email-context .agent-quick { display:none; }
|
||||
|
|
|
|||
71
frontend/src/utils/handoffUtils.js
Normal file
71
frontend/src/utils/handoffUtils.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
export const HANDOFF_LABELS = ["Important", "Finance", "Clients", "Projects", "Personal", "Support"];
|
||||
|
||||
export const EMAIL_TYPE_OPTIONS = [
|
||||
{
|
||||
id: "utility",
|
||||
title: "Utility",
|
||||
description: "Account updates, alerts and service information",
|
||||
},
|
||||
{
|
||||
id: "transactional",
|
||||
title: "Transactional",
|
||||
description: "Orders, payments, invoices, receipts and account activity",
|
||||
},
|
||||
{
|
||||
id: "promotional",
|
||||
title: "Promotional",
|
||||
description: "Offers, deals and promotional messages",
|
||||
},
|
||||
{
|
||||
id: "marketing",
|
||||
title: "Marketing",
|
||||
description: "Campaigns, newsletters and marketing outreach",
|
||||
},
|
||||
];
|
||||
|
||||
export const INSTRUCTION_EXAMPLES = [
|
||||
"Refund requests",
|
||||
"Human requested",
|
||||
"Approval needed",
|
||||
];
|
||||
|
||||
function joinOrNone(items, fallback = "any email") {
|
||||
if (!items?.length) return fallback;
|
||||
if (items.length === 1) return items[0];
|
||||
if (items.length === 2) return `${items[0]} or ${items[1]}`;
|
||||
return `${items.slice(0, -1).join(", ")} or ${items.at(-1)}`;
|
||||
}
|
||||
|
||||
function typeTitles(types) {
|
||||
return types.map((id) => EMAIL_TYPE_OPTIONS.find((item) => item.id === id)?.title || id);
|
||||
}
|
||||
|
||||
export function buildHandoffSummary({
|
||||
ownerEmail,
|
||||
labels,
|
||||
emailTypes,
|
||||
instructions,
|
||||
}) {
|
||||
const labelText = labels?.length
|
||||
? `${joinOrNone(labels)} emails`
|
||||
: "eligible emails";
|
||||
const typeText = emailTypes?.length
|
||||
? typeTitles(emailTypes).join(" or ").toLowerCase()
|
||||
: "any type";
|
||||
const instructionText = instructions?.trim()
|
||||
? "when they require your personal decision or attention"
|
||||
: "when your handoff instructions apply";
|
||||
|
||||
return `Your agent will handle email normally. ${labelText} that are ${typeText} will be handed to ${ownerEmail || "the human owner"} ${instructionText}.`;
|
||||
}
|
||||
|
||||
export function buildRuleCardSummary(rule) {
|
||||
const parts = [];
|
||||
if (rule.labels?.length) parts.push(rule.labels.join(", "));
|
||||
if (rule.email_types?.length) {
|
||||
parts.push(typeTitles(rule.email_types).join(", "));
|
||||
}
|
||||
const meta = parts.length ? parts.join(" • ") : "All eligible emails";
|
||||
const replies = rule.allow_replies ? "Replies enabled" : "View only";
|
||||
return `${meta} • ${replies}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue