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&apos;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)}
      />
    </>
  );
}