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