inbox streamlining

This commit is contained in:
debu-2 2026-10-05 22:22:18 +05:30
parent c26a8a2094
commit f969cfed44
4 changed files with 382 additions and 104 deletions

View file

@ -76,9 +76,26 @@ function metricTotal(stats) {
return cards.reduce((sum, card) => sum + (stats[card.key] || 0), 0);
}
function emailContextSummary(email) {
const text = (email?.body_text || email?.snippet || "").trim();
if (!text) return "Open message loaded — summary will appear here.";
return text.length > 160 ? `${text.slice(0, 157)}…` : text;
}
function emailContextActions(email) {
const text = (email?.body_text || email?.snippet || "").toLowerCase();
const actions = [];
if (text.includes("check") && text.includes("inbox")) actions.push("Check inbox");
if (text.includes("repl")) actions.push("Review replies");
if (text.includes("attach")) actions.push("Review attachments");
if (!actions.length) actions.push("Review message", "Draft a reply");
return actions.slice(0, 3);
}
export default function AgAgentPanel({
account,
emails,
contextEmail,
focus,
insight,
loading = false,
@ -95,9 +112,10 @@ export default function AgAgentPanel({
const firstName = account?.first_name || "there";
const allMetricsZero = metricTotal(stats) === 0;
const hasQuery = agentQuery.trim().length > 0;
const emailContextMode = Boolean(contextEmail) && !insight;
const showOnboarding = !insight && !loading && mailboxEmpty && isBrandNewMailbox;
const showQuietEmpty = !insight && !loading && mailboxEmpty && !isBrandNewMailbox;
const showFullPanel = !insight && !loading && !mailboxEmpty;
const showFullPanel = !insight && !loading && !mailboxEmpty && !emailContextMode;
function submitAgentQuery(event) {
event.preventDefault();
@ -108,7 +126,7 @@ export default function AgAgentPanel({
}
return (
<section className="agent-panel" aria-label="AG Agent">
<section className={`agent-panel${emailContextMode ? " email-context" : ""}`} aria-label="AG Agent">
<header className="agent-panel-head">
<span className="agent-panel-mark" aria-hidden="true">
<Sparkles {...ICON} />
@ -136,15 +154,33 @@ export default function AgAgentPanel({
<div className="agent-panel-hello">
<strong>{greeting(firstName)}</strong>
<span>
{showOnboarding
? "Your new inbox is ready."
: showQuietEmpty
? "Nothing needs your attention right now."
: "Here's what needs your attention today."}
{emailContextMode
? "About this email"
: showOnboarding
? "Your new inbox is ready."
: showQuietEmpty
? "Nothing needs your attention right now."
: "Here's what needs your attention today."}
</span>
</div>
)}
{emailContextMode && (
<section className="agent-email-context">
<h3>Summary</h3>
<p>{emailContextSummary(contextEmail)}</p>
<h3>Action items</h3>
<ul>
{emailContextActions(contextEmail).map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<button type="button" className="agent-email-context-reply" onClick={() => onCompose?.()}>
<PenLine {...ICON_SM} /> Draft reply
</button>
</section>
)}
{!insight && loading && (
<div className="agent-loading-block" aria-hidden="true">
<span className="agent-loading-pulse" />

View file

@ -1,18 +1,26 @@
import {
Archive,
ArrowLeft,
Clock,
Forward,
Mail,
MailOpen,
MoreHorizontal,
Reply,
RotateCcw,
Star,
Tag,
FolderInput,
ShieldAlert,
Trash2,
X,
ChevronDown,
} from "lucide-react";
import AgentSpark from "./AgentSpark";
import SenderAvatar from "./SenderAvatar";
import { senderDisplayName } from "../utils/senderAvatar";
import { addressText, senderDisplayName } from "../utils/senderAvatar";
import { attachmentCount } from "../utils/inboxSignals";
const ICON = { size: 18, strokeWidth: 1.75 };
const ICON_SM = { size: 16, strokeWidth: 1.75 };
const ICON = { size: 17, strokeWidth: 1.7 };
function joinAddresses(value) {
if (!value) return "";
@ -37,16 +45,80 @@ function messageBody(email) {
return { mode: "text", value: "This message has no readable content." };
}
function detailDate(email) {
const value =
email?.received_at || email?.sent_at || email?.created_at || email?.updated_at;
if (!value) return "";
function plainTextParagraphs(text) {
return text
.split(/\n{2,}/)
.map((block) => block.trim())
.filter(Boolean);
}
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 "";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
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 };
}
function senderIdentity(email) {
const name = senderDisplayName(email);
const address = addressText(email.sender || email.from);
if (name && address && !name.includes("@")) {
return `${name} <${address}>`;
}
return address || name || "Unknown sender";
}
function recipientLabel(email, viewerEmail) {
const recipients = email.recipients || email.to || [];
const list = Array.isArray(recipients) ? recipients : [recipients];
const addresses = list
.map((item) => (typeof item === "string" ? item : item?.address || item?.email || ""))
.filter(Boolean);
if (!addresses.length) return "Undisclosed recipients";
if (viewerEmail && addresses.length === 1 && addresses[0].toLowerCase() === viewerEmail.toLowerCase()) {
return "to me";
}
if (addresses.length === 1) return `to ${addresses[0]}`;
return `to ${addresses.length} recipients`;
}
function formatFileSize(bytes) {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function mailboxLabel(mailbox) {
if (mailbox === "inbox") return "Inbox";
if (mailbox === "sent") return "Sent";
if (mailbox === "trash") return "Trash";
return mailbox;
}
function ToolbarButton({ title, label, onClick, disabled, danger, children }) {
return (
<button
type="button"
className={danger ? "danger" : ""}
title={title}
aria-label={label || title}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
}
export default function MailDetail({
@ -55,96 +127,216 @@ export default function MailDetail({
error,
mailbox,
busy,
viewerEmail,
threadSize = 1,
onClose,
onToggleRead,
onToggleStar,
onTrash,
onRestore,
onDelete,
onArchive,
onSpam,
onSnooze,
onMove,
onReply,
onForward,
onExtractActions,
onSummarizeThread,
onAnalyzeAttachment,
onDraftReply,
}) {
const files = email ? attachmentCount(email) : 0;
const body = email ? messageBody(email) : null;
const time = email ? detailTime(email) : { absolute: "", relative: "" };
const attachments = email?.attachments || [];
const summarizeLabel = threadSize > 1 ? "Summarize thread" : "Summarize email";
return (
<article className={`mail-detail${email || loading ? " open" : ""}`} aria-live="polite">
<header className="mail-detail-toolbar">
<button type="button" onClick={onClose} aria-label="Back to message list">
<ArrowLeft {...ICON} />
</button>
<div>
{email && (
<button
type="button"
disabled={busy}
onClick={() => onToggleRead(!email.is_read)}
title={email.is_read ? "Mark unread" : "Mark read"}
aria-label={email.is_read ? "Mark unread" : "Mark read"}
>
{email.is_read ? <Mail {...ICON} /> : <MailOpen {...ICON} />}
</button>
)}
{email && mailbox === "trash" && (
<button type="button" disabled={busy} onClick={onRestore} title="Restore" aria-label="Restore message">
<RotateCcw {...ICON} />
</button>
)}
{email && (
<button
className={mailbox === "trash" ? "danger" : ""}
type="button"
disabled={busy}
onClick={mailbox === "trash" ? onDelete : onTrash}
title={mailbox === "trash" ? "Delete permanently" : "Move to trash"}
aria-label={mailbox === "trash" ? "Delete message permanently" : "Move message to trash"}
>
<Trash2 {...ICON} />
</button>
)}
<button className="mail-detail-close" type="button" onClick={onClose} aria-label="Close message">
<X size={18} strokeWidth={1.75} />
</button>
<div className="mail-detail-toolbar-group">
<ToolbarButton title="Back to inbox" label="Back" onClick={onClose}>
<ArrowLeft {...ICON} />
</ToolbarButton>
</div>
{email && (
<>
<div className="mail-detail-toolbar-group">
<ToolbarButton title="Archive" onClick={onArchive} disabled={busy}>
<Archive {...ICON} />
</ToolbarButton>
<ToolbarButton title="Report spam" onClick={onSpam} disabled={busy}>
<ShieldAlert {...ICON} />
</ToolbarButton>
<ToolbarButton
title={mailbox === "trash" ? "Delete permanently" : "Delete"}
onClick={mailbox === "trash" ? onDelete : onTrash}
disabled={busy}
danger
>
<Trash2 {...ICON} />
</ToolbarButton>
</div>
<span className="mail-detail-toolbar-sep" aria-hidden="true" />
<div className="mail-detail-toolbar-group">
<ToolbarButton
title={email.is_read ? "Mark unread" : "Mark read"}
onClick={() => onToggleRead(!email.is_read)}
disabled={busy}
>
{email.is_read ? <Mail {...ICON} /> : <MailOpen {...ICON} />}
</ToolbarButton>
<ToolbarButton title="Snooze" onClick={onSnooze} disabled={busy}>
<Clock {...ICON} />
</ToolbarButton>
</div>
<span className="mail-detail-toolbar-sep" aria-hidden="true" />
<div className="mail-detail-toolbar-group">
<ToolbarButton title="Move to" onClick={onMove} disabled={busy}>
<FolderInput {...ICON} />
</ToolbarButton>
<ToolbarButton title="Labels" onClick={onMove} disabled={busy}>
<Tag {...ICON} />
</ToolbarButton>
<ToolbarButton title="More actions" onClick={onMove} disabled={busy}>
<MoreHorizontal {...ICON} />
</ToolbarButton>
</div>
{mailbox === "trash" && (
<>
<span className="mail-detail-toolbar-sep" aria-hidden="true" />
<div className="mail-detail-toolbar-group">
<ToolbarButton title="Restore" onClick={onRestore} disabled={busy}>
<RotateCcw {...ICON} />
</ToolbarButton>
</div>
</>
)}
</>
)}
</header>
{loading && <div className="mail-detail-state">Opening message…</div>}
{error && <div className="mail-detail-state error" role="alert">{error}</div>}
{email && !loading && (
<div className="mail-detail-content">
<div className="mail-detail-agent-bar">
<button type="button" className="agent-action subtle" onClick={onExtractActions}>
<AgentSpark /> Extract actions
</button>
<button type="button" className="agent-action subtle" onClick={onSummarizeThread}>
<AgentSpark /> Summarize thread
</button>
{files > 0 && (
<button type="button" className="agent-action subtle" onClick={onAnalyzeAttachment}>
<AgentSpark /> Analyze attachment
</button>
)}
</div>
<h2>{email.subject?.trim() || "(No subject)"}</h2>
<div className="mail-detail-meta">
<div>
<div className="mail-detail-sender">
<SenderAvatar email={email} />
<strong>{senderDisplayName(email)}</strong>
<div className="mail-detail-subject-row">
<div className="mail-detail-subject-main">
<h1>{email.subject?.trim() || "(No subject)"}</h1>
<div className="mail-detail-labels">
<span className="mail-detail-label-pill">{mailboxLabel(mailbox)}</span>
{email.is_starred && <span className="mail-detail-label-pill starred">Starred</span>}
</div>
<small>From: {joinAddresses(email.sender) || "Unknown"}</small>
<small>To: {joinAddresses(email.recipients || email.to) || "Undisclosed"}</small>
{joinAddresses(email.cc) && <small>Cc: {joinAddresses(email.cc)}</small>}
</div>
<time>{detailDate(email)}</time>
<div className="mail-detail-ai-chips">
<button type="button" className="mail-detail-ai-chip" onClick={onSummarizeThread}>
<AgentSpark /> {summarizeLabel}
</button>
<button type="button" className="mail-detail-ai-chip" onClick={onExtractActions}>
<AgentSpark /> Extract actions
</button>
<button type="button" className="mail-detail-ai-chip" onClick={onDraftReply}>
<AgentSpark /> Draft reply
</button>
{files > 0 && (
<button type="button" className="mail-detail-ai-chip" onClick={onAnalyzeAttachment}>
<AgentSpark /> Analyze attachment
</button>
)}
</div>
</div>
<div className="mail-detail-body">
<div className="mail-detail-sender-row">
<div className="mail-detail-sender-left">
<SenderAvatar email={email} />
<div className="mail-detail-sender-meta">
<div className="mail-detail-sender-line">
<strong>{senderIdentity(email)}</strong>
</div>
<button type="button" className="mail-detail-recipient-toggle">
{recipientLabel(email, viewerEmail)} <ChevronDown size={12} strokeWidth={1.75} />
</button>
<div className="mail-detail-recipient-details">
<small>To: {joinAddresses(email.recipients || email.to) || "Undisclosed"}</small>
{joinAddresses(email.cc) && <small>Cc: {joinAddresses(email.cc)}</small>}
</div>
</div>
</div>
<div className="mail-detail-sender-actions">
<time dateTime={email.created_at}>
{time.absolute}
{time.relative ? ` (${time.relative})` : ""}
</time>
<button
type="button"
className={`mail-detail-icon-btn${email.is_starred ? " active" : ""}`}
title={email.is_starred ? "Unstar" : "Star"}
aria-label={email.is_starred ? "Unstar" : "Star"}
onClick={() => onToggleStar?.(!email.is_starred)}
>
<Star size={16} strokeWidth={1.75} />
</button>
<button type="button" className="mail-detail-icon-btn" title="Reply" onClick={onReply}>
<Reply size={16} strokeWidth={1.75} />
</button>
<button type="button" className="mail-detail-icon-btn" title="More" onClick={onMove}>
<MoreHorizontal size={16} strokeWidth={1.75} />
</button>
</div>
</div>
<div className={`mail-detail-body${body?.mode === "html" ? " is-html" : " is-text"}`}>
{body?.mode === "html" ? (
<div className="mail-detail-body-html" dangerouslySetInnerHTML={{ __html: body.value }} />
) : (
body?.value
<div className="mail-detail-body-text">
{plainTextParagraphs(body?.value || "").map((paragraph) => (
<p key={paragraph.slice(0, 24)}>
{paragraph.split("\n").map((line, index, lines) => (
<span key={`${line}-${index}`}>
{line}
{index < lines.length - 1 && <br />}
</span>
))}
</p>
))}
</div>
)}
</div>
{attachments.length > 0 && (
<div className="mail-detail-attachments" aria-label="Attachments">
{attachments.map((file) => (
<div key={`${file.filename}-${file.size}`} className="mail-detail-attachment">
<span className="mail-detail-attachment-icon">📄</span>
<div>
<strong>{file.filename}</strong>
<small>{file.content_type || "File"} · {formatFileSize(file.size)}</small>
</div>
<div className="mail-detail-attachment-actions">
<button type="button">Preview</button>
<button type="button">Download</button>
</div>
</div>
))}
</div>
)}
<footer className="mail-detail-reply-bar">
<button type="button" className="mail-detail-reply-btn" onClick={onReply}>
<Reply size={15} strokeWidth={1.75} /> Reply
</button>
{(email.recipients?.length > 1 || email.cc?.length) && (
<button type="button" className="mail-detail-reply-btn subtle" onClick={onReply}>
Reply all
</button>
)}
<button type="button" className="mail-detail-reply-btn subtle" onClick={onForward}>
<Forward size={15} strokeWidth={1.75} /> Forward
</button>
</footer>
</div>
)}
</article>

View file

@ -698,14 +698,24 @@ export default function MailboxView({
error={detailStatus.error}
busy={detailStatus.busy}
mailbox={mailbox}
viewerEmail={account?.email}
threadSize={1}
onClose={() => { setSelected(null); setDetail(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={() => { onCompose?.(); setAgentFocus(detail?.subject ? `Reply: ${detail.subject}` : "Reply"); }}
onForward={() => { onCompose?.(); setAgentFocus(detail?.subject ? `Forward: ${detail.subject}` : "Forward"); }}
onExtractActions={() => detail && extractActions(detail)}
onSummarizeThread={() => detail && summarizeThread(detail)}
onAnalyzeAttachment={() => detail && analyzeAttachment(detail)}
onDraftReply={() => detail && extractActions(detail)}
/>
</div>
</div>
@ -715,6 +725,7 @@ export default function MailboxView({
<AgAgentPanel
account={account}
emails={listedEmails}
contextEmail={detail}
focus={agentFocus}
insight={insight}
loading={status.loading}