human handoffs

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

View file

@ -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) =>

View file

@ -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) => (

View file

@ -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);
}

View file

@ -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) {

View file

@ -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"

View 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>&ldquo;{conversation.linked_external.subject}&rdquo;</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>
);
}

View 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">&ldquo;{item.snippet}&rdquo;</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>
);
}

View file

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

View 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>
);
}

View 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&apos;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&apos;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>
);
}

View file

@ -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">&lt;{sender.address}&gt;</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">&lt;{sender.address}&gt;</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>
)}

View file

@ -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>

View file

@ -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}

View file

@ -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; }

View 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}`;
}