diff --git a/web/src/components/app/unibox/compose/MailboxPicker.tsx b/web/src/components/app/unibox/compose/MailboxPicker.tsx index 5c284403..f7d0a606 100644 --- a/web/src/components/app/unibox/compose/MailboxPicker.tsx +++ b/web/src/components/app/unibox/compose/MailboxPicker.tsx @@ -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(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(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(); + for (const e of storeEmails) m.set(e.id, e.tags ?? []); + return m; + }, [storeEmails]); + + const usedTags = React.useMemo(() => { + const used = new Set(); + 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 (
- -
- {accounts.length === 0 && ( -
- No active mailboxes. Connect one under Emails. + {/* Search + tag filter header */} +
+
+ + 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" + />
- )} - {accounts.map((a) => ( - { - onChange(a.id); - setOpen(false); - }} - /> - ))} -
- - )} - , - document.body, + {usedTags.length > 0 && ( +
+ {usedTags.map((t) => ( + + ))} +
+ )} +
+ + {/* Auto */} + + + {/* Candidates, best first */} +
+ {filtered.length === 0 && ( +
+ {accounts.length === 0 + ? "No active mailboxes. Connect one under Emails." + : "No mailboxes match."} +
+ )} + {filtered.map((a) => ( + pick(a.id)} + /> + ))} +
+ + )} + , + document.body, )}
); } +// 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 ( ); }