ThreadsDebuggerTab.tsx19.1 KBView on GitHub import { Check, Copy, ChevronDown, ChevronRight, Search, List, Undo2 } from 'lucide-react';
import type { ThreadData } from '@/modules/threads/threadList/store/threadSlice';
import { motion, AnimatePresence } from 'motion/react';
import { useCedarStore } from '@/modules/store';
import { cn } from '@/styles/stylingUtils';
import { useState, useMemo, useEffect } from 'react';
import { JsonTreeView } from './JsonTreeView';
interface ThreadsTabProps {
threads: Record<string, ThreadData>; // Thread data from Zustand store
onCopy: (text: string, id: string) => void;
copiedId: string | null;
}
const UNDO_TTL_MS = 30_000;
const UndoStackSection = () => {
const undoEntry = useCedarStore((state) => state.undoEntry);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!undoEntry) return;
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [undoEntry]);
const ttlRemaining = undoEntry
? Math.max(0, Math.round((UNDO_TTL_MS - (now - undoEntry.timestamp)) / 1000))
: 0;
const isExpired = undoEntry ? now - undoEntry.timestamp > UNDO_TTL_MS : false;
return (
<div className="border-b border-gray-200 bg-amber-50/50 dark:border-gray-700 dark:bg-amber-950/20">
<div className="flex items-center gap-2 p-3">
<Undo2 className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span className="font-medium text-amber-900 dark:text-amber-100">Undo Stack</span>
{undoEntry && !isExpired ? (
<span className="ml-1 rounded bg-amber-200 px-1.5 py-0.5 text-xs dark:bg-amber-800">
{ttlRemaining}s
</span>
) : (
<span className="ml-1 rounded bg-gray-200 px-1.5 py-0.5 text-xs dark:bg-gray-700">
empty
</span>
)}
</div>
{undoEntry && (
<div className="px-3 pb-3">
<div
className={cn(
'rounded border p-2 text-xs',
isExpired
? 'border-gray-200 bg-gray-100 text-gray-400 dark:border-gray-700 dark:bg-gray-800'
: 'border-amber-200 bg-white dark:border-amber-700 dark:bg-gray-900',
)}
>
<div className="mb-1 flex items-center justify-between">
<span className="font-mono font-semibold">{undoEntry.action.type}</span>
{isExpired && (
<span className="rounded bg-red-100 px-1 py-0.5 text-xs text-red-600 dark:bg-red-900 dark:text-red-300">
expired
</span>
)}
</div>
<JsonTreeView data={undoEntry.action} onCopy={(text) => { void navigator.clipboard.writeText(text); }} copiedId={null} defaultExpandDepth={2} className="max-h-32" />
<div className="mt-1 text-gray-400">
{new Date(undoEntry.timestamp).toLocaleTimeString()}
</div>
</div>
</div>
)}
</div>
);
};
// Component to display the current thread list from threadSlice
const ThreadListComponent = ({
onCopy,
copiedId,
}: {
onCopy: (text: string, id: string) => void;
copiedId: string | null;
}) => {
const currentThreadList = useCedarStore((state) => state.currentThreadList);
const [isExpanded, setIsExpanded] = useState(false);
const parityMetadata = currentThreadList.find((thread) => thread.$raw?.queryHash)?.$raw;
const safeStringify = (obj: unknown, indent = 2): string => {
try {
return JSON.stringify(obj, null, indent);
} catch (error) {
return `[Error serializing object: ${error instanceof Error ? error.message : 'Unknown error'}]`;
}
};
// Strip per-thread duplication of query-level metadata (compiledQuery,
// queryHash, inboxName, gmailLatencyMs) and trim long snippets so the copied
// payload is small enough to paste into an AI agent for debugging.
const slimThreadList = (() => {
const first = currentThreadList[0]?.$raw;
const queryMeta = first
? {
inboxName: first.inboxName,
queryHash: first.queryHash,
compiledQuery: first.compiledQuery,
gmailLatencyMs: first.gmailLatencyMs,
}
: undefined;
return {
queryMeta,
threads: currentThreadList.map((t) => {
const raw = t.$raw;
if (!raw) return { id: t.id, historyId: t.historyId };
return {
id: t.id,
historyId: t.historyId,
conversationId: raw.conversationId,
subject: raw.subject,
sender: raw.sender,
latestReceivedOn: raw.latestReceivedOn,
messageCount: raw.messageCount,
hasDraft: raw.hasDraft,
labels: Array.isArray(raw.labels)
? (raw.labels as { id: string }[]).map((l) => l.id)
: undefined,
};
}),
};
})();
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(!isExpanded)}
>
<div className="flex items-center gap-2">
<List className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="font-medium text-blue-900 dark:text-blue-100">
Current Thread List ({currentThreadList.length})
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
onCopy(safeStringify(slimThreadList), 'currentThreadListSlim');
}}
title="Copy slim (no repeated query metadata, label IDs only)"
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs transition-colors hover:bg-blue-200 dark:hover:bg-blue-800"
>
{copiedId === 'currentThreadListSlim' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
slim
</button>
<button
onClick={(e) => {
e.stopPropagation();
onCopy(safeStringify(currentThreadList), 'currentThreadList');
}}
title="Copy full thread list"
className="rounded p-0.5 transition-colors hover:bg-blue-200 dark:hover:bg-blue-800"
>
{copiedId === 'currentThreadList' ? (
<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-[400px] overflow-y-auto p-3 pt-0">
{parityMetadata && (
<div className="mb-2 rounded border border-indigo-200 bg-indigo-50 p-2 text-xs dark:border-indigo-800 dark:bg-indigo-950/30">
<div className="font-medium text-indigo-900 dark:text-indigo-200">
Split parity metadata
</div>
<div className="mt-1 text-indigo-700 dark:text-indigo-300">
inboxName: {String(parityMetadata.inboxName ?? 'n/a')}
</div>
<div className="break-all text-indigo-700 dark:text-indigo-300">
queryHash: {String(parityMetadata.queryHash ?? 'n/a')}
</div>
<div className="break-all text-indigo-700 dark:text-indigo-300">
compiledQuery: {String(parityMetadata.compiledQuery ?? 'n/a')}
</div>
<div className="text-indigo-700 dark:text-indigo-300">
gmailLatencyMs: {String(parityMetadata.gmailLatencyMs ?? 'n/a')}
</div>
</div>
)}
{currentThreadList.length === 0 ? (
<div className="rounded bg-gray-100 p-3 text-center text-xs text-gray-500 dark:bg-gray-800 dark:text-gray-400">
No threads in current list
</div>
) : (
<div className="space-y-2">
{currentThreadList.map((thread, index) => (
<div
key=[redacted]
className="rounded border border-gray-200 bg-white p-2 dark:border-gray-600 dark:bg-gray-800"
>
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="font-mono text-xs font-medium">{thread.id}</span>
{thread.historyId && (
<span className="font-mono text-xs text-gray-500 dark:text-gray-400">
History: {thread.historyId}
</span>
)}
{thread.$raw !== undefined && (
<div className="text-xs text-gray-600 dark:text-gray-300">
Raw: <code className="text-xs">{safeStringify(thread.$raw, 0)}</code>
</div>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
onCopy(safeStringify(thread), `threadList-${thread.id}`);
}}
className="rounded p-0.5 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700"
>
{copiedId === `threadList-${thread.id}` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
</div>
))}
<div className="mt-2">
<JsonTreeView data={currentThreadList} onCopy={onCopy} copiedId={copiedId} defaultExpandDepth={1} className="max-h-32" />
</div>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export const ThreadsDebuggerTab = ({ threads, onCopy, copiedId }: ThreadsTabProps) => {
const [expandedThreads, setExpandedThreads] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const threadEntries = useMemo(() => {
return Object.entries(threads);
}, [threads]);
const filteredThreads = useMemo(() => {
if (!searchQuery) return threadEntries;
const query = searchQuery.toLowerCase();
return threadEntries.filter(([threadId, thread]) => {
// Search in thread ID
if (threadId.toLowerCase().includes(query)) return true;
// Search in conversation ID
if (thread?.conversationId?.toLowerCase().includes(query)) return true;
// Search in subject
if (thread?.latest?.subject?.toLowerCase().includes(query)) return true;
// Search in thread data
try {
const jsonString = JSON.stringify(thread).toLowerCase();
if (jsonString.includes(query)) return true;
} catch {
// Ignore serialization errors
}
return false;
});
}, [threadEntries, searchQuery]);
const toggleThread = (threadId: string) => {
setExpandedThreads((prev) => {
const next = new Set(prev);
if (next.has(threadId)) {
next.delete(threadId);
} else {
next.add(threadId);
}
return next;
});
};
const safeStringify = (obj: unknown, indent = 2): string => {
try {
return JSON.stringify(obj, null, indent);
} catch (error) {
return `[Error serializing object: ${error instanceof Error ? error.message : 'Unknown error'}]`;
}
};
// Strip heavy fields (full HTML bodies, blob URLs, raw message arrays) so the
// copied payload is small enough to paste into an AI agent for debugging.
const slimThreads = useMemo(() => {
return threadEntries.map(([threadId, thread]) => {
const latest = thread?.latest;
return {
id: threadId,
conversationId: thread?.conversationId,
hasUnread: thread?.hasUnread,
totalReplies: thread?.totalReplies,
messageCount: thread?.messages?.length ?? 0,
labels: thread?.labels,
recentParticipants: thread?.recentParticipants,
lastLoadedAt: thread?.lastLoadedAt,
latest: latest
? {
id: latest.id,
subject: latest.subject,
sender: latest.sender,
to: latest.to,
receivedOn: latest.receivedOn,
unread: latest.unread,
snippet: latest.snippet,
isDraft: latest.isDraft,
}
: undefined,
};
});
}, [threadEntries]);
return (
<div className="flex h-full flex-col">
{/* Undo Stack Section */}
<UndoStackSection />
{/* Current Thread List Section */}
<ThreadListComponent onCopy={onCopy} copiedId={copiedId} />
{/* Search Bar */}
<div className="flex items-center gap-1 border-b border-gray-200 p-2 dark:border-gray-700">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search threads..."
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>
<button
onClick={() => onCopy(safeStringify(slimThreads), 'threadsSlim')}
title="Copy thread list metadata only (no message bodies)"
className="flex items-center gap-1 rounded border border-gray-300 bg-white px-2 py-1 text-xs transition-colors hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700"
>
{copiedId === 'threadsSlim' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
Copy slim
</button>
</div>
{/* Threads List */}
<div className="flex-1 space-y-2 overflow-y-auto p-2">
{filteredThreads.length === 0 ? (
<div className="flex h-full items-center justify-center py-8 text-center text-xs text-gray-500 dark:text-gray-400">
<div>
<p className="mb-2">
{searchQuery ? 'No threads match your search' : 'No threads loaded'}
</p>
{!searchQuery && (
<p className="text-xs text-gray-400">
Threads will appear here as you interact with email
</p>
)}
</div>
</div>
) : (
filteredThreads.map(([threadId, thread]) => {
const isExpanded = expandedThreads.has(threadId);
const subject = thread?.latest?.subject || 'No Subject';
const conversationId = thread?.conversationId;
const messageCount = thread?.messages?.length || 0;
return (
<div
key=[redacted]
className="rounded-lg border border-gray-200 bg-gray-50 transition-all dark:border-gray-700 dark:bg-gray-950"
>
{/* Thread Header */}
<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={() => toggleThread(threadId)}
>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-1.5">
<span className="font-mono text-xs font-medium">{threadId}</span>
{messageCount > 0 && (
<span className="rounded bg-blue-200 px-1.5 py-0.5 text-xs dark:bg-blue-800">
{messageCount} msg{messageCount !== 1 ? 's' : ''}
</span>
)}
</div>
<span className="line-clamp-1 text-xs text-gray-700 dark:text-gray-300">
{subject}
</span>
{conversationId && (
<span className="font-mono text-xs text-gray-500 dark:text-gray-400">
{conversationId}
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
onCopy(safeStringify(thread), threadId);
}}
className="rounded p-0.5 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700"
>
{copiedId === threadId ? (
<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>
{/* Expanded Thread Details */}
<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={thread} onCopy={onCopy} copiedId={copiedId} defaultExpandDepth={2} className="max-h-[600px]" />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})
)}
</div>
{/* Footer with count */}
<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
? `${filteredThreads.length} of ${threadEntries.length} threads`
: `${threadEntries.length} thread${threadEntries.length !== 1 ? 's' : ''} loaded`}
</div>
</div>
);
};