diff --git a/ui/src/components/timeline/WorkTimelineChart.test.tsx b/ui/src/components/timeline/WorkTimelineChart.test.tsx index 11178c4901..8862a1196f 100644 --- a/ui/src/components/timeline/WorkTimelineChart.test.tsx +++ b/ui/src/components/timeline/WorkTimelineChart.test.tsx @@ -46,6 +46,12 @@ function renderChart( }); } +async function flushTimelineEffects(count = 5) { + for (let index = 0; index < count; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + function timelineSample(): WorkTimelineResult { return { actors: [ @@ -150,6 +156,28 @@ describe("WorkTimelineChart", () => { expect(container.querySelector("[data-testid='work-timeline-actor-gutter']")?.textContent).toContain("CodexCoder"); }); + it("reports the currently visible time window when the chart scrolls", async () => { + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(640); + const onVisibleWindowChange = vi.fn(); + const data = timelineSample(); + renderChart(data, { onVisibleWindowChange }); + + await flushTimelineEffects(); + + const scroller = container.querySelector("[data-testid='work-timeline-scroll']")!; + expect(onVisibleWindowChange).toHaveBeenCalled(); + + flushSync(() => { + scroller.scrollLeft = 0; + scroller.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + await flushTimelineEffects(); + + const lastCall = onVisibleWindowChange.mock.calls.at(-1)?.[0]; + expect(lastCall?.fromMs).toBe(new Date(data.window.from).getTime()); + expect(lastCall?.toMs).toBeCloseTo(new Date("2026-07-02T01:00:00.000Z").getTime(), -3); + }); + it("renders configured agent icons in the actor gutter instead of generated initials", () => { renderChart(timelineSample()); diff --git a/ui/src/components/timeline/WorkTimelineChart.tsx b/ui/src/components/timeline/WorkTimelineChart.tsx index 1c155f7a45..17c9804b21 100644 --- a/ui/src/components/timeline/WorkTimelineChart.tsx +++ b/ui/src/components/timeline/WorkTimelineChart.tsx @@ -3,7 +3,7 @@ * * 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 each bar's leading edge, straight + * 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. @@ -29,6 +29,11 @@ import { export type ZoomLevel = "hour" | "day" | "week"; +export interface VisibleTimelineWindow { + fromMs: number; + toMs: number; +} + const ZOOM_DURATION_MIN: Record = { hour: 60, day: 24 * 60, @@ -43,6 +48,29 @@ 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]); } @@ -224,6 +252,7 @@ export interface WorkTimelineChartProps { 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; } @@ -234,6 +263,7 @@ export function WorkTimelineChart({ zoomScale, onZoomScaleChange, onVisibleRangeLabelChange, + onVisibleWindowChange, nowMs, }: WorkTimelineChartProps) { const location = useLocation(); @@ -344,6 +374,18 @@ export function WorkTimelineChart({ 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; @@ -798,12 +840,9 @@ function MiniMap({ 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 timeAtX = (x: number) => { - const ms = layout.fromMs + ((x - layout.gutter) / layout.pxPerMinute) * 60000; - return Math.max(layout.fromMs, Math.min(layout.toMs, ms)); - }; - const visibleStartMs = timeAtX(scrollLeft + layout.gutter); - const visibleEndMs = timeAtX(scrollLeft + layout.gutter + (viewportW || W)); + 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; diff --git a/ui/src/lib/timeline/layout.test.ts b/ui/src/lib/timeline/layout.test.ts index 279de2696b..4597c9d622 100644 --- a/ui/src/lib/timeline/layout.test.ts +++ b/ui/src/lib/timeline/layout.test.ts @@ -119,6 +119,28 @@ describe("computeLayout", () => { expect(ceoRun.kickoff?.id).toBe("user:dotta"); // human kickoff shown as chip }); + it("does not reuse a board-created assignment edge on later automation runs", () => { + const data = sample(); + data.spans.push({ + actorId: "agent:ceo", + laneHint: null, + runId: "r1-later-automation", + issueId: "i-405", + issueIdentifier: "PAP-12405", + issueTitle: "PAP-12405 title", + start: t("10:30"), + end: t("10:36"), + status: "completed", + retryOfRunId: null, + invocationSource: "automation", + }); + + const layout = computeLayout(data, OPTS); + const ceoRuns = layout.rows.find((r) => r.actor.id === "agent:ceo")!.bars; + expect(ceoRuns.find((b) => b.span.runId === "r1")?.kickoff?.id).toBe("user:dotta"); + expect(ceoRuns.find((b) => b.span.runId === "r1-later-automation")?.kickoff).toBeNull(); + }); + it("prefers the nearest post-start kickoff edge when no prior edge exists", () => { const data = sample(); data.spans.push({ diff --git a/ui/src/lib/timeline/layout.ts b/ui/src/lib/timeline/layout.ts index 0f27db9478..5dd950a235 100644 --- a/ui/src/lib/timeline/layout.ts +++ b/ui/src/lib/timeline/layout.ts @@ -5,6 +5,7 @@ * Ports the board-locked "Direction C" logic (PAP-12422): agent/system rows only * (humans never get a row), overlapping runs packed into concurrency sub-lanes, * a kickoff actor derived per run (shown as an avatar chip — may be a human), + * with each kickoff edge assigned to only its closest matching run, * and straight agent→agent delegation connectors from a source bar's trailing * edge to a target bar's leading edge (dashed for retries / changes-requested). * @@ -178,6 +179,52 @@ function spanEndMs(s: WorkTimelineSpan, nowMs: number): number { return raw; } +function kickoffEdgeRunDistanceMs(edge: WorkTimelineEdge, span: WorkTimelineSpan): number { + return Math.abs(spanStartMs(span) - new Date(edge.at).getTime()); +} + +function spanGroupKey(actorId: string, issueId: string): string { + return `${actorId}\0${issueId}`; +} + +function closestRunForKickoffEdge(edge: WorkTimelineEdge, spans: readonly WorkTimelineSpan[]): string | null { + let closest: { span: WorkTimelineSpan; distance: number } | null = null; + for (const span of spans) { + const distance = kickoffEdgeRunDistanceMs(edge, span); + if ( + !closest + || distance < closest.distance + || (distance === closest.distance && span.runId.localeCompare(closest.span.runId) < 0) + ) { + closest = { span, distance }; + } + } + return closest?.span.runId ?? null; +} + +function buildClosestRunByKickoffEdge( + spans: readonly WorkTimelineSpan[], + edges: readonly WorkTimelineEdge[], +): Map { + const spansByActorIssue = new Map(); + for (const span of spans) { + const key = spanGroupKey(span.actorId, span.issueId); + const group = spansByActorIssue.get(key); + if (group) group.push(span); + else spansByActorIssue.set(key, [span]); + } + + const closestRunByEdge = new Map(); + for (const edge of edges) { + const closestRunId = closestRunForKickoffEdge( + edge, + spansByActorIssue.get(spanGroupKey(edge.toActorId, edge.issueId)) ?? [], + ); + if (closestRunId) closestRunByEdge.set(edge, closestRunId); + } + return closestRunByEdge; +} + /** * Resolve the kickoff actor for a run: the source of the delegation/assignment * edge that points at this run's actor on this run's issue, closest at-or-before @@ -188,6 +235,7 @@ function resolveKickoff( span: WorkTimelineSpan, edges: WorkTimelineEdge[], actorById: Map, + closestRunByKickoffEdge: ReadonlyMap, ): WorkTimelineActor | null { const start = spanStartMs(span); let best: { edge: WorkTimelineEdge; delta: number } | null = null; @@ -200,6 +248,7 @@ function resolveKickoff( if (!best || delta < best.delta) best = { edge: e, delta }; } if (!best) return null; + if (closestRunByKickoffEdge.get(best.edge) !== span.runId) return null; return actorById.get(best.edge.fromActorId) ?? null; } @@ -208,6 +257,7 @@ export function computeLayout(result: WorkTimelineResult, opts: LayoutOptions): const fromMs = new Date(result.window.from).getTime(); const toMs = new Date(result.window.to).getTime(); const actorById = new Map(result.actors.map((a) => [a.id, a])); + const closestRunByKickoffEdge = buildClosestRunByKickoffEdge(result.spans, result.edges); const x = (ms: number) => gutter + ((ms - fromMs) / 60000) * pxPerMinute; @@ -282,7 +332,7 @@ export function computeLayout(result: WorkTimelineResult, opts: LayoutOptions): yc: laneTop + barH / 2, height: barH, running: isRunningStatus(r.status), - kickoff: resolveKickoff(r, result.edges, actorById), + kickoff: resolveKickoff(r, result.edges, actorById, closestRunByKickoffEdge), }; barIndex.set(r.runId, bar); return bar; diff --git a/ui/src/pages/Timeline.test.tsx b/ui/src/pages/Timeline.test.tsx index f60972b076..d9b25cbe0d 100644 --- a/ui/src/pages/Timeline.test.tsx +++ b/ui/src/pages/Timeline.test.tsx @@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { WorkTimelineResult } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { Timeline } from "./Timeline"; +import { Timeline, timelineSummary } from "./Timeline"; const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); const mockWorkTimelineApi = vi.hoisted(() => ({ @@ -272,6 +272,20 @@ describe("Timeline", () => { expect(container.textContent).not.toContain("4K"); }); + it("summarizes only runs that overlap the visible timeline window", () => { + const summary = timelineSummary(populatedTimeline, { + fromMs: new Date("2026-07-02T10:15:00.000Z").getTime(), + toMs: new Date("2026-07-02T11:05:00.000Z").getTime(), + }); + + expect(summary).toEqual({ + runs: 2, + agents: 2, + activeMs: 20 * 60 * 1000, + totalTokens: 1_250, + }); + }); + it("requests the company timeline without a user lens parameter", async () => { root = createRoot(container); diff --git a/ui/src/pages/Timeline.tsx b/ui/src/pages/Timeline.tsx index 024597c82e..37620879e4 100644 --- a/ui/src/pages/Timeline.tsx +++ b/ui/src/pages/Timeline.tsx @@ -5,7 +5,7 @@ * (`GET /companies/:companyId/timeline`). Rendering is the board-locked * Direction C (PAP-12422): dense rows, mini-map brush, custom inline SVG. */ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Bot, Clock3, Coins, GanttChartSquare, Minus, Plus, RotateCcw, type LucideIcon } from "lucide-react"; import type { WorkTimelineResult } from "@paperclipai/shared"; @@ -24,6 +24,7 @@ import { clampZoomScale, defaultZoomForWindow, nearestZoomForScale, + type VisibleTimelineWindow, type ZoomLevel, zoomScaleForLevel, } from "@/components/timeline/WorkTimelineChart"; @@ -89,11 +90,12 @@ function spanEndMs(span: WorkTimelineResult["spans"][number], fallbackEndMs: num function spanWindowOverlap( span: WorkTimelineResult["spans"][number], + rawFallbackEndMs: number, windowFromMs: number, windowToMs: number, ) { const rawStartMs = spanStartMs(span); - const rawEndMs = spanEndMs(span, windowToMs); + const rawEndMs = spanEndMs(span, rawFallbackEndMs); const startMs = Math.max(rawStartMs, windowFromMs); const endMs = Math.min(rawEndMs, windowToMs); return { @@ -109,25 +111,34 @@ function spanWindowTokens(span: WorkTimelineResult["spans"][number], rawMs: numb return Math.round(totalTokens * (clippedMs / rawMs)); } -function timelineSummary(data: WorkTimelineResult) { +function dataWindow(data: WorkTimelineResult): VisibleTimelineWindow { + return { + fromMs: new Date(data.window.from).getTime(), + toMs: new Date(data.window.to).getTime(), + }; +} + +export function timelineSummary(data: WorkTimelineResult, visibleWindow: VisibleTimelineWindow = dataWindow(data)) { const actorById = new Map(data.actors.map((actor) => [actor.id, actor])); const activeAgentIds = new Set(); - const windowFromMs = new Date(data.window.from).getTime(); - const windowToMs = new Date(data.window.to).getTime(); + const fullWindow = dataWindow(data); + const windowFromMs = Math.max(fullWindow.fromMs, Math.min(fullWindow.toMs, visibleWindow.fromMs)); + const windowToMs = Math.max(windowFromMs, Math.min(fullWindow.toMs, visibleWindow.toMs)); let activeMs = 0; let totalTokens = 0; + let runs = 0; for (const span of data.spans) { - if (actorById.get(span.actorId)?.type === "agent") { - activeAgentIds.add(span.actorId); - } - const overlap = spanWindowOverlap(span, windowFromMs, windowToMs); + const overlap = spanWindowOverlap(span, fullWindow.toMs, windowFromMs, windowToMs); + if (overlap.clippedMs <= 0) continue; + runs += 1; + if (actorById.get(span.actorId)?.type === "agent") activeAgentIds.add(span.actorId); activeMs += overlap.clippedMs; totalTokens += spanWindowTokens(span, overlap.rawMs, overlap.clippedMs); } return { - runs: data.spans.length, + runs, agents: activeAgentIds.size, activeMs, totalTokens, @@ -235,6 +246,7 @@ export function Timeline() { const zoomTouched = useRef(false); const [rangePreset, setRangePreset] = useState("7d"); const [dateRange, setDateRange] = useState(() => presetRange("7d")); + const [visibleWindow, setVisibleWindow] = useState(null); useEffect(() => { setBreadcrumbs([{ label: "Timeline" }]); @@ -260,6 +272,18 @@ export function Timeline() { setZoomScale(undefined); }, [data]); + useEffect(() => { + setVisibleWindow(null); + }, [data?.window.from, data?.window.to]); + + const handleVisibleWindowChange = useCallback((nextWindow: VisibleTimelineWindow) => { + setVisibleWindow((current) => ( + current?.fromMs === nextWindow.fromMs && current.toMs === nextWindow.toMs + ? current + : nextWindow + )); + }, []); + if (!selectedCompanyId) { return ( <> @@ -293,7 +317,7 @@ export function Timeline() { setZoomScale(undefined); }; - const summary = data ? timelineSummary(data) : null; + const summary = data ? timelineSummary(data, visibleWindow ?? dataWindow(data)) : null; const rangeControls = (