feat(ui): add recency separators to task lists (#10454)

## Thinking Path

> - Paperclip is the open source control plane people use to manage AI
agents and their work
> - Project pages give operators a dense task list for understanding
what has changed recently
> - A purely chronological list makes the transition from fresh work to
aging work difficult to scan
> - The existing activity feed already uses a quiet labeled divider to
communicate a recency boundary
> - This pull request applies that familiar pattern to task lists at the
one-day and one-week boundaries
> - The benefit is faster age-based scanning without adding filters,
badges, or repeated metadata to every row

## Linked Issues or Issue Description

### Subsystem affected

`ui/` — React + Vite board UI.

### Problem or motivation

Operators scanning a project task list cannot quickly see where recently
created or updated work gives way to tasks that are more than a day or a
week old.

### Proposed solution

Insert subtle, accessible “Older than a day” and “Older than a week”
separators when a task list is sorted newest-first by creation or update
time.

### Alternatives considered

Per-row age badges would repeat state and add noise; persistent
age-based groups would interfere with the list's existing grouping
controls. Lightweight boundary markers preserve the current ordering and
interaction model.

### Roadmap alignment

This is a tightly scoped board-UI polish change and does not duplicate a
roadmap milestone.

### Additional context

The visual treatment follows the existing activity-feed recency
separator pattern. A public GitHub search found no duplicate or related
open issue or pull request.

## What Changed

- Added rolling one-day and one-week recency buckets for created/updated
timestamps.
- Rendered token-compliant, accessible separators only for newest-first
date sorts and only when visible rows cross a boundary.
- Traversed expanded nested rows in their exact visible order and
emitted every crossed boundary when adjacent rows skip an age bucket.
- Added component and helper coverage for sequential boundaries, skipped
buckets, expanded nested rows, and the no-separator same-bucket case.

## Verification

- `pnpm exec vitest run ui/src/components/IssuesList.test.tsx` — 42
tests passed.
- `pnpm check:token-gates` — all token gates clean.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- `pnpm --filter @paperclipai/ui build` — passed (with existing build
warnings only).

## Risks

- Low risk: the change is presentation-only and limited to list mode
when sorting `created` or `updated` descending.
- Boundaries use rolling 24-hour and 7-day windows rather than
calendar-day boundaries, matching the age-based labels.

> 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, GPT-5 family (the runtime did not expose a more specific
model build or context-window size), with reasoning, repository tool
use, code execution, and GitHub CLI access.

## 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
- [ ] All Paperclip CI gates are green
- [ ] 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-29 18:04:36 -07:00 committed by GitHub
parent 24aa2f516d
commit a6436126ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 240 additions and 3 deletions

View File

@ -6,7 +6,12 @@ import type { AnchorHTMLAttributes, ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, Project } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssuesList } from "./IssuesList";
import {
IssuesList,
issueAgeBucket,
issueAgeBucketsCrossed,
issueAgeSeparatorLabel,
} from "./IssuesList";
import { TooltipProvider } from "@/components/ui/tooltip";
const companyState = vi.hoisted(() => ({
@ -1958,4 +1963,149 @@ describe("IssuesList", () => {
root.unmount();
});
});
it("draws day and week separators between recency-sorted rows", async () => {
const now = Date.now();
const hourAgo = new Date(now - 60 * 60 * 1000);
const threeDaysAgo = new Date(now - 3 * 24 * 60 * 60 * 1000);
const tenDaysAgo = new Date(now - 10 * 24 * 60 * 60 * 1000);
const { root } = renderWithQueryClient(
<IssuesList
issues={[
createIssue({ id: "issue-recent", identifier: "PAP-1", title: "Just updated", updatedAt: hourAgo }),
createIssue({ id: "issue-mid", identifier: "PAP-2", title: "A few days old", updatedAt: threeDaysAgo }),
createIssue({ id: "issue-old", identifier: "PAP-3", title: "Over a week old", updatedAt: tenDaysAgo }),
]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
const separators = Array.from(container.querySelectorAll("[data-issues-date-separator]"));
const labels = separators.map((el) => el.getAttribute("aria-label"));
expect(labels).toEqual(["Older than a day", "Older than a week"]);
});
act(() => {
root.unmount();
});
});
it("draws both separators when adjacent rows skip the middle age bucket", async () => {
const now = Date.now();
const { root } = renderWithQueryClient(
<IssuesList
issues={[
createIssue({ id: "issue-recent", identifier: "PAP-1", title: "Just updated", updatedAt: new Date(now - 60 * 60 * 1000) }),
createIssue({ id: "issue-old", identifier: "PAP-2", title: "Over a week old", updatedAt: new Date(now - 10 * 24 * 60 * 60 * 1000) }),
]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
const labels = Array.from(container.querySelectorAll("[data-issues-date-separator]"))
.map((el) => el.getAttribute("aria-label"));
expect(labels).toEqual(["Older than a day", "Older than a week"]);
});
act(() => {
root.unmount();
});
});
it("places separators around expanded nested rows in visible order", async () => {
const now = Date.now();
const { root } = renderWithQueryClient(
<IssuesList
issues={[
createIssue({ id: "issue-parent", identifier: "PAP-1", title: "Recent parent", updatedAt: new Date(now - 60 * 60 * 1000) }),
createIssue({ id: "issue-child", identifier: "PAP-2", parentId: "issue-parent", title: "Older child", updatedAt: new Date(now - 3 * 24 * 60 * 60 * 1000) }),
createIssue({ id: "issue-old", identifier: "PAP-3", title: "Old root", updatedAt: new Date(now - 10 * 24 * 60 * 60 * 1000) }),
]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
const visibleOrder = Array.from(
container.querySelectorAll("[data-testid='issue-row'], [data-issues-date-separator]"),
).map((element) => element.getAttribute("aria-label") ?? element.firstElementChild?.textContent);
expect(visibleOrder).toEqual([
"Recent parent",
"Older than a day",
"Older child",
"Older than a week",
"Old root",
]);
});
act(() => {
root.unmount();
});
});
it("omits date separators when all rows share a recency bucket", async () => {
const now = Date.now();
const { root } = renderWithQueryClient(
<IssuesList
issues={[
createIssue({ id: "issue-a", identifier: "PAP-1", title: "One", updatedAt: new Date(now - 60 * 60 * 1000) }),
createIssue({ id: "issue-b", identifier: "PAP-2", title: "Two", updatedAt: new Date(now - 2 * 60 * 60 * 1000) }),
]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(container.querySelector("[data-testid='issue-row']")).not.toBeNull();
});
expect(container.querySelectorAll("[data-issues-date-separator]").length).toBe(0);
act(() => {
root.unmount();
});
});
});
describe("issueAgeBucket", () => {
const now = new Date("2026-04-10T12:00:00.000Z").getTime();
it("buckets by day and week boundaries", () => {
expect(issueAgeBucket(new Date(now - 60 * 60 * 1000), now)).toBe(0);
expect(issueAgeBucket(new Date(now - 3 * 24 * 60 * 60 * 1000), now)).toBe(1);
expect(issueAgeBucket(new Date(now - 10 * 24 * 60 * 60 * 1000), now)).toBe(2);
});
it("labels the day and week separators", () => {
expect(issueAgeSeparatorLabel(1)).toBe("Older than a day");
expect(issueAgeSeparatorLabel(2)).toBe("Older than a week");
});
it("returns every boundary crossed between adjacent rows", () => {
expect(issueAgeBucketsCrossed(0, 1)).toEqual([1]);
expect(issueAgeBucketsCrossed(1, 2)).toEqual([2]);
expect(issueAgeBucketsCrossed(0, 2)).toEqual([1, 2]);
expect(issueAgeBucketsCrossed(2, 1)).toEqual([]);
});
});

View File

@ -1,4 +1,5 @@
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
import type { ReactNode } from "react";
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { useVisibilityRefetchInterval } from "@/lib/polling";
import { accessApi } from "../api/access";
@ -303,6 +304,60 @@ function sortIssues(issues: Issue[], state: IssueViewState): Issue[] {
return sorted;
}
const AGE_BUCKET_DAY_MS = 24 * 60 * 60 * 1000;
const AGE_BUCKET_WEEK_MS = 7 * AGE_BUCKET_DAY_MS;
// Recency buckets for the date separators shown in date-sorted lists:
// 0 = within the last day, 1 = within the last week, 2 = older than a week.
// A separator is drawn between rows whenever the bucket increases, so the
// "1 day" and "1 week" boundaries appear as the list ages downward.
export function issueAgeBucket(date: Date | string, now: number = Date.now()): 0 | 1 | 2 {
const age = now - new Date(date).getTime();
if (age < AGE_BUCKET_DAY_MS) return 0;
if (age < AGE_BUCKET_WEEK_MS) return 1;
return 2;
}
export function issueAgeSeparatorLabel(bucket: 1 | 2): string {
return bucket === 1 ? "Older than a day" : "Older than a week";
}
export function issueAgeBucketsCrossed(
previousBucket: 0 | 1 | 2,
currentBucket: 0 | 1 | 2,
): Array<1 | 2> {
const crossedBuckets: Array<1 | 2> = [];
if (previousBucket < 1 && currentBucket >= 1) crossedBuckets.push(1);
if (previousBucket < 2 && currentBucket >= 2) crossedBuckets.push(2);
return crossedBuckets;
}
// Only recency sorts (newest first) get date separators — for any other
// sort/direction the boundaries would be meaningless.
function issueDateSeparatorField(state: IssueViewState): "createdAt" | "updatedAt" | null {
if (state.sortDir !== "desc") return null;
if (state.sortField === "created") return "createdAt";
if (state.sortField === "updated") return "updatedAt";
return null;
}
function IssueDateSeparator({ label }: { label: string }) {
return (
<div
className="flex items-center gap-2 px-3 py-1.5 sm:pl-0 sm:pr-4"
role="separator"
aria-label={label}
data-issues-date-separator=""
>
<div className="h-px flex-1 bg-border" />
<span className="text-(length:--text-nano) font-medium uppercase tracking-wider text-muted-foreground">
{label}
</span>
<div className="h-px flex-1 bg-border" />
</div>
);
}
function issueMatchesLocalSearch(issue: Issue, normalizedSearch: string): boolean {
if (!normalizedSearch) return true;
return [
@ -2254,12 +2309,44 @@ export function IssuesList({
) : undefined
)}
/>
{hasChildren && isExpanded && children.map((child) => renderIssueRow(child, depth + 1))}
</div>
);
};
return roots.map((issue) => renderIssueRow(issue, 0)).filter((node) => node !== null);
const separatorField = issueDateSeparatorField(viewState);
const separatorNow = Date.now();
const nodes: ReactNode[] = [];
let prevBucket: 0 | 1 | 2 | null = null;
const appendIssueRow = (issue: Issue, depth: number) => {
const node = renderIssueRow(issue, depth);
// Skip rows the render budget dropped so separators never
// dangle above an unrendered (or absent) row.
if (node === null) return;
if (separatorField) {
const bucket = issueAgeBucket(issue[separatorField], separatorNow);
for (const crossedBucket of prevBucket === null
? []
: issueAgeBucketsCrossed(prevBucket, bucket)) {
nodes.push(
<IssueDateSeparator
key={`age-sep-${issue.id}-${crossedBucket}`}
label={issueAgeSeparatorLabel(crossedBucket)}
/>,
);
}
prevBucket = bucket;
}
nodes.push(node);
if (!viewState.collapsedParents.includes(issue.id)) {
for (const child of childMap.get(issue.id) ?? []) {
appendIssueRow(child, depth + 1);
}
}
};
for (const issue of roots) {
appendIssueRow(issue, 0);
}
return nodes;
})()}
</CollapsibleContent>
</Collapsible>