InboxDebuggerTab.tsx12.3 KBView on GitHub
import { Check, Copy, ChevronDown, ChevronRight, Search, Inbox, ListChecks } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { useCedarStore } from '@/modules/store';
import {
  inboxSelectionId,
  selectInboxItemsById,
  selectMergedFeed,
} from '@/modules/inbox/store/inboxSlice';
import { cn } from '@/styles/stylingUtils';
import { useMemo, useState } from 'react';
import { JsonTreeView } from './JsonTreeView';

interface InboxDebuggerTabProps {
  onCopy: (text: string, id: string) => void;
  copiedId: string | null;
}

const safeStringify = (obj: unknown, indent = 2): string => {
  try {
    return JSON.stringify(obj, null, indent);
  } catch (error) {
    return `[Error serializing: ${error instanceof Error ? error.message : 'Unknown error'}]`;
  }
};

/** Selection summary — the shared `bulkSelected` (unified ids across channels) +
 *  the single-selected row, so cross-channel selection is inspectable. */
const SelectionSection = ({
  onCopy,
  copiedId,
}: {
  onCopy: (text: string, id: string) => void;
  copiedId: string | null;
}) => {
  const bulkSelected = useCedarStore((s) => s.bulkSelected);
  const selectedThreadId = useCedarStore((s) => s.selectedThreadId);
  const itemsById = useCedarStore(selectInboxItemsById);

  return (
    <div className="border-b border-gray-200 bg-emerald-50/50 dark:border-gray-700 dark:bg-emerald-950/20">
      <div className="flex items-center justify-between p-3">
        <div className="flex items-center gap-2">
          <ListChecks className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
          <span className="font-medium text-emerald-900 dark:text-emerald-100">Selection</span>
          <span className="rounded bg-emerald-200 px-1.5 py-0.5 text-xs dark:bg-emerald-800">
            {bulkSelected.length} bulk
          </span>
        </div>
        <button
          onClick={() => onCopy(safeStringify({ bulkSelected, selectedThreadId }), 'inbox-selection')}
          className="rounded p-0.5 transition-colors hover:bg-emerald-200 dark:hover:bg-emerald-800"
        >
          {copiedId === 'inbox-selection' ? (
            <Check className="h-3 w-3 text-green-600" />
          ) : (
            <Copy className="h-3 w-3" />
          )}
        </button>
      </div>
      <div className="px-3 pb-3 text-xs">
        <div className="mb-1 text-gray-600 dark:text-gray-300">
          selectedThreadId: <code>{selectedThreadId ?? 'null'}</code>
        </div>
        {bulkSelected.length === 0 ? (
          <div className="text-gray-400">no rows selected</div>
        ) : (
          <div className="space-y-0.5">
            {bulkSelected.map((id) => (
              <div key=[redacted] className="flex items-center gap-1.5 font-mono">
                <span className="rounded bg-gray-200 px-1 py-0.5 text-xs uppercase dark:bg-gray-700">
                  {itemsById[id]?.channel ?? '?'}
                </span>
                <span className="truncate">{id}</span>
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
};

/**
 * The merged id list — the render + selection order, as the client composes it. `held` is
 * how many loaded rows sit BELOW the watermark: a large number means one channel is starving
 * the others and `fetchNextPage` is waiting on it.
 */
const OrderSection = ({
  onCopy,
  copiedId,
}: {
  onCopy: (text: string, id: string) => void;
  copiedId: string | null;
}) => {
  const merged = useCedarStore(selectMergedFeed);
  const [isExpanded, setIsExpanded] = useState(false);

  const rows = useMemo(
    () => merged.items.map((item) => ({ id: inboxSelectionId(item), channel: item.channel })),
    [merged.items],
  );
  const byChannel = useMemo(() => {
    const counts: Record<string, number> = {};
    for (const row of rows) counts[row.channel] = (counts[row.channel] ?? 0) + 1;
    return counts;
  }, [rows]);

  return (
    <div className="border-b border-gray-200 bg-blue-50/50 dark:border-gray-700 dark:bg-blue-950/20">
      <div
        className="flex cursor-pointer items-center justify-between p-3 transition-colors hover:bg-blue-100/50 dark:hover:bg-blue-900/30"
        onClick={() => setIsExpanded((v) => !v)}
      >
        <div className="flex items-center gap-2">
          <Inbox className="h-4 w-4 text-blue-600 dark:text-blue-400" />
          <span className="font-medium text-blue-900 dark:text-blue-100">
            Merged order ({rows.length})
          </span>
          {Object.entries(byChannel).map(([ch, n]) => (
            <span key=[redacted] className="rounded bg-blue-200 px-1.5 py-0.5 text-xs dark:bg-blue-800">
              {ch}: {n}
            </span>
          ))}
          {merged.held > 0 && (
            <span className="rounded bg-amber-200 px-1.5 py-0.5 text-xs dark:bg-amber-800">
              held: {merged.held} · gating: {merged.gating.join(', ') || 'none'}
            </span>
          )}
        </div>
        <div className="flex items-center gap-1">
          <button
            onClick={(e) => {
              e.stopPropagation();
              onCopy(safeStringify(rows), 'inbox-order');
            }}
            className="rounded p-0.5 transition-colors hover:bg-blue-200 dark:hover:bg-blue-800"
          >
            {copiedId === 'inbox-order' ? (
              <Check className="h-3 w-3 text-green-600" />
            ) : (
              <Copy className="h-3 w-3" />
            )}
          </button>
          {isExpanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
        </div>
      </div>
      <AnimatePresence>
        {isExpanded && (
          <motion.div
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: 'auto', opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={{ duration: 0.2 }}
            className="overflow-hidden"
          >
            <div className="max-h-[300px] space-y-0.5 overflow-y-auto p-3 pt-0 text-xs">
              {rows.map((row, i) => (
                <div key=[redacted] className="flex items-center gap-1.5 font-mono">
                  <span className="w-6 text-gray-400">{i}</span>
                  <span className="rounded bg-gray-200 px-1 py-0.5 text-xs uppercase dark:bg-gray-700">
                    {row.channel}
                  </span>
                  <span className="truncate">{row.id}</span>
                </div>
              ))}
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
};

/**
 * Inbox slice debugger — the store-backed unified feed (inboxSlice): the merged
 * order, cross-channel selection, and every InboxItem in the map. Mirrors
 * ThreadsDebuggerTab for the email threadSlice.
 */
export const InboxDebuggerTab = ({ onCopy, copiedId }: InboxDebuggerTabProps) => {
  const itemsById = useCedarStore(selectInboxItemsById);
  const [expanded, setExpanded] = useState<Set<string>>(new Set());
  const [searchQuery, setSearchQuery] = useState('');

  const entries = useMemo(() => Object.entries(itemsById), [itemsById]);

  const filtered = useMemo(() => {
    if (!searchQuery) return entries;
    const q = searchQuery.toLowerCase();
    return entries.filter(([id, item]) => {
      if (id.toLowerCase().includes(q)) return true;
      try {
        return JSON.stringify(item).toLowerCase().includes(q);
      } catch {
        return false;
      }
    });
  }, [entries, searchQuery]);

  const toggle = (id: string) =>
    setExpanded((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });

  return (
    <div className="flex h-full flex-col">
      <SelectionSection onCopy={onCopy} copiedId={copiedId} />
      <OrderSection onCopy={onCopy} copiedId={copiedId} />

      {/* Search */}
      <div className="border-b border-gray-200 p-2 dark:border-gray-700">
        <div className="relative">
          <Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-gray-400" />
          <input
            type="text"
            placeholder="Search inbox items..."
            value={searchQuery}
            onChange={(e) => setSearchQuery(e.target.value)}
            className="w-full rounded border border-gray-300 bg-white py-1 pl-7 pr-2 text-xs placeholder:text-gray-400 focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-white"
          />
        </div>
      </div>

      {/* Items */}
      <div className="flex-1 space-y-2 overflow-y-auto p-2">
        {filtered.length === 0 ? (
          <div className="flex h-full items-center justify-center py-8 text-center text-xs text-gray-500 dark:text-gray-400">
            {searchQuery ? 'No items match your search' : 'No inbox items (open /inbox on a channel)'}
          </div>
        ) : (
          filtered.map(([id, item]) => {
            const isExpanded = expanded.has(id);
            return (
              <div
                key=[redacted]
                className="rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-950"
              >
                <div
                  className={cn(
                    'flex cursor-pointer items-center justify-between p-2 transition-colors hover:bg-gray-100 dark:hover:bg-gray-900/80',
                    isExpanded ? 'rounded-t-lg' : 'rounded-lg',
                  )}
                  onClick={() => toggle(id)}
                >
                  <div className="flex flex-1 flex-col gap-1">
                    <div className="flex items-center gap-1.5">
                      <span className="rounded bg-gray-200 px-1 py-0.5 text-xs uppercase dark:bg-gray-700">
                        {item.channel}
                      </span>
                      <span className="font-mono text-xs font-medium">{id}</span>
                    </div>
                    <span className="line-clamp-1 text-xs text-gray-700 dark:text-gray-300">
                      {item.counterpart.name}
                      {item.counterpart.subtitle ? ` · ${item.counterpart.subtitle}` : ''}
                    </span>
                    {item.conversationId && (
                      <span className="font-mono text-xs text-gray-500 dark:text-gray-400">
                        conv: {item.conversationId}
                      </span>
                    )}
                  </div>
                  <div className="flex items-center gap-1">
                    <button
                      onClick={(e) => {
                        e.stopPropagation();
                        onCopy(safeStringify(item), `inbox-${id}`);
                      }}
                      className="rounded p-0.5 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700"
                    >
                      {copiedId === `inbox-${id}` ? (
                        <Check className="h-3 w-3 text-green-600" />
                      ) : (
                        <Copy className="h-3 w-3" />
                      )}
                    </button>
                    {isExpanded ? (
                      <ChevronDown className="h-3 w-3" />
                    ) : (
                      <ChevronRight className="h-3 w-3" />
                    )}
                  </div>
                </div>
                <AnimatePresence>
                  {isExpanded && (
                    <motion.div
                      initial={{ height: 0, opacity: 0 }}
                      animate={{ height: 'auto', opacity: 1 }}
                      exit={{ height: 0, opacity: 0 }}
                      transition={{ duration: 0.2 }}
                      className="overflow-hidden"
                    >
                      <div className="rounded-b-lg p-2 pt-0">
                        <JsonTreeView
                          data={item}
                          onCopy={onCopy}
                          copiedId={copiedId}
                          defaultExpandDepth={2}
                          className="max-h-[500px]"
                        />
                      </div>
                    </motion.div>
                  )}
                </AnimatePresence>
              </div>
            );
          })
        )}
      </div>

      <div className="border-t border-gray-200 p-2 text-center text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400">
        {searchQuery
          ? `${filtered.length} of ${entries.length} items`
          : `${entries.length} inbox item${entries.length !== 1 ? 's' : ''}`}
      </div>
    </div>
  );
};