agentic.email/frontend/src/components/MailboxView.jsx

1008 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
Bell,
ChevronDown,
ChevronLeft,
ChevronRight,
CircleHelp,
Filter,
LayoutGrid,
Mail,
MoreHorizontal,
RefreshCw,
Search,
Settings,
Sparkles,
Sun,
Tag,
Users,
Wallet,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
getEmail,
getThread,
listThreads,
permanentlyDeleteEmail,
restoreEmail,
setEmailRead,
setEmailStar,
trashEmail,
} from "../api";
import AgAgentPanel from "./AgAgentPanel";
import AgentSpark from "./AgentSpark";
import CommandPalette from "./CommandPalette";
import { useAgentInsight } from "../hooks/useAgentInsight";
import { useMailboxMaturity } from "../hooks/useMailboxMaturity";
import MailDetail from "./MailDetail";
import MailRow from "./MailRow";
import MailboxEmptyState from "./MailboxEmptyState";
import MailboxSelectionBar from "./MailboxSelectionBar";
import StickyNotesPanel from "./StickyNotesPanel";
import WorkspaceAccountMenu from "./WorkspaceAccountMenu";
import { useShellMode } from "../shellMode";
import { shellTitle } from "./MobileChrome";
import {
categoryCounts,
emailCategory,
inboxIntelligence,
labelCounts,
matchesLens,
} from "../utils/inboxSignals";
import { buildReplyBodyHtml } from "../utils/emailReply";
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 },
{ name: "Promotions", icon: Tag },
{ name: "Social", icon: Users },
{ name: "Finance", icon: Wallet },
{ name: "All Mail", icon: Mail },
];
function resultPage(result) {
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),
};
}
function threadToRow(thread) {
const primarySender = thread.senders?.[0] || thread.participants?.[0] || { name: "Unknown", address: "" };
return {
id: thread.latest_message_id,
thread_id: thread.id,
mailbox: thread.mailbox,
sender: primarySender,
recipients: thread.participants || [],
subject: thread.subject,
snippet: thread.snippet,
created_at: thread.latest_at,
is_read: thread.is_read,
is_starred: thread.is_starred,
attachments: thread.has_attachments ? [{ filename: "attachment" }] : [],
message_count: thread.message_count,
unread_count: thread.unread_count,
};
}
function buildReplyContext(message, replyAll = false) {
const sender = message?.sender || { name: "", address: "" };
const subject = (message?.subject || "").trim();
const replySubject = /^re:/i.test(subject) ? subject : `Re: ${subject || "(No subject)"}`;
return {
replyToMessageId: message.id,
replyAll,
to: replyAll ? [] : [{ email: sender.address, name: sender.name }],
subject: replySubject,
bodyHtml: buildReplyBodyHtml(message),
};
}
export default function MailboxView({
mailbox,
title,
refreshKey = 0,
account,
lens = "all",
agentNav = null,
initialThreadId = null,
onLogout,
onOpenSettings,
onOpenProfile,
onAccountUpdate,
onCompose,
onStats,
}) {
const token = sessionStorage.getItem("agmail_access_token");
const { sheet, setSheet } = useShellMode();
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);
const [checked, setChecked] = useState({});
const [hidden, setHidden] = useState({});
const [detail, setDetail] = useState(null);
const [threadDetail, setThreadDetail] = useState(null);
const [status, setStatus] = useState({ loading: true, error: "" });
const [detailStatus, setDetailStatus] = useState({ loading: false, error: "", busy: false });
const [filtersOpen, setFiltersOpen] = useState(false);
const [category, setCategory] = useState("Primary");
const [agentFocus, setAgentFocus] = useState("");
const { insight, runInsight } = useAgentInsight();
const [commandOpen, setCommandOpen] = useState(false);
const [filters, setFilters] = useState({
query: "",
read: "all",
sender: "",
dateFrom: "",
dateTo: "",
});
const load = useCallback(async ({ silent = false } = {}) => {
if (!silent) {
setStatus({ loading: true, error: "" });
}
try {
const result = await listThreads(
{
mailbox,
limit: pageSize,
cursor,
q: filters.query.trim(),
is_read: filters.read === "all" ? undefined : filters.read === "read",
sender: filters.sender.trim(),
date_from: filters.dateFrom || undefined,
date_to: filters.dateTo || undefined,
},
token,
);
const page = resultPage(result);
setEmails(page.items.map(threadToRow));
setNextCursor(page.nextCursor);
setTotal(page.total);
setStatus({ loading: false, error: "" });
} catch (error) {
if (!silent) {
setStatus({ loading: false, error: error.message });
}
}
}, [cursor, filters, mailbox, pageSize, token]);
const canAutoRefresh = !cursor
&& !filters.query.trim()
&& filters.read === "all"
&& !filters.sender.trim()
&& !filters.dateFrom
&& !filters.dateTo;
useEffect(() => {
const timeout = window.setTimeout(() => load(), filters.query ? 250 : 0);
return () => window.clearTimeout(timeout);
}, [load, refreshKey]);
useEffect(() => {
if (!canAutoRefresh) return undefined;
const intervalId = window.setInterval(() => {
if (document.visibilityState === "visible") {
load({ silent: true });
}
}, 30000);
return () => window.clearInterval(intervalId);
}, [canAutoRefresh, load]);
useEffect(() => {
function handleVisibilityChange() {
if (document.visibilityState === "visible" && canAutoRefresh) {
load({ silent: true });
}
}
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
}, [canAutoRefresh, load]);
useEffect(() => {
if (!initialThreadId) return undefined;
let cancelled = false;
setSelected(initialThreadId);
setDetailStatus({ loading: true, error: "", busy: false });
getThread(initialThreadId, token)
.then((thread) => {
if (cancelled) return;
const messages = thread.messages || [];
const latest = messages[messages.length - 1];
setThreadDetail(thread);
setDetail(latest || {
id: thread.latest_message_id,
thread_id: initialThreadId,
subject: thread.subject,
snippet: thread.snippet,
});
setDetailStatus({ loading: false, error: "", busy: false });
})
.catch((error) => {
if (cancelled) return;
setDetailStatus({ loading: false, error: error.message, busy: false });
});
return () => {
cancelled = true;
};
}, [initialThreadId, token]);
useEffect(() => {
function openPalette(event) {
const target = event.target;
const typing =
target instanceof HTMLInputElement
|| target instanceof HTMLTextAreaElement
|| target instanceof HTMLSelectElement
|| target?.isContentEditable;
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setCommandOpen(true);
return;
}
if (event.key === "/" && !typing && !commandOpen) {
event.preventDefault();
setCommandOpen(true);
}
}
window.addEventListener("keydown", openPalette);
return () => window.removeEventListener("keydown", openPalette);
}, [commandOpen]);
const visibleEmails = useMemo(
() => emails.filter((email) => !hidden[email.id] && matchesLens(email, lens)),
[emails, hidden, lens],
);
const listedEmails = useMemo(() => {
if (mailbox !== "inbox" || category === "All Mail") return visibleEmails;
return visibleEmails.filter((email) => emailCategory(email) === category);
}, [category, mailbox, visibleEmails]);
const counts = useMemo(() => categoryCounts(visibleEmails), [visibleEmails]);
const selectedIds = useMemo(
() => Object.keys(checked).filter((id) => checked[id]),
[checked],
);
const selectedCount = selectedIds.length;
const hasFilters = filters.read !== "all" || Boolean(filters.sender.trim())
|| Boolean(filters.dateFrom) || Boolean(filters.dateTo);
const hasSearch = Boolean(filters.query.trim());
const { isBrandNew } = useMailboxMaturity(account, emails, status.loading);
const isEmpty = !status.loading && !status.error && listedEmails.length === 0;
useEffect(() => {
if (!onStats) return;
const intel = inboxIntelligence(emails);
onStats({
unread: emails.filter((email) => email.is_read === false).length,
starred: emails.filter((email) => email.is_starred).length,
important: intel.important,
drafts: 0,
actionItems: intel.actionItems,
labels: labelCounts(emails),
});
}, [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);
setCursorHistory([]);
}
async function openEmail(email) {
setSelected(email.thread_id || email.id);
setDetail(email);
setThreadDetail(null);
setDetailStatus({ loading: true, error: "", busy: false });
if (email.is_read === false) {
setEmails((current) =>
current.map((item) => (
item.thread_id === email.thread_id || item.id === email.id
? { ...item, is_read: true }
: item
)),
);
setDetail((current) => ({ ...current, is_read: true }));
setEmailRead(email.id, true, token).catch(() => {
setEmails((current) =>
current.map((item) => (
item.thread_id === email.thread_id || item.id === email.id
? { ...item, is_read: false }
: item
)),
);
setDetail((current) =>
current?.id === email.id ? { ...current, is_read: false } : current,
);
});
}
try {
if (email.thread_id) {
const thread = await getThread(email.thread_id, token);
const messages = thread.messages || [];
const latest = messages[messages.length - 1] || email;
setThreadDetail(thread);
setDetail((current) => ({
...current,
...latest,
is_read: current?.is_read ?? latest.is_read,
}));
} else {
const completeEmail = await getEmail(email.id, token);
setDetail((current) => ({
...current,
...completeEmail,
is_read: current?.is_read ?? completeEmail.is_read,
}));
}
setDetailStatus({ loading: false, error: "", busy: false });
} catch (error) {
setDetailStatus({ loading: false, error: error.message, busy: false });
}
}
async function toggleStar(email, isStarred) {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_starred: isStarred } : item),
);
if (detail?.id === email.id) {
setDetail((current) => ({ ...current, is_starred: isStarred }));
}
try {
await setEmailStar(email.id, isStarred, token);
} catch {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_starred: !isStarred } : item),
);
if (detail?.id === email.id) {
setDetail((current) => ({ ...current, is_starred: !isStarred }));
}
}
}
async function markUnread(email) {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_read: false } : item),
);
try {
await setEmailRead(email.id, false, token);
} catch {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_read: email.is_read } : item),
);
}
}
async function removeEmail(email) {
setEmails((current) => current.filter((item) => item.id !== email.id));
if (detail?.id === email.id) {
setSelected(null);
setDetail(null);
}
try {
await trashEmail(email.id, token);
} catch (error) {
setAgentFocus(error.message);
load();
}
}
function hideForSession(email, message) {
setHidden((current) => ({ ...current, [email.id]: true }));
if (detail?.id === email.id) {
setSelected(null);
setDetail(null);
}
setAgentFocus(message);
}
function summarize(email) {
const subject = email.subject?.trim() || "(No subject)";
const snippet = email.snippet || email.preview || email.body_text || "No preview available.";
setAgentFocus("");
runInsight({
title: subject,
summary: snippet,
details: [
`From ${email.sender?.name || email.sender?.email || "unknown sender"}`,
email.is_read === false ? "Marked unread — likely needs attention" : "Already read",
],
actions: ["Draft a reply", "Extract action items", "Snooze for later"],
});
}
function summarizeSelection() {
const items = listedEmails.filter((email) => checked[email.id]);
if (!items.length) return;
setAgentFocus("");
runInsight({
title: `${items.length} selected messages`,
summary: items.length === 1
? (items[0].snippet || items[0].preview || "No preview available.")
: `${items.length} conversations span ${items.filter((email) => email.is_read === false).length} unread threads.`,
details: items.slice(0, 3).map((email) => email.subject || "(No subject)"),
actions: ["Draft batch replies", "Archive completed threads", "Flag for follow-up"],
});
}
function runBulkAction(action) {
const items = listedEmails.filter((email) => checked[email.id]);
if (!items.length) return;
if (action === "archive" || action === "snooze") {
items.forEach((email) => hideForSession(
email,
action === "archive"
? "Archived in this view until you refresh."
: "Snoozed in this view until you refresh.",
));
}
if (action === "delete" || action === "spam") {
items.forEach((email) => removeEmail(email));
}
if (action === "unread") {
items.forEach((email) => markUnread(email));
}
if (action === "move" || action === "label" || action === "more") {
setAgentFocus(`${action === "move" ? "Move" : action === "label" ? "Label" : "More"} for ${items.length} selected messages is coming soon.`);
}
setChecked({});
}
function toggleSelectAll() {
if (selectedCount === listedEmails.length) {
setChecked({});
return;
}
setChecked(listedEmails.reduce((map, email) => ({ ...map, [email.id]: true }), {}));
}
function extractActions(email) {
const text = email.body_text || email.snippet || email.preview || "";
setAgentFocus("");
runInsight({
title: email.subject || "Message",
summary: text
? "Scanned the message for commitments, requests, and deadlines."
: "No plain-text content to extract actions from.",
details: text
? ["Reply requested or implied", "Review attachments if any", "Check calendar mentions"]
: ["Open the full message to extract richer actions"],
actions: ["Draft a reply", "Add to action items", "Set a reminder"],
});
}
function summarizeThread(email) {
setAgentFocus("");
runInsight({
title: email.subject || "Thread",
summary: email.snippet || email.preview || "Thread context loaded from the latest message.",
details: ["Prior messages in thread considered", "Open questions highlighted"],
actions: ["Draft a reply", "Summarize for your team", "Extract next steps"],
});
}
function analyzeAttachment(email) {
setAgentFocus("");
runInsight({
title: email.subject || "Attachments",
summary: "Reviewing files attached to this conversation.",
details: ["File types identified", "Key entities extracted from filenames"],
actions: ["Summarize document", "Find related messages", "Save to notes"],
});
}
function runQuickAction(action) {
const intel = inboxIntelligence(listedEmails);
if (action === "summarize") {
setAgentFocus("");
runInsight({
title: `${category} inbox`,
summary: listedEmails.length
? `${listedEmails.length} messages loaded. ${intel.actionItems} look like they need a reply.`
: "Nothing in this view to summarize yet.",
details: [
`${intel.meetings} mention meetings`,
`${intel.invoices} look like bills`,
`${intel.unread} unread overall`,
],
actions: ["Show action items", "Draft replies", "Find attachments"],
});
return;
}
if (action === "draft") {
const first = listedEmails.find((email) => email.is_read === false) || listedEmails[0];
setAgentFocus("");
runInsight({
title: first?.subject || "Draft replies",
summary: first
? `Ready to draft a reply to "${first.subject || "the latest message"}".`
: "No message is waiting for a reply.",
details: first ? ["Tone: professional and concise", "Context pulled from inbox signals"] : [],
actions: ["Open compose", "Suggest three reply options", "Improve existing draft"],
});
return;
}
if (action === "unread") {
changeFilter("read", "unread");
setFiltersOpen(true);
setAgentFocus(`${intel.unread} unread messages in this mailbox.`);
return;
}
if (action === "attachments" || action === "actions" || action === "important" || action === "actionItems" || action === "meetings" || action === "invoices") {
const labels = {
attachments: "attachments",
actions: "action items",
actionItems: "action items",
important: "important mail",
meetings: "meetings",
invoices: "invoices",
};
setCategory(action === "invoices" ? "Finance" : action === "important" ? "Primary" : category);
setAgentFocus(`Showing signals for ${labels[action] || action} from the messages already loaded.`);
}
}
function handleCommand(action, query = "") {
if (action === "search") {
if (query.trim()) changeFilter("query", query.trim());
window.setTimeout(() => document.getElementById("workspace-search")?.focus(), 0);
return;
}
if (action === "from") {
setFiltersOpen(true);
if (query.trim()) changeFilter("sender", query.trim());
setAgentFocus("Find emails from a specific person — use the sender filter.");
return;
}
runQuickAction(action);
}
useEffect(() => {
if (!agentNav || status.loading) return;
if (agentNav === "integrations") {
setAgentFocus("Connect CRM, calendar, and billing tools from Integrations.");
return;
}
if (agentNav === "ask" || agentNav === "summaries") {
runQuickAction("summarize");
return;
}
if (agentNav === "actions") {
runQuickAction("actions");
}
}, [agentNav, status.loading, listedEmails.length, category]);
async function toggleRead(isRead) {
if (!detail) return;
setDetailStatus((current) => ({ ...current, busy: true, error: "" }));
try {
await setEmailRead(detail.id, isRead, token);
setDetail((current) => ({ ...current, is_read: isRead }));
setEmails((current) =>
current.map((item) => item.id === detail.id ? { ...item, is_read: isRead } : item),
);
setDetailStatus((current) => ({ ...current, busy: false }));
} catch (error) {
setDetailStatus((current) => ({ ...current, busy: false, error: error.message }));
}
}
async function mutateSelected(action) {
if (!detail) return;
setDetailStatus((current) => ({ ...current, busy: true, error: "" }));
try {
await action(detail.id, token);
setEmails((current) => current.filter((item) => item.id !== detail.id));
setSelected(null);
setDetail(null);
setDetailStatus({ loading: false, error: "", busy: false });
} catch (error) {
setDetailStatus((current) => ({ ...current, busy: false, error: error.message }));
}
}
function deletePermanently() {
if (window.confirm("Permanently delete this message? This cannot be undone.")) {
mutateSelected(permanentlyDeleteEmail);
}
}
function nextPage() {
if (!nextCursor) return;
setCursorHistory((history) => [...history, cursor]);
setCursor(nextCursor);
setSelected(null);
setDetail(null);
setChecked({});
}
function previousPage() {
if (!cursorHistory.length) return;
const previous = cursorHistory[cursorHistory.length - 1];
setCursorHistory((history) => history.slice(0, -1));
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 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">
<header className="workspace-header">
<div className="workspace-zone workspace-zone-command">
<label className="workspace-search">
<Search size={16} />
<input
id="workspace-search"
type="search"
value={filters.query}
onChange={(event) => changeFilter("query", event.target.value)}
placeholder="Search emails, people, or ask your agent…"
/>
<button
type="button"
className="workspace-search-kbd"
aria-label="Open command palette"
onClick={() => setCommandOpen(true)}
>
<kbd>⌘K</kbd>
</button>
</label>
</div>
<div className="workspace-zone workspace-zone-actions">
<button
className={`workspace-filter${filtersOpen || hasFilters ? " active" : ""}`}
type="button"
onClick={() => setFiltersOpen((open) => !open)}
aria-expanded={filtersOpen}
aria-label="Advanced filters"
>
<Filter size={16} />
</button>
<button className="workspace-ask agent-action" type="button" onClick={() => runQuickAction("summarize")}>
<AgentSpark /> Ask Agent
</button>
<button
className="shell-agent-toggle"
type="button"
aria-label="Open AG Agent"
onClick={() => setSheet(sheet === "agent" ? null : "agent")}
>
<Sparkles size={16} />
</button>
</div>
<div className="workspace-zone workspace-zone-account">
<button type="button" className="workspace-icon" aria-label="Refresh" onClick={load}><RefreshCw size={16} /></button>
<button type="button" className="workspace-icon" aria-label="Theme"><Sun size={16} /></button>
<button type="button" className="workspace-icon" aria-label="Help"><CircleHelp size={16} /></button>
<button type="button" className="workspace-icon" aria-label="Settings" onClick={onOpenSettings}><Settings size={16} /></button>
<WorkspaceAccountMenu
account={account}
onOpenProfile={onOpenProfile}
onOpenSettings={onOpenSettings}
onLogout={onLogout}
/>
</div>
</header>
<div className="mailbox-stage">
<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")}
onDelete={() => runBulkAction("delete")}
onMarkUnread={() => runBulkAction("unread")}
onSnooze={() => runBulkAction("snooze")}
onMove={() => runBulkAction("move")}
onLabel={() => runBulkAction("label")}
onMore={() => runBulkAction("more")}
onSummarize={summarizeSelection}
/>
)}
{mailbox === "inbox" && selectedCount === 0 && (
<div className="mailbox-categories" role="tablist" aria-label="Inbox categories">
{CATEGORY_TABS.map(({ name, icon: Icon }) => (
<button
key={name}
type="button"
role="tab"
aria-selected={category === name}
className={category === name ? "active" : ""}
onClick={() => setCategory(name)}
>
<Icon size={16} strokeWidth={1.75} />
<span className="mailbox-category-label">{name}</span>
{name !== "All Mail" && counts[name] > 0 && (
<span className="mailbox-category-count">{counts[name]}</span>
)}
</button>
))}
</div>
)}
{mailbox !== "inbox" && (
<div className="mailbox-categories">
<button type="button" className="active">{title}</button>
</div>
)}
{filtersOpen && (
<div className="mailbox-filters">
<label>Read status
<select value={filters.read} onChange={(event) => changeFilter("read", event.target.value)}>
<option value="all">All messages</option>
<option value="unread">Unread</option>
<option value="read">Read</option>
</select>
</label>
<label>Sender
<input value={filters.sender} onChange={(event) => changeFilter("sender", event.target.value)} placeholder="name@example.com" />
</label>
<label>From
<input type="date" value={filters.dateFrom} onChange={(event) => changeFilter("dateFrom", event.target.value)} />
</label>
<label>To
<input type="date" value={filters.dateTo} onChange={(event) => changeFilter("dateTo", event.target.value)} />
</label>
</div>
)}
<div className={`mailbox-rows${isEmpty ? " is-empty" : ""}${selectedCount > 0 ? " selecting" : ""}`} aria-busy={status.loading}>
{status.loading && <div className="mailbox-state loading">Loading messages…</div>}
{status.error && (
<div className="mailbox-state error" role="alert">
<p>{status.error}</p><button type="button" onClick={load}>Try again</button>
</div>
)}
{isEmpty && (
<MailboxEmptyState
mailbox={mailbox}
title={title}
category={mailbox === "inbox" ? category : null}
isBrandNew={isBrandNew}
hasFilters={hasFilters}
hasSearch={hasSearch}
lens={lens}
onCompose={onCompose}
onRefresh={load}
/>
)}
{!status.loading && !status.error && listedEmails.map((email) => (
<MailRow
key={email.id}
email={email}
selected={selected === email.id}
checked={Boolean(checked[email.id])}
onOpen={openEmail}
onToggleStar={toggleStar}
onToggleCheck={(item) => setChecked((current) => ({ ...current, [item.id]: !current[item.id] }))}
onTrash={removeEmail}
onMarkUnread={markUnread}
onArchive={(item) => hideForSession(item, "Archived in this view until you refresh.")}
onSnooze={(item) => hideForSession(item, "Snoozed in this view until you refresh.")}
onSummarize={summarize}
/>
))}
</div>
<MailDetail
email={detail}
thread={threadDetail}
messages={threadDetail?.messages}
loading={detailStatus.loading}
error={detailStatus.error}
busy={detailStatus.busy}
mailbox={mailbox}
viewerEmail={account?.email}
onClose={() => { setSelected(null); setDetail(null); setThreadDetail(null); }}
onToggleRead={toggleRead}
onToggleStar={(starred) => detail && toggleStar(detail, starred)}
onTrash={() => mutateSelected(trashEmail)}
onRestore={() => mutateSelected(restoreEmail)}
onDelete={deletePermanently}
onArchive={() => detail && hideForSession(detail, "Archived in this view until you refresh.")}
onSpam={() => detail && hideForSession(detail, "Marked as spam in this view until you refresh.")}
onSnooze={() => detail && hideForSession(detail, "Snoozed in this view until you refresh.")}
onMove={() => setAgentFocus("Move and labels — coming soon.")}
onReply={(replyAll = false) => {
const target = threadDetail?.messages?.[threadDetail.messages.length - 1] || detail;
onCompose?.(buildReplyContext(target, replyAll));
}}
onForward={() => {
const target = threadDetail?.messages?.[threadDetail.messages.length - 1] || detail;
onCompose?.({
subject: `Fwd: ${target?.subject || ""}`,
bodyHtml: buildReplyBodyHtml(target),
});
}}
onExtractActions={() => detail && extractActions(detail)}
onSummarizeThread={() => detail && summarizeThread(detail)}
onAnalyzeAttachment={() => detail && analyzeAttachment(detail)}
/>
</div>
</div>
</div>
<aside className={`mailbox-rail${sheet ? ` shell-${sheet}` : ""}`}>
{(sheet === "agent" || sheet === "calendar" || sheet === "notes") && (
<div className="shell-sheet-bar">
<button type="button" aria-label="Back" onClick={() => setSheet(null)}>
<ChevronLeft size={18} strokeWidth={1.75} />
</button>
<strong>{shellTitle(sheet)}</strong>
</div>
)}
<AgAgentPanel
account={account}
emails={listedEmails}
contextEmail={detail}
contextThread={threadDetail}
focus={agentFocus}
insight={insight}
loading={status.loading}
mailboxEmpty={isEmpty}
isBrandNewMailbox={isBrandNew && mailbox === "inbox"}
onCompose={onCompose}
onQuickAction={runQuickAction}
onOpenEmail={(emailId) => {
const email = listedEmails.find((item) => item.id === emailId);
if (email) openEmail(email);
}}
onAgentAsk={(query) => {
setAgentFocus("");
runInsight({
title: "Your question",
summary: query,
details: ["Scanning loaded messages for relevant context"],
actions: ["Summarize matching threads", "Draft a reply", "Find related emails"],
});
}}
/>
<StickyNotesPanel compact contextEmail={detail} />
</aside>
</div>
<CommandPalette
open={commandOpen}
onClose={() => setCommandOpen(false)}
onCommand={handleCommand}
/>
</section>
);
}