From aa58fa107389e8b9ae5949f7ad815943dfd94292 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:58:53 -0500 Subject: [PATCH] perf(ui): restore fast Coordinator task and inbox loading (#18323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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`.
Individual paired readiness measurements (ms) | 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 |
### 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 --- maps/agent-access-map/inbox-search.md | 8 ++ maps/feature-map/inbox-search.md | 6 ++ packages/shared/src/index.ts | 2 + packages/shared/src/types/attention.ts | 7 ++ packages/shared/src/types/index.ts | 2 + .../src/__tests__/attention-service.test.ts | 50 +++++++++++ server/src/__tests__/openapi-routes.test.ts | 15 ++++ server/src/routes/attention.ts | 16 ++++ server/src/routes/openapi.ts | 26 ++++++ server/src/services/attention.ts | 80 ++++++++++------- tests/perf/collections/README.md | 24 ++++++ tests/perf/collections/fixture.ts | 50 +++++++++++ tests/perf/collections/playwright.config.ts | 13 +++ tests/perf/collections/startup.spec.ts | 83 ++++++++++++++++++ ui/src/api/attention.test.ts | 10 +++ ui/src/api/attention.ts | 4 +- ui/src/components/CoordinatorSidebar.test.tsx | 60 +++++++++++-- ui/src/components/CoordinatorSidebar.tsx | 9 +- .../MobileBottomNav.coordinator.test.tsx | 4 +- ui/src/components/MobileBottomNav.tsx | 16 ++-- ui/src/components/TaskList.test.tsx | 85 +++++++++++++++++++ ui/src/components/TaskList.tsx | 28 ++++-- ui/src/hooks/useCoordinatorAttentionCount.ts | 25 ++++++ ui/src/hooks/useTaskRowBudget.ts | 50 +++++++++++ ui/src/index.css | 7 ++ ui/src/lib/coordinator-attention.ts | 2 + ui/src/pages/CoordinatorTasks.test.tsx | 24 +++--- ui/src/pages/CoordinatorTasks.tsx | 11 +-- ui/src/pages/Inbox.test.tsx | 26 ++++++ ui/src/pages/Inbox.tsx | 4 +- 30 files changed, 664 insertions(+), 83 deletions(-) create mode 100644 tests/perf/collections/README.md create mode 100644 tests/perf/collections/fixture.ts create mode 100644 tests/perf/collections/playwright.config.ts create mode 100644 tests/perf/collections/startup.spec.ts create mode 100644 ui/src/components/TaskList.test.tsx create mode 100644 ui/src/hooks/useCoordinatorAttentionCount.ts create mode 100644 ui/src/hooks/useTaskRowBudget.ts diff --git a/maps/agent-access-map/inbox-search.md b/maps/agent-access-map/inbox-search.md index 1cf0c2b2466..526a7a54736 100644 --- a/maps/agent-access-map/inbox-search.md +++ b/maps/agent-access-map/inbox-search.md @@ -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 diff --git a/maps/feature-map/inbox-search.md b/maps/feature-map/inbox-search.md index 803f172f833..35a7963f9bb 100644 --- a/maps/feature-map/inbox-search.md +++ b/maps/feature-map/inbox-search.md @@ -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. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 77c4479d15c..160e0a887f3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -171,6 +171,8 @@ export type { DecisionHistoryPage, AttentionDetailImage, AttentionFeed, + AttentionSummary, + AttentionTaskIndex, AttentionItem, AttentionItemDetail, AttentionItemDismissal, diff --git a/packages/shared/src/types/attention.ts b/packages/shared/src/types/attention.ts index 6b290998ea3..4978a893caa 100644 --- a/packages/shared/src/types/attention.ts +++ b/packages/shared/src/types/attention.ts @@ -275,6 +275,13 @@ export interface AttentionFeed { countsBySourceKind: Record; items: AttentionItem[]; } + +/** Canonical attention counts without downloading the decision cards. */ +export type AttentionSummary = Omit; +/** 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; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 5feb5cdaad2..f5edbfca2c3 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -45,6 +45,8 @@ export type { DecisionHistoryPage, AttentionDetailImage, AttentionFeed, + AttentionSummary, + AttentionTaskIndex, AttentionItem, AttentionItemDetail, AttentionItemDismissal, diff --git a/server/src/__tests__/attention-service.test.ts b/server/src/__tests__/attention-service.test.ts index 8cb9bf90788..a024ed421d7 100644 --- a/server/src/__tests__/attention-service.test.ts +++ b/server/src/__tests__/attention-service.test.ts @@ -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> = [], 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 () => { diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index f1523060fe4..0a1a220b8fd 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -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; diff --git a/server/src/routes/attention.ts b/server/src/routes/attention.ts index d1c7a7c2a92..9f1671885d7 100644 --- a/server/src/routes/attention.ts +++ b/server/src/routes/attention.ts @@ -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); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index e9d0f0b69e2..7b10759ed26 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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({ diff --git a/server/src/services/attention.ts b/server/src/services/attention.ts index dbb06445874..8d5dedd3b03 100644 --- a/server/src/services/attention.ts +++ b/server/src/services/attention.ts @@ -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 => { 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) => summaryOnly + ? Promise.resolve(new Map()) : 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>`'{}'::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>`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()) + : 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`''` : 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 = {}): Promise => { + 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 = {}): Promise => { + 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] : []))] }; }, }; } diff --git a/tests/perf/collections/README.md b/tests/perf/collections/README.md new file mode 100644 index 00000000000..0065e926607 --- /dev/null +++ b/tests/perf/collections/README.md @@ -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. diff --git a/tests/perf/collections/fixture.ts b/tests/perf/collections/fixture.ts new file mode 100644 index 00000000000..d0c024a3bc1 --- /dev/null +++ b/tests/perf/collections/fixture.ts @@ -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 { + 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 } }); + }); +} diff --git a/tests/perf/collections/playwright.config.ts b/tests/perf/collections/playwright.config.ts new file mode 100644 index 00000000000..0e9c9c11518 --- /dev/null +++ b/tests/perf/collections/playwright.config.ts @@ -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"]], +}); diff --git a/tests/perf/collections/startup.spec.ts b/tests/perf/collections/startup.spec.ts new file mode 100644 index 00000000000..0450bbf0b97 --- /dev/null +++ b/tests/perf/collections/startup.spec.ts @@ -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); +}); diff --git a/ui/src/api/attention.test.ts b/ui/src/api/attention.test.ts index db1d29749f0..9caf14d2720 100644 --- a/ui/src/api/attention.test.ts +++ b/ui/src/api/attention.test.ts @@ -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"); + }); }); diff --git a/ui/src/api/attention.ts b/ui/src/api/attention.ts index d077a108f67..da35a511a38 100644 --- a/ui/src/api/attention.ts +++ b/ui/src/api/attention.ts @@ -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(`/companies/${companyId}/attention/summary`), + taskIndex: (companyId: string) => api.get(`/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)); diff --git a/ui/src/components/CoordinatorSidebar.test.tsx b/ui/src/components/CoordinatorSidebar.test.tsx index 2e7ca46c31f..3094e06d3c8 100644 --- a/ui/src/components/CoordinatorSidebar.test.tsx +++ b/ui/src/components/CoordinatorSidebar.test.tsx @@ -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 }) => {children}, 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(); + }); +}); diff --git a/ui/src/components/CoordinatorSidebar.tsx b/ui/src/components/CoordinatorSidebar.tsx index 8120d12877d..57a33dac71c 100644 --- a/ui/src/components/CoordinatorSidebar.tsx +++ b/ui/src/components/CoordinatorSidebar.tsx @@ -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
@@ -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 && }
- + { event.preventDefault(); moreContentRef.current?.focus(); }} side={isMobile ? "bottom" : "right"} align="start" className="w-56 p-1" aria-label="More workspace pages"> diff --git a/ui/src/components/MobileBottomNav.coordinator.test.tsx b/ui/src/components/MobileBottomNav.coordinator.test.tsx index 1a46c943b07..879076947cf 100644 --- a/ui/src/components/MobileBottomNav.coordinator.test.tsx +++ b/ui/src/components/MobileBottomNav.coordinator.test.tsx @@ -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 { diff --git a/ui/src/components/MobileBottomNav.tsx b/ui/src/components/MobileBottomNav.tsx index bfe534895f2..46f4943a58a 100644 --- a/ui/src/components/MobileBottomNav.tsx +++ b/ui/src/components/MobileBottomNav.tsx @@ -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 ( diff --git a/ui/src/components/TaskList.test.tsx b/ui/src/components/TaskList.test.tsx new file mode 100644 index 00000000000..460b93bedd7 --- /dev/null +++ b/ui/src/components/TaskList.test.tsx @@ -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 }) => +
{leadingControl}
})); +(globalThis as Record).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()); + } + 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('button[aria-label="Collapse Root"]')!; + await act(async () => collapse.click()); + expect(rows()).toHaveLength(1); + await act(async () => host.querySelector('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); + }); +}); diff --git a/ui/src/components/TaskList.tsx b/ui/src/components/TaskList.tsx index 060e4e19699..88336804bc9 100644 --- a/ui/src/components/TaskList.tsx +++ b/ui/src/components/TaskList.tsx @@ -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(); @@ -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 {items.length === 0 ? emptyState : (
- {[...groups].map(([label, groupRoots]) => ( + {[...groups].map(([label, groupRoots]) => groupRoots.some(issue => renderedIds.has(issue.id)) ? ( {label ? {groupRoots.length}} /> : null} - {groupRoots.map(issue => {renderBranch(issue, 0)})} + {groupRoots.filter(issue => renderedIds.has(issue.id)).map(issue => {renderBranch(issue, 0)})} - ))} + ) : null)}
)} + {hasMore ?
+ Rendering {Math.min(limit, rowIds.length)} of {rowIds.length} tasks + +
: null} ); } diff --git a/ui/src/hooks/useCoordinatorAttentionCount.ts b/ui/src/hooks/useCoordinatorAttentionCount.ts new file mode 100644 index 00000000000..10070388bbe --- /dev/null +++ b/ui/src/hooks/useCoordinatorAttentionCount.ts @@ -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; +} diff --git a/ui/src/hooks/useTaskRowBudget.ts b/ui/src/hooks/useTaskRowBudget.ts new file mode 100644 index 00000000000..afd4ace0221 --- /dev/null +++ b/ui/src/hooks/useTaskRowBudget.ts @@ -0,0 +1,50 @@ +import { useEffect, useRef, useState } from "react"; + +const INITIAL_ROWS = 100; +const ROW_BATCH = 150; +const rememberedBudgets = new Map(); + +/** 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(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 }; +} diff --git a/ui/src/index.css b/ui/src/index.css index d9e0f9aa191..6bc21d29dac 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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); diff --git a/ui/src/lib/coordinator-attention.ts b/ui/src/lib/coordinator-attention.ts index b898d1e4d17..4c0ca2e6c24 100644 --- a/ui/src/lib/coordinator-attention.ts +++ b/ui/src/lib/coordinator-attention.ts @@ -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; diff --git a/ui/src/pages/CoordinatorTasks.test.tsx b/ui/src/pages/CoordinatorTasks.test.tsx index 3ec1d10ce68..32ceb4a7f30 100644 --- a/ui/src/pages/CoordinatorTasks.test.tsx +++ b/ui/src/pages/CoordinatorTasks.test.tsx @@ -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()); diff --git a/ui/src/pages/CoordinatorTasks.tsx b/ui/src/pages/CoordinatorTasks.tsx index 3ab3a81d536..5234b3d60ff 100644 --- a/ui/src/pages/CoordinatorTasks.tsx +++ b/ui/src/pages/CoordinatorTasks.tsx @@ -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 }) => 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 ?

Loading tasks…

: !error && <> {collection.layout === "board" ? collection.matchingItems.length ? enrichIssueRow(item.issue))} agents={agents.data} onUpdateIssue={(id, data) => update.mutate({ id, data })} /> : empty : null} diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index a6dd42c939f..9c5d7ab2a9e 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -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()); + 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(); } + }); +}); diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 25ce1c17fd6..cea5ccbdf7d 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -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 ? : ; + const coordinator = useCoordinatorPresentation(); + return streamlinedUiEnabled || coordinator ? : ; } function StreamlinedInbox({