feat: shrink the From mailbox menu into a compact searchable list: single-line h-8 rows (auth dot, address, history count, sent/limit budget, best marker) with the name and score reasons moved into the tooltip, a search box that filters as you type, tag filter chips built from the org's mailbox tags (only tags actually in use are offered), an inline Auto row, and a no-match empty state

This commit is contained in:
Matthew Meszaros
2026-07-19 09:22:17 +02:00
parent eb1c707dcb
commit 1da0a932fa
@@ -1,9 +1,9 @@
// MailboxPicker — the compose "From" selector. Defaults to Auto: the backend
// scores every active mailbox for the current recipient (conversation
// affinity, remaining daily budget, domain auth) and this control shows the
// resolved pick with its reason. Opening the menu reveals every candidate
// with its budget bar, history badge, and auth health so a manual override
// is an informed choice, not a guess.
// resolved pick. The menu is a compact, searchable list (filterable by
// mailbox tag) so orgs with dozens of mailboxes can still pick in a second;
// per-row detail lives in the title tooltip instead of bloating the rows.
import React from "react";
import { createPortal } from "react-dom";
@@ -12,11 +12,12 @@ import {
CheckIcon,
ChevronDownIcon,
MessagesSquareIcon,
ShieldAlertIcon,
SearchIcon,
SparklesIcon,
} from "lucide-react";
import type { ComposeCandidate, ComposeCandidatesResponse } from "@/lib/api/models/app/unibox/Compose";
import useClickOutside from "@/hooks/useClickOutside";
import { useAppStore } from "@/stores";
import { cn } from "@/lib/utils";
interface MailboxPickerProps {
@@ -28,16 +29,21 @@ interface MailboxPickerProps {
loading?: boolean;
}
const PANEL_WIDTH = 320;
const PANEL_WIDTH = 300;
export default function MailboxPicker({ value, onChange, candidates, loading }: MailboxPickerProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const [tagFilter, setTagFilter] = React.useState<string | null>(null);
// Viewport anchor for the portaled panel (the compose window clips
// overflow, so the menu can't render inside it).
const [anchor, setAnchor] = React.useState<{ top: number; left: number; up: boolean } | null>(null);
const boxRef = React.useRef<HTMLDivElement>(null);
useClickOutside(boxRef, () => setOpen(false));
const storeEmails = useAppStore((s) => s.emails);
const storeTags = useAppStore((s) => s.tags);
const measure = React.useCallback(() => {
const el = boxRef.current;
if (!el) return;
@@ -46,12 +52,16 @@ export default function MailboxPicker({ value, onChange, candidates, loading }:
const vh = window.innerHeight;
const left = Math.min(Math.max(r.left, 8), vw - PANEL_WIDTH - 8);
// Flip above the trigger when the space below is tight.
const up = vh - r.bottom < 320 && r.top > vh - r.bottom;
const up = vh - r.bottom < 300 && r.top > vh - r.bottom;
setAnchor({ top: up ? r.top - 4 : r.bottom + 4, left, up });
}, []);
React.useEffect(() => {
if (!open) return;
if (!open) {
setSearch("");
setTagFilter(null);
return;
}
measure();
window.addEventListener("scroll", measure, true);
window.addEventListener("resize", measure);
@@ -61,10 +71,38 @@ export default function MailboxPicker({ value, onChange, candidates, loading }:
};
}, [open, measure]);
const accounts = candidates?.accounts ?? [];
const accounts = React.useMemo(() => candidates?.accounts ?? [], [candidates]);
const recommended = accounts.find((a) => a.recommended) ?? accounts[0];
const selected = value === "auto" ? recommended : accounts.find((a) => a.id === value);
// Tag ids per account come from the store's full mailbox records; only
// tags actually used by a listed account are offered as filters.
const tagsByAccount = React.useMemo(() => {
const m = new Map<string, string[]>();
for (const e of storeEmails) m.set(e.id, e.tags ?? []);
return m;
}, [storeEmails]);
const usedTags = React.useMemo(() => {
const used = new Set<string>();
for (const a of accounts) for (const t of tagsByAccount.get(a.id) ?? []) used.add(t);
return storeTags.filter((t) => used.has(t.id));
}, [accounts, storeTags, tagsByAccount]);
const filtered = React.useMemo(() => {
const q = search.trim().toLowerCase();
return accounts.filter((a) => {
if (tagFilter && !(tagsByAccount.get(a.id) ?? []).includes(tagFilter)) return false;
if (!q) return true;
return `${a.email} ${a.name}`.toLowerCase().includes(q);
});
}, [accounts, search, tagFilter, tagsByAccount]);
const pick = (next: string) => {
onChange(next);
setOpen(false);
};
return (
<div ref={boxRef} className="relative min-w-0 flex-1">
<button
@@ -103,81 +141,113 @@ export default function MailboxPicker({ value, onChange, candidates, loading }:
</button>
{createPortal(
<AnimatePresence>
{open && anchor && (
<motion.div
data-floating=""
initial={{ opacity: 0, y: anchor.up ? 4 : -4, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: anchor.up ? 4 : -4, scale: 0.98 }}
transition={{ duration: 0.14, ease: [0.16, 1, 0.3, 1] }}
style={{
position: "fixed",
left: anchor.left,
width: PANEL_WIDTH,
zIndex: 120,
...(anchor.up
? { bottom: window.innerHeight - anchor.top }
: { top: anchor.top }),
}}
className="max-w-[calc(100vw-16px)] rounded-lg border border-slate-200 bg-white shadow-xl overflow-hidden"
>
<button
type="button"
onClick={() => {
onChange("auto");
setOpen(false);
<AnimatePresence>
{open && anchor && (
<motion.div
data-floating=""
initial={{ opacity: 0, y: anchor.up ? 4 : -4, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: anchor.up ? 4 : -4, scale: 0.98 }}
transition={{ duration: 0.14, ease: [0.16, 1, 0.3, 1] }}
style={{
position: "fixed",
left: anchor.left,
width: PANEL_WIDTH,
zIndex: 120,
...(anchor.up
? { bottom: window.innerHeight - anchor.top }
: { top: anchor.top }),
}}
className={cn(
"w-full px-3 py-2 flex items-start gap-2 text-left transition-colors hover:bg-slate-50",
value === "auto" && "bg-sky-50/60",
)}
className="max-w-[calc(100vw-16px)] rounded-lg border border-slate-200 bg-white shadow-xl overflow-hidden"
>
<span className="size-6 rounded-md bg-sky-100 text-sky-600 inline-flex items-center justify-center shrink-0 mt-0.5">
<SparklesIcon className="w-3.5 h-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[12.5px] font-medium text-slate-900">
Auto
{value === "auto" && (
<CheckIcon className="inline w-3 h-3 ml-1.5 text-sky-600" />
)}
</span>
<span className="block text-[11px] text-slate-500 leading-snug">
{recommended
? `Picks ${recommended.email}${candidates?.recommended_reason ? `: ${candidates.recommended_reason}` : ""}`
: "Picks the best mailbox for each recipient"}
</span>
</span>
</button>
<div className="border-t border-slate-100 max-h-56 overflow-y-auto">
{accounts.length === 0 && (
<div className="px-3 py-3 text-[11.5px] text-slate-400">
No active mailboxes. Connect one under Emails.
{/* Search + tag filter header */}
<div className="px-1.5 pt-1.5 pb-1 border-b border-slate-100">
<div className="flex items-center gap-1.5 px-1.5 h-6 rounded-md bg-slate-50 focus-within:bg-white focus-within:ring-1 focus-within:ring-sky-200 transition-colors">
<SearchIcon className="w-3 h-3 text-slate-400 shrink-0" />
<input
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search mailboxes…"
className="flex-1 min-w-0 bg-transparent text-[11.5px] text-slate-900 placeholder:text-slate-400 outline-none"
/>
</div>
)}
{accounts.map((a) => (
<CandidateRow
key={a.id}
candidate={a}
active={value === a.id}
onPick={() => {
onChange(a.id);
setOpen(false);
}}
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>,
document.body,
{usedTags.length > 0 && (
<div className="mt-1 flex items-center gap-1 overflow-x-auto pb-0.5">
{usedTags.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setTagFilter((f) => (f === t.id ? null : t.id))}
className={cn(
"h-5 px-1.5 rounded-full text-[10px] font-medium inline-flex items-center gap-1 shrink-0 border transition-colors",
tagFilter === t.id
? "border-sky-300 bg-sky-50 text-sky-700"
: "border-slate-200 text-slate-500 hover:border-slate-300 hover:text-slate-700",
)}
>
<span
className="size-1.5 rounded-full"
style={{ backgroundColor: t.color }}
/>
{t.title}
</button>
))}
</div>
)}
</div>
{/* Auto */}
<button
type="button"
onClick={() => pick("auto")}
title={
recommended
? `Picks ${recommended.email}${candidates?.recommended_reason ? `: ${candidates.recommended_reason}` : ""}`
: "Picks the best mailbox for each recipient"
}
className={cn(
"w-full h-8 px-2.5 flex items-center gap-2 text-left transition-colors hover:bg-slate-50",
value === "auto" && "bg-sky-50/60",
)}
>
<SparklesIcon className="w-3 h-3 text-sky-500 shrink-0" />
<span className="text-[11.5px] font-medium text-slate-900">Auto</span>
<span className="min-w-0 flex-1 text-[10.5px] text-slate-400 truncate">
{recommended ? recommended.email : "best mailbox per recipient"}
</span>
{value === "auto" && <CheckIcon className="w-3 h-3 text-sky-600 shrink-0" />}
</button>
{/* Candidates, best first */}
<div className="border-t border-slate-100 max-h-52 overflow-y-auto">
{filtered.length === 0 && (
<div className="px-3 py-3 text-[11px] text-slate-400">
{accounts.length === 0
? "No active mailboxes. Connect one under Emails."
: "No mailboxes match."}
</div>
)}
{filtered.map((a) => (
<CandidateRow
key={a.id}
candidate={a}
active={value === a.id}
onPick={() => pick(a.id)}
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
</div>
);
}
// One compact line per mailbox: auth dot, address, then history + budget on
// the right. Everything else (name, score reasons) lives in the tooltip.
function CandidateRow({
candidate: a,
active,
@@ -187,72 +257,50 @@ function CandidateRow({
active: boolean;
onPick: () => void;
}) {
const pct = a.daily_limit > 0 ? Math.min(100, Math.round((a.sent_today / a.daily_limit) * 100)) : 0;
const spent = a.daily_limit > 0 && a.sent_today >= a.daily_limit;
return (
<button
type="button"
onClick={onPick}
title={[a.name, ...a.reasons].filter(Boolean).join(" · ")}
className={cn(
"w-full px-3 py-2 flex items-start gap-2 text-left transition-colors hover:bg-slate-50",
"w-full h-8 px-2.5 flex items-center gap-2 text-left transition-colors hover:bg-slate-50",
active && "bg-sky-50/60",
)}
>
<span
className={cn(
"size-1.5 rounded-full shrink-0 mt-[7px]",
a.auth_state === "failing" ? "bg-rose-500" : a.auth_state === "passing" ? "bg-emerald-500" : "bg-slate-300",
)}
title={
"size-1.5 rounded-full shrink-0",
a.auth_state === "failing"
? "Domain authentication failing (SPF/DMARC)"
? "bg-rose-500"
: a.auth_state === "passing"
? "Domain authenticated"
: "Domain authentication not checked yet"
}
? "bg-emerald-500"
: "bg-slate-300",
)}
/>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5 min-w-0">
<span className="text-[12px] font-medium text-slate-900 truncate">
{a.name || a.email}
<span className="min-w-0 flex-1 text-[11.5px] text-slate-800 truncate">
{a.email}
{a.recommended && (
<span className="ml-1.5 text-[9px] font-semibold uppercase tracking-wide text-sky-600">
best
</span>
{a.recommended && (
<span className="inline-flex items-center gap-0.5 h-4 px-1 rounded bg-sky-50 text-sky-700 text-[9.5px] font-semibold uppercase tracking-wide shrink-0">
best
</span>
)}
{active && <CheckIcon className="w-3 h-3 text-sky-600 shrink-0" />}
</span>
<span className="block font-mono text-[10.5px] text-slate-500 truncate">{a.email}</span>
<span className="mt-1 flex items-center gap-2">
<span className="w-16 h-1 rounded-full bg-slate-100 overflow-hidden shrink-0">
<span
className={cn(
"block h-full rounded-full",
pct >= 100 ? "bg-rose-400" : pct >= 80 ? "bg-amber-400" : "bg-sky-500",
)}
style={{ width: `${pct}%` }}
/>
</span>
<span className="text-[10px] text-slate-400 tabular-nums shrink-0">
{a.sent_today}/{a.daily_limit} today
</span>
{a.history_messages > 0 && (
<span
className="inline-flex items-center gap-0.5 text-[10px] text-emerald-700 shrink-0"
title="Messages already exchanged with this recipient from this mailbox"
>
<MessagesSquareIcon className="w-2.5 h-2.5" />
{a.history_messages}
</span>
)}
{a.auth_state === "failing" && (
<span className="inline-flex items-center gap-0.5 text-[10px] text-rose-600 shrink-0">
<ShieldAlertIcon className="w-2.5 h-2.5" />
auth
</span>
)}
</span>
)}
</span>
{a.history_messages > 0 && (
<span className="inline-flex items-center gap-0.5 text-[10px] text-emerald-700 shrink-0">
<MessagesSquareIcon className="w-2.5 h-2.5" />
{a.history_messages}
</span>
)}
<span
className={cn(
"font-mono text-[10px] tabular-nums shrink-0",
spent ? "text-rose-500" : "text-slate-400",
)}
>
{a.sent_today}/{a.daily_limit}
</span>
{active && <CheckIcon className="w-3 h-3 text-sky-600 shrink-0" />}
</button>
);
}