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