ConversationsDebuggerTab.tsx17.7 KBView on GitHub
import type { ConversationWithMetadata } from '@/modules/conversations/slice/conversationsSlice';
import { Check, Copy, ChevronDown, ChevronRight, Search, List, Filter } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import React, { useState, useMemo } from 'react';
import { useCedarStore } from '@/modules/store';
import { cn } from '@/styles/stylingUtils';
import { JsonTreeView } from './JsonTreeView';

interface ConversationsTabProps {
  conversations: Record<string, ConversationWithMetadata>;
  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 object: ${error instanceof Error ? error.message : 'Unknown error'}]`;
  }
};

const resolvePath = (obj: unknown, path: string): unknown => {
  return path.split('.').reduce((acc, key) => {
    if (acc && typeof acc === 'object' && key in (acc as Record<string, unknown>)) {
      return (acc as Record<string, unknown>)[key];
    }
    return undefined;
  }, obj);
};

const formatResolvedValue = (value: unknown): string => {
  if (value === undefined) return '—';
  if (typeof value === 'object') return safeStringify(value, 0);
  return String(value);
};

// Component to display the current conversation list from crmSlice
const CurrentConversationListComponent = ({
  onCopy,
  copiedId,
}: {
  onCopy: (text: string, id: string) => void;
  copiedId: string | null;
}) => {
  const currentConversationList = useCedarStore((state) => state.currentConversationList);
  const conversations = useCedarStore((state) => state.conversations);
  const [isExpanded, setIsExpanded] = useState(false);
  const [valuePath, setValuePath] = useState('');

  const resolvedEntries = useMemo(() => {
    return currentConversationList.map((conversationId, index) => {
      const actualId =
        typeof conversationId === 'string'
          ? conversationId
          : (conversationId as { id?: string }).id || JSON.stringify(conversationId);
      const conversation = conversations[actualId];
      const resolved = valuePath && conversation ? resolvePath(conversation, valuePath) : undefined;
      return { actualId, conversation, resolved, index };
    });
  }, [currentConversationList, conversations, valuePath]);

  const buildBulkCopyText = () => {
    return resolvedEntries
      .map(({ actualId, conversation, resolved }) => {
        if (valuePath) {
          return `${actualId}: ${formatResolvedValue(resolved)}`;
        }
        const name = conversation?.data.company?.name || 'No company';
        return `${actualId}: ${name}`;
      })
      .join('\n');
  };

  return (
    <div className="border-b border-gray-200 bg-purple-50/50 dark:border-gray-700 dark:bg-purple-950/20">
      <div
        className="flex cursor-pointer items-center justify-between p-3 transition-colors hover:bg-purple-100/50 dark:hover:bg-purple-900/30"
        onClick={() => setIsExpanded(!isExpanded)}
      >
        <div className="flex items-center gap-2">
          <List className="h-4 w-4 text-purple-600 dark:text-purple-400" />
          <span className="font-medium text-purple-900 dark:text-purple-100">
            Current Conversation List ({currentConversationList.length})
          </span>
        </div>
        <div className="flex items-center gap-1">
          <button
            onClick={(e) => {
              e.stopPropagation();
              onCopy(safeStringify(currentConversationList), 'currentConversationList');
            }}
            className="rounded p-0.5 transition-colors hover:bg-purple-200 dark:hover:bg-purple-800"
            title="Copy as JSON"
          >
            {copiedId === 'currentConversationList' ? (
              <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="p-3 pt-0">
              {/* Value Path Filter */}
              <div className="mb-2 flex items-center gap-1.5">
                <div className="relative flex-1">
                  <Filter className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-gray-400" />
                  <input
                    type="text"
                    placeholder="Value path, e.g. data.conversation.lastContactedAt"
                    value={valuePath}
                    onChange={(e) => setValuePath(e.target.value)}
                    onClick={(e) => e.stopPropagation()}
                    className="w-full rounded border border-gray-300 bg-white py-1 pl-7 pr-2 text-xs font-mono placeholder:font-sans placeholder:text-gray-400 focus:border-purple-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-white"
                  />
                </div>
                <button
                  onClick={(e) => {
                    e.stopPropagation();
                    onCopy(buildBulkCopyText(), 'conversationList-bulk');
                  }}
                  className="flex shrink-0 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"
                  title="Copy all as id: value pairs"
                >
                  {copiedId === 'conversationList-bulk' ? (
                    <Check className="h-3 w-3 text-green-600" />
                  ) : (
                    <Copy className="h-3 w-3" />
                  )}
                  Copy All
                </button>
              </div>

              {currentConversationList.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 conversations in current list
                </div>
              ) : (
                <div className="max-h-[500px] space-y-2 overflow-y-auto">
                  {resolvedEntries.map(({ actualId, conversation, resolved, 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="text-xs font-medium">
                            {index + 1}.{' '}
                            {conversation
                              ? conversation.data.company?.name || 'No company'
                              : <span className="text-red-500">Not loaded in store</span>}
                            <span className="ml-1 font-mono text-gray-400">{actualId}</span>
                          </span>
                          {valuePath && (
                            <span className="font-mono text-xs text-purple-700 dark:text-purple-300">
                              {valuePath}:{' '}
                              <span className="text-gray-900 dark:text-gray-100">
                                {formatResolvedValue(resolved)}
                              </span>
                            </span>
                          )}
                        </div>
                        <button
                          onClick={(e) => {
                            e.stopPropagation();
                            onCopy(
                              valuePath
                                ? `${actualId}: ${formatResolvedValue(resolved)}`
                                : safeStringify(conversation || { id: actualId }),
                              `conversationList-${actualId}`,
                            );
                          }}
                          className="rounded p-0.5 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700"
                        >
                          {copiedId === `conversationList-${actualId}` ? (
                            <Check className="h-3 w-3 text-green-600" />
                          ) : (
                            <Copy className="h-3 w-3" />
                          )}
                        </button>
                      </div>
                    </div>
                  ))}
                </div>
              )}
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
};

export const ConversationsDebuggerTab: React.FC<ConversationsTabProps> = ({
  conversations,
  onCopy,
  copiedId,
}) => {
  const [expandedConversations, setExpandedConversations] = useState<Set<string>>(new Set());
  const [searchQuery, setSearchQuery] = useState('');
  const [valuePath, setValuePath] = useState('');

  const conversationEntries = useMemo(() => {
    return Object.entries(conversations);
  }, [conversations]);

  const filteredConversations = useMemo(() => {
    if (!searchQuery) return conversationEntries;

    const query = searchQuery.toLowerCase();
    return conversationEntries.filter(([conversationId, conversation]) => {
      // Search in conversation ID
      if (conversationId.toLowerCase().includes(query)) return true;

      // Search in conversation data
      try {
        const jsonString = JSON.stringify(conversation.data).toLowerCase();
        if (jsonString.includes(query)) return true;
      } catch {
        // Ignore serialization errors
      }

      return false;
    });
  }, [conversationEntries, searchQuery]);

  const toggleConversation = (conversationId: string) => {
    setExpandedConversations((prev) => {
      const next = new Set(prev);
      if (next.has(conversationId)) {
        next.delete(conversationId);
      } else {
        next.add(conversationId);
      }
      return next;
    });
  };

  return (
    <div className="flex h-full flex-col">
      {/* Current Conversation List Section */}
      <CurrentConversationListComponent onCopy={onCopy} copiedId={copiedId} />

      {/* Search Bar & Value Path Filter */}
      <div className="space-y-1.5 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 conversations..."
            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 className="flex items-center gap-1.5">
          <div className="relative flex-1">
            <Filter className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-gray-400" />
            <input
              type="text"
              placeholder="Value path, e.g. data.conversation.lastContactedAt"
              value={valuePath}
              onChange={(e) => setValuePath(e.target.value)}
              className="w-full rounded border border-gray-300 bg-white py-1 pl-7 pr-2 text-xs font-mono placeholder:font-sans placeholder:text-gray-400 focus:border-purple-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-white"
            />
          </div>
          {valuePath && (
            <button
              onClick={() => {
                const text = filteredConversations
                  .map(([id, conv]) => {
                    const val = resolvePath(conv, valuePath);
                    return `${id}: ${formatResolvedValue(val)}`;
                  })
                  .join('\n');
                onCopy(text, 'conversations-bulk');
              }}
              className="flex shrink-0 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"
              title="Copy all as id: value pairs"
            >
              {copiedId === 'conversations-bulk' ? (
                <Check className="h-3 w-3 text-green-600" />
              ) : (
                <Copy className="h-3 w-3" />
              )}
              Copy All
            </button>
          )}
        </div>
      </div>

      {/* Conversations List */}
      <div className="flex-1 space-y-2 overflow-y-auto p-2">
        {filteredConversations.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 conversations match your search' : 'No conversations loaded'}
              </p>
              {!searchQuery && (
                <p className="text-xs text-gray-400">
                  Conversations will appear here as you interact with threads
                </p>
              )}
            </div>
          </div>
        ) : (
          filteredConversations.map(([conversationId, conversation], idx) => {
            const isExpanded = expandedConversations.has(conversationId);
            const resolvedValue = valuePath
              ? resolvePath(conversation, valuePath)
              : undefined;

            return (
              <div
                key=[redacted]
                className="rounded-lg border border-gray-200 bg-gray-50 transition-all dark:border-gray-700 dark:bg-gray-950"
              >
                {/* Conversation 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={() => toggleConversation(conversationId)}
                >
                  <div className="flex flex-1 flex-col gap-1">
                    <span className="text-xs font-medium">
                      {idx + 1}.{' '}
                      {conversation.data?.company?.name || 'No company'}
                      <span className="ml-1 font-mono text-gray-400">{conversationId}</span>
                    </span>
                    {valuePath && (
                      <span className="font-mono text-xs text-purple-700 dark:text-purple-300">
                        {valuePath}:{' '}
                        <span className="text-gray-900 dark:text-gray-100">
                          {formatResolvedValue(resolvedValue)}
                        </span>
                      </span>
                    )}
                  </div>
                  <div className="flex items-center gap-1">
                    <button
                      onClick={(e) => {
                        e.stopPropagation();
                        onCopy(
                          valuePath
                            ? String(resolvedValue ?? '')
                            : safeStringify(conversation),
                          conversationId,
                        );
                      }}
                      className="rounded p-0.5 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700"
                    >
                      {copiedId === conversationId ? (
                        <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 Conversation 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">
                        {valuePath ? (
                          resolvedValue === undefined ? (
                            <p className="text-xs text-muted-foreground">No value at path &quot;{valuePath}&quot;</p>
                          ) : (
                            <JsonTreeView data={resolvedValue} onCopy={onCopy} copiedId={copiedId} defaultExpandDepth={2} className="max-h-[600px]" />
                          )
                        ) : (
                          <JsonTreeView data={conversation.data} onCopy={onCopy} copiedId={copiedId} defaultExpandDepth={1} 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
          ? `${filteredConversations.length} of ${conversationEntries.length} conversations`
          : `${conversationEntries.length} conversation${conversationEntries.length !== 1 ? 's' : ''} loaded`}
      </div>
    </div>
  );
};