human hand off bug
This commit is contained in:
parent
223f846782
commit
6e7a0b9539
22 changed files with 1040 additions and 336 deletions
|
|
@ -1,3 +1,5 @@
|
|||
from datetime import timezone
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from pymongo import AsyncMongoClient
|
||||
|
|
@ -14,7 +16,11 @@ settings = get_settings()
|
|||
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
|
||||
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
mongo_client: AsyncMongoClient = AsyncMongoClient(settings.mongo_url)
|
||||
mongo_client: AsyncMongoClient = AsyncMongoClient(
|
||||
settings.mongo_url,
|
||||
tz_aware=True,
|
||||
tzinfo=timezone.utc,
|
||||
)
|
||||
mongo_db = mongo_client[settings.mongo_database]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -72,10 +72,10 @@ async def list_handoff_rules(
|
|||
|
||||
def _handoff_status_label(value: str) -> str:
|
||||
return {
|
||||
"ACTIVE": "Active",
|
||||
"ACTIVE": "Open",
|
||||
"WAITING_FOR_HUMAN": "Waiting for human",
|
||||
"HUMAN_REPLIED": "Human replied",
|
||||
"CLOSED": "Closed",
|
||||
"CLOSED": "Resolved",
|
||||
}.get(value, value.replace("_", " ").title())
|
||||
|
||||
|
||||
|
|
@ -125,6 +125,7 @@ async def list_handoff_conversations(
|
|||
external_subject=handoff.get("external_subject", ""),
|
||||
external_sender=handoff.get("external_sender", ""),
|
||||
human_email=handoff.get("human_email", ""),
|
||||
human_name=handoff.get("human_name", ""),
|
||||
status=handoff.get("status", "ACTIVE"),
|
||||
status_label=_handoff_status_label(
|
||||
handoff.get("status", "ACTIVE")
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ from app.schemas import (
|
|||
MessageReadState,
|
||||
MessageStarState,
|
||||
MessageSummary,
|
||||
parse_api_datetime,
|
||||
utc_iso,
|
||||
utc_now,
|
||||
)
|
||||
from app.services.domain_provisioning import get_domain_by_name
|
||||
|
|
@ -72,7 +74,7 @@ def address(value: str, name: str | None = None) -> dict[str, str]:
|
|||
|
||||
def encode_cursor(entry: dict) -> str:
|
||||
value = json.dumps(
|
||||
{"created_at": entry["created_at"].isoformat(), "id": str(entry["_id"])},
|
||||
{"created_at": utc_iso(entry["created_at"]), "id": str(entry["_id"])},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return base64.urlsafe_b64encode(value.encode()).decode().rstrip("=")
|
||||
|
|
@ -82,7 +84,7 @@ def decode_cursor(value: str) -> tuple[datetime, ObjectId]:
|
|||
try:
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded).decode())
|
||||
return datetime.fromisoformat(payload["created_at"]), ObjectId(payload["id"])
|
||||
return parse_api_datetime(payload["created_at"]), ObjectId(payload["id"])
|
||||
except (ValueError, TypeError, KeyError, json.JSONDecodeError) as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from app.database import get_mongo_database
|
|||
from app.models import Account
|
||||
from app.routers.auth import get_current_account
|
||||
from app.routers.emails import decode_cursor, encode_cursor, summarize
|
||||
from app.schemas import MessageRead, ThreadPage, ThreadRead, ThreadSummary
|
||||
from app.schemas import MessageRead, ThreadPage, ThreadRead, ThreadSummary, parse_api_datetime, utc_iso
|
||||
from app.services.mailbox_scope import apply_external_scope
|
||||
from app.services.mime_parser import parse_mime
|
||||
from app.services.reply_extractor import reply_fields_for_message
|
||||
|
|
@ -23,7 +23,7 @@ router = APIRouter(prefix="/threads", tags=["threads"])
|
|||
def encode_thread_cursor(thread: dict) -> str:
|
||||
value = json.dumps(
|
||||
{
|
||||
"latest_at": thread["latest_at"].isoformat(),
|
||||
"latest_at": utc_iso(thread["latest_at"]),
|
||||
"id": str(thread["_id"]),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
|
|
@ -35,7 +35,7 @@ def decode_thread_cursor(value: str) -> tuple[datetime, ObjectId]:
|
|||
try:
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded).decode())
|
||||
return datetime.fromisoformat(payload["latest_at"]), ObjectId(payload["id"])
|
||||
return parse_api_datetime(payload["latest_at"]), ObjectId(payload["id"])
|
||||
except (ValueError, TypeError, KeyError, json.JSONDecodeError) as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
|
|
@ -188,9 +188,10 @@ async def list_threads(
|
|||
}
|
||||
)
|
||||
pipeline.append({"$sort": {"latest_at": -1, "_id": -1}})
|
||||
page_stages: list[dict] = []
|
||||
if cursor:
|
||||
latest_at, thread_id = decode_thread_cursor(cursor)
|
||||
pipeline.append(
|
||||
page_stages.append(
|
||||
{
|
||||
"$match": {
|
||||
"$or": [
|
||||
|
|
@ -200,8 +201,8 @@ async def list_threads(
|
|||
}
|
||||
}
|
||||
)
|
||||
pipeline.append({"$limit": limit + 1})
|
||||
pipeline.append(
|
||||
page_stages.append({"$limit": limit + 1})
|
||||
page_stages.append(
|
||||
{
|
||||
"$addFields": {
|
||||
"latest_entry": {
|
||||
|
|
@ -215,8 +216,19 @@ async def list_threads(
|
|||
}
|
||||
}
|
||||
)
|
||||
cursor = await database.mailbox_entries.aggregate(pipeline)
|
||||
rows = [row async for row in cursor]
|
||||
pipeline.append(
|
||||
{
|
||||
"$facet": {
|
||||
"total": [{"$count": "count"}],
|
||||
"page": page_stages,
|
||||
}
|
||||
}
|
||||
)
|
||||
aggregate = await database.mailbox_entries.aggregate(pipeline)
|
||||
facets = [row async for row in aggregate]
|
||||
facet = facets[0] if facets else {"total": [], "page": []}
|
||||
total = int((facet.get("total") or [{}])[0].get("count") or 0)
|
||||
rows = list(facet.get("page") or [])
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
items = [thread_summary_from_group(row, mailbox) for row in rows]
|
||||
|
|
@ -225,7 +237,7 @@ async def list_threads(
|
|||
next_cursor = encode_thread_cursor(
|
||||
{"latest_at": rows[-1]["latest_at"], "_id": rows[-1]["_id"]}
|
||||
)
|
||||
return ThreadPage(items=items, next_cursor=next_cursor)
|
||||
return ThreadPage(items=items, next_cursor=next_cursor, total=total)
|
||||
|
||||
|
||||
async def message_read(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
from datetime import datetime, timezone
|
||||
from datetime import date
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
|
|
@ -313,6 +312,7 @@ class HandoffConversationSummary(BaseModel):
|
|||
external_subject: str
|
||||
external_sender: str
|
||||
human_email: str
|
||||
human_name: str = ""
|
||||
status: str
|
||||
status_label: str
|
||||
current_owner: str
|
||||
|
|
@ -488,6 +488,7 @@ class ThreadSummary(BaseModel):
|
|||
class ThreadPage(BaseModel):
|
||||
items: list[ThreadSummary]
|
||||
next_cursor: str | None = None
|
||||
total: int = 0
|
||||
|
||||
|
||||
class ThreadRead(BaseModel):
|
||||
|
|
@ -591,3 +592,18 @@ class DnsSetupResponse(BaseModel):
|
|||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def as_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def utc_iso(value: datetime) -> str:
|
||||
return as_utc(value).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_api_datetime(value: str) -> datetime:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return as_utc(parsed)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,15 @@ def test_cursor_round_trip_preserves_sort_key():
|
|||
assert entry_id == entry["_id"]
|
||||
|
||||
|
||||
def test_naive_cursor_timestamp_is_treated_as_utc():
|
||||
naive = datetime(2026, 8, 9, 12, 30)
|
||||
created_at, _entry_id = decode_cursor(
|
||||
encode_cursor({"_id": ObjectId(), "created_at": naive})
|
||||
)
|
||||
assert created_at.tzinfo is not None
|
||||
assert created_at == datetime(2026, 8, 9, 12, 30, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_summary_uses_account_mailbox_read_state():
|
||||
now = datetime(2026, 8, 9, 12, 30, tzinfo=timezone.utc)
|
||||
message_id = ObjectId()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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" && (
|
||||
|
|
|
|||
|
|
@ -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">“{item.snippet}”</p>}
|
||||
</div>
|
||||
<div className="handoff-inbox-row-meta">
|
||||
<time>{formatRelativeTime(item.updated_at)}</time>
|
||||
<span>{item.message_count} message{item.message_count === 1 ? "" : "s"}</span>
|
||||
<span className={`handoff-inbox-status status-${item.status.toLowerCase()}`}>
|
||||
{item.status_label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -527,26 +527,169 @@ 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:910px; margin:0 auto; padding:8px 4px 32px; }
|
||||
.human-handoff-head { margin-bottom:16px; display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }
|
||||
.human-handoff-head h1 { margin:0 0 4px; font:700 26px/1.15 "Manrope"; color:#171424; }
|
||||
.human-handoff-head p { margin:0; max-width:700px; color:#77758a; font-size:12px; line-height:1.5; }
|
||||
.human-handoff-tabs { margin-top:16px; display:inline-flex; gap:6px; padding:4px; border:1px solid #ece9f2; border-radius:10px; background:#faf9fc; }
|
||||
.human-handoff-tabs button { height:32px; padding:0 14px; border:0; border-radius:8px; background:transparent; color:#6f6b7c; font-size:12px; font-weight:600; }
|
||||
.human-handoff-tabs button.active { background:#fff; color:#171424; box-shadow:0 1px 4px rgba(20,18,30,0.08); }
|
||||
.handoff-inbox, .handoff-settings, .handoff-conversation { margin-top:16px; }
|
||||
.human-handoff-page { max-width:900px; margin:0; padding:8px 4px 32px; }
|
||||
.human-handoff-head { margin:0; }
|
||||
.handoff-page-title,
|
||||
.human-handoff-head h1.handoff-page-title {
|
||||
margin:0;
|
||||
font-family:"DM Sans",sans-serif;
|
||||
font-size:26px;
|
||||
line-height:1.2;
|
||||
font-weight:700;
|
||||
letter-spacing:-0.015em;
|
||||
color:#171424;
|
||||
white-space:nowrap;
|
||||
font-stretch:normal;
|
||||
font-kerning:normal;
|
||||
font-synthesis:none;
|
||||
text-shadow:none;
|
||||
transform:none;
|
||||
}
|
||||
.handoff-page-lead,
|
||||
.human-handoff-head p.handoff-page-lead {
|
||||
margin:5px 0 0;
|
||||
max-width:640px;
|
||||
font-size:12px;
|
||||
line-height:1.45;
|
||||
font-weight:400;
|
||||
color:#77758A;
|
||||
}
|
||||
.human-handoff-tabs {
|
||||
margin-top:24px;
|
||||
height:38px;
|
||||
padding:3px;
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:0;
|
||||
border:1px solid #E6E2EA;
|
||||
border-radius:9px;
|
||||
background:#FAF9FB;
|
||||
}
|
||||
.human-handoff-tabs button {
|
||||
height:30px;
|
||||
padding:0 14px;
|
||||
border:0;
|
||||
border-radius:6px;
|
||||
background:transparent;
|
||||
color:#777286;
|
||||
font-size:11px;
|
||||
font-weight:550;
|
||||
}
|
||||
.human-handoff-tabs button.active {
|
||||
background:#FFFFFF;
|
||||
color:#252130;
|
||||
box-shadow:0 1px 2px rgba(20,18,30,.06);
|
||||
}
|
||||
.handoff-inbox, .handoff-settings, .handoff-conversation { margin-top:22px; }
|
||||
.handoff-section-head { margin:0 0 12px; }
|
||||
.handoff-section-head h2 {
|
||||
margin:0;
|
||||
font-size:13px;
|
||||
font-weight:650;
|
||||
line-height:1.3;
|
||||
color:#292536;
|
||||
}
|
||||
.handoff-section-head p {
|
||||
margin:3px 0 0;
|
||||
font-size:10.5px;
|
||||
font-weight:400;
|
||||
line-height:1.4;
|
||||
color:#8A8696;
|
||||
}
|
||||
.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-list {
|
||||
overflow:hidden;
|
||||
border:1px solid #E7E4EA;
|
||||
border-radius:11px;
|
||||
background:#FFFFFF;
|
||||
box-shadow:0 1px 2px rgba(20,18,30,.02);
|
||||
}
|
||||
.handoff-inbox-row {
|
||||
width:100%;
|
||||
min-height:82px;
|
||||
padding:14px 16px;
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1fr) 120px 16px;
|
||||
gap:12px;
|
||||
align-items:center;
|
||||
border:0;
|
||||
border-bottom:1px solid #EFEDF1;
|
||||
border-radius:0;
|
||||
background:#fff;
|
||||
text-align:left;
|
||||
cursor:pointer;
|
||||
box-shadow:none;
|
||||
transition:background 120ms ease;
|
||||
}
|
||||
.handoff-inbox-row:last-child { border-bottom:0; }
|
||||
.handoff-inbox-row:hover { background:#FAF9FC; }
|
||||
.handoff-inbox-row-main { min-width:0; }
|
||||
.handoff-inbox-row-title {
|
||||
margin:0;
|
||||
font-size:13px;
|
||||
line-height:1.3;
|
||||
color:#211E2C;
|
||||
}
|
||||
.handoff-inbox-row-title strong { font-weight:650; }
|
||||
.handoff-inbox-row-title span { font-weight:500; color:#8A8696; }
|
||||
.handoff-inbox-row-title em { font-style:normal; font-weight:500; color:#211E2C; }
|
||||
.handoff-inbox-row-party {
|
||||
display:block;
|
||||
margin-top:3px;
|
||||
color:#77758A;
|
||||
font-size:9.5px;
|
||||
font-weight:500;
|
||||
text-transform:none;
|
||||
letter-spacing:0;
|
||||
white-space:nowrap;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
}
|
||||
.handoff-inbox-row-party span { color:#7564D9; }
|
||||
.handoff-inbox-row-snippet {
|
||||
margin:4px 0 0;
|
||||
max-width:100%;
|
||||
color:#77758A;
|
||||
font-size:11px;
|
||||
font-weight:400;
|
||||
line-height:1.4;
|
||||
white-space:nowrap;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
}
|
||||
.handoff-inbox-row-meta {
|
||||
width:120px;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
align-items:flex-end;
|
||||
color:#8A8696;
|
||||
font-size:10px;
|
||||
font-weight:400;
|
||||
}
|
||||
.handoff-inbox-row-meta time { font-size:10px; font-weight:400; color:#8A8696; }
|
||||
.handoff-inbox-row-meta>span:not(.handoff-inbox-status) {
|
||||
margin-top:2px;
|
||||
font-size:9.5px;
|
||||
font-weight:400;
|
||||
color:#A09CAA;
|
||||
}
|
||||
.handoff-inbox-status {
|
||||
height:20px;
|
||||
margin-top:7px;
|
||||
padding:0 8px;
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
border-radius:999px;
|
||||
background:#F2F1F5;
|
||||
color:#686475;
|
||||
font-size:9px;
|
||||
font-weight:600;
|
||||
}
|
||||
.handoff-inbox-status.status-waiting_for_human { background:#FFF5DF; color:#A96813; }
|
||||
.handoff-inbox-status.status-human_replied { background:#edf4ff; color:#2f67c5; }
|
||||
.handoff-inbox-status.status-agent_replied { background:#edf9f4; color:#11966a; }
|
||||
.handoff-inbox-row-chevron { color:#C6C2CE; opacity:0; justify-self:end; transition:opacity 120ms ease; }
|
||||
.handoff-inbox-row:hover .handoff-inbox-row-chevron { opacity:1; }
|
||||
.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; }
|
||||
|
|
@ -710,10 +853,26 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
|||
.mailbox-empty-link { border:0; background:transparent; color:#7b67c8; font:600 12px "DM Sans"; cursor:pointer; }
|
||||
.mailbox-empty-link:hover { color:#5a3fe0; }
|
||||
.mailbox-empty-badge { margin:14px 0 0; display:inline-flex; align-items:center; gap:6px; color:#3d8a5d; font:500 11px "DM Sans"; }
|
||||
.agent-ready-card { margin:0 0 12px; padding:12px 13px; border:1px solid rgba(118,86,232,0.12); border-radius:12px; background:#faf8ff; }
|
||||
.agent-ready-card { margin:0 0 12px; padding:10px 12px; border:1px solid rgba(118,86,232,0.07); border-radius:8px; background:#fcfbfe; }
|
||||
.agent-ready-card p { margin:0; color:#77758A; font:400 10px/15px "DM Sans"; }
|
||||
.agent-ready-lead { margin:0 0 6px !important; display:flex; align-items:center; gap:6px; color:#5a3fe0 !important; font:600 11px/14px "DM Sans" !important; }
|
||||
.agent-ready-lead { margin:0 0 6px !important; display:flex; align-items:center; gap:6px; color:#6d6680 !important; font:600 11px/14px "DM Sans" !important; }
|
||||
.agent-quick-btn:disabled { opacity:.45; cursor:not-allowed; }
|
||||
.mailbox-list-bar { min-height:48px; padding:0 8px 0 12px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-bottom:1px solid var(--border-subtle); background:var(--surface); }
|
||||
.mailbox-list-bar-actions,.mailbox-list-bar-page { display:flex; align-items:center; gap:2px; }
|
||||
.mailbox-select-all { width:40px; height:40px; display:grid; place-items:center; }
|
||||
.mailbox-select-all input { width:16px; height:16px; margin:0; accent-color:#5f6368; }
|
||||
.mailbox-list-bar-actions>button,.mailbox-page-size-wrap>button,.mailbox-list-bar-page button { width:40px; height:40px; display:grid; place-items:center; border:0; border-radius:50%; background:transparent; color:#5f6368; }
|
||||
.mailbox-list-bar-actions>button:hover,.mailbox-page-size-wrap>button:hover,.mailbox-list-bar-page button:hover:not(:disabled) { background:#f1f3f4; }
|
||||
.mailbox-list-bar-page button:disabled { opacity:.35; cursor:default; }
|
||||
.mailbox-list-bar-actions>button.is-busy svg { animation:mailbox-spin .8s linear infinite; }
|
||||
.mailbox-pagination-range { margin-right:8px; color:#5f6368; font-size:12px; font-weight:500; white-space:nowrap; }
|
||||
.mailbox-page-size-wrap { position:relative; }
|
||||
.mailbox-page-size-menu { position:absolute; top:42px; left:0; z-index:8; min-width:168px; padding:8px 0; border:1px solid #ece9f2; border-radius:10px; background:#fff; box-shadow:0 8px 24px rgba(20,18,30,.12); }
|
||||
.mailbox-page-size-menu p { margin:0; padding:6px 14px 8px; color:#6f6b7c; font-size:11px; font-weight:700; letter-spacing:.02em; text-transform:uppercase; }
|
||||
.mailbox-page-size-menu button { width:100%; height:36px; padding:0 14px; display:flex; align-items:center; justify-content:flex-start; border:0; border-radius:0; background:transparent; color:#241f2b; font-size:13px; }
|
||||
.mailbox-page-size-menu button:hover,.mailbox-page-size-menu button.active { background:#f6f3ff; color:#5a3fe0; }
|
||||
@keyframes mailbox-spin { to { transform:rotate(360deg); } }
|
||||
|
||||
.mailbox-pagination { min-height:44px; padding:var(--space-2) var(--space-4); display:grid; grid-template-columns:1fr auto auto; align-items:center; gap:var(--space-3); border-top:1px solid var(--border-subtle); background:var(--surface); color:#8d8792; font-size:var(--text-micro); }
|
||||
.mailbox-pagination-range { font-weight:500; }
|
||||
.mailbox-pagination-controls,.mailbox-pagination-density { display:flex; align-items:center; gap:var(--space-1); }
|
||||
|
|
@ -788,74 +947,86 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
|||
.mailbox-shell.detail-visible .mailbox-list-pane { display:block; min-height:0; height:auto; background:var(--canvas); border-radius:0; padding:0 0 24px; }
|
||||
.mail-detail { min-width:0; display:none; position:relative; z-index:1; }
|
||||
.mail-detail.open { display:block; }
|
||||
.mail-detail-reader { --reader-x0:32px; --reader-avatar:38px; --reader-gap:8px; --reader-x2:calc(var(--reader-x0) + var(--reader-avatar) + var(--reader-gap)); width:100%; height:auto; min-height:0; background:#fff; border:1px solid rgba(25,22,40,.05); border-radius:12px; box-shadow:0 1px 2px rgba(20,18,30,.02),0 6px 18px rgba(20,18,30,.025); overflow:hidden; }
|
||||
.mail-detail-toolbar { height:48px; padding:0 24px; display:flex; align-items:center; gap:2px; border-bottom:1px solid #ECE9EE; background:#fff; }
|
||||
.mail-detail-reader { --reader-x0:32px; --reader-avatar:38px; --reader-gap:8px; --reader-x2:calc(var(--reader-x0) + var(--reader-avatar) + var(--reader-gap)); width:100%; height:auto; min-height:0; background:#fff; border:1px solid rgba(25,22,40,.055); border-radius:8px; box-shadow:none; overflow:hidden; }
|
||||
.mail-detail-toolbar { height:48px; padding:0 var(--reader-x0); display:flex; align-items:center; gap:2px; border-bottom:1px solid #ECE9EE; background:#fff; }
|
||||
.mail-detail-toolbar-group { display:flex; align-items:center; gap:2px; }
|
||||
.mail-detail-toolbar-sep { width:1px; height:18px; margin:0 10px; background:#ECE9EE; flex-shrink:0; }
|
||||
.mail-detail-toolbar button { width:32px; height:32px; padding:0; display:grid; place-items:center; border:0; border-radius:7px; background:transparent; color:#6f6b7c; }
|
||||
.mail-detail-toolbar button { width:32px; height:32px; padding:0; display:grid; place-items:center; border:0; border-radius:6px; background:transparent; color:#6f6b7c; }
|
||||
.mail-detail-toolbar button svg { width:16px; height:16px; }
|
||||
.mail-detail-toolbar button:hover { background:#f5f4f2; color:#241f2b; }
|
||||
.mail-detail-toolbar button.destructive { color:#6f6b7c; }
|
||||
.mail-detail-toolbar button.destructive:hover { background:#fff0ed; color:#e94d3d; }
|
||||
.mail-detail-sheet { height:auto; min-height:0; margin:0; background:#fff; }
|
||||
.mail-detail-state { min-height:160px; padding:32px; display:grid; place-items:center; color:#8d8891; font-size:12px; text-align:center; background:#fff; border:1px solid rgba(25,22,40,.055); border-radius:12px; }
|
||||
.mail-detail-state { min-height:160px; padding:32px; display:grid; place-items:center; color:#8d8891; font-size:12px; text-align:center; background:#fff; border:1px solid rgba(25,22,40,.055); border-radius:8px; }
|
||||
.mail-detail-state.error { color:#a94f40; }
|
||||
.mail-detail-subject-row { min-height:42px; padding:22px var(--reader-x0) 0; display:flex; align-items:center; justify-content:space-between; gap:16px; width:100%; box-sizing:border-box; }
|
||||
.mail-detail-subject-row { min-height:32px; padding:16px var(--reader-x0) 0; display:flex; align-items:center; justify-content:space-between; gap:16px; width:100%; box-sizing:border-box; }
|
||||
.mail-detail-subject-main { min-width:0; display:flex; flex-wrap:wrap; align-items:center; gap:8px; }
|
||||
.mail-detail-subject-row h1 { margin:0; overflow-wrap:anywhere; font:650 22px/30px "Manrope"; letter-spacing:-.4px; color:#171424; }
|
||||
.mail-detail-subject-row h1 { margin:0; overflow-wrap:anywhere; font-family:"DM Sans",sans-serif; font-size:20px; font-weight:650; line-height:1.25; letter-spacing:-.015em; color:#171424; }
|
||||
.mail-detail-labels { display:flex; flex-wrap:wrap; align-items:center; gap:6px; }
|
||||
.mail-detail-label-pill { height:20px; padding:0 7px; display:inline-flex; align-items:center; border-radius:5px; background:#f1eff3; color:#716d7a; font-size:9.5px; font-weight:550; }
|
||||
.mail-detail-label-pill { height:20px; padding:0 7px; display:inline-flex; align-items:center; border-radius:5px; background:#f1eff3; color:#77758a; font-size:10.5px; font-weight:500; }
|
||||
.mail-detail-label-pill.starred { background:#fff8e8; color:#9a7228; }
|
||||
.mail-detail-ai-chips { display:flex; flex-wrap:wrap; align-items:center; justify-content:flex-end; gap:6px; flex-shrink:0; }
|
||||
.mail-detail-ai-chip { height:28px; padding:0 10px; display:inline-flex; align-items:center; gap:5px; border:1px solid #ece7ff; border-radius:7px; background:#fbf9ff; color:#6d5bd0; font-size:10.5px; font-weight:550; white-space:nowrap; }
|
||||
.mail-detail-ai-chip:hover { background:#f3efff; border-color:#ddd4ff; }
|
||||
.mail-detail-message-header { margin-top:22px; padding:0 var(--reader-x0); width:100%; box-sizing:border-box; display:grid; grid-template-columns:var(--reader-avatar) minmax(0,1fr) auto; column-gap:var(--reader-gap); align-items:start; }
|
||||
.mail-detail-ai-chip { height:28px; padding:0 10px; display:inline-flex; align-items:center; gap:5px; border:1px solid rgba(115,87,246,.08); border-radius:6px; background:#faf9fc; color:#7a7390; font-size:11px; font-weight:500; white-space:nowrap; }
|
||||
.mail-detail-ai-chip svg,.mail-detail-ai-chip .agent-spark { color:inherit; opacity:.8; }
|
||||
.mail-detail-ai-chip:hover { background:#f5f3fa; border-color:rgba(115,87,246,.14); color:#5f5778; }
|
||||
.mail-detail-message-header { margin-top:18px; padding:0 var(--reader-x0); width:100%; box-sizing:border-box; display:grid; grid-template-columns:var(--reader-avatar) minmax(0,1fr) auto; column-gap:var(--reader-gap); align-items:start; }
|
||||
.mail-detail-message-header .sender-avatar { width:38px; height:38px; font-size:12px; }
|
||||
.mail-detail-sender-meta { min-width:0; display:grid; gap:2px; }
|
||||
.mail-detail-sender-line { display:flex; flex-wrap:wrap; align-items:baseline; gap:4px; }
|
||||
.mail-detail-sender-line strong { font:650 13px/1.35 "DM Sans"; color:#171424; }
|
||||
.mail-detail-sender-email { font:400 11px/1.35 "DM Sans"; color:#77758a; overflow-wrap:anywhere; }
|
||||
.mail-detail-sender-meta { min-width:0; display:grid; gap:1px; }
|
||||
.mail-detail-sender-line { display:flex; flex-wrap:wrap; align-items:baseline; gap:5px; }
|
||||
.mail-detail-sender-line strong { font:650 12.5px/1.35 "DM Sans"; color:#171424; }
|
||||
.mail-detail-sender-email { font:400 10.5px/1.35 "DM Sans"; color:#77758a; overflow-wrap:anywhere; }
|
||||
.mail-detail-recipient-toggle { padding:0; display:inline-flex; align-items:center; gap:3px; border:0; background:transparent; color:#77758a; font-size:10.5px; font-weight:500; text-align:left; }
|
||||
.mail-detail-recipient-toggle svg { transition:transform var(--motion-fast) var(--ease-standard); }
|
||||
.mail-detail-recipient-toggle.open svg { transform:rotate(180deg); }
|
||||
.mail-detail-recipient-toggle:hover { color:#4a4652; }
|
||||
.mail-detail-recipient-details { margin-top:4px; padding:8px 10px; display:grid; gap:4px; border-radius:8px; background:#faf9fc; border:1px solid #f0eef5; }
|
||||
.mail-detail-recipient-details small { color:#77758a; font-size:11px; line-height:1.4; }
|
||||
.mail-detail-message-header-actions { display:flex; align-items:center; gap:4px; margin-left:auto; flex-shrink:0; }
|
||||
.mail-detail-message-header-actions time { margin-right:6px; color:#77758a; font-size:11px; white-space:nowrap; }
|
||||
.mail-detail-message-action { width:30px; height:30px; padding:0; display:inline-flex; align-items:center; justify-content:center; border:0; border-radius:7px; background:transparent; color:#6f6a73; }
|
||||
.mail-detail-recipient-details { margin-top:4px; padding:8px 10px; display:grid; gap:4px; border-radius:6px; background:#faf9fc; border:1px solid #f0eef5; }
|
||||
.mail-detail-recipient-details small { color:#77758a; font-size:10.5px; line-height:1.4; }
|
||||
.mail-detail-message-header-actions { display:flex; align-items:center; gap:2px; margin-left:auto; flex-shrink:0; }
|
||||
.mail-detail-message-header-actions time { margin-right:6px; color:#77758a; font-size:10.5px; font-weight:400; white-space:nowrap; }
|
||||
.mail-detail-message-action { width:32px; height:32px; padding:0; display:inline-flex; align-items:center; justify-content:center; border:0; border-radius:6px; background:transparent; color:#6f6a73; }
|
||||
.mail-detail-message-action svg { width:16px; height:16px; }
|
||||
.mail-detail-message-action:hover { background:#f3f1f5; color:#241f2b; }
|
||||
.mail-detail-message-action.active { color:#d4a017; }
|
||||
.mail-detail-message-content { margin-top:22px; padding:0 var(--reader-x0) 0 var(--reader-x2); width:100%; box-sizing:border-box; }
|
||||
.mail-detail-message-content.is-html { padding-left:var(--reader-x0); }
|
||||
.mail-detail-body { color:#292536; }
|
||||
.mail-detail-body.is-text { max-width:760px; font-size:13px; line-height:1.58; }
|
||||
.mail-detail-body-text { white-space:pre-line; overflow-wrap:anywhere; }
|
||||
.mail-detail-body.is-html { max-width:none; font-size:14px; line-height:1.55; }
|
||||
.mail-detail-body-html { white-space:normal; }
|
||||
.mail-detail-body-html p { margin:0 0 12px; }
|
||||
.mail-detail-message-content { margin-top:14px; padding:0 var(--reader-x0) 0 var(--reader-x2); width:100%; box-sizing:border-box; }
|
||||
.mail-detail-message-content.is-html { padding-left:var(--reader-x2); }
|
||||
.mail-detail-body { max-width:760px; color:#241f2b; font-size:13px; line-height:1.55; }
|
||||
.mail-detail-body-text { white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.mail-detail-body.is-html { max-width:none; }
|
||||
.mail-detail-body-html { white-space:normal; overflow-wrap:anywhere; }
|
||||
.mail-detail-body-html p,.mail-detail-body-html h1,.mail-detail-body-html h2,.mail-detail-body-html h3 { margin:0 0 .55em; }
|
||||
.mail-detail-body-html div { margin:0; }
|
||||
.mail-detail-body-html a { color:#4d74c4; text-decoration:underline; }
|
||||
.mail-detail-attachments { margin-top:20px; max-width:760px; display:flex; flex-wrap:wrap; gap:10px; }
|
||||
.mail-detail-body-html strong,.mail-detail-body-html b { font-weight:700; }
|
||||
.mail-detail-body-html em,.mail-detail-body-html i { font-style:italic; }
|
||||
.mail-detail-body-html u { text-decoration:underline; }
|
||||
.mail-detail-body-html s,.mail-detail-body-html strike { text-decoration:line-through; }
|
||||
.mail-detail-body-html ul,.mail-detail-body-html ol { margin:.35em 0 .55em 1.35em; padding:0; }
|
||||
.mail-detail-body-html li { margin:0 0 .2em; }
|
||||
.mail-detail-body-html blockquote { margin:.4em 0 .4em .8ex; padding-left:1ex; border-left:1px solid #ccc; color:#555; }
|
||||
.mail-detail-attachments { margin-top:16px; max-width:760px; display:flex; flex-wrap:wrap; gap:10px; }
|
||||
.mail-detail-attachment { min-width:220px; max-width:320px; padding:10px 12px; display:grid; grid-template-columns:auto 1fr auto; gap:10px; align-items:center; border:1px solid #ECE9EE; border-radius:8px; background:#fcfbfa; }
|
||||
.mail-detail-attachment-icon { display:grid; place-items:center; color:#6f6b7c; }
|
||||
.mail-detail-attachment strong { display:block; font-size:12px; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.mail-detail-attachment small { display:block; margin-top:2px; color:#77758a; font-size:10px; }
|
||||
.mail-detail-attachment small { display:block; margin-top:2px; color:#77758a; font-size:10.5px; }
|
||||
.mail-detail-attachment-actions { display:flex; flex-direction:column; gap:2px; }
|
||||
.mail-detail-attachment-actions button { padding:2px 4px; border:0; background:transparent; color:#6d5bd0; font-size:9px; font-weight:700; }
|
||||
.mail-detail-message-footer { margin-top:24px; padding:0 0 24px; width:100%; box-sizing:border-box; }
|
||||
.mail-detail-reply-bar { padding:16px var(--reader-x0) 0 var(--reader-x2); display:flex; flex-wrap:wrap; gap:8px; border-top:1px solid #f0eef2; }
|
||||
.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-attachment-actions button { padding:2px 4px; border:0; background:transparent; color:#7a7390; font-size:10px; font-weight:600; }
|
||||
.mail-detail-message-footer { margin-top:18px; padding:0 0 18px; width:100%; box-sizing:border-box; }
|
||||
.mail-detail-reply-bar { padding:18px var(--reader-x0) 0 var(--reader-x2); display:flex; flex-wrap:wrap; gap:8px; border-top:1px solid #EFEDF1; }
|
||||
.mail-detail-reply-btn { height:34px; padding:0 14px; display:inline-flex; align-items:center; gap:7px; border:1px solid #e4e1e8; border-radius:8px; background:#fff; color:#241f2b; font-size:12px; 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-messages.is-thread { padding:0; }
|
||||
.mail-thread-message { padding:0; border-top:0; }
|
||||
.mail-thread-messages.is-thread .mail-thread-message { border-top:1px solid #EFEDF1; }
|
||||
.mail-thread-messages.is-thread .mail-thread-message:first-child { border-top:0; }
|
||||
.mail-thread-messages.is-thread .mail-thread-message:not(:last-child) .mail-detail-message-content { padding-bottom:18px; }
|
||||
.mail-thread-message-collapsed { width:100%; padding:10px var(--reader-x0); display:grid; grid-template-columns:var(--reader-avatar) minmax(0,1fr) auto; gap:var(--reader-gap); align-items:center; border:0; border-top:1px solid #EFEDF1; 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 strong { font:650 12.5px/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; }
|
||||
.mail-thread-message-collapsed time { color:#77758a; font-size:10.5px; white-space:nowrap; }
|
||||
.mail-quoted-toggle { margin:10px 0 0; padding:2px 10px; border:1px solid #e8e5ee; border-radius:999px; background:#f7f6f9; color:#77758a; font-size:16px; line-height:1; letter-spacing:1px; cursor:pointer; }
|
||||
.mail-quoted-toggle:hover { background:#f1eff3; color:#4a4652; }
|
||||
.mail-quoted-body { margin:10px 0 0; padding:12px 14px; border-left:2px solid #e8e5ee; border-radius:0 8px 8px 0; background:#faf9fc; color:#77758a; font-size:12px; line-height:1.5; white-space:pre-wrap; overflow:auto; }
|
||||
|
|
@ -863,16 +1034,16 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
|||
.agent-panel.email-context .agent-stat-grid,
|
||||
.agent-panel.email-context .agent-context-block,
|
||||
.agent-panel.email-context .agent-quick { display:none; }
|
||||
.agent-panel.email-context .agent-panel-hello { margin-top:14px; }
|
||||
.agent-panel.email-context .agent-panel-hello { margin-top:12px; }
|
||||
.agent-email-context { margin:8px 0 0; padding:0; }
|
||||
.agent-email-context h3 { margin:0 0 5px; font-size:10px; font-weight:700; letter-spacing:.4px; text-transform:uppercase; color:#8a8496; }
|
||||
.agent-email-context h3 { margin:0 0 5px; font-size:10px; font-weight:550; letter-spacing:.04em; text-transform:uppercase; color:#9a96a4; }
|
||||
.agent-email-context h3 + h3 { margin-top:12px; }
|
||||
.agent-email-context p { margin:0 0 12px; font-size:12px; line-height:1.55; color:#4a4652; }
|
||||
.agent-email-action-list { margin:0 0 12px; padding:0; list-style:none; display:grid; gap:7px; }
|
||||
.agent-email-action-list li { display:flex; align-items:center; gap:8px; color:#4a4652; font-size:12px; line-height:1.4; }
|
||||
.agent-email-action-check { width:14px; height:14px; flex-shrink:0; border:1.5px solid #c8c3d0; border-radius:4px; background:#fff; }
|
||||
.agent-email-context-reply { height:32px; padding:0 12px; display:inline-flex; align-items:center; gap:6px; border:1px solid #ddd6fe; border-radius:8px; background:#faf8ff; color:#6750e8; font-size:11px; font-weight:600; }
|
||||
.agent-email-context-reply:hover { background:#f3efff; }
|
||||
.agent-email-action-check { width:14px; height:14px; flex-shrink:0; border:1.5px solid #d5d1da; border-radius:4px; background:#fff; }
|
||||
.agent-email-context-reply { height:28px; padding:0 10px; display:inline-flex; align-items:center; gap:6px; border:1px solid rgba(115,87,246,.1); border-radius:6px; background:#faf9fc; color:#6d6680; font-size:11px; font-weight:550; }
|
||||
.agent-email-context-reply:hover { background:#f5f3fa; }
|
||||
|
||||
.compose-dock { position:fixed; right:var(--compose-dock-right,16px); bottom:16px; z-index:45; display:flex; flex-direction:column; align-items:flex-end; gap:10px; pointer-events:none; transition:right 160ms ease-out; }
|
||||
.compose-dock-expanded,.compose-dock-chips { display:flex; flex-direction:row-reverse; align-items:flex-end; gap:16px; pointer-events:auto; }
|
||||
|
|
@ -884,14 +1055,14 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
|||
.compose-draft-overflow-menu { position:absolute; right:0; bottom:calc(100% + 6px); min-width:180px; padding:6px; display:grid; gap:2px; border:1px solid #ECE9EE; border-radius:8px; background:#fff; box-shadow:0 8px 24px rgba(20,18,30,.12); }
|
||||
.compose-draft-overflow-menu button { width:100%; min-height:32px; padding:0 10px; border:0; border-radius:6px; background:transparent; text-align:left; font-size:11px; font-weight:600; }
|
||||
.compose-draft-overflow-menu button:hover { background:#f7f4f1; }
|
||||
.compose-window { --compose-pad:14px; --compose-label:30px; width:560px; min-width:500px; max-width:640px; height:560px; display:flex; flex-direction:column; overflow:hidden; background:#fff; border:1px solid rgba(24,20,35,.08); border-radius:10px; box-shadow:0 1px 2px rgba(20,18,30,.08),0 8px 24px rgba(20,18,30,.12),0 20px 48px rgba(20,18,30,.08); animation:composeSlideUp var(--motion-slow) var(--ease-standard); pointer-events:auto; flex-shrink:0; }
|
||||
.compose-window { --compose-pad:14px; --compose-label:30px; width:560px; min-width:500px; max-width:640px; height:572px; display:flex; flex-direction:column; overflow:hidden; background:#fff; border:1px solid rgba(24,20,35,.08); border-radius:10px; box-shadow:0 1px 2px rgba(20,18,30,.08),0 8px 24px rgba(20,18,30,.12),0 20px 48px rgba(20,18,30,.08); animation:composeSlideUp var(--motion-slow) var(--ease-standard); pointer-events:auto; flex-shrink:0; }
|
||||
.compose-window.docked { position:relative; right:auto; bottom:auto; }
|
||||
.compose-window.expanded { position:fixed; left:50%; top:50%; right:auto !important; bottom:auto !important; transform:translate(-50%,-50%); width:min(900px,calc(100vw - 80px)); min-width:min(800px,calc(100vw - 80px)); max-width:960px; height:min(78vh,760px); z-index:50; }
|
||||
.compose-window.focused { box-shadow:0 1px 2px rgba(20,18,30,.1),0 10px 28px rgba(20,18,30,.14),0 24px 56px rgba(20,18,30,.1); }
|
||||
@keyframes composeSlideUp { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } }
|
||||
.compose-window.expanded { animation:composeExpandIn var(--motion-slow) var(--ease-standard); }
|
||||
@keyframes composeExpandIn { from { opacity:0; transform:translate(-50%,-48%); } to { opacity:1; transform:translate(-50%,-50%); } }
|
||||
.compose-window-header { height:48px; padding:0 var(--compose-pad); display:flex; align-items:center; justify-content:space-between; gap:10px; border-bottom:1px solid #ECE9EE; background:#FCFAFB; flex-shrink:0; }
|
||||
.compose-window-header { min-height:56px; height:56px; padding:10px var(--compose-pad) 6px; display:flex; align-items:center; justify-content:space-between; gap:10px; border-bottom:1px solid #ECE9EE; background:#FCFAFB; flex-shrink:0; }
|
||||
.compose-window-title { min-width:0; display:flex; align-items:center; gap:8px; }
|
||||
.compose-header-spark { display:inline-flex; align-items:center; justify-content:center; width:14px; height:14px; font-size:14px; line-height:1; color:#7357F6; flex-shrink:0; }
|
||||
.compose-window-heading { min-width:0; display:grid; gap:1px; }
|
||||
|
|
@ -902,7 +1073,7 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
|||
.compose-window-controls { display:flex; align-items:center; gap:8px; flex-shrink:0; }
|
||||
.compose-window-controls button { width:28px; height:28px; display:grid; place-items:center; border:0; border-radius:6px; background:transparent; color:#6f6a73; }
|
||||
.compose-window-controls button:hover { background:rgba(20,18,30,.05); color:var(--ink); }
|
||||
.compose-window-body { min-height:0; flex:1; display:flex; flex-direction:column; overflow:hidden; background:#fff; }
|
||||
.compose-window-body { min-height:0; flex:1; display:flex; flex-direction:column; overflow:hidden; background:#fff; position:relative; }
|
||||
.compose-row { height:44px; padding:0 var(--compose-pad); display:grid; grid-template-columns:var(--compose-label) minmax(0,1fr) auto; align-items:center; gap:12px; border-bottom:1px solid #ECE9EE; flex-shrink:0; }
|
||||
.compose-row-label { color:#77717a; font-size:11px; font-weight:700; }
|
||||
.compose-row-main { min-width:0; }
|
||||
|
|
@ -958,8 +1129,9 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
|||
.compose-agent-arrow.active { color:#7357F6; }
|
||||
.compose-format-bar { height:38px; padding:0 12px; display:flex; align-items:center; gap:2px; border-bottom:1px solid #ECE9EE; background:#fff; }
|
||||
.compose-format-group { display:flex; align-items:center; gap:2px; }
|
||||
.compose-format-bar button { width:28px; height:28px; display:grid; place-items:center; border:0; border-radius:0; background:transparent; color:#6f6a73; }
|
||||
.compose-format-bar button { width:28px; height:28px; display:grid; place-items:center; border:0; border-radius:4px; background:transparent; color:#6f6a73; }
|
||||
.compose-format-bar button:hover { background:#f7f4f1; color:#c25c49; }
|
||||
.compose-format-bar button.active { background:#ece7e1; color:#241f2b; }
|
||||
.compose-format-divider { width:1px; height:16px; margin:0 5px; background:#ECE9EE; flex-shrink:0; }
|
||||
.compose-warnings { padding:6px var(--compose-pad); border-bottom:1px solid #f0e2c8; background:#fff8ea; }
|
||||
.compose-warnings p { margin:0; color:#9a7228; font-size:10px; line-height:1.4; }
|
||||
|
|
@ -996,6 +1168,17 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
|||
.compose-emoji-panel { position:absolute; bottom:calc(100% + 6px); left:0; z-index:8; width:180px; padding:8px; display:grid; grid-template-columns:repeat(6,1fr); gap:4px; border:1px solid #ECE9EE; border-radius:8px; background:#fff; box-shadow:0 8px 24px rgba(20,18,30,.12); }
|
||||
.compose-emoji-panel button { width:26px; height:26px; border:0; border-radius:4px; background:transparent; font-size:15px; }
|
||||
.compose-emoji-panel button:hover { background:#f3efec; }
|
||||
.compose-link-popover { position:absolute; left:50%; bottom:148px; z-index:12; width:min(360px,calc(100% - 32px)); transform:translateX(-50%); padding:10px; display:grid; gap:8px; border:1px solid #ece9ee; border-radius:12px; background:#fff; box-shadow:0 8px 28px rgba(20,18,30,.16); }
|
||||
.compose-link-row { min-height:36px; padding:0 8px 0 10px; display:flex; align-items:center; gap:8px; border:1px solid #e4e0ea; border-radius:8px; background:#fff; color:#6f6a73; }
|
||||
.compose-link-row:focus-within { border-color:#1a73e8; box-shadow:0 0 0 1px #1a73e8; }
|
||||
.compose-link-row input { flex:1; min-width:0; height:34px; border:0; outline:0; background:transparent; color:var(--ink); font:500 13px/20px "DM Sans"; }
|
||||
.compose-link-row input::placeholder { color:#9a95a3; }
|
||||
.compose-link-apply { height:28px; padding:0 10px; border:0; border-radius:6px; background:transparent; color:#9a95a3; font:600 12px "DM Sans"; }
|
||||
.compose-link-apply:disabled { color:#c5c1ca; cursor:default; }
|
||||
.compose-link-row:focus-within .compose-link-apply:not(:disabled),
|
||||
.compose-link-apply:not(:disabled) { color:#1a73e8; }
|
||||
.compose-editor a { color:#1a73e8; text-decoration:underline; }
|
||||
.compose-editor ul,.compose-editor ol { margin:4px 0 8px 1.3em; padding:0; }
|
||||
body.compose-blank-open .mailbox-empty-cta { opacity:.42; filter:saturate(.55); pointer-events:none; }
|
||||
@media (max-width:640px) {
|
||||
.compose-format-divider-advanced,.compose-format-advanced { display:none; }
|
||||
|
|
@ -1044,18 +1227,19 @@ body.compose-blank-open .mailbox-empty-cta { opacity:.42; filter:saturate(.55);
|
|||
.mailbox-notes-pane { display:none; }
|
||||
.mailbox-list-pane { border-right:0; }
|
||||
.mailbox-shell.detail-visible .mailbox-toolbar,
|
||||
.mailbox-shell.detail-visible .mailbox-list-bar,
|
||||
.mailbox-shell.detail-visible .mailbox-filters,
|
||||
.mailbox-shell.detail-visible .mailbox-list-footer { display:none; }
|
||||
.mail-detail-reader { --reader-x0:18px; --reader-avatar:34px; --reader-gap:8px; border-radius:10px; }
|
||||
.mail-detail-toolbar { padding:0 16px; }
|
||||
.mail-detail-subject-row { flex-direction:column; align-items:flex-start; padding-top:18px; }
|
||||
.mail-detail-reader { --reader-x0:18px; --reader-avatar:34px; --reader-gap:8px; border-radius:8px; }
|
||||
.mail-detail-toolbar { padding:0 var(--reader-x0); }
|
||||
.mail-detail-subject-row { flex-direction:column; align-items:flex-start; padding-top:14px; }
|
||||
.mail-detail-ai-chips { justify-content:flex-start; }
|
||||
.mail-detail-message-header { grid-template-columns:34px minmax(0,1fr); }
|
||||
.mail-detail-message-header { grid-template-columns:var(--reader-avatar) minmax(0,1fr); }
|
||||
.mail-detail-message-header-actions { grid-column:1/-1; justify-content:flex-start; margin-left:0; }
|
||||
.mail-detail-message-content,.mail-detail-message-content.is-html { padding-left:var(--reader-x0); }
|
||||
.mail-detail-reply-bar { padding-left:var(--reader-x0); }
|
||||
.mail-detail-message-content,.mail-detail-message-content.is-html { padding-left:var(--reader-x2); }
|
||||
.mail-detail-reply-bar { padding-left:var(--reader-x2); }
|
||||
.compose-dock { right:12px !important; bottom:12px; }
|
||||
.compose-window { width:min(calc(100vw - 24px),560px); min-width:0; max-width:none; height:min(560px,calc(100vh - 24px)); }
|
||||
.compose-window { width:min(calc(100vw - 24px),560px); min-width:0; max-width:none; height:min(572px,calc(100vh - 24px)); }
|
||||
.compose-window.expanded { width:calc(100vw - 24px); min-width:0; height:min(78vh,calc(100vh - 24px)); }
|
||||
}
|
||||
@media(max-width:480px) {
|
||||
|
|
@ -1164,12 +1348,12 @@ body.compose-blank-open .mailbox-empty-cta { opacity:.42; filter:saturate(.55);
|
|||
.agent-spark.pulse { animation:agentSparkPulse 1.2s ease-in-out infinite; }
|
||||
.agent-label { display:inline-flex; align-items:center; gap:var(--space-1); }
|
||||
.agent-insight { margin:0 0 var(--space-3); display:grid; gap:var(--space-2); }
|
||||
.agent-insight-status { margin:0; display:flex; align-items:center; gap:var(--space-2); color:#6d4aff; font-size:var(--text-meta); font-weight:600; line-height:var(--line-meta); }
|
||||
.agent-insight-block { padding:var(--space-3); border-radius:var(--radius-md); background:#fff; box-shadow:0 1px 2px #08082108; }
|
||||
.agent-insight-block h4 { margin:0 0 var(--space-1); display:flex; align-items:center; gap:var(--space-1); color:#5a3fe0; font-size:var(--text-meta); font-weight:700; letter-spacing:.2px; text-transform:uppercase; }
|
||||
.agent-insight-status { margin:0; display:flex; align-items:center; gap:var(--space-2); color:#7a7390; font-size:var(--text-meta); font-weight:550; line-height:var(--line-meta); }
|
||||
.agent-insight-block { padding:var(--space-3); border-radius:8px; background:#fff; box-shadow:none; border:1px solid rgba(25,22,40,.04); }
|
||||
.agent-insight-block h4 { margin:0 0 var(--space-1); display:flex; align-items:center; gap:var(--space-1); color:#9a96a4; font-size:10.5px; font-weight:550; letter-spacing:.04em; text-transform:uppercase; }
|
||||
.agent-insight-block p,.agent-insight-block li { margin:0; color:#4a454f; font-size:var(--text-content); line-height:var(--line-content); }
|
||||
.agent-insight-block ul { margin:0; padding:0; list-style:none; display:grid; gap:var(--space-1); }
|
||||
.agent-insight-actions li { display:flex; align-items:center; gap:var(--space-1); color:#6d4aff; font-size:var(--text-meta); font-weight:600; }
|
||||
.agent-insight-actions li { display:flex; align-items:center; gap:var(--space-1); color:#6d6680; font-size:var(--text-meta); font-weight:550; }
|
||||
.agent-insight-block.reveal { animation:agentReveal var(--motion-slow) var(--ease-standard) both; }
|
||||
.agent-insight-block.reveal.delay-1 { animation-delay:80ms; }
|
||||
.agent-insight-block.reveal.delay-2 { animation-delay:160ms; }
|
||||
|
|
@ -1252,18 +1436,18 @@ body.compose-blank-open .mailbox-empty-cta { opacity:.42; filter:saturate(.55);
|
|||
.mail-label-green { background:#e7f6ee; color:#2f7a52; }
|
||||
.mail-label-pink { background:#fceeff; color:#8b5ca8; }
|
||||
.mailbox-rail { width:320px; min-width:320px; max-width:320px; display:flex; flex-direction:column; gap:8px; position:sticky; top:var(--space-3); align-self:start; }
|
||||
.agent-panel { --ag-coral:#FF6654; --ag-coral-dark:#B72416; --ag-coral-soft:#FFF1ED; --ag-purple:#7357F6; --ag-purple-dark:#5633CE; --ag-purple-soft:#F3F0FF; --ag-blue:#2764F1; --ag-blue-dark:#153EAB; --ag-blue-soft:#EEF3FF; --ag-green:#11966A; --ag-green-dark:#087852; --ag-green-soft:#EDF9F4; --ag-text-primary:#171424; --ag-text-secondary:#77758A; --ag-text-tertiary:#A6A2AE; --ag-border:#E4E1E8; --ag-divider:rgba(25,22,40,.07); --ag-panel:#FFFFFF; width:100%; border:1px solid rgba(26,24,38,0.055); border-radius:14px; background:var(--ag-panel); box-shadow:0 1px 2px rgba(20,18,30,0.025),0 8px 24px rgba(20,18,30,0.035); overflow:hidden; }
|
||||
.agent-panel-head { padding:14px 14px 0; display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:10px; }
|
||||
.agent-panel-mark { width:32px; height:32px; flex-shrink:0; display:grid; place-items:center; border-radius:10px; background:#FFF1EC; color:#FF735C; }
|
||||
.agent-panel { --ag-coral:#FF6654; --ag-coral-dark:#B72416; --ag-coral-soft:#FFF1ED; --ag-purple:#7357F6; --ag-purple-dark:#5633CE; --ag-purple-soft:#F3F0FF; --ag-blue:#2764F1; --ag-blue-dark:#153EAB; --ag-blue-soft:#EEF3FF; --ag-green:#11966A; --ag-green-dark:#087852; --ag-green-soft:#EDF9F4; --ag-text-primary:#171424; --ag-text-secondary:#77758A; --ag-text-tertiary:#A6A2AE; --ag-border:#E4E1E8; --ag-divider:rgba(25,22,40,.06); --ag-panel:#FFFFFF; width:100%; border:1px solid rgba(25,22,40,.045); border-radius:10px; background:var(--ag-panel); box-shadow:none; overflow:hidden; }
|
||||
.agent-panel-head { padding:12px 12px 0; display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:10px; }
|
||||
.agent-panel-mark { width:28px; height:28px; flex-shrink:0; display:grid; place-items:center; border-radius:8px; background:#FFF4F1; color:#E56B5C; }
|
||||
.agent-panel-title { min-width:0; }
|
||||
.agent-panel-head h2 { margin:0; font:700 15px/18px "DM Sans"; color:#151322; letter-spacing:-.2px; }
|
||||
.agent-panel-head p { margin:1px 0 0; font:400 10.5px/14px "DM Sans"; color:#77758A; }
|
||||
.agent-panel-toggle { width:28px; height:28px; display:grid; place-items:center; border:0; border-radius:6px; background:transparent; color:#30304A; cursor:pointer; transition:background 120ms ease; }
|
||||
.agent-panel-head h2 { margin:0; font:650 14px/18px "DM Sans"; color:#3d3948; letter-spacing:0; }
|
||||
.agent-panel-head p { margin:1px 0 0; font:400 10.5px/14px "DM Sans"; color:#9a96a4; }
|
||||
.agent-panel-toggle { width:28px; height:28px; display:grid; place-items:center; border:0; border-radius:6px; background:transparent; color:#6f6b7c; cursor:pointer; transition:background 120ms ease; }
|
||||
.agent-panel-toggle:hover { background:#F5F4F2; }
|
||||
.agent-panel-toggle .open { transform:rotate(180deg); }
|
||||
.agent-panel-body { padding:0 14px 14px; animation:agentReveal var(--motion-slow) var(--ease-standard); }
|
||||
.agent-panel-hello { margin:14px 0 0; display:grid; gap:2px; }
|
||||
.agent-panel-hello strong { font:700 14px/18px "DM Sans"; color:#171424; }
|
||||
.agent-panel-body { padding:0 12px 12px; animation:agentReveal var(--motion-slow) var(--ease-standard); }
|
||||
.agent-panel-hello { margin:12px 0 0; display:grid; gap:2px; }
|
||||
.agent-panel-hello strong { font:650 13px/18px "DM Sans"; color:#292536; }
|
||||
.agent-panel-hello span { font:400 11px/15px "DM Sans"; color:#77758A; }
|
||||
.agent-panel-note { margin:0 0 8px; font-size:11px; line-height:15px; color:#77758A; }
|
||||
.agent-caught-up { margin:10px 0 0; padding:10px 12px; border-radius:10px; background:#EDF9F4; border:1px solid #D7F0E5; }
|
||||
|
|
@ -1301,18 +1485,18 @@ body.compose-blank-open .mailbox-empty-cta { opacity:.42; filter:saturate(.55);
|
|||
.agent-stat-empty .agent-stat-label { color:#77757D!important; }
|
||||
.agent-stat-empty small { color:#B5B2BA!important; }
|
||||
.agent-quick { margin-top:12px; padding-top:10px; border-top:1px solid var(--ag-divider); }
|
||||
.agent-quick h3 { margin:0 0 7px; font:600 9.5px/12px "DM Sans"; letter-spacing:.06em; text-transform:uppercase; color:#6F6C7B; }
|
||||
.agent-quick h3 { margin:0 0 7px; font:550 10px/12px "DM Sans"; letter-spacing:.04em; text-transform:uppercase; color:#9a96a4; }
|
||||
.agent-quick-grid { display:grid; grid-template-columns:1fr 1fr; gap:6px 8px; }
|
||||
.agent-quick-btn { height:33px; padding:0 9px; display:flex; align-items:center; justify-content:flex-start; gap:7px; border:1px solid #DFDDE5; border-radius:8px; background:#FFFFFF; color:#292536; font:600 10.5px/12px "DM Sans"; white-space:nowrap; cursor:pointer; transition:background 120ms ease,border-color 120ms ease; }
|
||||
.agent-quick-btn:hover { background:#F9F7FF; border-color:#D9D0FF; }
|
||||
.agent-quick-btn { height:32px; padding:0 9px; display:flex; align-items:center; justify-content:flex-start; gap:7px; border:1px solid #E8E6EC; border-radius:7px; background:#FFFFFF; color:#3d3948; font:550 10.5px/12px "DM Sans"; white-space:nowrap; cursor:pointer; transition:background 120ms ease,border-color 120ms ease; }
|
||||
.agent-quick-btn:hover { background:#FAF9FC; border-color:#E0DCE6; }
|
||||
.agent-quick-icon { flex-shrink:0; }
|
||||
.agent-quick-icon-purple { color:#7357F6; }
|
||||
.agent-quick-icon-purple { color:#8B7AE0; }
|
||||
.agent-quick-icon-muted { color:#667085; }
|
||||
.agent-quick-icon-slate { color:#50627A; }
|
||||
.agent-context-block { margin-top:12px; padding-top:10px; border-top:1px solid var(--ag-divider); }
|
||||
.agent-context-head { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:6px; }
|
||||
.agent-context-head h3,.agent-context-block>h3 { margin:0 0 5px; font:700 11.5px/14px "DM Sans"; color:#292536; letter-spacing:0; text-transform:none; }
|
||||
.agent-context-link { border:0; background:transparent; font:500 9.5px/12px "DM Sans"; color:#5D4CF5; cursor:pointer; }
|
||||
.agent-context-head h3,.agent-context-block>h3 { margin:0 0 5px; font:550 11px/14px "DM Sans"; color:#6f6b7c; letter-spacing:0; text-transform:none; }
|
||||
.agent-context-link { border:0; background:transparent; font:500 9.5px/12px "DM Sans"; color:#7a7390; cursor:pointer; }
|
||||
.agent-context-link:hover { text-decoration:underline; }
|
||||
.agent-empty-line { margin:0; padding:2px 0; font:400 10px/14px "DM Sans"; color:#A6A2AE; }
|
||||
.agent-agenda-list { margin:0; padding:0; list-style:none; display:grid; gap:2px; }
|
||||
|
|
@ -1338,14 +1522,14 @@ body.compose-blank-open .mailbox-empty-cta { opacity:.42; filter:saturate(.55);
|
|||
.agent-deadlines ul { margin:0; padding:0; list-style:none; }
|
||||
.agent-deadlines li { display:flex; align-items:center; justify-content:space-between; padding:4px 0; font-size:10px; }
|
||||
.agent-deadlines em { color:#77758A; font-size:9px; font-style:normal; }
|
||||
.agent-ask-input { margin-top:8px; height:35px; padding:0 4px 0 10px; display:flex; align-items:center; gap:2px; border:1px solid #E1DFE7; border-radius:9px; background:#FFFFFF; }
|
||||
.agent-ask-input:focus-within { border-color:#D9D0FF; box-shadow:0 0 0 2px rgba(115,87,246,0.12); }
|
||||
.agent-ask-input { margin-top:8px; height:35px; padding:0 4px 0 10px; display:flex; align-items:center; gap:2px; border:1px solid #E8E6EC; border-radius:8px; background:#FFFFFF; }
|
||||
.agent-ask-input:focus-within { border-color:#E0DCE8; box-shadow:0 0 0 2px rgba(115,87,246,0.06); }
|
||||
.agent-ask-input input { flex:1; min-width:0; border:0; outline:0; background:transparent; font:400 10px/12px "DM Sans"; color:#171424; }
|
||||
.agent-ask-input input::placeholder { color:#9995A6; }
|
||||
.agent-ask-attach { width:26px; height:26px; flex-shrink:0; display:grid; place-items:center; border:0; border-radius:6px; background:transparent; color:#6F6B7C; cursor:pointer; }
|
||||
.agent-ask-attach:hover { background:#F5F4F2; }
|
||||
.agent-ask-send { width:28px; height:28px; flex-shrink:0; display:grid; place-items:center; border:0; border-radius:8px; background:#BEC5D4; color:#fff; cursor:pointer; transition:background 120ms ease; }
|
||||
.agent-ask-send.active { background:#7357F6; }
|
||||
.agent-ask-send.active { background:#8B7AE0; }
|
||||
.agent-ask-send:disabled { cursor:default; }
|
||||
.agent-loading-block,.agent-loading-rows { display:grid; gap:6px; padding:4px 0; }
|
||||
.agent-loading-pulse { display:block; height:10px; border-radius:999px; background:linear-gradient(90deg,#ece8e4 0%,#f4f1ee 50%,#ece8e4 100%); background-size:200% 100%; animation:agentShimmer 1.2s ease infinite; }
|
||||
|
|
|
|||
117
frontend/src/utils/datetime.js
Normal file
117
frontend/src/utils/datetime.js
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
const HAS_TIMEZONE = /(?:Z|[+-]\d{2}:?\d{2})$/i;
|
||||
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export function parseApiDate(value) {
|
||||
if (!value && value !== 0) return null;
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
if (typeof value !== "string") return null;
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (DATE_ONLY.test(trimmed)) {
|
||||
const date = new Date(`${trimmed}T00:00:00`);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
let normalized = trimmed.includes("T") ? trimmed : trimmed.replace(" ", "T");
|
||||
if (!HAS_TIMEZONE.test(normalized)) {
|
||||
normalized = `${normalized}Z`;
|
||||
}
|
||||
const date = new Date(normalized);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function localFormatter(options) {
|
||||
return new Intl.DateTimeFormat(undefined, options);
|
||||
}
|
||||
|
||||
export function formatClockTime(value) {
|
||||
const date = parseApiDate(value);
|
||||
if (!date) return "";
|
||||
return localFormatter({ hour: "numeric", minute: "2-digit" }).format(date);
|
||||
}
|
||||
|
||||
export function formatShortDate(value) {
|
||||
const date = parseApiDate(value);
|
||||
if (!date) return "";
|
||||
const now = new Date();
|
||||
return localFormatter({
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...(date.getFullYear() !== now.getFullYear() ? { year: "numeric" } : {}),
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatMailboxTime(value) {
|
||||
const date = parseApiDate(value);
|
||||
if (!date) return "";
|
||||
const now = new Date();
|
||||
if (date.toDateString() === now.toDateString()) return formatClockTime(date);
|
||||
return formatShortDate(date);
|
||||
}
|
||||
|
||||
export function formatAbsoluteDateTime(value) {
|
||||
const date = parseApiDate(value);
|
||||
if (!date) return "";
|
||||
return localFormatter({
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatFullDateTime(value) {
|
||||
const date = parseApiDate(value);
|
||||
if (!date) return "";
|
||||
return localFormatter({
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatRelativeTime(value, { compact = false } = {}) {
|
||||
const date = parseApiDate(value);
|
||||
if (!date) return "";
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000));
|
||||
if (compact) {
|
||||
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`;
|
||||
}
|
||||
if (seconds < 60) return "Just now";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
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 formatShortDate(date);
|
||||
}
|
||||
|
||||
export function formatMailDetailTime(value) {
|
||||
const date = parseApiDate(value);
|
||||
if (!date) return { absolute: "", relative: "" };
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const diffHours = Math.max(1, Math.round(Math.abs(diffMs) / (1000 * 60 * 60)));
|
||||
const relative = diffHours < 24
|
||||
? `${diffHours}h ago`
|
||||
: `${Math.round(diffHours / 24)}d ago`;
|
||||
return {
|
||||
absolute: formatAbsoluteDateTime(date),
|
||||
relative: diffMs >= 0 ? relative : formatClockTime(date),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
const BLOCK_TAGS = new Set(["DIV", "P", "PRE", "LI", "UL", "OL"]);
|
||||
import { parseApiDate } from "./datetime";
|
||||
|
||||
const BLOCK_TAGS = new Set(["DIV", "P", "PRE", "LI", "UL", "OL", "H1", "H2", "H3", "TABLE", "TR"]);
|
||||
const SAFE_TAGS = new Set([
|
||||
"A",
|
||||
"B",
|
||||
|
|
@ -7,16 +9,23 @@ const SAFE_TAGS = new Set([
|
|||
"CODE",
|
||||
"DIV",
|
||||
"EM",
|
||||
"H1",
|
||||
"H2",
|
||||
"H3",
|
||||
"I",
|
||||
"LI",
|
||||
"OL",
|
||||
"P",
|
||||
"PRE",
|
||||
"S",
|
||||
"SPAN",
|
||||
"STRIKE",
|
||||
"STRONG",
|
||||
"U",
|
||||
"UL",
|
||||
]);
|
||||
const SAFE_STYLES = new Set(["font-weight", "font-style", "text-decoration", "text-align"]);
|
||||
const UNSAFE_TAGS = new Set(["SCRIPT", "STYLE", "IFRAME", "OBJECT", "EMBED", "LINK", "META", "NOSCRIPT"]);
|
||||
|
||||
export function escapeHtml(value = "") {
|
||||
return value
|
||||
|
|
@ -27,31 +36,117 @@ export function escapeHtml(value = "") {
|
|||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function cleanMessageHtml(value) {
|
||||
function parseStyle(styleText) {
|
||||
const out = {};
|
||||
for (const part of (styleText || "").split(";")) {
|
||||
const [rawKey, ...rest] = part.split(":");
|
||||
if (!rawKey || !rest.length) continue;
|
||||
const key = rawKey.trim().toLowerCase();
|
||||
const value = rest.join(":").trim();
|
||||
if (!SAFE_STYLES.has(key) || !value) continue;
|
||||
if (/expression|url\s*\(|javascript:/i.test(value)) continue;
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function styleAttribute(styleMap) {
|
||||
return Object.entries(styleMap)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function isBold(value) {
|
||||
const normalized = String(value || "").toLowerCase();
|
||||
if (normalized === "bold" || normalized === "bolder") return true;
|
||||
const numeric = Number.parseInt(normalized, 10);
|
||||
return Number.isFinite(numeric) && numeric >= 600;
|
||||
}
|
||||
|
||||
function allowedHref(value) {
|
||||
const href = (value || "").trim();
|
||||
if (!href) return "";
|
||||
if (/^(https?:|mailto:)/i.test(href)) return href;
|
||||
if (href.startsWith("/") || href.startsWith("#")) return href;
|
||||
return "";
|
||||
}
|
||||
|
||||
import { escapeHtml } from "./emailReply";
|
||||
const wrapper = element.ownerDocument.createElement(tagName);
|
||||
while (element.firstChild) wrapper.appendChild(element.firstChild);
|
||||
element.appendChild(wrapper);
|
||||
}
|
||||
|
||||
function applySemanticStyles(element) {
|
||||
const style = parseStyle(element.getAttribute("style"));
|
||||
const decoration = String(style["text-decoration"] || "").toLowerCase();
|
||||
|
||||
if (isBold(style["font-weight"])) {
|
||||
wrapContents(element, "strong");
|
||||
delete style["font-weight"];
|
||||
}
|
||||
if (String(style["font-style"] || "").includes("italic")) {
|
||||
wrapContents(element, "em");
|
||||
delete style["font-style"];
|
||||
}
|
||||
if (decoration.includes("underline")) wrapContents(element, "u");
|
||||
if (decoration.includes("line-through")) wrapContents(element, "s");
|
||||
if (decoration.includes("underline") || decoration.includes("line-through")) {
|
||||
delete style["text-decoration"];
|
||||
}
|
||||
if (!["DIV", "P", "H1", "H2", "H3", "LI", "BLOCKQUOTE"].includes(element.tagName)) {
|
||||
delete style["text-align"];
|
||||
}
|
||||
|
||||
if (Object.keys(style).length) element.setAttribute("style", styleAttribute(style));
|
||||
else element.removeAttribute("style");
|
||||
}
|
||||
|
||||
export function sanitizeEmailHtml(value) {
|
||||
if (!value) return "";
|
||||
const doc = new DOMParser().parseFromString(value, "text/html");
|
||||
|
||||
function clean(node) {
|
||||
[...node.childNodes].forEach((child) => {
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) return;
|
||||
if (UNSAFE_TAGS.has(child.tagName)) {
|
||||
child.remove();
|
||||
return;
|
||||
}
|
||||
if (!SAFE_TAGS.has(child.tagName)) {
|
||||
if (["SCRIPT", "STYLE", "IFRAME", "OBJECT", "EMBED"].includes(child.tagName)) {
|
||||
child.remove();
|
||||
return;
|
||||
}
|
||||
clean(child);
|
||||
child.replaceWith(...child.childNodes);
|
||||
return;
|
||||
}
|
||||
|
||||
const href = child.tagName === "A" ? allowedHref(child.getAttribute("href")) : "";
|
||||
const className = child.getAttribute("class") || "";
|
||||
const styleText = child.getAttribute("style") || "";
|
||||
const keepQuoteClass = /\b(gmail_quote|gmail_attr)\b/.test(className);
|
||||
[...child.attributes].forEach((attribute) => child.removeAttribute(attribute.name));
|
||||
if (href) child.setAttribute("href", href);
|
||||
if (keepQuoteClass) {
|
||||
child.setAttribute("class", className.split(/\s+/).filter((name) => (
|
||||
name === "gmail_quote" || name === "gmail_attr"
|
||||
)).join(" "));
|
||||
}
|
||||
if (child.tagName === "BLOCKQUOTE") {
|
||||
child.setAttribute(
|
||||
"style",
|
||||
"margin:0 0 0 .8ex;border-left:1px solid #ccc;padding-left:1ex",
|
||||
);
|
||||
} else if (styleText) {
|
||||
child.setAttribute("style", styleText);
|
||||
applySemanticStyles(child);
|
||||
}
|
||||
clean(child);
|
||||
if (
|
||||
child.tagName === "SPAN"
|
||||
&& !child.attributes.length
|
||||
&& child.parentNode
|
||||
) {
|
||||
child.replaceWith(...child.childNodes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -78,8 +173,8 @@ function attributionDate(message) {
|
|||
|| message?.sent_at
|
||||
|| message?.created_at
|
||||
|| message?.updated_at;
|
||||
const date = value ? new Date(value) : null;
|
||||
if (!date || Number.isNaN(date.getTime())) return "";
|
||||
const date = parseApiDate(value);
|
||||
if (!date) return "";
|
||||
|
||||
const datePart = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: "short",
|
||||
|
|
@ -95,7 +190,7 @@ function attributionDate(message) {
|
|||
}
|
||||
|
||||
export function buildReplyBodyHtml(message) {
|
||||
const original = cleanMessageHtml(message?.body_html)
|
||||
const original = sanitizeEmailHtml(message?.body_html)
|
||||
|| textBodyAsHtml(message?.body_text || message?.snippet || "");
|
||||
const date = attributionDate(message);
|
||||
const identity = senderIdentity(message);
|
||||
|
|
@ -142,3 +237,16 @@ export function plainTextFromEmailHtml(value) {
|
|||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function htmlForOutboundEmail(value) {
|
||||
const cleaned = sanitizeEmailHtml(value);
|
||||
if (!cleaned || !plainTextFromEmailHtml(cleaned)) return "";
|
||||
return `<div dir="ltr">${cleaned}</div>`;
|
||||
}
|
||||
|
||||
export function visibleMessageHtml(value) {
|
||||
if (!value) return "";
|
||||
const doc = new DOMParser().parseFromString(value, "text/html");
|
||||
doc.querySelectorAll(".gmail_quote").forEach((node) => node.remove());
|
||||
return sanitizeEmailHtml(doc.body.innerHTML);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { senderDisplayName } from "./senderAvatar";
|
||||
import { parseApiDate } from "./datetime";
|
||||
|
||||
const CATEGORY_ORDER = ["Primary", "Updates", "Promotions", "Social", "Finance"];
|
||||
|
||||
|
|
@ -135,9 +136,7 @@ export function attachmentCount(email) {
|
|||
|
||||
function emailTimestamp(email) {
|
||||
const value = email.received_at || email.sent_at || email.created_at || email.updated_at;
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
return parseApiDate(value);
|
||||
}
|
||||
|
||||
function formatRelativeAge(date) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue