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