fix(ui): load the full selected timeline window (#9576)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The timeline page visualizes company activity across a selected date
window
> - The UI requested only the first paginated issue batch even when the
selected zoom covered seven or thirty days
> - A busy company could therefore render an incomplete timeline while
the controls implied the full window was loaded
> - The timeline query needs to exhaust the API pagination for the
selected date range and combine each page without duplicating shared
timeline records
> - This pull request adds a paginated window loader, merges the
returned timeline data, and covers the multi-page behavior with a
regression test
> - The benefit is that the visible timeline matches the selected zoom
window instead of silently omitting later issues

## Linked Issues or Issue Description

### Pre-submission checklist

- [x] I searched existing open and closed issues and pull requests; no
matching report or implementation was found.
- [x] I reproduced the behavior against the pre-change `master`
implementation.
- [x] I confirmed the error originates in Paperclip's core timeline UI,
not an adapter, provider, or local configuration.

### What happened?

Selecting the default seven-day timeline range loaded only the first API
page (up to 500 issues). Companies with more activity therefore
displayed incomplete data even though the controls showed the full
selected window.

### Expected behavior

The timeline should load all issue pages that fall within the selected
date window.

### Steps to reproduce

1. Open the company timeline for a date range containing more than 500
issues.
2. Keep the default seven-day range or select another multi-day preset.
3. Observe that only the first page of issue-backed timeline data is
shown.

### Paperclip version or commit

Pre-change `master`.

### Deployment mode

Local dev source build. The behavior is not adapter-specific and is
independent of database mode and access context.

### Privacy checklist

- [x] No logs, configuration, personally identifiable information, or
user data are included.

## What Changed

- Added pagination parameters to the timeline API client contract.
- Added a timeline window loader that requests every issue page and
deduplicates actors, spans, events, and edges while preserving
pagination metadata.
- Switched the timeline query to use the complete-window loader.
- Added a regression test proving a 501-issue window loads both API
pages and combines their records.
- Preserved delegation events and edges when parent and child issues
fall on different API pages, with a server regression test.

## Verification

- `pnpm exec vitest run
server/src/__tests__/work-timeline-service.test.ts
ui/src/pages/Timeline.test.tsx` — 16 tests passed.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- `pnpm check:token-gates` — all gates clean.
- `git diff --check origin/master...HEAD` — passed.
- Remote CI: build, typecheck, both e2e shards, canary, policy,
security, every general/serialized test shard, and the aggregate
`verify` gate passed on head `24784b28e9`.

## Risks

- Low risk: the change is isolated to timeline data loading and has no
schema or API endpoint changes.
- Large date windows now make sequential requests for all issue pages,
increasing request count for very active companies; the 500-item page
size bounds each response.
- Merged records rely on stable identifiers or composite event/edge
keys; the regression test covers cross-page combination and
deduplication behavior.

> 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 — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex using GPT-5.4 with reasoning, repository tool use, shell
execution, and test execution. The runtime does not expose the exact
context-window size.

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-30 21:40:57 -07:00 committed by GitHub
parent dd1a7f5290
commit 6a3cbe1c58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 179 additions and 7 deletions

View File

@ -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();

View File

@ -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 {

View File

@ -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<WorkTimelineResult>(`/companies/${companyId}/timeline${query(params)}`),
get: (companyId: string, params: WorkTimelineParams = {}, options?: RequestOptions) =>
api.get<WorkTimelineResult>(`/companies/${companyId}/timeline${query(params)}`, options),
};

View File

@ -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");
});

View File

@ -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<WorkTimelineResult> {
const actors = new Map<string, WorkTimelineResult["actors"][number]>();
const spans = new Map<string, WorkTimelineResult["spans"][number]>();
const events = new Map<string, WorkTimelineResult["events"][number]>();
const edges = new Map<string, WorkTimelineResult["edges"][number]>();
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,
});