TimelineDebuggerTab.tsx18.1 KBView on GitHub import { ChevronDown, ChevronRight, Copy, Check, Pause, Play, Trash2, X, Scissors } from 'lucide-react';
import React, { useSyncExternalStore, useState, useMemo, useEffect, useRef } from 'react';
import {
subscribe,
getVersion,
readAll,
clear,
replaceAll,
setMode,
getMode,
isMailTagged,
isPaused,
setPaused,
isCaptureBodies,
setCaptureBodies,
} from '@/modules/debugger/deepDebugger/buffer';
import {
formatAsJson,
formatAsMarkdown,
entryHeadline,
entryBody,
} from '@/modules/debugger/deepDebugger/format';
import type { TimelineEntry, TimelineSource } from '@/modules/debugger/deepDebugger/types';
import { cn } from '@/styles/stylingUtils';
const SOURCE_LABELS: Record<TimelineSource, string> = {
'trpc.request': 'tRPC →',
'trpc.response': 'tRPC ←',
'trpc.error': 'tRPC ✕',
zustand: 'zustand',
error: 'error',
perf: 'perf',
sse: 'sse',
freeze: 'freeze',
note: 'note',
};
const SOURCE_COLORS: Record<TimelineSource, string> = {
'trpc.request': 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800',
'trpc.response': 'bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800',
'trpc.error': 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800',
zustand: 'bg-amber-50 dark:bg-amber-950 border-amber-200 dark:border-amber-800',
error: 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800',
perf: 'bg-purple-50 dark:bg-purple-950 border-purple-200 dark:border-purple-800',
sse: 'bg-cyan-50 dark:bg-cyan-950 border-cyan-200 dark:border-cyan-800',
freeze: 'bg-indigo-50 dark:bg-indigo-950 border-indigo-200 dark:border-indigo-800',
note: 'bg-gray-50 dark:bg-gray-950 border-gray-200 dark:border-gray-800',
};
const ALL_SOURCES: TimelineSource[] = [
'trpc.request',
'trpc.response',
'trpc.error',
'zustand',
'error',
'perf',
'sse',
'freeze',
'note',
];
function fmtTime(ts: number): string {
const d = new Date(ts);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
export const TimelineDebuggerTab: React.FC = () => {
const version = useSyncExternalStore(subscribe, getVersion, () => 0);
const entries = useMemo(() => readAll(), [version]);
const [mode, setLocalMode] = useState(getMode());
const [paused, setLocalPaused] = useState(isPaused());
const [captureBodies, setLocalCaptureBodies] = useState(isCaptureBodies());
const [filterText, setFilterText] = useState('');
const [enabledSources, setEnabledSources] = useState<Set<TimelineSource>>(new Set(ALL_SOURCES));
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [copyFromInput, setCopyFromInput] = useState('');
const scrollRef = useRef<HTMLDivElement>(null);
// `mode` is a view filter: the buffer captures everything; in `mail` mode we
// hide non-mail entries here (and from copy/export, which operate on
// `filtered`). Switching to `all` reveals what was captured all along.
const filtered = useMemo(() => {
const text = filterText.trim().toLowerCase();
return entries.filter((e) => {
if (mode === 'mail' && !isMailTagged(e)) return false;
if (!enabledSources.has(e.source)) return false;
if (!text) return true;
const hay = `${e.source} ${entryHeadline(e)}`.toLowerCase();
return hay.includes(text);
});
}, [entries, enabledSources, filterText, mode]);
useEffect(() => {
if (scrollRef.current && !paused) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [filtered.length, paused]);
const toggleExpanded = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleSource = (s: TimelineSource) => {
setEnabledSources((prev) => {
const next = new Set(prev);
if (next.has(s)) next.delete(s);
else next.add(s);
return next;
});
};
const copyToClipboard = async (text: string, key=[redacted] => {
try {
await navigator.clipboard.writeText(text);
setCopiedKey(key);
setTimeout(() => setCopiedKey((k) => (k === key ? null : k)), 1500);
} catch {
// ignore
}
};
const copyAllMarkdown = () => copyToClipboard(formatAsMarkdown(filtered, { mode }), 'all-md');
const copyAllJson = () => copyToClipboard(formatAsJson(filtered), 'all-json');
const selectedIndex = useMemo(() => {
if (!selectedId) return -1;
return filtered.findIndex((e) => e.id === selectedId);
}, [filtered, selectedId]);
const copyFromIndex = (idx: number, fmt: 'md' | 'json') => {
if (idx < 0 || idx >= filtered.length) return;
const slice = filtered.slice(idx);
const text = fmt === 'md' ? formatAsMarkdown(slice, { mode }) : formatAsJson(slice);
copyToClipboard(text, `from-${idx}-${fmt}`);
};
const copyToIndex = (idx: number, fmt: 'md' | 'json') => {
if (idx < 0 || idx >= filtered.length) return;
const slice = filtered.slice(0, idx + 1);
const text = fmt === 'md' ? formatAsMarkdown(slice, { mode }) : formatAsJson(slice);
copyToClipboard(text, `to-${idx}-${fmt}`);
};
const deleteBeforeSelected = () => {
if (!selectedId) return;
const fullIdx = entries.findIndex((e) => e.id === selectedId);
if (fullIdx < 0) return;
replaceAll(entries.slice(fullIdx));
setSelectedId(null);
};
const deleteAfterSelected = () => {
if (!selectedId) return;
const fullIdx = entries.findIndex((e) => e.id === selectedId);
if (fullIdx < 0) return;
replaceAll(entries.slice(0, fullIdx + 1));
setSelectedId(null);
};
const handleCopyFromInput = (fmt: 'md' | 'json') => {
const n = parseInt(copyFromInput, 10);
if (!Number.isFinite(n) || n < 1 || n > filtered.length) return;
copyFromIndex(n - 1, fmt);
};
return (
<div className="flex h-full flex-col">
{/* Toolbar */}
<div className="flex flex-col gap-1 border-b border-gray-200 px-2 py-1.5 dark:border-gray-700">
<div className="flex items-center gap-2">
<div className="flex overflow-hidden rounded border border-gray-300 dark:border-gray-600">
<button
onClick={() => {
setMode('mail');
setLocalMode('mail');
}}
className={cn(
'px-2 py-0.5 text-xs',
mode === 'mail'
? 'bg-blue-500 text-white'
: 'bg-white text-gray-700 dark:bg-gray-800 dark:text-gray-300',
)}
>
Mail
</button>
<button
onClick={() => {
setMode('all');
setLocalMode('all');
}}
className={cn(
'px-2 py-0.5 text-xs',
mode === 'all'
? 'bg-blue-500 text-white'
: 'bg-white text-gray-700 dark:bg-gray-800 dark:text-gray-300',
)}
title="Capture everything (higher overhead)"
>
All
</button>
</div>
<input
type="text"
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
placeholder="Filter…"
className="flex-1 rounded border border-gray-300 px-2 py-0.5 text-xs dark:border-gray-600 dark:bg-gray-800"
/>
<button
onClick={() => {
const np = !paused;
setPaused(np);
setLocalPaused(np);
}}
className="rounded p-1 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
title={paused ? 'Resume capture' : 'Pause capture'}
>
{paused ? <Play className="h-3 w-3" /> : <Pause className="h-3 w-3" />}
</button>
<button
onClick={clear}
className="rounded p-1 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
title="Clear timeline"
>
<Trash2 className="h-3 w-3" />
</button>
<button
onClick={copyAllMarkdown}
className="flex items-center gap-1 rounded border border-gray-300 px-1.5 py-0.5 text-xs hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-800"
title="Copy as Markdown for LLM"
>
{copiedKey === 'all-md' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
MD
</button>
<button
onClick={copyAllJson}
className="flex items-center gap-1 rounded border border-gray-300 px-1.5 py-0.5 text-xs hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-800"
>
{copiedKey === 'all-json' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
JSON
</button>
</div>
<div className="flex flex-wrap items-center gap-1">
<label className="flex items-center gap-1 text-[10px] text-gray-600 dark:text-gray-400" title="Capture email bodies (PII!). Required for some mail.send debugging.">
<input
type="checkbox"
checked={captureBodies}
onChange={(e) => {
setCaptureBodies(e.target.checked);
setLocalCaptureBodies(e.target.checked);
}}
className="h-3 w-3"
/>
bodies
</label>
<span className="mx-1 text-gray-300 dark:text-gray-700">|</span>
{ALL_SOURCES.map((s) => (
<button
key={s}
onClick={() => toggleSource(s)}
className={cn(
'rounded px-1.5 py-0.5 text-[10px] transition-colors',
enabledSources.has(s)
? 'bg-gray-200 text-gray-900 dark:bg-gray-700 dark:text-gray-100'
: 'bg-gray-50 text-gray-400 dark:bg-gray-900 dark:text-gray-600',
)}
>
{SOURCE_LABELS[s]}
</button>
))}
<span className="ml-auto text-[10px] text-gray-500 dark:text-gray-400">
{filtered.length} / {entries.length}
</span>
</div>
{/* Range actions */}
<div className="flex flex-wrap items-center gap-1">
<span className="text-[10px] text-gray-500 dark:text-gray-400">Copy from #</span>
<input
type="number"
min={1}
max={filtered.length}
value={copyFromInput}
onChange={(e) => setCopyFromInput(e.target.value)}
placeholder="N"
className="w-14 rounded border border-gray-300 px-1 py-0.5 text-[10px] dark:border-gray-600 dark:bg-gray-800"
/>
<button
onClick={() => handleCopyFromInput('md')}
className="rounded border border-gray-300 px-1.5 py-0.5 text-[10px] hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-800"
title="Copy entries from #N onward as Markdown"
>
MD
</button>
<button
onClick={() => handleCopyFromInput('json')}
className="rounded border border-gray-300 px-1.5 py-0.5 text-[10px] hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-800"
title="Copy entries from #N onward as JSON"
>
JSON
</button>
{selectedIndex >= 0 ? (
<>
<span className="mx-1 text-gray-300 dark:text-gray-700">|</span>
<span className="rounded bg-yellow-100 px-1.5 py-0.5 text-[10px] font-medium text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
Selected #{selectedIndex + 1}
</span>
<button
onClick={() => copyFromIndex(selectedIndex, 'md')}
className="flex items-center gap-0.5 rounded border border-gray-300 px-1.5 py-0.5 text-[10px] hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-800"
title="Copy from selected onward as Markdown"
>
{copiedKey === `from-${selectedIndex}-md` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
from
</button>
<button
onClick={() => copyToIndex(selectedIndex, 'md')}
className="flex items-center gap-0.5 rounded border border-gray-300 px-1.5 py-0.5 text-[10px] hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-800"
title="Copy up to selected as Markdown"
>
{copiedKey === `to-${selectedIndex}-md` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
to
</button>
<button
onClick={deleteBeforeSelected}
className="flex items-center gap-0.5 rounded border border-red-300 px-1.5 py-0.5 text-[10px] text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-950"
title="Delete all entries before selected"
>
<Scissors className="h-3 w-3" />
del before
</button>
<button
onClick={deleteAfterSelected}
className="flex items-center gap-0.5 rounded border border-red-300 px-1.5 py-0.5 text-[10px] text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-950"
title="Delete all entries after selected"
>
<Scissors className="h-3 w-3" />
del after
</button>
<button
onClick={() => setSelectedId(null)}
className="rounded p-0.5 hover:bg-gray-100 dark:hover:bg-gray-800"
title="Clear selection"
>
<X className="h-3 w-3" />
</button>
</>
) : (
<span className="text-[10px] italic text-gray-400 dark:text-gray-500">
click #N on a row to select
</span>
)}
</div>
</div>
{/* Entries */}
<div ref={scrollRef} className="flex-1 space-y-1 overflow-y-auto p-2">
{filtered.length === 0 ? (
<div className="py-4 text-center text-xs text-gray-500 dark:text-gray-400">
No entries match. Interact with mail or toggle "All" mode.
</div>
) : (
filtered.map((entry, idx) => (
<TimelineEntryRow
key=[redacted]
entry={entry}
index={idx + 1}
selected={selectedId === entry.id}
onSelect={() =>
setSelectedId((prev) => (prev === entry.id ? null : entry.id))
}
expanded={expanded.has(entry.id)}
onToggle={() => toggleExpanded(entry.id)}
onCopy={copyToClipboard}
copiedKey=[redacted]
/>
))
)}
</div>
</div>
);
};
interface RowProps {
entry: TimelineEntry;
index: number;
selected: boolean;
onSelect: () => void;
expanded: boolean;
onToggle: () => void;
onCopy: (text: string, key=[redacted] => void;
copiedKey=[redacted] | null;
}
const TimelineEntryRow: React.FC<RowProps> = ({
entry,
index,
selected,
onSelect,
expanded,
onToggle,
onCopy,
copiedKey,
}) => {
// Expanded view shows the compact diff (changed leaves only); the copy
// button below grabs the full before/after snapshot for deeper context.
const body = entryBody(entry, { compact: true });
const fullBody = entryBody(entry);
const headline = entryHeadline(entry);
return (
<div
className={cn(
'rounded border transition-all',
SOURCE_COLORS[entry.source],
selected && 'ring-2 ring-yellow-500 dark:ring-yellow-400',
)}
>
<div
className="flex cursor-pointer items-center gap-1.5 px-2 py-1"
onClick={onToggle}
>
{expanded ? (
<ChevronDown className="h-3 w-3 shrink-0" />
) : (
<ChevronRight className="h-3 w-3 shrink-0" />
)}
<button
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
className={cn(
'min-w-[28px] shrink-0 rounded px-1 py-0.5 font-mono text-[10px] tabular-nums transition-colors',
selected
? 'bg-yellow-400 text-yellow-950 hover:bg-yellow-500 dark:bg-yellow-500 dark:text-yellow-950'
: 'bg-white/60 text-gray-600 hover:bg-yellow-100 dark:bg-black/30 dark:text-gray-400 dark:hover:bg-yellow-900',
)}
title={selected ? 'Click to deselect' : 'Click to select as range anchor'}
>
#{index}
</button>
<span className="font-mono text-[10px] text-gray-500 dark:text-gray-400">
{fmtTime(entry.ts)}
</span>
<span className="rounded bg-white/60 px-1 py-0.5 text-[10px] dark:bg-black/30">
{SOURCE_LABELS[entry.source]}
</span>
<span className="flex-1 truncate font-mono text-xs">{headline}</span>
<button
onClick={(e) => {
e.stopPropagation();
onCopy(`### ${fmtTime(entry.ts)} [${entry.source}] ${headline}\n\`\`\`json\n${fullBody}\n\`\`\``, entry.id);
}}
className="rounded p-0.5 hover:bg-white/60 dark:hover:bg-black/30"
title="Copy entry (full snapshot)"
>
{copiedKey === entry.id ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
{expanded && (
<pre className="max-h-80 overflow-auto border-t border-black/10 bg-white/50 p-2 text-[11px] dark:border-white/10 dark:bg-black/30">
{body}
</pre>
)}
</div>
);
};