import { useT } from "@agent-native/core/client/i18n"; import { IconArrowUpRight, IconChevronRight, IconCloudDataConnection, IconPlayerPlay, IconSearch, IconTerminal2, } from "@tabler/icons-react"; import { type ReactNode, type PointerEvent as ReactPointerEvent, useEffect, useMemo, useRef, useState, } from "react"; import { Link } from "react-router"; import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import { useErrorsT } from "../monitoring/errors/i18n"; import { type ConsoleLevelFilter, consoleLevelBucket, filterConsoleEntries, filterNetworkEntries, formatOffsetClock, latestEntryIndexAt, middleTruncate, type NetworkKindFilter, networkDisplayUrl, type ReplayConsoleEntry, type ReplayDevToolsDiagnostics, type ReplayNetworkEntry, } from "./session-replay-devtools"; /** * A session console error line resolved to its captured, Sentry-style issue. * Keyed by `ReplayConsoleEntry.id`, computed server-side by `match-error-issues` * so the resolution shares one fingerprint implementation with ingest. */ export type SessionIssueMatch = { issueId: string; status: string }; /** Deep-link from a session error to the Monitoring → Errors issue detail. */ export function issueDetailPath(issueId: string): string { return `/monitoring?view=errors&issue=${encodeURIComponent(issueId)}`; } /** Search Monitoring for all captured issues resembling an unmatched line. */ export function issueSearchPath(message: string): string { const params = new URLSearchParams({ view: "errors", status: "all", q: message, }); return `/monitoring?${params.toString()}`; } /** Pause row auto-follow for a while after the user scrolls the list. */ const MANUAL_SCROLL_FOLLOW_PAUSE_MS = 4000; const DEVTOOLS_ROW_HEIGHT = 34; const DEVTOOLS_EXPANDED_ESTIMATE = 220; const DEVTOOLS_OVERSCAN_ROWS = 10; const DEVTOOLS_MIN_HEIGHT = 180; const DEVTOOLS_MAX_HEIGHT = 620; /** * Layout offsets for the virtualized Dev Tools list. Expanded rows reserve * extra height so details render inline under the selected line without * disabling virtualization for the rest of the list. */ export function buildDevToolsRowOffsets( entryCount: number, expandedIndex: number, expandedHeight = DEVTOOLS_EXPANDED_ESTIMATE, ): number[] { const offsets = new Array(entryCount + 1); offsets[0] = 0; for (let index = 0; index < entryCount; index += 1) { const height = index === expandedIndex ? expandedHeight : DEVTOOLS_ROW_HEIGHT; offsets[index + 1] = offsets[index] + height; } return offsets; } export function SessionDevToolsPanel({ diagnostics, currentTime, height, maxHeight = DEVTOOLS_MAX_HEIGHT, onHeightChange, onSeek, issueMatches, issueMatching = false, }: { diagnostics: ReplayDevToolsDiagnostics; currentTime: number; height: number; maxHeight?: number; onHeightChange: (height: number) => void; onSeek: (ms: number) => void; /** Resolved error issues by console entry id, for cross-linking to Errors. */ issueMatches?: ReadonlyMap; /** Prevent an unmatched fallback from flashing while issue lookup is active. */ issueMatching?: boolean; }) { const t = useT(); const [tab, setTab] = useState<"console" | "network">("console"); const [consoleLevel, setConsoleLevel] = useState("all"); const [consoleQuery, setConsoleQuery] = useState(""); const [networkKind, setNetworkKind] = useState("all"); const [networkQuery, setNetworkQuery] = useState(""); const [selectedConsoleId, setSelectedConsoleId] = useState( null, ); const [selectedNetworkId, setSelectedNetworkId] = useState( null, ); const filteredConsole = useMemo( () => filterConsoleEntries(diagnostics.console, consoleLevel, consoleQuery), [diagnostics.console, consoleLevel, consoleQuery], ); const filteredNetwork = useMemo( () => filterNetworkEntries(diagnostics.network, networkKind, networkQuery), [diagnostics.network, networkKind, networkQuery], ); const activeConsoleId = tab === "console" ? (filteredConsole[latestEntryIndexAt(filteredConsole, currentTime)] ?.id ?? null) : null; const activeNetworkId = tab === "network" ? (filteredNetwork[latestEntryIndexAt(filteredNetwork, currentTime)] ?.id ?? null) : null; useEffect(() => { if ( selectedConsoleId && !filteredConsole.some((entry) => entry.id === selectedConsoleId) ) { setSelectedConsoleId(null); } }, [filteredConsole, selectedConsoleId]); useEffect(() => { if ( selectedNetworkId && !filteredNetwork.some((entry) => entry.id === selectedNetworkId) ) { setSelectedNetworkId(null); } }, [filteredNetwork, selectedNetworkId]); const consoleLevelCounts = useMemo(() => { const counts = { log: 0, info: 0, warn: 0, error: 0 }; for (const entry of diagnostics.console) { counts[consoleLevelBucket(entry.level)] += 1; } return counts; }, [diagnostics.console]); const networkKindCounts = useMemo(() => { const counts = { fetch: 0, xhr: 0, failed: 0 }; for (const entry of diagnostics.network) { counts[entry.api] += 1; if (entry.failed) counts.failed += 1; } return counts; }, [diagnostics.network]); return (
setTab(value as "console" | "network")} className="flex min-h-0 flex-1 flex-col" >
{t("sessions.devtoolsConsoleTab", { count: String(diagnostics.console.length), })} {diagnostics.consoleErrorCount > 0 ? ( ) : null} {t("sessions.devtoolsNetworkTab", { count: String(diagnostics.network.length), })} {diagnostics.networkFailedCount > 0 ? ( ) : null}
setConsoleLevel("all")} /> setConsoleLevel("log")} /> setConsoleLevel("info")} /> setConsoleLevel("warn")} tone="warn" /> setConsoleLevel("error")} tone="error" />
( setSelectedConsoleId((current) => current === entry.id ? null : entry.id, ) } onSeek={onSeek} /> )} />
setNetworkKind("all")} /> setNetworkKind("fetch")} /> setNetworkKind("xhr")} /> setNetworkKind("failed")} tone="error" />
( setSelectedNetworkId((current) => current === entry.id ? null : entry.id, ) } onSeek={onSeek} /> )} />
); } function DevToolsResizeHandle({ height, maxHeight, onHeightChange, }: { height: number; maxHeight: number; onHeightChange: (height: number) => void; }) { const t = useT(); const cappedMax = Math.max(DEVTOOLS_MIN_HEIGHT, maxHeight); function handlePointerDown(event: ReactPointerEvent) { event.preventDefault(); const startY = event.clientY; const startHeight = height; function handlePointerMove(moveEvent: PointerEvent) { onHeightChange( clamp( startHeight - (moveEvent.clientY - startY), Math.min(DEVTOOLS_MIN_HEIGHT, cappedMax), cappedMax, ), ); } function handlePointerUp() { window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerUp); } window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp, { once: true }); } return (
); } function VirtualizedDevToolsList({ entries, activeEntryId, expandedEntryId, emptyMessage, renderRow, }: { entries: T[]; activeEntryId: string | null; expandedEntryId: string | null; emptyMessage: string; renderRow: (entry: T, expanded: boolean) => ReactNode; }) { const containerRef = useRef(null); const lastManualScrollAtRef = useRef(0); const [scrollTop, setScrollTop] = useState(0); const [viewportHeight, setViewportHeight] = useState(0); const [expandedRowHeight, setExpandedRowHeight] = useState( DEVTOOLS_EXPANDED_ESTIMATE, ); const [expandedElement, setExpandedElement] = useState( null, ); const activeIndex = activeEntryId ? entries.findIndex((entry) => entry.id === activeEntryId) : -1; const expandedIndex = expandedEntryId ? entries.findIndex((entry) => entry.id === expandedEntryId) : -1; const rowOffsets = useMemo( () => buildDevToolsRowOffsets(entries.length, expandedIndex, expandedRowHeight), [entries.length, expandedIndex, expandedRowHeight], ); const totalHeight = rowOffsets[entries.length] ?? 0; useEffect(() => { setExpandedRowHeight(DEVTOOLS_EXPANDED_ESTIMATE); }, [expandedEntryId]); useEffect(() => { if (!expandedElement) return; const update = () => { const measured = Math.ceil( expandedElement.getBoundingClientRect().height, ); if (measured > 0) setExpandedRowHeight(measured); }; update(); const observer = new ResizeObserver(update); observer.observe(expandedElement); return () => observer.disconnect(); }, [expandedElement]); useEffect(() => { const el = containerRef.current; if (!el) return; const update = () => setViewportHeight(el.clientHeight); update(); const observer = new ResizeObserver(update); observer.observe(el); return () => observer.disconnect(); }, []); useEffect(() => { if (activeIndex < 0) return; if ( Date.now() - lastManualScrollAtRef.current < MANUAL_SCROLL_FOLLOW_PAUSE_MS ) { return; } const el = containerRef.current; if (!el) return; const rowTop = rowOffsets[activeIndex] ?? 0; const rowBottom = rowOffsets[activeIndex + 1] ?? rowTop + DEVTOOLS_ROW_HEIGHT; if (rowTop >= el.scrollTop && rowBottom <= el.scrollTop + el.clientHeight) { return; } el.scrollTo({ top: Math.max(0, rowTop - el.clientHeight / 2 + DEVTOOLS_ROW_HEIGHT), }); }, [activeIndex, entries.length, rowOffsets]); const markManualScroll = () => { lastManualScrollAtRef.current = Date.now(); }; if (!entries.length) { return (
); } const measuredHeight = viewportHeight || 240; let startIndex = 0; while ( startIndex < entries.length && (rowOffsets[startIndex + 1] ?? 0) < scrollTop ) { startIndex += 1; } startIndex = clamp(startIndex - DEVTOOLS_OVERSCAN_ROWS, 0, entries.length); let endIndex = startIndex; while ( endIndex < entries.length && (rowOffsets[endIndex] ?? 0) < scrollTop + measuredHeight ) { endIndex += 1; } endIndex = clamp( endIndex + DEVTOOLS_OVERSCAN_ROWS, startIndex, entries.length, ); const visibleEntries = entries.slice(startIndex, endIndex); return (
setScrollTop(event.currentTarget.scrollTop)} onWheel={markManualScroll} onPointerDown={markManualScroll} onTouchMove={markManualScroll} >
{visibleEntries.map((entry, offset) => { const index = startIndex + offset; const expanded = entry.id === expandedEntryId; const top = rowOffsets[index] ?? 0; return (
{renderRow(entry, expanded)}
); })}
); } function FilterChip({ label, active, onClick, tone = "default", }: { label: string; active: boolean; onClick: () => void; tone?: "default" | "warn" | "error"; }) { return ( ); } function DevToolsSearchInput({ value, onChange, placeholder, }: { value: string; onChange: (value: string) => void; placeholder: string; }) { return (
onChange(event.target.value)} />
); } function DevToolsEmptyState({ message }: { message: string }) { return (
{message}
); } function JumpToButton({ offsetMs, onSeek, }: { offsetMs: number; onSeek: (ms: number) => void; }) { const t = useT(); return ( ); } /** * Compact link from a captured session error to its Errors issue detail. Kept * outside the row's toggle ` {issueMatch ? : null} {!issueMatch && !issueMatching && bucket === "error" ? ( ) : null} {selected ? (
              {entry.message || entry.level}
            
{entry.url ? (

{entry.url}

) : null} {entry.args.length ? (
                {entry.args.join("\n")}
              
) : null} {entry.stack ? (
                {entry.stack}
              
) : null} {issueMatch ? (
) : !issueMatching && bucket === "error" ? (
) : null}
) : null} ); } function NetworkRow({ entry, active, selected, onSelect, onSeek, }: { entry: ReplayNetworkEntry; active: boolean; selected: boolean; onSelect: () => void; onSeek: (ms: number) => void; }) { const t = useT(); const displayUrl = middleTruncate(networkDisplayUrl(entry.url), 72); return (
{selected ? (
0 ? String(entry.status) : t("sessions.devtoolsFailedStatus") } />

{entry.url}

{entry.error ? (
                {entry.error}
              
) : null} {entry.responseBody ? (
                {entry.responseBody}
              
) : null}
) : null}
); } function DetailValue({ label, value }: { label: string; value: string }) { return (

{label}

{value}

); } function DetailField({ label, children, }: { label: string; children: ReactNode; }) { return (

{label}

{children}
); } function clamp(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; return Math.min(max, Math.max(min, value)); }