SplitInboxTabs.tsx98.4 KBView on GitHub import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
type ReactNode,
} from 'react';
import {
Archive,
Check,
CheckCheck,
ChevronLeft,
Clock,
FileText,
GripVertical,
Inbox,
Menu,
Plus,
Send,
Settings,
Trash2,
TriangleAlert,
X,
} from 'lucide-react';
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
import { useAOPs } from '@/modules/aop/hooks/use-aops';
import { useLabels } from '@/modules/labels/hooks/use-labels';
import useSearchLabels from '@/modules/labels/hooks/use-labels-search';
import {
findInboxBySlug,
getInboxSlug,
getRowInboxOrder,
useInboxes,
type InboxConfig,
type InboxRule,
} from '@/modules/threads/hooks/use-inboxes';
import {
SplitSettingsCreator,
type SplitSettingsDraft,
} from '@/modules/threads/components/SplitSettingsCreator';
import { SplitTemplateCard } from '@/modules/threads/components/SplitTemplateCard';
import { isOffRampStageLabel } from '@/modules/crm/utils/stage-terminality';
import { mergeEnumOptionsFromAops } from '@/modules/crm/utils/merge-enum-options';
import { formatInboxCount } from '@/modules/threads/lib/format-inbox-count';
import { useInboxCounts } from '@/hooks/use-inbox-counts';
import { cn } from '@/lib/utils';
import { useNavigate, useLocation } from 'react-router';
import { useCedarStore } from '@/modules/store';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/providers/query-provider';
import { toast } from 'sonner';
import {
ALWAYS_ACTIVE_SYSTEM_SPLIT_FOLDERS,
getSystemFolderSplit,
SYSTEM_FOLDER_SPLITS,
} from '@/modules/threads/lib/system-folder-splits';
const SYSTEM_INBOX_DESCRIPTIONS: Record<string, string> = {
default: 'all mail',
important: "Gmail's Important",
other: 'not in any inbox',
};
export type TemplateCategory =
| 'General'
| 'Task'
| 'Collaboration'
| 'Product Development'
| 'Project Management'
| 'Document Signing'
| 'Sales'
| 'Hiring'
| 'Meetings & Recordings';
/**
* Explicit display order for template categories. `Task` is placed right after
* General so agent-draft templates surface above team/collaboration tooling.
*/
const CATEGORY_ORDER: TemplateCategory[] = [
'General',
'Task',
'Collaboration',
'Product Development',
'Project Management',
'Document Signing',
'Sales',
'Hiring',
'Meetings & Recordings',
];
export type SplitTemplate = {
category: TemplateCategory;
name: string;
description: string;
accentClassName: string;
query?: string;
action?: 'set_important_other_layout';
/**
* When true, conversations matching this template's query ALSO appear in the
* Important / Other inboxes (the template's query is NOT added to the
* Important/Other exclusion list). Defaults to false. Task templates default
* to true so agent drafts stay visible in the main inbox.
*/
alsoShowInImportantOrOther?: boolean;
/**
* Marks a template that needs interactive user input before it can be applied
* (e.g. VIP needs a list of emails/domains). When set, the card expands an
* inline form on click instead of immediately calling `addInbox`.
*/
requiresUserInput?: 'vip-list' | 'pipeline-preset';
/**
* Builds a Gmail query at apply-time using runtime context (e.g. AOPs). When
* provided, this overrides the static `query` field. Used by Pitches to OR-in
* AOP labels named "spam", "pitches", "inbound cold spam".
*/
buildQuery?: (ctx: { aopIds: string[] }) => string;
/**
* Optional Cedar AI labels this template depends on.
*/
aiLabels?: Array<{
slug: string;
displayName: string;
description: string;
}>;
};
const SUPERHUMAN_BASE_PREFIX = '-in:CHAT in:inbox';
function getAiLabelTerms(slug: string): string[] {
return [
`label:[superhuman]/ai/${slug}`,
`label:"Cedar/AI/${slug}"`,
`label:"[Cedar]/AI/${slug}"`,
];
}
function buildAiLabelUnionQuery(slugs: string[]): string {
const terms = slugs.flatMap((slug) => getAiLabelTerms(slug));
return `{${terms.join(' ')}}`;
}
function getCedarAiLabelName(slug: string): string {
return `Cedar/AI/${slug}`;
}
function domainsToFromQuery(domains: string[]): string {
const normalized = domains
.map((domain) => domain.replace(/^(\*?@)/, '').trim())
.filter(Boolean)
.map((domain) => `"${domain}"`);
if (normalized.length === 0) return '';
if (normalized.length === 1) return `from:${normalized[0]}`;
return `from:(${normalized.join(' OR ')})`;
}
export const SUPERHUMAN_TEMPLATES: SplitTemplate[] = [
{
category: 'General',
name: 'Important + Other',
description: 'Split conversations from marketing, social networks, and automatic updates.',
accentClassName: 'border-t-slate-300',
action: 'set_important_other_layout',
},
{
category: 'General',
name: 'Calendar',
description: 'All your calendar events and meeting notifications.',
accentClassName: 'border-t-rose-200',
/**
* Sender + `filename:ics` alone is NOT enough, and both halves fail in a
* different way:
*
* - `filename:ics` only matches when the invite's `text/calendar` part is
* surfaced as a NAMED attachment. Google Calendar invites frequently are
* not (the part carries no filename), so the very mail this split exists
* to catch slips through. It is kept because it still catches genuinely
* attached `.ics` files, which the subject terms miss.
* - The sender terms only match mail Google/Calendly generate. They miss
* the much larger half of calendar traffic: RSVP notices and the human
* replies on invite threads (`Re: Declined: …`, `Automatic reply: Updated
* invitation: …`, `Out of office … Re: Invitation: …`), which arrive from
* the ATTENDEE's own address.
*
* So match on the subject shapes calendar systems emit, which work
* identically on the Gmail `q:` path and the mirror's `subject ILIKE`
* interpreter. Each term keeps its trailing colon on purpose: it is what
* separates a calendar `Invitation:` from product mail like "Your
* invitation to the Claude Console" or "Isabelle invited you to work
* together in Slack". `(GMT` catches Outlook-style invites, whose subjects
* carry no `Invitation:` prefix but do append the timezone.
*/
query: `${SUPERHUMAN_BASE_PREFIX} {from:"<email>" from:"<email>" filename:ics subject:"invitation:" subject:"invitation with note" subject:"accepted:" subject:"declined:" subject:"tentative:" subject:"canceled event:" subject:"cancelled event:" subject:"new event:" subject:"changed event:" subject:"(GMT"}`,
},
{
category: 'General',
name: 'VIP',
description: 'Conversations from a list of senders or domains you mark as VIP.',
accentClassName: 'border-t-emerald-200',
requiresUserInput: 'vip-list',
},
{
category: 'General',
name: 'Starred',
description: "Conversations that you've starred.",
accentClassName: 'border-t-amber-200',
query: `${SUPERHUMAN_BASE_PREFIX} is:starred`,
},
{
category: 'General',
name: 'Unread',
description: 'Conversations that are currently unread.',
accentClassName: 'border-t-sky-200',
query: `${SUPERHUMAN_BASE_PREFIX} is:unread`,
},
{
category: 'General',
name: 'Reminders',
description: 'Reminders that are ready to follow up.',
accentClassName: 'border-t-violet-200',
query: `${SUPERHUMAN_BASE_PREFIX} {from:"<email>" from:"<email>" from:"<email>"}`,
},
{
category: 'Task',
name: 'Agent Drafts',
description: 'All drafts created by Cedar agents across every task type.',
accentClassName: 'border-t-blue-300',
query: `${SUPERHUMAN_BASE_PREFIX} label:"Cedar/Agent drafts"`,
alsoShowInImportantOrOther: true,
},
{
category: 'Task',
name: 'Post-meeting',
description: 'Agent drafts written after a meeting wraps up.',
accentClassName: 'border-t-blue-200',
query: `${SUPERHUMAN_BASE_PREFIX} label:"Cedar/Task/Post-meeting"`,
alsoShowInImportantOrOther: true,
},
{
category: 'Task',
name: 'Pre-meeting',
description: 'Agent drafts prepping for an upcoming meeting.',
accentClassName: 'border-t-blue-200',
query: `${SUPERHUMAN_BASE_PREFIX} label:"Cedar/Task/Pre-meeting"`,
alsoShowInImportantOrOther: true,
},
{
category: 'Task',
name: 'Follow-up',
description: 'Agent follow-up drafts on sent conversations.',
accentClassName: 'border-t-blue-200',
query: `${SUPERHUMAN_BASE_PREFIX} label:"Cedar/Task/Follow-up"`,
alsoShowInImportantOrOther: true,
},
{
category: 'Task',
name: 'Response',
description: 'Agent drafts responding to inbound emails.',
accentClassName: 'border-t-blue-200',
query: `${SUPERHUMAN_BASE_PREFIX} label:"Cedar/Task/Response"`,
alsoShowInImportantOrOther: true,
},
{
category: 'Task',
name: 'Reactivation',
description: 'Agent drafts to reactivate stale conversations.',
accentClassName: 'border-t-blue-200',
query: `${SUPERHUMAN_BASE_PREFIX} label:"Cedar/Task/Reactivation"`,
alsoShowInImportantOrOther: true,
},
{
category: 'Task',
name: 'Manual',
description: 'Drafts from manually created agent tasks.',
accentClassName: 'border-t-blue-200',
query: `${SUPERHUMAN_BASE_PREFIX} label:"Cedar/Task/Manual"`,
alsoShowInImportantOrOther: true,
},
{
category: 'Collaboration',
name: 'Shared',
description: 'Conversations shared via Superhuman or Cedar Mail.',
accentClassName: 'border-t-indigo-200',
},
{
category: 'Collaboration',
name: 'Team',
description: 'Messages from your team to you.',
accentClassName: 'border-t-cyan-200',
query: `${SUPERHUMAN_BASE_PREFIX} from:"<your-domain>" to:{<your-email>}`,
},
{
category: 'Collaboration',
name: 'Notes',
description: 'Messages from you to yourself.',
accentClassName: 'border-t-rose-100',
query: `${SUPERHUMAN_BASE_PREFIX} from:{<your-email>} to:{<your-email>}`,
},
{
category: 'General',
name: 'Travel',
description: 'Tickets, reservations, and check-in information.',
accentClassName: 'border-t-indigo-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${buildAiLabelUnionQuery(['travel'])}`,
aiLabels: [
{
slug: 'travel',
displayName: 'Travel',
description:
'Travel itineraries, tickets, reservations, and airline or hotel check-in updates.',
},
],
},
{
category: 'General',
name: 'Purchases',
description: 'Orders, receipts, and purchase confirmations.',
accentClassName: 'border-t-cyan-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${buildAiLabelUnionQuery(['order', 'shipping'])}`,
aiLabels: [
{
slug: 'order',
displayName: 'Order',
description: 'Order confirmations, receipts, and purchase-related transaction emails.',
},
{
slug: 'shipping',
displayName: 'Shipping',
description: 'Shipping updates, package tracking notices, and delivery confirmations.',
},
],
},
{
category: 'General',
name: 'Finance',
description: 'Invoices, bills, and tax documents.',
accentClassName: 'border-t-lime-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${buildAiLabelUnionQuery(['invoice', 'tax'])}`,
aiLabels: [
{
slug: 'invoice',
displayName: 'Invoice',
description: 'Invoices, billing notices, payment requests, and accounts receivable emails.',
},
{
slug: 'tax',
displayName: 'Tax',
description: 'Tax forms, filing reminders, tax documents, and compliance notices.',
},
],
},
{
category: 'General',
name: 'Notifications',
description: 'Automated emails and updates.',
accentClassName: 'border-t-cyan-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${buildAiLabelUnionQuery(['notifications', 'comment'])}`,
aiLabels: [
{
slug: 'notifications',
displayName: 'Notifications',
description: 'Automated account notifications, alerts, and status update emails.',
},
{
slug: 'comment',
displayName: 'Comment',
description: 'Comment notifications from docs, tasks, and collaboration platforms.',
},
],
},
{
category: 'General',
name: 'News',
description: 'News, articles, and newsletters.',
accentClassName: 'border-t-amber-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${buildAiLabelUnionQuery(['news'])}`,
aiLabels: [
{
slug: 'news',
displayName: 'News',
description: 'Newsletters, news digests, media updates, and editorial content.',
},
],
},
{
category: 'General',
name: 'Social',
description: 'Social network and online communities.',
accentClassName: 'border-t-indigo-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${buildAiLabelUnionQuery(['social'])}`,
aiLabels: [
{
slug: 'social',
displayName: 'Social',
description: 'Social network updates, community notifications, and social platform activity.',
},
],
},
{
category: 'General',
name: 'Marketing',
description: 'Marketing and promotional messages.',
accentClassName: 'border-t-rose-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${buildAiLabelUnionQuery(['marketing'])}`,
aiLabels: [
{
slug: 'marketing',
displayName: 'Marketing',
description: 'Promotional campaigns, sales outreach, product marketing, and offers.',
},
],
},
{
category: 'General',
name: 'Pitches',
description: 'Cold pitch and outreach messages, plus any AOP labeled as spam or cold outreach.',
accentClassName: 'border-t-amber-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${buildAiLabelUnionQuery(['pitch'])}`,
aiLabels: [
{
slug: 'pitch',
displayName: 'Pitch',
description: 'Cold outreach, unsolicited sales pitches, and inbound prospecting messages.',
},
],
buildQuery: ({ aopIds }) => {
const clauses = getAiLabelTerms('pitch');
for (const aopId of aopIds) {
clauses.push(`label:"Cedar/aop/${aopId}"`);
}
return `${SUPERHUMAN_BASE_PREFIX} {${clauses.join(' ')}}`;
},
},
{
category: 'Collaboration',
name: 'Documents',
description: 'Google Workspace, Office 365, and document platform notifications.',
accentClassName: 'border-t-cyan-100',
},
{
category: 'Collaboration',
name: 'Google',
description: 'Google Workspace comments, edits, and sharing notifications.',
accentClassName: 'border-t-cyan-100',
query: `${SUPERHUMAN_BASE_PREFIX} {from:"docs.google.com" from:"<email>" from:"<email>"}`,
},
{
category: 'Collaboration',
name: 'Office',
description: 'Microsoft Office Suite comments, edits, and sharing notifications.',
accentClassName: 'border-t-slate-200',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@microsoft.com'])}`,
},
{
category: 'Collaboration',
name: 'Notion',
description: 'Notion comments, updates, and sharing notifications.',
accentClassName: 'border-t-violet-100',
query: `${SUPERHUMAN_BASE_PREFIX} from:"<email>"`,
},
{
category: 'Collaboration',
name: 'Coda',
description: 'Coda comments, updates, and sharing notifications.',
accentClassName: 'border-t-rose-100',
query: `${SUPERHUMAN_BASE_PREFIX} {from:"<email>" from:"<email>"}`,
},
{
category: 'Collaboration',
name: 'Confluence',
description: 'Confluence comments, updates, and sharing notifications.',
accentClassName: 'border-t-indigo-100',
query: `${SUPERHUMAN_BASE_PREFIX} from:confluence`,
},
{
category: 'Collaboration',
name: 'Loom',
description: 'Video recordings, sharing notifications, and activity.',
accentClassName: 'border-t-cyan-200',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@loom.com'])}`,
},
{
category: 'Product Development',
name: 'Figma',
description: 'Comments and updates from Figma files.',
accentClassName: 'border-t-violet-200',
query: `${SUPERHUMAN_BASE_PREFIX} from:"email.figma.com"`,
},
{
category: 'Product Development',
name: 'GitHub',
description: 'Commits, forks, and project activity.',
accentClassName: 'border-t-zinc-200',
query: `${SUPERHUMAN_BASE_PREFIX} {from:"<email>" from:"noreply.github.com" to:"noreply.github.com"}`,
},
{
category: 'Product Development',
name: 'Linear',
description: 'Status updates, task progress, and planning.',
accentClassName: 'border-t-indigo-200',
query: `${SUPERHUMAN_BASE_PREFIX} {from:"<email>" from:"<email>"}`,
},
{
category: 'Product Development',
name: 'Jira',
description: 'Project changes, comments, and status updates.',
accentClassName: 'border-t-sky-200',
query: `${SUPERHUMAN_BASE_PREFIX} from:jira`,
},
{
category: 'Product Development',
name: 'Aha!',
description: 'Feature updates, roadmap changes, and tasks.',
accentClassName: 'border-t-rose-200',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@aha.io'])}`,
},
{
category: 'Project Management',
name: 'Asana',
description: 'Task updates, deadlines, and project notifications.',
accentClassName: 'border-t-rose-200',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@asana.com'])}`,
},
{
category: 'Project Management',
name: 'Trello',
description: 'Card updates, board changes, and task assignments.',
accentClassName: 'border-t-indigo-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@trello.com'])}`,
},
{
category: 'Project Management',
name: 'ClickUp',
description: 'Task updates, comments, and project changes.',
accentClassName: 'border-t-violet-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@clickup.com'])}`,
},
{
category: 'Project Management',
name: 'Monday',
description: 'Task assignments, due dates, and progress notifications.',
accentClassName: 'border-t-cyan-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@monday.com'])}`,
},
{
category: 'Document Signing',
name: 'Signature',
description: 'Documents from every signature platform.',
accentClassName: 'border-t-amber-100',
},
{
category: 'Document Signing',
name: 'DocuSign',
description: 'Signature requests, document statuses, and reminders.',
accentClassName: 'border-t-amber-200',
query: `${SUPERHUMAN_BASE_PREFIX} from:"docusign.net"`,
},
{
category: 'Document Signing',
name: 'SignNow',
description: 'Signature requests, document statuses, and reminders.',
accentClassName: 'border-t-lime-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@signnow.com'])}`,
},
{
category: 'Document Signing',
name: 'Dropbox Sign',
description: 'Signature requests and document statuses.',
accentClassName: 'border-t-sky-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@dropboxsign.com', '*@hellosign.com'])}`,
},
{
category: 'Document Signing',
name: 'Signeasy',
description: 'Signatures and document progress.',
accentClassName: 'border-t-emerald-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@signeasy.com'])}`,
},
{
category: 'Document Signing',
name: 'PandaDoc',
description: 'Signature actions, views, and reminders.',
accentClassName: 'border-t-rose-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@pandadoc.com'])}`,
},
{
category: 'Sales',
name: 'Active Pipeline',
description:
'Mail on deals that are actually moving — your Deals AOP, minus the closed, disqualified, and parked stages.',
accentClassName: 'border-t-emerald-300',
requiresUserInput: 'pipeline-preset',
},
{
category: 'Sales',
name: 'Salesforce',
description: 'Sales alerts, lead updates, and account activity.',
accentClassName: 'border-t-blue-200',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@salesforce.com'])}`,
},
{
category: 'Sales',
name: 'Zoho',
description: 'CRM updates, lead activities, and project notifications.',
accentClassName: 'border-t-cyan-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@zoho.com', '*@zohocrm.com'])}`,
},
{
category: 'Sales',
name: 'HubSpot',
description: 'Customer interactions, deal statuses, and email campaigns.',
accentClassName: 'border-t-amber-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@hubspot.com'])}`,
},
{
category: 'Sales',
name: 'Pipedrive',
description: 'Sales activities, lead progress, and pipeline changes.',
accentClassName: 'border-t-emerald-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@pipedrive.com'])}`,
},
{
category: 'Sales',
name: 'Gong',
description: 'Call recordings and coaching feedback.',
accentClassName: 'border-t-indigo-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@gong.io'])}`,
},
{
category: 'Sales',
name: 'Chorus',
description: 'Call recordings, conversation insights, and coaching.',
accentClassName: 'border-t-sky-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@chorus.ai'])}`,
},
{
category: 'Sales',
name: 'DocSend',
description: 'Shared documents, access notifications, and insights.',
accentClassName: 'border-t-violet-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@docsend.com'])}`,
},
{
category: 'Hiring',
name: 'Workday Recruiting',
description: 'Job postings, interview workflows, and candidate pipelines.',
accentClassName: 'border-t-sky-200',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@workday.com', '*@myworkday.com'])}`,
},
{
category: 'Hiring',
name: 'Greenhouse',
description: 'Job applications, candidate updates, and interview notifications.',
accentClassName: 'border-t-emerald-200',
query: `${SUPERHUMAN_BASE_PREFIX} {from:"eu.greenhouse.io" from:"<email>" from:"greenhouse.io"}`,
},
{
category: 'Hiring',
name: 'Lever',
description: 'Candidate updates, interview feedback, and hiring stages.',
accentClassName: 'border-t-indigo-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@lever.co'])}`,
},
{
category: 'Hiring',
name: 'Ashby',
description: 'Hiring stages, candidate activity, and interview notes.',
accentClassName: 'border-t-violet-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@ashbyhq.com'])}`,
},
{
category: 'Hiring',
name: 'SmartRecruiters',
description: 'Job postings, candidate pipelines, and interview feedback.',
accentClassName: 'border-t-cyan-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@smartrecruiters.com'])}`,
},
{
category: 'Hiring',
name: 'Workable',
description: 'Job openings, applicant updates, and interview feedback.',
accentClassName: 'border-t-amber-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@workable.com'])}`,
},
{
category: 'Hiring',
name: 'Breezy HR',
description: 'Job applications, candidate progress, and interview schedules.',
accentClassName: 'border-t-lime-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@breezy.hr'])}`,
},
{
category: 'Meetings & Recordings',
name: 'Zoom',
description: 'Meeting links, recordings, and schedule updates.',
accentClassName: 'border-t-sky-200',
query: `${SUPERHUMAN_BASE_PREFIX} from:"<email>"`,
},
{
category: 'Meetings & Recordings',
name: 'Meet',
description: 'Google Meet recordings and playback notifications.',
accentClassName: 'border-t-cyan-100',
query: `${SUPERHUMAN_BASE_PREFIX} {from:"<email>" from:"<email>" from:"<email>"}`,
},
{
category: 'Meetings & Recordings',
name: 'Otter',
description: 'Meeting notes, transcriptions, and insights.',
accentClassName: 'border-t-violet-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@otter.ai'])}`,
},
{
category: 'Meetings & Recordings',
name: 'Fireflies.ai',
description: 'Meeting transcripts, notes, and action items.',
accentClassName: 'border-t-emerald-100',
query: `${SUPERHUMAN_BASE_PREFIX} ${domainsToFromQuery(['*@fireflies.ai'])}`,
},
];
// Gmail's label API only accepts colors from a fixed palette; this pair
// (light/dark purple) is on it and matches the in-app AI-label styling.
const AI_LABEL_DEFAULT_COLOR = {
backgroundColor: '#e3d7ff',
textColor: '#3d188e',
};
// ─── Individual Inbox Tab ──────────────────────────────────────────────────────
/**
* "Unread" — the ⇧U filter, shown next to the inbox it is narrowing.
*
* Both the marker that a tab is filtered and the way out of it: the whole point of badging it
* is that a filtered inbox looks exactly like an empty one, so the badge has to be visible AND
* reversible without knowing the shortcut. Rendered as a plain span inside the tab button, and
* deliberately WITHOUT `role="button"` — a button inside a button is invalid in the
* accessibility tree, and the role bought nothing anyway since the span was never focusable.
* The click is a mouse affordance layered on top; ⇧U is the keyboard path, and the title
* says so.
*/
function UnreadFilterBadge({ onClear }: { onClear: () => void }) {
return (
<span
title="Showing unread only — click or press ⇧U to show everything"
onClick={(e) => {
e.stopPropagation();
onClear();
}}
className="bg-primary/10 text-primary hover:bg-primary/20 text-xxs cursor-pointer rounded-full px-1.5 py-0.5 font-medium uppercase leading-none tracking-wide transition-colors"
>
Unread
</span>
);
}
interface InboxTabProps {
inbox: InboxConfig;
count?: { count: number; isExact: boolean };
isActive: boolean;
/** ⇧U is on for this tab — its count and its list are unread-only. */
unreadOnly?: boolean;
onClearUnreadOnly: () => void;
onSelect: () => void;
}
function InboxTab({
inbox,
count,
isActive,
unreadOnly,
onClearUnreadOnly,
onSelect,
}: InboxTabProps) {
return (
<button
type="button"
className={cn(
'relative self-stretch flex cursor-pointer items-center px-2 text-base font-medium leading-none transition-colors whitespace-nowrap select-none outline-none',
isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground/80',
)}
onClick={onSelect}
>
<span className="flex items-baseline gap-1">
<span>{inbox.name}</span>
{!!count && count.count > 0 && (
<span className="text-muted-foreground text-[11px] font-normal tabular-nums">
{formatInboxCount(count.count, count.isExact)}
</span>
)}
{unreadOnly && <UnreadFilterBadge onClear={onClearUnreadOnly} />}
</span>
</button>
);
}
// ─── Rule editor ───────────────────────────────────────────────────────────────
function describeRule(rule: InboxRule): string {
if (rule.kind === 'all') return 'Everything';
const parts = rule.clauses.map((c) => {
switch (c.type) {
case 'aop':
return 'AOP';
case 'gmail':
return `${c.field} ${c.op} "${c.value}"`;
case 'gmailLabel':
return 'Gmail label';
case 'ai':
return `AI: "${c.prompt.slice(0, 30)}${c.prompt.length > 30 ? '…' : ''}"`;
}
});
if (parts.length === 0) return 'No rules — shows everything';
return parts.join(rule.kind === 'all_of' ? ' AND ' : ' OR ');
}
function inboxToSplitDraft(inbox: InboxConfig) {
return {
name: inbox.name,
query: inbox.query,
alsoShowInImportant: inbox.alsoShowInImportant ?? false,
hideWhenEmpty: inbox.hideWhenEmpty ?? false,
position: inbox.position,
conversationFilter: inbox.conversationFilter ?? null,
};
}
// ─── Sortable inbox button (drag handle + click to edit) ──────────────────────
interface SortableInboxButtonProps {
inbox: InboxConfig;
onEdit: (id: string) => void;
}
function SortableInboxButton({ inbox, onEdit }: SortableInboxButtonProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: inbox.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition: transition || 'transform 200ms ease',
};
const isSystem = !!inbox.system;
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'inline-flex h-8 items-center rounded-md border border-border pl-1 text-xs text-muted-foreground transition-colors hover:border-foreground/30 hover:text-foreground',
isSystem ? 'bg-sunken' : 'bg-background',
isDragging && 'opacity-40',
)}
>
<button
type="button"
{...attributes}
{...listeners}
className="flex h-full cursor-grab items-center px-0.5 text-muted-foreground/60 hover:text-foreground active:cursor-grabbing"
aria-label={`Reorder ${inbox.name}`}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => onEdit(inbox.id)}
disabled={isSystem}
className={cn(
'flex h-full items-center gap-1.5 pr-3 pl-1',
isSystem ? 'cursor-default' : 'cursor-pointer',
)}
title={
isSystem
? SYSTEM_INBOX_DESCRIPTIONS[inbox.id] ?? 'Built-in inbox'
: describeRule(inbox.rule)
}
>
{inbox.name}
</button>
</div>
);
}
interface ReorderInboxesSectionProps {
inboxes: InboxConfig[];
onEditInbox: (id: string) => void;
onAddInbox: () => void;
onReorder: (orderedIds: string[]) => void;
}
function ReorderInboxesSection({
inboxes,
onEditInbox,
onAddInbox,
onReorder,
}: ReorderInboxesSectionProps) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = inboxes.findIndex((i) => i.id === active.id);
const newIndex = inboxes.findIndex((i) => i.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const next = arrayMove(inboxes, oldIndex, newIndex);
onReorder(next.map((i) => i.id));
},
[inboxes, onReorder],
);
return (
<div className="space-y-2">
<p className="text-foreground text-base font-semibold">Reorder inboxes</p>
<div className="flex flex-wrap items-center gap-2">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={inboxes.map((i) => i.id)}
strategy={horizontalListSortingStrategy}
>
{inboxes.map((inbox) => (
<SortableInboxButton key=[redacted] inbox={inbox} onEdit={onEditInbox} />
))}
</SortableContext>
</DndContext>
<Button
type="button"
size="sm"
variant="outline"
onClick={onAddInbox}
className="h-8 text-xs"
>
<Plus className="h-3.5 w-3.5" />
Add Custom Inbox
</Button>
</div>
</div>
);
}
// ─── Edit Inboxes Dialog ───────────────────────────────────────────────────────
interface EditInboxesDialogProps {
open: boolean;
onClose: () => void;
}
function EditInboxesDialog({ open, onClose }: EditInboxesDialogProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const {
inboxes,
inboxLayout,
importantSignal,
setInboxLayout,
setImportantSignal,
addInbox,
assertCanAddInbox,
updateInbox,
removeInbox,
reorderInboxes,
} = useInboxes();
const { data: aopsData } = useAOPs();
const { allUserLabels = [], userLabels = [] } = useLabels();
const { mutateAsync: createUserLabel } = useMutation(
trpc.labels.create.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey=[redacted] });
},
}),
);
const { mutateAsync: deleteUserLabel } = useMutation(
trpc.labels.delete.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey=[redacted] });
},
}),
);
const [view, setView] = useState<
// `preset` pre-fills the Add form — the Pipeline template opens the real
// builder with a starting rule rather than applying a bespoke inline form,
// so whatever it produces is something the user can then edit normally.
| { kind: 'list' }
| { kind: 'add'; preset?: Partial<SplitSettingsDraft> }
| { kind: 'edit'; id: string }
>({ kind: 'list' });
const templateSectionRefs = useRef<Record<string, HTMLElement | null>>({});
const dialogBodyRef = useRef<HTMLDivElement | null>(null);
const [templateAlsoShowOverrides, setTemplateAlsoShowOverrides] = useState<
Record<string, boolean>
>({});
const [isAddingAiLabel, setIsAddingAiLabel] = useState(false);
const [newAiLabelName, setNewAiLabelName] = useState('');
const [newAiLabelDescription, setNewAiLabelDescription] = useState('');
const [optimisticTemplateNames, setOptimisticTemplateNames] = useState<Set<string>>(new Set());
// VIP inline form state — open the editor when the user clicks the VIP card
// and collect comma/newline-separated emails and domains.
const [vipFormOpen, setVipFormOpen] = useState(false);
const [vipInput, setVipInput] = useState('');
const { mutateAsync: createAiLabel } = useMutation(
trpc.mail.createAiLabel.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey=[redacted] });
},
}),
);
const { mutateAsync: backfillAiLabels } = useMutation(
trpc.mail.backfillAiLabels.mutationOptions(),
);
const { mutateAsync: deleteAiLabel, isPending: isDeletingAiLabel } = useMutation(
trpc.mail.deleteAiLabel.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey=[redacted] });
},
}),
);
// Which AI label chip is showing its inline delete confirm.
const [aiLabelPendingDelete, setAiLabelPendingDelete] = useState<string | null>(null);
// Create an AI label for a custom split, then backfill it onto recent threads so
// the label requirement we just added to the definition actually matches mail.
const createAiLabelForSplit = useCallback(
async ({ displayName, description }: { displayName: string; description: string }) => {
const result = await createAiLabel({
displayName,
description,
color: AI_LABEL_DEFAULT_COLOR,
});
toast.info('Backfilling the AI label on your 50 most recent inbox threads…');
void backfillAiLabels({
maxThreads: 50,
folder: 'INBOX',
reexecute: true,
bypassAgentExecutionEnabledCheck: true,
})
.catch((error) =>
toast.error(error instanceof Error ? error.message : 'AI label backfill failed'),
);
return result;
},
[createAiLabel, backfillAiLabels],
);
const deleteLabelForSplit = useCallback(
async (label: { id: string; name: string }) => {
try {
await deleteUserLabel({ id: label.id });
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete label');
throw error;
}
},
[deleteUserLabel],
);
const aiLabels = useMemo(
() =>
allUserLabels.filter(
(label: { id: string; name: string; color?: { backgroundColor?: string; textColor?: string } }) =>
label.name.startsWith('Cedar/AI/') || label.name.startsWith('[Cedar]/AI/'),
),
[allUserLabels],
);
const templateCategories = useMemo(() => {
const present = new Set(SUPERHUMAN_TEMPLATES.map((template) => template.category));
return CATEGORY_ORDER.filter((category) => present.has(category));
}, []);
const templatesByCategory = useMemo(() => {
const grouped = new Map<TemplateCategory, SplitTemplate[]>();
for (const template of SUPERHUMAN_TEMPLATES) {
const list = grouped.get(template.category) ?? [];
list.push(template);
grouped.set(template.category, list);
}
return grouped;
}, []);
// Pre-compute the AOP id list once so per-render `buildQuery` is cheap.
const matchingPitchesAopIds = useMemo(() => {
const targetNames = new Set(['spam', 'pitches', 'inbound cold spam']);
return (aopsData?.aops ?? [])
.filter((aop: { name?: string }) => aop.name && targetNames.has(aop.name.toLowerCase()))
.map((aop: { id: string }) => aop.id);
}, [aopsData]);
const resolveTemplateQuery = useCallback(
(template: SplitTemplate): string | undefined => {
if (template.buildQuery) return template.buildQuery({ aopIds: matchingPitchesAopIds });
return template.query;
},
[matchingPitchesAopIds],
);
// Scroll the dialog body itself rather than calling `scrollIntoView` on the
// section: the body is now the only scroll container, and driving it directly
// keeps the target pinned to the top edge instead of "nearest visible".
/**
* The "Active Pipeline" template's starting rule: the org's Deals AOP, with every
* stage whose LABEL reads as an off-ramp moved into the excluded set — closed and
* disqualified, but also the parked side states (On hold, Nurture, Paused,
* Stalled, Unqualified). "Active" is the operative word: a deal sitting on hold
* is not closed, but it is not moving either, and mail about it is not what this
* inbox is for.
*
* Two deliberate choices here:
* - Stages are derived, never hard-coded. `isOffRampStageLabel` matches those
* states in any org's own vocabulary, so the preset works without knowing
* their enum.
* - It EXCLUDES rather than includes. The ask was phrased as "anything X can be
* removed", and excluding means a stage added to the enum later defaults to
* included — a new mid-funnel stage must not silently vanish from pipeline.
*
* Returns null when there is no Deals AOP with stages to derive from; the caller
* explains rather than opening an empty form.
*/
const buildPipelinePreset = useCallback((): Partial<SplitSettingsDraft> | null => {
const aops = aopsData?.aops ?? [];
const dealsAop = aops.find((aop: { name?: string }) => aop.name?.toLowerCase() === 'deals');
if (!dealsAop) return null;
const stages = mergeEnumOptionsFromAops([dealsAop], 'status');
const offRamp = stages
.filter((option) => isOffRampStageLabel(option.label ?? option.value))
.map((option) => option.value)
.filter((value): value is string => typeof value === 'string');
if (offRamp.length === 0) return null;
return {
name: 'Active Pipeline',
query: 'label:INBOX',
alsoShowInImportant: true,
conversationFilter: {
filters: { aopIds: [dealsAop.id], status: { exclude: offRamp } },
uiConfig: { status: { filter: { excluded: offRamp } } },
},
};
}, [aopsData]);
const scrollToCategory = useCallback((category: TemplateCategory) => {
const container = dialogBodyRef.current;
const section = templateSectionRefs.current[category];
if (!container || !section) return;
const offset =
section.getBoundingClientRect().top - container.getBoundingClientRect().top;
container.scrollTo({ top: container.scrollTop + offset, behavior: 'smooth' });
}, []);
const getTemplateAlsoShow = useCallback(
(template: SplitTemplate): boolean => {
const key=[redacted];
if (templateAlsoShowOverrides[key] !== undefined) return templateAlsoShowOverrides[key]!;
// Once a template is applied, reflect the live server value so the checkbox
// matches the actual stored state for the split. We match by includeQuery
// first, then fall back to name — query strings can drift across versions
// (prefix changes, normalisation tweaks) and a strict-equality miss would
// make the checkbox show the template default while the persisted value
// is something different, which silently lies to the user and breaks the
// first toggle click (no-op write of the already-stored value).
const resolved = resolveTemplateQuery(template);
// Query match first; name match is a fallback for when query strings drift.
// A renamed user split that happens to share the template's name will match
// here, but only when the query lookup already failed — so the collision
// is acceptable. Don't reverse this order.
const match =
(resolved
? inboxes.find((d) => d.query?.trim() === resolved && d.enabled !== false)
: undefined) ??
inboxes.find(
(d) => d.name.toLowerCase() === template.name.toLowerCase() && d.enabled !== false,
);
if (match) return match.alsoShowInImportant ?? false;
return template.alsoShowInImportantOrOther ?? false;
},
[templateAlsoShowOverrides, inboxes, resolveTemplateQuery],
);
const setTemplateAlsoShow = useCallback((template: SplitTemplate, value: boolean) => {
const key=[redacted];
setTemplateAlsoShowOverrides((current) => ({ ...current, [key]: value }));
}, []);
const ensureTemplateAiLabels = useCallback(
async (template: SplitTemplate) => {
if (!template.aiLabels || template.aiLabels.length === 0) return;
const existingLabelNames = new Set(
allUserLabels
.map((label: { name?: string }) => label.name?.toLowerCase())
.filter((name): name is string => !!name),
);
for (const aiLabel of template.aiLabels) {
const cedarName = getCedarAiLabelName(aiLabel.slug).toLowerCase();
const legacyName = `[Cedar]/AI/${aiLabel.slug}`.toLowerCase();
if (existingLabelNames.has(cedarName) || existingLabelNames.has(legacyName)) continue;
try {
await createAiLabel({
displayName: aiLabel.displayName,
description: aiLabel.description,
color: AI_LABEL_DEFAULT_COLOR,
});
} catch (error) {
const message = error instanceof Error ? error.message.toLowerCase() : '';
if (!message.includes('already exists')) throw error;
}
}
},
[allUserLabels, createAiLabel],
);
const templateIsApplied = useCallback(
(template: SplitTemplate): boolean => {
if (optimisticTemplateNames.has(template.name)) return true;
if (template.action === 'set_important_other_layout') {
return inboxLayout === 'important_other';
}
const resolved = resolveTemplateQuery(template);
// For templates that need user input (e.g. VIP), `resolved` is undefined
// until the user fills the form — fall back to name match against inboxes.
if (!resolved || resolved.includes('<')) {
return inboxes.some(
(inbox) => !inbox.system && inbox.name.toLowerCase() === template.name.toLowerCase(),
);
}
const byQuery = inboxes.some(
(definition) =>
definition.enabled !== false && definition.query?.trim() === resolved,
);
const byName = inboxes.some(
(inbox) => !inbox.system && inbox.name.toLowerCase() === template.name.toLowerCase(),
);
return byQuery || byName;
},
[optimisticTemplateNames, inboxLayout, inboxes, inboxes, resolveTemplateQuery],
);
// Find the local inbox + server split definition that matches a template, so
// we can deselect it or update its `alsoShowInImportant` flag in place.
const findTemplateMatch = useCallback(
(template: SplitTemplate) => {
const resolved = resolveTemplateQuery(template);
const inbox =
inboxes.find(
(i) => !i.system && i.name.toLowerCase() === template.name.toLowerCase(),
) ??
(resolved ? inboxes.find((i) => !i.system && i.query?.trim() === resolved) : undefined);
const split =
(resolved
? inboxes.find((d) => d.query?.trim() === resolved && d.enabled !== false)
: undefined) ??
inboxes.find(
(d) => d.name.toLowerCase() === template.name.toLowerCase() && d.enabled !== false,
);
return { inbox, split };
},
[inboxes, inboxes, resolveTemplateQuery],
);
useEffect(() => {
if (open) {
setView({ kind: 'list' });
setIsAddingAiLabel(false);
setNewAiLabelName('');
setNewAiLabelDescription('');
setTemplateAlsoShowOverrides({});
setOptimisticTemplateNames(new Set());
setVipFormOpen(false);
setVipInput('');
}
}, [open]);
const editingInbox = view.kind === 'edit' ? inboxes.find((i) => i.id === view.id) : undefined;
let title = 'Edit inboxes';
if (view.kind === 'add') title = 'Add inbox';
if (view.kind === 'edit') title = editingInbox?.name ?? 'Edit inbox';
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
{/* `flex flex-col` (not the DialogContent default grid) so the body row can
shrink below its content height — that is what makes the WHOLE dialog a
single scroll container instead of each section owning its own scrollbar.
The width MUST stay capped against the viewport. DialogContent's base caps
at `calc(100% - 2rem)`, but that rule has no breakpoint prefix, so a bare
`sm:max-w-6xl` overrides it from 640px up and the dialog renders 1152px wide
on a 1024px laptop — running off-screen and dragging every popover anchored
near its right edge with it. `min()` keeps the gallery wide on big screens
without ever exceeding the window. */}
<DialogContent className="flex max-h-[92vh] flex-col overflow-hidden sm:max-w-[min(72rem,calc(100vw-3rem))]">
<DialogHeader>
<DialogTitle className="flex items-center gap-1">
{view.kind !== 'list' && (
<button
type="button"
onClick={() => setView({ kind: 'list' })}
className="text-muted-foreground hover:text-foreground -ml-1"
aria-label="Back"
>
<ChevronLeft className="h-4 w-4" />
</button>
)}
{title}
</DialogTitle>
</DialogHeader>
{view.kind === 'list' && (
<>
<div ref={dialogBodyRef} className="min-h-0 flex-1 space-y-5 overflow-y-auto py-2 pr-1">
<ReorderInboxesSection
inboxes={inboxes}
onEditInbox={(id) => setView({ kind: 'edit', id })}
onAddInbox={() => setView({ kind: 'add' })}
onReorder={reorderInboxes}
/>
<div className="space-y-2">
<p className="text-foreground text-base font-semibold">
AI Mail labels
</p>
<div className="flex flex-wrap items-center gap-2">
{(aopsData?.aops ?? []).map((aop) => (
<span
key=[redacted]
className="inline-flex h-8 cursor-pointer items-center rounded-md border border-border bg-sunken px-3 text-xs text-foreground"
title="AOP labels are managed in AOP settings"
>
{aop.name}
</span>
))}
{aiLabels.map((label) =>
aiLabelPendingDelete === label.id ? (
<span
key=[redacted]
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-destructive/40 bg-destructive/10 px-2 text-xs"
>
<span className="text-destructive">
Delete {label.name.replace('Cedar/AI/', '').replace('[Cedar]/AI/', '')}?
</span>
<button
type="button"
aria-label="Confirm delete"
disabled={isDeletingAiLabel}
className="text-destructive hover:opacity-70 disabled:opacity-40"
onClick={async () => {
try {
await deleteAiLabel({ labelId: label.id, labelName: label.name });
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to delete AI label',
);
} finally {
setAiLabelPendingDelete(null);
}
}}
>
<Check className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label="Cancel delete"
className="text-muted-foreground hover:opacity-70"
onClick={() => setAiLabelPendingDelete(null)}
>
<X className="h-3.5 w-3.5" />
</button>
</span>
) : (
<span
key=[redacted]
className="group inline-flex h-8 items-center gap-1.5 rounded-md border border-border bg-sunken px-3 text-xs"
style={{
color: label.color?.textColor ?? AI_LABEL_DEFAULT_COLOR.textColor,
}}
>
{label.name.replace('Cedar/AI/', '').replace('[Cedar]/AI/', '')}
<button
type="button"
aria-label={`Delete ${label.name}`}
onClick={() => setAiLabelPendingDelete(label.id)}
className="text-muted-foreground hover:text-destructive -mr-1 opacity-0 transition-opacity group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</span>
),
)}
{!isAddingAiLabel && (
<Button
type="button"
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={() => setIsAddingAiLabel(true)}
>
<Plus className="h-3.5 w-3.5" />
Add AI label
</Button>
)}
{aiLabels.length === 0 && (aopsData?.aops ?? []).length === 0 && !isAddingAiLabel && (
<span className="text-muted-foreground text-xs">No AI labels yet</span>
)}
</div>
{isAddingAiLabel && (
<div className="space-y-2 rounded-md border border-border bg-muted/20 p-2">
<Input
value={newAiLabelName}
onChange={(event) => setNewAiLabelName(event.target.value)}
placeholder="Name (e.g. Newsletters)"
className="h-8 text-xs"
/>
<Input
value={newAiLabelDescription}
onChange={(event) => setNewAiLabelDescription(event.target.value)}
placeholder="Description for LLM matching (what should this label catch?)"
className="h-8 text-xs"
/>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant="ghost"
className="h-8 text-xs"
onClick={() => {
setIsAddingAiLabel(false);
setNewAiLabelName('');
setNewAiLabelDescription('');
}}
>
Cancel
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-8 text-xs"
disabled={
newAiLabelName.trim().length === 0 ||
newAiLabelDescription.trim().length === 0
}
onClick={async () => {
const displayName = newAiLabelName.trim();
const description = newAiLabelDescription.trim();
if (!displayName || !description) return;
try {
await createAiLabel({
displayName,
description,
color: AI_LABEL_DEFAULT_COLOR,
});
setIsAddingAiLabel(false);
setNewAiLabelName('');
setNewAiLabelDescription('');
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to create AI label',
);
}
}}
>
Save
</Button>
</div>
</div>
)}
</div>
<div className="space-y-2">
<p className="text-foreground text-base font-semibold">Display mode</p>
<div className="flex items-start justify-between gap-4 rounded-md border border-border bg-sunken/40 p-3">
<p className="text-muted-foreground text-xs">
Tabs: one inbox at a time, switch with the tab strip.
Containers: every inbox stacks vertically as a collapsible
section on a single Inbox view.
</p>
<div className="flex items-center gap-2">
<span
className={cn(
'text-xs',
inboxLayout !== 'stacked' ? 'text-foreground font-medium' : 'text-muted-foreground',
)}
>
Tabs
</span>
<Switch
aria-label="Toggle stacked inbox layout"
checked={inboxLayout === 'stacked'}
onCheckedChange={(checked) => {
setInboxLayout(checked ? 'stacked' : 'inbox');
}}
/>
<span
className={cn(
'text-xs',
inboxLayout === 'stacked' ? 'text-foreground font-medium' : 'text-muted-foreground',
)}
>
Containers
</span>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-foreground text-base font-semibold">
Templates
</p>
<div className="grid gap-3 md:grid-cols-[180px_1fr]">
<div className="bg-background space-y-1 self-start md:sticky md:top-0">
{templateCategories.map((category) => (
<button
key=[redacted]
type="button"
onClick={() => scrollToCategory(category)}
className="w-full rounded-md px-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
{category}
</button>
))}
</div>
<div className="space-y-6 pr-1">
{templateCategories.map((category) => {
const templates = templatesByCategory.get(category) ?? [];
if (templates.length === 0) return null;
return (
<section
key=[redacted]
ref={(el) => {
templateSectionRefs.current[category] = el;
}}
className="space-y-3"
>
<h3 className="text-sm font-semibold tracking-tight">{category}</h3>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{templates.map((template) => {
const isApplied = templateIsApplied(template);
const isToggle = template.action === 'set_important_other_layout';
const alsoShow = getTemplateAlsoShow(template);
const resolvedQuery = resolveTemplateQuery(template);
const canToggleAlsoShow = !isToggle && !!resolvedQuery;
const isVip = template.requiresUserInput === 'vip-list';
const alsoShowDestinationLabel =
inboxLayout === 'important_other' ? 'Important / Other' : 'Inbox';
return (
<SplitTemplateCard
key=[redacted]
name={
isToggle
? inboxLayout === 'important_other'
? 'Important + Other'
: 'Inbox'
: template.name
}
description={template.description}
accentClassName={template.accentClassName}
isToggle={isToggle}
isApplied={isToggle ? inboxLayout === 'important_other' : isApplied}
onClick={async () => {
if (isToggle) {
const nextLayout =
inboxLayout === 'important_other'
? 'inbox'
: 'important_other';
setInboxLayout(nextLayout);
setOptimisticTemplateNames((current) => {
const next = new Set(current);
if (nextLayout === 'important_other')
next.add(template.name);
else next.delete(template.name);
return next;
});
return;
}
if (isApplied) {
// Click-to-deselect: remove the matching inbox and
// disable the server-side split definition.
const { inbox } = findTemplateMatch(template);
setOptimisticTemplateNames((current) => {
const next = new Set(current);
next.delete(template.name);
return next;
});
if (inbox) {
try {
await removeInbox(inbox.id);
} catch (error) {
// The hook already put the row back; re-select
// the card so the two agree again.
setOptimisticTemplateNames((current) =>
new Set(current).add(template.name),
);
toast.error(
error instanceof Error
? error.message
: `Failed to remove ${template.name}`,
);
}
}
return;
}
if (isVip) {
// Expand the VIP inline form instead of applying.
setVipFormOpen((open) => !open);
return;
}
if (template.requiresUserInput === 'pipeline-preset') {
const preset = buildPipelinePreset();
if (!preset) {
toast.info(
'No Deals AOP with stages configured yet — set one up first.',
);
return;
}
setView({ kind: 'add', preset });
return;
}
if (!resolvedQuery) {
toast.info(
'This template depends on your personal account data, so we need a setup step first.',
);
return;
}
if (resolvedQuery.includes('<')) {
toast.info(
'This template includes placeholders (like your email/domain) and needs a quick setup step first.',
);
return;
}
try {
await ensureTemplateAiLabels(template);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: 'Failed to create required AI labels',
);
return;
}
setOptimisticTemplateNames((current) => {
const next = new Set(current);
next.add(template.name);
return next;
});
try {
await addInbox({
name: template.name,
rule: { kind: 'all_of', clauses: [] },
lookbackDays: 30,
query: resolvedQuery,
alsoShowInImportant: alsoShow,
});
if (template.aiLabels && template.aiLabels.length > 0) {
toast.info(
'Backfilling AI labels on your 50 most recent inbox threads…',
);
void backfillAiLabels({
maxThreads: 50,
folder: 'INBOX',
reexecute: true,
bypassAgentExecutionEnabledCheck: true,
})
.catch((error) => {
toast.error(
error instanceof Error
? error.message
: 'AI label backfill failed',
);
});
}
} catch (error) {
setOptimisticTemplateNames((current) => {
const next = new Set(current);
next.delete(template.name);
return next;
});
toast.error(
error instanceof Error
? error.message
: `Failed to add ${template.name}`,
);
}
}}
>
{isToggle && inboxLayout === 'important_other' && (
<label
className="flex cursor-pointer items-center gap-2 border-t border-border px-3 py-2 text-xs"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={importantSignal === 'gmail_important'}
onCheckedChange={(checked) => {
const next = checked
? 'gmail_important'
: 'category_personal';
setImportantSignal(next);
}}
/>
<span className="text-muted-foreground">
Use Gmail's Important label
<span className="text-muted-foreground/70">
{' '}
(instead of the Personal category)
</span>
</span>
</label>
)}
{isVip && vipFormOpen && !isApplied && (
<div
className="space-y-2 border-t border-border px-3 py-2 text-xs"
onClick={(e) => e.stopPropagation()}
>
<textarea
value={vipInput}
onChange={(e) => setVipInput(e.target.value)}
placeholder={'Enter emails or domains, one per line or comma-separated:\<email>\<email>'}
rows={4}
className="w-full resize-y rounded-md border border-border bg-background p-2 text-xs focus:outline-none focus:ring-1 focus:ring-foreground/30"
/>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={() => {
setVipFormOpen(false);
setVipInput('');
}}
>
Cancel
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs"
disabled={vipInput.trim().length === 0}
onClick={async () => {
const tokens = vipInput
.split(/[\n,]+/)
.map((t) => t.trim())
.filter(Boolean);
const emails = tokens.filter(
(t) => t.includes('@') && !t.startsWith('@'),
);
const domains = tokens.filter(
(t) => !t.includes('@') || t.startsWith('@'),
);
const fromClauses: string[] = [];
for (const email of emails) {
fromClauses.push(`from:"${email}"`);
}
const domainQuery = domainsToFromQuery(domains);
if (domainQuery) fromClauses.push(domainQuery);
if (fromClauses.length === 0) {
toast.error('Enter at least one valid email or domain');
return;
}
const query = `${SUPERHUMAN_BASE_PREFIX} {${fromClauses.join(' ')}}`;
setOptimisticTemplateNames((current) => {
const next = new Set(current);
next.add(template.name);
return next;
});
try {
await addInbox({
name: template.name,
rule: { kind: 'all_of', clauses: [] },
lookbackDays: 30,
query,
alsoShowInImportant: alsoShow,
});
setVipFormOpen(false);
setVipInput('');
} catch (error) {
setOptimisticTemplateNames((current) => {
const next = new Set(current);
next.delete(template.name);
return next;
});
toast.error(
error instanceof Error
? error.message
: `Failed to add ${template.name}`,
);
}
}}
>
Save
</Button>
</div>
</div>
)}
{canToggleAlsoShow && (
<label
className="flex cursor-pointer items-start gap-2 border-t border-border px-3 py-2 text-xs text-muted-foreground"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
className="mt-0.5"
checked={alsoShow}
onChange={async (e) => {
const nextValue = e.target.checked;
setTemplateAlsoShow(template, nextValue);
if (!isApplied) return;
// Read splits directly from the React
// Query cache rather than via the
// render-time `inboxes`
// closure — that snapshot can be
// stale (commonly empty) during the
// create/refetch race, and the
// closure won't refresh until the
// component re-renders. Pulling from
// getQueryData inside the handler
// gives us the latest cache, and
// works correctly across a forced
// refetch.
type CachedSplit = {
id: string;
name: string;
enabled?: boolean;
query?: string;
alsoShowInImportant?: boolean;
};
const findSplitFromCache = (): CachedSplit | undefined => {
const splits =
(queryClient.getQueryData(
trpc.mail.listInboxes.queryKey(),
) as CachedSplit[] | undefined) ?? [];
const resolved = resolveTemplateQuery(template);
return (
(resolved
? splits.find(
(d) =>
d.query?.trim() === resolved &&
d.enabled !== false,
)
: undefined) ??
splits.find(
(d) =>
d.name.toLowerCase() ===
template.name.toLowerCase() &&
d.enabled !== false,
)
);
};
let split = findSplitFromCache();
// No more optimistic-id entries in
// cache after dropping optimistic
// inserts — but the create/refetch
// race window still exists, so refetch
// once if the split is missing.
if (!split) {
await queryClient.refetchQueries({
queryKey=[redacted],
});
split = findSplitFromCache();
}
if (!split) {
console.error(
'[alsoShow toggle] no split found after refetch',
{
templateName: template.name,
resolved: resolveTemplateQuery(template),
cachedSplits: (queryClient.getQueryData(
trpc.mail.listInboxes.queryKey(),
) as CachedSplit[] | undefined) ?? [],
},
);
toast.error(
'Could not find the matching split to update — try again in a moment.',
);
setTemplateAlsoShow(template, !nextValue);
return;
}
try {
await updateInbox(split.id, {
alsoShowInImportant: nextValue,
});
} catch (err) {
toast.error(
err instanceof Error
? err.message
: 'Failed to update split',
);
setTemplateAlsoShow(template, !nextValue);
}
}}
/>
<span>{`Also show in ${alsoShowDestinationLabel}`}</span>
</label>
)}
</SplitTemplateCard>
);
})}
</div>
</section>
);
})}
{/* Without trailing slack the final category ("Meetings &
Recordings") can only scroll until the list bottoms out,
so its jump-link left it stranded mid-container. */}
<div aria-hidden className="h-[55vh]" />
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={onClose}>
Done
</Button>
</DialogFooter>
</>
)}
{view.kind === 'add' && (
<SplitSettingsCreator
draft={view.preset}
alsoShowTargetLabel={inboxLayout === 'important_other' ? 'Important / Other' : 'inbox'}
labels={userLabels}
onCreateLabel={async ({ name, color }) => {
await createUserLabel({ name, color });
}}
onCreateAiLabel={createAiLabelForSplit}
onDeleteLabel={deleteLabelForSplit}
onBack={() => setView({ kind: 'list' })}
onSave={({ name, alsoShowInImportant, query, conversationFilter }) => {
// Validate up-front so a bad name keeps the form (and the user's
// typed query) open, then return to the list WITHOUT awaiting the
// write — the optimistic cache insert already renders the inbox.
try {
assertCanAddInbox(name);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to add inbox');
return;
}
setView({ kind: 'list' });
void addInbox({
name,
rule: { kind: 'all_of', clauses: [] },
lookbackDays: 30,
alsoShowInImportant,
query,
conversationFilter: conversationFilter ?? undefined,
}).catch((error) => {
toast.error(error instanceof Error ? error.message : 'Failed to add inbox');
});
}}
/>
)}
{view.kind === 'edit' && editingInbox && (
<SplitSettingsCreator
draft={inboxToSplitDraft(editingInbox)}
alsoShowTargetLabel={inboxLayout === 'important_other' ? 'Important / Other' : 'inbox'}
labels={userLabels}
onCreateLabel={async ({ name, color }) => {
await createUserLabel({ name, color });
}}
onCreateAiLabel={createAiLabelForSplit}
onDeleteLabel={deleteLabelForSplit}
onAlsoShowInImportantChange={async (nextValue) => {
// Persist the toggle immediately — don't wait for Save. Mirrors
// the template-card UX where each click is its own write.
if ((editingInbox.alsoShowInImportant ?? false) === nextValue) return;
try {
await updateInbox(editingInbox.id, { alsoShowInImportant: nextValue });
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to update inbox',
);
}
}}
onBack={() => setView({ kind: 'list' })}
onDelete={() => {
setView({ kind: 'list' });
void removeInbox(editingInbox.id).catch((error) => {
toast.error(
error instanceof Error ? error.message : 'Failed to delete inbox',
);
});
}}
onSave={async ({ name, alsoShowInImportant, query, conversationFilter }) => {
try {
// `conversationFilter: null` is the meaningful "clear the CRM
// rule" value, so it is always sent rather than elided.
updateInbox(editingInbox.id, {
name,
query,
conversationFilter: conversationFilter ?? null,
});
// The toggle is normally saved on change via
// `onAlsoShowInImportantChange`; this final reconcile handles
// the edge case where the user closed via Save without ever
// toggling but the form value diverged for some reason.
if ((editingInbox.alsoShowInImportant ?? false) !== alsoShowInImportant) {
await updateInbox(editingInbox.id, { alsoShowInImportant });
}
setView({ kind: 'list' });
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to update inbox',
);
}
}}
/>
)}
</DialogContent>
</Dialog>
);
}
// ─── Hamburger Menu ────────────────────────────────────────────────────────────
const SYSTEM_FOLDER_ICON_BY_FOLDER: Record<string, ComponentType<{ className?: string }>> = {
done: CheckCheck,
draft: FileText,
sent: Send,
archive: Archive,
spam: TriangleAlert,
bin: Trash2,
all: Inbox,
};
const OTHER_FOLDERS = ALWAYS_ACTIVE_SYSTEM_SPLIT_FOLDERS.filter((folder) => folder !== 'all').map(
(folder) => ({
label: SYSTEM_FOLDER_SPLITS[folder]!.label,
icon: SYSTEM_FOLDER_ICON_BY_FOLDER[folder] ?? Inbox,
url: `/mail/${folder}`,
}),
);
// "Scheduled" is a virtual folder: send-later emails live in the server KV, not in
// a provider mailbox, so it isn't part of SYSTEM_FOLDER_SPLITS. It's spliced into
// the folder menu right after Sent, where users look for outbound mail.
const SCHEDULED_FOLDER = {
label: 'Scheduled',
icon: Clock,
url: '/mail/scheduled',
};
const FOLDER_MENU_ITEMS = (() => {
const sentIndex = OTHER_FOLDERS.findIndex((folder) => folder.url === '/mail/sent');
if (sentIndex === -1) return [...OTHER_FOLDERS, SCHEDULED_FOLDER];
return [
...OTHER_FOLDERS.slice(0, sentIndex + 1),
SCHEDULED_FOLDER,
...OTHER_FOLDERS.slice(sentIndex + 1),
];
})();
const FOLDER_TAB_NAMES: Record<string, string> = {
...Object.fromEntries(
ALWAYS_ACTIVE_SYSTEM_SPLIT_FOLDERS.map((folder) => [
folder,
SYSTEM_FOLDER_SPLITS[folder]!.label,
]),
),
organised: 'Organised',
scheduled: SCHEDULED_FOLDER.label,
};
interface HamburgerMenuProps {
onOpenSettings: () => void;
doneCount?: { count: number; isExact: boolean };
align?: 'start' | 'end';
}
function HamburgerMenu({ onOpenSettings, doneCount, align = 'start' }: HamburgerMenuProps) {
const navigate = useNavigate();
const { setLabels } = useSearchLabels();
const handleNavigate = (url: string) => {
setLabels([]);
navigate(url);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground hover:bg-accent flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition-colors"
aria-label="More folders"
>
<Menu className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-48">
<DropdownMenuItem onSelect={() => handleNavigate('/mail/inbox')} className="gap-2">
<Inbox className="text-muted-foreground h-3.5 w-3.5" />
<span>Inbox</span>
<span className="text-muted-foreground ml-auto text-xs">full view</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{FOLDER_MENU_ITEMS.map(({ label, icon: Icon, url }) => (
<DropdownMenuItem key=[redacted] onSelect={() => handleNavigate(url)} className="gap-2">
<Icon className="text-muted-foreground h-3.5 w-3.5" />
{label}
{label === 'Done' && !!doneCount && doneCount.count > 0 && (
<span className="text-muted-foreground ml-auto text-xs tabular-nums">
{formatInboxCount(doneCount.count, doneCount.isExact)}
</span>
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onOpenSettings} className="gap-2">
<Settings className="text-muted-foreground h-3.5 w-3.5" />
<span>Edit inboxes</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
// ─── Main SplitInboxTabs ───────────────────────────────────────────────────────
// Synthetic single inbox tab used in the stacked layout. Routes to /mail/inbox
// (an ALLOWED_FOLDERS standard slug) so the existing folder route + MailLayout
// branching mounts <StackedInboxView />.
const STACKED_INBOX_TAB: InboxConfig = {
id: 'stacked-root',
name: 'Inbox',
position: 0,
system: true,
rule: { kind: 'all' },
};
export function SplitInboxTabs({
grow = true,
leading,
}: {
grow?: boolean;
/** Optional control rendered as the first item of the tab row (e.g. the omni-channel badge). */
leading?: ReactNode;
}) {
const { inboxes, inboxLayout, activeInbox, hasCustomInboxOrder } = useInboxes();
const { setLabels } = useSearchLabels();
const navigate = useNavigate();
const location = useLocation();
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const isThreadOpen = useCedarStore((state) => state.isThreadOpen);
const isConversationOpen = useCedarStore((state) => state.isConversationOpen);
const counts = useInboxCounts();
// ⇧U — which tabs are narrowed to unread. Read as the whole map (not just the active tab's
// flag) so a tab you filtered and then navigated away from still says so when you look back
// at the strip; the filter is remembered per inbox, like Superhuman's splits.
const unreadOnlyByFolder = useCedarStore((state) => state.unreadOnlyByFolder);
const setUnreadOnly = useCedarStore((state) => state.setUnreadOnly);
// The mail surface lives under both /mail (legacy) and /inbox (omni-channel).
// Detect/navigate against whichever prefix we're on so the folder tabs work
// identically on either. See apps/mail/docs/omni-channel-inbox.md Phase 1.
const base = location.pathname.startsWith('/inbox') ? '/inbox' : '/mail';
const folderFromPath = location.pathname.match(/\/(?:inbox|mail)\/([^/?#]+)/)?.[1] ?? 'inbox';
const inboxFromPath = findInboxBySlug(inboxes, folderFromPath);
const isInboxRoute = folderFromPath === 'inbox' || !!inboxFromPath;
const isSystemFolderSplitRoute = !!getSystemFolderSplit(folderFromPath);
// Keep split tabs visible on inbox plus Gmail system-folder split routes.
const showInboxRow = isInboxRoute || isSystemFolderSplitRoute;
const currentInbox = inboxFromPath ?? (isInboxRoute ? activeInbox : undefined);
const folderTabName = FOLDER_TAB_NAMES[folderFromPath] ?? null;
const isStackedLayout = inboxLayout === 'stacked';
const rowInboxes = useMemo(
() => (isStackedLayout ? [STACKED_INBOX_TAB] : getRowInboxOrder(inboxes, hasCustomInboxOrder)),
[inboxes, isStackedLayout, hasCustomInboxOrder],
);
// In stacked layout the single synthetic Inbox tab is "active" whenever the
// URL is /mail/inbox (no slug match yields no inboxFromPath; the route is a
// standard ALLOWED_FOLDERS slug).
const isStackedInboxActive = isStackedLayout && folderFromPath === 'inbox';
// Sum unread across every real inbox for the synthetic tab's badge.
const stackedAggregateCount = useMemo(() => {
if (!isStackedLayout) return undefined;
let count = 0;
let isExact = true;
for (const inbox of getRowInboxOrder(inboxes, hasCustomInboxOrder)) {
const c = counts.byId[inbox.id];
if (!c) continue;
count += c.count;
if (!c.isExact) isExact = false;
}
return { count, isExact };
}, [isStackedLayout, inboxes, counts.byId, hasCustomInboxOrder]);
// Apply the active inbox's label filter while on the inbox route.
useEffect(() => {
if (!isInboxRoute) return;
setLabels([]);
}, [activeInbox, setLabels, isInboxRoute]);
const selectInbox = useCallback(
(inbox: InboxConfig) => {
// The synthetic stacked-inbox tab has no id in `inboxes`; just route to
// the canonical /mail/inbox slug and leave activeInboxId alone.
if (inbox.id === STACKED_INBOX_TAB.id) {
const inboxPath = `${base}/inbox`;
if (location.pathname !== inboxPath) navigate(inboxPath);
setLabels([]);
return;
}
// URL is the source of truth for activeInboxId — MailPage syncs it
// after navigation. Setting it eagerly here flip-flops it (new id paired
// with the old folder triggers a "correction" back) and fires 3 saves.
const targetPath = `${base}/${getInboxSlug(inbox)}`;
if (location.pathname !== targetPath) navigate(targetPath);
setLabels([]);
},
[setLabels, navigate, location.pathname],
);
// Cmd+Arrow cycles through the inbox tabs (blocked when a thread/conversation
// is open).
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
if (isThreadOpen || isConversationOpen) return;
if (rowInboxes.length === 0) return;
const target = e.target as HTMLElement;
const isTypingInInput =
target.tagName === 'INPUT' ||
target.tagName === 'SELECT' ||
target.tagName === 'TEXTAREA' ||
target.getAttribute('contenteditable') === 'true' ||
target.closest('[contenteditable="true"]') !== null;
if (isTypingInInput) return;
e.preventDefault();
const tabCount = rowInboxes.length;
const currentIndex = isStackedLayout
? 0
: Math.max(0, rowInboxes.findIndex((s) => s.id === currentInbox?.id));
const delta = e.key === 'ArrowLeft' ? -1 : 1;
const nextIndex = (currentIndex + delta + tabCount) % tabCount;
selectInbox(rowInboxes[nextIndex]!);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [rowInboxes, currentInbox, selectInbox, isStackedLayout, isThreadOpen, isConversationOpen]);
return (
<>
{showInboxRow ? (
<div
className={cn(
'flex min-w-0 items-stretch self-stretch gap-1 overflow-x-auto pl-10 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
grow && 'flex-1',
)}
>
{leading && <div className="flex shrink-0 items-center">{leading}</div>}
{rowInboxes.map((inbox) => {
const isStackedSynthetic = inbox.id === STACKED_INBOX_TAB.id;
// The stacked layout's one synthetic tab stands for the whole `/mail/inbox` route,
// which is the slug the filter is keyed by there.
const filterSlug = isStackedSynthetic ? 'inbox' : getInboxSlug(inbox);
return (
<InboxTab
key=[redacted]
inbox={inbox}
count={isStackedSynthetic ? stackedAggregateCount : counts.byId[inbox.id]}
isActive={
isStackedSynthetic
? isStackedInboxActive
: currentInbox?.id === inbox.id
}
unreadOnly={!!unreadOnlyByFolder[filterSlug]}
onClearUnreadOnly={() => setUnreadOnly(filterSlug, false)}
onSelect={() => selectInbox(inbox)}
/>
);
})}
<div className="flex shrink-0 items-center">
<HamburgerMenu
align="start"
doneCount={counts.done}
onOpenSettings={() => setSettingsDialogOpen(true)}
/>
</div>
{isSystemFolderSplitRoute && (
<span className="text-foreground flex shrink-0 items-center gap-1 self-stretch whitespace-nowrap text-base font-medium leading-none">
{folderTabName ?? folderFromPath}
{/* Done / Sent / Spam run through the same `useThreads` compiled query, so ⇧U
filters them too and has to say so here — there is no tab to badge. */}
{!!unreadOnlyByFolder[folderFromPath] && (
<UnreadFilterBadge onClear={() => setUnreadOnly(folderFromPath, false)} />
)}
</span>
)}
</div>
) : (
<div className={cn('flex min-w-0 items-stretch self-stretch gap-1 pl-10', grow && 'flex-1')}>
{leading && <div className="flex shrink-0 items-center">{leading}</div>}
<div className="flex shrink-0 items-center">
<HamburgerMenu
align="start"
doneCount={counts.done}
onOpenSettings={() => setSettingsDialogOpen(true)}
/>
</div>
<span className="text-foreground flex shrink-0 items-center gap-1 self-stretch whitespace-nowrap text-base font-medium leading-none">
{folderTabName ?? folderFromPath}
{/* Any folder that reaches the thread list can be filtered, so the badge follows it
here too — a filter with nowhere to show itself looks like missing mail. */}
{!!unreadOnlyByFolder[folderFromPath] && (
<UnreadFilterBadge onClear={() => setUnreadOnly(folderFromPath, false)} />
)}
</span>
</div>
)}
<EditInboxesDialog
open={settingsDialogOpen}
onClose={() => setSettingsDialogOpen(false)}
/>
</>
);
}