mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
fix(ui): stop refetching the @task mention pool on every task event (#19931)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The board UI keeps its caches fresh from live events in `LiveUpdatesProvider` > - Each task activity event invalidates the whole `["issues", companyId]` query prefix > - That prefix also holds the `@task` mention pool: the 100 most recently updated tasks, about 315 KB per response > - A chat turn emits many comment, status and run events, so an open task page downloads that list again for almost every event > - This pull request keeps the pool fresh from push events without a refetch per event: a closed server flag marks real membership or title changes, progress on pooled tasks reorders the cache in place, and other progress refetches at most once per 30 seconds > - The benefit is less bandwidth and CPU for users with an open task or chat, and smaller eval traces ## Linked Issues or Issue Description **What happened** While a task chat page is open, the browser repeats `GET /api/companies/:id/issues?limit=100&sortField=updated&sortDir=desc` (about 315 KB each). One 10-minute chat-eval attempt recorded about 194 of these requests. The trigger is `invalidateActivityQueries` in `ui/src/context/LiveUpdatesProvider.tsx`. For every `entityType: "issue"` activity event, it invalidates `queryKeys.issues.list(companyId)`. That prefix matches `queryKeys.issues.mentionPool(companyId)`, which `IssueDetail` (and `NewIssueDialog`) keep active for the `@task` picker. Invalidation refetches active queries and ignores `staleTime`. **Expected behavior** Comments, run progress and status changes on any task do not download the full mention pool again. The pool still updates when a task that the picker can show is created, deleted or renamed. **Steps to reproduce** 1. Run `pnpm dev` and create a company with about 140 tasks. 2. Open a task page, for example `/REF/issues/REF-1`. 3. Every 4 seconds for 15 rounds, post comments on the open task, on a task in the top 100, and on a task outside the top 100, and change the open task's priority (60 events). 4. Count requests to `issues?limit=100&sortField=updated`. Before this change there are 15 (about 3.3 MB). After this change there are 2 (about 480 KB). ## What Changed - New shared helper `issueActivityChangesTaskIdentity` (`packages/shared/src/issue-activity-identity.ts`). It is true for a created, deleted, hidden, unhidden or renamed task. - `server/src/realtime/live-events-ws.ts`: task `activity.logged` events now carry the closed boolean `taskIdentityChanged`. Authenticated sockets still get no `details`; the flag carries no task content. - `ui/src/context/LiveUpdatesProvider.tsx` refreshes the mention pool this way: - The task identity flag is true (or full details show a create, delete, hide, unhide or rename): the pool refetches at once. - Other events on a task already in the pool: the task moves to the front of the cached list. There is no request. The freshness clock does not change, and a pending invalidation is never overwritten. - Other events on a task outside the pool: one trailing refetch at most every 30 seconds, because the task can now be in the top 100. - The picker is closed: the pool is only marked stale. - `ui/src/lib/query-invalidation-batcher.ts`: new `excludingQueryKeys()` keyed exclusion predicate. The batcher coalesces it and uses it for prefix coverage. The broad `["issues", companyId]` invalidation uses it to skip the pool, so a burst is again one broad refresh per batch. - Tests: `ui/src/context/LiveUpdatesProvider.mention-pool.test.ts` (authenticated event shape, hide and unhide, the 30-second window, reorder safety, a burst through the batcher), new batcher tests, `packages/shared/src/issue-activity-identity.test.ts`, and socket assertions in `server/src/__tests__/live-events-toast.test.ts`. ## Verification - `cd ui && npx vitest run src/context/LiveUpdatesProvider src/lib/query-invalidation-batcher`: 231 tests pass. - `cd server && npx vitest run src/__tests__/live-events-toast.test.ts`: 11 tests pass. In authenticated mode the socket keeps `details` off and sends `taskIdentityChanged` (false for routine updates, true for a create, rename or hide). - `cd packages/shared && npx vitest run src/issue-activity-identity.test.ts`: 14 tests pass. - The new UI tests fail against the first commit of this PR: the authenticated burst, hide, unhide, the 30-second window and the batcher burst. - `tsc --noEmit` for ui, server and shared: no new errors. - Browser measurement: a local `pnpm dev` instance (local trusted), headless Chromium on a task page, 141 tasks, 60 events over about 63 seconds (the reproduction steps above): - Before (master UI): **15** mention-pool requests, about 3.3 MB. - After: **2** requests, about 480 KB (one per 30-second window). - Freshness after the fix: one rename, hide, unhide or create each caused **1** refetch. A priority change caused **0**. - I did not run a browser session in authenticated mode. The authenticated event shape is covered by the socket test and the UI unit tests. ## Risks - A task that moves into the top 100 can be missing from the `@task` picker for up to 30 seconds. - The cached order is approximate between refetches: a moved task goes to the front, but its `updatedAt` value is not changed. - Other queries under `["issues", companyId]` (descendants, created-from, summaries, onboarding first task) still refresh for each event batch. They are small, but they can get the same treatment later. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b4842df1e4
commit
2a2d3f6e23
9 files changed
+465
-10
No files matched your search
@@ -128,6 +128,8 @@ describe("authenticated activity notifications", () => {
|
||||
if (actorType === "agent") expect(event.payload.actorName).toBe("Coder");
|
||||
if (actorType === "user") expect(event.payload.actorName).toBe("Alex Example");
|
||||
if (deploymentMode === "authenticated") expect(event.payload).not.toHaveProperty("details");
|
||||
// Routine updates are not task identity changes.
|
||||
expect(event.payload.taskIdentityChanged).toBe(false);
|
||||
}
|
||||
expect(messages[3].payload.issueIdentifier).toBe("PAP-1234");
|
||||
} finally {
|
||||
@@ -260,6 +262,16 @@ describe("authenticated activity notifications", () => {
|
||||
await second;
|
||||
expect(messages[1].payload).toMatchObject({ issueIdentifier: "PAP-1234", actorName: "Coder", actorType: "agent", actorId: "agent", actorAppearance: { paletteId: "bubblegum-sky" } });
|
||||
if (deploymentMode === "authenticated") expect(messages[1].payload).not.toHaveProperty("details");
|
||||
expect(messages[0].payload.taskIdentityChanged).toBe(true);
|
||||
const third = once(socket, "message");
|
||||
publishLiveEvent({ companyId: "company", type: "activity.logged", payload: {
|
||||
action: "issue.updated", entityType: "issue", entityId: "visible", actorType: "user", actorId: "viewer",
|
||||
details: { title: "Secret new title", hiddenAt: null },
|
||||
} });
|
||||
await third;
|
||||
// A rename or hide is flagged with a closed boolean; its content stays off the socket.
|
||||
expect(messages[2].payload.taskIdentityChanged).toBe(true);
|
||||
if (deploymentMode === "authenticated") expect(JSON.stringify(messages[2].payload)).not.toContain("Secret new title");
|
||||
} finally {
|
||||
socket.terminate();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Duplex } from "node:stream";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentApiKeys, companyMemberships, instanceUserRoles, heartbeatRuns, projects } from "@paperclipai/db";
|
||||
import type { DeploymentMode, LiveEvent } from "@paperclipai/shared";
|
||||
import { issueActivityChangesTaskIdentity, type DeploymentMode, type LiveEvent } from "@paperclipai/shared";
|
||||
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { activityToastIdentity } from "./activity-toast-identity.js";
|
||||
@@ -289,6 +289,7 @@ export function setupLiveEventsWebSocketServer(
|
||||
if (context!.actor.source === "local_implicit") {
|
||||
if (event.type === "activity.logged" && event.payload.entityType === "issue") {
|
||||
return { ...event, payload: { ...event.payload,
|
||||
taskIdentityChanged: issueActivityChangesTaskIdentity(event.payload.action, event.payload.details),
|
||||
...await activityToastIdentity(db, event.companyId, event.payload) } };
|
||||
}
|
||||
return event;
|
||||
@@ -351,6 +352,11 @@ export function setupLiveEventsWebSocketServer(
|
||||
// A handoff can attach the first case to a task. Its already-authorized
|
||||
// task identity lets an empty cases panel refresh without exposing content.
|
||||
...(payload.entityType === "case" && String(payload.action).startsWith("case.handoff_") && issueId ? { issueId } : {}),
|
||||
// A closed boolean derived from details, so task pickers can tell a
|
||||
// created, deleted, hidden or renamed task from routine progress.
|
||||
...(payload.entityType === "issue"
|
||||
? { taskIdentityChanged: issueActivityChangesTaskIdentity(payload.action, payload.details) }
|
||||
: {}),
|
||||
// The task read check above authorizes attribution, but not arbitrary details.
|
||||
...(issueId ? await activityToastIdentity(db, event.companyId, payload) : {}),
|
||||
} };
|
||||
|
||||
Reference in new issue
Block a user