"use client"; import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { useAuiState } from "@assistant-ui/store"; import { SpanPrimitive, type SpanState } from "@assistant-ui/react-o11y"; import { WaterfallRow } from "./waterfall-row"; const LABEL_WIDTH = 200; const MAX_LIST_HEIGHT = 400; const MIN_ZOOM = 1; const MAX_ZOOM = 20; const RIGHT_PADDING_RATIO = 0.08; export type WaterfallLayoutContextValue = { barWidth: number; timeRange: { min: number; max: number }; barHeight: number; contentWidth: number; selectedSpanId: string | null; onSelectSpan: (spanId: string) => void; }; export const WaterfallLayoutContext = createContext(null); export function useWaterfallLayout(): WaterfallLayoutContextValue { const ctx = useContext(WaterfallLayoutContext); if (!ctx) { throw new Error( "useWaterfallLayout must be used within WaterfallLayoutContext", ); } return ctx; } function formatTime(ms: number): string { if (ms < 1000) return `${Math.round(ms)} ms`; return `${(ms / 1000).toFixed(1)} s`; } function TimeAxisTicks({ timeRange, barWidth, }: { timeRange: { min: number; max: number }; barWidth: number; }) { const duration = timeRange.max - timeRange.min; const tickCount = Math.min(5, Math.max(2, Math.floor(barWidth / 100))); const ticks = Array.from({ length: tickCount + 1 }, (_, i) => { const t = (i / tickCount) * duration; const x = (i / tickCount) * barWidth; return { t, x }; }); return ( ); } export function WaterfallTimeline() { const outerRef = useRef(null); const scrollRef = useRef(null); const [baseBarWidth, setBaseBarWidth] = useState(400); const [zoom, setZoom] = useState(1); const zoomRef = useRef(1); const baseBarWidthRef = useRef(400); const scrollAdjustRef = useRef<{ mouseX: number; ratio: number; } | null>(null); const [selectedSpanId, setSelectedSpanId] = useState(null); const barWidth = Math.max(200, Math.round(baseBarWidth * zoom)); const hasSpans = useAuiState((s) => s.span.hasChildren); const timeRange = useAuiState( (s) => s.span.timeRange, ) as SpanState["timeRange"]; // Measure outer container width — re-attach when scroll container mounts useEffect(() => { const el = outerRef.current; if (!el) return; const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) { const w = Math.max(200, entry.contentRect.width - LABEL_WIDTH); baseBarWidthRef.current = w; setBaseBarWidth(w); } }); observer.observe(el); return () => observer.disconnect(); }, [hasSpans]); // Cmd + scroll wheel zoom — re-attach when scroll container mounts useEffect(() => { const el = scrollRef.current; if (!el) return; const handleWheel = (e: WheelEvent) => { if (!e.metaKey) return; e.preventDefault(); const rect = el.getBoundingClientRect(); const mouseXInView = e.clientX - rect.left - LABEL_WIDTH; const mouseXInContent = el.scrollLeft + mouseXInView; const currentBarWidth = baseBarWidthRef.current * zoomRef.current; const ratio = currentBarWidth > 0 ? mouseXInContent / currentBarWidth : 0; const newZoom = Math.max( MIN_ZOOM, Math.min(MAX_ZOOM, zoomRef.current * 2 ** (-e.deltaY * 0.003)), ); scrollAdjustRef.current = { mouseX: mouseXInView, ratio }; zoomRef.current = newZoom; setZoom(newZoom); }; el.addEventListener("wheel", handleWheel, { passive: false }); return () => el.removeEventListener("wheel", handleWheel); }, [hasSpans]); // Adjust scroll after zoom useLayoutEffect(() => { const adj = scrollAdjustRef.current; const el = scrollRef.current; if (!adj || !el) return; scrollAdjustRef.current = null; const newBarX = adj.ratio * barWidth; el.scrollLeft = newBarX - adj.mouseX; }, [barWidth]); const renderTimeRange = useMemo(() => { const duration = timeRange.max - timeRange.min; return { min: timeRange.min, max: timeRange.max + duration * RIGHT_PADDING_RATIO, }; }, [timeRange]); const contentWidth = LABEL_WIDTH + barWidth; const scrollMaxHeight = MAX_LIST_HEIGHT + 28; const layoutValue = useMemo( () => ({ barWidth, timeRange: renderTimeRange, barHeight: 32, contentWidth, selectedSpanId, onSelectSpan: setSelectedSpanId, }), [barWidth, renderTimeRange, contentWidth, selectedSpanId], ); if (!hasSpans) { return (
No spans recorded.
); } return (
{/* Time axis */}
Span
{/* Span rows */}
{ const target = e.target as HTMLElement; const el = target.closest("[data-span-id]") as HTMLElement | null; if (el?.dataset.spanId) { setSelectedSpanId(el.dataset.spanId); } }} > {() => }
{/* Legend */}
action
api
tool
flow
pipeline
); }