ChatThreadsDebuggerTab.tsx11.3 KBView on GitHub import { Check, Copy, ChevronDown, ChevronRight, Pin, Search } from 'lucide-react';
import type { MessageThread } from '@/modules/cedar-os/src/store/messages/MessageTypes';
import { motion, AnimatePresence } from 'motion/react';
import { useCedarStore } from '@/modules/store';
import { cn } from '@/styles/stylingUtils';
import { useMemo, useState } from 'react';
import { JsonTreeView } from './JsonTreeView';
interface ChatThreadsTabProps {
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'}]`;
}
};
/**
* Max length for any single string value in a bulk thread copy. Tool results
* embed large static payloads — a `load-skill` result carries the skill's entire
* `instructions` markdown (crm-filtering is ~30k chars), and `get-conversation`
* results are similarly heavy — which blows up the clipboard and any context
* window the thread is pasted into. Longer strings are truncated with a marker.
* To get an untruncated value, drill into its node and use its own copy button.
*/
const MAX_COPY_STRING_LEN = 2000;
const truncateForCopy = (value: unknown, maxLen = MAX_COPY_STRING_LEN): unknown => {
if (typeof value === 'string') {
if (value.length <= maxLen) return value;
return `${value.slice(0, maxLen)}… [truncated ${value.length - maxLen} chars]`;
}
if (Array.isArray(value)) return value.map((v) => truncateForCopy(v, maxLen));
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([k, v]) => [
k,
truncateForCopy(v, maxLen),
]),
);
}
return value;
};
const STATUS_COLORS: Record<NonNullable<MessageThread['status']>, string> = {
streaming: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
finished: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
idle: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300',
};
/**
* Inspects the cedar-os chat-thread slice (`threadMap` + `mainThreadId` /
* `activeThreadId`). Each row is a `MessageThread`; expanding shows the full
* thread JSON (messages, chatContext, per-thread artifact, etc.).
*/
export const ChatThreadsDebuggerTab = ({ onCopy, copiedId }: ChatThreadsTabProps) => {
const threadMap = useCedarStore((state) => state.threadMap);
const mainThreadId = useCedarStore((state) => state.mainThreadId);
const activeThreadId = useCedarStore((state) => state.activeThreadId);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
// Active/selected thread first, then main, then the rest — so the chat the
// user is looking at is always at the top of the list.
const entries = useMemo(() => {
const rank = (threadId: string) =>
threadId === activeThreadId ? 0 : threadId === mainThreadId ? 1 : 2;
return Object.entries(threadMap).sort(([a], [b]) => rank(a) - rank(b));
}, [threadMap, activeThreadId, mainThreadId]);
const filtered = useMemo(() => {
if (!searchQuery) return entries;
const query = searchQuery.toLowerCase();
return entries.filter(([threadId, thread]) => {
if (threadId.toLowerCase().includes(query)) return true;
if (thread?.name?.toLowerCase().includes(query)) return true;
try {
return JSON.stringify(thread).toLowerCase().includes(query);
} catch {
return false;
}
});
}, [entries, searchQuery]);
const toggle = (threadId: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(threadId)) next.delete(threadId);
else next.add(threadId);
return next;
});
};
return (
<div className="flex h-full flex-col">
{/* Active/main thread summary */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-gray-200 px-2 py-1.5 text-[10px] dark:border-gray-700">
<span className="text-gray-500 dark:text-gray-400">
main:{' '}
<span className="font-mono text-gray-800 dark:text-gray-200">{mainThreadId || '—'}</span>
</span>
<span className="text-gray-500 dark:text-gray-400">
active:{' '}
<span className="font-mono text-gray-800 dark:text-gray-200">
{activeThreadId || '—'}
</span>
</span>
</div>
{/* 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 chat 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(truncateForCopy(threadMap)), 'chatThreadMap')}
title="Copy the entire threadMap"
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 === 'chatThreadMap' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
Copy all
</button>
</div>
{/* Threads List */}
<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">
<div>
<p className="mb-2">
{searchQuery ? 'No chat threads match your search' : 'No chat threads'}
</p>
{!searchQuery && (
<p className="text-xs text-gray-400">
Chat threads will appear here as you use the assistant
</p>
)}
</div>
</div>
) : (
filtered.map(([threadId, thread]) => {
const isExpanded = expanded.has(threadId);
const messageCount = thread?.messages?.length ?? 0;
const isMain = threadId === mainThreadId;
const isActive = threadId === activeThreadId;
const status = thread?.status;
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={() => toggle(threadId)}
>
<div className="flex flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-1.5">
{thread?.color && (
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: thread.color }}
/>
)}
<span className="font-mono text-xs font-medium">{threadId}</span>
{thread?.pinned && <Pin className="h-3 w-3 text-amber-500" />}
{isMain && (
<span className="rounded bg-purple-200 px-1.5 py-0.5 text-[10px] dark:bg-purple-800">
main
</span>
)}
{isActive && !isMain && (
<span className="rounded bg-indigo-200 px-1.5 py-0.5 text-[10px] dark:bg-indigo-800">
active
</span>
)}
{status && (
<span className={cn('rounded px-1.5 py-0.5 text-[10px]', STATUS_COLORS[status])}>
{status}
</span>
)}
{messageCount > 0 && (
<span className="rounded bg-blue-200 px-1.5 py-0.5 text-[10px] dark:bg-blue-800">
{messageCount} msg{messageCount !== 1 ? 's' : ''}
</span>
)}
</div>
{thread?.name && (
<span className="line-clamp-1 text-xs text-gray-700 dark:text-gray-300">
{thread.name}
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
onCopy(safeStringify(truncateForCopy(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
? `${filtered.length} of ${entries.length} threads`
: `${entries.length} chat thread${entries.length !== 1 ? 's' : ''}`}
</div>
</div>
);
};