diff --git a/server/src/__tests__/work-timeline-service.test.ts b/server/src/__tests__/work-timeline-service.test.ts index 5fd9330809..3d75d73552 100644 --- a/server/src/__tests__/work-timeline-service.test.ts +++ b/server/src/__tests__/work-timeline-service.test.ts @@ -260,6 +260,62 @@ describeEmbeddedPostgres("work timeline aggregation", () => { ])); }); + it("preserves delegation edges when parent and child cross a page boundary", async () => { + const { companyId, userId, agentAId, agentBId } = await seedBase(); + const parentIssueId = randomUUID(); + const childIssueId = randomUUID(); + + await db.insert(issues).values([ + { + id: parentIssueId, + companyId, + title: "Parent", + status: "in_progress", + priority: "medium", + createdByUserId: userId, + assigneeAgentId: agentAId, + createdAt: new Date("2026-03-01T10:00:00Z"), + updatedAt: new Date("2026-03-01T10:00:00Z"), + }, + { + id: childIssueId, + companyId, + title: "Child", + status: "in_progress", + priority: "medium", + parentId: parentIssueId, + createdByAgentId: agentAId, + assigneeAgentId: agentBId, + createdAt: new Date("2026-03-01T11:00:00Z"), + updatedAt: new Date("2026-03-01T11:00:00Z"), + }, + ]); + + const result = await workTimelineService(db).getTimeline({ + companyId, + from: new Date("2026-03-01T00:00:00Z"), + to: new Date("2026-03-02T00:00:00Z"), + limit: 1, + }); + + expect(result.pagination).toEqual({ + limit: 1, + offset: 0, + totalIssues: 2, + hasMore: true, + }); + expect(result.events).toContainEqual(expect.objectContaining({ + kind: "delegated", + issueId: childIssueId, + })); + expect(result.edges).toContainEqual(expect.objectContaining({ + kind: "delegation", + issueId: childIssueId, + fromActorId: `agent:${agentAId}`, + toActorId: `agent:${agentBId}`, + })); + }); + it("does not join activity rows to runs from another company", async () => { const { companyId, agentAId } = await seedBase(); const otherCompanyId = randomUUID(); diff --git a/server/src/services/work-timeline.ts b/server/src/services/work-timeline.ts index 96e7e6722b..1769368d5d 100644 --- a/server/src/services/work-timeline.ts +++ b/server/src/services/work-timeline.ts @@ -471,8 +471,8 @@ export function workTimelineService(db: Db) { const accessibleIssues = await filterReadableIssues(userScopedIssues, input.canReadIssue); const sortedIssues = accessibleIssues.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()); const pagedIssues = sortedIssues.slice(offset, offset + limit); - const issueById = new Map(pagedIssues.map((issue) => [issue.id, issue])); - const readableIssueIds = Array.from(issueById.keys()); + const issueById = new Map(sortedIssues.map((issue) => [issue.id, issue])); + const readableIssueIds = pagedIssues.map((issue) => issue.id); if (readableIssueIds.length === 0) { return { diff --git a/ui/src/api/workTimeline.ts b/ui/src/api/workTimeline.ts index 42a22b8ebf..cd2124a740 100644 --- a/ui/src/api/workTimeline.ts +++ b/ui/src/api/workTimeline.ts @@ -1,5 +1,5 @@ import type { WorkTimelineResult } from "@paperclipai/shared"; -import { api } from "./client"; +import { api, type RequestOptions } from "./client"; export interface WorkTimelineParams { from?: string; @@ -10,6 +10,7 @@ export interface WorkTimelineParams { projectId?: string; issueId?: string; limit?: number; + offset?: number; } function query(params: WorkTimelineParams): string { @@ -21,11 +22,12 @@ function query(params: WorkTimelineParams): string { if (params.projectId) search.set("projectId", params.projectId); if (params.issueId) search.set("issueId", params.issueId); if (params.limit) search.set("limit", String(params.limit)); + if (params.offset) search.set("offset", String(params.offset)); const qs = search.toString(); return qs ? `?${qs}` : ""; } export const workTimelineApi = { - get: (companyId: string, params: WorkTimelineParams = {}) => - api.get(`/companies/${companyId}/timeline${query(params)}`), + get: (companyId: string, params: WorkTimelineParams = {}, options?: RequestOptions) => + api.get(`/companies/${companyId}/timeline${query(params)}`, options), }; diff --git a/ui/src/pages/Timeline.test.tsx b/ui/src/pages/Timeline.test.tsx index d9b25cbe0d..22d7e32bd6 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, timelineSummary } from "./Timeline"; +import { loadTimelineWindow, Timeline, timelineSummary } from "./Timeline"; const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); const mockWorkTimelineApi = vi.hoisted(() => ({ @@ -203,6 +203,55 @@ describe("Timeline", () => { expect(footer).not.toBeUndefined(); }); + it("loads every timeline page in the selected window", async () => { + mockWorkTimelineApi.get + .mockResolvedValueOnce({ + ...populatedTimeline, + actors: [populatedTimeline.actors[0]], + spans: [populatedTimeline.spans[0]], + pagination: { + limit: 500, + offset: 0, + totalIssues: 501, + hasMore: true, + }, + }) + .mockResolvedValueOnce({ + ...populatedTimeline, + actors: [populatedTimeline.actors[1]], + spans: [populatedTimeline.spans[1]], + pagination: { + limit: 500, + offset: 500, + totalIssues: 501, + hasMore: false, + }, + }); + + const controller = new AbortController(); + const result = await loadTimelineWindow("company-1", { + from: populatedTimeline.window.from, + to: populatedTimeline.window.to, + }, controller.signal); + + expect(mockWorkTimelineApi.get).toHaveBeenNthCalledWith(1, "company-1", expect.objectContaining({ + limit: 500, + offset: 0, + }), { signal: controller.signal }); + expect(mockWorkTimelineApi.get).toHaveBeenNthCalledWith(2, "company-1", expect.objectContaining({ + limit: 500, + offset: 500, + }), { signal: controller.signal }); + expect(result.actors.map((actor) => actor.id)).toEqual(["agent:codex", "agent:qa"]); + expect(result.spans.map((span) => span.runId)).toEqual(["run-1", "run-2"]); + expect(result.pagination).toEqual({ + limit: 500, + offset: 0, + totalIssues: 501, + hasMore: false, + }); + }); + it("clamps open run summary time to the returned timeline window", async () => { mockWorkTimelineApi.get.mockResolvedValue({ ...populatedTimeline, @@ -304,6 +353,7 @@ describe("Timeline", () => { from: expect.any(String), to: expect.any(String), }), + { signal: expect.any(AbortSignal) }, ); expect(mockWorkTimelineApi.get.mock.calls[0]?.[1]).not.toHaveProperty("userId"); }); diff --git a/ui/src/pages/Timeline.tsx b/ui/src/pages/Timeline.tsx index 37620879e4..afa400ae22 100644 --- a/ui/src/pages/Timeline.tsx +++ b/ui/src/pages/Timeline.tsx @@ -32,11 +32,75 @@ import { formatDuration, TIMELINE_COLORS } from "@/lib/timeline/layout"; import { cn } from "@/lib/utils"; type RangePreset = "today" | "7d" | "30d" | "custom"; +const TIMELINE_PAGE_LIMIT = 500; + interface DateRangeState { fromDate: string; toDate: string; } +function timelineEventKey(event: WorkTimelineResult["events"][number]) { + return `${event.actorId}\0${event.kind}\0${event.issueId}\0${event.at}`; +} + +function timelineEdgeKey(edge: WorkTimelineResult["edges"][number]) { + return `${edge.fromActorId}\0${edge.toActorId}\0${edge.issueId}\0${edge.at}\0${edge.kind}`; +} + +export async function loadTimelineWindow( + companyId: string, + params: WorkTimelineParams, + signal?: AbortSignal, +): Promise { + const actors = new Map(); + const spans = new Map(); + const events = new Map(); + const edges = new Map(); + let offset = 0; + let firstPage: WorkTimelineResult | null = null; + let totalIssues = 0; + let capped = false; + + while (true) { + const page = await workTimelineApi.get(companyId, { + ...params, + limit: TIMELINE_PAGE_LIMIT, + offset, + }, { signal }); + firstPage ??= page; + totalIssues = Math.max(totalIssues, page.pagination.totalIssues); + capped ||= page.window.capped; + + for (const actor of page.actors) actors.set(actor.id, actor); + for (const span of page.spans) spans.set(span.runId, span); + for (const event of page.events) events.set(timelineEventKey(event), event); + for (const edge of page.edges) edges.set(timelineEdgeKey(edge), edge); + + if (!page.pagination.hasMore) break; + const nextOffset = page.pagination.offset + page.pagination.limit; + if (nextOffset <= offset) throw new Error("Timeline pagination did not advance"); + offset = nextOffset; + } + + if (!firstPage) throw new Error("Timeline response was empty"); + return { + actors: Array.from(actors.values()), + spans: Array.from(spans.values()), + events: Array.from(events.values()), + edges: Array.from(edges.values()), + pagination: { + limit: TIMELINE_PAGE_LIMIT, + offset: 0, + totalIssues, + hasMore: false, + }, + window: { + ...firstPage.window, + capped, + }, + }; +} + function dateInputValue(date: Date): string { const yyyy = date.getFullYear(); const mm = String(date.getMonth() + 1).padStart(2, "0"); @@ -261,7 +325,7 @@ export function Timeline() { const { data, isLoading, error } = useQuery({ queryKey: [...queryKeys.workTimeline(selectedCompanyId ?? ""), dateRange.fromDate, dateRange.toDate], - queryFn: () => workTimelineApi.get(selectedCompanyId!, params!), + queryFn: ({ signal }) => loadTimelineWindow(selectedCompanyId!, params!, signal), enabled: !!selectedCompanyId && !!params, });