From a6436126ce3ce65b77588063e6fb3d9b01e197aa Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:04:36 -0700 Subject: [PATCH] feat(ui): add recency separators to task lists (#10454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- ui/src/components/IssuesList.test.tsx | 152 +++++++++++++++++++++++++- ui/src/components/IssuesList.tsx | 91 ++++++++++++++- 2 files changed, 240 insertions(+), 3 deletions(-) diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index 9965f62aee..65c56da3ec 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -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( + 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( + 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( + 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( + 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([]); + }); }); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 921e15793c..63bdb73d84 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -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 ( +
+
+ + {label} + +
+
+ ); +} + 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))}
); }; - 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( + , + ); + } + 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; })()}