import { AgentSidebar, AgentToggleButton, } from "@agent-native/core/client/agent-chat"; import { agentNativePath } from "@agent-native/core/client/api-path"; import { appApiPath } from "@agent-native/core/client/api-path"; import { DevDatabaseLink } from "@agent-native/core/client/db-admin"; import { LanguagePicker, useT } from "@agent-native/core/client/i18n"; import { openCommandMenu } from "@agent-native/core/client/navigation"; import { InvitationBanner, OrgSwitcher } from "@agent-native/core/client/org"; import { FeedbackButton } from "@agent-native/core/client/ui"; import { SidebarFooterActions } from "@agent-native/toolkit/app-shell"; import { normalizeMailLabel } from "@shared/gmail-labels"; import type { Label } from "@shared/types"; import { IconMenu2, IconSettings, IconSearch, IconCheck, IconPlus, IconRefresh, IconPin, IconPinnedFilled, IconArchive, IconClock, IconFileText, IconInbox, IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand, IconMailForward, IconStar, IconTrash, } from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { Link, useNavigate, useLocation, useSearchParams } from "react-router"; import { toast } from "sonner"; import { ComposeModal } from "@/components/email/ComposeModal"; import { SnoozeModal } from "@/components/email/SnoozeModal"; import { GoogleConnectBanner } from "@/components/GoogleConnectBanner"; import { ThemeToggle } from "@/components/ThemeToggle"; import { Button } from "@/components/ui/button"; import { Popover, PopoverTrigger, PopoverContent, } from "@/components/ui/popover"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { AccountFilterContext } from "@/hooks/use-account-filter"; import { useComposeState } from "@/hooks/use-compose-state"; import { useQueuedDraftCount } from "@/hooks/use-draft-queue"; import { useLabels, useSettings, useUpdateSettings, useEmails, useReportSpam, useBlockSender, useMuteThread, markExternalEmailRefresh, } from "@/hooks/use-emails"; import { useGoogleAuthStatus, useGoogleAuthUrl, useDisconnectGoogle, } from "@/hooks/use-google-auth"; import { useKeyboardShortcuts, useSequenceShortcuts, } from "@/hooks/use-keyboard-shortcuts"; import { useIsMobile } from "@/hooks/use-mobile"; import { runUndo } from "@/hooks/use-undo"; import { qualifiesForInboxTab, pinnedTriageLabels, augmentSelfSentLabels, } from "@/lib/inbox-tabs"; import { isMcpEmbedSurface } from "@/lib/mcp-embed"; import { cn } from "@/lib/utils"; import { CommandPalette } from "./CommandPalette"; import { useHeaderTitle, useHeaderActions } from "./HeaderActions"; import { SearchBar } from "./SearchBar"; const BARE_ROUTES = new Set(["/email"]); type SnoozeTarget = { emailId: string; accountEmail?: string; }; const COMPOSE_FULLSCREEN_PARAM = "composeFullscreen"; const SIDEBAR_COLLAPSE_KEY = "mail-sidebar-collapsed"; function AccountAvatar({ email, photoUrl, imageClassName, fallbackClassName, }: { email: string; photoUrl?: string | null; imageClassName: string; fallbackClassName: string; }) { const [imageFailed, setImageFailed] = useState(false); const [stablePhotoUrl, setStablePhotoUrl] = useState(photoUrl ?? null); useEffect(() => { if (!photoUrl || photoUrl === stablePhotoUrl) return; setStablePhotoUrl(photoUrl); setImageFailed(false); }, [photoUrl, stablePhotoUrl]); const shouldLoadRemoteAvatar = !!stablePhotoUrl && !isMcpEmbedSurface() && !imageFailed; if (shouldLoadRemoteAvatar) { return ( setImageFailed(true)} /> ); } return
{email[0]?.toUpperCase()}
; } /** * Routes that render the slim "standard layout" chrome instead of the full * inbox chrome (tabs, search bar, account stack, compose pen, draft queue * badge button, theme toggle, etc.). These pages have their own internal * toolbars and only need a generic h-12 header with the page title + the * AgentToggleButton. */ function isStandardLayoutPath(pathname: string): boolean { return ( pathname === "/settings" || pathname === "/agent" || pathname === "/team" || pathname === "/draft-queue" || pathname.startsWith("/draft-queue/") || pathname === "/extensions" || pathname.startsWith("/extensions/") ); } /** Extract the trailing segment of a nested label name, e.g. "[Superhuman]/AI/Pitch" → "Pitch" */ function shortLabelName(name: string): string { const lastSlash = name.lastIndexOf("/"); if (lastSlash >= 0) return name.slice(lastSlash + 1).replace(/_/g, " "); return name; } function labelDepth(name: string): number { return Math.max(0, name.split("/").length - 1); } interface AppLayoutProps { children: React.ReactNode; } // System views that can be shown/hidden via settings const collapsibleViews = [ { id: "unread", labelKey: "mail.views.unread" }, { id: "starred", labelKey: "mail.views.starred" }, { id: "sent", labelKey: "mail.views.sent" }, { id: "drafts", labelKey: "mail.views.drafts" }, { id: "archive", labelKey: "mail.views.archive" }, { id: "trash", labelKey: "mail.views.trash" }, ]; export function AppLayout({ children }: AppLayoutProps) { const location = useLocation(); const isMobile = useIsMobile(); const t = useT(); if (BARE_ROUTES.has(location.pathname)) { return <>{children}; } const content = isStandardLayoutPath(location.pathname) ? ( {children} ) : ( {children} ); return ( {content} ); } function AppLayoutInner({ children }: AppLayoutProps) { const t = useT(); const isMobile = useIsMobile(); const compose = useComposeState(); const headerActions = useHeaderActions(); const [paletteOpen, setPaletteOpen] = useState(false); const [snoozeOpen, setSnoozeOpen] = useState(false); // When the user requests snooze from the list, we need to snooze the live // focused/selected rows — not whatever is currently in navigation state. // This override wins over `targetEmail` while the modal is open. const [snoozeOverride, setSnoozeOverride] = useState<{ targets: SnoozeTarget[]; } | null>(null); const [searchFocused, setSearchFocused] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const navigate = useNavigate(); const location = useLocation(); // Parse view and threadId from pathname since AppLayout is outside const pathSegments = location.pathname.split("/").filter(Boolean); const view = pathSegments[0] || "inbox"; const threadId = pathSegments[1] || undefined; const queuedDrafts = useQueuedDraftCount(); const [searchParams] = useSearchParams(); const activeSearchQuery = searchParams.get("q"); const activeLabel = searchParams.get("label"); const composeInitialExpanded = searchParams.get(COMPOSE_FULLSCREEN_PARAM) === "1"; const clearComposeInitialExpanded = useCallback(() => { const next = new URLSearchParams(searchParams); if (!next.has(COMPOSE_FULLSCREEN_PARAM)) return; next.delete(COMPOSE_FULLSCREEN_PARAM); const search = next.toString(); navigate( { pathname: location.pathname, search: search ? `?${search}` : "", }, { replace: true }, ); }, [location.pathname, navigate, searchParams]); // Remember which view (and label tab) the user was in before searching — // SearchBar always routes searches through /all?q=..., so on clear we'd // otherwise drop a user searching from Starred/Sent/Archive or from a // label-filtered tab back into plain Inbox. const preSearchViewRef = useRef<{ view: string; label: string | null }>({ view, label: activeLabel, }); useEffect(() => { if (!activeSearchQuery) { preSearchViewRef.current = { view, label: activeLabel }; } }, [view, activeLabel, activeSearchQuery]); const restorePreSearchPath = useCallback(() => { const { view: v, label: l } = preSearchViewRef.current; return `/${v}${l ? `?label=${encodeURIComponent(l)}` : ""}`; }, []); // When the search param is cleared externally (browser back/forward, // agent navigation), drop the searchFocused flag — otherwise the bar // stays mounted with an empty input and no focus, since nothing fires // onBlur after the input was already blurred by a prior Enter. const prevSearchQueryRef = useRef(activeSearchQuery); useEffect(() => { if (prevSearchQueryRef.current && !activeSearchQuery) { setSearchFocused(false); setSearchQuery(""); } prevSearchQueryRef.current = activeSearchQuery; }, [activeSearchQuery]); const { data: labels = [], isLoading: labelsLoading } = useLabels(); const { data: settings, isLoading: settingsLoading } = useSettings(); const updateSettings = useUpdateSettings(); const googleStatus = useGoogleAuthStatus(); const accounts = googleStatus.data?.accounts ?? []; const hasAccounts = accounts.length > 0; const googleStatusReady = !googleStatus.isLoading && !googleStatus.isError; const [accountPopoverOpen, setAccountPopoverOpen] = useState(false); // Account filter: which accounts' emails to show. Empty set = all accounts. // Persisted to localStorage so it survives page refreshes. const [activeAccounts, setActiveAccounts] = useState>(() => { if (typeof window === "undefined") return new Set(); try { const saved = localStorage.getItem("active-accounts"); if (saved) { const arr = JSON.parse(saved); if (Array.isArray(arr) && arr.length > 0) return new Set(arr); } } catch {} return new Set(); }); // Persist active accounts to localStorage useEffect(() => { if (activeAccounts.size === 0) { localStorage.removeItem("active-accounts"); } else { localStorage.setItem( "active-accounts", JSON.stringify([...activeAccounts]), ); } }, [activeAccounts]); const [tabSettingsOpen, setTabSettingsOpen] = useState(false); const [labelSearch, setLabelSearch] = useState(""); // Spin the refresh icon only when the user clicked the button — background // poll-driven `inboxIsFetching` should not animate the icon. Reset shortly // after click so the spin always feels like a deliberate action. const [isManuallyRefreshing, setIsManuallyRefreshing] = useState(false); const isGoogleConnected = (googleStatus.data?.accounts?.length ?? 0) > 0; const connectedEmails = useMemo( () => new Set(accounts.map((a) => a.email.toLowerCase())), [accounts], ); // Important is always on and always first when Google is connected const userPinnedLabels = settings?.pinnedLabels ?? []; const pinnedLabels = isGoogleConnected ? ["important", ...userPinnedLabels.filter((id) => id !== "important")] : userPinnedLabels; const hasNoteToSelf = pinnedLabels.includes("note-to-self"); const labelAliases = settings?.labelAliases ?? {}; const { data: rawInboxEmails = [], isLoading: emailsLoading, isFetching: inboxIsFetching, } = useEmails("inbox"); const { data: rawAllLocalEmails = [], isLoading: allLocalEmailsLoading } = useEmails("all", undefined, undefined, { enabled: googleStatusReady && !hasAccounts, }); const hasLocalMailboxData = !hasAccounts && (rawAllLocalEmails.length > 0 || rawInboxEmails.length > 0 || labels.some( (label) => (label.totalCount ?? 0) > 0 || (label.unreadCount ?? 0) > 0, )); // Augment emails: self-sent → "important" (or "note-to-self" if pinned) const inboxEmails = useMemo( () => augmentSelfSentLabels(rawInboxEmails, { isGoogleConnected, connectedEmails, hasNoteToSelf, }), [rawInboxEmails, isGoogleConnected, connectedEmails, hasNoteToSelf], ); const tabsLoading = labelsLoading || settingsLoading || emailsLoading || allLocalEmailsLoading; const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarPinned, setSidebarPinned] = useState(() => { if (typeof window === "undefined") return false; return localStorage.getItem("mail-sidebar-pinned") === "true"; }); const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { if (typeof window === "undefined") return false; return localStorage.getItem(SIDEBAR_COLLAPSE_KEY) === "true"; }); useEffect(() => { if (sidebarPinned) localStorage.setItem("mail-sidebar-pinned", "true"); else localStorage.removeItem("mail-sidebar-pinned"); }, [sidebarPinned]); useEffect(() => { if (sidebarCollapsed) { localStorage.setItem(SIDEBAR_COLLAPSE_KEY, "true"); } else { localStorage.removeItem(SIDEBAR_COLLAPSE_KEY); } }, [sidebarCollapsed]); const showSidebar = sidebarOpen || (sidebarPinned && !isMobile); const showCollapsedSidebar = sidebarPinned && sidebarCollapsed && !isMobile; const closeSidebar = useCallback(() => { if (!sidebarPinned || isMobile) setSidebarOpen(false); }, [sidebarPinned, isMobile]); const collapseButton = sidebarPinned && !isMobile ? ( {showCollapsedSidebar ? t("sidebar.expandSidebar") : t("sidebar.collapseSidebar")} ) : null; const searchButton = ( {t("mail.search.label")} ); const translateButton = ( ); const feedbackButton = ( ); // Drag-to-reorder tabs const [dragPinnedId, setDragPinnedId] = useState(null); const [dropIndicator, setDropIndicator] = useState<{ tabIndex: number; side: "left" | "right"; } | null>(null); // Compute local thread counts for virtual labels and local/demo mail. Gmail // system/user labels use server-provided counts when available. const labelThreadCounts = useMemo(() => { const unread: Record = {}; const total: Record = {}; // Filter emails by active accounts before counting const filtered = activeAccounts.size > 0 ? inboxEmails.filter( (e) => e.accountEmail && activeAccounts.has(e.accountEmail), ) : inboxEmails; // Find the latest message + unread state per thread. const threadState = new Map< string, { latest: (typeof filtered)[0]; hasUnread: boolean } >(); for (const e of filtered) { const key = e.threadId || e.id; const existing = threadState.get(key); if (!existing) { threadState.set(key, { latest: e, hasUnread: !e.isRead }); } else { existing.hasUnread ||= !e.isRead; if (new Date(e.date) > new Date(existing.latest.date)) { existing.latest = e; } } } const threadRows = [...threadState.values()]; const triageLabels = pinnedTriageLabels(pinnedLabels); // "Other" = the inbox remainder. Shared with the rendered list // (InboxPage) via qualifiesForInboxTab so a tab's badge can never // disagree with the emails it actually shows. const inboxRows = threadRows.filter(({ latest }) => qualifiesForInboxTab(latest.labelIds, null, triageLabels), ); total["__inboxTotal"] = threadRows.length; unread["__inboxTotal"] = threadRows.filter( ({ hasUnread }) => hasUnread, ).length; total["inbox"] = inboxRows.length; unread["inbox"] = inboxRows.filter(({ hasUnread }) => hasUnread).length; // Count threads per pinned label using the exact same membership rule as // the rendered list: latest message has the label; "important" is // exclusive of any other pinned tab. for (let i = 0; i < pinnedLabels.length; i++) { const full = pinnedLabels[i]; const rows = threadRows.filter(({ latest }) => qualifiesForInboxTab(latest.labelIds, full, triageLabels), ); total[full] = rows.length; unread[full] = rows.filter(({ hasUnread }) => hasUnread).length; // Also index by the canonical label.id (which uses spaces, not // underscores) so count lookups find it for nested labels. const canonical = labels.find( (l) => l.id === full || l.id === normalizeMailLabel(full) || l.name.toLowerCase() === full.toLowerCase(), ); if (canonical) { total[canonical.id] = total[full]; unread[canonical.id] = unread[full]; } } return { total, unread }; }, [inboxEmails, pinnedLabels, activeAccounts, labels]); // Tabs to show in the bar: pinned triage filters first, then the inbox // remainder as "Other". Without pinned filters, the inbox is just "Inbox". const hasPinnedFilters = pinnedLabels.some( (id) => !collapsibleViews.some((v) => v.id === id), ); const visibleTabs = useMemo(() => { const tabs: { id: string; pinnedId?: string; label: string; fullLabel?: string; href: string; isActive: boolean; color?: string; type: "system" | "label"; }[] = []; if (!hasPinnedFilters) { tabs.push({ id: "inbox", label: t("mail.views.inbox"), href: "/inbox", isActive: view === "inbox" && !activeLabel, type: "system", }); } const seenLabels = new Set(["inbox"]); for (const id of pinnedLabels) { // Check if it's a system view const sysView = collapsibleViews.find((v) => v.id === id); if (sysView) { if (seenLabels.has(sysView.id)) continue; seenLabels.add(sysView.id); tabs.push({ id: sysView.id, pinnedId: id, label: t(sysView.labelKey), href: `/${sysView.id}`, isActive: view === sysView.id, type: "system", }); continue; } // Check if it's a user label (handle old nested-path IDs like "[superhuman]/ai/pitch") const normalizedId = id.includes("/") ? id .slice(id.lastIndexOf("/") + 1) .replace(/_/g, " ") .toLowerCase() : id.toLowerCase(); const lbl = labels.find( (l) => l.id === normalizedId || l.id === id || l.name.toLowerCase() === id.toLowerCase(), ); if (lbl) { const rawName = shortLabelName(lbl.name); const aliasedName = labelAliases[lbl.id] || labelAliases[id] || rawName; const displayKey = aliasedName.toLowerCase(); if (seenLabels.has(displayKey)) continue; seenLabels.add(displayKey); tabs.push({ id: lbl.id, pinnedId: id, label: aliasedName, fullLabel: lbl.name, href: `/inbox?label=${encodeURIComponent(lbl.id)}`, isActive: activeLabel === lbl.id, color: lbl.color, type: "label", }); } } if (hasPinnedFilters) { tabs.push({ id: "inbox", label: t("mail.views.other"), href: "/inbox", isActive: view === "inbox" && !activeLabel, type: "system", }); } return tabs; }, [ labels, pinnedLabels, labelAliases, view, activeLabel, hasPinnedFilters, t, ]); const topBarTabs = useMemo(() => { const tabs = [...visibleTabs]; if (activeLabel && !tabs.some((tab) => tab.id === activeLabel)) { const active = labels.find((label) => label.id === activeLabel); if (active) { const aliasedName = labelAliases[active.id] || shortLabelName(active.name); tabs.push({ id: active.id, label: aliasedName, fullLabel: active.name, href: `/inbox?label=${encodeURIComponent(active.id)}`, isActive: true, color: active.color, type: "label", }); } } return tabs; }, [activeLabel, labels, labelAliases, visibleTabs]); // System views NOT pinned (go in the "more" dropdown) const hiddenViews = useMemo( () => collapsibleViews.filter((v) => !pinnedLabels.includes(v.id)), [pinnedLabels], ); // Is current view one of the hidden ones? If so force-show it const currentInHidden = hiddenViews.some((v) => v.id === view); // User labels available for pinning const userLabels = useMemo(() => { const filtered = labels.filter( (l) => !["inbox", ...collapsibleViews.map((v) => v.id)].includes(l.id), ); // Deduplicate by display name (different paths can have the same short name) const seen = new Set(); return filtered.filter((l) => { const key = l.name.toLowerCase(); if (seen.has(key)) return false; seen.add(key); return true; }); }, [labels]); const handleCompose = useCallback(() => { compose.open({ to: "", cc: "", bcc: "", subject: "", body: "", mode: "compose", }); }, [compose]); // Spam / block / mute actions (need current email context) const isMailboxView = [ "inbox", "starred", "sent", "drafts", "archive", "trash", "snoozed", "scheduled", "all", ].includes(view); const { data: currentViewEmails = [] } = useEmails( isMailboxView ? view : "inbox", undefined, undefined, { enabled: isMailboxView }, ); const reportSpam = useReportSpam(); const blockSender = useBlockSender(); const muteThread = useMuteThread(); // Find the target email: from open thread, or the focused row in the list via navigation state const [focusedListId, setFocusedListId] = useState(null); // Poll navigation.json for the focused email ID (synced by InboxPage) useEffect(() => { if (threadId) return; // thread view has its own context const fetchNav = async () => { try { const res = await fetch( agentNativePath("/_agent-native/application-state/navigation"), ); if (res.ok) { const nav = await res.json(); if (nav?.focusedEmailId) setFocusedListId(nav.focusedEmailId); } } catch {} }; fetchNav(); // Re-check when palette opens if (paletteOpen) fetchNav(); }, [threadId, paletteOpen]); const targetEmail = useMemo(() => { if (threadId) { return currentViewEmails.find((e) => (e.threadId || e.id) === threadId); } if (focusedListId) { const focused = currentViewEmails.find((e) => e.id === focusedListId); if (focused) return focused; } // Fall back to the first email in the list — if it's auto-focused in the // UI, or the synced id points at a row that has since disappeared, shortcuts // should still work. return currentViewEmails[0] ?? undefined; }, [threadId, focusedListId, currentViewEmails]); const dismissEmail = useCallback((emailId: string) => { window.dispatchEvent( new CustomEvent("email:snoozed", { detail: { emailId } }), ); }, []); const handleSpam = useCallback(() => { if (!targetEmail) { toast.error(t("mail.toasts.noEmailSelected")); return; } dismissEmail(targetEmail.id); reportSpam.mutate({ id: targetEmail.id, threadId: targetEmail.threadId || targetEmail.id, }); toast(t("mail.toasts.reportedSpam")); }, [targetEmail, reportSpam, dismissEmail, t]); const handleBlockSender = useCallback(() => { if (!targetEmail) { toast.error(t("mail.toasts.noEmailSelected")); return; } dismissEmail(targetEmail.id); blockSender.mutate({ id: targetEmail.id, threadId: targetEmail.threadId || targetEmail.id, senderEmail: targetEmail.from.email, }); toast( t("mail.toasts.reportedSpamBlocked", { email: targetEmail.from.email }), ); }, [targetEmail, blockSender, dismissEmail, t]); const handleMuteThread = useCallback(() => { const tid = threadId || (targetEmail ? targetEmail.threadId || targetEmail.id : undefined); if (!tid) { toast.error(t("mail.toasts.noThreadSelected")); return; } if (targetEmail) dismissEmail(targetEmail.id); muteThread.mutate(tid); toast(t("mail.toasts.threadMuted")); }, [threadId, targetEmail, muteThread, dismissEmail, t]); const togglePinned = useCallback( (id: string) => { const current = settings?.pinnedLabels ?? []; const next = current.includes(id) ? current.filter((x) => x !== id) : [...current, id]; updateSettings.mutate({ pinnedLabels: next }); }, [settings?.pinnedLabels, updateSettings], ); // Drag-to-reorder tab handlers const handleTabDragStart = useCallback( (e: React.DragEvent, pinnedId: string) => { setDragPinnedId(pinnedId); e.dataTransfer.effectAllowed = "move"; }, [], ); const handleTabDragOver = useCallback( (e: React.DragEvent, tabIndex: number) => { if (!dragPinnedId) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const midX = rect.left + rect.width / 2; setDropIndicator({ tabIndex, side: e.clientX < midX ? "left" : "right", }); }, [dragPinnedId], ); const handleTabDrop = useCallback(() => { if (!dragPinnedId || !dropIndicator) return; const current = settings?.pinnedLabels ?? []; if (!current.includes(dragPinnedId)) return; const targetTab = visibleTabs[dropIndicator.tabIndex]; if (!targetTab) return; const without = current.filter((id) => id !== dragPinnedId); let insertAt: number; if (targetTab.pinnedId === "important") { insertAt = 0; } else if (!targetTab.pinnedId) { insertAt = without.length; } else { const targetIdx = without.indexOf(targetTab.pinnedId); if (targetIdx < 0) { insertAt = without.length; } else { insertAt = dropIndicator.side === "left" ? targetIdx : targetIdx + 1; } } without.splice(insertAt, 0, dragPinnedId); updateSettings.mutate({ pinnedLabels: without }); setDragPinnedId(null); setDropIndicator(null); }, [ dragPinnedId, dropIndicator, settings?.pinnedLabels, visibleTabs, updateSettings, ]); const handleTabDragEnd = useCallback(() => { setDragPinnedId(null); setDropIndicator(null); }, []); // Global keyboard shortcuts const cycleTab = useCallback( (reverse?: boolean) => { if (visibleTabs.length < 2) return; const activeIdx = visibleTabs.findIndex((t) => t.isActive); const delta = reverse ? -1 : 1; const nextIdx = (activeIdx === -1 ? 0 : activeIdx + delta + visibleTabs.length) % visibleTabs.length; navigate(visibleTabs[nextIdx].href); }, [visibleTabs, navigate], ); const handleSnooze = useCallback(() => { const listSnoozeEvent = new CustomEvent("email:shortcut-snooze", { cancelable: true, }); window.dispatchEvent(listSnoozeEvent); if (listSnoozeEvent.defaultPrevented) return; if (!targetEmail) { toast.error(t("mail.toasts.noEmailSelected")); return; } setSnoozeOverride(null); setSnoozeOpen(true); }, [targetEmail]); // List snooze requests carry the current focused/selected rows. Swipe // requests still carry one target; keyboard and command-palette requests may // carry many. useEffect(() => { const handler = (e: Event) => { const detail = ( e as CustomEvent<{ emailId?: string; accountEmail?: string; targets?: SnoozeTarget[]; }> ).detail; const targets = detail?.targets?.filter((target) => target.emailId) ?? (detail?.emailId ? [{ emailId: detail.emailId, accountEmail: detail.accountEmail }] : []); if (targets.length === 0) return; setSnoozeOverride({ targets, }); setSnoozeOpen(true); }; window.addEventListener("email:request-snooze", handler); return () => window.removeEventListener("email:request-snooze", handler); }, []); useKeyboardShortcuts([ { key: "k", meta: true, handler: () => setPaletteOpen(true), skipInInput: false, }, { key: "/", handler: () => { document.getElementById("mail-search")?.focus(); }, }, { key: "c", handler: handleCompose }, { key: "h", handler: handleSnooze }, { key: "!", shift: true, handler: handleSpam }, { key: "z", handler: runUndo }, { key: "Tab", handler: () => cycleTab(false), }, { key: "Tab", shift: true, handler: () => cycleTab(true), }, { key: "Escape", handler: () => { setSearchQuery(""); setSearchFocused(false); (document.getElementById("mail-search") as HTMLInputElement)?.blur(); if (activeSearchQuery) { navigate(restorePreSearchPath()); } }, }, ]); useEffect(() => { const handler = () => setPaletteOpen(true); window.addEventListener("agent-native:open-command-menu", handler); return () => window.removeEventListener("agent-native:open-command-menu", handler); }, []); // Sequence shortcuts (g + key = go to view) const qc = useQueryClient(); useSequenceShortcuts([ { keys: ["g", "i"], handler: () => { navigate("/inbox"); qc.invalidateQueries({ queryKey: ["emails"] }); qc.invalidateQueries({ queryKey: ["labels"] }); }, }, { keys: ["g", "s"], handler: () => navigate("/starred") }, { keys: ["g", "t"], handler: () => navigate("/sent") }, { keys: ["g", "d"], handler: () => navigate("/drafts") }, { keys: ["g", "a"], handler: () => navigate("/archive") }, { keys: ["g", "e"], handler: () => navigate("/archive") }, { keys: ["g", "#"], handler: () => navigate("/trash") }, ]); const resolveLabelForCount = (id: string) => { const normalizedId = id.includes("/") ? id .slice(id.lastIndexOf("/") + 1) .replace(/_/g, " ") .toLowerCase() : id.toLowerCase(); return labels.find( (label) => label.id === id || label.id === normalizedId || label.name.toLowerCase() === id.toLowerCase(), ); }; const useServerLabelCounts = activeAccounts.size === 0; type CountKind = "unread" | "total"; const countFieldForKind = (kind: CountKind) => kind === "total" ? "totalCount" : "unreadCount"; const localCountsForKind = (kind: CountKind) => kind === "total" ? labelThreadCounts.total : labelThreadCounts.unread; // Take the larger of the server-reported count and the count we compute // locally from loaded inbox emails. Either side can be stale (Gmail label // totals can lag; loaded emails may be a partial window). const getInboxCount = (kind: CountKind) => { const inboxLabel = resolveLabelForCount("inbox"); const countField = countFieldForKind(kind); const localCounts = localCountsForKind(kind); const serverCount = useServerLabelCounts ? (inboxLabel?.[countField] ?? 0) : 0; const localCount = localCounts["inbox"] ?? 0; return Math.max(serverCount, localCount); }; const isExclusivePinnedTab = (viewId: string) => { if (!hasPinnedFilters) return false; // Pinned label rows (the ones that contribute to the "Other" remainder) // are exclusive: each inbox thread is counted in exactly one tab. Gmail's // server label counts don't have that exclusivity (e.g. server "important" // returns *all* important threads, regardless of whether they're also // categorized elsewhere), so we can't mix the two — use local only. return pinnedLabels.some((id) => { if (collapsibleViews.some((view) => view.id === id)) return false; const label = resolveLabelForCount(id); return (label?.id ?? id) === viewId || id === viewId; }); }; const getOtherCount = (kind: CountKind) => { if (!hasPinnedFilters) return getInboxCount(kind); const localCounts = localCountsForKind(kind); // Don't subtract pinned-label server counts from inbox server count: // Gmail's label totals include archived/sent/trash threads outside the // inbox, so the subtraction can drop "Other" to zero or undercount. The // local count (computed from loaded inbox emails, filtered by pinned-tab // membership) is the authoritative "Other" number. return localCounts["inbox"] ?? 0; }; const getTabCount = (viewId: string, kind: CountKind) => { if (viewId === "inbox") return getOtherCount(kind); const label = resolveLabelForCount(viewId); const countField = countFieldForKind(kind); const localCounts = localCountsForKind(kind); const localCount = localCounts[viewId] ?? (label ? (localCounts[label.id] ?? 0) : 0); // Exclusive pinned tabs (when hasPinnedFilters is on, a thread belongs to // exactly one tab) can't fall back to Gmail's non-exclusive server count // — that would over-report the badge relative to what the tab renders. if (isExclusivePinnedTab(viewId)) return localCount; const serverCount = useServerLabelCounts && viewId !== "note-to-self" ? (label?.[countField] ?? 0) : 0; return Math.max(serverCount, localCount); }; const getTopBarCount = (viewId: string) => getTabCount(viewId, "total"); const getUnreadCount = (viewId: string) => getTabCount(viewId, "unread"); const inboxSidebarUnreadCount = labelThreadCounts.unread["__inboxTotal"] ?? labelThreadCounts.unread["inbox"] ?? 0; const railNavItems = [ { id: "inbox", label: t("mail.views.inbox"), href: "/inbox", icon: IconInbox, count: inboxSidebarUnreadCount, }, { id: "unread", label: t("mail.views.unread"), href: "/unread", icon: IconMailForward, }, { id: "starred", label: t("mail.views.starred"), href: "/starred", icon: IconStar, }, { id: "snoozed", label: t("mail.views.snoozed"), href: "/snoozed", icon: IconClock, }, { id: "sent", label: t("mail.views.sent"), href: "/sent", icon: IconMailForward, }, { id: "draft-queue", label: t("mail.views.draftQueue"), href: "/draft-queue", icon: IconFileText, count: queuedDrafts.count, }, { id: "archive", label: t("mail.views.archive"), href: "/archive", icon: IconArchive, }, { id: "trash", label: t("mail.views.trash"), href: "/trash", icon: IconTrash, }, ]; const accountFilterValue = useMemo( () => ({ activeAccounts, allAccounts: accounts }), [activeAccounts, accounts], ); return (
{/* Top nav bar */}
{/* Hamburger menu */} {t("mail.toolbar.menu")} {/* Primary tabs stay mounted during search so navigation does not jump. */} <> {tabsLoading ? ( ) : ( )} {/* Tab settings cog */}
{ setTabSettingsOpen(open); if (!open) setLabelSearch(""); }} > {t("mail.toolbar.configureTabs")} { const next = { ...labelAliases }; if (alias) next[id] = alias; else delete next[id]; updateSettings.mutate({ labelAliases: next }); }} />
{headerActions && (
{headerActions}
)} {/* Search — stays visible while a search is active so the user always knows what they searched */} {searchFocused || activeSearchQuery ? ( { setSearchFocused(false); setSearchQuery(""); if (activeSearchQuery) { navigate(restorePreSearchPath()); } }} /> ) : ( {t("mail.search.label")} )} {/* Hidden input for keyboard shortcut target */} {!searchFocused && !activeSearchQuery && ( setSearchFocused(true)} /> )} {/* Manual refresh — auto-poll backs off on error, but users still want a button to force a fresh fetch on demand. The spin animation only fires on user click, never on background poll-driven fetches. */} {t("mail.toolbar.refreshInbox")} {/* Compose — prominent outline button */} {t("mail.toolbar.composeShortcut")} {/* Account avatars — overlapping stack */} {googleStatus.isLoading && (
)} {googleStatusReady && hasAccounts && ( {t("mail.toolbar.accounts")} { setActiveAccounts((prev) => { const next = new Set(prev); if (next.size === 0) { // Switching from "all" → deselect this one (keep others) for (const a of accounts) { if (a.email !== email) next.add(a.email); } } else if (next.has(email)) { next.delete(email); // If nothing left, reset to "all" if (next.size === 0) return new Set(); } else { next.add(email); // If all are now checked, reset to "all" (empty set) if (next.size === accounts.length) return new Set(); } return next; }); }} onRemoveAccount={(email) => { setActiveAccounts((prev) => { const next = new Set(prev); next.delete(email); return next; }); }} /> )}
{/* Sidebar overlay / pinned rail */} {showSidebar && ( <> {(!sidebarPinned || isMobile) && (
setSidebarOpen(false)} /> )}
{showCollapsedSidebar ? null : ( <> {t("mail.appName")}
{sidebarPinned ? t("mail.toolbar.unpinSidebar") : t("mail.toolbar.pinSidebar")}
)}
{showCollapsedSidebar ? ( ) : ( <>
{/* Accounts */} {hasAccounts && (
{accounts.map((account) => { const isActive = activeAccounts.size === 0 || activeAccounts.has(account.email); return ( ); })}
)}
{[ { id: "inbox", label: t("mail.views.inbox"), href: "/inbox", }, { id: "unread", label: t("mail.views.unread"), href: "/unread", }, { id: "starred", label: t("mail.views.starred"), href: "/starred", }, { id: "snoozed", label: t("mail.views.snoozed"), href: "/snoozed", }, { id: "sent", label: t("mail.views.sent"), href: "/sent", }, { id: "draft-queue", label: t("mail.views.draftQueue"), href: "/draft-queue", }, { id: "scheduled", label: t("mail.views.scheduled"), href: "/scheduled", }, { id: "drafts", label: t("mail.views.drafts"), href: "/drafts", }, { id: "archive", label: t("mail.views.archive"), href: "/archive", }, { id: "trash", label: t("mail.views.trash"), href: "/trash", }, ].map((item) => ( {item.label} {item.id === "draft-queue" && queuedDrafts.count > 0 && ( {queuedDrafts.count} )} {item.id === "inbox" && inboxSidebarUnreadCount > 0 && ( {inboxSidebarUnreadCount} )} ))}
{/* Pinned labels */} {pinnedLabels.filter( (l) => !collapsibleViews.some((v) => v.id === l), ).length > 0 && ( <>

{t("mail.views.labels")}

{visibleTabs .filter( (t) => t.id !== "inbox" && t.type === "label", ) .map((tab) => { const count = getUnreadCount(tab.id); const depth = labelDepth( tab.fullLabel ?? tab.label, ); return ( {tab.color && ( )} {shortLabelName( tab.fullLabel ?? tab.label, )} {count > 0 && ( {count} )} ); })}
)}
{t("mail.toolbar.settings")}
)}
)}
{/* Show full-page takeover when no accounts connected (except on settings page) */} {!googleStatus.isLoading && !googleStatus.isError && !hasAccounts && !hasLocalMailboxData && view !== "settings" && view !== "draft-queue" ? ( ) : (
{children}
)}
{(() => { // Filter out inline drafts (rendered in thread view, not the popout composer) const popoutDrafts = compose.drafts.filter((d) => !d.inline); if (popoutDrafts.length === 0) return null; const popoutActiveId = compose.activeId && popoutDrafts.some((d) => d.id === compose.activeId) ? compose.activeId : popoutDrafts[popoutDrafts.length - 1].id; const popoutActiveDraft = popoutDrafts.find((d) => d.id === popoutActiveId) ?? null; return ( { const draft = popoutDrafts.find((d) => d.id === id); const hasContent = !!( draft?.to?.trim() || draft?.subject?.trim() || draft?.body?.trim() ); const snapshot = draft ? { ...draft } : null; compose.close(id); if (hasContent && snapshot) { toast("Draft saved.", { action: { label: "REOPEN", onClick: () => { const { id: _id, ...reopenData } = snapshot; compose.open(reopenData); }, }, cancel: { label: "DELETE DRAFT", onClick: () => { if (snapshot.savedDraftId) { fetch( appApiPath(`/api/emails/${snapshot.savedDraftId}`), { method: "DELETE", }, ); } }, }, }); } }} onCloseAll={() => { const draftsWithContent = popoutDrafts.filter( (d) => !!(d.to?.trim() || d.subject?.trim() || d.body?.trim()), ); const snapshots = draftsWithContent.map((d) => ({ ...d })); const ids = popoutDrafts.map((d) => d.id); ids.forEach((id) => compose.close(id)); if (snapshots.length > 0) { toast(`${snapshots.length} draft(s) saved.`, { action: { label: "REOPEN", onClick: () => { for (const snap of snapshots) { const { id: _id, ...reopenData } = snap; compose.open(reopenData); } }, }, cancel: { label: "DELETE DRAFTS", onClick: () => { for (const snap of snapshots) { if (snap.savedDraftId) { fetch( appApiPath(`/api/emails/${snap.savedDraftId}`), { method: "DELETE", }, ); } } }, }, }); } }} onDiscard={compose.discard} onNewDraft={handleCompose} onFlush={compose.flush} onReopen={compose.open} onInitialExpandedConsumed={clearComposeInitialExpanded} /> ); })()} { setSnoozeOpen(false); setSnoozeOverride(null); }} onSnoozed={() => { setSnoozeOpen(false); setSnoozeOverride(null); }} /> ); } // ─── Standard Layout (settings, team, tools, draft-queue) ──────────────────── /** * Slim chrome used on secondary pages. Renders a clean h-12 header (title + * AgentToggleButton) instead of the inbox-specific top bar (tabs, search, * account stack, compose pen, etc.). * * Pages can hoist a custom title or right-side actions via * `useSetPageTitle` / `useSetHeaderActions` from `./HeaderActions`. */ function StandardLayout({ children }: AppLayoutProps) { const t = useT(); const location = useLocation(); const [sidebarOpen, setSidebarOpen] = useState(false); const headerTitle = useHeaderTitle(); const headerActions = useHeaderActions(); const queuedDrafts = useQueuedDraftCount(); const view = location.pathname.split("/").filter(Boolean)[0] || ""; const collapseButton = ( {t("sidebar.collapseSidebar")} ); const searchButton = ( {t("mail.search.label")} ); const translateButton = ( ); const feedbackButton = ( ); // Extensions (`/extensions` list and `/extensions/:id` viewer) render their own h-12 // toolbar inside the shared // ExtensionViewer / ExtensionsListPage components. Skip our header to avoid stacking. const pageOwnsToolbar = location.pathname === "/extensions" || location.pathname.startsWith("/extensions/"); const fallbackTitle = (() => { if (location.pathname === "/settings") return t("settings.title"); if (location.pathname === "/agent") return t("settings.agentTitle"); if (location.pathname === "/team") return t("mail.pages.team"); if (location.pathname.startsWith("/draft-queue")) return t("mail.views.draftQueue"); if (location.pathname.startsWith("/extensions")) return t("mail.views.extensions"); return t("mail.appName"); })(); return (
{!pageOwnsToolbar && (
{t("mail.toolbar.menu")}
{headerTitle ?? (

{fallbackTitle}

)}
{headerActions}
)} <> ); } // ─── Tab Settings Popover ──────────────────────────────────────────────────── function CheckboxRow({ checked, label, color, onToggle, }: { checked: boolean; label: string; color?: string; onToggle: () => void; }) { return ( ); } function TabSettingsPopover({ systemViews, userLabels, pinnedLabels, labelAliases, search, onSearchChange, onToggle, onRename, }: { systemViews: { id: string; labelKey: string }[]; userLabels: Label[]; pinnedLabels: string[]; labelAliases: Record; search: string; onSearchChange: (v: string) => void; onToggle: (id: string) => void; onRename: (id: string, alias: string) => void; }) { const t = useT(); const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(""); const q = search.toLowerCase(); const filteredViews = search ? systemViews.filter((v) => t(v.labelKey).toLowerCase().includes(q)) : systemViews; // Split labels into Gmail categories and regular user labels // "important" is excluded — it's always on and not toggleable const gmailCategoryIds = new Set([ "note-to-self", "promotions", "social", "updates", "forums", "personal", ]); // Ensure all known Gmail categories always appear (some are virtual, not from API) const knownCategories: Label[] = [ { id: "note-to-self", name: t("mail.views.noteToSelf"), type: "system", unreadCount: 0, }, { id: "promotions", name: "Promotions", type: "system", unreadCount: 0 }, { id: "social", name: "Social", type: "system", unreadCount: 0 }, { id: "updates", name: "Updates", type: "system", unreadCount: 0 }, { id: "forums", name: "Forums", type: "system", unreadCount: 0 }, ]; const apiCategories = userLabels.filter((l) => gmailCategoryIds.has(l.id)); const apiCategoryIds = new Set(apiCategories.map((l) => l.id)); const mergedCategories = [ ...apiCategories, ...knownCategories.filter((c) => !apiCategoryIds.has(c.id)), ]; const allLabels = search ? userLabels.filter((l) => l.name.toLowerCase().includes(q)) : userLabels; const filteredCategories = search ? mergedCategories.filter((l) => l.name.toLowerCase().includes(q)) : mergedCategories; const filteredLabels = allLabels.filter((l) => !gmailCategoryIds.has(l.id)); // Sort: pinned first, then alphabetical const sortedLabels = [...filteredLabels].sort((a, b) => { const ap = pinnedLabels.includes(a.id) ? 0 : 1; const bp = pinnedLabels.includes(b.id) ? 0 : 1; return ap - bp || a.name.localeCompare(b.name); }); const showViews = filteredViews.length > 0; const showCategories = filteredCategories.length > 0; const showLabels = sortedLabels.length > 0; const noResults = !showViews && !showCategories && !showLabels && search; return ( <> {/* Search */}
onSearchChange(e.target.value)} placeholder={t("mail.search.placeholder")} className="w-full bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground/40 outline-none px-1 py-0.5" />
{noResults && (

{t("mail.search.noMatches")}

)} {/* System views */} {showViews && (

{t("mail.tabSettings.views")}

{filteredViews.map((v) => ( onToggle(v.id)} /> ))}
)} {/* Gmail categories */} {showCategories && (

{t("mail.tabSettings.categories")}

{filteredCategories.map((cat) => ( onToggle(cat.id)} /> ))}
)} {/* User labels */} {showLabels && (

{t("mail.views.labels")}

{sortedLabels.map((label) => { const isPinned = pinnedLabels.includes(label.id); const isEditing = editingId === label.id; const alias = labelAliases[label.id]; const displayName = alias || shortLabelName(label.name); return (
{isEditing ? (
setEditValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { onRename(label.id, editValue.trim()); setEditingId(null); } if (e.key === "Escape") setEditingId(null); }} onBlur={() => { onRename(label.id, editValue.trim()); setEditingId(null); }} className="flex-1 bg-transparent text-[13px] text-foreground outline-none border-b border-primary/50 px-0 py-0.5" placeholder={shortLabelName(label.name)} />
) : ( onToggle(label.id)} /> )}
{isPinned && !isEditing && ( {t("mail.tabSettings.renameTab")} )}
); })}
)}

{t("mail.tabSettings.help")}

); } // ─── Account Popover ───────────────────────────────────────────────────────── function AccountPopover({ accounts, activeAccounts, onToggleAccount, onRemoveAccount, }: { accounts: Array<{ email: string; displayName?: string; photoUrl?: string }>; activeAccounts: Set; onToggleAccount: (email: string) => void; onRemoveAccount: (email: string) => void; }) { const t = useT(); const [wantAuthUrl, setWantAuthUrl] = useState(false); const authUrl = useGoogleAuthUrl(wantAuthUrl); const disconnectGoogle = useDisconnectGoogle(); useEffect(() => { if (!wantAuthUrl || !authUrl.data?.url) return; setWantAuthUrl(false); window.open(authUrl.data.url, "_blank"); const interval = setInterval(async () => { const res = await fetch( agentNativePath("/_agent-native/google/status"), ).catch(() => null); if (res?.ok) { const data = await res.json(); if (data.accounts?.length > accounts.length) { clearInterval(interval); window.location.reload(); } } }, 2000); return () => clearInterval(interval); }, [wantAuthUrl, authUrl.data, accounts.length]); // Empty activeAccounts means "all selected" const allSelected = activeAccounts.size === 0; return ( <>

{t("mail.toolbar.accounts")}

{accounts.map((account) => { const isChecked = allSelected || activeAccounts.has(account.email); return (
{/* Checkbox */} {account.displayName || account.email} {account.displayName && account.displayName !== account.email && ( {account.email} )}
); })}
); }