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 "{valuePath}"</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>
);
};