Introduced 1 production defect in 180 days, median 48 days to fix.
import {
PencilCompose,
Search,
} from '@/components/icons/icons';
import { useThreadsOperations } from '@/modules/threads/threadList/hooks/use-threads-operations';
import { SplitInboxTabs } from '@/modules/threads/components/SplitInboxTabs';
import { BulkActionsBar } from '@/modules/threads/components/bulk-actions-bar';
import { StackedInboxView } from '@/modules/threads/components/stacked-inbox-view';
import { useInboxes } from '@/modules/threads/hooks/use-inboxes';
import { useRouteInbox } from '@/modules/threads/hooks/use-route-inbox';
import { MailList } from '@/modules/threads/threadList/components/mail-list';
import { ScheduledMailView } from '@/modules/drafting/components/scheduled-mail-view';
import { useMail, useMailActions, useCedarStore } from '@/modules/store';
import { ActiveViewDisplay } from '@/components/ui/active-view-display';
import { ChannelSelector } from '@/modules/inbox/components/ChannelSelector';
import { ChannelThreadView } from '@/modules/inbox/components/ChannelThreadView';
import { InboxList } from '@/modules/inbox/components/InboxList';
import { useUnreadFilter } from '@/modules/threads/hooks/use-unread-filter';
import { useSearchQuerySync } from '@/modules/threads/hooks/use-search-query-sync';
import { CHANNEL_FILTERS, type InboxChannelFilter } from '@/modules/inbox/types';
import { useOpenChannelItem } from '@/modules/inbox/hooks/use-open-channel-item';
import { resolveChannelFilter } from '@/modules/inbox/lib/channel-availability';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/providers/query-provider';
import { INBOX_REFRESH_EVENT } from '@/modules/inbox/hooks/use-inbox-items';
import { useActiveConnection } from '@/hooks/use-connections';
import { SearchInput } from '@/components/ui/search-input';
import { perfSessionLog } from '@/lib/performance-logger';
import { AnimatePresence, motion } from 'framer-motion';
import { Button } from '@/components/ui/button';
import { RefreshCcw } from 'lucide-react';
import { useParams } from 'react-router';
import { useQueryState } from 'nuqs';
import { cn } from '@/lib/utils';
export function MailLayout() {
perfSessionLog('[MailLayout] Render', 'color: #ff00ff');
const params = useParams<{ folder: string }>();
const folder = params?.folder ?? 'inbox';
const [mail] = useMail();
const { clearBulkSelection } = useMailActions();
const prevFolderRef = useRef(folder);
useActiveConnection();
// Landing on the inbox starts clean: no highlighted row, no thread, and — critically —
// no conversation carried in from wherever the user just was. `activeConversationId`
// and `conversationSelection` are what the chat input turns into context badges, so a
// leftover selection means the agent silently inherits a deal the user never opened here.
//
// Skipped when the URL names an artifact (`?threadOpen` / `?conversationId` / any of the three
// chat params) — that is an explicit deep-link that LayoutUrlSync is about to restore, and
// clearing would race it. All three chat channels, not just Slack: `?linkedin=` and
// `?whatsapp=` restore through the same hook and are what a table's output cell navigates to.
// Mount-only: it must not re-fire on folder switches, which are their own navigation.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const named = ['threadOpen', 'conversationId', 'slack', 'linkedin', 'whatsapp'];
if (named.some((param) => params.has(param))) return;
const store = useCedarStore.getState();
store.selectThreadId(null);
store.setFocusedIndex(null);
store.clearBulkSelection();
store.clearConversationSelection();
store.setActiveConversationId(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { inboxLayout, scopeReady: feedScopeReady } = useInboxes();
// The unified feed is scoped by `folder`, but a custom inbox's slug
// (`/mail/active-pipeline`) is not a real Gmail folder — so on a custom inbox
// tab the email source resolved `in:active-pipeline`, matched nothing, and the
// chat sources (which never read `folder` at all) returned everything. Hand the
// feed the inbox's own rule instead, through the SAME resolver `useThreads` uses:
// one route, one compiled query, so `All` and `Email` cannot disagree about which
// tab is open. What the tab MEANS, not what its record happens to store — the
// system tabs (Important / Other / Inbox) are local stubs with no `compiledQuery`
// of their own, so reading it off the record handed the feed `undefined` and let
// the server fall back to `in:<folder>`, which for Important skipped the split
// subtraction (a Calendar split's mail reappeared) and for Other named a Gmail
// label that does not exist (no email in the tab at all).
const routeInbox = useRouteInbox({ folder });
const activeFeedCompiled = routeInbox.compiled;
// The channel badge and the inbox tab are orthogonal filters over the same feed, and both
// have to be known before it can be asked. While settings/inboxes load, `inboxLayout` reads
// as the default `inbox` — under which no stub owns the `important`/`other` slug, so
// `findInboxBySlug` misses, `activeFeedCompiled` is undefined, and the server falls back to
// a folder mapping that cannot express the tab. `scopeReady` (not `isLoading`) holds the
// feed for that window — see the note on it in `useInboxes`.
// ⇧U — the unread-only filter for the inbox on screen. The email list reads the same flag
// inside `useThreads`; the unibox has to be handed it, since its feed is a different query.
const { unreadOnly } = useUnreadFilter();
const useStackedView = inboxLayout === 'stacked' && folder === 'inbox';
// Virtual folder — scheduled ("send later") sends live in the server KV, so this
// view replaces the thread list while keeping the surrounding mail chrome.
const isScheduledFolder = folder === 'scheduled';
const isThreadOpen = useCedarStore((state) => state.isThreadOpen);
const isConversationOpen = useCedarStore((state) => state.isConversationOpen);
const isTaskOutputOpen = useCedarStore((state) => state.isTaskOutputOpen);
const newEmail = useCedarStore((state) => state.newEmail);
const openNewEmail = useCedarStore((state) => state.openNewEmail);
// Omni-channel: the top-row channel badge. Defaults to Email so the existing
// mail experience is unchanged; picking All/LinkedIn/WhatsApp/Slack swaps the
// list to the unified feed. See apps/mail/docs/omni-channel-inbox.md.
const [channelParam, setChannelParam] = useQueryState('channel');
const channel = (channelParam as InboxChannelFilter | null) ?? 'email';
const showUnifiedFeed = channel !== 'email';
// Remember the last-picked channel locally so /inbox restores it instead of always
// defaulting to Email. This reconciles on EVERY render where the URL has no ?channel —
// not once on mount — because navigations that drop the query (the /inbox → /inbox/inbox
// redirect, folder-tab clicks) leave mail.tsx mounted, and a one-shot restore would never
// re-fire, silently reverting the inbox to Email. `saved !== 'email'` means an explicit
// Email pick (which writes 'email' + clears the param) stays on Email without a loop.
// See omni-channel-inbox.md.
useEffect(() => {
if (channelParam) return; // explicit URL wins
const saved = typeof window !== 'undefined' ? window.localStorage.getItem('inbox:channel') : null;
if (saved && saved !== 'email') void setChannelParam(saved);
}, [channelParam, setChannelParam]);
// A `?slack=` / `?linkedin=` / `?whatsapp=` deep-link names a chat that only the UNIFIED feed
// carries, so arriving on the email list would set the artifact and open nothing. Adopt that
// channel's filter on the way in — once, so a later channel pick isn't dragged back. Links
// copied out of the app already carry `?channel=` (you can only open a chat row from the
// unibox); this is for the hand-written / server-generated shape, and for the one an output
// cell in a table navigates to (see `open-output-in-channel.ts`).
const [slackDeepLink] = useQueryState('slack');
const [linkedinDeepLink] = useQueryState('linkedin');
const [whatsappDeepLink] = useQueryState('whatsapp');
const adoptedChatDeepLink = useRef(false);
useEffect(() => {
if (adoptedChatDeepLink.current) return;
const channel = slackDeepLink
? 'slack'
: linkedinDeepLink
? 'linkedin'
: whatsappDeepLink
? 'whatsapp'
: null;
if (!channel) return;
adoptedChatDeepLink.current = true;
if (channelParam !== channel && channelParam !== 'all') void setChannelParam(channel);
}, [slackDeepLink, linkedinDeepLink, whatsappDeepLink, channelParam, setChannelParam]);
// Tell the store which ordered list is on screen. The unibox and the email list share
// selectedThreadId / bulkSelected / focusedIndex (the unibox just holds unified ids), so
// every keyboard action — j/k, Enter, Escape — has to walk the list the user can see.
// Switching back to Email must restore 'threads', hence the else branch rather than a
// mount-only write.
const setActiveListSource = useCedarStore((state) => state.setActiveListSource);
useEffect(() => {
setActiveListSource(showUnifiedFeed ? 'unified' : 'threads');
return () => setActiveListSource('threads');
}, [showUnifiedFeed, setActiveListSource]);
const pickChannel = useCallback(
(v: InboxChannelFilter) => {
if (typeof window !== 'undefined') window.localStorage.setItem('inbox:channel', v);
void setChannelParam(v === 'email' ? null : v);
},
[setChannelParam],
);
// Opening a LinkedIn/WhatsApp/Slack chat takes over the full content area —
// the SAME mechanism email threads use: the header+list block hides and the
// channel view renders where ActiveViewDisplay does. No folder tabs behind it.
// A Slack channel additionally rides the display artifact → `?slack=<channelId>`,
// so it is deep-linkable and back-restorable like a thread (see the hook).
const { openChannelItem, openChannel, closeChannel } = useOpenChannelItem();
useEffect(() => {
if (prevFolderRef.current !== folder && mail.bulkSelected.length > 0) {
clearBulkSelection();
}
prevFolderRef.current = folder;
}, [folder, mail.bulkSelected.length, clearBulkSelection]);
const { isFetching, refetch: refetchThreads } = useThreadsOperations();
const trpc = useTRPC();
const queryClient = useQueryClient();
// Which channels the ACTIVE TAB can answer. The same `inbox.getFeedScope` read the feed makes
// (same key, same input), so react-query serves both from one request — and it is read here
// rather than out of `useInboxItems` because the badge menu needs it on the Email badge too,
// where the unified feed is not mounted at all.
const feedScope = useQuery(
trpc.inbox.getFeedScope.queryOptions(
{ inboxId: routeInbox.record?.id },
{ enabled: feedScopeReady },
),
);
const tabChannels = feedScope.data?.channels;
// A badge this tab cannot answer resolves to `all`.
//
// Disabling the menu item closes the front door; this closes the others. On a Gmail-query tab
// the chat sources are gated out — correctly, they cannot evaluate a Gmail query — so a Slack
// badge there asks for an intersection that is empty, and the feed rendered that as a
// confident "No slack messages." A badge still arrives around the menu: `?channel=slack` in a
// shared link, or the remembered pick restored below, followed by a click onto such a tab.
//
// The URL is written, never `localStorage`: the remembered pick is the user's standing choice,
// and a tab that happens to be unable to honour it should not erase it. Returning to a tab
// that CAN answer Slack restores Slack. `all` is answerable everywhere, so this converges on
// the next render rather than fighting the restore effect above.
useEffect(() => {
const resolved = resolveChannelFilter(channel, tabChannels);
if (resolved !== channel) void setChannelParam(resolved);
}, [channel, tabChannels, setChannelParam]);
// Local state for refresh button (like tasks-view.tsx pattern)
const [isRefreshing, setIsRefreshing] = useState(false);
const isRefreshingOrFetching = isRefreshing || isFetching;
const [isSearchOpen, setIsSearchOpen] = useState(false);
// While a search query is active in the URL, keep the search bar expanded —
// collapsing it to an icon while results stay filtered hides any way to clear it.
const [searchQuery] = useQueryState('search');
const isSearchActive = isSearchOpen || !!searchQuery;
// The `?search=` → search-slice mirror, mounted with the LIST rather than with the input.
// `SearchInput` used to own it, and it renders only while `isSearchActive` — so an input that
// unmounted with a query still in the store left the list filtered by a search with no box on
// screen to show it or clear it. See use-search-query-sync.ts.
useSearchQuerySync();
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== '/') return;
const activeElement = document.activeElement as HTMLElement | null;
const isInteractive =
activeElement?.closest('.ProseMirror') ||
activeElement?.isContentEditable ||
activeElement?.tagName === 'INPUT' ||
activeElement?.tagName === 'TEXTAREA';
if (isInteractive) return;
e.preventDefault();
setIsSearchOpen(true);
};
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
}, []);
// Backtick cycles the omni-channel filter (All · Email · LinkedIn · WhatsApp · Slack).
// `c` is already the global "compose" shortcut, so the channel switch rides `` ` `` instead.
// Only fires while the list is on screen (no thread / compose / channel chat open) and the
// focus isn't in an editor, mirroring the `/` search handler above.
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== '`') return;
const activeElement = document.activeElement as HTMLElement | null;
const isInteractive =
activeElement?.closest('.ProseMirror') ||
activeElement?.isContentEditable ||
activeElement?.tagName === 'INPUT' ||
activeElement?.tagName === 'TEXTAREA';
if (isInteractive) return;
if (isThreadOpen || isConversationOpen || isTaskOutputOpen || newEmail || openChannelItem)
return;
e.preventDefault();
const index = CHANNEL_FILTERS.findIndex((c) => c.value === channel);
const next = CHANNEL_FILTERS[(index + 1) % CHANNEL_FILTERS.length];
pickChannel(next.value);
};
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
}, [
channel,
pickChannel,
isThreadOpen,
isConversationOpen,
isTaskOutputOpen,
newEmail,
openChannelItem,
]);
// Add mailto protocol handler registration
useEffect(() => {
// Register as a mailto protocol handler if browser supports it
if (typeof window !== 'undefined' && 'registerProtocolHandler' in navigator) {
try {
// Register the mailto protocol handler
// When a user clicks a mailto: link, it will be passed to our dedicated handler
// which will:
// 1. Parse the mailto URL to extract email, subject and body
// 2. Create a draft with these values
// 3. Redirect to the compose page with just the draft ID
// This ensures we don't keep the email content in the URL
navigator.registerProtocolHandler('mailto', `/api/mailto-handler?mailto=%s`);
} catch (error) {
console.error('Failed to register protocol handler:', error);
}
}
}, []);
const handleExitBulkSelection = useCallback(() => {
clearBulkSelection();
}, [clearBulkSelection]);
const handleRefetchThreads = useCallback(async () => {
setIsRefreshing(true);
try {
if (showUnifiedFeed) {
// The unibox is composed from one query per channel — email through
// `mail.listThreads`, the chats through `inbox.listChannelItems` — so a refresh
// has to reach both. Ping the channel sync hooks too, so re-pulling Unipile
// surfaces new LinkedIn/WhatsApp messages rather than re-reading the mirror.
window.dispatchEvent(new Event(INBOX_REFRESH_EVENT));
await Promise.all([
queryClient.invalidateQueries({
queryKey=[redacted],
}),
queryClient.invalidateQueries({
queryKey=[redacted],
}),
]);
} else {
await refetchThreads();
}
} finally {
setIsRefreshing(false);
}
}, [showUnifiedFeed, refetchThreads, queryClient, trpc.mail.listThreads, trpc.inbox.listChannelItems]);
return (
<>
{/* URL⇄store sync is now a single root-level LayoutUrlSync (url-driven-layout Phase 3). */}
<div className="relative flex h-full w-full flex-col">
{/* Main content area - takes remaining space */}
<div className="min-h-0 flex-1">
{/* Always render mail list - hide when thread/conversation is open OR compose is open */}
<div
className={cn(
'flex h-full w-full',
(isThreadOpen || isConversationOpen || isTaskOutputOpen || newEmail || openChannelItem) &&
'hidden',
)}
>
<div className="flex h-full w-full max-w-full flex-col">
{/* Header — compact title bar with actions */}
<div className="z-15 sticky top-0">
<div className="flex h-[44px] items-center gap-2 pl-4 pr-[3.375rem]">
{/* Middle: bulk actions, or split tabs (tabs stay visible while
searching). The channel badge leads the tab row — positioned
exactly where the first inbox tab sits. */}
{mail.bulkSelected.length > 0 && !isSearchActive ? (
<BulkActionsBar
selectedThreadIds={mail.bulkSelected}
currentFolder={folder}
onExitSelection={handleExitBulkSelection}
/>
) : (
<SplitInboxTabs
grow={!isSearchActive}
leading={
<ChannelSelector
value={channel}
onChange={pickChannel}
participating={tabChannels}
/>
}
/>
)}
<AnimatePresence>
{isSearchActive && (
<motion.div
key=[redacted]
initial={{ clipPath: 'inset(0 0 0 100%)' }}
animate={{ clipPath: 'inset(0 0 0 0%)' }}
exit={{ clipPath: 'inset(0 0 0 100%)' }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="min-w-64 flex-1 overflow-hidden pl-8"
>
<SearchInput autoFocus onClose={() => setIsSearchOpen(false)} />
</motion.div>
)}
</AnimatePresence>
{/* Search icon button */}
{!isSearchActive && mail.bulkSelected.length === 0 && (
<Button
onClick={() => setIsSearchOpen(true)}
variant="ghost"
size="icon"
className="hover:bg-accent/50 h-7 w-7 rounded-full border border-transparent bg-transparent hover:border-border/50"
>
<Search className="fill-muted-foreground h-4 w-4" />
</Button>
)}
<button
onClick={() => openNewEmail()}
className="text-muted-foreground hover:text-foreground mx-1 shrink-0 transition-colors"
>
<PencilCompose className="fill-muted-foreground h-3.5 w-3.5" />
</button>
<Button
onClick={handleRefetchThreads}
variant="ghost"
size="icon"
disabled={isRefreshingOrFetching}
className="hover:bg-accent/50 h-7 w-7 rounded-full border-none bg-transparent"
>
<RefreshCcw
className={cn(
'text-muted-foreground h-4 w-4',
isRefreshingOrFetching && 'animate-spin',
)}
/>
</Button>
</div>
</div>
{/* Mail list section */}
<div className={cn('z-1 relative flex h-full flex-col overflow-hidden pt-0')}>
<div className="h-full overflow-hidden">
{showUnifiedFeed ? (
<InboxList
channel={channel}
folder={folder}
compiledQuery={activeFeedCompiled?.compiledQuery}
queryHash={activeFeedCompiled?.queryHash}
inboxId={routeInbox.record?.id}
unreadOnly={unreadOnly}
scopeReady={feedScopeReady}
onOpenChannel={openChannel}
/>
) : isScheduledFolder ? (
<ScheduledMailView />
) : useStackedView && !isSearchActive ? (
<StackedInboxView />
) : (
<MailList />
)}
</div>
</div>
</div>
</div>
{/* Active view — top of view stack determines what to show. `isChannelChatOpen` is
deliberately NOT here: this route opens a channel through `openChannelItem` below,
so listing it would mount a second ChannelThreadView over the first. Every OTHER
artifact kind has to be listed, or it is set and never drawn — `?task=` is routed
from the root layout and reaches here. */}
{(isConversationOpen || isThreadOpen || isTaskOutputOpen || newEmail) && (
<ActiveViewDisplay />
)}
{/* Channel chat — full content area, folder tabs hidden (same as a thread).
LinkedIn/WhatsApp/Slack all render through the one ChannelThreadView. */}
{openChannelItem && (
<div className="bg-surface flex h-full w-full">
<ChannelThreadView item={openChannelItem} onClose={closeChannel} />
</div>
)}
</div>
</div>
</>
);
}