human hand off bug

This commit is contained in:
debu-2 2026-10-06 18:27:06 +05:30
parent 223f846782
commit 6e7a0b9539
22 changed files with 1040 additions and 336 deletions

View file

@ -20,6 +20,7 @@ import {
listMyDomains,
verifyMyDomainDns,
} from "../api";
import { formatRelativeTime, formatShortDate } from "../utils/datetime";
const ADD_STEPS = [
{ id: 1, title: "Add domain" },
@ -57,18 +58,6 @@ const DNS_GROUPS = [
},
];
function formatRelativeTime(date) {
if (!date) return null;
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
const days = Math.floor(hours / 24);
return `${days} day${days === 1 ? "" : "s"} ago`;
}
async function copyText(value) {
try {
await navigator.clipboard.writeText(value);
@ -419,7 +408,7 @@ function DomainDetail({
<dl className="company-domain-meta">
<div><dt>Status</dt><dd>{domain.status === "active" ? "Active" : "Pending DNS"}</dd></div>
<div><dt>DKIM selector</dt><dd><code>{domain.dkim_selector}</code></dd></div>
<div><dt>Added</dt><dd>{new Date(domain.created_at).toLocaleDateString()}</dd></div>
<div><dt>Added</dt><dd>{formatShortDate(domain.created_at)}</dd></div>
</dl>
</div>
)}

View file

@ -15,6 +15,7 @@ import {
listMailGroups,
listOrgMembers,
} from "../api";
import { formatRelativeTime } from "../utils/datetime";
const TYPE_TABS = [
{ key: "all", label: "All groups" },
@ -49,17 +50,6 @@ const SEND_PERMISSIONS = [
const EMAIL_LOCAL_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
function formatRelativeTime(dateValue) {
const date = new Date(dateValue);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 3600) return `${Math.max(1, Math.floor(seconds / 60))}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
const days = Math.floor(seconds / 86400);
if (days < 7) return `${days}d ago`;
const weeks = Math.floor(days / 7);
return `${weeks}w ago`;
}
function groupInitial(name) {
return (name?.trim()?.[0] || "G").toUpperCase();
}
@ -594,7 +584,7 @@ export default function CompanyGroups({ account }) {
{group.member_count}
</span>
</td>
<td>{formatRelativeTime(group.created_at)}</td>
<td>{formatRelativeTime(group.created_at, { compact: true })}</td>
<td>
<button type="button" className="people-row-menu" aria-label="More actions">
<MoreHorizontal size={16} />

View file

@ -20,6 +20,7 @@ import {
verifyMyDomainDns,
} from "../api";
import { getOrgDetails } from "./CompanyOverviewSetup";
import { formatRelativeTime, parseApiDate } from "../utils/datetime";
const HEALTH_ITEMS = [
{ purpose: "ownership", label: "Domain verified" },
@ -29,16 +30,6 @@ const HEALTH_ITEMS = [
{ purpose: "dmarc", label: "DMARC" },
];
function formatRelativeTime(dateValue) {
const date = new Date(dateValue);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 3600) return `${Math.max(1, Math.floor(seconds / 60))}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
const days = Math.floor(seconds / 86400);
if (days < 7) return `${days}d ago`;
return `${Math.floor(days / 7)}w ago`;
}
function companyTitle(account, details, domainName) {
if (details?.companyName?.trim()) return details.companyName.trim();
if (domainName) {
@ -147,7 +138,7 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
})),
];
return events
.sort((left, right) => new Date(right.time) - new Date(left.time))
.sort((left, right) => (parseApiDate(right.time)?.getTime() || 0) - (parseApiDate(left.time)?.getTime() || 0))
.slice(0, 5);
}, [groups.items, members.items]);
@ -243,7 +234,7 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
{dnsError
? dnsError
: lastChecked
? `Last checked ${formatRelativeTime(lastChecked)}`
? `Last checked ${formatRelativeTime(lastChecked, { compact: true })}`
: "Not checked yet"}
</span>
<button type="button" onClick={loadDnsHealth} disabled={dnsLoading || !primaryDomain}>
@ -274,7 +265,7 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
</span>
<div>
<p>{item.text}</p>
<time>{formatRelativeTime(item.time)}</time>
<time>{formatRelativeTime(item.time, { compact: true })}</time>
</div>
</li>
);

View file

@ -28,13 +28,14 @@ import {
RemoveFormatting,
IndentIncrease,
IndentDecrease,
Type,
} from "lucide-react";
import AgentSpark from "./AgentSpark";
import RecipientField, { recipientsToPayload } from "./compose/RecipientField";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { listMailAliases, listOrgMembers, sendEmail } from "../api";
import { clearDraft, draftStorageKey } from "../utils/composeDraft";
import { plainTextFromEmailHtml } from "../utils/emailReply";
import { escapeHtml, htmlForOutboundEmail, plainTextFromEmailHtml, sanitizeEmailHtml } from "../utils/emailReply";
const EMOJI_SAMPLES = ["😀", "😊", "👍", "🙏", "✅", "🎉", "📎", "📅", "💡", "🔥", "❤️", "😅"];
@ -115,6 +116,37 @@ function MenuDropdown({
);
}
function normalizeLinkUrl(value) {
const trimmed = (value || "").trim();
if (!trimmed) return "";
if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed;
if (trimmed.startsWith("/") || trimmed.startsWith("#")) return trimmed;
return `https://${trimmed}`;
}
function selectionInside(editor) {
const selection = window.getSelection();
if (!editor || !selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
if (!editor.contains(range.commonAncestorContainer)) return null;
return { selection, range };
}
function FormatButton({ title, active, onClick, children }) {
return (
<button
type="button"
title={title}
className={active ? "active" : ""}
aria-pressed={Boolean(active)}
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
>
{children}
</button>
);
}
function SendSplitButton({ onSend, onSchedule, disabled, iconStroke = 1.7 }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
@ -179,6 +211,8 @@ export default function ComposeModal({
const imageInputRef = useRef(null);
const draftTimerRef = useRef(null);
const draftHydratedRef = useRef(false);
const savedRangeRef = useRef(null);
const linkPopoverRef = useRef(null);
const [showCc, setShowCc] = useState(false);
const [showBcc, setShowBcc] = useState(false);
@ -195,6 +229,8 @@ export default function ComposeModal({
const [notice, setNotice] = useState("");
const [warnings, setWarnings] = useState([]);
const [showEmoji, setShowEmoji] = useState(false);
const [linkDialog, setLinkDialog] = useState(null);
const [formatState, setFormatState] = useState({});
const [showQuoted, setShowQuoted] = useState(false);
const [status, setStatus] = useState({ sending: false, error: "" });
const [replyToMessageId, setReplyToMessageId] = useState(null);
@ -303,6 +339,26 @@ export default function ComposeModal({
setWarnings(detectWarnings({ bodyHtml, to, cc, bcc, attachments, orgDomain }));
}, [bodyHtml, to, cc, bcc, attachments, orgDomain]);
useEffect(() => {
function onSelectionChange() {
const current = selectionInside(editorRef.current);
if (!current) return;
savedRangeRef.current = current.range.cloneRange();
updateFormatState();
}
document.addEventListener("selectionchange", onSelectionChange);
return () => document.removeEventListener("selectionchange", onSelectionChange);
}, []);
useEffect(() => {
if (!linkDialog) return undefined;
function onPointerDown(event) {
if (!linkPopoverRef.current?.contains(event.target)) setLinkDialog(null);
}
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, [linkDialog]);
function flashNotice(message) {
setNotice(message);
window.setTimeout(() => setNotice(""), 2800);
@ -310,24 +366,95 @@ export default function ComposeModal({
function syncBodyFromEditor() {
const html = editorRef.current?.innerHTML || "";
setBodyHtml(html === "<br>" ? "" : html);
setBodyHtml(html === "<br>" || html === "<div><br></div>" ? "" : html);
}
function saveSelection() {
const current = selectionInside(editorRef.current);
savedRangeRef.current = current ? current.range.cloneRange() : savedRangeRef.current;
return savedRangeRef.current;
}
function restoreSelection() {
const editor = editorRef.current;
const range = savedRangeRef.current;
if (!editor) return false;
editor.focus();
if (!range) return false;
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
return true;
}
function updateFormatState() {
try {
setFormatState({
bold: document.queryCommandState("bold"),
italic: document.queryCommandState("italic"),
underline: document.queryCommandState("underline"),
strikeThrough: document.queryCommandState("strikeThrough"),
justifyLeft: document.queryCommandState("justifyLeft"),
justifyCenter: document.queryCommandState("justifyCenter"),
justifyRight: document.queryCommandState("justifyRight"),
insertUnorderedList: document.queryCommandState("insertUnorderedList"),
insertOrderedList: document.queryCommandState("insertOrderedList"),
});
} catch {
/* queryCommandState can throw in some browsers */
}
}
function execFormat(command, value = null) {
editorRef.current?.focus();
restoreSelection();
document.execCommand("defaultParagraphSeparator", false, "div");
document.execCommand(command, false, value);
saveSelection();
syncBodyFromEditor();
updateFormatState();
}
function insertLink() {
const url = window.prompt("Enter link URL", "https://");
function openLinkDialog() {
restoreSelection();
saveSelection();
const current = selectionInside(editorRef.current);
const selectedText = current?.selection.toString() || "";
let url = "";
const node = current?.range.startContainer;
const element = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement;
const anchor = element?.closest?.("a");
if (anchor) url = anchor.getAttribute("href") || "";
setShowEmoji(false);
setLinkDialog({ text: selectedText, url });
}
function applyLink(event) {
event?.preventDefault();
if (!linkDialog) return;
const url = normalizeLinkUrl(linkDialog.url);
if (!url) return;
execFormat("createLink", url);
const label = (linkDialog.text || "").trim() || url;
restoreSelection();
const current = selectionInside(editorRef.current);
const selectedText = current?.selection.toString() || "";
if (selectedText && selectedText === label) {
document.execCommand("createLink", false, url);
} else {
document.execCommand(
"insertHTML",
false,
`<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`,
);
}
saveSelection();
syncBodyFromEditor();
setLinkDialog(null);
}
function insertEmoji(emoji) {
editorRef.current?.focus();
restoreSelection();
document.execCommand("insertText", false, emoji);
saveSelection();
syncBodyFromEditor();
setShowEmoji(false);
}
@ -336,9 +463,10 @@ export default function ComposeModal({
if (!signature.html) return;
const editor = editorRef.current;
if (!editor) return;
editor.focus();
restoreSelection();
const block = `${editor.innerHTML ? "<br>" : ""}${signature.html}`;
document.execCommand("insertHTML", false, block);
saveSelection();
syncBodyFromEditor();
flashNotice(`Inserted signature: ${signature.label}`);
}
@ -386,11 +514,25 @@ export default function ComposeModal({
onDiscard?.();
}
function handleEditorPaste(event) {
const html = event.clipboardData?.getData("text/html");
const text = event.clipboardData?.getData("text/plain");
if (!html && !text) return;
event.preventDefault();
if (html) document.execCommand("insertHTML", false, sanitizeEmailHtml(html));
else document.execCommand("insertText", false, text);
syncBodyFromEditor();
}
const submitSend = useCallback(async () => {
if (!to.length) {
setStatus({ sending: false, error: "Add at least one recipient." });
return;
}
const rawHtml = editorRef.current?.innerHTML || bodyHtml;
const outboundHtml = htmlForOutboundEmail(rawHtml);
const outboundText = plainTextFromHtml(outboundHtml || rawHtml);
setBodyHtml(rawHtml === "<br>" || rawHtml === "<div><br></div>" ? "" : rawHtml);
setStatus({ sending: true, error: "" });
try {
const payload = {
@ -398,8 +540,8 @@ export default function ComposeModal({
cc: recipientsToPayload(cc),
bcc: recipientsToPayload(bcc),
subject,
body_text: plainTextFromHtml(bodyHtml),
body_html: bodyHtml || null,
body_text: outboundText,
body_html: outboundHtml || null,
};
if (replyToMessageId) {
payload.reply_to_message_id = replyToMessageId;
@ -419,12 +561,22 @@ export default function ComposeModal({
const mod = event.metaKey || event.ctrlKey;
if (!isFocused) return;
if (mod && event.key === "Enter" && !status.sending) {
if (linkDialog) {
event.preventDefault();
applyLink(event);
return;
}
event.preventDefault();
submitSend();
}
if (event.key === "Escape" && linkDialog) {
event.preventDefault();
setLinkDialog(null);
return;
}
if (mod && event.key === "k") {
event.preventDefault();
insertLink();
openLinkDialog();
}
if (mod && event.shiftKey && event.key.toLowerCase() === "c") {
event.preventDefault();
@ -437,7 +589,7 @@ export default function ComposeModal({
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [status.sending, submitSend, isFocused]);
}, [status.sending, submitSend, isFocused, linkDialog]);
function popOut() {
const url = `${window.location.origin}${window.location.pathname}?popup=compose&compose=${windowId}`;
@ -595,7 +747,14 @@ export default function ComposeModal({
role="textbox"
aria-multiline="true"
data-placeholder="Write your message…"
onInput={syncBodyFromEditor}
onFocus={() => document.execCommand("defaultParagraphSeparator", false, "div")}
onMouseUp={saveSelection}
onKeyUp={saveSelection}
onInput={() => {
syncBodyFromEditor();
updateFormatState();
}}
onPaste={handleEditorPaste}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
@ -614,6 +773,37 @@ export default function ComposeModal({
</button>
)}
{linkDialog && (
<form
className="compose-link-popover"
ref={linkPopoverRef}
role="dialog"
aria-label="Insert link"
onSubmit={applyLink}
>
<label className="compose-link-row">
<Type size={16} strokeWidth={1.7} />
<input
value={linkDialog.text}
onChange={(event) => setLinkDialog((current) => ({ ...current, text: event.target.value }))}
placeholder="Text"
autoFocus
/>
</label>
<label className="compose-link-row">
<Link size={16} strokeWidth={1.7} />
<input
value={linkDialog.url}
onChange={(event) => setLinkDialog((current) => ({ ...current, url: event.target.value }))}
placeholder="Type or paste a link"
/>
<button type="submit" className="compose-link-apply" disabled={!linkDialog.url.trim()}>
Apply
</button>
</label>
</form>
)}
<div className="compose-bottom-stack">
<div className="compose-agent-bar compose-agent-bar-disabled" title="AG Agent drafting — coming soon">
<AgentSpark />
@ -629,29 +819,29 @@ export default function ComposeModal({
<div className="compose-format-bar" aria-label="Formatting">
<div className="compose-format-group">
<button type="button" title="Bold" onClick={() => execFormat("bold")}><Bold size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Italic" onClick={() => execFormat("italic")}><Italic size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Underline" onClick={() => execFormat("underline")}><Underline size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Strikethrough" onClick={() => execFormat("strikeThrough")}><Strikethrough size={14} strokeWidth={iconStroke} /></button>
<FormatButton title="Bold" active={formatState.bold} onClick={() => execFormat("bold")}><Bold size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Italic" active={formatState.italic} onClick={() => execFormat("italic")}><Italic size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Underline" active={formatState.underline} onClick={() => execFormat("underline")}><Underline size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Strikethrough" active={formatState.strikeThrough} onClick={() => execFormat("strikeThrough")}><Strikethrough size={14} strokeWidth={iconStroke} /></FormatButton>
</div>
<span className="compose-format-divider" />
<div className="compose-format-group compose-format-align">
<button type="button" title="Align left" onClick={() => execFormat("justifyLeft")}><AlignLeft size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Align center" onClick={() => execFormat("justifyCenter")}><AlignCenter size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Align right" onClick={() => execFormat("justifyRight")}><AlignRight size={14} strokeWidth={iconStroke} /></button>
<FormatButton title="Align left" active={formatState.justifyLeft} onClick={() => execFormat("justifyLeft")}><AlignLeft size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Align center" active={formatState.justifyCenter} onClick={() => execFormat("justifyCenter")}><AlignCenter size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Align right" active={formatState.justifyRight} onClick={() => execFormat("justifyRight")}><AlignRight size={14} strokeWidth={iconStroke} /></FormatButton>
</div>
<span className="compose-format-divider" />
<div className="compose-format-group">
<button type="button" title="Bulleted list" onClick={() => execFormat("insertUnorderedList")}><List size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Numbered list" onClick={() => execFormat("insertOrderedList")}><ListOrdered size={14} strokeWidth={iconStroke} /></button>
<FormatButton title="Bulleted list" active={formatState.insertUnorderedList} onClick={() => execFormat("insertUnorderedList")}><List size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Numbered list" active={formatState.insertOrderedList} onClick={() => execFormat("insertOrderedList")}><ListOrdered size={14} strokeWidth={iconStroke} /></FormatButton>
</div>
<span className="compose-format-divider compose-format-divider-advanced" />
<div className="compose-format-group compose-format-advanced">
<button type="button" title="Indent" onClick={() => execFormat("indent")}><IndentIncrease size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Outdent" onClick={() => execFormat("outdent")}><IndentDecrease size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Quote" onClick={() => execFormat("formatBlock", "blockquote")}><Quote size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Link" onClick={insertLink}><Link size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Remove formatting" onClick={() => execFormat("removeFormat")}><RemoveFormatting size={14} strokeWidth={iconStroke} /></button>
<FormatButton title="Indent" onClick={() => execFormat("indent")}><IndentIncrease size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Outdent" onClick={() => execFormat("outdent")}><IndentDecrease size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Quote" onClick={() => execFormat("formatBlock", "blockquote")}><Quote size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Link" onClick={openLinkDialog}><Link size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Remove formatting" onClick={() => execFormat("removeFormat")}><RemoveFormatting size={14} strokeWidth={iconStroke} /></FormatButton>
</div>
</div>
@ -680,11 +870,23 @@ export default function ComposeModal({
<button type="button" className="compose-icon-btn" title="Insert image" onClick={() => imageInputRef.current?.click()}>
<Image size={16} strokeWidth={iconStroke} />
</button>
<button type="button" className="compose-icon-btn" title="Insert link" onClick={insertLink}>
<button
type="button"
className="compose-icon-btn"
title="Insert link"
onMouseDown={(event) => event.preventDefault()}
onClick={openLinkDialog}
>
<Link size={16} strokeWidth={iconStroke} />
</button>
<div className="compose-emoji-wrap">
<button type="button" className="compose-icon-btn" title="Emoji" onClick={() => setShowEmoji((v) => !v)}>
<button
type="button"
className="compose-icon-btn"
title="Emoji"
onMouseDown={(event) => event.preventDefault()}
onClick={() => setShowEmoji((v) => !v)}
>
<Smile size={16} strokeWidth={iconStroke} />
</button>
{showEmoji && (

View file

@ -1,6 +1,7 @@
import { ArrowLeft } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { getHandoffConversation, retryHandoffDelivery } from "../api";
import { formatClockTime } from "../utils/datetime";
function HandoffMessageBody({ message }) {
const [showQuoted, setShowQuoted] = useState(false);
@ -25,14 +26,6 @@ function HandoffMessageBody({ message }) {
);
}
function formatTime(dateValue) {
if (!dateValue) return "";
return new Date(dateValue).toLocaleString(undefined, {
hour: "numeric",
minute: "2-digit",
});
}
export default function HandoffConversation({ handoffId, onBack, onOpenExternal }) {
const token = sessionStorage.getItem("agmail_access_token");
const [conversation, setConversation] = useState(null);
@ -111,7 +104,7 @@ export default function HandoffConversation({ handoffId, onBack, onOpenExternal
? "HUMAN OWNER"
: "GENTIC → HUMAN"}
</strong>
<time>{formatTime(message.event_at)}</time>
<time>{formatClockTime(message.event_at)}</time>
</header>
<HandoffMessageBody message={message} />
{message.role === "human" && message.relay_status === "SENT" && (

View file

@ -1,20 +1,21 @@
import { ArrowLeftRight } from "lucide-react";
import { ArrowLeftRight, ChevronRight } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { listHandoffConversations } from "../api";
import { formatRelativeTime } from "../utils/datetime";
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();
function ownerDisplayName(item) {
const named = (item.human_name || "").trim();
if (named) return named;
const local = (item.human_email || "").split("@")[0];
if (!local) return item.rule_name || "Human Owner";
return local.replace(/[._-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
function previewSnippet(value) {
return (value || "")
.replace(/\s+/g, " ")
.trim()
.replace(/^(sender|responder|human|agent)\s*:\s*/i, "");
}
export default function HandoffInbox({ mailbox = "handoff_inbox", onSelect }) {
@ -22,6 +23,7 @@ export default function HandoffInbox({ mailbox = "handoff_inbox", onSelect }) {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const isInbox = mailbox === "handoff_inbox";
const load = useCallback(async () => {
setLoading(true);
@ -42,11 +44,14 @@ export default function HandoffInbox({ mailbox = "handoff_inbox", onSelect }) {
return (
<div className="handoff-inbox">
<p className="handoff-inbox-lead">
{mailbox === "handoff_inbox"
? "Replies received from Human Owners"
: "Messages mirrored from Gentic to Human Owners"}
</p>
<header className="handoff-section-head">
<h2>{isInbox ? "Handoff Inbox" : "Handoff Outbox"}</h2>
<p>
{isInbox
? "Replies received from Human Owners."
: "Messages mirrored from Gentic to Human Owners."}
</p>
</header>
{error && <p className="form-error" role="alert">{error}</p>}
{loading ? (
<p className="people-table-empty">Loading handoffs…</p>
@ -55,7 +60,7 @@ export default function HandoffInbox({ mailbox = "handoff_inbox", onSelect }) {
<div className="human-handoff-empty-icon" aria-hidden="true">
<ArrowLeftRight size={24} strokeWidth={1.75} />
</div>
<h2>{mailbox === "handoff_inbox" ? "No Human replies yet" : "No mirrored messages yet"}</h2>
<h2>{isInbox ? "No Human replies yet" : "No mirrored messages yet"}</h2>
<p>
When the Agent hands off a customer conversation, it appears here as a private
Agent ↔ Human thread — separate from your normal Inbox.
@ -63,29 +68,41 @@ export default function HandoffInbox({ mailbox = "handoff_inbox", onSelect }) {
</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.rule_name} · {item.external_subject || "(No subject)"}</strong>
<span className="handoff-inbox-row-party">
{item.external_sender} ↔ {item.human_email}
</span>
{item.snippet && <p className="handoff-inbox-row-snippet">&ldquo;{item.snippet}&rdquo;</p>}
</div>
<div className="handoff-inbox-row-meta">
<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>
))}
{items.map((item) => {
const preview = previewSnippet(item.snippet);
return (
<button
key={item.id}
type="button"
className="handoff-inbox-row"
onClick={() => onSelect(item.id)}
>
<div className="handoff-inbox-row-main">
<p className="handoff-inbox-row-title">
<strong>{ownerDisplayName(item)}</strong>
<span> · </span>
<em>{item.external_subject || "(No subject)"}</em>
</p>
<span className="handoff-inbox-row-party">
{item.external_sender}
<span aria-hidden="true"> ↔ </span>
{item.human_email}
</span>
{preview && <p className="handoff-inbox-row-snippet">{preview}</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>
<ChevronRight className="handoff-inbox-row-chevron" size={14} strokeWidth={1.75} />
</button>
);
})}
</div>
)}
</div>

View file

@ -16,8 +16,10 @@ export default function HumanHandoff({ onOpenExternal }) {
<section className="human-handoff-page">
<header className="human-handoff-head">
<div>
<h1>Human Handoff</h1>
<p>Private mirrored conversations between your Agent and Human Owners.</p>
<h1 className="handoff-page-title">Human Handoff</h1>
<p className="handoff-page-lead">
Manage conversations that your Agent has handed over to a Human Owner.
</p>
</div>
</header>
<nav className="human-handoff-tabs" aria-label="Human Handoff">

View file

@ -21,6 +21,8 @@ import AgentSpark from "./AgentSpark";
import SenderAvatar from "./SenderAvatar";
import { addressText, senderDisplayName } from "../utils/senderAvatar";
import { attachmentCount } from "../utils/inboxSignals";
import { visibleMessageHtml } from "../utils/emailReply";
import { formatMailDetailTime } from "../utils/datetime";
const TOOLBAR_ICON = { size: 16, strokeWidth: 1.7 };
const ACTION_ICON = { size: 14, strokeWidth: 1.7 };
@ -44,12 +46,16 @@ function messagePreview(message) {
}
function messageBody(email) {
const preview = messagePreview(email);
if (preview) return { mode: "text", value: preview };
const text = (email?.body_text || email?.text_body || email?.plain_text || "").trim();
if (text) return { mode: "text", value: text };
const html = (email?.body_html || "").trim();
const html = visibleMessageHtml(email?.body_html || "");
if (html) return { mode: "html", value: html };
const text = (
email?.new_reply_content
|| email?.body_text
|| email?.text_body
|| email?.plain_text
|| ""
).trim();
if (text) return { mode: "text", value: text };
const snippet = (email?.snippet || "").trim();
if (snippet) return { mode: "text", value: snippet };
return { mode: "text", value: "This message has no readable content." };
@ -89,20 +95,9 @@ function MessageBodyContent({ message }) {
}
function detailTime(email) {
const value = email?.received_at || email?.sent_at || email?.created_at || email?.updated_at;
if (!value) return { absolute: "", relative: "" };
const date = new Date(value);
if (Number.isNaN(date.getTime())) return { absolute: "", relative: "" };
const absolute = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date);
const diffMs = Date.now() - date.getTime();
const diffHours = Math.max(1, Math.round(diffMs / (1000 * 60 * 60)));
const relative = diffHours < 24 ? `${diffHours}h ago` : `${Math.round(diffHours / 24)}d ago`;
return { absolute, relative };
return formatMailDetailTime(
email?.received_at || email?.sent_at || email?.created_at || email?.updated_at,
);
}
function senderIdentity(email) {

View file

@ -11,28 +11,10 @@ import AgentSpark from "./AgentSpark";
import SenderAvatar from "./SenderAvatar";
import { senderDisplayName } from "../utils/senderAvatar";
import { attachmentCount, emailLabels } from "../utils/inboxSignals";
import { formatMailboxTime } from "../utils/datetime";
const ICON = { size: 15, strokeWidth: 1.75 };
function formatTimestamp(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const now = new Date();
const sameDay = date.toDateString() === now.toDateString();
if (sameDay) {
return new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
}).format(date);
}
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
...(date.getFullYear() !== now.getFullYear() ? { year: "numeric" } : {}),
}).format(date);
}
export default function MailRow({
email,
selected,
@ -129,7 +111,7 @@ export default function MailRow({
{files > 0 && <Paperclip size={14} strokeWidth={1.75} />}
</span>
<div className="mailbox-row-rail">
<time className="mailbox-time" dateTime={timestamp || undefined}>{formatTimestamp(timestamp)}</time>
<time className="mailbox-time" dateTime={timestamp || undefined}>{formatMailboxTime(timestamp)}</time>
<div className="mailbox-hover-actions" aria-label="Message actions">
<button type="button" title="Archive" aria-label="Archive" onClick={(event) => stop(event, () => onArchive?.(email))}>
<Archive {...ICON} />

View file

@ -14,6 +14,7 @@ const ICON = { size: 16, strokeWidth: 1.75 };
export default function MailboxSelectionBar({
count,
total,
hideSelectAll = false,
onToggleAll,
onArchive,
onSpam,
@ -30,12 +31,14 @@ export default function MailboxSelectionBar({
return (
<div className="mailbox-selection-bar" role="toolbar" aria-label="Bulk message actions">
<div className="mailbox-selection-meta">
<input
type="checkbox"
checked={allSelected}
aria-label={allSelected ? "Deselect all messages" : "Select all messages"}
onChange={onToggleAll}
/>
{!hideSelectAll && (
<input
type="checkbox"
checked={allSelected}
aria-label={allSelected ? "Deselect all messages" : "Select all messages"}
onChange={onToggleAll}
/>
)}
<span>{count} selected</span>
</div>
<div className="mailbox-selection-actions">

View file

@ -6,8 +6,8 @@ import {
CircleHelp,
Filter,
LayoutGrid,
List,
Mail,
MoreHorizontal,
RefreshCw,
Search,
Settings,
@ -46,7 +46,14 @@ import {
} from "../utils/inboxSignals";
import { buildReplyBodyHtml } from "../utils/emailReply";
const PAGE_SIZE = 25;
const PAGE_SIZE_KEY = "agmail_page_size";
const PAGE_SIZE_OPTIONS = [25, 50, 100];
function readPageSize() {
const stored = Number(window.localStorage.getItem(PAGE_SIZE_KEY));
return PAGE_SIZE_OPTIONS.includes(stored) ? stored : 50;
}
const CATEGORY_TABS = [
{ name: "Primary", icon: LayoutGrid },
{ name: "Updates", icon: Bell },
@ -57,10 +64,11 @@ const CATEGORY_TABS = [
];
function resultPage(result) {
if (Array.isArray(result)) return { items: result, nextCursor: null };
if (Array.isArray(result)) return { items: result, nextCursor: null, total: result.length };
return {
items: result.items || result.emails || result.results || [],
nextCursor: result.next_cursor || result.nextCursor || null,
total: Number(result.total || 0),
};
}
@ -112,6 +120,9 @@ export default function MailboxView({
const token = sessionStorage.getItem("agmail_access_token");
const [emails, setEmails] = useState([]);
const [nextCursor, setNextCursor] = useState(null);
const [total, setTotal] = useState(0);
const [pageSize, setPageSize] = useState(readPageSize);
const [pageMenuOpen, setPageMenuOpen] = useState(false);
const [cursor, setCursor] = useState(null);
const [cursorHistory, setCursorHistory] = useState([]);
const [selected, setSelected] = useState(null);
@ -140,7 +151,7 @@ export default function MailboxView({
const result = await listThreads(
{
mailbox,
limit: PAGE_SIZE,
limit: pageSize,
cursor,
q: filters.query.trim(),
is_read: filters.read === "all" ? undefined : filters.read === "read",
@ -153,11 +164,12 @@ export default function MailboxView({
const page = resultPage(result);
setEmails(page.items.map(threadToRow));
setNextCursor(page.nextCursor);
setTotal(page.total);
setStatus({ loading: false, error: "" });
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}, [cursor, filters, mailbox, token]);
}, [cursor, filters, mailbox, pageSize, token]);
useEffect(() => {
const timeout = window.setTimeout(load, filters.query ? 250 : 0);
@ -250,6 +262,22 @@ export default function MailboxView({
});
}, [emails, onStats]);
useEffect(() => {
setCursor(null);
setCursorHistory([]);
setChecked({});
setPageMenuOpen(false);
}, [mailbox]);
useEffect(() => {
if (!pageMenuOpen) return undefined;
function closeMenu() {
setPageMenuOpen(false);
}
window.addEventListener("click", closeMenu);
return () => window.removeEventListener("click", closeMenu);
}, [pageMenuOpen]);
function changeFilter(name, value) {
setFilters((current) => ({ ...current, [name]: value }));
setCursor(null);
@ -580,6 +608,7 @@ export default function MailboxView({
setCursor(nextCursor);
setSelected(null);
setDetail(null);
setChecked({});
}
function previousPage() {
@ -589,11 +618,30 @@ export default function MailboxView({
setCursor(previous);
setSelected(null);
setDetail(null);
setChecked({});
}
function changePageSize(size) {
if (size === pageSize) {
setPageMenuOpen(false);
return;
}
window.localStorage.setItem(PAGE_SIZE_KEY, String(size));
setPageSize(size);
setCursor(null);
setCursorHistory([]);
setChecked({});
setPageMenuOpen(false);
}
const initials = `${account?.first_name?.[0] || ""}${account?.last_name?.[0] || ""}`.toUpperCase();
const rangeStart = listedEmails.length ? cursorHistory.length * PAGE_SIZE + 1 : 0;
const rangeEnd = cursorHistory.length * PAGE_SIZE + listedEmails.length;
const rangeStart = emails.length ? cursorHistory.length * pageSize + 1 : 0;
const rangeEnd = emails.length ? rangeStart + emails.length - 1 : 0;
const rangeLabel = emails.length === 0
? `0–0 of ${total}`
: `${rangeStart}–${rangeEnd} of ${total || rangeEnd}`;
const allSelected = listedEmails.length > 0 && selectedCount === listedEmails.length;
const someSelected = selectedCount > 0 && !allSelected;
return (
<section className="mailbox-workspace">
@ -652,10 +700,85 @@ export default function MailboxView({
<div className="mailbox-main-column">
<div className={`mailbox-shell${selected ? " detail-visible" : ""}`}>
<div className="mailbox-list-pane">
<div className="mailbox-list-bar" role="toolbar" aria-label="Mailbox controls">
<div className="mailbox-list-bar-actions">
<label className="mailbox-select-all">
<input
type="checkbox"
checked={allSelected}
ref={(node) => {
if (node) node.indeterminate = someSelected;
}}
aria-label={allSelected ? "Deselect all messages" : "Select all messages"}
onChange={toggleSelectAll}
/>
</label>
<button
type="button"
className={status.loading ? "is-busy" : ""}
title="Refresh"
aria-label="Refresh"
onClick={load}
>
<RefreshCw size={16} strokeWidth={1.75} />
</button>
<div className="mailbox-page-size-wrap" onClick={(event) => event.stopPropagation()}>
<button
type="button"
title="More options"
aria-label="More options"
aria-expanded={pageMenuOpen}
onClick={() => setPageMenuOpen((open) => !open)}
>
<MoreHorizontal size={16} strokeWidth={1.75} />
</button>
{pageMenuOpen && (
<div className="mailbox-page-size-menu" role="menu">
<p>Emails per page</p>
{PAGE_SIZE_OPTIONS.map((size) => (
<button
key={size}
type="button"
role="menuitemradio"
aria-checked={pageSize === size}
className={pageSize === size ? "active" : ""}
onClick={() => changePageSize(size)}
>
{size}
</button>
))}
</div>
)}
</div>
</div>
<div className="mailbox-list-bar-page">
<span className="mailbox-pagination-range">{rangeLabel}</span>
<button
type="button"
title="Newer"
aria-label="Previous page"
disabled={!cursorHistory.length || status.loading}
onClick={previousPage}
>
<ChevronLeft size={18} strokeWidth={1.75} />
</button>
<button
type="button"
title="Older"
aria-label="Next page"
disabled={!nextCursor || status.loading}
onClick={nextPage}
>
<ChevronRight size={18} strokeWidth={1.75} />
</button>
</div>
</div>
{selectedCount > 0 && (
<MailboxSelectionBar
count={selectedCount}
total={listedEmails.length}
hideSelectAll
onToggleAll={toggleSelectAll}
onArchive={() => runBulkAction("archive")}
onSpam={() => runBulkAction("spam")}
@ -755,32 +878,6 @@ export default function MailboxView({
))}
</div>
{!status.loading && (
<footer className="mailbox-pagination">
<span className="mailbox-pagination-range">
{listedEmails.length === 0
? "0 messages"
: `${rangeStart}–${rangeEnd}${nextCursor ? "" : ` · ${rangeEnd} loaded`}`}
</span>
<div className="mailbox-pagination-controls">
{listedEmails.length > 0 && (
<>
<button type="button" title="Previous page" aria-label="Previous page" disabled={!cursorHistory.length} onClick={previousPage}>
<ChevronLeft size={16} strokeWidth={1.75} />
</button>
<button type="button" title="Next page" aria-label="Next page" disabled={!nextCursor} onClick={nextPage}>
<ChevronRight size={16} strokeWidth={1.75} />
</button>
</>
)}
</div>
<div className="mailbox-pagination-density" aria-label="List density">
<button type="button" className="active" title="Comfortable density" aria-label="Comfortable density"><List size={15} strokeWidth={1.75} /></button>
<button type="button" title="Compact density" aria-label="Compact density"><LayoutGrid size={15} strokeWidth={1.75} /></button>
</div>
</footer>
)}
<MailDetail
email={detail}
thread={threadDetail}

View file

@ -16,6 +16,7 @@ import greenPin from "../data/green.png";
import pinkPin from "../data/pink.png";
import yellowPin from "../data/yellow.png";
import { senderDisplayName } from "../utils/senderAvatar";
import { formatAbsoluteDateTime } from "../utils/datetime";
const NOTE_COLORS = ["yellow", "green", "blue", "pink"];
const PIN_IMAGES = {
@ -25,18 +26,6 @@ const PIN_IMAGES = {
pink: pinkPin,
};
function formatNoteTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date);
}
function noteMatchesContext(note, contextEmail) {
const needle = senderDisplayName(contextEmail).toLowerCase();
if (!needle) return false;
@ -84,7 +73,7 @@ function NoteCard({ note, onDelete, menuOpen, onMenuToggle }) {
<span key={tag}>{tag}</span>
))}
</div>
<time dateTime={note.updated_at}>{formatNoteTime(note.updated_at)}</time>
<time dateTime={note.updated_at}>{formatAbsoluteDateTime(note.updated_at)}</time>
</footer>
</article>
);