perf(ui): restore fast Coordinator task and inbox loading (#18323)

## Thinking Path

> - Paperclip lets people manage work done by agents.
> - Tasks, Inbox, and Needs You must stay usable as work history grows.
> - The Coordinator views bypassed some existing collection
optimizations.
> - Navigation fetched complete decision cards for a count, and Tasks
mounted every loaded row.
> - This PR uses the canonical attention rules for small count and
task-ID responses. It also restores bounded rendering and the optimized
Inbox selection.
> - The benefit is less data and less initial rendering work without a
second definition of pending work.

## Linked Issues or Issue Description

**What happened?**

Coordinator navigation fetched the full attention feed on every cold
collection visit. Tasks rendered the entire loaded collection.
Coordinator could also select the old Inbox loader when Streamlined UI
was off.

**Expected behavior**

Fetch only the projection needed by each surface. Start with a bounded
set of rows. Keep complete task membership, accurate counts, current
permissions, and stable loaded rows.

**Steps to reproduce**

1. Enable Coordinator and open Tasks or Inbox with an empty browser
query cache.
2. Observe a complete attention-card download for the navigation badge.
3. Open a large task list and observe that every loaded row mounts at
once.
4. Disable Streamlined UI while keeping Coordinator enabled, then open
Mine and observe three overlapping compact task reads.

Related work: #17756, #3744, #17983, and #18312.

## What Changed

- Add board-only attention count and complete task-ID projections. Reuse
canonical company, privacy, addressee, deduplication, dismissal, snooze,
and archive rules. Skip card bodies, plan/image reads, and presentation
roster lookup.
- Share the small count read across desktop and mobile navigation. Reuse
the page-owned response on Needs You and Needs attention. Keep the
newest known count through navigation, including zero.
- Use the task-ID index for Needs attention and keep task row hydration
in batches of at most 100.
- Render 100 task rows initially, across the whole expanded tree. Reveal
150 more at a time through scrolling or a button. Keep existing rows
through refresh and append. Defer offscreen leaf layout with shared
tokens.
- Keep Coordinator on the optimized Inbox when Streamlined UI is off.
Use index reads for expanded project recents.
- Add real-database, component, and production-build browser regression
checks. Update the access and feature maps.

## Verification

- All required CI checks pass on
`1c68ac8957490a6c8585ba0939355f536c8120bf`: typecheck, build,
server/workspace tests, serialized database suites, and eight E2E
shards. Greptile is **5/5**, with its row-retention finding fixed and
the review thread resolved. Storybook checks were intentionally skipped;
Paperclip Review completed neutral.
- Workspace typecheck and production build passed locally. The final
rebased source also passed UI typecheck and production build. Token
gates and map checks passed.
- The attention service suite passed all 34 tests on disposable
PostgreSQL. Final targeted checks cover compact projections,
complete-set counts, quality approvals, snoozes, private tasks, and
board/company access. Database response capture confirms compact
projections do not read plans or images. All 31 OpenAPI tests passed
after registering both endpoint contracts.
- Six production-build browser journeys passed: desktop, mobile,
Coordinator Inbox with Streamlined UI off, Needs You, Needs attention,
and an older server page followed by task open/Back. They verify the
initial 100-row budget, scrolling, retained DOM identity/height, one
owner per attention read, complete task membership, 220 retained rows on
Back, and less than 2 px of return-position difference.
- The row-budget and Inbox regressions fail against original source and
pass with this change. The final three affected UI suites passed 40
tests, and the broader Inbox/IssuesList suites passed 101 tests
serially.

### Controlled production-build comparison

Baseline `b31ec0a2dc079e32272a8f618425fe92b68bbaec` versus PR head
`1c68ac8957490a6c8585ba0939355f536c8120bf`. Isolated baseline asset
`index-CS9uPxMx.js`; candidate asset `index-BxJeTQ30.js`. Both UI builds
used the same warm disposable API server and database, with 220 tasks
and 110 pending board approvals. Chromium **151.0.7922.34**, 1440 × 900
viewport, **4× CPU throttle**, loopback with no added network latency,
alternating baseline/candidate order. Cold cases use a new browser
context with network cache disabled; warm cases navigate within the same
context. Other host workloads are uncontrolled.

Readiness requires the destination collection's first visible task row,
then two animation frames. Inbox readiness waits for its canonical Mine
route so previous-page rows cannot satisfy the measurement. Five paired
samples per case; cold Inbox has a second five-pair confirmation wave
because the first result was slower. Times below are **milliseconds**;
parentheses show min–max. Paired savings are the median of individual
before-minus-after pairs, not the difference between medians.

| Journey | Pairs | Baseline median (range) | Candidate median (range) |
Median paired saving | Faster pairs |
| --- | ---: | ---: | ---: | ---: | ---: |
| Cold Tasks | 5 | 2752 (2542–3025) | 2300 (2275–2524) | 328 | 5/5 |
| Cold Inbox | 10 | 2536 (2283–2927) | 2582 (2117–2856) | 37 | 5/10 |
| Warm Tasks navigation | 5 | 2054 (1826–2479) | 1448 (1419–1548) | 635
| 5/5 |
| Warm Inbox navigation | 5 | 2020 (1485–2254) | 1568 (1289–1848) | 350
| 5/5 |
| Task → Back to Tasks | 5 | 1747 (1609–1948) | 1327 (1238–1355) | 472 |
5/5 |

Cold Tasks, warm Tasks, warm Inbox, and Back improved in every measured
pair. **Cold Inbox latency is inconclusive:** the two waves pointed in
opposite directions, with the candidate faster in 5/10 pairs. This PR
claims less badge work there, not a proven cold Inbox latency
improvement.

The canonical badge response shrank from **200,039 to 359 decoded
bytes** (encoded HTTP transfer: **8,125 to 598 bytes**). The compact
response also skips card payloads, plans, images, and presentation-only
roster work. These are measured mechanism reductions, separate from
end-to-end latency. Needs You still owns its full card feed; Needs
attention owns its complete task-ID projection. Reproduction setup and
assertions are in `tests/perf/collections/README.md`.

<details>
<summary>Individual paired readiness measurements (ms)</summary>

| Journey | Pair | Baseline | Candidate |
| --- | ---: | ---: | ---: |
| Cold Tasks | 1 | 3025 | 2283 |
| Cold Tasks | 2 | 2542 | 2275 |
| Cold Tasks | 3 | 2752 | 2424 |
| Cold Tasks | 4 | 2783 | 2300 |
| Cold Tasks | 5 | 2641 | 2524 |
| Cold Inbox | 1 | 2927 | 2702 |
| Cold Inbox | 2 | 2422 | 2629 |
| Cold Inbox | 3 | 2409 | 2606 |
| Cold Inbox | 4 | 2621 | 2769 |
| Cold Inbox | 5 | 2539 | 2856 |
| Cold Inbox | 6 | 2283 | 2117 |
| Cold Inbox | 7 | 2466 | 2558 |
| Cold Inbox | 8 | 2674 | 2286 |
| Cold Inbox | 9 | 2696 | 2389 |
| Cold Inbox | 10 | 2533 | 2183 |
| Warm Tasks navigation | 1 | 2479 | 1423 |
| Warm Tasks navigation | 2 | 2228 | 1548 |
| Warm Tasks navigation | 3 | 2054 | 1419 |
| Warm Tasks navigation | 4 | 1826 | 1448 |
| Warm Tasks navigation | 5 | 1954 | 1518 |
| Warm Inbox navigation | 1 | 2020 | 1661 |
| Warm Inbox navigation | 2 | 2198 | 1848 |
| Warm Inbox navigation | 3 | 1733 | 1497 |
| Warm Inbox navigation | 4 | 2254 | 1568 |
| Warm Inbox navigation | 5 | 1485 | 1289 |
| Task → Back to Tasks | 1 | 1721 | 1330 |
| Task → Back to Tasks | 2 | 1747 | 1275 |
| Task → Back to Tasks | 3 | 1948 | 1355 |
| Task → Back to Tasks | 4 | 1609 | 1327 |
| Task → Back to Tasks | 5 | 1790 | 1238 |

</details>

### Failed attempts and limits

- The local full repository command hit embedded PostgreSQL startup
failures and 16 failures in the untouched native session executor suite;
it was stopped. The full local suite is not claimed green. CI on the
final revision provides the broad test result.
- A concurrent local UI run hit timing failures under host contention.
Inbox/IssuesList subsequently passed all 101 tests serially. No tests
were weakened to address these timeouts.
- CI caught two integration gaps introduced during this work: missing
OpenAPI entries for the new endpoints, and an attention fixture
incompatible with the routine filters added on master. Both were fixed;
the affected 31 OpenAPI and 40 UI tests pass.
- The final revision's first CI attempt failed two untouched tests:
scheduled-retry batch cancellation and the tool-access audience/archive
race. Both failed jobs passed when rerun without changing the revision.
Their original failed attempts remain visible in [the workflow
history](https://github.com/paperclipai/paperclip/actions/runs/41083379875).
- Browser evidence uses synthetic disposable data. This PR is not
deployed staging or production verification.

## Risks

- New attention sources must preserve canonical eligibility. Both
projections call the existing collector, so they share its permissions
and complete-set behavior.
- The count and task-ID caches must not stand in for full decision
cards. Each projection has its own key under the existing attention
invalidation prefix.
- A row budget can omit descendants if applied per parent. The tests
cover one global budget across a large expanded tree, refresh, append,
reset, and incremental loading.
- Local browser evidence uses synthetic data and is not staging
deployment proof.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, tool use, and code
execution. The exact backend model deployment ID and context window were
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 (affected suites; broad-run
limitations are listed above)
- [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:
DottaandPaperclip authored and GitHub committed 2026-10-30 11:58:53 -05:00
1 parent ebd05e9f4c
commit aa58fa1073
30 files changed
+664 -83

No files matched your search

+8
View File
@@ -105,6 +105,14 @@ API: `present` — representative operation IDs:
- `GET /api/companies/{companyId}/decision-queues`.
- `GET /api/companies/{companyId}/decision-queues/{key}/items`.
- `GET /api/companies/{companyId}/attention/summary` — canonical outstanding
counts without decision cards, images, plan bodies, or pagination. Board user
and company access are required. Task privacy, addressee, dismissal, snooze,
archive, and complete-set counting rules match the attention feed. This read
grants no agent or resolution authority.
- `GET /api/companies/{companyId}/attention/tasks` — the same canonical counts
and distinct accessible task IDs for Needs attention. It has the same board,
company, privacy, addressee, dismissal, snooze, and archive gates as the feed.
- `GET /api/companies/{companyId}/attention/history` — read-only canonical
interaction/Decisions-v1 projection with cursor, search, outcome, agent, project,
date, and chronological sort filters. Each source and v1 target must pass task
+6
View File
@@ -92,6 +92,12 @@ approvals/runs resolve. Only the stable list placeholder should appear until the
initial sources settle; a failed-run row must not jump when tasks arrive. Release
visible-row details separately and compare row/title bounds. Mine reads its own
100-row index, with parent/run context by ID; sidebar badges use the count endpoint.
Coordinator desktop and mobile navigation share `/attention/summary` for the
outstanding count. Needs You owns its complete feed; Tasks → Needs attention
owns a compact complete task-ID index. Navigation reuses either page response. Coordinator keeps the optimized Inbox even when
the instance Streamlined UI setting is off. The loopback browser checks in
[collection startup](../../tests/perf/collections/startup.spec.ts) exercise these
reads and the initial task row budget. They do not establish deployed latency.
The [Inbox component checks](../../ui/src/pages/Inbox.test.tsx) cover the source
barrier and selected-source query; [badge tests](../../server/src/__tests__/inbox-dismissals.test.ts)
cover unread counts. Browser timing and layout proof remain separate from these tests.
+2
View File
@@ -171,6 +171,8 @@ export type {
DecisionHistoryPage,
AttentionDetailImage,
AttentionFeed,
AttentionSummary,
AttentionTaskIndex,
AttentionItem,
AttentionItemDetail,
AttentionItemDismissal,
+7
View File
@@ -275,6 +275,13 @@ export interface AttentionFeed {
countsBySourceKind: Record<AttentionSourceKind, number>;
items: AttentionItem[];
}
/** Canonical attention counts without downloading the decision cards. */
export type AttentionSummary = Omit<AttentionFeed, "items" | "nextCursor">;
/** Complete accessible task membership for Tasks → Needs attention. */
export interface AttentionTaskIndex extends AttentionSummary {
taskIds: string[];
}
/** Read-only projection of canonical task decisions; never a second write store. */
export interface DecisionHistoryItem {
id: string;
+2
View File
@@ -45,6 +45,8 @@ export type {
DecisionHistoryPage,
AttentionDetailImage,
AttentionFeed,
AttentionSummary,
AttentionTaskIndex,
AttentionItem,
AttentionItemDetail,
AttentionItemDismissal,
@@ -1406,6 +1406,22 @@ describeEmbeddedPostgres("attention service", () => {
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
const interactionItems = feed.items.filter((item) => item.sourceKind === "issue_thread_interaction");
const detailsByKind = new Map(interactionItems.map((item) => [item.detail?.kind, item]));
const summaryEvents: Array<Record<string, unknown>> = [], captureNow = Date.now();
const summaryCapture = createDatabaseResponseCapture({ start: captureNow - 100, end: captureNow + 59_000 }, event => summaryEvents.push(event));
installDatabaseResponseCapture(db, { documents, assets, issueAttachments }, summaryCapture);
try {
const summary = await attentionService(db).summary(companyId, { userId: "board-user" });
expect(summary.countsBySourceKind).toEqual(feed.countsBySourceKind);
expect(summary.totalCount).toBe(feed.totalCount);
const index = await attentionService(db).taskIndex(companyId, { userId: "board-user" });
expect(index.taskIds).toEqual([...new Set(feed.items.flatMap(item => item.relatedIssue ? [item.relatedIssue.id] : item.subject.kind === "issue" ? [item.subject.id] : []))]);
expect(index.totalCount).toBe(feed.totalCount);
} finally { summaryCapture.flush(); }
const cardReads = summaryEvents.filter(event => event.event === "paperclip.db_response_capture.entry.v1"
&& ["documents", "assets", "issue_attachments"].includes(String(event.table)));
expect(cardReads).toEqual([]);
const planItem = detailsByKind.get("plan_approval");
expect(planItem?.subject.title).toBe("Plan approval - Approve launch plan");
@@ -1527,6 +1543,9 @@ describeEmbeddedPostgres("attention service", () => {
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
const interactions = feed.items.filter(item => item.sourceKind === "issue_thread_interaction");
expect(interactions.map(item => item.subject.id).sort()).toEqual([ids[0], ids[1], ids[3]].sort());
const summary = await attentionService(db).summary(companyId, { userId: "board-user" });
expect(summary.countsBySourceKind).toEqual(feed.countsBySourceKind);
// Resolving one output removes only that decision, leaving the other output
// and the ordinary confirmation independently actionable.
await db.update(issueThreadInteractions).set({ status: "accepted", resolvedAt: new Date() }).where(eq(issueThreadInteractions.id, ids[0]));
@@ -1858,6 +1877,7 @@ describeEmbeddedPostgres("attention service", () => {
await expect(attentionService(db).list(companyId, { userId: "board-user" }))
.resolves.toMatchObject({ totalCount: 1 }); // agent_error_alert from seed
await expect(attentionService(db).summary(companyId, { userId: "board-user" })).resolves.toMatchObject({ totalCount: 1 });
const hiddenFeed = await attentionService(db).list(companyId, { userId: "board-user", includeDismissed: true });
expect(hiddenFeed.items.find((item) => item.dedupKey === `approval:${approvalId}`)?.dismissal)
.toMatchObject({ kind: "snooze", isActive: true, snoozedUntil: "2099-01-02T00:00:00.000Z" });
@@ -1871,6 +1891,9 @@ describeEmbeddedPostgres("attention service", () => {
const visibleApproval = visibleFeed.items.find((item) => item.dedupKey === `approval:${approvalId}`);
expect(visibleApproval?.dismissal).toMatchObject({ kind: "snooze", isActive: false });
expect(visibleApproval).toBeTruthy();
const summary = await attentionService(db).summary(companyId, { userId: "board-user" });
expect(summary.totalCount).toBe(visibleFeed.totalCount);
});
it("enriches, filters, paginates, snoozes, and ranks the decide-now feed", async () => {
@@ -2113,6 +2136,13 @@ describeEmbeddedPostgres("attention service", () => {
expect(approvalRows.every((approval) => internalSnapshotIds.has(approval.id))).toBe(true);
expect(internalSnapshot.items.length).toBeGreaterThan(100);
expect(internalSnapshot.nextCursor).toBeNull();
const summary = await attentionService(db).summary(companyId, { userId: "board-user" });
expect(summary).toEqual({
companyId, generatedAt: expect.any(String), totalCount: internalSnapshot.totalCount,
deskBadgeCount: internalSnapshot.deskBadgeCount, countsBySourceKind: internalSnapshot.countsBySourceKind,
});
expect(JSON.stringify(summary).length).toBeLessThan(JSON.stringify(internalSnapshot).length / 10);
await expect(attentionService(db).list(companyId, { userId: "board-user", all: true }))
.rejects.toThrow("all requires a queue filter");
@@ -2166,6 +2196,10 @@ describeEmbeddedPostgres("attention service", () => {
allowUnscopedAll: true,
});
expect(complete.items.filter((item) => item.sourceKind === "decision")).toHaveLength(2);
const summary = await svc.summary(companyId, { userId: "board-user" });
expect(summary.totalCount).toBe(complete.totalCount);
expect(summary.countsBySourceKind).toEqual(complete.countsBySourceKind);
expect(complete.nextCursor).toBeNull();
});
@@ -2281,6 +2315,18 @@ describeEmbeddedPostgres("attention service", () => {
.get(`/api/companies/${companyId}/attention?includeDismissed=true&all=true`)
.expect(200);
expect(completeFeed.body.nextCursor).toBeNull();
const summary = await request(app(board)).get(`/api/companies/${companyId}/attention/summary`).expect(200);
expect(summary.body).not.toHaveProperty("items");
expect(summary.body.totalCount).toBe(completeFeed.body.totalCount);
const taskIndex = await request(app(board)).get(`/api/companies/${companyId}/attention/tasks`).expect(200);
expect(taskIndex.body).not.toHaveProperty("items");
expect(taskIndex.body.taskIds).toEqual([]);
await request(app(agent)).get(`/api/companies/${companyId}/attention/tasks`).expect(403);
await request(app({ ...board, source: "session", companyIds: [] })).get(`/api/companies/${companyId}/attention/tasks`).expect(403);
await request(app(agent)).get(`/api/companies/${companyId}/attention/summary`).expect(403);
await request(app({ ...board, source: "session", companyIds: [] })).get(`/api/companies/${companyId}/attention/summary`).expect(403);
await request(app(board))
.get(`/api/companies/${companyId}/attention?activitySince=yesterday`)
.expect(400, { error: "activitySince must be an ISO timestamp" });
@@ -2317,6 +2363,10 @@ describeEmbeddedPostgres("attention service", () => {
expect(feed.items.map((item) => item.subject.title)).not.toContain("Confidential review");
expect(JSON.stringify(feed)).not.toContain(privateIssueId);
const summary = await attentionService(db).summary(companyId, { userId: actor.userId, actor });
expect(summary.totalCount).toBe(feed.totalCount);
expect(summary.countsBySourceKind).toEqual(feed.countsBySourceKind);
});
it("computes the aging shelf uniformly across approval, interaction, and review sources", async () => {
@@ -315,6 +315,21 @@ function loadSpecRoutes() {
}
describe("openapi routes", () => {
it.each(["summary", "tasks"])("documents the board-only compact attention %s contract", projection => {
const spec = buildOpenApiSpec() as any;
const operation = spec.paths[`/api/companies/{companyId}/attention/${projection}`].get;
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
expect(operation.parameters.filter((parameter: { in: string }) => parameter.in === "query")).toEqual([]);
const response = operation.responses["200"].content["application/json"].schema;
expect(response.properties.totalCount).toMatchObject({ type: "integer", minimum: 0 });
expect(response.properties.generatedAt).toMatchObject({ format: "date-time" });
expect(response.properties.items).toBeUndefined();
expect(response.properties.nextCursor).toBeUndefined();
if (projection === "tasks") expect(response.properties.taskIds).toMatchObject({ type: "array", items: { type: "string", format: "uuid" } });
for (const code of ["200", "401", "403"]) expect(operation.responses[code]).toBeDefined();
});
it("documents the owned pending-question read as canonical attention items without client scope overrides", () => {
const spec = buildOpenApiSpec() as any;
const operation = spec.paths["/api/issues/{id}/pending-questions"].get;
+16
View File
@@ -17,6 +17,22 @@ export function attentionRoutes(db: Db) {
const router = Router();
const svc = attentionService(db);
router.get("/companies/:companyId/attention/summary", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
if (!req.actor.userId) { res.status(403).json({ error: "Board user context required" }); return; }
res.json(await svc.summary(companyId, { userId: req.actor.userId, actor: req.actor }));
});
router.get("/companies/:companyId/attention/tasks", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
if (!req.actor.userId) { res.status(403).json({ error: "Board user context required" }); return; }
res.json(await svc.taskIndex(companyId, { userId: req.actor.userId, actor: req.actor }));
});
router.get("/companies/:companyId/attention/history", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
+26
View File
@@ -1,6 +1,7 @@
import { MUSE_ASSET_VERSION, museStopBoundarySchema, museDetectorCleanupAttestationSchema, createGrokInvitationSchema, configureGrokRoutineSchema, verifyGrokRoutineSchema, recoverGrokRoutineSchema, pairGrokBindingSchema, stopGrokWorkSchema, retryGrokDeliverySchema, updateFastResponseSchema } from "@paperclipai/shared";
import { slackTestMessageSchema, slackTestMessageResultSchema, slackManagerAuthorizationSchema, slackSetupOptionsSchema, slackManagedProvisionSchema, slackManagedResultSchema, slackRegistrationSchema, slackSetupActionSchema, slackInstallAuthorizationSchema, slackRegistrationStateSchema, slackAppConfigurationSchema, slackAvatarStateSchema, slackAccountStateSchema } from "@paperclipai/shared";
import { experimentalApiMetadata } from "./experimental-api-metadata.js";
import { ATTENTION_SOURCE_KINDS } from "@paperclipai/shared";
import { agentCaseHandoffSchema, caseWorkflowDocumentTextSchema, doneCaseHandoffSchema, createWorkflowSchema, updateWorkflowSchema, listWorkflowsSchema, readWorkflowQuerySchema } from "@paperclipai/shared";
import {
experimentalApiPaths,
@@ -1417,6 +1418,8 @@ const BOARD_ONLY_OPERATIONS = new Set([
"GET /api/issues/{id}/decision-receipts",
"GET /api/issues/{id}/chat-handoffs",
"GET /api/companies/{companyId}/attention/history",
"GET /api/companies/{companyId}/attention/summary",
"GET /api/companies/{companyId}/attention/tasks",
"POST /api/issues/{id}/interactions/{interactionId}/dismiss",
"POST /api/issues/{id}/retry",
"GET /api/companies/{companyId}/prompt-injection-prevention",
@@ -6160,6 +6163,29 @@ registry.registerPath({
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden },
});
const attentionSummaryResponseSchema = z.object({
companyId: z.string().uuid(),
generatedAt: z.string().datetime(),
totalCount: z.number().int().nonnegative(),
deskBadgeCount: z.number().int().nonnegative(),
countsBySourceKind: z.record(z.enum(ATTENTION_SOURCE_KINDS), z.number().int().nonnegative()),
}).strict();
for (const [projection, responseSchema] of [
["summary", attentionSummaryResponseSchema],
["tasks", attentionSummaryResponseSchema.extend({ taskIds: z.array(z.string().uuid()) })],
] as const) {
registry.registerPath({
method: "get",
path: `/api/companies/{companyId}/attention/${projection}`,
tags: ["inbox"],
summary: projection === "summary" ? "Get canonical attention counts" : "Get complete attention task membership",
description: "Requires a board user with company access. Reads the complete canonical attention set with current task privacy, addressee, dismissal, snooze, and archive rules. Returns counts without decision cards, images, plan bodies, or pagination. The tasks projection also returns distinct accessible task IDs. This read grants no resolution authority.",
request: { params: z.object({ companyId: z.string() }) },
responses: { 200: r.ok(responseSchema), 401: r.unauthorized, 403: r.forbidden },
});
}
// ─── Decisions ──────────────────────────────────────────────────────────────
registry.registerPath({
+49 -31
View File
@@ -31,6 +31,8 @@ import { deriveProjectUrlKey } from "@paperclipai/shared";
import type {
AttentionDecisionVerb,
AttentionFeed,
AttentionSummary,
AttentionTaskIndex,
AttentionFeedQuery,
AttentionDetailImage,
AttentionItem,
@@ -163,6 +165,8 @@ type AttentionListOptions = AttentionFeedQuery & {
/** Internal-only escape hatch for callers that need one stable, unpaginated feed snapshot. */
allowUnscopedAll?: boolean;
excludeSources?: readonly AttentionSourceKind[];
/** Count the canonical complete set without card bodies or presentation reads. */
summaryOnly?: boolean;
};
type AttentionServiceOptions = {
@@ -554,7 +558,7 @@ function parseActivityBoundary(value: string | undefined, field: "activitySince"
return parsed;
}
async function enrichAttentionItems(db: Db, companyId: string, items: AttentionItem[], now: number) {
async function enrichAttentionItems(db: Db, companyId: string, items: AttentionItem[], now: number, includePresentation = true) {
if (items.length === 0) return items;
const sourceIds = [...new Set(items.map((item) => item.subject.id))];
const queueRows = await db
@@ -605,7 +609,7 @@ async function enrichAttentionItems(db: Db, companyId: string, items: AttentionI
...items.map(readMetadataAgentId),
...triageRows.map((row) => row.setByAgentId),
].filter((value): value is string => Boolean(value)))];
const agentNameById = new Map(agentIds.length === 0 ? [] : await db
const agentNameById = new Map(!includePresentation || agentIds.length === 0 ? [] : await db
.select({ id: agents.id, name: agents.name })
.from(agents)
.where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds)))
@@ -1173,7 +1177,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
Math.max(Math.trunc(serviceOptions.openDecisionLimit ?? OPEN_DECISION_DEFAULT_LIMIT), 1),
OPEN_DECISION_MAX_LIMIT,
);
return {
const service = {
list: async (companyId: string, options: AttentionListOptions = {}): Promise<AttentionFeed> => {
if (options.all && !options.queue && !options.allowUnscopedAll) {
throw badRequest("all requires a queue filter");
@@ -1187,6 +1191,9 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
]);
const includeDismissed = options.includeDismissed === true;
const now = serviceOptions.now?.() ?? Date.now();
const summaryOnly = options.summaryOnly === true;
const loadImages = (...args: Parameters<typeof issueImageMap>) => summaryOnly
? Promise.resolve(new Map<string, AttentionDetailImage[]>()) : issueImageMap(...args);
const collected: AttentionItem[] = [];
const add = (item: AttentionItem) => {
@@ -1204,7 +1211,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
status: approvals.status,
requestedByAgentId: approvals.requestedByAgentId,
requestedByUserId: approvals.requestedByUserId,
payload: approvals.payload,
payload: summaryOnly ? sql<Record<string, unknown>>`'{}'::jsonb` : approvals.payload,
createdAt: approvals.createdAt,
updatedAt: approvals.updatedAt,
})
@@ -1271,7 +1278,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
status: issueThreadInteractions.status,
title: issueThreadInteractions.title,
summary: issueThreadInteractions.summary,
payload: issueThreadInteractions.payload,
payload: summaryOnly ? sql<Record<string, unknown>>`jsonb_build_object('target', ${issueThreadInteractions.payload}->'target', 'qualityReview', ${issueThreadInteractions.payload}->'qualityReview')` : issueThreadInteractions.payload,
addresseeAgentId: issueThreadInteractions.addresseeAgentId,
addresseeUserId: issueThreadInteractions.addresseeUserId,
createdByAgentId: issueThreadInteractions.createdByAgentId,
@@ -1318,22 +1325,23 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
// A pending human request remains actionable in a persistent chat even
// though its idle container is intentionally absent from work queues.
issueSummaryMap(db, companyId, visibleInteractionRows.map((row) => row.issueId), readCondition, true),
issueImageMap(db, companyId, visibleInteractionRows.map((row) => row.issueId), readCondition),
planDocumentMap(db, companyId, visibleInteractionRows.map((row) => row.issueId), readCondition),
loadImages(db, companyId, visibleInteractionRows.map((row) => row.issueId), readCondition),
summaryOnly ? Promise.resolve(new Map<string, PlanDocumentSummary>())
: planDocumentMap(db, companyId, visibleInteractionRows.map((row) => row.issueId), readCondition),
]);
for (const interaction of visibleInteractionRows) {
const issue = interactionIssueMap.get(interaction.issueId) ?? null;
if (!issue) continue;
const payload = readRecord(interaction.payload);
const detail = interactionDetail({
const detail = summaryOnly ? null : interactionDetail({
kind: interaction.kind,
payload,
issue,
planDocument: interactionPlanDocumentMap.get(interaction.issueId) ?? null,
images: issueImages(interactionImageMap, interaction.issueId),
});
const isPlanTarget = detail.kind === "plan_approval";
const isPlanTarget = interaction.kind === "request_confirmation" && isPlanDocumentTarget(payload);
const dedupKey = `interaction:${interaction.id}`;
add(createItem({
companyId,
@@ -1380,7 +1388,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
originAgentId: decisions.originAgentId,
ruleKey: decisions.ruleKey,
title: decisions.title,
body: decisions.body,
body: summaryOnly ? sql<string>`''` : decisions.body,
status: decisions.status,
expiresAt: decisions.expiresAt,
originIssueId: decisions.originIssueId,
@@ -1511,7 +1519,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
recoveryRows.flatMap((row) => [row.sourceIssueId, row.recoveryIssueId]),
readCondition,
),
issueImageMap(db, companyId, recoveryRows.map((row) => row.sourceIssueId), readCondition),
loadImages(db, companyId, recoveryRows.map((row) => row.sourceIssueId), readCondition),
]);
for (const recovery of recoveryRows) {
@@ -1580,7 +1588,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
const [blockedIssueSummaries, terminalBlockerSummaries, blockerImageMap, blockingIssues] = await Promise.all([
issueSummaryMap(db, companyId, blockedIssues.map((issue) => issue.id), readCondition),
issueSummaryMap(db, companyId, terminalBlockerIssueIds, readCondition),
issueImageMap(
loadImages(
db,
companyId,
[...blockedIssues.map((issue) => issue.id), ...terminalBlockerIssueIds],
@@ -1721,7 +1729,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
const [reviewAttentionByIssueId, reviewIssueMap, reviewImageMap] = await Promise.all([
issueService(db).listReviewAttention(companyId, reviewRows),
issueSummaryMap(db, companyId, reviewIssueIds, readCondition),
issueImageMap(db, companyId, reviewIssueIds, readCondition),
loadImages(db, companyId, reviewIssueIds, readCondition),
]);
for (const review of reviewRows) {
@@ -1787,7 +1795,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
failedIssueIds,
readCondition,
),
issueImageMap(db, companyId, failedIssueIds, readCondition),
loadImages(db, companyId, failedIssueIds, readCondition),
listFailedRunsWithNewerRun(db, companyId, failedRows.map((row, index) => ({
id: row.id,
agentId: row.agentId,
@@ -1962,7 +1970,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
const collectedItems = [...deduped.values()].sort(compareAttentionItems);
await decisionQueueService(db).materializeSeededQueues(companyId, collectedItems);
const enrichedItems = await enrichAttentionItems(db, companyId, collectedItems, now);
const enrichedItems = await enrichAttentionItems(db, companyId, collectedItems, now, !summaryOnly);
const activitySince = parseActivityBoundary(options.activitySince, "activitySince");
const activityUntil = parseActivityBoundary(options.activityUntil, "activityUntil");
@@ -1980,6 +1988,15 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
return true;
});
const countsBySourceKind = emptyCounts();
for (const item of visibleItems) countsBySourceKind[item.sourceKind] += 1;
const summary: AttentionSummary = {
companyId, generatedAt: new Date().toISOString(), totalCount: visibleItems.length,
deskBadgeCount: visibleItems.filter(item => isNewToday(item, now) || isDecideNow(item, now)).length,
countsBySourceKind,
};
if (summaryOnly) return { ...summary, nextCursor: null, items: visibleItems };
const sort = options.sort ?? "activity";
if (sort !== "activity" && sort !== "decide") throw badRequest("sort must be 'activity' or 'decide'");
const selectedComparator = sort === "decide"
@@ -2051,22 +2068,23 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
}
}
}
const countsBySourceKind = emptyCounts();
for (const item of rankedItems) countsBySourceKind[item.sourceKind] += 1;
return {
companyId,
generatedAt: new Date().toISOString(),
totalCount: rankedItems.length,
// Desk badge: distinct items that surfaced
// today OR carry an explicit decide-by deadline due today/past. Counted
// over the full ranked set (pre-pagination) so the sidebar badge stays
// company-wide accurate even on a small first page.
deskBadgeCount: rankedItems.filter((item) => isNewToday(item, now) || isDecideNow(item, now)).length,
nextCursor,
countsBySourceKind,
items,
};
return { ...summary, nextCursor, items };
},
};
return {
...service,
summary: async (companyId: string, options: Pick<AttentionListOptions, "userId" | "actor"> = {}): Promise<AttentionSummary> => {
const { items: _items, nextCursor: _cursor, ...summary } = await service.list(companyId, {
...options, all: true, allowUnscopedAll: true, summaryOnly: true,
});
return summary;
},
taskIndex: async (companyId: string, options: Pick<AttentionListOptions, "userId" | "actor"> = {}): Promise<AttentionTaskIndex> => {
const { items, nextCursor: _cursor, ...summary } = await service.list(companyId, {
...options, all: true, allowUnscopedAll: true, summaryOnly: true,
});
return { ...summary, taskIds: [...new Set(items.flatMap(item => item.relatedIssue
? [item.relatedIssue.id] : item.subject.kind === "issue" ? [item.subject.id] : []))] };
},
};
}
+24
View File
@@ -0,0 +1,24 @@
# Tasks and Inbox cold-open checks
Run against a disposable local instance with production UI assets. The suite
creates 220 unassigned backlog tasks and 110 board approvals. It does not invoke
a model. The origin must be a loopback address.
```sh
pnpm --filter @paperclipai/ui build
PAPERCLIP_COLLECTION_PERF_BASE_URL=http://127.0.0.1:3219 \
pnpm exec playwright test --config tests/perf/collections/playwright.config.ts
```
Start the isolated instance with `PAPERCLIP_UI_DEV_MIDDLEWARE=false` to serve the
production build. The instance and its database must be disposable. These tests
retain and reuse their fixture in `test-results/collections/fixture.json`; they
only replace it when the previous company's API returns 404. Other errors fail.
The journeys check desktop and phone row budgets, incremental scrolling,
retained row identity and height, shared count-only navigation, the Coordinator
Inbox with Streamlined UI disabled, and the page-owned Needs You feed and Needs
attention task index. Screenshots and failure traces stay in `test-results`.
Request counts and row budgets are acceptance checks. They do not establish an
end-to-end latency improvement. Measure cold, warm, and Back timings separately
with the same fixture, assets, cache, viewport, and CPU/network settings.
+50
View File
@@ -0,0 +1,50 @@
import fs from "node:fs/promises";
import { expect, type APIRequestContext, type Page } from "@playwright/test";
export type CollectionFixture = { company: { id: string; issuePrefix: string }; taskIds: string[] };
export const fixturePath = "test-results/collections/fixture.json";
export async function collectionFixture(request: APIRequestContext): Promise<CollectionFixture> {
const saved = await fs.readFile(fixturePath, "utf8").then(JSON.parse).catch(error => {
if (error.code !== "ENOENT") throw error;
return null;
});
if (saved) {
const existing = await request.get(`/api/companies/${saved.company.id}`);
if (existing.ok()) return saved;
expect(existing.status(), await existing.text()).toBe(404);
}
const created = await request.post("/api/companies", { data: { name: "Collection performance fixture" } });
expect(created.ok(), await created.text()).toBeTruthy();
const company = await created.json();
const taskIds: string[] = [];
for (let offset = 0; offset < 220; offset += 10) {
const rows = await Promise.all(Array.from({ length: 10 }, async (_, i) => {
const response = await request.post(`/api/companies/${company.id}/issues`, { data: {
title: `Collection task ${offset + i}`, status: "backlog", description: "Synthetic saved task context. ".repeat(200),
} });
expect(response.ok(), await response.text()).toBeTruthy();
return (await response.json()).id;
}));
taskIds.push(...rows);
}
for (let offset = 0; offset < 110; offset += 10) await Promise.all(Array.from({ length: 10 }, async (_, i) => {
const response = await request.post(`/api/companies/${company.id}/approvals`, { data: {
type: "request_board_approval", payload: { title: `Collection approval ${offset + i}`, summary: "Synthetic decision context. ".repeat(200) },
} });
expect(response.ok(), await response.text()).toBeTruthy();
}));
const fixture = { company, taskIds };
await fs.mkdir("test-results/collections", { recursive: true });
await fs.writeFile(fixturePath, JSON.stringify(fixture));
return fixture;
}
export async function coordinatorPreference(page: Page, streamlined = true) {
await page.route("**/api/workspace-preferences/me", async route => {
const response = await route.fetch();
await route.fulfill({ json: { ...await response.json(), coordinatorWorkspaceEnabled: true } });
});
await page.route("**/api/instance/settings/experimental", async route => {
const response = await route.fetch();
await route.fulfill({ json: { ...await response.json(), enableStreamlinedUi: streamlined } });
});
}
@@ -0,0 +1,13 @@
import { defineConfig } from "@playwright/test";
const baseURL = process.env.PAPERCLIP_COLLECTION_PERF_BASE_URL;
if (!baseURL) throw new Error("Set PAPERCLIP_COLLECTION_PERF_BASE_URL to a disposable local instance.");
const target = new URL(baseURL);
if (!["http:", "https:"].includes(target.protocol) || !["127.0.0.1", "localhost", "[::1]"].includes(target.hostname) || target.pathname !== "/" || target.username || target.password || target.search || target.hash) {
throw new Error("Collection tests create fixtures and only support a disposable loopback instance.");
}
export default defineConfig({
testDir: ".", workers: 1, fullyParallel: false, timeout: 120_000,
use: { baseURL, browserName: "chromium", headless: true, viewport: { width: 1440, height: 900 }, serviceWorkers: "block", trace: "retain-on-failure" },
outputDir: "../../../test-results/collections/playwright", reporter: [["list"]],
});
+83
View File
@@ -0,0 +1,83 @@
import { expect, test } from "@playwright/test";
import { collectionFixture, coordinatorPreference, type CollectionFixture } from "./fixture";
let fixture: CollectionFixture;
test.beforeAll(async ({ request }) => { fixture = await collectionFixture(request); });
for (const mobile of [false, true]) test(`Tasks use a bounded render and a shared count on ${mobile ? "mobile" : "desktop"}`, async ({ page }) => {
if (mobile) await page.setViewportSize({ width: 390, height: 844 });
await coordinatorPreference(page);
const reads: string[] = [];
page.on("request", request => { if (request.url().includes("/api/")) reads.push(request.url()); });
await page.goto(`/${fixture.company.issuePrefix}/issues`);
const rows = page.locator('[data-slot="task-row"][data-issue-id]');
await expect(rows).toHaveCount(100);
await expect(page.getByText("Rendering 100 of 200 tasks")).toBeVisible();
await expect.poll(() => reads.filter(url => url.includes("/attention/summary")).length).toBe(1);
expect(reads.filter(url => /\/attention\?/.test(url))).toEqual([]);
const first = await rows.first().elementHandle();
const before = await rows.first().boundingBox();
// Reaching the sentinel loads the next batch before a button click is needed.
await page.getByRole("button", { name: "Show more tasks", exact: true }).scrollIntoViewIfNeeded();
await expect(rows).toHaveCount(200);
expect(await first!.evaluate(node => node.isConnected)).toBe(true);
await rows.first().scrollIntoViewIfNeeded();
const after = await rows.first().boundingBox();
expect(Math.abs(before!.height - after!.height)).toBeLessThan(1);
await page.screenshot({ path: test.info().outputPath("tasks.png") });
});
test("Coordinator Inbox retains index reads when Streamlined UI is disabled", async ({ page }) => {
await coordinatorPreference(page, false);
const reads: URL[] = [];
page.on("request", request => { if (request.url().includes("/api/")) reads.push(new URL(request.url())); });
await page.goto(`/${fixture.company.issuePrefix}/inbox/mine`);
await expect(page.locator("[data-inbox-issue-link]").first()).toBeVisible();
const tasks = reads.filter(url => /\/companies\/[^/]+\/issues$/.test(url.pathname) && url.searchParams.get("view") !== "rows");
expect(tasks).toHaveLength(1);
expect(tasks[0].searchParams.get("view")).toBe("index");
expect(reads.filter(url => url.pathname.endsWith("/dashboard"))).toEqual([]);
expect(reads.filter(url => url.pathname.endsWith("/attention") && url.searchParams.get("all") === "true")).toEqual([]);
});
test("Needs You owns one complete feed and no redundant summary read", async ({ page }) => {
await coordinatorPreference(page);
const reads: URL[] = [];
page.on("request", request => { if (request.url().includes("/api/")) reads.push(new URL(request.url())); });
await page.goto(`/${fixture.company.issuePrefix}/decisions`);
await expect(page.getByRole("main").getByRole("heading", { name: "Needs You", exact: true })).toBeVisible();
await expect.poll(() => reads.filter(url => url.pathname.endsWith("/attention") && url.searchParams.get("all") === "true").length).toBe(1);
expect(reads.filter(url => url.pathname.endsWith("/attention/summary"))).toEqual([]);
});
test("Needs attention reads task membership without downloading decision cards", async ({ page }) => {
await coordinatorPreference(page);
const reads: URL[] = [];
page.on("request", request => { if (request.url().includes("/api/")) reads.push(new URL(request.url())); });
await page.goto(`/${fixture.company.issuePrefix}/issues?view=needs_attention`);
await expect(page.getByText("No tasks need a decision.", { exact: true })).toBeVisible();
expect(reads.filter(url => url.pathname.endsWith("/attention/tasks"))).toHaveLength(1);
expect(reads.filter(url => url.pathname.endsWith("/attention/summary"))).toEqual([]);
expect(reads.filter(url => url.pathname.endsWith("/attention") && url.searchParams.get("all") === "true")).toEqual([]);
});
test("older pages and Back keep revealed task rows and the reading position", async ({ page }) => {
await coordinatorPreference(page);
await page.goto(`/${fixture.company.issuePrefix}/issues`);
const rows = page.locator('[data-slot="task-row"][data-issue-id]');
await expect(rows).toHaveCount(100);
const loadPage = page.getByRole("button", { name: "Load more tasks", exact: true });
await loadPage.scrollIntoViewIfNeeded();
await expect(rows).toHaveCount(200);
const retained = await rows.last().elementHandle();
await loadPage.click();
await expect(rows).toHaveCount(220);
expect(await retained!.evaluate(node => node.isConnected)).toBe(true);
await rows.last().scrollIntoViewIfNeeded();
const before = await rows.last().boundingBox();
await rows.last().locator("[data-inbox-issue-link]").click();
await expect(page.getByTestId("issue-detail-header")).toBeVisible();
await page.goBack();
await expect(rows).toHaveCount(220);
const after = await rows.last().boundingBox();
expect(Math.abs(before!.y - after!.y)).toBeLessThan(2);
});
+10
View File
@@ -42,4 +42,14 @@ describe("attentionApi.list", () => {
expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/attention");
});
it("requests the complete task membership without decision card bodies", async () => {
await attentionApi.taskIndex("company-1");
expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/attention/tasks");
});
it("requests canonical counts without requesting decision cards", async () => {
await attentionApi.summary("company-1");
expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/attention/summary");
});
});
+3 -1
View File
@@ -1,7 +1,9 @@
import type { AttentionFeed, AttentionFeedQuery, DecisionHistoryPage, DecisionHistoryQuery } from "@paperclipai/shared";
import type { AttentionFeed, AttentionFeedQuery, AttentionSummary, AttentionTaskIndex, DecisionHistoryPage, DecisionHistoryQuery } from "@paperclipai/shared";
import { api } from "./client";
export const attentionApi = {
summary: (companyId: string) => api.get<AttentionSummary>(`/companies/${companyId}/attention/summary`),
taskIndex: (companyId: string) => api.get<AttentionTaskIndex>(`/companies/${companyId}/attention/tasks`),
history: (companyId: string, options: DecisionHistoryQuery = {}) => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(options)) if (value !== undefined && value !== "") params.set(key, String(value));
+51 -9
View File
@@ -6,22 +6,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttentionFeed, AttentionItem } from "@paperclipai/shared";
import { CoordinatorSidebar } from "./CoordinatorSidebar";
import { TooltipProvider } from "./ui/tooltip";
import { coordinatorAttentionQueryKey } from "@/lib/coordinator-attention";
import { coordinatorAttentionQueryKey, coordinatorAttentionSummaryQueryKey } from "@/lib/coordinator-attention";
import { queryKeys } from "@/lib/queryKeys";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const fixture = vi.hoisted(() => ({ attention: vi.fn(), collapsed: false, userId: "reviewer" }));
const fixture = vi.hoisted(() => ({ attention: vi.fn(), summary: vi.fn(), pathname: "/COO/issues", collapsed: false, userId: "reviewer" }));
vi.mock("@/hooks/useAgentChatNavigation", () => ({ useAgentChatNavigation: () => ({
companyId: "company", userId: fixture.userId, agents: { data: [] }, chats: { data: [] }, session: { isSuccess: true },
}) }));
vi.mock("@/hooks/useRecentTasks", () => ({ useRecentTasks: () => ({ entries: [] }) }));
vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: vi.fn() }) }));
vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ collapsed: fixture.collapsed, peeking: false, isMobile: false, setSidebarOpen: vi.fn() }) }));
vi.mock("@/api/attention", () => ({ attentionApi: { list: fixture.attention } }));
vi.mock("@/api/attention", () => ({ attentionApi: { list: fixture.attention, summary: fixture.summary } }));
vi.mock("@/api/projects", () => ({ projectsApi: { list: async () => [] } }));
vi.mock("./SidebarCompanyMenu", () => ({ SidebarCompanyMenu: () => null }));
vi.mock("@/lib/router", () => ({
useLocation: () => ({ pathname: "/COO/decisions" }),
useLocation: () => ({ pathname: fixture.pathname, search: "" }),
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => <a href={to} {...props}>{children}</a>,
NavLink: ({ to, children, className, state: _state, end: _end, ...props }: {
to: string; children: ReactNode; className?: string | ((state: { isActive: boolean }) => string); state?: unknown; end?: boolean;
@@ -35,10 +35,15 @@ function feed(items: AttentionItem[]): AttentionFeed {
nextCursor: null, countsBySourceKind: {} as AttentionFeed["countsBySourceKind"], items };
}
function summary(items: AttentionItem[]) {
const { items: _items, nextCursor: _cursor, ...counts } = feed(items);
return counts;
}
let root: Root, container: HTMLDivElement, client: QueryClient;
beforeEach(() => {
vi.clearAllMocks(); localStorage.clear(); fixture.collapsed = false; fixture.userId = "reviewer";
fixture.attention.mockResolvedValue(feed([pending("question"), pending("review")]));
vi.clearAllMocks(); localStorage.clear(); fixture.collapsed = false; fixture.userId = "reviewer"; fixture.pathname = "/COO/issues";
fixture.summary.mockResolvedValue(summary([pending("question"), pending("review")]));
container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
@@ -104,17 +109,19 @@ describe("Coordinator Needs You badge", () => {
client.setQueryData([...coordinatorAttentionQueryKey("company"), { search: "no match" }], feed([]));
client.setQueryData([...queryKeys.attention("company"), "history", "recent"], { items: [{ id: "answered-question" }] });
await renderSidebar();
expect(fixture.attention).toHaveBeenCalledWith("company", { all: true });
expect(fixture.attention).not.toHaveBeenCalled();
expect(client.getQueryData(coordinatorAttentionQueryKey("company"))).toBeUndefined();
expect(fixture.summary).toHaveBeenCalledWith("company");
expect(decisionLink().querySelector('[data-slot="badge"]')?.textContent).toBe("2");
});
it("updates after canonical attention refreshes and removes the badge once every decision is resolved", async () => {
await renderSidebar();
fixture.attention.mockResolvedValue(feed([pending("review")]));
fixture.summary.mockResolvedValue(summary([pending("review")]));
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.attention("company") }); });
await flush();
expect(decisionLink().querySelector('[data-slot="badge"]')?.textContent).toBe("1");
fixture.attention.mockResolvedValue(feed([]));
fixture.summary.mockResolvedValue(summary([]));
client.setQueryData([...queryKeys.attention("company"), "history", "recent"], { items: [{ id: "answered-question" }, { id: "accepted-review" }] });
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.attention("company") }); });
await flush();
@@ -128,3 +135,38 @@ describe("Coordinator Needs You badge", () => {
expect(decisionLink().getAttribute("aria-label")).toBe("Needs You, 2 decisions");
});
});
describe("Coordinator collection reads", () => {
it("does not replace a resolved count with an older summary that arrives later", async () => {
const key = coordinatorAttentionSummaryQueryKey("company");
client.setQueryDefaults(key, { staleTime: Infinity });
client.setQueryData(key, { ...summary([pending("old")]), generatedAt: "2026-10-27T12:00:00Z" }, { updatedAt: 20 });
client.setQueryData(coordinatorAttentionQueryKey("company"), { ...feed([]), generatedAt: "2026-10-27T12:01:00Z" }, { updatedAt: 10 });
await renderSidebar();
expect(decisionLink().querySelector('[data-slot="badge"]')).toBeNull();
});
it("keeps a newer resolved count when returning to a cached navigation summary", async () => {
const key = coordinatorAttentionSummaryQueryKey("company");
client.setQueryDefaults(key, { staleTime: Infinity });
client.setQueryData(key, summary([pending("old")]), { updatedAt: 1 });
client.setQueryData(coordinatorAttentionQueryKey("company"), feed([]), { updatedAt: 2 });
await renderSidebar();
expect(decisionLink().querySelector('[data-slot="badge"]')).toBeNull();
expect(fixture.summary).not.toHaveBeenCalled();
expect(fixture.attention).not.toHaveBeenCalled();
});
it("reuses the page-owned complete feed on Needs You without a second summary read", async () => {
fixture.pathname = "/COO/decisions";
client.setQueryData(coordinatorAttentionQueryKey("company"), feed([pending("review")]));
await renderSidebar();
expect(fixture.summary).not.toHaveBeenCalled();
expect(fixture.attention).not.toHaveBeenCalled();
expect(decisionLink().querySelector('[data-slot="badge"]')?.textContent).toBe("1");
await act(async () => client.setQueryData(coordinatorAttentionQueryKey("company"), feed([])));
await flush();
expect(decisionLink().querySelector('[data-slot="badge"]')).toBeNull();
});
});
+4 -5
View File
@@ -1,9 +1,8 @@
import { coordinatorAttentionQueryKey } from "@/lib/coordinator-attention";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { Inbox, Bell, Boxes, ChevronRight, CircleCheck, FolderOpen, History, LayoutDashboard, MessageCircle, MoreHorizontal, Network, Pin, PinOff, Plus, Repeat, Search, Settings, Unplug } from "lucide-react";
import type { Project } from "@paperclipai/shared";
import { attentionApi } from "@/api/attention";
import { useCoordinatorAttentionCount } from "@/hooks/useCoordinatorAttentionCount";
import { issuesApi } from "@/api/issues";
import { projectsApi } from "@/api/projects";
import { useAgentChatNavigation } from "@/hooks/useAgentChatNavigation";
@@ -40,7 +39,7 @@ function ProjectRecent({ project }: { project: Project }) {
const [expanded, setExpanded] = useState(false);
const { data: tasks, isPending, error } = useQuery({
queryKey: [...queryKeys.issues.listByProject(project.companyId, project.id), "coordinator-recents"],
queryFn: () => issuesApi.list(project.companyId, { projectId: project.id, limit: 12, sortField: "updated", sortDir: "desc" }),
queryFn: () => issuesApi.listCompact(project.companyId, { projection: "index", projectId: project.id, limit: 12, sortField: "updated", sortDir: "desc" }),
enabled: expanded,
});
return <div>
@@ -73,7 +72,7 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
const { openNewIssue } = useDialogActions();
const location = useLocation();
const handleRecentScroll = useScrollbarWhileScrolling();
const { data: attention } = useQuery({ queryKey: coordinatorAttentionQueryKey(companyId!), queryFn: () => attentionApi.list(companyId!, { all: true }), enabled: !!companyId });
const attentionCount = useCoordinatorAttentionCount(companyId);
const { data: projects } = useQuery({ queryKey: queryKeys.projects.list(companyId!), queryFn: () => projectsApi.list(companyId!), enabled: !!companyId });
// Only project IDs and visit times are kept in memory. Labels and task lists
// always come from the authorized API, scoped to the current person/company.
@@ -106,7 +105,7 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
{!rail && <button type="button" aria-label="New task" title="New task" onClick={() => openNewIssue()}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity group-hover/coordinator-new:opacity-100 group-focus-within/coordinator-new:opacity-100 pointer-coarse:opacity-100 hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-ring"><Plus className="size-4" /></button>}
</div>
<SidebarNavItem className="design-sidebar-item" to="/decisions" label="Needs You" icon={Bell} badge={attention?.totalCount ?? 0} badgeLabel="decisions" />
<SidebarNavItem className="design-sidebar-item" to="/decisions" label="Needs You" icon={Bell} badge={attentionCount} badgeLabel="decisions" />
<Flyout open={moreOpen} onOpenChange={setMoreOpen}>
<FlyoutTrigger asChild><button type="button" aria-label="More workspace pages" className="design-sidebar-item flex w-auto items-center gap-2.5 rounded-lg py-1.5 text-left transition-colors focus-visible:ring-2 focus-visible:ring-ring"><MoreHorizontal className="size-4 shrink-0" /><span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : undefined}>More</span></button></FlyoutTrigger>
<FlyoutContent ref={moreContentRef} tabIndex={-1} onOpenAutoFocus={(event) => { event.preventDefault(); moreContentRef.current?.focus(); }} side={isMobile ? "bottom" : "right"} align="start" className="w-56 p-1" aria-label="More workspace pages">
@@ -4,7 +4,7 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { expect, it, vi } from "vitest";
import { MobileBottomNav } from "./MobileBottomNav";
import { coordinatorAttentionQueryKey } from "@/lib/coordinator-attention";
import { coordinatorAttentionSummaryQueryKey } from "@/lib/coordinator-attention";
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({ enabled: true }) }));
vi.mock("@/hooks/useAgentChatEnabled", () => ({ useAgentChatEnabled: () => ({ enabled: true }) }));
@@ -16,7 +16,7 @@ vi.mock("@/lib/router", () => ({ useLocation: () => ({ pathname: "/chats" }), Na
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
it("uses the canonical outstanding count on the phone as well as the desktop", async () => {
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
const key = coordinatorAttentionQueryKey("company");
const key = coordinatorAttentionSummaryQueryKey("company");
client.setQueryData(key, { totalCount: 3, deskBadgeCount: 0 });
const host = document.createElement("div"); document.body.append(host); const root = createRoot(host);
try {
+5 -11
View File
@@ -1,8 +1,6 @@
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { attentionApi } from "@/api/attention";
import { coordinatorAttentionQueryKey } from "@/lib/coordinator-attention";
import { useCoordinatorAttentionCount } from "@/hooks/useCoordinatorAttentionCount";
import { NavLink, useLocation } from "@/lib/router";
import {
House,
@@ -47,15 +45,11 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
const location = useLocation();
const { selectedCompanyId } = useCompany();
const { openNewIssue } = useDialogActions();
const inboxBadge = useInboxBadge(selectedCompanyId);
const { enabled: agentChatEnabled } = useAgentChatEnabled();
const { enabled: coordinatorWorkspaceEnabled } = useCoordinatorWorkspaceEnabled();
const inboxBadge = useInboxBadge(coordinatorWorkspaceEnabled ? null : selectedCompanyId);
const { enabled: combinedInboxTasksEnabled } = useCombinedInboxTasksEnabled();
const { data: attention } = useQuery({
queryKey: coordinatorAttentionQueryKey(selectedCompanyId!),
queryFn: () => attentionApi.list(selectedCompanyId!, { all: true }),
enabled: coordinatorWorkspaceEnabled && !!selectedCompanyId,
});
const attentionCount = useCoordinatorAttentionCount(selectedCompanyId, coordinatorWorkspaceEnabled);
// PAP-670: with both flags off the bar is the original Home · Tasks · + ·
// Agents · Inbox. Agent Chat adds Chat in the second slot (Home · Chat · + ·
@@ -66,7 +60,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
() => coordinatorWorkspaceEnabled ? [
{ type: "link", to: "/chats", label: "Chat", icon: MessageCircle },
{ type: "link", to: "/issues", label: "Tasks", icon: CircleCheck },
{ type: "link", to: "/decisions", label: "Needs You", icon: Bell, badge: attention?.totalCount ?? 0 },
{ type: "link", to: "/decisions", label: "Needs You", icon: Bell, badge: attentionCount },
{ type: "action", label: "New task", icon: SquarePen, onClick: () => openNewIssue() },
] : !agentChatEnabled && !combinedInboxTasksEnabled ? [
{ type: "link", to: "/dashboard", label: "Home", icon: House },
@@ -98,7 +92,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
? [{ type: "link", to: "/inbox", label: "Inbox", icon: Inbox, badge: inboxBadge.inbox } as MobileNavItem]
: []),
],
[openNewIssue, inboxBadge.inbox, agentChatEnabled, combinedInboxTasksEnabled, coordinatorWorkspaceEnabled, attention?.totalCount],
[openNewIssue, inboxBadge.inbox, agentChatEnabled, combinedInboxTasksEnabled, coordinatorWorkspaceEnabled, attentionCount],
);
return (
+85
View File
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createIssue } from "../../storybook/fixtures/paperclipData";
import { TaskList, type TaskListItem } from "./TaskList";
import { TooltipProvider } from "./ui/tooltip";
vi.mock("./IssueRow", () => ({ IssueRow: ({ issue, leadingControl }: { issue: { id: string; title: string }; leadingControl?: ReactNode }) =>
<div data-issue-row-details={issue.id}>{leadingControl}<button>{issue.title}</button></div> }));
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
const tasks = (count: number, parentId: string | null = null): TaskListItem[] => Array.from({ length: count }, (_, i) => ({
issue: createIssue({ id: `task-${i}`, title: `Task ${i}`, parentId, createdAt: new Date(0), updatedAt: new Date(100_000 - i) }),
}));
describe("TaskList progressive rendering", () => {
let host: HTMLDivElement, root: Root;
beforeEach(() => { host = document.createElement("div"); document.body.append(host); root = createRoot(host); });
afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.unstubAllGlobals(); });
const rows = () => host.querySelectorAll("[data-issue-row-details]");
async function render(items: TaskListItem[], collectionKey?: string) {
await act(async () => root.render(<TooltipProvider><TaskList items={items} collectionKey={collectionKey} /></TooltipProvider>));
}
async function more() {
await act(async () => Array.from(host.querySelectorAll("button")).find(b => b.textContent === "Show more tasks")!.click());
}
it("bounds the first render, reveals batches, and retains mounted rows through enrichment and append", async () => {
const items = tasks(400);
await render(items);
expect(rows()).toHaveLength(100);
const first = rows()[0];
await more();
expect(rows()).toHaveLength(250);
await render(items.map(item => ({ ...item, assignee: "New name" })));
expect(rows()).toHaveLength(250);
expect(rows()[0]).toBe(first);
await render(tasks(450));
expect(rows()).toHaveLength(250);
await more(); await more();
expect(rows()).toHaveLength(450);
expect(host.textContent).not.toContain("Show more tasks");
});
it("retains revealed rows when an older server page sorts before them", async () => {
const items = tasks(200).map((item, i) => ({ issue: { ...item.issue, identifier: `PAP-${i + 21}` } }));
await render(items, "company:person:paged");
await more();
const originalRows = [...rows()];
const older = tasks(20).map((item, i) => ({ issue: { ...item.issue, id: `older-${i}`, identifier: `PAP-${i + 1}` } }));
await render([...items, ...older], "company:person:paged");
expect(rows()).toHaveLength(220);
expect(originalRows.every(row => row.isConnected)).toBe(true);
expect(rows()[0].getAttribute("data-issue-row-details")).toBe("older-0");
await act(async () => root.unmount());
root = createRoot(host);
await render([...items, ...older], "company:person:paged");
expect(rows()).toHaveLength(220);
});
it("uses one budget across a large expanded tree, preserving its disclosure and parent", async () => {
const parent = { issue: createIssue({ id: "root", title: "Root", parentId: null }) };
await render([parent, ...tasks(220, "root")]);
expect(rows()).toHaveLength(100);
expect(rows()[0].getAttribute("data-issue-row-details")).toBe("root");
const collapse = host.querySelector<HTMLButtonElement>('button[aria-label="Collapse Root"]')!;
await act(async () => collapse.click());
expect(rows()).toHaveLength(1);
await act(async () => host.querySelector<HTMLButtonElement>('button[aria-label="Expand Root"]')!.click());
expect(rows()).toHaveLength(100);
await more();
expect(rows()).toHaveLength(221);
});
it("reveals more when the sentinel intersects and resets the budget for a different result set", async () => {
let intersect!: (entries: { isIntersecting: boolean }[]) => void;
vi.stubGlobal("IntersectionObserver", class {
constructor(callback: typeof intersect) { intersect = callback; }
observe() {} disconnect() {}
});
await render(tasks(300));
expect(rows()).toHaveLength(100);
await act(async () => intersect([{ isIntersecting: true }]));
expect(rows()).toHaveLength(250);
await render(tasks(300).map(item => ({ issue: { ...item.issue, id: `other-${item.issue.id}` } })), "different-filter");
expect(rows()).toHaveLength(100);
});
});
+23 -5
View File
@@ -1,4 +1,4 @@
import { Fragment, useState, type ReactNode } from "react";
import { Fragment, useId, useState, type ReactNode } from "react";
import type { Issue } from "@paperclipai/shared";
import { ChevronDown, ChevronRight, GitBranch } from "lucide-react";
import { buildIssueTree } from "../lib/issue-tree";
@@ -11,6 +11,7 @@ import { TaskListColumnHeader, TaskListColumnSettings, TaskListViewport } from "
import { TaskRowGroup } from "./TaskRowGroup";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { useTaskRowBudget } from "../hooks/useTaskRowBudget";
export type TaskListItem = {
issue: Issue;
@@ -36,7 +37,7 @@ export function includeTaskListAncestors(items: readonly TaskListItem[], matches
}
/** Opt-in collection built from the reviewed row, hierarchy and column primitives. */
export function TaskList({ items, allItems = items, initialColumns = defaultTaskListColumns, density = "comfortable", collapsedIds, onCollapsedIdsChange, groupBy = "none", emptyState, issueLinkState }: {
export function TaskList({ items, allItems = items, initialColumns = defaultTaskListColumns, density = "comfortable", collapsedIds, onCollapsedIdsChange, groupBy = "none", emptyState, issueLinkState, collectionKey }: {
items: readonly TaskListItem[];
allItems?: readonly TaskListItem[];
initialColumns?: readonly TaskListColumnId[];
@@ -46,7 +47,10 @@ export function TaskList({ items, allItems = items, initialColumns = defaultTask
groupBy?: TaskListGrouping;
emptyState?: ReactNode;
issueLinkState?: unknown;
/** Company/person/view/filter identity; membership changes do not reset it. */
collectionKey?: string;
}) {
const localKey = useId();
const [columns, setColumns] = useState(initialColumns);
const [columnOrder, setColumnOrder] = useState(allTaskListColumns);
const [sort, setSort] = useState<TaskListSort>();
@@ -71,11 +75,20 @@ export function TaskList({ items, allItems = items, initialColumns = defaultTask
}
};
const { roots, childMap } = buildIssueTree(sortTaskListItems(items.map(item => item.issue), sort ?? defaultTaskListSort, sortValue));
const rowIds: string[] = [];
const collectRows = (issue: Issue) => {
rowIds.push(issue.id);
if (!collapsed.has(issue.id)) for (const child of childMap.get(issue.id) ?? []) collectRows(child);
};
roots.forEach(collectRows);
const { limit, hasMore, sentinel, loadMore } = useTaskRowBudget(rowIds, JSON.stringify([collectionKey ?? localKey, sort ?? defaultTaskListSort, groupBy]));
const renderedIds = new Set(rowIds.slice(0, limit));
const updateColumns = (next: readonly TaskListColumnId[]) => {
setColumns(next);
if (sort && !next.includes(sort.column)) setSort(undefined);
};
const renderBranch = (issue: Issue, depth: number): ReactNode => {
if (!renderedIds.has(issue.id)) return null;
const item = visibleById.get(issue.id)!;
const children = childMap.get(issue.id) ?? [];
const expanded = !collapsed.has(issue.id);
@@ -118,14 +131,19 @@ export function TaskList({ items, allItems = items, initialColumns = defaultTask
<TaskListColumnHeader sort={sort} onSortChange={setSort} onColumnOrderChange={setColumnOrder} />
{items.length === 0 ? emptyState : (
<div aria-label={`${density} task list`}>
{[...groups].map(([label, groupRoots]) => (
{[...groups].map(([label, groupRoots]) => groupRoots.some(issue => renderedIds.has(issue.id)) ? (
<Fragment key={label}>
{label ? <IssueGroupHeader label={label} className="task-list-group-heading" trailing={<span className="text-xs text-muted-foreground">{groupRoots.length}</span>} /> : null}
{groupRoots.map(issue => <TaskRowGroup key={issue.id} role="rowgroup" aria-label={`${issue.title} task group`}>{renderBranch(issue, 0)}</TaskRowGroup>)}
{groupRoots.filter(issue => renderedIds.has(issue.id)).map(issue => <TaskRowGroup key={issue.id} role="rowgroup" aria-label={`${issue.title} task group`}
data-density={density} className={childMap.get(issue.id)?.length ? undefined : "task-row-deferred"}>{renderBranch(issue, 0)}</TaskRowGroup>)}
</Fragment>
))}
) : null)}
</div>
)}
{hasMore ? <div ref={sentinel} className="flex items-center gap-3 py-2 text-sm text-muted-foreground">
<span>Rendering {Math.min(limit, rowIds.length)} of {rowIds.length} tasks</span>
<Button variant="outline" size="sm" onClick={loadMore}>Show more tasks</Button>
</div> : null}
</TaskListViewport>
);
}
@@ -0,0 +1,25 @@
import { useQuery } from "@tanstack/react-query";
import { attentionApi } from "@/api/attention";
import { coordinatorAttentionQueryKey, coordinatorAttentionSummaryQueryKey, coordinatorAttentionTasksQueryKey } from "@/lib/coordinator-attention";
import { useLocation } from "@/lib/router";
/** Navigation never starts a full-feed read. Reuse it on pages that own one;
* elsewhere desktop and mobile navigation share the same count-only read. */
export function useCoordinatorAttentionCount(companyId: string | null | undefined, enabled = true) {
const location = useLocation();
const feedPage = location.pathname.endsWith("/decisions");
const taskPage = location.pathname.endsWith("/issues") && new URLSearchParams(location.search).get("view") === "needs_attention";
const attention = useQuery({ queryKey: coordinatorAttentionQueryKey(companyId!),
queryFn: () => attentionApi.list(companyId!, { all: true }), enabled: false });
const summary = useQuery({ queryKey: coordinatorAttentionSummaryQueryKey(companyId!),
queryFn: () => attentionApi.summary(companyId!), enabled: enabled && !!companyId && !feedPage && !taskPage });
const tasks = useQuery({ queryKey: coordinatorAttentionTasksQueryKey(companyId!),
queryFn: () => attentionApi.taskIndex(companyId!), enabled: false });
// These are three projections of the same company/person count. Keep the
// newest known count during route changes, including an explicit fresh zero.
// Server generation time wins over a delayed older response arriving later.
const snapshots = feedPage ? [attention, tasks, summary] : taskPage ? [tasks, attention, summary] : [summary, attention, tasks];
return snapshots.filter(snapshot => snapshot.data !== undefined)
.sort((a, b) => ((Date.parse(b.data?.generatedAt ?? "") || 0) - (Date.parse(a.data?.generatedAt ?? "") || 0))
|| b.dataUpdatedAt - a.dataUpdatedAt)[0]?.data?.totalCount ?? 0;
}
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useRef, useState } from "react";
const INITIAL_ROWS = 100;
const ROW_BATCH = 150;
const rememberedBudgets = new Map<string, number>();
/** Bound React work across the whole tree. A view/filter change resets the
* budget; paging and background changes retain every already revealed row. */
export function useTaskRowBudget(rowIds: readonly string[], viewKey: string) {
const key = rowIds.join("\0");
const [budget, setBudget] = useState(() => ({ viewKey, key, ids: rowIds, limit: rememberedBudgets.get(viewKey) ?? INITIAL_ROWS }));
const sameView = budget.viewKey === viewKey;
let limit = sameView ? budget.limit : rememberedBudgets.get(viewKey) ?? INITIAL_ROWS;
if (sameView && budget.key !== key) {
const revealed = new Set(budget.ids.slice(0, budget.limit));
// Older pages can sort before existing rows. Keep the last revealed row
// inside the new prefix rather than assuming that fetched pages append.
rowIds.forEach((id, index) => { if (revealed.has(id)) limit = Math.max(limit, index + 1); });
}
const hasMore = rowIds.length > limit;
const sentinel = useRef<HTMLDivElement>(null);
const loadMore = () => setBudget({ viewKey, key, ids: rowIds, limit: limit + ROW_BATCH });
useEffect(() => {
if (!sameView || budget.key !== key || budget.limit !== limit) setBudget({ viewKey, key, ids: rowIds, limit });
// Remember only presentation counts, never records. Reopening the same view
// has its previous height before the shell restores the reading position.
rememberedBudgets.delete(viewKey);
rememberedBudgets.set(viewKey, limit);
if (rememberedBudgets.size > 50) rememberedBudgets.delete(rememberedBudgets.keys().next().value!);
}, [viewKey, key, sameView, budget.key, budget.limit, limit, rowIds]);
useEffect(() => {
const element = sentinel.current;
if (!hasMore || !element || typeof IntersectionObserver === "undefined") return;
let root = element.parentElement;
while (root && root !== document.body) {
const style = window.getComputedStyle(root);
if (["auto", "scroll", "overlay"].includes(style.overflowY) && root.scrollHeight > root.clientHeight) break;
root = root.parentElement;
}
const observer = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) setBudget({ viewKey, key, ids: rowIds, limit: limit + ROW_BATCH });
}, { root: root === document.body ? null : root });
observer.observe(element);
return () => observer.disconnect();
}, [hasMore, viewKey, key, limit, rowIds]);
return { limit, hasMore, sentinel, loadMore };
}
+7
View File
@@ -3750,6 +3750,13 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
border-block: var(--task-row-rule-width) solid var(--task-row-rule-color);
}
.task-row-group + .task-row-group { border-top: 0; }
.task-row-deferred {
content-visibility: auto;
contain-intrinsic-block-size: auto var(--task-row-height-comfortable);
}
.task-row-deferred[data-density="compact"] {
contain-intrinsic-block-size: auto var(--task-row-height-compact);
}
.task-row-stable, .task-row-skeleton {
height: var(--task-row-height-comfortable);
+2
View File
@@ -2,6 +2,8 @@ import type { AttentionItem, DecisionHistoryItem } from "@paperclipai/shared";
import { queryKeys } from "./queryKeys";
export const coordinatorAttentionQueryKey = (companyId: string) => [...queryKeys.attention(companyId), "coordinator", "pending"] as const;
export const coordinatorAttentionSummaryQueryKey = (companyId: string) => [...queryKeys.attention(companyId), "coordinator", "summary"] as const;
export const coordinatorAttentionTasksQueryKey = (companyId: string) => [...queryKeys.attention(companyId), "coordinator", "tasks"] as const;
/** Presentation-only assumption from the prototype, never a retention policy. */
export const COORDINATOR_RECENT_DECISION_MS = 24 * 60 * 60 * 1_000;
+11 -13
View File
@@ -3,9 +3,8 @@ import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttentionItem } from "@paperclipai/shared";
import { createIssue } from "../../storybook/fixtures/paperclipData";
import { coordinatorAttentionQueryKey } from "@/lib/coordinator-attention";
import { coordinatorAttentionTasksQueryKey } from "@/lib/coordinator-attention";
import { MemoryRouter } from "@/lib/router";
import { useLocation, useNavigate } from "react-router-dom";
import { CoordinatorTasks } from "./CoordinatorTasks";
@@ -14,7 +13,7 @@ import { queryKeys } from "@/lib/queryKeys";
const mocks = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn(), attention: vi.fn(), breadcrumbs: vi.fn(), liveRuns: vi.fn() }));
vi.mock("@/api/heartbeats", () => ({ heartbeatsApi: { liveRunsForCompany: mocks.liveRuns } }));
vi.mock("@/api/issues", () => ({ issuesApi: { list: mocks.list, listCompact: mocks.list, get: mocks.get, listLabels: async () => [], update: vi.fn() } }));
vi.mock("@/api/attention", () => ({ attentionApi: { list: mocks.attention } }));
vi.mock("@/api/attention", () => ({ attentionApi: { taskIndex: mocks.attention } }));
vi.mock("@/api/agents", () => ({ agentsApi: { list: async () => [] } }));
vi.mock("@/api/projects", () => ({ projectsApi: { list: async () => [] } }));
vi.mock("@/api/access", () => ({ accessApi: { listUserDirectory: async () => ({ users: [] }) } }));
@@ -41,11 +40,10 @@ vi.mock("@/components/TaskList", async importOriginal => ({
describe("Coordinator tasks using the proposed table", () => {
let root: Root, host: HTMLDivElement, client: QueryClient;
const pending = createIssue({ id: "pending", companyId: "company", status: "todo", parentId: null, title: "Needs a channel answer" });
const decision = { subject: { kind: "interaction", id: "question" }, relatedIssue: { id: pending.id } } as AttentionItem;
beforeEach(() => {
vi.clearAllMocks(); localStorage.clear();
mocks.liveRuns.mockResolvedValue([]);
mocks.attention.mockResolvedValue({ items: [decision, { ...decision, subject: { kind: "interaction", id: "approval" } }] });
mocks.attention.mockResolvedValue({ taskIds: [pending.id] });
mocks.get.mockResolvedValue(pending);
mocks.list.mockImplementation(async (_company, filters) => filters.q ? [] : filters.ids ? [pending] : [createIssue({ id: "unrelated-review", companyId: "company", status: "in_review", parentId: null })]);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -78,7 +76,7 @@ describe("Coordinator tasks using the proposed table", () => {
it("filters pending-decision batches by routine origin without treating excluded IDs as missing", async () => {
const routine = createIssue({ id: "routine-decision", companyId: "company", parentId: null, originKind: "routine_execution", title: "Routine decision" });
mocks.attention.mockResolvedValue({ items: [decision, { ...decision, relatedIssue: { id: routine.id } }] });
mocks.attention.mockResolvedValue({ taskIds: [pending.id, routine.id] });
mocks.list.mockResolvedValue([routine]);
await mount("/issues?view=needs_attention&originKind=routine_execution");
expect(mocks.list).toHaveBeenCalledWith("company", expect.objectContaining({ ids: [pending.id, routine.id], originKind: "routine_execution" }), expect.anything());
@@ -122,7 +120,7 @@ describe("Coordinator tasks using the proposed table", () => {
it("batches pending task reads instead of fetching one enriched detail per decision", async () => {
const ids = Array.from({ length: 105 }, (_, index) => `task-${index}`);
mocks.attention.mockResolvedValue({ items: ids.map(id => ({ ...decision, relatedIssue: { id } })) });
mocks.attention.mockResolvedValue({ taskIds: ids });
mocks.list.mockImplementation(async (_company, filters) => filters.ids.map((id: string) => createIssue({ id, companyId: "company", parentId: null })));
await mount("/issues?view=needs_attention");
expect(mocks.get).not.toHaveBeenCalled();
@@ -135,11 +133,11 @@ describe("Coordinator tasks using the proposed table", () => {
expect([...host.querySelectorAll("[data-task-id]")].map(node => node.getAttribute("data-task-id"))).toEqual(["pending"]);
expect(mocks.get).not.toHaveBeenCalled();
expect(mocks.list).toHaveBeenCalledExactlyOnceWith("company", expect.objectContaining({ ids: [pending.id], projection: "index" }), expect.anything());
// The same cache update used by Needs You removes the task only after its
// last decision has resolved; no parallel status-based queue is involved.
flushSync(() => client.setQueryData(coordinatorAttentionQueryKey("company"), { items: [decision] }));
// Canonical attention invalidation refreshes the complete task membership.
// No parallel status-based queue is involved.
flushSync(() => client.setQueryData(coordinatorAttentionTasksQueryKey("company"), { taskIds: [pending.id] }));
await vi.waitFor(() => expect(host.querySelectorAll("[data-task-id]")).toHaveLength(1));
flushSync(() => client.setQueryData(coordinatorAttentionQueryKey("company"), { items: [] }));
flushSync(() => client.setQueryData(coordinatorAttentionTasksQueryKey("company"), { taskIds: [] }));
await vi.waitFor(() => expect(host.textContent).toContain("No tasks need a decision."));
});
it("filters live tasks and updates when a run finishes, excluding terminal tasks with stale runs", async () => {
@@ -262,7 +260,7 @@ describe("Coordinator tasks using the proposed table", () => {
});
it("retains workspace scope when switching to Needs attention", async () => {
const other = createIssue({ id: "other-workspace", companyId: "company", parentId: null, executionWorkspaceId: "other-workspace" });
mocks.attention.mockResolvedValue({ items: [decision, { ...decision, relatedIssue: { id: other.id } }] });
mocks.attention.mockResolvedValue({ taskIds: [pending.id, other.id] });
mocks.list.mockResolvedValue([pending, other]);
await mount(`/issues?view=needs_attention&workspace=${pending.executionWorkspaceId}`);
expect([...host.querySelectorAll("[data-task-id]")].map(node => node.getAttribute("data-task-id"))).toEqual([pending.id]);
@@ -271,7 +269,7 @@ describe("Coordinator tasks using the proposed table", () => {
it("retains authoritative participant scope in Needs attention, including tasks assigned to another agent", async () => {
const participated = { ...pending, assigneeAgentId: "another-assignee" };
const unrelated = createIssue({ id: "unrelated-agent", companyId: "company", parentId: null });
mocks.attention.mockResolvedValue({ items: [decision, { ...decision, relatedIssue: { id: unrelated.id } }] });
mocks.attention.mockResolvedValue({ taskIds: [pending.id, unrelated.id] });
mocks.list.mockImplementation(async (_company, filters) => filters.participantAgentId === "participant" && (!filters.ids || filters.ids[0] === pending.id) ? [participated] : []);
await mount("/issues?participantAgentId=participant");
flushSync(() => Array.from(host.querySelectorAll("button")).find(button => button.textContent === "Needs attention")!.click());
+6 -5
View File
@@ -23,7 +23,7 @@ import { useIssueRowDetails } from "@/hooks/useIssueRowDetails";
import { usePublishSharedQueryData, useSharedPollingQuery } from "@/hooks/useSharedPolling";
import { collectLiveIssueIds } from "@/lib/liveIssueIds";
import { buildCompanyUserLabelMap } from "@/lib/company-members";
import { coordinatorAttentionQueryKey, pendingDecisionTaskIds } from "@/lib/coordinator-attention";
import { coordinatorAttentionTasksQueryKey } from "@/lib/coordinator-attention";
import { createIssueDetailLocationState } from "@/lib/issueDetailBreadcrumb";
import { defaultIssueFilterState, type IssueFilterState } from "@/lib/issue-filters";
import { queryKeys } from "@/lib/queryKeys";
@@ -86,11 +86,11 @@ export function CoordinatorTasks() {
getNextPageParam: (last, _pages, offset) => last.hasNextPage ? offset + pageSize : undefined,
enabled: !!companyId && !needsAttention,
});
const attention = useQuery({ queryKey: coordinatorAttentionQueryKey(companyId!),
queryFn: () => attentionApi.list(companyId!, { all: true }), enabled: !!companyId && needsAttention });
const attention = useQuery({ queryKey: coordinatorAttentionTasksQueryKey(companyId!),
queryFn: () => attentionApi.taskIndex(companyId!), enabled: !!companyId && needsAttention });
// Resolve every pending task in bounded index batches, including tasks outside
// the first list page. Keep full task enrichment out of the collection read.
const attentionIds = needsAttention ? [...pendingDecisionTaskIds(attention.data?.items ?? [])] : [];
const attentionIds = needsAttention ? attention.data?.taskIds ?? [] : [];
const attentionBatches = Array.from({ length: Math.ceil(attentionIds.length / 100) }, (_, index) => attentionIds.slice(index * 100, (index + 1) * 100));
const attentionTasks = useQueries({ queries: attentionBatches.map(ids => ({
queryKey: [...queryKeys.issues.list(companyId!), "coordinator-attention-index", userId, participantAgentId, deferredQuery, originKind, ids],
@@ -173,7 +173,7 @@ export function CoordinatorTasks() {
}, { replace: true });
};
const update = useMutation({ mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => issuesApi.update(id, data),
onSuccess: () => { void client.invalidateQueries({ queryKey: queryKeys.issues.list(companyId!) }); void client.invalidateQueries({ queryKey: coordinatorAttentionQueryKey(companyId!) }); },
onSuccess: () => { void client.invalidateQueries({ queryKey: queryKeys.issues.list(companyId!) }); void client.invalidateQueries({ queryKey: queryKeys.attention(companyId!) }); },
});
const error = (needsAttention ? attention.error ?? attentionTasks.find(task => task.error)?.error : tasks.error)
?? agents.error ?? projects.error ?? directory.error ?? labels.error ?? update.error
@@ -212,6 +212,7 @@ export function CoordinatorTasks() {
{pending ? <p role="status" className="text-sm text-muted-foreground">Loading tasks…</p> : !error && <>
<div hidden={collection.layout !== "list"} className="design-task-list-panel">{collection.matchingItems.length ? <TaskList key={companyId}
items={(needsAttention ? collection.matchingItems : collection.visibleItems).map(enrichItem)} allItems={collection.companyItems.map(enrichItem)}
collectionKey={JSON.stringify([companyId, userId, needsAttention, deferredQuery, participantAgentId, workspaceIds, collection.view, collection.filters])}
issueLinkState={issueLinkState} collapsedIds={collection.collapsedIds} onCollapsedIdsChange={collection.setCollapsedIds} /> : empty}</div>
{collection.layout === "board" ? collection.matchingItems.length
? <KanbanBoard showOverflowFades issues={collection.matchingItems.map(item => enrichIssueRow(item.issue))} agents={agents.data} onUpdateIssue={(id, data) => update.mutate({ id, data })} /> : empty : null}
+26
View File
@@ -1,5 +1,7 @@
// @vitest-environment jsdom
import { CoordinatorPresentation } from "../components/coordinator/CoordinatorPresentation";
import { queryKeys } from "../lib/queryKeys";
import type { ComponentProps } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
@@ -1752,3 +1754,27 @@ describe("InboxGroupHeader", () => {
});
});
});
describe("Coordinator Inbox collection reads", () => {
it.each([true, false])("keeps optimized reads with Coordinator and Streamlined=%s", async (streamlined) => {
resetInboxApiMocks(); localStorage.clear();
routerMock.location.pathname = "/inbox/mine";
const settings = { enableIsolatedWorkspaces: false, enableStreamlinedUi: streamlined };
apiMocks.experimentalSettings.mockResolvedValue(settings);
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: 0 } } });
client.setQueryData(queryKeys.instance.experimentalSettings, settings);
const container = document.createElement("div"); document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => root.render(<QueryClientProvider client={client}><CoordinatorPresentation.Provider value={true}><Inbox /></CoordinatorPresentation.Provider></QueryClientProvider>));
await vi.waitFor(() => expect(apiMocks.heartbeatRunsList).toHaveBeenCalled());
await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); });
const sources = apiMocks.issuesList.mock.calls.filter(call => call[1]?.projection !== "rows").map(call => call[1]);
expect(sources).toHaveLength(1);
expect(sources.every(source => source.projection === "index")).toBe(true);
expect(apiMocks.dashboardSummary).toHaveBeenCalledTimes(0);
expect(apiMocks.heartbeatRunsList.mock.calls[0]?.[3]?.latestFailuresOnly === true).toBe(true);
} finally { await act(async () => root.unmount()); client.clear(); container.remove(); }
});
});
+3 -1
View File
@@ -1,3 +1,4 @@
import { useCoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation";
import { describeError } from "../api/errors";
import { useIssueRowDetails } from "../hooks/useIssueRowDetails";
import { IssueRowDetailsNotice } from "../components/IssueRowDetailsNotice";
@@ -832,7 +833,8 @@ export interface InboxSurfaceProps {
export function Inbox(props: InboxSurfaceProps = {}) {
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
return streamlinedUiEnabled ? <StreamlinedInbox {...props} /> : <LegacyInbox />;
const coordinator = useCoordinatorPresentation();
return streamlinedUiEnabled || coordinator ? <StreamlinedInbox {...props} /> : <LegacyInbox />;
}
function StreamlinedInbox({