import { useT } from "@agent-native/core/client/i18n";
import {
isInboxScopedAppLabel,
mailLabelsInclude,
mailLabelsIncludeAny,
} from "@shared/gmail-labels";
import type { EmailMessage } from "@shared/types";
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { useParams, useNavigate, useSearchParams } from "react-router";
import { EmailList, InboxZero } from "@/components/email/EmailList";
import { EmailThread } from "@/components/email/EmailThread";
import { IntegrationsSidebar } from "@/components/email/IntegrationsSidebar";
import { GoogleConnectBanner } from "@/components/GoogleConnectBanner";
import { useAccountFilter } from "@/hooks/use-account-filter";
import {
FOCUS_COMPOSE_DRAFT_EVENT,
useComposeState,
} from "@/hooks/use-compose-state";
import { useEmails, useMarkRead, useSettings } from "@/hooks/use-emails";
import { useGoogleAuthStatus } from "@/hooks/use-google-auth";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useIsMobile } from "@/hooks/use-mobile";
import { useNavigationState } from "@/hooks/use-navigation-state";
import {
resolvePinnedLabels,
pinnedTriageLabels,
augmentSelfSentLabels,
filterInboxTabEmails,
} from "@/lib/inbox-tabs";
import { groupIntoThreads, type ThreadSummary } from "@/lib/threads";
import { cn } from "@/lib/utils";
function ContactPanel({
emailId,
contactEmail,
emails,
}: {
emailId: string | undefined;
contactEmail?: string;
emails: EmailMessage[];
}) {
const t = useT();
// Look up from already-cached list data instead of making a separate API call
const email = useMemo(
() =>
emails.find((e) => e.id === emailId || (e.threadId || e.id) === emailId),
[emails, emailId],
);
// Always use inbox emails for "recent from contact" — shares React Query cache,
// no extra fetch. The `emails` prop may be a different view (sent, starred, etc.)
const { data: inboxEmails = [] } = useEmails("inbox");
const displayEmail = contactEmail || email?.from.email;
const displayName = contactEmail
? contactEmail
: email?.from.name || email?.from.email;
if (!displayEmail) {
return (
{t("mail.contacts.noContactSelected")}
);
}
const recentFromContact = inboxEmails
.filter((e) => e.from.email === displayEmail && e.id !== emailId)
.slice(0, 4)
.map((e) => ({ id: e.id, subject: e.subject }));
return (
);
}
function formatSidebarSender(thread: ThreadSummary): string {
if (thread.messageCount <= 1) {
return thread.latestMessage.from.name || thread.latestMessage.from.email;
}
if (thread.participants.length <= 1) return thread.participants[0] || "";
const firstNames = thread.participants.map(
(participant) => participant.split(" ")[0],
);
if (firstNames.length <= 2) return firstNames.join(", ");
return `${firstNames[0]} .. ${firstNames[firstNames.length - 1]}`;
}
function ThreadListSidebar({
emails,
activeThreadId,
view,
routeSearchSuffix,
selectedIds,
setSelectedIds,
onNavigateThread,
}: {
emails: EmailMessage[];
activeThreadId: string;
view: string;
routeSearchSuffix: string;
selectedIds: Set;
setSelectedIds: React.Dispatch>>;
onNavigateThread: (threadId: string) => void;
}) {
const navigate = useNavigate();
const markRead = useMarkRead();
const threads = useMemo(() => groupIntoThreads(emails), [emails]);
const selectAllThreads = useCallback(() => {
if (threads.length === 0) return;
setSelectedIds(
new Set(
threads.map(
(thread) => thread.latestMessage.threadId || thread.latestMessage.id,
),
),
);
}, [threads, setSelectedIds]);
useKeyboardShortcuts([{ key: "a", meta: true, handler: selectAllThreads }]);
return (
{threads.map((thread) => {
const email = thread.latestMessage;
const threadKey = email.threadId || email.id;
const isActive = threadKey === activeThreadId;
const isMultiSelected = selectedIds.has(threadKey);
const senderName = formatSidebarSender(thread);
return (
);
})}
);
}
// Stable references for the default "empty" fallbacks of useQuery data —
// using `[]` inline creates a fresh array on every render, which cascades
// through memos into EmailThread's props and causes re-render storms.
const EMPTY_ACCOUNTS: { email: string; displayName?: string }[] = [];
const EMPTY_LABELS: string[] = [];
const EMPTY_EMAILS: EmailMessage[] = [];
export function InboxPage() {
const { view = "inbox", threadId: routeThreadId } = useParams<{
view: string;
threadId: string;
}>();
const navigate = useNavigate();
// Immediate thread route override. React Router wraps navigations in
// startTransition, which can leave the previous route visible until the new
// route commits. `undefined` means "use the URL", a string means "show this
// thread now", and `null` means "show the list now".
const [optimisticThreadId, setOptimisticThreadId] = useState<
string | null | undefined
>(undefined);
const threadId =
optimisticThreadId === undefined
? routeThreadId
: (optimisticThreadId ?? undefined);
const handleOptimisticThreadNavigation = useCallback(
(nextThreadId: string | undefined) => {
setOptimisticThreadId(nextThreadId ?? null);
},
[],
);
// Clear the override once the URL catches up.
useEffect(() => {
if (optimisticThreadId === undefined) return;
if (
optimisticThreadId === null
? !routeThreadId
: routeThreadId === optimisticThreadId
) {
setOptimisticThreadId(undefined);
}
}, [routeThreadId, optimisticThreadId]);
const [focusedId, setFocusedId] = useState(null);
const [selectedIds, setSelectedIds] = useState>(new Set());
const selectedThreadIds = useMemo(
() => Array.from(selectedIds),
[selectedIds],
);
const [isMaximized, setIsMaximized] = useState(false);
const compose = useComposeState();
const navState = useNavigationState();
const [, setLastArchivedId] = useState(null);
const { data: settings } = useSettings();
const [searchParams] = useSearchParams();
const activeLabel = searchParams.get("label");
const routeSearchSuffix = searchParams.toString()
? `?${searchParams.toString()}`
: "";
const googleStatus = useGoogleAuthStatus();
const { activeAccounts } = useAccountFilter();
// Memoize every derived array — the emails memo depends on these, and fresh
// array refs on every render were cascading into EmailThread as unstable
// threads/emailIds props.
const connectedAccounts = useMemo(
() => googleStatus.data?.accounts ?? EMPTY_ACCOUNTS,
[googleStatus.data?.accounts],
);
const isGoogleConnected = connectedAccounts.length > 0;
const connectedEmails = useMemo(
() => new Set(connectedAccounts.map((a) => a.email.toLowerCase())),
[connectedAccounts],
);
const userPinnedLabels = useMemo(
() => settings?.pinnedLabels ?? EMPTY_LABELS,
[settings?.pinnedLabels],
);
const pinnedLabels = useMemo(
() => resolvePinnedLabels(userPinnedLabels, isGoogleConnected),
[isGoogleConnected, userPinnedLabels],
);
const triageLabels = useMemo(
() => pinnedTriageLabels(pinnedLabels),
[pinnedLabels],
);
const hasNoteToSelf = pinnedLabels.includes("note-to-self");
// Always fetch from the URL view (inbox, starred, etc.).
// Top-bar triage tabs (Important / pinned labels / "Other") are slices of
// the single inbox query — NOT a separate Gmail `label:` search — so the
// tab badge count and the list it shows always agree. Non-pinned sidebar
// labels (and label searches) still hit the server label query.
const searchQuery = searchParams.get("q") ?? undefined;
const isPinnedTab =
!!activeLabel &&
view === "inbox" &&
mailLabelsInclude(triageLabels, activeLabel);
const clientSliceTab = isPinnedTab && !searchQuery;
const effectiveLabel = clientSliceTab
? undefined
: (activeLabel ?? undefined);
const {
data: rawEmails,
isLoading,
isFetching,
isError,
error: emailsError,
refetch: refetchEmails,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useEmails(view, searchQuery, effectiveLabel);
const hasEmailData = rawEmails !== undefined;
const emailListLoading =
isLoading ||
!hasEmailData ||
(!googleStatus.data && googleStatus.isLoading);
const emails = useMemo(() => {
// Self-sent mail → virtual "important"/"note-to-self" so it lands in the
// matching triage tab. Shared with the badge counts (AppLayout) so the
// two agree on self-sent threads.
let filtered = augmentSelfSentLabels(rawEmails ?? EMPTY_EMAILS, {
isGoogleConnected,
connectedEmails,
hasNoteToSelf,
});
// Filter by active accounts (empty set = all accounts, no filtering)
if (activeAccounts.size > 0) {
filtered = filtered.filter(
(e) => e.accountEmail && activeAccounts.has(e.accountEmail),
);
}
// Top-bar triage tab: slice the loaded inbox with the exact same
// membership rule the badge uses (qualifiesForInboxTab). This is what
// keeps the tab number equal to the emails listed under it.
if (clientSliceTab && activeLabel) {
return filterInboxTabEmails(filtered, activeLabel, pinnedLabels);
}
// "Other" tab — the inbox remainder, same partition as its badge.
if (
!searchQuery &&
view === "inbox" &&
!activeLabel &&
triageLabels.length > 0
) {
return filterInboxTabEmails(filtered, null, pinnedLabels);
}
if (activeLabel) {
// Non-pinned sidebar label (or a label search): server-fetched. User
// Gmail labels keep thread membership when any fetched message carries
// the label, so replies don't disappear just because the latest row
// differs; inbox-scoped app labels stay a latest-message slice.
const isInboxScopedLabel = isInboxScopedAppLabel(activeLabel);
const hasLabel = (e: (typeof filtered)[0]) =>
mailLabelsInclude(e.labelIds, activeLabel);
const latestByThread = new Map();
const labelThreadIds = new Set();
for (const e of filtered) {
const key = e.threadId || e.id;
if (hasLabel(e)) labelThreadIds.add(key);
const existing = latestByThread.get(key);
if (!existing || new Date(e.date) > new Date(existing.date)) {
latestByThread.set(key, e);
}
}
// For "important", exclude threads that belong to any other pinned tab
const otherPinnedLabels =
activeLabel === "important"
? triageLabels.filter((l) => l !== "important")
: [];
const qualifiedThreadIds = new Set(
[...latestByThread.entries()]
.filter(([threadKey, latest]) => {
if (
isInboxScopedLabel
? !hasLabel(latest)
: !labelThreadIds.has(threadKey)
)
return false;
if (
otherPinnedLabels.length > 0 &&
mailLabelsIncludeAny(latest.labelIds, otherPinnedLabels)
)
return false;
return true;
})
.map(([threadId]) => threadId),
);
return filtered.filter((e) => qualifiedThreadIds.has(e.threadId || e.id));
}
return filtered;
}, [
rawEmails,
view,
searchQuery,
activeLabel,
clientSliceTab,
pinnedLabels,
triageLabels,
activeAccounts,
isGoogleConnected,
connectedEmails,
hasNoteToSelf,
]);
// Clear multi-selection when switching views or label tabs. Do NOT clear on
// threadId changes — shift+j/k in detail view navigates between threads while
// extending the selection, so selection must persist across thread nav.
useEffect(() => setSelectedIds(new Set()), [view, activeLabel]);
// Sync current navigation state to file (write-only, so agent can read it)
const searchQ = searchParams.get("q") ?? undefined;
useEffect(() => {
navState.sync({
view,
threadId,
focusedEmailId: focusedId ?? undefined,
search: searchQ,
label: activeLabel ?? undefined,
selectedThreadIds:
selectedThreadIds.length > 0 ? selectedThreadIds : undefined,
});
}, [view, threadId, focusedId, searchQ, activeLabel, selectedThreadIds]); // eslint-disable-line react-hooks/exhaustive-deps
// One-shot agent navigation: agent writes navigate.json, UI reads it, navigates, deletes it
const { data: navCommand } = navState.command;
const lastCommandRef = useRef("");
useEffect(() => {
if (!navCommand) return;
const key = JSON.stringify(navCommand);
if (key === lastCommandRef.current) return;
lastCommandRef.current = key;
const targetView = navCommand.view || view;
const targetThread = navCommand.threadId;
if (navCommand.composeDraftId && !targetThread) {
// A deep link reopened a compose draft. The open route already wrote the
// matching compose- app-state entry, which the compose panel
// auto-opens via polling. Select the requested draft immediately so
// existing compose tabs do not keep focus when the draft arrives.
compose.setActiveId(navCommand.composeDraftId);
window.dispatchEvent(
new CustomEvent(FOCUS_COMPOSE_DRAFT_EVENT, {
detail: { id: navCommand.composeDraftId },
}),
);
if (view !== "inbox") navigate("/inbox");
} else if (targetView === "draft-queue") {
const target = navCommand.queuedDraftId
? `/draft-queue?id=${encodeURIComponent(navCommand.queuedDraftId)}`
: "/draft-queue";
navigate(target);
} else if (targetView === "settings") {
const target = navCommand.settingsSection
? `/settings?section=${encodeURIComponent(navCommand.settingsSection)}`
: "/settings";
navigate(target);
} else if (targetThread) {
navigate(`/${targetView}/${targetThread}`);
} else if (targetView !== view) {
navigate(`/${targetView}`);
}
// Delete the command file so it doesn't re-trigger
navState.clearCommand();
}, [navCommand, view, navigate]); // eslint-disable-line react-hooks/exhaustive-deps
// Stable-identity pattern: keep the previous array reference when the
// content hasn't meaningfully changed. Without this, markThreadRead's
// optimistic update (which rebuilds the emails array for a single isRead
// flip) produces a new `threads` reference on every unread-open, which
// cascades through EmailThread's props and re-renders the whole detail
// view. With this, the props only change when the list of threads (or
// their latest-message identities) actually changes.
const rawThreads = useMemo(() => groupIntoThreads(emails), [emails]);
const prevThreadsRef = useRef([]);
const threads = useMemo(() => {
const prev = prevThreadsRef.current;
if (
prev.length === rawThreads.length &&
prev.every(
(t, i) =>
t.latestMessage.id === rawThreads[i].latestMessage.id &&
t.latestMessage.threadId === rawThreads[i].latestMessage.threadId &&
t.hasUnread === rawThreads[i].hasUnread,
)
) {
return prev;
}
prevThreadsRef.current = rawThreads;
return rawThreads;
}, [rawThreads]);
const threadIds = useMemo(
() => threads.map((t) => t.latestMessage.threadId || t.latestMessage.id),
[threads],
);
// Safety valve: if optimisticThreadId points to a thread that was removed from
// the view (archived/trashed before the route caught up), clear it so the
// app doesn't get stuck rendering a ghost thread.
useEffect(() => {
if (
optimisticThreadId &&
threads.length > 0 &&
!threads.some(
(t) =>
(t.latestMessage.threadId || t.latestMessage.id) ===
optimisticThreadId,
)
) {
setOptimisticThreadId(undefined);
}
}, [optimisticThreadId, threads]);
const handleCompose = useCallback(
(email: EmailMessage, mode: "reply" | "forward") => {
if (mode === "reply") {
compose.open({
to: email.from.email,
subject: email.subject.startsWith("Re:")
? email.subject
: `Re: ${email.subject}`,
body: `\n\n\n\n— On ${new Date(email.date).toLocaleDateString()}, ${email.from.name || email.from.email} wrote:\n\n${email.body
.split("\n")
.map((l) => `> ${l}`)
.join("\n")}`,
mode: "reply",
replyToId: email.id,
replyToThreadId: email.threadId,
});
} else {
compose.open({
to: "",
subject: email.subject.startsWith("Fwd:")
? email.subject
: `Fwd: ${email.subject}`,
body: `\n\n\n\n— Forwarded message —\nFrom: ${email.from.name} <${email.from.email}>\n\n${email.body}`,
mode: "forward",
replyToId: email.id,
replyToThreadId: email.threadId,
});
}
},
[compose],
);
// Open a saved draft in the compose window
const handleDraftOpen = useCallback(
(email: EmailMessage) => {
compose.open({
to: email.to.map((r) => r.email).join(", "),
cc: email.cc?.map((r) => r.email).join(", ") ?? "",
bcc: email.bcc?.map((r) => r.email).join(", ") ?? "",
subject: email.subject === "(no subject)" ? "" : email.subject,
body: email.body,
attachments: email.attachments?.map((attachment) => ({
id: attachment.id,
filename: attachment.filename,
originalName: attachment.filename,
mimeType: attachment.mimeType,
size: attachment.size,
url: `/api/attachments?messageId=${encodeURIComponent(
email.id,
)}&id=${encodeURIComponent(
attachment.id,
)}&mimeType=${encodeURIComponent(attachment.mimeType)}`,
source: "gmail",
gmailMessageId: email.id,
gmailAttachmentId: attachment.id,
accountEmail: email.accountEmail,
})),
mode: "compose",
replyToId: (email as any).replyToId,
replyToThreadId: (email as any).replyToThreadId,
savedDraftId: email.id,
});
},
[compose],
);
const isMobile = useIsMobile();
const hasThread = !!threadId;
const showsScenicInboxZero =
view === "inbox" && (!activeLabel || activeLabel === "important");
const isInboxZero =
showsScenicInboxZero &&
hasEmailData &&
!emailListLoading &&
!isError &&
!hasThread &&
!searchQuery &&
threads.length === 0;
const [sidebarContactEmail, setSidebarContactEmail] = useState<
string | undefined
>();
// Reset sidebar contact when navigating away from a thread
useEffect(() => {
setSidebarContactEmail(undefined);
}, [threadId]);
// Use the focused email ID for the contact panel, falling back to the selected thread
const contactEmailId = threadId ?? focusedId ?? undefined;
// Error state — only show connect banner when Google is definitively not connected.
// For transient errors (rate limits, network blips), let EmailList render its
// richer retry/cooldown state instead of replacing it with a generic error.
if (isError && !hasThread && threads.length === 0) {
const message = emailsError?.message ?? "";
const needsGoogleConnection =
/No Google account connected|GOOGLE_CLIENT_ID|GOOGLE_CLIENT_SECRET/i.test(
message,
);
if (
needsGoogleConnection ||
(!googleStatus.isLoading && googleStatus.data?.connected === false)
) {
return ;
}
}
// Inbox Zero — full-bleed image, no sidebar
if (isInboxZero) {
return ;
}
return (
{/* Main content */}
{hasThread && !isMobile && !isMaximized && (
)}
{hasThread ? (
setIsMaximized((v) => !v)}
/>
) : (
)}
{/* Right contact panel — hidden during initial load or when maximized */}
{!emailListLoading && !(hasThread && isMaximized) && (
)}
);
}