ConversationDocsDebuggerTab.tsx25.7 KBView on GitHub
'use client';

import { Check, Copy, FileText, Trash2, WifiOff } from 'lucide-react';
import { useCedarStore } from '@/modules/store';
import {
  inferScopeTypeFromPath,
  inferScopeIdFromPath,
} from '@/modules/files/store/documentsSlice';
import type { DocSaveEvent } from '@/modules/files/store/documentSaveLogSlice';
import { formatDistanceToNow } from 'date-fns';
import { cn } from '@/styles/stylingUtils';
import React, { useMemo, useState } from 'react';
import { JsonTreeView } from './JsonTreeView';
import { getProviderYDoc, inspectYDoc, type InspectedNode } from '@/modules/documents/yjs';
import { useQueryClient } from '@tanstack/react-query';

const safeStringify = (obj: unknown, indent = 2): string => {
  try {
    return JSON.stringify(obj, null, indent);
  } catch (error) {
    return `[Error serializing: ${error instanceof Error ? error.message : 'Unknown'}]`;
  }
};

const tryParseJson = (raw: string): unknown | undefined => {
  try {
    return JSON.parse(raw);
  } catch {
    return undefined;
  }
};

interface ConversationDocsTabProps {
  onCopy: (text: string, id: string) => void;
  copiedId: string | null;
}

type SaveFilter = 'all' | 'network' | 'errors';

const EVENT_COLORS: Record<string, string> = {
  'flush-start': 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
  'flush-success': 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
  'flush-error': 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
  'remote-apply': 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
  'initial-state': 'bg-teal-100 text-teal-700 dark:bg-teal-900 dark:text-teal-300',
};

/**
 * A single Y.js save-log event row (formerly the standalone "DocDebug" tab).
 */
function SaveEventRow({
  entry,
  onCopy,
  copiedId,
}: {
  entry: DocSaveEvent;
  onCopy: (text: string, id: string) => void;
  copiedId: string | null;
}) {
  const [expanded, setExpanded] = useState(false);
  const { event } = entry;

  const colorClass = EVENT_COLORS[event.type] ?? 'bg-muted text-muted-foreground';

  const axiomTraceId = event.type === 'flush-success' ? event.axiomTraceId : undefined;

  const saveRequestId =
    event.type === 'flush-start' || event.type === 'flush-success' || event.type === 'flush-error'
      ? event.saveRequestId
      : undefined;

  const durationMs =
    event.type === 'flush-success' || event.type === 'flush-error' ? event.durationMs : undefined;

  const version = event.type === 'flush-success' ? event.version : undefined;
  const yjsRevision = event.type === 'flush-success' ? event.yjsRevision : undefined;
  const hookOutcomes = event.type === 'flush-success' ? event.hookOutcomes : undefined;
  const error = event.type === 'flush-error' ? event.error : undefined;
  const byteLength =
    event.type === 'remote-apply' ||
    event.type === 'initial-state' ||
    event.type === 'flush-start'
      ? event.updateByteLength
      : undefined;

  return (
    <div className="border-b last:border-0 text-xs">
      <div
        role="button"
        tabIndex={0}
        className="flex items-start gap-2 px-2 py-1.5 hover:bg-muted/40 cursor-pointer"
        onClick={() => setExpanded((v) => !v)}
        onKeyDown={(e) => {
          if (e.key === 'Enter' || e.key === ' ') setExpanded((v) => !v);
        }}
      >
        <span className={cn('shrink-0 rounded px-1 py-px font-mono font-medium', colorClass)}>
          {event.type}
        </span>

        <div className="flex flex-1 flex-wrap gap-x-3 gap-y-0.5 min-w-0 text-muted-foreground">
          {saveRequestId && (
            <span className="font-mono truncate" title={saveRequestId}>
              req:{saveRequestId.slice(0, 8)}…
            </span>
          )}
          {durationMs !== undefined && (
            <span className={durationMs > 3000 ? 'text-orange-600 dark:text-orange-400' : ''}>
              {durationMs}ms
            </span>
          )}
          {version !== undefined && <span>v{version}</span>}
          {yjsRevision !== undefined && <span>yjs#{yjsRevision}</span>}
          {byteLength !== undefined && <span>{byteLength}B</span>}
          {error && <span className="text-red-600 dark:text-red-400 truncate">{error}</span>}
          {axiomTraceId && (
            <span
              className="font-mono text-purple-600 dark:text-purple-400 truncate"
              title={axiomTraceId}
            >
              trace:{axiomTraceId.slice(0, 8)}…
            </span>
          )}
        </div>

        <div className="flex shrink-0 items-center gap-1 text-muted-foreground">
          <span className="text-[10px]">
            {formatDistanceToNow(entry.timestamp, { addSuffix: true })}
          </span>
          {axiomTraceId && (
            <button
              type="button"
              title="Copy Axiom traceId"
              className="rounded p-0.5 hover:text-foreground"
              onClick={(e) => {
                e.stopPropagation();
                onCopy(axiomTraceId, `trace-${entry.id}`);
              }}
            >
              {copiedId === `trace-${entry.id}` ? (
                <Check className="h-3 w-3 text-green-600" />
              ) : (
                <Copy className="h-3 w-3" />
              )}
            </button>
          )}
        </div>
      </div>

      {expanded && (
        <div className="border-t bg-muted/30 px-2 py-2 space-y-2">
          <JsonTreeView
            data={{
              documentId: entry.documentId,
              timestamp: new Date(entry.timestamp).toISOString(),
              ...event,
            }}
            onCopy={onCopy}
            copiedId={copiedId}
            defaultExpandDepth={1}
            className="max-h-[400px]"
          />

          {hookOutcomes && hookOutcomes.length > 0 && (
            <div className="mt-1.5 space-y-0.5">
              <p className="text-[10px] font-medium text-muted-foreground">Hooks</p>
              {hookOutcomes.map((h, i) => (
                <div key={i} className="flex items-center gap-1.5 text-[10px]">
                  <span
                    className={cn('h-1.5 w-1.5 rounded-full', h.ok ? 'bg-green-500' : 'bg-red-500')}
                  />
                  <span className="font-mono">{h.name}</span>
                  {h.durationMs !== undefined && (
                    <span className="text-muted-foreground">{h.durationMs}ms</span>
                  )}
                </div>
              ))}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

/**
 * Save-log timeline for a single document (filter pills + legend + event rows).
 * Rendered inside the expanded doc row under the "saves" view.
 */
function SaveEventsView({
  documentId,
  events,
  onCopy,
  copiedId,
}: {
  documentId: string;
  events: DocSaveEvent[];
  onCopy: (text: string, id: string) => void;
  copiedId: string | null;
}) {
  const clearDocSaveEvents = useCedarStore((s) => s.clearDocSaveEvents);
  const [filter, setFilter] = useState<SaveFilter>('all');

  const filtered = useMemo(() => {
    if (filter === 'network') {
      return events.filter(
        (e) =>
          e.event.type === 'flush-start' ||
          e.event.type === 'flush-success' ||
          e.event.type === 'flush-error',
      );
    }
    if (filter === 'errors') {
      return events.filter((e) => e.event.type === 'flush-error');
    }
    return events;
  }, [events, filter]);

  const errorCount = events.filter((e) => e.event.type === 'flush-error').length;

  const timelineJson = safeStringify({
    documentId,
    events: events.map((e) => ({ timestamp: new Date(e.timestamp).toISOString(), ...e.event })),
  });

  return (
    <div className="space-y-2">
      {/* Filter pills + actions */}
      <div className="flex flex-wrap items-center gap-1.5">
        {(['all', 'network', 'errors'] as SaveFilter[]).map((f) => (
          <button
            key={f}
            type="button"
            onClick={() => setFilter(f)}
            className={cn(
              'rounded px-2 py-0.5 text-[10px] font-medium transition-colors',
              filter === f
                ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
                : 'bg-muted text-muted-foreground hover:text-foreground',
              f === 'errors' && errorCount > 0 && filter !== 'errors' ? 'ring-1 ring-red-400' : '',
            )}
          >
            {f}
            {f === 'errors' && errorCount > 0 && ` (${errorCount})`}
          </button>
        ))}
        <div className="flex-1" />
        <button
          type="button"
          title="Copy entire timeline as JSON"
          onClick={() => onCopy(timelineJson, `timeline-${documentId}`)}
          className="flex items-center gap-1 rounded border border-input bg-background px-2 py-0.5 text-[10px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
        >
          {copiedId === `timeline-${documentId}` ? (
            <Check className="h-3 w-3 text-green-600" />
          ) : (
            <Copy className="h-3 w-3" />
          )}
          Copy timeline
        </button>
        <button
          type="button"
          title="Clear this doc's events"
          onClick={() => clearDocSaveEvents(documentId)}
          className="rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
        >
          <Trash2 className="h-3 w-3" />
        </button>
      </div>

      {/* Legend */}
      <div className="flex flex-wrap gap-1.5 text-[10px]">
        {Object.entries(EVENT_COLORS).map(([type, cls]) => (
          <span key=[redacted] className={cn('rounded px-1 py-px font-mono', cls)}>
            {type}
          </span>
        ))}
      </div>

      {/* Event timeline */}
      {filtered.length === 0 ? (
        <div className="flex h-24 flex-col items-center justify-center gap-1.5 text-muted-foreground">
          <WifiOff className="h-4 w-4" />
          <span className="text-xs">No events for this document</span>
        </div>
      ) : (
        <div className="rounded border bg-card divide-y">
          {filtered.map((entry) => (
            <SaveEventRow key=[redacted] entry={entry} onCopy={onCopy} copiedId={copiedId} />
          ))}
        </div>
      )}
    </div>
  );
}

/**
 * Four views for a doc:
 *   - "live"   — the current Y.Doc JSON (only when a provider is open)
 *   - "stored" — the FsNode.content string, parsed as JSON if possible
 *   - "node"   — the raw FsNode metadata
 *   - "saves"  — the Y.js save-log timeline for this document
 */
function ExpandedDocContent({
  entry,
  saveEvents,
  onCopy,
  copiedId,
}: {
  entry: DocEntry;
  saveEvents: DocSaveEvent[];
  onCopy: (text: string, id: string) => void;
  copiedId: string | null;
}) {
  const liveJson: InspectedNode | null = (() => {
    const ydoc = getProviderYDoc(entry.documentId);
    if (!ydoc) return null;
    try {
      return inspectYDoc(ydoc);
    } catch {
      return null;
    }
  })();

  const storedJson =
    entry.content !== undefined ? (tryParseJson(entry.content) ?? entry.content) : null;

  const hasEvents = saveEvents.length > 0;

  const [view, setView] = useState<'live' | 'stored' | 'node' | 'saves'>(
    liveJson ? 'live' : storedJson !== null ? 'stored' : hasEvents ? 'saves' : 'node',
  );

  const data: unknown = view === 'live' ? liveJson : view === 'stored' ? storedJson : entry.raw;

  return (
    <div className="border-t bg-muted/30 p-2 space-y-2">
      <div className="flex items-center gap-2">
        <div className="text-[10px] text-muted-foreground">
          documentId: {entry.documentId}
          {entry.version !== undefined && <> · v{entry.version}</>}
        </div>
        <div className="flex-1" />
        {(['live', 'stored', 'node', 'saves'] as const).map((v) => {
          const enabled =
            v === 'live'
              ? liveJson !== null
              : v === 'stored'
                ? storedJson !== null
                : v === 'saves'
                  ? hasEvents
                  : true;
          return (
            <button
              key={v}
              type="button"
              disabled={!enabled}
              onClick={() => setView(v)}
              className={cn(
                'rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors',
                view === v
                  ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
                  : 'bg-muted text-muted-foreground hover:text-foreground',
                !enabled && 'opacity-40 cursor-not-allowed',
              )}
              title={
                v === 'live'
                  ? 'Live ProseMirror JSON derived from the open Y.Doc'
                  : v === 'stored'
                    ? 'Server-mirrored content (markdown / JSON string)'
                    : v === 'saves'
                      ? 'Y.js save-log timeline for this document'
                      : 'Raw FsNode metadata'
              }
            >
              {v}
              {v === 'saves' && hasEvents && ` (${saveEvents.length})`}
            </button>
          );
        })}
      </div>
      {view === 'saves' ? (
        <SaveEventsView
          documentId={entry.documentId}
          events={saveEvents}
          onCopy={onCopy}
          copiedId={copiedId}
        />
      ) : (
        <JsonTreeView
          data={data ?? entry.raw}
          onCopy={onCopy}
          copiedId={copiedId}
          defaultExpandDepth={1}
          className="max-h-[600px]"
        />
      )}
    </div>
  );
}

type DocEntry = {
  key=[redacted];
  documentId: string;
  title: string;
  subtitle: string;
  scope: string;
  documentType: string;
  lastLoadedAt: number;
  version?: number;
  content?: string;
  raw: unknown;
  /** True when this row only exists because of save events (doc not loaded in the fs store). */
  eventsOnly?: boolean;
};

/**
 * `path` for an `agent_file` row is `agent-{aopAgentId}/{filename}`. Split it
 * into the agent id and the trailing filename so the breadcrumb can show the
 * agent's human-readable name + the doc slot it owns.
 */
function parseAgentScopedPath(path: string): { agentId: string; tail: string } | null {
  const m = path.match(/^agent-([^/]+)\/(.+)$/);
  return m ? { agentId: m[1]!, tail: m[2]! } : null;
}

function prettifySegment(segment: string): string {
  return segment
    .split(/[_-]/)
    .map((w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : w))
    .join(' ');
}

function entryTitle(
  doc: {
    title: string | null;
    path: string;
    documentType: string;
  },
  aopAgentsById: Record<string, { name?: string | null } | undefined>,
  agendaConversationBadges: Record<
    string,
    { name?: string | null; companyName?: string | null } | undefined
  >,
  conversationsById: Record<string, { data?: { conversation?: { name?: string | null } } } | undefined>,
  scopeId: string,
): string {
  // Agent-scoped docs: any folder/file under a `…/agent-{uuid}` segment is
  // given an auto-humanized title ("Agent 1dd091b6 Ee48 40bf …") that's
  // useless for telling agents apart. Resolve the real agent name instead.
  const segments = doc.path.split('/');
  const agentIdx = segments.findIndex((s) => /^agent-.+/.test(s));
  if (agentIdx !== -1) {
    const agentSegment = segments[agentIdx]!;
    const agentId = agentSegment.slice('agent-'.length);
    // Only override when the stored title is the auto-generated UUID one — a
    // genuinely user-renamed folder keeps its title.
    if (!doc.title || doc.title === prettifySegment(agentSegment)) {
      const agentName = aopAgentsById[agentId]?.name ?? `agent ${agentId.slice(0, 8)}…`;
      const tail = segments.slice(agentIdx + 1).join('/');
      return tail ? `${agentName} / ${prettifySegment(tail)}` : agentName;
    }
  }

  if (doc.title) return doc.title;

  // User-scoped daily agenda: path is `user/agendas/{yyyy-MM-dd}`.
  if (doc.documentType === 'agenda' && doc.path.startsWith('user/')) {
    const date = doc.path.startsWith('user/agendas/') ? doc.path.slice('user/agendas/'.length) : '';
    return `Agenda · ${date}`;
  }

  if (doc.path.startsWith('conversation/')) {
    const conv = conversationsById[scopeId];
    const badge = agendaConversationBadges[scopeId];
    const conversationName =
      conv?.data?.conversation?.name ?? badge?.name ?? badge?.companyName ?? `conv ${scopeId.slice(0, 8)}…`;

    // Agent-scoped rows: `agent_file` with path `agent-{id}/{slot}`. Render
    // the agent's human-readable name + the slot (e.g. "overview").
    const agentScoped = parseAgentScopedPath(doc.path);
    if (agentScoped) {
      const agentName =
        aopAgentsById[agentScoped.agentId]?.name ?? `agent ${agentScoped.agentId.slice(0, 8)}…`;
      return `${conversationName} / ${agentName} / ${prettifySegment(agentScoped.tail)}`;
    }

    // Non-agent conversation doc: e.g. `deal_overview`, `research/figma`.
    return `${conversationName} / ${prettifySegment(doc.path.split('/').pop() ?? doc.path)}`;
  }

  return doc.path;
}

export const ConversationDocsDebuggerTab: React.FC<ConversationDocsTabProps> = ({ onCopy, copiedId }) => {
  const documents = useCedarStore((s) => s.documents);
  const activeConversationId = useCedarStore((s) => s.activeConversationId);
  const agendaConversationBadges = useCedarStore((s) => s.agendaConversationBadges);
  const conversationsById = useCedarStore((s) => s.conversations);
  const docSaveEvents = useCedarStore((s) => s.docSaveEvents);

  const [filterActive, setFilterActive] = useState(false);
  const [expandedKey, setExpandedKey] = useState<string | null>(null);

  // Group save events by documentId once, so each row can surface a save badge
  // and its expanded "saves" view without re-scanning the full log.
  const eventsByDocId = useMemo(() => {
    const map = new Map<string, DocSaveEvent[]>();
    for (const ev of docSaveEvents) {
      const list = map.get(ev.documentId);
      if (list) list.push(ev);
      else map.set(ev.documentId, [ev]);
    }
    return map;
  }, [docSaveEvents]);

  // Build an aopAgentsById map from every `aopAgents.listForAop` result already
  // sitting in the React Query cache. The AgentsEditor / agent-row hooks
  // populate this whenever the user opens a conversation, so by the time the
  // debugger is inspected we usually have agents for every AOP they've
  // interacted with — no extra fetch.
  const queryClient = useQueryClient();
  const aopAgentsById = useMemo(() => {
    const out: Record<string, { name?: string | null }> = {};
    const cached = queryClient.getQueriesData<Array<{ id: string; name?: string | null }>>({
      queryKey: [['aopAgents', 'listForAop']],
    });
    for (const [, data] of cached) {
      if (!Array.isArray(data)) continue;
      for (const agent of data) {
        if (agent?.id) out[agent.id] = agent;
      }
    }
    return out;
    // documents is in the dep list so the lookup table refreshes when new docs
    // appear (likely after a new conversation/agents have just been fetched).
  }, [queryClient, documents]);

  const allEntries: DocEntry[] = Object.entries(documents).map(([key, doc]) => {
    const scopeType = inferScopeTypeFromPath(doc.path);
    const scopeId = inferScopeIdFromPath(doc.path);
    return {
      key,
      documentId: doc.id,
      title: entryTitle(doc, aopAgentsById, agendaConversationBadges, conversationsById, scopeId),
      subtitle: doc.path,
      scope: `${scopeType}/${scopeId.slice(0, 8)}…`,
      documentType: doc.documentType,
      lastLoadedAt: doc.lastLoadedAt,
      version: doc.version,
      content: doc.content,
      raw: doc,
    };
  });

  // Include documents that only exist in the save-log (edited then evicted from
  // the fs store) so the merged tab never loses save history the old DocDebug
  // tab would have shown.
  const loadedDocIds = new Set(allEntries.map((e) => e.documentId));
  for (const [docId, events] of eventsByDocId.entries()) {
    if (loadedDocIds.has(docId)) continue;
    const lastTs = events.reduce((max, e) => Math.max(max, e.timestamp), 0);
    allEntries.push({
      key=[redacted],
      documentId: docId,
      title: `${docId.slice(0, 8)}…`,
      subtitle: 'save events only — not loaded in fs store',
      scope: 'events/?',
      documentType: 'unloaded',
      lastLoadedAt: lastTs,
      raw: { documentId: docId, note: 'Not present in documents store; showing save events only.' },
      eventsOnly: true,
    });
  }

  const filtered =
    filterActive && activeConversationId
      ? allEntries.filter((e) =>
          e.scope.startsWith(`conversation/${activeConversationId.slice(0, 8)}`),
        )
      : allEntries;

  filtered.sort((a, b) => b.lastLoadedAt - a.lastLoadedAt);

  const totalErrorCount = docSaveEvents.filter((e) => e.event.type === 'flush-error').length;

  return (
    <div className="flex h-full flex-col overflow-hidden">
      {/* Toolbar */}
      <div className="flex shrink-0 items-center gap-2 border-b px-2 py-1.5">
        <button
          type="button"
          onClick={() => setFilterActive((v) => !v)}
          className={cn(
            'rounded px-2 py-0.5 text-[10px] font-medium transition-colors',
            filterActive
              ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
              : 'bg-muted text-muted-foreground hover:text-foreground',
          )}
        >
          {filterActive ? 'Active conv only' : 'All docs'}
        </button>
        <span className="text-[10px] text-muted-foreground">
          {filtered.length} doc{filtered.length !== 1 ? 's' : ''} · {docSaveEvents.length} save
          event{docSaveEvents.length !== 1 ? 's' : ''}
          {totalErrorCount > 0 && (
            <>
              {' · '}
              <span className="text-red-600 dark:text-red-400">{totalErrorCount} error</span>
            </>
          )}
        </span>
      </div>

      <div className="flex-1 overflow-y-auto">
        {filtered.length === 0 ? (
          <div className="flex h-32 flex-col items-center justify-center gap-1 text-muted-foreground text-xs">
            <FileText className="h-4 w-4" />
            <span>No documents loaded</span>
          </div>
        ) : (
          <div className="space-y-1 p-2">
            {filtered.map((entry) => {
              const isExpanded = expandedKey === entry.key;
              const saveEvents = eventsByDocId.get(entry.documentId) ?? [];
              const saveErrorCount = saveEvents.filter(
                (e) => e.event.type === 'flush-error',
              ).length;

              return (
                <div key=[redacted] className="rounded border bg-card overflow-hidden text-xs">
                  <div
                    role="button"
                    tabIndex={0}
                    className="flex w-full items-start gap-2 px-2 py-1.5 text-left hover:bg-muted/50 cursor-pointer"
                    onClick={() => setExpandedKey(isExpanded ? null : entry.key)}
                    onKeyDown={(e) => {
                      if (e.key === 'Enter' || e.key === ' ')
                        setExpandedKey(isExpanded ? null : entry.key);
                    }}
                  >
                    <FileText className="mt-px h-3 w-3 shrink-0 text-muted-foreground" />
                    <div className="flex-1 min-w-0">
                      <div className="flex items-center gap-1.5">
                        <span className="font-medium truncate">{entry.title}</span>
                        <span className="shrink-0 rounded bg-muted px-1 py-px text-[10px] text-muted-foreground">
                          {entry.documentType}
                        </span>
                        {entry.documentType === 'agenda' && (
                          <span className="shrink-0 rounded bg-emerald-100 px-1 py-px text-[10px] text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300">
                            agenda
                          </span>
                        )}
                        {saveEvents.length > 0 && (
                          <span
                            className={cn(
                              'shrink-0 rounded px-1 py-px text-[10px]',
                              saveErrorCount > 0
                                ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
                                : 'bg-muted text-muted-foreground',
                            )}
                          >
                            {saveErrorCount > 0
                              ? `${saveErrorCount} err`
                              : `${saveEvents.length} ev`}
                          </span>
                        )}
                      </div>
                      <div className="text-[10px] text-muted-foreground truncate">
                        {entry.scope} · {entry.subtitle} ·{' '}
                        {entry.lastLoadedAt
                          ? formatDistanceToNow(entry.lastLoadedAt, { addSuffix: true })
                          : 'unloaded'}
                      </div>
                    </div>
                    <button
                      type="button"
                      onClick={(e) => {
                        e.stopPropagation();
                        onCopy(safeStringify(entry.raw), entry.key);
                      }}
                      className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
                    >
                      {copiedId === entry.key ? (
                        <Check className="h-3 w-3" />
                      ) : (
                        <Copy className="h-3 w-3" />
                      )}
                    </button>
                  </div>

                  {isExpanded && (
                    <ExpandedDocContent
                      entry={entry}
                      saveEvents={saveEvents}
                      onCopy={onCopy}
                      copiedId={copiedId}
                    />
                  )}
                </div>
              );
            })}
          </div>
        )}
      </div>
    </div>
  );
};