import { useVirtualizer } from '@tanstack/react-virtual'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSession } from '../../hooks/useSession'; import { useWire } from '../../hooks/useWire'; import { computeIssues, topSeverity } from '../../lib/issues'; import type { AgentRecord, WireEntry } from '../../types'; import { IssuesDrawer } from './IssuesDrawer'; import { WireRow, type PairHint } from './WireRow'; interface PairRecord { callLineNo: number | null; resultLineNo: number | null; callTime: number | null; resultTime: number | null; } /** Scan all entries and pair every `tool.call` with its `tool.result` * by `toolCallId`. Used to render the inline "→ #N" / "← #N" cross- * references, the call→result duration, and to drive the hover-pair * highlight. */ function computePairMap(entries: readonly WireEntry[]): Map { const map = new Map(); const ensure = (id: string): PairRecord => { const existing = map.get(id); if (existing) return existing; const fresh: PairRecord = { callLineNo: null, resultLineNo: null, callTime: null, resultTime: null }; map.set(id, fresh); return fresh; }; for (const entry of entries) { if (entry.data.type !== 'context.append_loop_event') continue; const ev = entry.data.event; const time = entry.data.time ?? null; if (ev.type === 'tool.call') { const rec = ensure(ev.toolCallId); rec.callLineNo = entry.lineNo; rec.callTime = time; } else if (ev.type === 'tool.result') { const rec = ensure(ev.toolCallId); rec.resultLineNo = entry.lineNo; rec.resultTime = time; } } return map; } function pairInfoFor(record: AgentRecord, map: Map): PairHint | undefined { if (record.type !== 'context.append_loop_event') return undefined; const ev = record.event; if (ev.type !== 'tool.call' && ev.type !== 'tool.result') return undefined; const entry = map.get(ev.toolCallId); if (entry === undefined) return undefined; const durationMs = entry.callTime !== null && entry.resultTime !== null ? entry.resultTime - entry.callTime : null; return { toolCallId: ev.toolCallId, kind: ev.type === 'tool.call' ? 'call' : 'result', callLineNo: entry.callLineNo, resultLineNo: entry.resultLineNo, durationMs, }; } interface WireTabProps { sessionId: string; /** Override starting agentId; defaults to 'main'. */ initialAgentId?: string; } export function WireTab({ sessionId, initialAgentId = 'main' }: WireTabProps) { const [agentId, setAgentId] = useState(initialAgentId); // Re-sync when the route changes either the session or the agent id // while this component stays mounted. Without `sessionId` in the deps, // navigating /sessions/A → /sessions/B (with default initialAgentId) // would preserve a subagent selection from the previous session and // 404 on the new one. useEffect(() => { setAgentId(initialAgentId); }, [sessionId, initialAgentId]); const { data: detail } = useSession(sessionId); const { data: wire, isLoading, error } = useWire(sessionId, agentId); const parentRef = useRef(null); const [search, setSearch] = useState(''); const [expanded, setExpanded] = useState>(new Set()); const [drawerOpen, setDrawerOpen] = useState(false); const [hoveredPairId, setHoveredPairId] = useState(null); const entries: WireEntry[] = useMemo(() => { return (wire?.records ?? []) as WireEntry[]; }, [wire?.records]); const warnings = wire?.warnings ?? []; const pairMap = useMemo(() => computePairMap(entries), [entries]); // Precompute the per-entry PairHint so the object identity is stable // across hover state changes. Without this, every hover would create // fresh pair objects and bust WireRow's memo for every tool row. const pairByLineNo = useMemo(() => { const m = new Map(); for (const entry of entries) { const p = pairInfoFor(entry.data, pairMap); if (p !== undefined) m.set(entry.lineNo, p); } return m; }, [entries, pairMap]); const onHoverPair = useCallback((id: string | null) => { setHoveredPairId(id); }, []); const filtered = useMemo(() => { if (search.length === 0) return entries; const needle = search.toLowerCase(); return entries.filter((e) => { if (e.data.type.toLowerCase().includes(needle)) return true; try { return JSON.stringify(e.data).toLowerCase().includes(needle); } catch { return false; } }); }, [entries, search]); const issues = useMemo(() => computeIssues(entries, warnings), [entries, warnings]); const issuesSeverity = topSeverity(issues); const virt = useVirtualizer({ count: filtered.length, getScrollElement: () => parentRef.current, estimateSize: () => 28, overscan: 10, getItemKey: (i) => filtered[i]?.lineNo ?? i, }); const toggle = useCallback((lineNo: number) => { setExpanded((prev) => { const next = new Set(prev); if (next.has(lineNo)) next.delete(lineNo); else next.add(lineNo); return next; }); }, []); const filteredLineIdx = useMemo(() => { const m = new Map(); for (let i = 0; i < filtered.length; i += 1) { const e = filtered[i]; if (e !== undefined) m.set(e.lineNo, i); } return m; }, [filtered]); const jumpToLine = useCallback( (lineNo: number) => { const idx = filteredLineIdx.get(lineNo); if (idx === undefined) return; virt.scrollToIndex(idx, { align: 'center' }); setExpanded((prev) => (prev.has(lineNo) ? prev : new Set(prev).add(lineNo))); }, [filteredLineIdx, virt], ); const expandAll = () => { setExpanded(new Set(filtered.map((e) => e.lineNo))); }; const collapseAll = () => { setExpanded(new Set()); }; const agents = detail?.agents ?? []; return (
{/* Toolbar */}
{ setSearch(e.target.value); }} className="w-80 border border-border bg-surface-0 px-2 py-1 font-mono text-[12px] text-fg-0 placeholder:text-fg-3 focus:border-border-strong focus:outline-none" />
{filtered.length} / {entries.length} ev {issues.length > 0 && issuesSeverity !== null ? ( ) : null}
{warnings.length > 0 ? (
{warnings.length} warning{warnings.length > 1 ? 's' : ''} · first: {warnings[0]}
) : null} {isLoading ? (
loading wire…
) : error ? (
{(error as Error).message}
) : (
{filtered.length === 0 ? (
no records match the current filter
) : (
{virt.getVirtualItems().map((vi) => { const e = filtered[vi.index]; if (!e) return null; const pair = pairByLineNo.get(e.lineNo); const highlighted = pair !== undefined && hoveredPairId === pair.toolCallId; return (
{ toggle(e.lineNo); }} onJumpTo={jumpToLine} pair={pair} highlighted={highlighted} onHoverPair={onHoverPair} />
); })}
)}
)} {drawerOpen ? ( { setDrawerOpen(false); }} onJumpTo={jumpToLine} isLineVisible={(lineNo) => filteredLineIdx.has(lineNo)} /> ) : null}
); }