mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 13:38:01 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Its recovery loop keeps assigned work moving after execution failures. > - Productivity review used run counts, comment counts, and elapsed time to create management tasks. > - Infrastructure failures could satisfy those rules and create more tasks without evidence that the source work needed management review. > - This pull request removes that detector and its continuation holds. > - Bounded recovery, budgets, explicit blockers, and normal review stages remain in place. > - Existing task records stay readable and unchanged. ## Linked Issues or Issue Description Refs #5897. That request describes unwanted automatic productivity reviews and asks to preserve existing tasks. This change retires the feature instead of adding another configuration switch. Related prior approaches: Refs #9191, Refs #12489. Those changes excluded infrastructure failures or bounded review creation. This removal replaces the detector rather than tuning its thresholds. ## What Changed - Delete the scheduled detector, automatic task creation, evidence refresh, and productivity continuation holds. - Remove computed productivity fields, special attention items, badges, and Storybook fixtures. - Retain historical origin values, decision compatibility, and recovery recursion exclusions. Add no migration and change no existing task data. - Update the execution contract. Replace feature tests with regressions for legacy task reads, ordinary attention, and bounded continuation in the presence of an old review. ## Verification - Targeted attention, issue-route, startup, and UI tests: 4 files and 101 tests passed. - Updated issue-route and UI tests: 2 files and 61 tests passed. - Bounded continuation regression: 2 cases passed, including a legacy review plus pre-dispatch cancellation churn. - `pnpm check:token-gates`: all four gates passed. - `git diff --check`: passed. - `pnpm build-storybook`: passed. - Greptile: 5/5 on `a5a612eea`, with no actionable findings. - Scheduler and historical recovery regressions: 2 files and 28 tests passed. - Repository `pnpm -r typecheck` and `pnpm build`: passed. - The complete `pnpm test:run` suite passed across the CI server, serialized-server, and workspace shards on `a5a612eea`. Stopped the duplicate local monolithic run after the full CI suite passed; no completed local full-suite result is claimed. The targeted local suites above passed. - CI serialized shard 5 initially hit a 10-second timeout in the first interaction-route test. The complete file passed locally (78 tests), then the single CI rerun passed. - All CI gates are green, including the build and end-to-end suites. - A local merge check against current `master` (`ce09ea40b`) completed without conflicts. ## Risks - API responses no longer include the computed `productivityReview` field. Consumers must stop using it. - The scheduler no longer creates management work from elapsed time, run counts, or missing comments. This is the intended behavior change. - Existing review tasks and explicit dependencies remain in place. Historical origins still prevent recursive recovery treatment. No task cleanup or data migration occurs. - The native review handoff repair is separate from this removal. ## Model Used OpenAI GPT-6 through Codex, with reasoning, tool use, and code execution. The exact runtime model identifier and context-window size are not exposed in this session. ## 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>
662 lines
25 KiB
TypeScript
662 lines
25 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import type { AttentionFeed, AttentionItem, AttentionSourceKind } from "@paperclipai/shared";
|
|
import {
|
|
ATTENTION_GROUP_BY_KEY,
|
|
ATTENTION_GROUP_BY_OPTIONS,
|
|
attentionBadgeCount,
|
|
attentionDateBucket,
|
|
attentionDetailLine,
|
|
attentionIsNewToday,
|
|
attentionKind,
|
|
attentionStatus,
|
|
attentionTaskRef,
|
|
buildAttentionFilterOptions,
|
|
buildDeskShelves,
|
|
countActiveAttentionFilters,
|
|
defaultAttentionFilterState,
|
|
filterAttentionItems,
|
|
groupAttentionItems,
|
|
isInlineResolvable,
|
|
loadAttentionGroupBy,
|
|
NO_GROUP_SENTINEL,
|
|
planAttentionRenderRows,
|
|
saveAttentionGroupBy,
|
|
severityStyle,
|
|
sortAttentionItems,
|
|
sourceMeta,
|
|
} from "./attention";
|
|
|
|
function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
|
|
return {
|
|
id: "a1",
|
|
companyId: "c1",
|
|
sourceKind: "approval",
|
|
subject: { kind: "approval", id: "s1", companyId: "c1", title: "t", identifier: null, status: null, href: null },
|
|
whyNow: "why",
|
|
decisionVerbs: [],
|
|
inlineResolvable: true,
|
|
entryRule: "",
|
|
exitRule: "",
|
|
dedupKey: "d1",
|
|
dismissalKey: "attention:d1",
|
|
severity: "medium",
|
|
rank: 0,
|
|
activityAt: "2026-07-09T12:00:00Z",
|
|
createdAt: "2026-07-09T12:00:00Z",
|
|
updatedAt: "2026-07-09T12:00:00Z",
|
|
relatedIssue: null,
|
|
project: null,
|
|
workspace: null,
|
|
expiresAt: null,
|
|
ruleKey: null,
|
|
originAgentName: null,
|
|
queues: [],
|
|
shelf: false,
|
|
retentionDays: 30,
|
|
keep: false,
|
|
archivedAt: null,
|
|
retentionVersion: 1,
|
|
decideBy: null,
|
|
decideByAttribution: null,
|
|
snoozedUntil: null,
|
|
detail: null,
|
|
dismissal: null,
|
|
...overrides,
|
|
trainingExampleId: overrides.trainingExampleId ?? null,
|
|
};
|
|
}
|
|
|
|
describe("attention group preference persistence", () => {
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
});
|
|
|
|
it("defaults to None and lists it as the first group option", () => {
|
|
expect(loadAttentionGroupBy()).toBe("none");
|
|
expect(ATTENTION_GROUP_BY_OPTIONS[0]).toEqual(["none", "None"]);
|
|
});
|
|
|
|
it("round-trips explicit grouped choices and treats stale values as None", () => {
|
|
saveAttentionGroupBy("date");
|
|
expect(loadAttentionGroupBy()).toBe("date");
|
|
|
|
localStorage.setItem(ATTENTION_GROUP_BY_KEY, "unexpected");
|
|
expect(loadAttentionGroupBy()).toBe("none");
|
|
});
|
|
});
|
|
|
|
describe("isInlineResolvable", () => {
|
|
it("is true for approvals/interactions/join when server flags inlineResolvable", () => {
|
|
for (const kind of ["approval", "issue_thread_interaction", "join_request"] as AttentionSourceKind[]) {
|
|
expect(isInlineResolvable(buildItem({ sourceKind: kind, inlineResolvable: true }))).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("is false when the server marks a row non-inline (e.g. board approval)", () => {
|
|
expect(isInlineResolvable(buildItem({ sourceKind: "approval", inlineResolvable: false }))).toBe(false);
|
|
});
|
|
|
|
it("inlines a stalled review the server flagged (PAP-16080 §4.4)", () => {
|
|
expect(isInlineResolvable(buildItem({ sourceKind: "review", inlineResolvable: true }))).toBe(true);
|
|
});
|
|
|
|
it("keeps a covered review deep-linking (server leaves inlineResolvable off)", () => {
|
|
expect(isInlineResolvable(buildItem({ sourceKind: "review", inlineResolvable: false }))).toBe(false);
|
|
});
|
|
|
|
it("deep-links recovery/failure/budget rows rather than inlining", () => {
|
|
for (const kind of ["recovery_action", "failed_run", "budget_alert", "blocker_attention"] as AttentionSourceKind[]) {
|
|
expect(isInlineResolvable(buildItem({ sourceKind: kind, inlineResolvable: true }))).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("attentionBadgeCount", () => {
|
|
it("uses the server's pre-pagination desk badge count", () => {
|
|
const feed: AttentionFeed = {
|
|
companyId: "c1",
|
|
generatedAt: "2026-07-09T12:00:00Z",
|
|
totalCount: 3,
|
|
deskBadgeCount: 2,
|
|
nextCursor: "next-page",
|
|
countsBySourceKind: {} as AttentionFeed["countsBySourceKind"],
|
|
items: [buildItem({ id: "1" }), buildItem({ id: "2" }), buildItem({ id: "3" })],
|
|
};
|
|
expect(attentionBadgeCount(feed)).toBe(2);
|
|
});
|
|
|
|
it("is zero for an empty or missing feed", () => {
|
|
expect(attentionBadgeCount(null)).toBe(0);
|
|
expect(attentionBadgeCount(undefined)).toBe(0);
|
|
});
|
|
});
|
|
|
|
// Desk grouping — arrival-based ("New today" / "Earlier") with a "Decide now"
|
|
// shelf only when an explicit decide-by deadline is due.
|
|
describe("buildDeskShelves", () => {
|
|
const NOW = Date.parse("2026-07-09T12:00:00Z");
|
|
const todayIso = "2026-07-09T09:00:00Z";
|
|
const earlierIso = "2026-07-01T09:00:00Z";
|
|
|
|
it("groups by arrival with no shelf when nothing has a due deadline", () => {
|
|
const items = [
|
|
buildItem({ id: "new-1", createdAt: todayIso }),
|
|
buildItem({ id: "old-1", createdAt: earlierIso }),
|
|
buildItem({ id: "new-2", createdAt: "2026-07-09T02:00:00Z" }),
|
|
];
|
|
const shelves = buildDeskShelves(items, NOW);
|
|
expect(shelves.map((s) => s.key)).toEqual(["desk:new-today", "desk:earlier"]);
|
|
expect(shelves[0]!.label).toBe("New today");
|
|
expect(shelves[0]!.items.map((i) => i.id).sort()).toEqual(["new-1", "new-2"]);
|
|
expect(shelves[1]!.items.map((i) => i.id)).toEqual(["old-1"]);
|
|
});
|
|
|
|
it("adds the 'Decide now' shelf only for items with a due decide-by, and never double-buckets them", () => {
|
|
const items = [
|
|
buildItem({ id: "due", decideBy: "today", createdAt: todayIso }),
|
|
buildItem({ id: "overdue", decideBy: "2026-07-01", createdAt: earlierIso }),
|
|
buildItem({ id: "new", createdAt: "2026-07-09T05:00:00Z" }),
|
|
buildItem({ id: "old", createdAt: earlierIso }),
|
|
buildItem({ id: "whenever", decideBy: "whenever", createdAt: "2026-07-09T11:00:00Z" }),
|
|
];
|
|
const shelves = buildDeskShelves(items, NOW);
|
|
expect(shelves.map((s) => s.key)).toEqual(["desk:decide-now", "desk:new-today", "desk:earlier"]);
|
|
// Decide-now items are pulled out of the arrival groups (disjoint shelves).
|
|
expect(shelves[0]!.items.map((i) => i.id)).toEqual(["overdue", "due"]);
|
|
// "New today" is newest-arrival-first: whenever (11:00) before new (05:00).
|
|
expect(shelves[1]!.items.map((i) => i.id)).toEqual(["whenever", "new"]);
|
|
expect(shelves[2]!.items.map((i) => i.id)).toEqual(["old"]);
|
|
// Every item lands in exactly one shelf.
|
|
const total = shelves.reduce((n, s) => n + s.items.length, 0);
|
|
expect(total).toBe(items.length);
|
|
});
|
|
|
|
it("returns no shelves for an empty desk", () => {
|
|
expect(buildDeskShelves([], NOW)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("attentionIsNewToday", () => {
|
|
const NOW = Date.parse("2026-07-09T12:00:00Z");
|
|
it("is true when the item surfaced on the current UTC day", () => {
|
|
expect(attentionIsNewToday(buildItem({ createdAt: "2026-07-09T00:00:01Z" }), NOW)).toBe(true);
|
|
expect(attentionIsNewToday(buildItem({ createdAt: "2026-07-08T23:59:59Z" }), NOW)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("sourceMeta + severityStyle", () => {
|
|
it("uses a generic task label for persisted legacy productivity decisions", () => {
|
|
expect(sourceMeta("productivity_review").label).toBe("Task");
|
|
});
|
|
it("labels every catalog source kind", () => {
|
|
const kinds: AttentionSourceKind[] = [
|
|
"approval",
|
|
"decision",
|
|
"issue_thread_interaction",
|
|
"join_request",
|
|
"recovery_action",
|
|
"productivity_review",
|
|
"blocker_attention",
|
|
"review",
|
|
"failed_run",
|
|
"budget_alert",
|
|
"agent_error_alert",
|
|
];
|
|
for (const kind of kinds) {
|
|
expect(sourceMeta(kind).label.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it("maps escalation severity to distinct accents", () => {
|
|
expect(severityStyle("critical").accent).not.toBe(severityStyle("low").accent);
|
|
});
|
|
});
|
|
|
|
// Supersedes the five-tone map (sky/violet/rose/amber/neutral) + severityBadge:
|
|
// rows now resolve to one of two kinds, each borrowing a task status for its
|
|
// colour and glyph, so the queue and the task list share one vocabulary.
|
|
describe("attentionKind + attentionStatus (flattened decision types)", () => {
|
|
it("reads anything stuck as blocking", () => {
|
|
expect(attentionKind(buildItem({ sourceKind: "failed_run" }))).toBe("blocking");
|
|
expect(attentionKind(buildItem({ sourceKind: "agent_error_alert" }))).toBe("blocking");
|
|
expect(attentionKind(buildItem({ sourceKind: "blocker_attention" }))).toBe("blocking");
|
|
expect(attentionKind(buildItem({ sourceKind: "recovery_action" }))).toBe("blocking");
|
|
expect(attentionKind(buildItem({ sourceKind: "budget_alert" }))).toBe("blocking");
|
|
});
|
|
|
|
it("reads anything awaiting a verdict as review", () => {
|
|
expect(attentionKind(buildItem({ sourceKind: "approval" }))).toBe("review");
|
|
expect(attentionKind(buildItem({ sourceKind: "issue_thread_interaction" }))).toBe("review");
|
|
expect(attentionKind(buildItem({ sourceKind: "join_request" }))).toBe("review");
|
|
expect(attentionKind(buildItem({ sourceKind: "review" }))).toBe("review");
|
|
expect(attentionKind(buildItem({ sourceKind: "productivity_review" }))).toBe("review");
|
|
});
|
|
|
|
it("keeps plan approvals in the review family whichever surface raised them", () => {
|
|
const planApproval = (): AttentionItem["detail"] => ({
|
|
kind: "plan_approval",
|
|
issueTitle: "I",
|
|
planTitle: "P",
|
|
summaryExcerpt: null,
|
|
images: [],
|
|
});
|
|
expect(attentionStatus(buildItem({ sourceKind: "approval", detail: planApproval() }))).toBe("in_review");
|
|
expect(attentionStatus(buildItem({ sourceKind: "issue_thread_interaction", detail: planApproval() }))).toBe(
|
|
"in_review",
|
|
);
|
|
});
|
|
|
|
it("keeps blocking and review distinct", () => {
|
|
expect(attentionStatus(buildItem({ sourceKind: "agent_error_alert" }))).not.toBe(
|
|
attentionStatus(buildItem({ sourceKind: "approval" })),
|
|
);
|
|
});
|
|
|
|
it("borrows exactly two task statuses — and never keys colour off severity", () => {
|
|
expect(attentionStatus(buildItem({ sourceKind: "agent_error_alert" }))).toBe("blocked");
|
|
expect(attentionStatus(buildItem({ sourceKind: "approval" }))).toBe("in_review");
|
|
// Same source, opposite severities → identical status (colour ≠ severity).
|
|
expect(attentionStatus(buildItem({ sourceKind: "failed_run", severity: "critical" }))).toBe(
|
|
attentionStatus(buildItem({ sourceKind: "failed_run", severity: "low" })),
|
|
);
|
|
});
|
|
});
|
|
|
|
// The feed stores the task in two different fields depending on what the row is
|
|
// about. Reading only `relatedIssue` silently dropped the key on every row whose
|
|
// subject *is* the task — reviews and blocked dependencies, i.e. the rows most
|
|
// obviously about a task.
|
|
describe("attentionTaskRef", () => {
|
|
it("reads the task off the subject when the subject IS the task", () => {
|
|
const item = buildItem({
|
|
sourceKind: "blocker_attention",
|
|
subject: {
|
|
kind: "issue",
|
|
id: "i1",
|
|
companyId: "c1",
|
|
title: "Update primary paperclip instance",
|
|
identifier: "PAP-23",
|
|
status: "blocked",
|
|
href: "/PAP/issues/PAP-23",
|
|
},
|
|
});
|
|
expect(attentionTaskRef(item)).toEqual({ identifier: "PAP-23", href: "/PAP/issues/PAP-23" });
|
|
});
|
|
|
|
it("reads the task off relatedIssue when the subject merely hangs off one", () => {
|
|
const item = buildItem({
|
|
sourceKind: "issue_thread_interaction",
|
|
subject: {
|
|
kind: "interaction",
|
|
id: "x1",
|
|
companyId: "c1",
|
|
title: "Ship it?",
|
|
identifier: null,
|
|
status: "pending",
|
|
href: "/PAP/issues/PAP-20#interaction-x1",
|
|
},
|
|
relatedIssue: {
|
|
kind: "issue",
|
|
id: "i2",
|
|
companyId: "c1",
|
|
title: "Produce launch video",
|
|
identifier: "PAP-20",
|
|
status: "in_review",
|
|
href: "/PAP/issues/PAP-20",
|
|
},
|
|
});
|
|
expect(attentionTaskRef(item)).toEqual({ identifier: "PAP-20", href: "/PAP/issues/PAP-20" });
|
|
});
|
|
|
|
it("prefers relatedIssue when both are present — it is the record the subject can't describe", () => {
|
|
const item = buildItem({
|
|
subject: {
|
|
kind: "issue",
|
|
id: "i1",
|
|
companyId: "c1",
|
|
title: "Subject task",
|
|
identifier: "PAP-1",
|
|
status: "todo",
|
|
href: "/PAP/issues/PAP-1",
|
|
},
|
|
relatedIssue: {
|
|
kind: "issue",
|
|
id: "i2",
|
|
companyId: "c1",
|
|
title: "Related task",
|
|
identifier: "PAP-2",
|
|
status: "todo",
|
|
href: "/PAP/issues/PAP-2",
|
|
},
|
|
});
|
|
expect(attentionTaskRef(item)?.identifier).toBe("PAP-2");
|
|
});
|
|
|
|
it("returns null for rows genuinely not attached to a task", () => {
|
|
// A hire approval: subject is the approval itself, no task anywhere.
|
|
expect(attentionTaskRef(buildItem({ sourceKind: "approval" }))).toBeNull();
|
|
// An agent error: subject is the agent.
|
|
expect(
|
|
attentionTaskRef(
|
|
buildItem({
|
|
sourceKind: "agent_error_alert",
|
|
subject: {
|
|
kind: "agent",
|
|
id: "ag1",
|
|
companyId: "c1",
|
|
title: "CTO",
|
|
identifier: null,
|
|
status: "error",
|
|
href: "/PAP/agents/ag1",
|
|
},
|
|
}),
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("does not borrow a key from a non-task subject that happens to have one", () => {
|
|
const item = buildItem({
|
|
subject: {
|
|
kind: "approval",
|
|
id: "ap1",
|
|
companyId: "c1",
|
|
title: "Sign off",
|
|
identifier: "APR-9",
|
|
status: "pending",
|
|
href: "/PAP/approvals/ap1",
|
|
},
|
|
});
|
|
expect(attentionTaskRef(item)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("attentionDetailLine (§7)", () => {
|
|
it("summarizes questions with a count and the first question", () => {
|
|
const line = attentionDetailLine(
|
|
buildItem({
|
|
detail: { kind: "questions", questionCount: 2, firstQuestionText: "Which auth provider?", images: [] },
|
|
}),
|
|
);
|
|
expect(line).toContain("2 questions");
|
|
expect(line).toContain("Which auth provider?");
|
|
});
|
|
|
|
it("singularizes a single suggested task", () => {
|
|
const line = attentionDetailLine(
|
|
buildItem({
|
|
detail: { kind: "suggested_tasks", taskCount: 1, firstTaskTitle: "Add index", images: [] },
|
|
}),
|
|
);
|
|
expect(line).toContain("1 suggested task");
|
|
expect(line).not.toContain("tasks");
|
|
});
|
|
|
|
it("renders a failed run as agent — reason", () => {
|
|
const line = attentionDetailLine(
|
|
buildItem({
|
|
sourceKind: "failed_run",
|
|
detail: { kind: "failed_run", agentName: "Deployer", failureReasonExcerpt: "exit code 1", images: [] },
|
|
}),
|
|
);
|
|
expect(line).toContain("Deployer");
|
|
expect(line).toContain("exit code 1");
|
|
});
|
|
|
|
it("returns null when there is no detail block", () => {
|
|
expect(attentionDetailLine(buildItem({ detail: null }))).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("sortAttentionItems", () => {
|
|
const older = buildItem({ id: "old", activityAt: "2026-07-01T00:00:00Z", rank: 5 });
|
|
const newer = buildItem({ id: "new", activityAt: "2026-07-09T00:00:00Z", rank: 9 });
|
|
|
|
it("puts newest first by default", () => {
|
|
expect(sortAttentionItems([older, newer], "newest").map((i) => i.id)).toEqual(["new", "old"]);
|
|
});
|
|
|
|
it("reverses to oldest first", () => {
|
|
expect(sortAttentionItems([older, newer], "oldest").map((i) => i.id)).toEqual(["old", "new"]);
|
|
});
|
|
|
|
it("breaks activity ties by rank (lower rank wins) regardless of order", () => {
|
|
const a = buildItem({ id: "a", activityAt: "2026-07-09T00:00:00Z", rank: 2 });
|
|
const b = buildItem({ id: "b", activityAt: "2026-07-09T00:00:00Z", rank: 1 });
|
|
expect(sortAttentionItems([a, b], "newest").map((i) => i.id)).toEqual(["b", "a"]);
|
|
expect(sortAttentionItems([a, b], "oldest").map((i) => i.id)).toEqual(["b", "a"]);
|
|
});
|
|
|
|
it("does not mutate the input array", () => {
|
|
const input = [older, newer];
|
|
sortAttentionItems(input, "newest");
|
|
expect(input.map((i) => i.id)).toEqual(["old", "new"]);
|
|
});
|
|
});
|
|
|
|
// `attentionDateBucket` walks back from the start of the *local* day
|
|
// (`setHours(0, 0, 0, 0)`), so every date fixture below is local too. Pinned to
|
|
// UTC instants they drift across the boundary under test: `2026-07-09T23:00:00Z`
|
|
// is 08:00 on the 10th at UTC+9 and buckets as "today", and at UTC+14 and UTC-11
|
|
// even the mid-morning fixtures land on the wrong calendar day.
|
|
const localTime = (month: number, day: number, hour: number) =>
|
|
new Date(2026, month - 1, day, hour, 0, 0, 0);
|
|
|
|
describe("attentionDateBucket", () => {
|
|
const now = localTime(7, 10, 12).getTime();
|
|
|
|
it("buckets by rolling calendar-day windows relative to now", () => {
|
|
expect(attentionDateBucket(localTime(7, 10, 9).toISOString(), now)).toBe("today");
|
|
expect(attentionDateBucket(localTime(7, 9, 23).toISOString(), now)).toBe("yesterday");
|
|
expect(attentionDateBucket(localTime(7, 6, 9).toISOString(), now)).toBe("this_week");
|
|
expect(attentionDateBucket(localTime(6, 1, 9).toISOString(), now)).toBe("earlier");
|
|
});
|
|
|
|
it("treats invalid timestamps as earlier", () => {
|
|
expect(attentionDateBucket("not-a-date", now)).toBe("earlier");
|
|
});
|
|
});
|
|
|
|
describe("groupAttentionItems", () => {
|
|
const now = localTime(7, 10, 12).getTime();
|
|
|
|
it("leaves None as one unlabeled group that preserves caller sort order", () => {
|
|
const items = sortAttentionItems(
|
|
[
|
|
buildItem({ id: "old", activityAt: "2026-07-10T08:00:00Z" }),
|
|
buildItem({ id: "new", activityAt: "2026-07-10T10:00:00Z" }),
|
|
],
|
|
"newest",
|
|
);
|
|
const groups = groupAttentionItems(items, "none", { now });
|
|
expect(groups).toHaveLength(1);
|
|
expect(groups[0].label).toBeNull();
|
|
expect(groups[0].items.map((i) => i.id)).toEqual(["new", "old"]);
|
|
});
|
|
|
|
it("groups by date into fixed Today/Yesterday/This week/Earlier order", () => {
|
|
const items = [
|
|
buildItem({ id: "earlier", activityAt: localTime(6, 1, 9).toISOString() }),
|
|
buildItem({ id: "today", activityAt: localTime(7, 10, 8).toISOString() }),
|
|
buildItem({ id: "yesterday", activityAt: localTime(7, 9, 8).toISOString() }),
|
|
];
|
|
const groups = groupAttentionItems(items, "date", { now });
|
|
expect(groups.map((g) => g.label)).toEqual(["Today", "Yesterday", "Earlier"]);
|
|
expect(groups.map((g) => g.key)).toEqual(["date:today", "date:yesterday", "date:earlier"]);
|
|
});
|
|
|
|
it("groups by severity in escalation order regardless of input order", () => {
|
|
const items = [
|
|
buildItem({ id: "low", severity: "low" }),
|
|
buildItem({ id: "crit", severity: "critical" }),
|
|
buildItem({ id: "med", severity: "medium" }),
|
|
];
|
|
const groups = groupAttentionItems(items, "severity");
|
|
expect(groups.map((g) => g.label)).toEqual(["Critical", "Medium", "Low"]);
|
|
});
|
|
|
|
it("groups by project, keeping a 'No project' bucket for unassigned rows", () => {
|
|
const items = [
|
|
buildItem({ id: "p1", activityAt: "2026-07-10T10:00:00Z", project: { id: "proj-1", name: "Alpha", urlKey: "alpha", color: null, icon: null } }),
|
|
buildItem({ id: "none", activityAt: "2026-07-10T11:00:00Z", project: null }),
|
|
];
|
|
const groups = groupAttentionItems(items, "project");
|
|
const noneGroup = groups.find((g) => g.key === `project:${NO_GROUP_SENTINEL}`);
|
|
expect(noneGroup?.label).toBe("No project");
|
|
expect(groups.find((g) => g.key === "project:proj-1")?.label).toBe("Alpha");
|
|
// Freshest group floats first (No project row is newer).
|
|
expect(groups[0].key).toBe(`project:${NO_GROUP_SENTINEL}`);
|
|
});
|
|
|
|
it("groups by type using source labels", () => {
|
|
const items = [
|
|
buildItem({ id: "a", sourceKind: "approval" }),
|
|
buildItem({ id: "j", sourceKind: "join_request" }),
|
|
];
|
|
const groups = groupAttentionItems(items, "type");
|
|
expect(groups.map((g) => g.key).sort()).toEqual(["type:approval", "type:join_request"]);
|
|
});
|
|
|
|
it("preserves the caller-provided intra-group order (sort governs within a bucket)", () => {
|
|
const items = sortAttentionItems(
|
|
[
|
|
buildItem({ id: "t1", activityAt: localTime(7, 10, 8).toISOString() }),
|
|
buildItem({ id: "t2", activityAt: localTime(7, 10, 10).toISOString() }),
|
|
],
|
|
"newest",
|
|
);
|
|
const [today] = groupAttentionItems(items, "date", { now });
|
|
expect(today.items.map((i) => i.id)).toEqual(["t2", "t1"]);
|
|
});
|
|
|
|
it("returns no groups for an empty list", () => {
|
|
expect(groupAttentionItems([], "date", { now })).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("filterAttentionItems", () => {
|
|
const approval = buildItem({ id: "ap", sourceKind: "approval", severity: "high", project: { id: "p1", name: "Alpha", urlKey: "a", color: null, icon: null } });
|
|
const join = buildItem({ id: "jn", sourceKind: "join_request", severity: "low", project: null });
|
|
const items = [approval, join];
|
|
|
|
it("returns everything when no filters are active", () => {
|
|
expect(filterAttentionItems(items, defaultAttentionFilterState)).toHaveLength(2);
|
|
expect(countActiveAttentionFilters(defaultAttentionFilterState)).toBe(0);
|
|
});
|
|
|
|
it("filters by source kind", () => {
|
|
const result = filterAttentionItems(items, { ...defaultAttentionFilterState, sourceKinds: ["approval"] });
|
|
expect(result.map((i) => i.id)).toEqual(["ap"]);
|
|
});
|
|
|
|
it("filters by severity", () => {
|
|
const result = filterAttentionItems(items, { ...defaultAttentionFilterState, severities: ["low"] });
|
|
expect(result.map((i) => i.id)).toEqual(["jn"]);
|
|
});
|
|
|
|
it("filters by project id and the no-project sentinel", () => {
|
|
expect(filterAttentionItems(items, { ...defaultAttentionFilterState, projectIds: ["p1"] }).map((i) => i.id)).toEqual(["ap"]);
|
|
expect(
|
|
filterAttentionItems(items, { ...defaultAttentionFilterState, projectIds: [NO_GROUP_SENTINEL] }).map((i) => i.id),
|
|
).toEqual(["jn"]);
|
|
});
|
|
|
|
it("ANDs across dimensions", () => {
|
|
const result = filterAttentionItems(items, {
|
|
...defaultAttentionFilterState,
|
|
sourceKinds: ["approval"],
|
|
severities: ["low"],
|
|
});
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("buildAttentionFilterOptions", () => {
|
|
it("collects the distinct dimensions present in the feed", () => {
|
|
const items = [
|
|
buildItem({ sourceKind: "approval", severity: "high", project: { id: "p1", name: "Alpha", urlKey: "a", color: null, icon: null }, workspace: { id: "w1", name: "WS" } }),
|
|
buildItem({ sourceKind: "join_request", severity: "low", project: null, workspace: null }),
|
|
];
|
|
const options = buildAttentionFilterOptions(items);
|
|
expect(options.sourceKinds.sort()).toEqual(["approval", "join_request"]);
|
|
expect(options.severities).toEqual(["high", "low"]);
|
|
expect(options.projects.map((p) => p.id)).toEqual(["p1"]);
|
|
expect(options.workspaces.map((w) => w.id)).toEqual(["w1"]);
|
|
expect(options.hasNoProject).toBe(true);
|
|
expect(options.hasNoWorkspace).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("planAttentionRenderRows (PAP-13784 incremental rendering)", () => {
|
|
const items = (prefix: string, count: number) =>
|
|
Array.from({ length: count }, (_, i) => buildItem({ id: `${prefix}${i}` }));
|
|
|
|
it("allocates the budget across groups in document order", () => {
|
|
const plan = planAttentionRenderRows({
|
|
groups: [
|
|
{ key: "g1", label: "One", items: items("a", 3) },
|
|
{ key: "g2", label: "Two", items: items("b", 3) },
|
|
],
|
|
collapsedGroupKeys: new Set(),
|
|
snoozedItems: [],
|
|
snoozedOpen: false,
|
|
dismissedItems: [],
|
|
dismissedOpen: false,
|
|
limit: 4,
|
|
});
|
|
expect(plan.groupRows.get("g1")).toHaveLength(3);
|
|
expect(plan.groupRows.get("g2")).toHaveLength(1);
|
|
expect(plan.hasMoreRows).toBe(true);
|
|
});
|
|
|
|
it("renders everything and reports no more rows when the budget covers the feed", () => {
|
|
const plan = planAttentionRenderRows({
|
|
groups: [{ key: "g1", label: null, items: items("a", 5) }],
|
|
collapsedGroupKeys: new Set(),
|
|
snoozedItems: items("s", 2),
|
|
snoozedOpen: true,
|
|
dismissedItems: items("d", 2),
|
|
dismissedOpen: true,
|
|
limit: 9,
|
|
});
|
|
expect(plan.groupRows.get("g1")).toHaveLength(5);
|
|
expect(plan.snoozedRows).toHaveLength(2);
|
|
expect(plan.dismissedRows).toHaveLength(2);
|
|
expect(plan.hasMoreRows).toBe(false);
|
|
});
|
|
|
|
it("collapsed groups and closed curtains consume no budget and never truncate", () => {
|
|
const plan = planAttentionRenderRows({
|
|
groups: [
|
|
{ key: "g1", label: "One", items: items("a", 50) },
|
|
{ key: "g2", label: "Two", items: items("b", 2) },
|
|
],
|
|
collapsedGroupKeys: new Set(["g1"]),
|
|
snoozedItems: items("s", 50),
|
|
snoozedOpen: false,
|
|
dismissedItems: [],
|
|
dismissedOpen: false,
|
|
limit: 2,
|
|
});
|
|
expect(plan.groupRows.get("g1")).toHaveLength(0);
|
|
expect(plan.groupRows.get("g2")).toHaveLength(2);
|
|
expect(plan.snoozedRows).toHaveLength(0);
|
|
expect(plan.hasMoreRows).toBe(false);
|
|
});
|
|
|
|
it("curtains draw from the same budget after the active groups", () => {
|
|
const plan = planAttentionRenderRows({
|
|
groups: [{ key: "g1", label: null, items: items("a", 3) }],
|
|
collapsedGroupKeys: new Set(),
|
|
snoozedItems: items("s", 5),
|
|
snoozedOpen: true,
|
|
dismissedItems: items("d", 5),
|
|
dismissedOpen: true,
|
|
limit: 5,
|
|
});
|
|
expect(plan.groupRows.get("g1")).toHaveLength(3);
|
|
expect(plan.snoozedRows).toHaveLength(2);
|
|
expect(plan.dismissedRows).toHaveLength(0);
|
|
expect(plan.hasMoreRows).toBe(true);
|
|
});
|
|
});
|