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) && (