From 97e0752158a2e0e2ee3f0723b8d8d24b6c09bd59 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:30:46 -0500 Subject: [PATCH] Fix work timeline visible stats and kickoff attribution (#9271) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The work timeline helps operators understand what agents did, when they did it, and who kicked off each run > - Timeline statistics should describe the window the operator is viewing, not hidden work outside the viewport > - Kickoff chips should point to the run that was actually closest to the triggering edge, not stale later runs on the same issue > - This pull request scopes timeline summary stats to the visible window and deduplicates kickoff attribution to the nearest matching run > - The benefit is that operators get a more accurate, less misleading timeline while scanning agent work ## Linked Issues or Issue Description No matching public GitHub issue was found. Public duplicate search found no open PR for "work timeline visible window kickoff". ### What happened? Work timeline summary stats could count hidden spans outside the selected visible window, and kickoff attribution could appear on a stale matching run instead of the closest run associated with the edge. ### Expected behavior Visible timeline stats should reflect only the current visible range, and each kickoff edge should attach to the nearest matching run. ### Steps to reproduce 1. View a work timeline with agent spans that begin before or end after the selected visible window. 2. Compare the summary stats against only the spans visible in the viewport. 3. View repeated runs for the same actor and issue that share a kickoff edge. 4. Check which run gets the kickoff chip. ### Paperclip version or commit Current `origin/master` before this PR. ### Deployment mode Board UI. ## What Changed - Filters timeline summary stats to the visible time window. - Passes visible-window metadata into the work timeline chart and page state. - Assigns each kickoff edge to only the closest matching run, with deterministic tie-breaking. - Adds focused regression coverage for visible stats and stale kickoff attribution. ## Verification - `pnpm exec vitest run ui/src/components/timeline/WorkTimelineChart.test.tsx ui/src/lib/timeline/layout.test.ts ui/src/pages/Timeline.test.tsx` -- 38 tests passed. - `pnpm check:token-gates` -- all gates clean. - `git diff --check origin/master...HEAD` -- passed. ## Visual Evidence Public review artifacts for the user-visible timeline behaviours. The SVGs use sanitized run labels and no internal Paperclip issue identifiers. ![Visible-window stat panel before and after scroll](https://gist.githubusercontent.com/cryppadotta/dc2645d54c4bc56ffc0323224b3ea4bf/raw/5f33032ee33035d11334a907ebd5907d92fb3485/visible-window-stats.svg) ![Kickoff attribution before and after stale-chip fix](https://gist.githubusercontent.com/cryppadotta/dc2645d54c4bc56ffc0323224b3ea4bf/raw/3e0ac8de60b56ff97b0ba48cfe897c0faa161ea9/kickoff-attribution.svg) Artifact gist: https://gist.github.com/cryppadotta/dc2645d54c4bc56ffc0323224b3ea4bf ## Risks - Low risk. The change affects timeline rendering and summary calculations only; the main risk is a subtle difference in which run gets a kickoff chip when multiple runs are very close together. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex coding agent based on GPT-5, tool-enabled shell workflow. Exact hosted model variant and context window were not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../timeline/WorkTimelineChart.test.tsx | 28 ++++++++++ .../components/timeline/WorkTimelineChart.tsx | 53 ++++++++++++++++--- ui/src/lib/timeline/layout.test.ts | 22 ++++++++ ui/src/lib/timeline/layout.ts | 52 +++++++++++++++++- ui/src/pages/Timeline.test.tsx | 16 +++++- ui/src/pages/Timeline.tsx | 47 ++++++++++++---- 6 files changed, 198 insertions(+), 20 deletions(-) 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 = (