AgentCanvasDebuggerTab.tsx28.1 KBView on GitHub
import { Check, Copy, ChevronDown, ChevronRight, AlertCircle, Eye, EyeOff } from 'lucide-react';
import { useGlobalCanvasPresets } from '@/modules/crm/hooks/use-global-canvas-presets';
import { useCedarStore } from '@/modules/store';
import { motion, AnimatePresence } from 'motion/react';
import { cn } from '@/styles/stylingUtils';
import React, { useState } from 'react';
import { JsonTreeView } from './JsonTreeView';

// ─── helpers ──────────────────────────────────────────────────────────────────

function safeStringify(obj: unknown, indent = 2): string {
    try {
      return JSON.stringify(obj, null, indent);
  } catch (e) {
    return `[Error serializing: ${e instanceof Error ? e.message : String(e)}]`;
  }
}

const COLOR = {
  blue:   'bg-blue-50   dark:bg-blue-950   border-blue-200   dark:border-blue-800',
      purple: 'bg-purple-50 dark:bg-purple-950 border-purple-200 dark:border-purple-800',
  green:  'bg-green-50  dark:bg-green-950  border-green-200  dark:border-green-800',
  amber:  'bg-amber-50  dark:bg-amber-950  border-amber-200  dark:border-amber-800',
  cyan:   'bg-cyan-50   dark:bg-cyan-950   border-cyan-200   dark:border-cyan-800',
      orange: 'bg-orange-50 dark:bg-orange-950 border-orange-200 dark:border-orange-800',
  rose:   'bg-rose-50   dark:bg-rose-950   border-rose-200   dark:border-rose-800',
  gray:   'bg-gray-50   dark:bg-gray-900   border-gray-200   dark:border-gray-700',
} as const;

type ColorKey=[redacted] typeof COLOR;

// ─── sub-components ───────────────────────────────────────────────────────────

function CompactRow({
  label,
  value,
  color = 'blue',
  mono = false,
  onCopy,
  copiedId,
  id,
}: {
  label: string;
  value: string | null | undefined;
  color?: ColorKey;
  mono?: boolean;
  onCopy: (text: string, id: string) => void;
  copiedId: string | null;
  id: string;
}) {
  const has = !!value;
    return (
      <div
      onClick={() => has && onCopy(value!, id)}
        className={cn(
          'flex cursor-pointer items-center justify-between gap-2 rounded-lg border p-2 transition-colors',
        has && 'hover:bg-white/50 dark:hover:bg-black/20',
        COLOR[color],
        )}
      >
        <div className="flex min-w-0 flex-1 items-center gap-2">
        <span className="shrink-0 text-xs font-medium text-gray-700 dark:text-gray-300">{label}:</span>
        {has ? (
          <span className={cn('min-w-0 truncate text-xs text-gray-900 dark:text-gray-100', mono && 'font-mono')}>
              {value}
            </span>
          ) : (
          <span className="text-xs text-gray-400 dark:text-gray-500">—</span>
          )}
        </div>
      {has && (
          <div className="shrink-0 rounded p-0.5">
          {copiedId === id ? <Check className="h-3 w-3 text-green-600" /> : <Copy className="h-3 w-3 opacity-40" />}
          </div>
        )}
      </div>
    );
}

function Section({
  id,
  title,
  badge,
  color = 'blue',
  expanded,
  onToggle,
  children,
  empty,
}: {
  id: string;
  title: string;
  badge?: string | number;
  color?: ColorKey;
  expanded: boolean;
  onToggle: (id: string) => void;
  children: React.ReactNode;
  empty?: boolean;
}) {
    return (
    <div className={cn('rounded-lg border transition-all', COLOR[color])}>
      <div
        className={cn(
          'flex cursor-pointer items-center justify-between p-2 transition-colors',
          expanded ? 'rounded-t-lg' : 'rounded-lg',
          `hover:brightness-95`,
        )}
        onClick={() => onToggle(id)}
      >
        <div className="flex flex-1 items-center gap-1.5 min-w-0">
          <span className="text-xs font-semibold truncate">{title}</span>
          {badge !== undefined && (
            <span className="shrink-0 rounded-full bg-black/10 px-1.5 py-0.5 text-xs dark:bg-white/10">
              {badge}
            </span>
          )}
          {empty && (
            <span className="shrink-0 rounded bg-gray-200 px-1.5 py-0.5 text-xs dark:bg-gray-700 text-gray-500">
              empty
              </span>
            )}
          </div>
        {expanded ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
      </div>
      <AnimatePresence>
        {expanded && (
          <motion.div
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: 'auto', opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={{ duration: 0.15 }}
            className="overflow-hidden"
          >
            <div className="rounded-b-lg p-2 pt-0">{children}</div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

function JsonBlock({
  content,
  onCopy,
  copiedId,
}: {
  id?: string;
  content: string | null;
  onCopy: (text: string, id: string) => void;
  copiedId: string | null;
}) {
  if (!content) return <p className="text-xs text-gray-400 dark:text-gray-500 py-1">No data</p>;
  let parsed: unknown = content;
  try { parsed = JSON.parse(content); } catch { /* keep as string */ }
  return (
    <JsonTreeView data={parsed} onCopy={onCopy} copiedId={copiedId} defaultExpandDepth={2} className="max-h-64" />
  );
}

// Per-conversation entry row
function EntryRow({
  convId,
  entry,
  onCopy,
  copiedId,
}: {
  convId: string;
  entry: {
    agentProcessing?: { state: string; message?: string; isActive: boolean; columns?: unknown[] };
    reasoning?: { isStreaming: boolean; text: string };
    pendingDraft?: { body: string; subject?: string; reviewStatus?: string };
    pendingAnalysis?: { content: string; isStreaming: boolean };
  };
  onCopy: (text: string, id: string) => void;
  copiedId: string | null;
}) {
  const [open, setOpen] = useState(false);
  const { agentProcessing, reasoning, pendingDraft, pendingAnalysis } = entry;

  const stateColor = (() => {
    if (!agentProcessing) return 'text-gray-400';
    switch (agentProcessing.state) {
      case 'complete': return 'text-green-600 dark:text-green-400';
      case 'error':    return 'text-red-600   dark:text-red-400';
      case 'skipped':  return 'text-gray-400';
      case 'running':
      case 'processing':
      case 'scanning': return 'text-blue-600 dark:text-blue-400';
      default:         return 'text-gray-500';
    }
  })();

  return (
    <div className="rounded border border-gray-200 dark:border-gray-700 bg-white/40 dark:bg-black/20 text-xs">
      {/* Header row */}
      <div
        className="flex cursor-pointer items-center gap-2 px-2 py-1.5 hover:bg-white/50 dark:hover:bg-black/10"
        onClick={() => setOpen((v) => !v)}
      >
        {open ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
        <span className="font-mono text-gray-500 truncate" style={{ maxWidth: 80 }}>
          {convId.slice(0, 8)}…
        </span>
        {agentProcessing && (
          <span className={cn('font-medium', stateColor)}>
            {agentProcessing.state}
            {agentProcessing.isActive && (
              <span className="ml-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
            )}
          </span>
        )}
        {agentProcessing?.message && (
          <span className="truncate text-gray-400 italic">{agentProcessing.message}</span>
        )}
        {pendingDraft && (
          <span className={cn(
            'ml-auto shrink-0 rounded px-1 py-0.5 font-medium',
            pendingDraft.reviewStatus === 'pending'  && 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
            pendingDraft.reviewStatus === 'sent'     && 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
            pendingDraft.reviewStatus === 'rejected' && 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
          )}>
            draft:{pendingDraft.reviewStatus ?? 'none'}
          </span>
        )}
        {reasoning?.isStreaming && (
          <span className="ml-auto shrink-0 rounded bg-cyan-100 px-1 py-0.5 font-medium text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300">
            reasoning…
          </span>
        )}
        </div>

      {/* Expanded detail */}
        <AnimatePresence>
        {open && (
            <motion.div
              initial={{ height: 0, opacity: 0 }}
              animate={{ height: 'auto', opacity: 1 }}
              exit={{ height: 0, opacity: 0 }}
            transition={{ duration: 0.12 }}
              className="overflow-hidden"
            >
            <div className="space-y-1.5 border-t border-gray-100 dark:border-gray-800 px-2 py-1.5">
              {/* Full conv ID */}
              <div className="flex items-center gap-1">
                <span className="text-gray-400 shrink-0">convId:</span>
                <span
                  className="font-mono text-gray-600 dark:text-gray-300 truncate cursor-pointer hover:text-blue-600"
                  onClick={() => onCopy(convId, `conv-${convId}`)}
                >
                  {convId}
                </span>
              </div>

              {/* Agent processing */}
              {agentProcessing && (
                <JsonBlock
                  id={`ap-${convId}`}
                  content={safeStringify({ state: agentProcessing.state, message: agentProcessing.message, isActive: agentProcessing.isActive, columns: agentProcessing.columns })}
                  onCopy={onCopy}
                  copiedId={copiedId}
                />
              )}

              {/* Pending draft */}
              {pendingDraft && (
                <div className="rounded bg-amber-50 dark:bg-amber-950/30 border border-amber-100 dark:border-amber-900 p-1.5">
                  <div className="mb-1 flex items-center justify-between">
                    <span className="font-semibold text-amber-700 dark:text-amber-400">Draft</span>
                    <span className={cn(
                      'rounded px-1 py-0.5 text-xs font-medium',
                      pendingDraft.reviewStatus === 'pending'  && 'bg-amber-200 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
                      pendingDraft.reviewStatus === 'sent'     && 'bg-green-200 text-green-800 dark:bg-green-900 dark:text-green-200',
                      pendingDraft.reviewStatus === 'rejected' && 'bg-red-200 text-red-800 dark:bg-red-900 dark:text-red-200',
                    )}>
                      {pendingDraft.reviewStatus ?? '—'}
                    </span>
                  </div>
                  {pendingDraft.subject && (
                    <p className="text-gray-600 dark:text-gray-300 truncate">
                      <span className="text-gray-400">Subject:</span> {pendingDraft.subject}
                    </p>
                  )}
                  <p className="mt-0.5 line-clamp-3 text-gray-600 dark:text-gray-300">
                    {pendingDraft.body.slice(0, 300)}
                    {pendingDraft.body.length > 300 && '…'}
                  </p>
                </div>
              )}

              {/* Reasoning */}
              {reasoning && (
                <div className="rounded bg-cyan-50 dark:bg-cyan-950/30 border border-cyan-100 dark:border-cyan-900 p-1.5">
                  <div className="mb-0.5 flex items-center gap-1.5">
                    <span className="font-semibold text-cyan-700 dark:text-cyan-400">Reasoning</span>
                    {reasoning.isStreaming && (
                      <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-cyan-500" />
                    )}
                  </div>
                  <p className="line-clamp-4 text-gray-600 dark:text-gray-300">{reasoning.text || '…'}</p>
                </div>
              )}

              {/* Analysis */}
              {pendingAnalysis && (
                <div className="rounded bg-purple-50 dark:bg-purple-950/30 border border-purple-100 dark:border-purple-900 p-1.5">
                  <div className="mb-0.5 flex items-center gap-1.5">
                    <span className="font-semibold text-purple-700 dark:text-purple-400">Analysis</span>
                    {pendingAnalysis.isStreaming && (
                      <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-purple-500" />
                    )}
                  </div>
                  <p className="line-clamp-4 text-gray-600 dark:text-gray-300">
                    {pendingAnalysis.content.slice(0, 300) || '…'}
                  </p>
                </div>
              )}
              </div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    );
}

// ─── main component ───────────────────────────────────────────────────────────

export const AgentCanvasDebuggerTab: React.FC = () => {
  const [expanded, setExpanded] = useState<Set<string>>(new Set(['globalCanvas', 'homeCanvas', 'entries', 'globalPresets']));
  const [copiedId, setCopiedId] = useState<string | null>(null);
  const [showAllEntries, setShowAllEntries] = useState(false);

  const onCopy = (text: string, id: string) => {
    void navigator.clipboard.writeText(text);
    setCopiedId(id);
    setTimeout(() => setCopiedId(null), 1500);
  };

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

  // ── Global Presets ────────────────────────────────────────────────────────
  const { globalPresets, hasGlobalPresets } = useGlobalCanvasPresets();

  // ── Global Canvas overlay state ──────────────────────────────────────────
  // There is no separate `globalCanvasId`: the overlay shows `activeCanvasId` while
  // `isGlobalCanvasOpen` is true (see uxSlice).
  const isGlobalCanvasOpen = useCedarStore((s) => s.isGlobalCanvasOpen);
  const activeCanvasId = useCedarStore((s) => s.activeCanvasId);
  const globalCanvasId = isGlobalCanvasOpen ? activeCanvasId : null;
  const canvasesById = useCedarStore((s) => s.canvasesById);
  const ephemeralCanvasIds = useCedarStore((s) => s.ephemeralCanvasIds);

  const globalCanvasEntity = globalCanvasId ? canvasesById[globalCanvasId] : null;
  const globalCanvasKind = globalCanvasEntity
    ? (ephemeralCanvasIds.has(globalCanvasId!) ? 'ephemeral-entity' : 'persisted-entity')
    : globalCanvasId === 'calendar' ? 'calendar'
    : 'none';

  // ── HomeView canvas state ─────────────────────────────────────────────────
  const homeCanvasId = useCedarStore((s) => s.activeCanvasId);
  const homeCanvas = useCedarStore((s) =>
    s.activeCanvasId ? s.canvasesById[s.activeCanvasId] : undefined,
  );
  const homeCanvasUIState = useCedarStore((s) =>
    s.activeCanvasId ? s.canvasUIState[s.activeCanvasId] : undefined,
  );

  // ── Effective canvas (for entries / run state) ────────────────────────────
  const effectiveCanvasId = homeCanvasId ?? (globalCanvasEntity ? globalCanvasId : null);
  const effectiveCanvas = effectiveCanvasId ? canvasesById[effectiveCanvasId] : undefined;
  const agentRunState = effectiveCanvas?.data?.agentRunState;
  const conversationEntries = effectiveCanvas?.data?.conversationEntries ?? {};
  const entryIds = Object.keys(conversationEntries);

  // Summary counts
  const totalEntries   = entryIds.length;
  const activeEntries  = Object.values(conversationEntries).filter((e) => e.agentProcessing?.isActive).length;
  const draftPending   = Object.values(conversationEntries).filter((e) => e.pendingDraft?.reviewStatus === 'pending').length;
  const draftSent      = Object.values(conversationEntries).filter((e) => e.pendingDraft?.reviewStatus === 'sent').length;
  const reasoningCount = Object.values(conversationEntries).filter((e) => e.reasoning).length;
  const analysisCount  = Object.values(conversationEntries).filter((e) => e.pendingAnalysis).length;

  const ENTRY_PREVIEW = 10;
  const visibleEntryIds = showAllEntries ? entryIds : entryIds.slice(0, ENTRY_PREVIEW);

  return (
    <div className="h-full space-y-2 overflow-y-auto p-2 text-xs">

      {/* ── Section 1: Global Canvas overlay ───────────────────────── */}
      <Section
        id="globalCanvas"
        title="Global Canvas Overlay"
        badge={isGlobalCanvasOpen ? 'OPEN' : 'closed'}
        color={isGlobalCanvasOpen ? 'green' : 'gray'}
        expanded={expanded.has('globalCanvas')}
        onToggle={toggle}
      >
        <div className="space-y-1">
          <CompactRow id="gc-id"    label="globalCanvasId"    value={globalCanvasId}                 color="green" mono   onCopy={onCopy} copiedId={copiedId} />
          <CompactRow id="gc-open"  label="isGlobalCanvasOpen" value={String(isGlobalCanvasOpen)}    color="green"        onCopy={onCopy} copiedId={copiedId} />
          <CompactRow id="gc-kind"  label="kind"              value={globalCanvasKind}               color="green"        onCopy={onCopy} copiedId={copiedId} />
          {globalCanvasEntity && (
            <>
              <CompactRow id="gc-title" label="title" value={globalCanvasEntity.title}              color="green"        onCopy={onCopy} copiedId={copiedId} />
              <CompactRow id="gc-type"  label="type"  value={globalCanvasEntity.type}               color="green"        onCopy={onCopy} copiedId={copiedId} />
            </>
          )}
          {globalCanvasEntity && (
            <JsonBlock id="gc-viewconfig" content={safeStringify(globalCanvasEntity.viewConfig)} onCopy={onCopy} copiedId={copiedId} />
          )}
      </div>
      </Section>

      {/* ── Section 2: HomeView canvas tab ─────────────────────────── */}
      <Section
        id="homeCanvas"
        title="HomeView Canvas Tab"
        badge={homeCanvasId ? homeCanvas?.title ?? homeCanvasId.slice(0, 8) : undefined}
        color={homeCanvasId ? 'blue' : 'gray'}
        expanded={expanded.has('homeCanvas')}
        onToggle={toggle}
        empty={!homeCanvasId}
      >
        {homeCanvasId ? (
          <div className="space-y-1">
            <CompactRow id="hc-id"    label="id"    value={homeCanvasId}           color="blue" mono onCopy={onCopy} copiedId={copiedId} />
            <CompactRow id="hc-title" label="title" value={homeCanvas?.title}      color="blue"      onCopy={onCopy} copiedId={copiedId} />
            <CompactRow id="hc-type"  label="type"  value={homeCanvas?.type}       color="blue"      onCopy={onCopy} copiedId={copiedId} />
            <CompactRow id="hc-dr"    label="draftReview"
              value={homeCanvasUIState?.draftReview?.isOpen
                ? `open (idx ${homeCanvasUIState.draftReview.currentIndex})`
                : 'closed'}
              color="amber" onCopy={onCopy} copiedId={copiedId} />
            <CompactRow id="hc-ar"    label="analysisReview"
              value={homeCanvasUIState?.analysisReview?.isOpen
                ? `open (idx ${homeCanvasUIState.analysisReview.currentIndex})`
                : 'closed'}
              color="cyan" onCopy={onCopy} copiedId={copiedId} />
            <JsonBlock id="hc-viewconfig" content={safeStringify(homeCanvas?.viewConfig)} onCopy={onCopy} copiedId={copiedId} />
          </div>
        ) : (
          <p className="py-1 text-gray-400">No HomeView canvas active</p>
        )}
      </Section>

      {/* ── Section 3: Agent Run State ─────────────────────────────── */}
      <Section
        id="runState"
        title="Agent Run State"
        badge={agentRunState?.status}
        color={
          agentRunState?.status === 'running'   ? 'blue'
          : agentRunState?.status === 'complete' ? 'green'
          : agentRunState?.status === 'error'    ? 'rose'
          : 'gray'
        }
        expanded={expanded.has('runState')}
        onToggle={toggle}
        empty={!agentRunState}
      >
        {agentRunState ? (
          <div className="space-y-1">
            <CompactRow id="rs-title"  label="title"     value={agentRunState.title}              color="purple" onCopy={onCopy} copiedId={copiedId} />
            <CompactRow id="rs-status" label="status"    value={agentRunState.status}             color="purple" onCopy={onCopy} copiedId={copiedId} />
            <CompactRow id="rs-start"  label="startedAt" value={agentRunState.startedAt}          color="purple" onCopy={onCopy} copiedId={copiedId} />
          </div>
        ) : (
          <p className="py-1 text-gray-400">No run state (canvas idle)</p>
        )}
      </Section>

      {/* ── Section 4: Conversation Entries ────────────────────────── */}
      <Section
        id="entries"
        title="Conversation Entries"
        badge={`${totalEntries} total · ${activeEntries} active · ${draftPending} draft · ${reasoningCount} reasoning`}
        color="blue"
        expanded={expanded.has('entries')}
        onToggle={toggle}
        empty={totalEntries === 0}
      >
        {totalEntries === 0 ? (
          <p className="py-1 text-gray-400">No entries yet</p>
        ) : (
          <div className="space-y-1.5">
            {/* Summary row */}
            <div className="flex flex-wrap gap-1">
              {[
                { label: `${totalEntries} total`,      color: 'bg-gray-100 text-gray-700   dark:bg-gray-800 dark:text-gray-300' },
                { label: `${activeEntries} active`,    color: 'bg-blue-100 text-blue-700   dark:bg-blue-900/40 dark:text-blue-300' },
                { label: `${draftPending} pending`,    color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' },
                { label: `${draftSent} sent`,          color: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' },
                { label: `${reasoningCount} reasoning`,color: 'bg-cyan-100 text-cyan-700   dark:bg-cyan-900/40 dark:text-cyan-300' },
                { label: `${analysisCount} analyses`,  color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300' },
              ].map(({ label, color }) => (
                <span key=[redacted] className={cn('rounded px-1.5 py-0.5 text-xs font-medium', color)}>
                  {label}
                </span>
              ))}
            </div>

            {/* Per-entry rows */}
            {visibleEntryIds.map((id) => (
              <EntryRow
                key=[redacted]
                convId={id}
                entry={conversationEntries[id] as Parameters<typeof EntryRow>[0]['entry']}
                onCopy={onCopy}
                copiedId={copiedId}
              />
            ))}

            {/* Show more / less */}
            {totalEntries > ENTRY_PREVIEW && (
              <button
                onClick={() => setShowAllEntries((v) => !v)}
                className="flex w-full items-center justify-center gap-1 rounded-lg border border-dashed border-gray-300 py-1.5 text-xs text-gray-500 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-900"
              >
                {showAllEntries ? (
                  <><EyeOff className="h-3 w-3" /> Show less</>
                ) : (
                  <><Eye className="h-3 w-3" /> Show all {totalEntries} entries</>
                )}
              </button>
            )}

            {/* Full JSON dump */}
            <details className="group">
              <summary className="cursor-pointer text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 list-none flex items-center gap-1 py-0.5">
                <ChevronRight className="h-3 w-3 group-open:rotate-90 transition-transform" />
                Full entries JSON
              </summary>
              <JsonBlock id="entries-json" content={safeStringify(conversationEntries)} onCopy={onCopy} copiedId={copiedId} />
            </details>
          </div>
        )}
      </Section>

      {/* ── Section 5: Global Presets ──────────────────────────────── */}
      <Section
        id="globalPresets"
        title="Global Canvas Presets"
        badge={globalPresets.length}
        color={hasGlobalPresets ? 'purple' : 'gray'}
        expanded={expanded.has('globalPresets')}
        onToggle={toggle}
        empty={!hasGlobalPresets}
      >
        {!hasGlobalPresets ? (
          <p className="py-1 text-gray-400">No global presets saved</p>
        ) : (
          <div className="space-y-1.5">
            {/* Copy all presets as JSON */}
            <button
              onClick={() => onCopy(safeStringify(globalPresets), 'all-presets')}
              className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-purple-300 dark:border-purple-700 py-1.5 text-xs text-purple-600 dark:text-purple-300 hover:bg-purple-50 dark:hover:bg-purple-950/40 transition-colors"
            >
              {copiedId === 'all-presets' ? (
                <><Check className="h-3 w-3 text-green-600" /> Copied!</>
              ) : (
                <><Copy className="h-3 w-3" /> Copy all presets as JSON</>
              )}
            </button>
            {globalPresets.map((preset, idx) => (
              <div
                key=[redacted]
                className="rounded border border-purple-200 dark:border-purple-800 bg-purple-50/50 dark:bg-purple-950/30 p-1.5 space-y-1"
              >
                <div className="flex items-center gap-1.5">
                  <span className="shrink-0 rounded bg-purple-200 dark:bg-purple-800 px-1 py-0.5 text-xs font-mono text-purple-800 dark:text-purple-200">
                    {idx + 1}
                  </span>
                  <span
                    className="flex-1 truncate text-xs font-medium text-gray-800 dark:text-gray-200 cursor-pointer hover:text-blue-600"
                    onClick={() => onCopy(preset.name, `preset-name-${preset.id}`)}
                  >
                    {preset.name}
                  </span>
                  {preset.expanded && (
                    <span className="shrink-0 rounded bg-cyan-100 dark:bg-cyan-900/40 px-1 py-0.5 text-xs text-cyan-700 dark:text-cyan-300">
                      expanded
                    </span>
                  )}
                </div>
                <CompactRow
                  id={`preset-id-${preset.id}`}
                  label="id"
                  value={preset.id}
                  color="purple"
                  mono
                  onCopy={onCopy}
                  copiedId={copiedId}
                />
                <div className="rounded bg-white/50 dark:bg-black/20 px-2 py-1 text-xs text-gray-600 dark:text-gray-300">
                  <span className="text-gray-400 mr-1">columns ({preset.columnIds.length}):</span>
                  {preset.columnIds.join(', ') || '—'}
                </div>
              </div>
            ))}
            <details className="group">
              <summary className="cursor-pointer text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 list-none flex items-center gap-1 py-0.5">
                <ChevronRight className="h-3 w-3 group-open:rotate-90 transition-transform" />
                Full presets JSON
              </summary>
              <JsonBlock id="presets-json" content={safeStringify(globalPresets)} onCopy={onCopy} copiedId={copiedId} />
            </details>
          </div>
        )}
      </Section>

      {/* ── Section 6: No active canvas warning ────────────────────── */}
      {!effectiveCanvasId && (
        <div className="flex items-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-2 dark:border-orange-800 dark:bg-orange-950">
          <AlertCircle className="h-3.5 w-3.5 shrink-0 text-orange-500" />
          <span className="text-xs text-orange-700 dark:text-orange-300">
            No effective canvas. Streaming data will be dropped until a canvas entity is active.
          </span>
        </div>
      )}
    </div>
  );
};