diff --git a/packages/shared/src/types/work-timeline.ts b/packages/shared/src/types/work-timeline.ts index 01b3838277..7973100ddc 100644 --- a/packages/shared/src/types/work-timeline.ts +++ b/packages/shared/src/types/work-timeline.ts @@ -33,6 +33,12 @@ export interface WorkTimelineSpan { retryOfRunId?: string | null; continuationAttempt?: number; invocationSource?: string | null; + usage?: { + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + totalTokens: number; + } | null; } export interface WorkTimelineEvent { diff --git a/server/src/__tests__/work-timeline-service.test.ts b/server/src/__tests__/work-timeline-service.test.ts index 8825b805e1..5fd9330809 100644 --- a/server/src/__tests__/work-timeline-service.test.ts +++ b/server/src/__tests__/work-timeline-service.test.ts @@ -175,6 +175,7 @@ describeEmbeddedPostgres("work timeline aggregation", () => { invocationSource: "issue_assigned", startedAt: new Date("2026-03-01T12:00:00Z"), finishedAt: null, + usageJson: { inputTokens: 120, cachedInputTokens: 30, outputTokens: 50 }, contextSnapshot: { issueId: childIssueId }, }, { @@ -237,7 +238,13 @@ describeEmbeddedPostgres("work timeline aggregation", () => { expect(result.actors.map((actor) => actor.name)).toEqual(expect.arrayContaining(["Coder", "QA", "User One"])); expect(result.spans).toEqual(expect.arrayContaining([ - expect.objectContaining({ runId: contextRunId, issueId: childIssueId, end: null, status: "running" }), + expect.objectContaining({ + runId: contextRunId, + issueId: childIssueId, + end: null, + status: "running", + usage: { inputTokens: 120, cachedInputTokens: 30, outputTokens: 50, totalTokens: 200 }, + }), expect.objectContaining({ runId: activityRunId, issueId: parentIssueId, status: "completed" }), ])); expect(result.events.map((event) => event.kind)).toEqual(expect.arrayContaining([ diff --git a/server/src/services/work-timeline.ts b/server/src/services/work-timeline.ts index 56756b6a57..46d36976da 100644 --- a/server/src/services/work-timeline.ts +++ b/server/src/services/work-timeline.ts @@ -73,6 +73,8 @@ type IssueRow = { createdAt: Date; }; +type RunUsage = NonNullable; + const DEFAULT_LIMIT = 200; const MAX_LIMIT = 500; const MAX_WINDOW_MS = 31 * 24 * 60 * 60 * 1000; @@ -119,6 +121,39 @@ function readString(value: unknown) { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function readNumber(value: unknown) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} + +function readUsageToken(source: Record, ...keys: string[]) { + for (const key of keys) { + const value = readNumber(source[key]); + if (value != null) return Math.max(0, Math.floor(value)); + } + return 0; +} + +function normalizeRunUsage(usageJson: unknown): RunUsage | null { + if (!usageJson || typeof usageJson !== "object" || Array.isArray(usageJson)) return null; + const source = usageJson as Record; + const inputTokens = readUsageToken(source, "inputTokens", "input_tokens", "rawInputTokens", "raw_input_tokens"); + const cachedInputTokens = readUsageToken( + source, + "cachedInputTokens", + "cached_input_tokens", + "cacheReadInputTokens", + "cache_read_input_tokens", + ); + const outputTokens = readUsageToken(source, "outputTokens", "output_tokens", "rawOutputTokens", "raw_output_tokens"); + const totalTokens = inputTokens + cachedInputTokens + outputTokens; + return totalTokens > 0 ? { inputTokens, cachedInputTokens, outputTokens, totalTokens } : null; +} + function maybeUuidList(ids: Iterable) { return Array.from(new Set(Array.from(ids).filter((id) => id.length > 0))); } @@ -508,6 +543,7 @@ export function workTimelineService(db: Db) { retryOfRunId: heartbeatRuns.retryOfRunId, continuationAttempt: heartbeatRuns.continuationAttempt, invocationSource: heartbeatRuns.invocationSource, + usageJson: heartbeatRuns.usageJson, }) .from(heartbeatRuns) .where( @@ -529,6 +565,7 @@ export function workTimelineService(db: Db) { retryOfRunId: heartbeatRuns.retryOfRunId, continuationAttempt: heartbeatRuns.continuationAttempt, invocationSource: heartbeatRuns.invocationSource, + usageJson: heartbeatRuns.usageJson, }) .from(activityLog) .innerJoin(heartbeatRuns, eq(activityLog.runId, heartbeatRuns.id)) @@ -639,6 +676,7 @@ export function workTimelineService(db: Db) { retryOfRunId: row.retryOfRunId ?? null, continuationAttempt: row.continuationAttempt, invocationSource: row.invocationSource ?? null, + usage: normalizeRunUsage(row.usageJson), }); } diff --git a/ui/src/components/timeline/WorkTimelineChart.test.tsx b/ui/src/components/timeline/WorkTimelineChart.test.tsx index 627f399f42..11178c4901 100644 --- a/ui/src/components/timeline/WorkTimelineChart.test.tsx +++ b/ui/src/components/timeline/WorkTimelineChart.test.tsx @@ -139,6 +139,8 @@ describe("WorkTimelineChart", () => { expect(gutter?.getAttribute("width")).toBe("176"); expect(chartSvg?.getAttribute("width")).not.toBe(gutter?.getAttribute("width")); expect(gutter?.textContent).toContain("CodexCoder"); + expect(gutter?.textContent).not.toContain("agent"); + expect(gutter?.textContent).not.toContain("×"); flushSync(() => { scroller!.scrollLeft = 10_000; @@ -323,7 +325,7 @@ describe("WorkTimelineChart", () => { const onZoomScaleChange = vi.fn(); renderChart(timelineSample(), { onZoomScaleChange }); - const rightHandle = container.querySelector("[data-testid='timeline-minimap-right-handle']")!; + const rightHandle = container.querySelector("[data-testid='timeline-minimap-right-handle']")!; const minimap = rightHandle.ownerSVGElement!; vi.spyOn(minimap, "getBoundingClientRect").mockReturnValue({ x: 0, @@ -346,6 +348,18 @@ describe("WorkTimelineChart", () => { expect(onZoomScaleChange).toHaveBeenCalled(); }); + it("shows grab-handle affordances on minimap selection edges", () => { + renderChart(timelineSample(), { onZoomScaleChange: vi.fn() }); + + const leftHandle = container.querySelector("[data-testid='timeline-minimap-left-handle']")!; + const rightHandle = container.querySelector("[data-testid='timeline-minimap-right-handle']")!; + + expect(leftHandle.getAttribute("class")).toContain("cursor-grab"); + expect(rightHandle.getAttribute("class")).toContain("cursor-grab"); + expect(leftHandle.querySelectorAll("line")).toHaveLength(3); + expect(leftHandle.textContent).toContain("Drag left edge"); + }); + it("cleans up chart drag listeners when unmounted mid-drag", () => { const add = vi.spyOn(document, "addEventListener"); const remove = vi.spyOn(document, "removeEventListener"); @@ -383,7 +397,7 @@ describe("WorkTimelineChart", () => { const remove = vi.spyOn(document, "removeEventListener"); renderChart(timelineSample(), { onZoomScaleChange: vi.fn() }); - const rightHandle = container.querySelector("[data-testid='timeline-minimap-right-handle']")!; + const rightHandle = container.querySelector("[data-testid='timeline-minimap-right-handle']")!; const minimap = rightHandle.ownerSVGElement!; vi.spyOn(minimap, "getBoundingClientRect").mockReturnValue({ x: 0, diff --git a/ui/src/components/timeline/WorkTimelineChart.tsx b/ui/src/components/timeline/WorkTimelineChart.tsx index cd1f6f2403..3f223797de 100644 --- a/ui/src/components/timeline/WorkTimelineChart.tsx +++ b/ui/src/components/timeline/WorkTimelineChart.tsx @@ -533,12 +533,9 @@ export function WorkTimelineChart({ return ( - + {truncate(row.actor.name, 18)} - - {row.actor.type} - {Array.from({ length: row.laneCount }).map((_, ln) => { const ly = row.y + AXIS_H + 6 + ln * (GEOM.barH + GEOM.laneGap) + GEOM.barH / 2; @@ -682,23 +679,9 @@ function ActorGutter({ rows, height }: { rows: ReturnType[ opacity={i % 2 ? 0.35 : 1} /> - + {truncate(row.actor.name, 16)} - - {row.actor.type} - - {/* "Signal" rail: run count + active time, right-aligned in the gutter. */} - - {row.runCount}× · {formatDuration(0, row.activeMs)} - ); })} @@ -822,6 +805,7 @@ function MiniMap({ const visibleEndMs = timeAtX(scrollLeft + layout.gutter + (viewportW || W)); const brushX = mx(visibleStartMs); const brushW = Math.max(24, mx(visibleEndMs) - brushX); + const handleW = 14; const clearDocumentDrag = () => { documentDragCleanupRef.current?.(); @@ -886,7 +870,7 @@ function MiniMap({ width={W} height={H} viewBox={`0 0 ${W} ${H}`} - className="block cursor-ew-resize" + className="block cursor-grab active:cursor-grabbing" onMouseDown={(e) => { const el = e.currentTarget; seek(e.clientX, el); @@ -924,27 +908,67 @@ function MiniMap({ strokeWidth={1.5} onMouseDown={(e) => 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} + + + + + + ); +} diff --git a/ui/src/pages/Timeline.test.tsx b/ui/src/pages/Timeline.test.tsx index cfe8943338..f60972b076 100644 --- a/ui/src/pages/Timeline.test.tsx +++ b/ui/src/pages/Timeline.test.tsx @@ -24,6 +24,10 @@ vi.mock("@/api/workTimeline", () => ({ workTimelineApi: mockWorkTimelineApi, })); +vi.mock("@/lib/router", () => ({ + useLocation: () => ({ pathname: "/PAP/timeline" }), +})); + vi.mock("@/components/RequestCollapsedSidebar", () => ({ RequestCollapsedSidebar: () =>
, })); @@ -46,6 +50,65 @@ const emptyTimeline: WorkTimelineResult = { }, }; +const populatedTimeline: WorkTimelineResult = { + actors: [ + { id: "agent:codex", type: "agent", name: "CodexCoder", avatar: "code" }, + { id: "agent:qa", type: "agent", name: "QA", avatar: "shield" }, + { id: "user:board", type: "user", name: "Board Operator", avatar: "/avatar.png" }, + ], + spans: [ + { + actorId: "agent:codex", + laneHint: "assignment", + runId: "run-1", + issueId: "issue-1", + issueIdentifier: "PAP-1", + issueTitle: "Implement timeline stats", + start: "2026-07-02T10:00:00.000Z", + end: "2026-07-02T10:30:00.000Z", + status: "succeeded", + retryOfRunId: null, + usage: { + inputTokens: 1_000, + cachedInputTokens: 0, + outputTokens: 500, + totalTokens: 1_500, + }, + }, + { + actorId: "agent:qa", + laneHint: "assignment", + runId: "run-2", + issueId: "issue-2", + issueIdentifier: "PAP-2", + issueTitle: "Verify timeline stats", + start: "2026-07-02T11:00:00.000Z", + end: "2026-07-02T11:15:00.000Z", + status: "succeeded", + retryOfRunId: null, + usage: { + inputTokens: 900, + cachedInputTokens: 100, + outputTokens: 500, + totalTokens: 1_500, + }, + }, + ], + events: [], + edges: [], + pagination: { + limit: 100, + offset: 0, + totalIssues: 2, + hasMore: false, + }, + window: { + from: "2026-07-02T00:00:00.000Z", + to: "2026-07-02T23:59:59.999Z", + capped: false, + }, +}; + async function flushReact() { for (let index = 0; index < 3; index += 1) { await Promise.resolve(); @@ -114,6 +177,101 @@ describe("Timeline", () => { expect(container.textContent).not.toContain("visible"); }); + it("renders top timeline stats and keeps range controls in the chart footer", async () => { + mockWorkTimelineApi.get.mockResolvedValue(populatedTimeline); + root = createRoot(container); + + flushSync(() => { + root?.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Runs"); + expect(container.textContent).toContain("Agents"); + expect(container.textContent).toContain("Run time"); + expect(container.textContent).toContain("Tokens used"); + expect(container.textContent).toContain("45m"); + expect(container.textContent).toContain("3K"); + + const footer = Array.from(container.querySelectorAll("div")).find((element) => + element.textContent?.includes("2 runs") && element.textContent.includes("Range"), + ); + expect(footer).not.toBeUndefined(); + }); + + it("clamps open run summary time to the returned timeline window", async () => { + mockWorkTimelineApi.get.mockResolvedValue({ + ...populatedTimeline, + spans: [ + { + ...populatedTimeline.spans[0], + start: "2026-07-02T00:00:00.000Z", + end: null, + }, + ], + window: { + from: "2026-07-02T00:00:00.000Z", + to: "2026-07-02T02:00:00.000Z", + capped: false, + }, + }); + root = createRoot(container); + + flushSync(() => { + root?.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Run time"); + expect(container.textContent).toContain("2h 0m"); + }); + + it("prorates summary tokens to the returned timeline window for clipped spans", async () => { + mockWorkTimelineApi.get.mockResolvedValue({ + ...populatedTimeline, + spans: [ + { + ...populatedTimeline.spans[0], + start: "2026-07-02T00:00:00.000Z", + end: "2026-07-02T04:00:00.000Z", + usage: { + inputTokens: 2_000, + cachedInputTokens: 0, + outputTokens: 2_000, + totalTokens: 4_000, + }, + }, + ], + window: { + from: "2026-07-02T02:00:00.000Z", + to: "2026-07-02T04:00:00.000Z", + capped: false, + }, + }); + root = createRoot(container); + + flushSync(() => { + root?.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Tokens used"); + expect(container.textContent).toContain("2K"); + expect(container.textContent).not.toContain("4K"); + }); + 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 8183a34fbc..9039a734a3 100644 --- a/ui/src/pages/Timeline.tsx +++ b/ui/src/pages/Timeline.tsx @@ -7,7 +7,8 @@ */ import { useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { GanttChartSquare, Minus, Plus, RotateCcw } from "lucide-react"; +import { Bot, Clock3, Coins, GanttChartSquare, Minus, Plus, RotateCcw, type LucideIcon } from "lucide-react"; +import type { WorkTimelineResult } from "@paperclipai/shared"; import { workTimelineApi, type WorkTimelineParams } from "@/api/workTimeline"; import { queryKeys } from "@/lib/queryKeys"; import { useCompany } from "@/context/CompanyContext"; @@ -25,7 +26,7 @@ import { type ZoomLevel, zoomScaleForLevel, } from "@/components/timeline/WorkTimelineChart"; -import { TIMELINE_COLORS } from "@/lib/timeline/layout"; +import { formatDuration, TIMELINE_COLORS } from "@/lib/timeline/layout"; import { cn } from "@/lib/utils"; type RangePreset = "today" | "7d" | "30d" | "custom"; @@ -66,6 +67,72 @@ function rangeError(range: DateRangeState): string | null { return null; } +function formatInteger(value: number): string { + return new Intl.NumberFormat("en-US").format(value); +} + +function formatCompactInteger(value: number): string { + return new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, + }).format(value); +} + +function spanStartMs(span: WorkTimelineResult["spans"][number]) { + return new Date(span.start).getTime(); +} + +function spanEndMs(span: WorkTimelineResult["spans"][number], fallbackEndMs: number) { + return span.end ? new Date(span.end).getTime() : fallbackEndMs; +} + +function spanWindowOverlap( + span: WorkTimelineResult["spans"][number], + windowFromMs: number, + windowToMs: number, +) { + const rawStartMs = spanStartMs(span); + const rawEndMs = spanEndMs(span, windowToMs); + const startMs = Math.max(rawStartMs, windowFromMs); + const endMs = Math.min(rawEndMs, windowToMs); + return { + clippedMs: Math.max(0, endMs - startMs), + rawMs: Math.max(0, rawEndMs - rawStartMs), + }; +} + +function spanWindowTokens(span: WorkTimelineResult["spans"][number], rawMs: number, clippedMs: number) { + const totalTokens = span.usage?.totalTokens ?? 0; + if (totalTokens <= 0 || clippedMs <= 0) return 0; + if (rawMs <= 0 || clippedMs >= rawMs) return totalTokens; + return Math.round(totalTokens * (clippedMs / rawMs)); +} + +function timelineSummary(data: WorkTimelineResult) { + 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(); + let activeMs = 0; + let totalTokens = 0; + + for (const span of data.spans) { + if (actorById.get(span.actorId)?.type === "agent") { + activeAgentIds.add(span.actorId); + } + const overlap = spanWindowOverlap(span, windowFromMs, windowToMs); + activeMs += overlap.clippedMs; + totalTokens += spanWindowTokens(span, overlap.rawMs, overlap.clippedMs); + } + + return { + runs: data.spans.length, + agents: activeAgentIds.size, + activeMs, + totalTokens, + }; +} + function Segmented({ value, options, @@ -125,6 +192,40 @@ function TimelineLegend() { ); } +function TimelineSummaryStats({ + summary, +}: { + summary: ReturnType; +}) { + const stats: { label: string; value: string; icon: LucideIcon }[] = [ + { label: "Runs", value: formatInteger(summary.runs), icon: GanttChartSquare }, + { label: "Agents", value: formatInteger(summary.agents), icon: Bot }, + { label: "Run time", value: formatDuration(0, summary.activeMs), icon: Clock3 }, + { + label: "Tokens used", + value: summary.totalTokens > 0 ? formatCompactInteger(summary.totalTokens) : "Not tracked", + icon: Coins, + }, + ]; + + return ( +
+ {stats.map((stat) => { + const Icon = stat.icon; + return ( +
+
+ + {stat.label} +
+
{stat.value}
+
+ ); + })} +
+ ); +} + export function Timeline() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -191,46 +292,52 @@ export function Timeline() { setZoomScale(undefined); }; + const summary = data ? timelineSummary(data) : null; + + const rangeControls = ( + + ); + const toolbar = ( -
- -
+
+ {summary && } +