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>
);
};