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