SplitSettingsCreator.tsx21.9 KBView on GitHub
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { useCallback, useMemo, useState } from 'react';
import { Check, ChevronDown, Plus, Sparkles, Tag, Trash2 } from 'lucide-react';
import { LABEL_COLORS, type LabelColor } from '@/modules/labels/utils/label-colors';
import { cn } from '@/lib/utils';
import {
  InboxLinkedDealsSection,
  type LinkedDealsState,
} from '@/modules/threads/components/InboxLinkedDealsSection';
import { computeFiltersFromConfig } from '@/modules/crm/utils/compute-canvas-filters';
import { toConversationFilterParams } from '@/modules/crm/utils/conversation-filter-params';
import { resolveAops } from '@/modules/crm/hooks/use-aop-filter-columns';
import { mergeCustomFieldDefinitions } from '@/modules/crm/utils/aop-columns';
import type { CanvasFilterSortConfiguration } from '@/modules/cedar-os/src/store/messages/MessageTypes';
import type { InboxConversationFilter } from '@/modules/threads/hooks/use-inboxes';
import { useCedarStore } from '@/modules/store';

/** A user label the split definition can reference. */
export type SplitLabelOption = {
  id: string;
  name: string;
  color?: { backgroundColor: string; textColor: string };
};

export type SplitSettingsDraft = {
  name: string;
  query?: string;
  alsoShowInImportant: boolean;
  hideWhenEmpty: boolean;
  position: number;
  /**
   * The CRM rule from the "Linked deals" section. `null` on save means the
   * user cleared it — `undefined` would read as "leave whatever is stored".
   */
  conversationFilter?: InboxConversationFilter | null;
};

type SplitSettingsCreatorProps = {
  draft?: Partial<SplitSettingsDraft>;
  /** Label for the "also show in" target — e.g. "inbox" or "Important / Other". */
  alsoShowTargetLabel?: string;
  /**
   * Optional callback fired immediately when the user toggles the "Also show in"
   * switch. Lets the parent persist the change to the server without waiting
   * for the user to click Save (matches the template-card UX where each
   * checkbox click is its own save). When omitted, the switch only updates
   * local form state.
   */
  onAlsoShowInImportantChange?: (value: boolean) => void;
  /** Existing user labels offered in the "Add label" picker. */
  labels?: SplitLabelOption[];
  /** Create a plain Gmail label. Omit to hide the "create label" affordance. */
  onCreateLabel?: (input: { name: string; color: LabelColor }) => Promise<void>;
  /**
   * Create a Cedar AI label from a name + agent instructions. Returns the
   * resolved label name (e.g. `Cedar/AI/recruiting`) so it can be added to the
   * definition. Omit to hide the "create AI label" affordance.
   */
  onCreateAiLabel?: (input: {
    displayName: string;
    description: string;
  }) => Promise<{ labelName: string }>;
  /** Permanently delete a label. Omit to hide the per-label delete affordance. */
  onDeleteLabel?: (label: SplitLabelOption) => Promise<void>;
  onBack: () => void;
  onDelete?: () => void;
  onSave: (draft: SplitSettingsDraft) => void;
};

const DEFAULT_QUERY = 'label:INBOX';

/**
 * Build the Gmail-style search token for a label. Names with whitespace must be
 * quoted so Gmail treats them as a single label rather than two AND'd terms.
 */
function labelToken(name: string): string {
  return /\s/.test(name) ? `label:"${name}"` : `label:${name}`;
}

/** Remove a label's `label:` clause from the definition (used when it's deleted). */
function removeLabelFromQuery(query: string | undefined, labelName: string): string {
  const token=[redacted];
  const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  return (query ?? '')
    .replace(new RegExp(`(^|\\s)${escaped}(?=\\s|$)`, 'g'), '$1')
    .replace(/\s+/g, ' ')
    .trim();
}

/**
 * Reopen a stored rule as editable state. `uiConfig` is the round-trip copy of
 * the per-column config; the AOP scope comes back off the resolved filters,
 * which is the only place it is stored.
 */
function linkedDealsFromFilter(
  filter: InboxConversationFilter | null | undefined,
): LinkedDealsState {
  const aopIds = filter?.filters?.aopIds;
  return {
    aopIds: Array.isArray(aopIds) ? (aopIds as (string | null)[]) : [],
    config: (filter?.uiConfig as CanvasFilterSortConfiguration | undefined) ?? {},
  };
}

/** Append a `label:` clause to the definition, ignoring the throwaway default. */
function appendLabelToQuery(query: string | undefined, labelName: string): string {
  const token=[redacted];
  const trimmed = (query ?? '').trim();
  if (!trimmed || trimmed === DEFAULT_QUERY) return token;
  if (trimmed.includes(token)) return trimmed;
  return `${trimmed} ${token}`;
}

export function SplitSettingsCreator({
  draft,
  alsoShowTargetLabel = 'inbox',
  labels = [],
  onCreateLabel,
  onCreateAiLabel,
  onDeleteLabel,
  onAlsoShowInImportantChange,
  onBack,
  onDelete,
  onSave,
}: SplitSettingsCreatorProps) {
  const [form, setForm] = useState<SplitSettingsDraft>({
    name: draft?.name ?? '',
    query: draft?.query ?? undefined,
    alsoShowInImportant: draft?.alsoShowInImportant ?? false,
    hideWhenEmpty: draft?.hideWhenEmpty ?? false,
    position: draft?.position ?? 0,
  });

  // The CRM rule is edited as its two live halves — the AOP scope and the
  // per-column config — and only assembled into the stored shape on save.
  const [linkedDeals, setLinkedDeals] = useState<LinkedDealsState>(() =>
    linkedDealsFromFilter(draft?.conversationFilter),
  );
  const aopsById = useCedarStore((state) => state.aopsById);

  const canSave = useMemo(() => form.name.trim().length > 0, [form.name]);

  const hasConversationFilter = linkedDeals.aopIds.length > 0;

  /**
   * Attaching a CRM rule flips "also show in inbox" on. A filtered inbox's
   * query is a bare `label:INBOX` scope anchor, so treating it as a partition
   * would carve the whole Inbox out of the default tab. The switch stays
   * user-editable — the exclusion builder skips filtered inboxes regardless.
   */
  const handleLinkedDealsChange = useCallback(
    (next: LinkedDealsState) => {
      if (linkedDeals.aopIds.length === 0 && next.aopIds.length > 0) {
        setForm((current) => ({ ...current, alsoShowInImportant: true }));
      }
      setLinkedDeals(next);
    },
    [linkedDeals.aopIds.length],
  );

  /**
   * Assemble what the server stores: the resolved rule under `filters`, plus
   * the raw per-column state under `uiConfig` so reopening this form
   * round-trips exactly what the user picked (the resolved rule is lossy —
   * relative dates have already been flattened to absolute ISO strings).
   */
  const buildConversationFilter = useCallback((): InboxConversationFilter | null => {
    if (!hasConversationFilter) return null;
    const customFieldDefinitions = mergeCustomFieldDefinitions(
      resolveAops(linkedDeals.aopIds, aopsById),
    );
    const { filters, customFieldFilters } = computeFiltersFromConfig(
      linkedDeals.config,
      undefined,
      customFieldDefinitions,
    );
    return {
      filters: toConversationFilterParams(filters, {
        aopIds: linkedDeals.aopIds,
        customFieldFilters,
      }),
      uiConfig: linkedDeals.config,
    };
  }, [hasConversationFilter, linkedDeals, aopsById]);

  const [panel, setPanel] = useState<'none' | 'add' | 'create'>('none');
  const [labelSearch, setLabelSearch] = useState('');
  const [newLabelName, setNewLabelName] = useState('');
  const [newLabelColor, setNewLabelColor] = useState<LabelColor>(LABEL_COLORS[0]);
  const [createMode, setCreateMode] = useState<'label' | 'ai'>('label');
  const [aiInstructions, setAiInstructions] = useState('');
  const [isCreating, setIsCreating] = useState(false);
  const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
  const [isDeleting, setIsDeleting] = useState(false);

  const canCreate = !!onCreateLabel || !!onCreateAiLabel;

  const filteredLabels = useMemo(() => {
    const term = labelSearch.trim().toLowerCase();
    if (!term) return labels;
    return labels.filter((label) => label.name.toLowerCase().includes(term));
  }, [labels, labelSearch]);

  const resetPanels = () => {
    setPanel('none');
    setLabelSearch('');
    setNewLabelName('');
    setAiInstructions('');
    setCreateMode(onCreateLabel ? 'label' : 'ai');
  };

  const togglePanel = (next: 'add' | 'create') => {
    setPanel((current) => (current === next ? 'none' : next));
    if (next === 'create') setCreateMode(onCreateLabel ? 'label' : 'ai');
  };

  const addLabelToken=[redacted] string) => {
    setForm((current) => ({ ...current, query: appendLabelToQuery(current.query, labelName) }));
    resetPanels();
  };

  const handleCreateLabel = async () => {
    const name = newLabelName.trim();
    if (!name || !onCreateLabel || isCreating) return;
    setIsCreating(true);
    try {
      await onCreateLabel({ name, color: newLabelColor });
      addLabelToken(name);
    } finally {
      setIsCreating(false);
    }
  };

  const handleCreateAiLabel = async () => {
    const displayName = newLabelName.trim();
    const description = aiInstructions.trim();
    if (!displayName || !description || !onCreateAiLabel || isCreating) return;
    setIsCreating(true);
    try {
      const { labelName } = await onCreateAiLabel({ displayName, description });
      addLabelToken(labelName);
    } finally {
      setIsCreating(false);
    }
  };

  const handleDeleteLabel = async (label: SplitLabelOption) => {
    if (!onDeleteLabel || isDeleting) return;
    setIsDeleting(true);
    try {
      await onDeleteLabel(label);
      // Drop any dangling reference to the now-deleted label from the definition.
      setForm((current) => ({ ...current, query: removeLabelFromQuery(current.query, label.name) }));
      setConfirmDeleteId(null);
    } catch {
      // The parent surfaces the failure toast; keep the confirm row for retry.
    } finally {
      setIsDeleting(false);
    }
  };

  return (
    // The dialog is sized for the template gallery (6xl); a form is not. Constrain
    // to a readable column and centre it, so fields aren't stretched to ~1150px and
    // the filter rows below keep their popovers anchored well inside the viewport.
    <div className="min-h-0 flex-1 overflow-y-auto py-2 pr-1">
      <div className="mx-auto w-full max-w-2xl space-y-4">
      <div className="space-y-2">
        <Label>Name</Label>
        <Input
          value={form.name}
          placeholder="Important"
          onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
        />
      </div>

      <div className="space-y-2">
        <Label>Definition</Label>
        <Textarea
          value={form.query ?? ''}
          placeholder={DEFAULT_QUERY}
          rows={2}
          className="font-mono text-xs"
          onChange={(event) => setForm((current) => ({ ...current, query: event.target.value }))}
        />
        <p className="text-muted-foreground text-xs">
          A Gmail-style search query that decides which threads land in this split — e.g.{' '}
          <code className="rounded bg-muted px-1 py-0.5">from:<email></code> or{' '}
          <code className="rounded bg-muted px-1 py-0.5">label:Receipts is:unread</code>.
        </p>
        {hasConversationFilter && (
          <p className="text-muted-foreground text-xs">
            This inbox also has a Linked deals rule. Keep the definition as{' '}
            <code className="rounded bg-muted px-1 py-0.5">{DEFAULT_QUERY}</code> — the CRM rule
            below does the narrowing, and the two are AND-ed. Extra Gmail clauses here narrow it
            further, they never widen it.
          </p>
        )}
      </div>

      <InboxLinkedDealsSection value={linkedDeals} onChange={handleLinkedDealsChange} />

      <div className="space-y-2">
        <div className="flex items-center justify-between">
          <Label>Labels</Label>
          <div className="flex items-center gap-2">
            <Button
              type="button"
              size="sm"
              variant="outline"
              className="h-7 text-xs"
              onClick={() => togglePanel('add')}
            >
              {panel === 'add' ? (
                <ChevronDown className="h-3.5 w-3.5" />
              ) : (
                <Plus className="h-3.5 w-3.5" />
              )}
              Add label
            </Button>
            {canCreate && (
              <Button
                type="button"
                size="sm"
                variant="outline"
                className="h-7 text-xs"
                onClick={() => togglePanel('create')}
              >
                {panel === 'create' ? (
                  <ChevronDown className="h-3.5 w-3.5" />
                ) : (
                  <Tag className="h-3.5 w-3.5" />
                )}
                Create label
              </Button>
            )}
          </div>
        </div>
        <p className="text-muted-foreground text-xs">
          Add an existing label, or create a new one — including an AI label with instructions for
          the agent to apply. The chosen label is added to the definition above.
        </p>

        {panel === 'add' && (
          <div className="bg-muted/20 space-y-2 rounded-md border p-2">
            <Input
              autoFocus
              value={labelSearch}
              onChange={(event) => setLabelSearch(event.target.value)}
              placeholder="Search labels…"
              className="h-8 text-xs"
            />
            <div className="max-h-44 space-y-0.5 overflow-y-auto">
              {filteredLabels.map((label) =>
                confirmDeleteId === label.id ? (
                  <div
                    key=[redacted]
                    className="flex w-full items-center gap-2 rounded-md bg-destructive/10 px-2 py-1.5 text-xs"
                  >
                    <span className="text-destructive truncate">Delete “{label.name}”?</span>
                    <div className="ml-auto flex items-center gap-1">
                      <Button
                        type="button"
                        size="sm"
                        variant="ghost"
                        className="h-6 px-2 text-xs"
                        disabled={isDeleting}
                        onClick={() => setConfirmDeleteId(null)}
                      >
                        Cancel
                      </Button>
                      <Button
                        type="button"
                        size="sm"
                        variant="destructive"
                        className="h-6 px-2 text-xs"
                        disabled={isDeleting}
                        onClick={() => handleDeleteLabel(label)}
                      >
                        Delete
                      </Button>
                    </div>
                  </div>
                ) : (
                  <div
                    key=[redacted]
                    className="group hover:bg-muted flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs"
                  >
                    <button
                      type="button"
                      onClick={() => addLabelToken(label.name)}
                      className="flex min-w-0 flex-1 items-center gap-2 text-left"
                    >
                      <span
                        className="h-2.5 w-2.5 shrink-0 rounded-full"
                        style={{ backgroundColor: label.color?.backgroundColor || '#888' }}
                      />
                      <span className="truncate">{label.name}</span>
                    </button>
                    {onDeleteLabel && (
                      <button
                        type="button"
                        aria-label={`Delete ${label.name}`}
                        onClick={() => setConfirmDeleteId(label.id)}
                        className="text-muted-foreground hover:text-destructive shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
                      >
                        <Trash2 className="h-3.5 w-3.5" />
                      </button>
                    )}
                  </div>
                ),
              )}
              {filteredLabels.length === 0 && (
                <p className="text-muted-foreground px-2 py-1.5 text-xs">No matching labels.</p>
              )}
            </div>
          </div>
        )}

        {panel === 'create' && canCreate && (
          <div className="bg-muted/20 space-y-2 rounded-md border p-2">
            {onCreateLabel && onCreateAiLabel && (
              <div className="bg-muted flex items-center gap-1 rounded-md p-0.5">
                <button
                  type="button"
                  onClick={() => setCreateMode('label')}
                  className={cn(
                    'flex flex-1 items-center justify-center gap-1 rounded px-2 py-1 text-xs',
                    createMode === 'label' && 'bg-background shadow-sm',
                  )}
                >
                  <Tag className="h-3.5 w-3.5" />
                  Label
                </button>
                <button
                  type="button"
                  onClick={() => setCreateMode('ai')}
                  className={cn(
                    'flex flex-1 items-center justify-center gap-1 rounded px-2 py-1 text-xs',
                    createMode === 'ai' && 'bg-background shadow-sm',
                  )}
                >
                  <Sparkles className="h-3.5 w-3.5" />
                  AI label
                </button>
              </div>
            )}

            <Input
              autoFocus
              value={newLabelName}
              onChange={(event) => setNewLabelName(event.target.value)}
              placeholder="Label name"
              className="h-8 text-xs"
            />

            {onCreateLabel && (!onCreateAiLabel || createMode === 'label') ? (
              <>
                <div className="flex items-center gap-1.5">
                  {LABEL_COLORS.map((color) => (
                    <button
                      key=[redacted]
                      type="button"
                      onClick={() => setNewLabelColor(color)}
                      className="flex h-5 w-5 items-center justify-center rounded-full"
                      style={{ backgroundColor: color.backgroundColor }}
                      aria-label={`Color ${color.backgroundColor}`}
                    >
                      {newLabelColor.backgroundColor === color.backgroundColor && (
                        <Check className="h-3 w-3" style={{ color: color.textColor }} />
                      )}
                    </button>
                  ))}
                </div>
                <Button
                  type="button"
                  size="sm"
                  variant="outline"
                  className="h-8 w-full text-xs"
                  disabled={isCreating || newLabelName.trim().length === 0}
                  onClick={handleCreateLabel}
                >
                  <Tag className="h-3.5 w-3.5" />
                  Create label
                </Button>
              </>
            ) : (
              <>
                <Textarea
                  value={aiInstructions}
                  onChange={(event) => setAiInstructions(event.target.value)}
                  rows={3}
                  placeholder="Instructions for the agent — what should this label catch? e.g. “Emails about job applications or recruiting outreach.”"
                  className="text-xs"
                />
                <Button
                  type="button"
                  size="sm"
                  variant="outline"
                  className="h-8 w-full text-xs"
                  disabled={
                    isCreating ||
                    newLabelName.trim().length === 0 ||
                    aiInstructions.trim().length === 0
                  }
                  onClick={handleCreateAiLabel}
                >
                  <Sparkles className="h-3.5 w-3.5" />
                  Create AI label
                </Button>
              </>
            )}
          </div>
        )}
      </div>

      <div className="flex items-center justify-between rounded-md border p-3">
        <div>
          <p className="text-sm font-medium">Hide when empty</p>
          <p className="text-muted-foreground text-xs">Do not show this tab when there are no matching threads.</p>
        </div>
        <Switch
          checked={form.hideWhenEmpty}
          onCheckedChange={(value) => setForm((current) => ({ ...current, hideWhenEmpty: value }))}
        />
      </div>

      <div className="flex items-center justify-between rounded-md border p-3">
        <div>
          <p className="text-sm font-medium">{`Also show in ${alsoShowTargetLabel}`}</p>
          <p className="text-muted-foreground text-xs">
            Threads in this split will also appear in the default {alsoShowTargetLabel} view.
          </p>
        </div>
        <Switch
          checked={form.alsoShowInImportant}
          onCheckedChange={(value) => {
            setForm((current) => ({ ...current, alsoShowInImportant: value }));
            // Fire immediately so the toggle persists without waiting for Save.
            onAlsoShowInImportantChange?.(value);
          }}
        />
      </div>

      <div className="flex items-center justify-between">
        <Button variant="ghost" size="sm" onClick={onBack}>
          Back
        </Button>
        <div className="flex items-center gap-2">
          {onDelete && (
            <Button variant="destructive" size="sm" onClick={onDelete}>
              Delete
            </Button>
          )}
          <Button
            size="sm"
            disabled={!canSave}
            onClick={() =>
              onSave({
                ...form,
                name: form.name.trim(),
                query: form.query?.trim() || DEFAULT_QUERY,
                conversationFilter: buildConversationFilter(),
              })
            }
          >
            Save split
          </Button>
        </div>
      </div>
      </div>
    </div>
  );
}