mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
fix(ui): refresh dependent tasks when a blocker changes (#20836)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Tasks can depend on other tasks. A task detail shows its blockers in the properties panel and in a "Still blocked by" notice. > - The task detail response embeds a summary of each blocker, including its status. > - Live updates refresh only the changed task's own cached detail. They do not refresh other cached tasks that embed it. > - So when a user marks a blocker done, an open dependent task keeps the old blocker status. The "Blocked by" chip shows "blocked" while the Subtasks chip shows "done". The page looks stuck until a reload or until a run starts. > - This pull request refreshes every cached task detail that embeds the changed task in `blockedBy` or `blocks`. > - The benefit is that dependent tasks show the current blocker status immediately. ## Linked Issues or Issue Description **What happened?** A user marked a blocker task done by hand. The dependent task page still showed the blocker with a "blocked" icon and a "Still blocked by" notice. The same blocker showed as "done" in the Subtasks row of the same panel. The server had already resolved the dependency and woke the dependent task. Only the open page was stale. **Steps to reproduce** 1. Open task A, which is blocked by its subtask B. 2. In another view, mark B done. 3. Go back to A without a reload. "Blocked by" still shows B as blocked, and the notice says "Still blocked by B". "Subtasks" shows B as done. **Expected behavior** The "Blocked by" chip and the notice update when B changes, the same as the Subtasks row. ## What Changed - `LiveUpdatesProvider`: when activity changes a task's status, retry or recovery fields, invalidate every cached `issues.detail` query whose `blockedBy` or `blocks` summaries contain that task. The match uses the UUID or the identifier, because authenticated events can omit the identifier. - Add a regression test for this in `LiveUpdatesProvider.test.ts`. ## Verification - Red: the new test failed before the fix. The dependent detail was not invalidated. - Green: `pnpm vitest run src/context/` in `ui` passes (311 tests). - `pnpm verify` passes (549 selected test files). - `tsc -p ui --noEmit` shows no new errors. Existing errors in `ui/storybook/support/feature-map` are unrelated. - Staging data check: the server returned the blocker as `done` in the dependent's `blockedBy`. The stale display came only from the client cache. ## Risks Low risk. The predicate scans only cached task details. It refetches only details that embed the changed task. Refetches use the existing invalidation batcher. The list of actions excludes comments and document edits, so busy tasks do not cause extra refetches of their dependents. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use and code execution. ## 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
e2cf9a670f
commit
693743cd59
2 files changed
+62
No files matched your search
@@ -41,6 +41,28 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
}
|
||||
client.clear();
|
||||
});
|
||||
it("refreshes cached tasks whose relation summaries embed a changed blocker", () => {
|
||||
const client = new QueryClient();
|
||||
const blocker = { id: "blocker-uuid", identifier: "PAP-3808", title: "Blocker", status: "blocked" };
|
||||
const dependentKey = queryKeys.issues.detail("PAP-3797");
|
||||
const dependentByIdKey = queryKeys.issues.detail("dependent-uuid");
|
||||
const blockedTaskKey = queryKeys.issues.detail("PAP-3900");
|
||||
const unrelatedKey = queryKeys.issues.detail("PAP-1");
|
||||
client.setQueryData(dependentKey, { id: "dependent-uuid", identifier: "PAP-3797", blockedBy: [blocker], blocks: [] });
|
||||
client.setQueryData(dependentByIdKey, { id: "dependent-uuid", identifier: "PAP-3797", blockedBy: [blocker], blocks: [] });
|
||||
client.setQueryData(blockedTaskKey, { id: "blocked-uuid", identifier: "PAP-3900", blockedBy: [], blocks: [blocker] });
|
||||
client.setQueryData(unrelatedKey, { id: "other-uuid", identifier: "PAP-1", blockedBy: [], blocks: [] });
|
||||
// Authenticated events can carry only the UUID; the dependent cache is still found by its summary id.
|
||||
__liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", {
|
||||
entityType: "issue", entityId: "blocker-uuid", action: "issue.updated", actorType: "user", actorId: "user-1",
|
||||
details: { status: "done", _previous: { status: "blocked" } },
|
||||
}, { userId: "user-1", agentId: null });
|
||||
expect(client.getQueryState(dependentKey)?.isInvalidated).toBe(true);
|
||||
expect(client.getQueryState(dependentByIdKey)?.isInvalidated).toBe(true);
|
||||
expect(client.getQueryState(blockedTaskKey)?.isInvalidated).toBe(true);
|
||||
expect(client.getQueryState(unrelatedKey)?.isInvalidated).toBe(false);
|
||||
client.clear();
|
||||
});
|
||||
it("drops cached decision content when its access is revoked", () => {
|
||||
const client = new QueryClient();
|
||||
const receiptKey = ["issues", "decision-receipts", "chat-1", "user-1"];
|
||||
|
||||
@@ -1498,6 +1498,43 @@ function invalidateHeartbeatProgressQueries(
|
||||
}
|
||||
}
|
||||
|
||||
// Actions that can change the status, retry or recovery fields that related
|
||||
// tasks embed in their `blockedBy`/`blocks` summaries.
|
||||
const ISSUE_RELATION_SUMMARY_ACTIVITY_ACTIONS = new Set([
|
||||
"issue.updated",
|
||||
"issue.deleted",
|
||||
"issue.status_decision_recorded",
|
||||
"issue.checked_out",
|
||||
"issue.released",
|
||||
"issue.recovery_action_resolved",
|
||||
"issue.recovery_generation_replaced",
|
||||
"issue.retry_requested",
|
||||
"issue.scheduled_retry_retry_now",
|
||||
]);
|
||||
|
||||
/**
|
||||
* A task detail embeds summaries of its blockers and the tasks it blocks. When
|
||||
* one of those changes, refresh every cached detail that embeds it so the
|
||||
* dependent page does not keep showing the old blocker status.
|
||||
*/
|
||||
function invalidateIssueDetailsEmbeddingRelation(queryClient: QueryClient, refs: Iterable<string>) {
|
||||
const changed = new Set(refs);
|
||||
if (changed.size === 0) return;
|
||||
const embeds = (summaries: unknown) =>
|
||||
Array.isArray(summaries) &&
|
||||
summaries.some((summary) => {
|
||||
const record = readRecord(summary);
|
||||
return !!record && (changed.has(readString(record.id) ?? "") || changed.has(readString(record.identifier) ?? ""));
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["issues", "detail"],
|
||||
predicate: (query) => {
|
||||
const cached = readRecord(query.state.data);
|
||||
return !!cached && !changed.has(readString(cached.id) ?? "") && (embeds(cached.blockedBy) || embeds(cached.blocks));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function invalidateActivityQueries(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
companyId: string,
|
||||
@@ -1649,6 +1686,9 @@ function invalidateActivityQueries(
|
||||
details,
|
||||
options,
|
||||
);
|
||||
if (action && ISSUE_RELATION_SUMMARY_ACTIVITY_ACTIONS.has(action)) {
|
||||
invalidateIssueDetailsEmbeddingRelation(queryClient, [entityId, ...issueRefs]);
|
||||
}
|
||||
const taskDocumentsChanged = !!action && ISSUE_DOCUMENT_ACTIVITY_ACTIONS.has(action);
|
||||
const caseWorkChanged = !!action && ISSUE_CASE_WORK_ACTIVITY_ACTIONS.has(action);
|
||||
if (caseWorkChanged) {
|
||||
|
||||
Reference in new issue
Block a user