/** * Work Timeline — custom-SVG Gantt (board-locked Direction C, PAP-12422). * * Renders actor rows with concurrency sub-lanes, run bars (no issue IDs on the * bar — identity is the thin left colour tab; truncated title shows on hover), * human kickoff chips at the first matching run's leading edge, straight * hover-revealed agent→agent delegation connectors (dashed for retries), an * in-progress fade to "now", a hover tooltip, and a full-window mini-map with a * draggable brush. */ import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation } from "@/lib/router"; import type { WorkTimelineActor, WorkTimelineResult } from "@paperclipai/shared"; import { applyCompanyPrefix, extractCompanyPrefixFromPath } from "@/lib/company-routes"; import { getAgentIcon } from "@/lib/agent-icons"; import { AXIS_H, actorType, barColor, chooseTickStepMs, computeLayout, formatDuration, isCancelledStatus, shortLabel, TIMELINE_COLORS, type LayoutOptions, type PositionedBar, } from "@/lib/timeline/layout"; export type ZoomLevel = "hour" | "day" | "week"; export interface VisibleTimelineWindow { fromMs: number; toMs: number; } const ZOOM_DURATION_MIN: Record = { hour: 60, day: 24 * 60, week: 7 * 24 * 60, }; const MIN_PX_PER_MIN = 0.08; const MAX_PX_PER_MIN = 12; const DEFAULT_VIEWPORT_W = 960; const MIN_MINIMAP_SELECTION_MS = 15 * 60 * 1000; function plotViewportWidth(viewportWidth: number): number { return Math.max(240, viewportWidth - GEOM.gutter - 24); } function clampTime(ms: number, fromMs: number, toMs: number): number { return Math.max(fromMs, Math.min(toMs, ms)); } function visibleWindowForScroll( layout: Pick, "fromMs" | "toMs" | "pxPerMinute">, scrollLeft: number, viewportWidth: number, ): VisibleTimelineWindow { const plotWidth = plotViewportWidth(viewportWidth); const fromMs = clampTime( layout.fromMs + (scrollLeft / layout.pxPerMinute) * 60000, layout.fromMs, layout.toMs, ); const toMs = clampTime( layout.fromMs + ((scrollLeft + plotWidth) / layout.pxPerMinute) * 60000, layout.fromMs, layout.toMs, ); return { fromMs, toMs: Math.max(fromMs, toMs) }; } export function zoomScaleForLevel(level: ZoomLevel, viewportWidth = DEFAULT_VIEWPORT_W): number { return clampZoomScale(plotViewportWidth(viewportWidth) / ZOOM_DURATION_MIN[level]); } export function nearestZoomForScale(pxPerMinute: number, viewportWidth = DEFAULT_VIEWPORT_W): ZoomLevel { return (Object.entries(ZOOM_DURATION_MIN) as [ZoomLevel, number][]).reduce((best, [level]) => ( Math.abs(zoomScaleForLevel(level, viewportWidth) - pxPerMinute) < Math.abs(zoomScaleForLevel(best, viewportWidth) - pxPerMinute) ? level : best ), "day"); } export function clampZoomScale(pxPerMinute: number): number { return Math.min(MAX_PX_PER_MIN, Math.max(MIN_PX_PER_MIN, pxPerMinute)); } /** Pick an initial zoom whose plotted width comfortably fills a typical viewport. */ export function defaultZoomForWindow(fromMs: number, toMs: number): ZoomLevel { const hours = (toMs - fromMs) / 3_600_000; if (hours <= 4) return "hour"; if (hours <= 48) return "day"; return "week"; } const GEOM: Omit = { gutter: 176, rowH: 34, barH: 15, laneGap: 4, }; const AVATAR_R = 11; const CHIP_R = 9; interface TooltipState { x: number; y: number; bar: PositionedBar; connectorHint: string | null; } interface DragSelectionState { anchorX: number; currentX: number; } function fmtClock(ms: number): string { const d = new Date(ms); const hasMinutes = d.getMinutes() !== 0; return d.toLocaleTimeString("en-US", { hour: "numeric", minute: hasMinutes ? "2-digit" : undefined, hour12: true, }); } function fmtTick(ms: number, stepMs: number): string { const d = new Date(ms); const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); if (stepMs >= 24 * 60 * 60 * 1000) { return date; } return `${date}, ${fmtClock(ms)}`; } export function formatVisibleDurationMinutes(minutes: number): string { const rounded = Math.max(1, Math.round(minutes)); if (rounded >= 7 * 24 * 60 && rounded % (7 * 24 * 60) === 0) { const weeks = rounded / (7 * 24 * 60); return `${weeks} week${weeks === 1 ? "" : "s"} visible`; } if (rounded >= 24 * 60 && rounded % (24 * 60) === 0) { const days = rounded / (24 * 60); return `${days} day${days === 1 ? "" : "s"} visible`; } if (rounded >= 24 * 60) { const days = Math.floor(rounded / (24 * 60)); const hours = Math.round((rounded % (24 * 60)) / 60); return `${days}d${hours > 0 ? ` ${hours}h` : ""} visible`; } if (rounded >= 60 && rounded % 60 === 0) { const hours = rounded / 60; return `${hours} hour${hours === 1 ? "" : "s"} visible`; } if (rounded >= 60) return `${Math.floor(rounded / 60)}h ${rounded % 60}m visible`; return `${rounded} minutes visible`; } function truncate(text: string, n = 42): string { return text.length > n ? `${text.slice(0, n - 1)}…` : text; } function svgFragmentId(value: string): string { return value.replace(/[^a-zA-Z0-9_-]/g, "-"); } /** An SVG avatar glyph: agents use their configured sidebar icon, humans use their avatar image. */ function ActorGlyph({ actor, cx, cy, r, clipId, }: { actor: WorkTimelineActor; cx: number; cy: number; r: number; clipId: string; }) { if (actor.type === "agent") { const Icon = getAgentIcon(actor.avatar); const size = r > 10 ? 16 : 13; return ( ); } const stroke = "var(--color-foreground)"; const fill = actor.type === "system" ? "var(--color-muted)" : "var(--color-card)"; const label = shortLabel(actor.name); if (actor.type === "user" && actor.avatar) { return ( ); } return ( {actor.type === "user" ? ( ) : ( )} 10 ? 9 : 8} textAnchor="middle" fill={stroke}> {label} ); } export interface WorkTimelineChartProps { data: WorkTimelineResult; zoom: ZoomLevel; zoomScale?: number; onZoomScaleChange?: (nextScale: number, nextZoom: ZoomLevel) => void; onVisibleRangeLabelChange?: (label: string) => void; onVisibleWindowChange?: (window: VisibleTimelineWindow) => void; /** override "now" (tests / stories); defaults to Date.now(). */ nowMs?: number; } export function WorkTimelineChart({ data, zoom, zoomScale, onZoomScaleChange, onVisibleRangeLabelChange, onVisibleWindowChange, nowMs, }: WorkTimelineChartProps) { const location = useLocation(); const scrollRef = useRef(null); const initialWindowKeyRef = useRef(null); const centerMsRef = useRef(null); const defaultNowRef = useRef(null); const documentDragCleanupRef = useRef<(() => void) | null>(null); const [tooltip, setTooltip] = useState(null); const [hoveredRunId, setHoveredRunId] = useState(null); const [scrollLeft, setScrollLeft] = useState(0); const [viewportW, setViewportW] = useState(0); const [dragSelection, setDragSelection] = useState(null); const clearDocumentDrag = () => { documentDragCleanupRef.current?.(); documentDragCleanupRef.current = null; }; const setDocumentDrag = (move: (event: MouseEvent) => void, up: (event: MouseEvent) => void) => { clearDocumentDrag(); const handleUp = (event: MouseEvent) => { clearDocumentDrag(); up(event); }; document.addEventListener("mousemove", move); document.addEventListener("mouseup", handleUp); documentDragCleanupRef.current = () => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", handleUp); }; }; useEffect(() => () => clearDocumentDrag(), []); if (defaultNowRef.current == null) defaultNowRef.current = Date.now(); const now = nowMs ?? defaultNowRef.current; const pxPerMinute = zoomScale ?? zoomScaleForLevel(zoom, viewportW || DEFAULT_VIEWPORT_W); const layout = useMemo( () => computeLayout(data, { ...GEOM, pxPerMinute, nowMs: now }), [data, pxPerMinute, now], ); const connectedRunIds = useMemo(() => { if (!hoveredRunId) return null; const connected = new Set([hoveredRunId]); let changed = true; while (changed) { changed = false; for (const c of layout.connectors) { if (connected.has(c.sourceRunId) && !connected.has(c.targetRunId)) { connected.add(c.targetRunId); changed = true; } if (connected.has(c.targetRunId) && !connected.has(c.sourceRunId)) { connected.add(c.sourceRunId); changed = true; } } } return connected; }, [hoveredRunId, layout.connectors]); const visibleConnectors = useMemo( () => connectedRunIds ? layout.connectors.filter((c) => connectedRunIds.has(c.sourceRunId) && connectedRunIds.has(c.targetRunId)) : [], [connectedRunIds, layout.connectors], ); const companyPrefix = extractCompanyPrefixFromPath(location.pathname); const timeToScrollLeft = (ms: number, viewportWidth: number) => { const x = layout.gutter + ((ms - layout.fromMs) / 60000) * layout.pxPerMinute; return Math.max(0, Math.min(layout.width - viewportWidth, x - viewportWidth / 2)); }; const scrollCenterMs = (el: HTMLDivElement) => { const centerX = el.scrollLeft + el.clientWidth / 2; return layout.fromMs + ((centerX - layout.gutter) / layout.pxPerMinute) * 60000; }; useEffect(() => { const el = scrollRef.current; if (!el) return; const nextViewportW = el.clientWidth; if (nextViewportW > 0 && nextViewportW !== viewportW) setViewportW(nextViewportW); const windowKey = `${data.window.from}:${data.window.to}`; if (initialWindowKeyRef.current !== windowKey) { initialWindowKeyRef.current = windowKey; const latest = Math.max(0, layout.width - nextViewportW); el.scrollLeft = latest; setScrollLeft(latest); centerMsRef.current = scrollCenterMs(el); return; } if (centerMsRef.current != null) { const next = timeToScrollLeft(centerMsRef.current, nextViewportW); el.scrollLeft = next; setScrollLeft(next); } }, [data.window.from, data.window.to, layout.fromMs, layout.gutter, layout.pxPerMinute, layout.toMs, layout.width, viewportW]); useEffect(() => { if (!onVisibleRangeLabelChange) return; const effectiveViewportW = viewportW || DEFAULT_VIEWPORT_W; const minutes = plotViewportWidth(effectiveViewportW) / layout.pxPerMinute; onVisibleRangeLabelChange(formatVisibleDurationMinutes(minutes)); }, [layout.pxPerMinute, onVisibleRangeLabelChange, viewportW]); useEffect(() => { if (!onVisibleWindowChange || viewportW <= 0) return; onVisibleWindowChange(visibleWindowForScroll(layout, scrollLeft, viewportW)); }, [ layout.fromMs, layout.toMs, layout.pxPerMinute, onVisibleWindowChange, scrollLeft, viewportW, ]); const stepMs = chooseTickStepMs(layout.pxPerMinute); const ticks: number[] = []; const startTick = Math.ceil(layout.fromMs / stepMs) * stepMs; for (let ms = startTick; ms <= layout.toMs; ms += stepMs) ticks.push(ms); const openIssue = (issueId: string) => { const href = applyCompanyPrefix(`/issues/${encodeURIComponent(issueId)}`, companyPrefix); window.open(href, "_blank", "noopener,noreferrer"); }; const updateVisibleRange = (fromMs: number, toMs: number) => { if (!onZoomScaleChange) return; const el = scrollRef.current; const boundedFrom = Math.max(layout.fromMs, Math.min(layout.toMs, fromMs)); const boundedTo = Math.max(layout.fromMs, Math.min(layout.toMs, toMs)); const startMs = Math.min(boundedFrom, boundedTo); const endMs = Math.max(boundedFrom, boundedTo); const durationMs = Math.max(MIN_MINIMAP_SELECTION_MS, endMs - startMs); const centerMs = startMs + durationMs / 2; const effectiveViewportW = el?.clientWidth || viewportW || DEFAULT_VIEWPORT_W; const nextScale = clampZoomScale(plotViewportWidth(effectiveViewportW) / (durationMs / 60000)); centerMsRef.current = centerMs; onZoomScaleChange(nextScale, nearestZoomForScale(nextScale, effectiveViewportW)); }; const svgXFromClientX = (clientX: number, el: SVGSVGElement) => { const rect = el.getBoundingClientRect(); if (rect.width <= 0) return layout.gutter; const x = ((clientX - rect.left) / rect.width) * layout.width; return Math.max(layout.gutter, Math.min(layout.width - 40, x)); }; const msFromSvgX = (x: number) => ( layout.fromMs + ((x - layout.gutter) / layout.pxPerMinute) * 60000 ); const handlePlotMouseDown = (event: React.MouseEvent) => { if (!onZoomScaleChange || event.button !== 0) return; const el = event.currentTarget; const startX = svgXFromClientX(event.clientX, el); event.preventDefault(); setTooltip(null); setHoveredRunId(null); setDragSelection({ anchorX: startX, currentX: startX }); const move = (moveEvent: MouseEvent) => { setDragSelection((prev) => prev && { ...prev, currentX: svgXFromClientX(moveEvent.clientX, el), }); }; const up = (upEvent: MouseEvent) => { const endX = svgXFromClientX(upEvent.clientX, el); setDragSelection(null); if (Math.abs(endX - startX) < 8) return; const fromMs = Math.min(msFromSvgX(startX), msFromSvgX(endX)); const toMs = Math.max(msFromSvgX(startX), msFromSvgX(endX)); updateVisibleRange(fromMs, toMs); }; setDocumentDrag(move, up); }; const connectorHintForBar = (bar: PositionedBar): string | null => { const related = layout.connectors.filter((c) => c.sourceRunId === bar.span.runId || c.targetRunId === bar.span.runId); if (related.length === 0) return null; return related.some((c) => c.dashed) ? "dashed handoff: retry or changes requested" : "solid handoff: delegation or assignment"; }; const showTooltip = (evt: React.MouseEvent, bar: PositionedBar) => { setHoveredRunId(bar.span.runId); setTooltip({ x: evt.clientX, y: evt.clientY, bar, connectorHint: connectorHintForBar(bar) }); }; const handleWheel = (evt: React.WheelEvent) => { if (!onZoomScaleChange || !(evt.ctrlKey || evt.metaKey || evt.altKey)) return; evt.preventDefault(); const el = scrollRef.current; if (el) { centerMsRef.current = scrollCenterMs(el); } const nextScale = clampZoomScale(layout.pxPerMinute * Math.exp(-evt.deltaY * 0.001)); onZoomScaleChange(nextScale, nearestZoomForScale(nextScale, el?.clientWidth ?? viewportW)); }; return (
{ setScrollLeft(e.currentTarget.scrollLeft); centerMsRef.current = scrollCenterMs(e.currentTarget); }} onWheel={handleWheel} >
{ if (el && viewportW === 0 && scrollRef.current) setViewportW(scrollRef.current.clientWidth); }} > {/* row backgrounds */} {layout.rows.map((row, i) => ( ))} {/* vertical gridlines */} {ticks.map((ms) => { const gx = layout.gutter + ((ms - layout.fromMs) / 60000) * layout.pxPerMinute; return ( ); })} {/* now line — status-blue "Signal" present marker (gallery r2; was teal) */} {now >= layout.fromMs && now <= layout.toMs && ( )} {/* gutter divider + axis baseline */} {/* connectors (behind bars): hover reveals the connected handoff graph. */} {visibleConnectors.map((c, i) => { const y1 = c.y1 + AXIS_H; const y2 = c.y2 + AXIS_H; const arrow = c.x2 >= c.x1 ? `M${c.x2},${y2} l-10,-5 l0,10 z` : `M${c.x2},${y2} l10,-5 l0,10 z`; return ( ); })} {/* rows: gutter avatar/label, lane baselines, bars, human kickoff chips */} {layout.rows.map((row) => { const cy = row.y + AXIS_H + row.h / 2; const actorGlyphId = svgFragmentId(`plot-${row.actor.id}`); return ( {truncate(row.actor.name, 18)} {Array.from({ length: row.laneCount }).map((_, ln) => { const ly = row.y + AXIS_H + 6 + ln * (GEOM.barH + GEOM.laneGap) + GEOM.barH / 2; return ( ); })} {row.bars.map((bar) => { const yTop = bar.yTop + AXIS_H; const w = bar.x2 - bar.x1; const cancelled = isCancelledStatus(bar.span.status); const color = barColor(bar); const connectedState = connectedRunIds == null ? "idle" : connectedRunIds.has(bar.span.runId) ? "connected" : "faded"; const barOpacity = connectedState === "idle" ? 0.88 : connectedState === "connected" ? 1 : 0.22; return ( showTooltip(e, bar)} onMouseOver={(e) => showTooltip(e, bar)} onMouseMove={(e) => showTooltip(e, bar)} onMouseLeave={() => { setTooltip(null); setHoveredRunId(null); }} onMouseDown={(e) => e.stopPropagation()} onClick={() => openIssue(bar.span.issueId)} > {/* "Signal" encoding: fill = how the run started (delegated / automation); cancelled runs drop the fill and read as a hollow dashed bar. */} {/* in-progress fade to "now" */} {bar.running && !cancelled && w > 8 && ( )} {bar.kickoff && actorType(bar.kickoff) === "user" && ( )} ); })} ); })} {dragSelection && ( )}
{tooltip && }
); } function ActorGutter({ rows, height }: { rows: ReturnType["rows"]; height: number }) { return ( ); } function TimeAxisOverlay({ layout, ticks, stepMs, scrollLeft, }: { layout: ReturnType; ticks: number[]; stepMs: number; scrollLeft: number; }) { return ( ); } function Tooltip({ tooltip, now }: { tooltip: TooltipState; now: number }) { const { bar } = tooltip; const startMs = new Date(bar.span.start).getTime(); const endMs = bar.span.end ? new Date(bar.span.end).getTime() : now; const title = bar.span.issueTitle ?? bar.span.issueIdentifier ?? "run"; const left = Math.min(tooltip.x + 14, (typeof window !== "undefined" ? window.innerWidth : 1200) - 300); return (
{truncate(title)}
{fmtClock(startMs)}–{bar.span.end ? fmtClock(endMs) : "now"} · {formatDuration(startMs, endMs)} ·{" "} {bar.span.status}
{bar.kickoff && (
kicked off by: {(bar.kickoff as WorkTimelineActor).name} {bar.span.retryOfRunId ? " · retry" : ""}
)} {tooltip.connectorHint && (
{tooltip.connectorHint}
)}
); } function MiniMap({ layout, scrollRef, viewportW, scrollLeft, onVisibleRangeChange, }: { layout: ReturnType; scrollRef: React.RefObject; viewportW: number; scrollLeft: number; onVisibleRangeChange: (fromMs: number, toMs: number) => void; }) { const documentDragCleanupRef = useRef<(() => void) | null>(null); const W = Math.max(320, viewportW || 900); const H = 54; const pad = 8; const spanMs = layout.toMs - layout.fromMs || 1; const mx = (ms: number) => pad + ((ms - layout.fromMs) / spanMs) * (W - 2 * pad); // one thin tick per run, stacked by row order const rowIndex = new Map(layout.rows.map((r, i) => [r.actor.id, i])); const laneH = (H - 2 * pad) / Math.max(1, layout.rows.length); const visibleWindow = visibleWindowForScroll(layout, scrollLeft, viewportW || W); const visibleStartMs = visibleWindow.fromMs; const visibleEndMs = visibleWindow.toMs; const brushX = mx(visibleStartMs); const brushW = Math.max(24, mx(visibleEndMs) - brushX); const handleW = 14; const clearDocumentDrag = () => { documentDragCleanupRef.current?.(); documentDragCleanupRef.current = null; }; const setDocumentDrag = (move: (event: MouseEvent) => void, up: (event: MouseEvent) => void) => { clearDocumentDrag(); const handleUp = (event: MouseEvent) => { clearDocumentDrag(); up(event); }; document.addEventListener("mousemove", move); document.addEventListener("mouseup", handleUp); documentDragCleanupRef.current = () => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", handleUp); }; }; useEffect(() => () => clearDocumentDrag(), []); const msAtClientX = (clientX: number, el: SVGSVGElement) => { const rect = el.getBoundingClientRect(); const f = Math.min(1, Math.max(0, (clientX - rect.left - pad) / (W - 2 * pad))); return layout.fromMs + f * spanMs; }; const seek = (clientX: number, el: SVGSVGElement) => { const centerMs = msAtClientX(clientX, el); if (scrollRef.current) { scrollRef.current.scrollLeft = layout.gutter + ((centerMs - layout.fromMs) / 60000) * layout.pxPerMinute - scrollRef.current.clientWidth / 2; } }; const startRangeDrag = (mode: "left" | "right" | "move", event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); const el = event.currentTarget.ownerSVGElement; if (!el) return; const startLeftMs = visibleStartMs; const startRightMs = visibleEndMs; const durationMs = Math.max(MIN_MINIMAP_SELECTION_MS, startRightMs - startLeftMs); const move = (ev: MouseEvent) => { const hitMs = msAtClientX(ev.clientX, el); if (mode === "left") { onVisibleRangeChange(Math.min(hitMs, startRightMs - MIN_MINIMAP_SELECTION_MS), startRightMs); } else if (mode === "right") { onVisibleRangeChange(startLeftMs, Math.max(hitMs, startLeftMs + MIN_MINIMAP_SELECTION_MS)); } else { const nextFrom = Math.max(layout.fromMs, Math.min(layout.toMs - durationMs, hitMs - durationMs / 2)); onVisibleRangeChange(nextFrom, nextFrom + durationMs); } }; setDocumentDrag(move, () => {}); }; return (
{ const el = e.currentTarget; seek(e.clientX, el); const move = (ev: MouseEvent) => seek(ev.clientX, el); setDocumentDrag(move, () => {}); }} > {layout.rows.flatMap((row) => row.bars.map((bar) => { const startMs = new Date(bar.span.start).getTime(); const endMs = bar.span.end ? new Date(bar.span.end).getTime() : layout.toMs; const yy = pad + (rowIndex.get(row.actor.id) ?? 0) * laneH; return ( ); }), )} startRangeDrag("move", e)} /> startRangeDrag("left", e)} /> startRangeDrag("right", e)} />
); } function MiniMapHandle({ x, y, width, height, testId, label, onMouseDown, }: { x: number; y: number; width: number; height: number; testId: string; label: string; onMouseDown: (event: React.MouseEvent) => void; }) { const left = x - width / 2; const gripTop = y + height / 2 - 7; return ( {label} ); }