From 91e26ff237470b33322d29df13c5bade36627e25 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 12 Nov 2026 03:02:55 -0600 Subject: [PATCH] fix(ui): refetch an open chat's comments when its conversation settles (#21054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - People talk to agents in chat, and the open chat updates live when an agent replies > - Run finalization can save a turn's reply after the run's terminal status is published, and that save logs no `issue.comment_added` activity > - Finalization then settles the conversation, which logs `issue.updated`, but the UI did not refetch comments on that update > - So the reply stayed hidden in the open chat until the person reloaded the page > - This pull request makes the open chat refetch its saved comments when its conversation is updated > - The benefit is that a person sees the agent's reply without a reload ## Linked Issues or Issue Description **What happened?** In the chat eval cohort on master 98c18784c6, the agent's reply in `cases.case-history` was saved at 06:57:46.481 by run finalization (presentation decision `final_agent_message`, comment action `reuse`). The run's terminal status went out at about 06:57:45. No `issue.comment_added` activity exists for the reply. The browser fetched the issue, runs, live runs and activity after the run ended, but it never fetched comments again. At 06:57:59, 13 seconds later, the open chat still showed only the person's message (`renderedCommentIds` held only that comment). The reply showed after a reload. `tasks.ambiguous-task` showed the same state. **Expected behavior** When finalization saves the reply, the open chat shows it without a reload. **Steps to reproduce** 1. Open a chat with an agent. 2. Send a message. The run ends, and finalization then saves the reply. 3. The reply does not appear until you reload the page. Refs #21034 ## What Changed - `LiveUpdatesProvider`: an `issue.updated` activity for the conversation that is open now also invalidates that conversation's comments query. Finalization logs this update when it settles the turn, after it saves the reply (cohort 5: reply 06:57:46.481, update 06:57:46.510; paid run: reply 08:19:38.433, update 08:19:38.489). - The first version of this PR used the `run.presentation.resolved` run event. The paid run showed that a page does not receive it: run transcript events reach only pages subscribed to that run, and the subscription ends with the run. That handler is removed. - A red/green test: an update for another task, and an update for the chat while another page is open, do not refetch; an update for the open chat refetches its comments. The test fails without the change. ## Verification - `pnpm exec vitest run --project @paperclipai/ui ui/src/context/`: 17 files, 319 tests pass. - The new test fails with the change removed (1 failed, 117 passed) and passes with it. - `node scripts/check-token-gates.mjs`: clean. - The companion eval PR reloads once when a saved reply is still not rendered and records both waits. A paid eval run on a branch with both changes checks the live result. ## Risks - Low risk. Each update of the open chat's conversation (status or conversation state) now refetches its recent comments window. Other pages and other tasks are unchanged. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use, shell execution and code editing. ## 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 - [ ] 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 --- ui/src/context/LiveUpdatesProvider.test.ts | 22 ++++++++++++++++++++++ ui/src/context/LiveUpdatesProvider.tsx | 9 +++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 0a4f5634852..302fcf6b9e3 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -250,6 +250,28 @@ describe("LiveUpdatesProvider issue invalidation", () => { expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.comments("chat-1") }); client.clear(); }); + it("fetches the open chat's saved comments when its conversation is updated, and not for other tasks", () => { + const client = new QueryClient(); + client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); + client.setQueryData(queryKeys.companies.list("user-1"), { companies: [{ id: "company-1", issuePrefix: "PAP" }], unauthorized: false }); + client.setQueryData(queryKeys.agents.list("company-1"), [{ id: "agent-1", name: "Coder", urlKey: "coder" }]); + const chat = { id: "chat-1", companyId: "company-1", identifier: "PAP-1", assigneeAgentId: "agent-1" }; + client.setQueryData(queryKeys.agentChats.detail("company-1", "user-1", "agent-1"), chat); + client.setQueryData(queryKeys.issues.detail("chat-1"), chat); + const invalidate = vi.spyOn(client, "invalidateQueries"); + const deliver = (path: string, entityId: string, action: string) => __liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", + { entityType: "issue", entityId, action, actorType: "system", actorId: "system" }, { userId: "user-1", agentId: null }, { pathname: path, isForegrounded: true }); + const commentCalls = (ref: string) => invalidate.mock.calls.filter(([arg]) => JSON.stringify((arg as { queryKey?: unknown }).queryKey) === JSON.stringify(queryKeys.issues.comments(ref))); + const commentInvalidations = (ref: string) => commentCalls(ref).length; + deliver("/PAP/chats/agent-1", "other-task", "issue.updated"); + expect(commentInvalidations("other-task")).toBe(0); + deliver("/PAP/issues/PAP-9", "chat-1", "issue.updated"); + expect(commentInvalidations("chat-1")).toBe(0); + deliver("/PAP/chats/agent-1", "chat-1", "issue.updated"); + expect(commentInvalidations("chat-1")).toBe(1); + expect((commentCalls("chat-1")[0]![0] as { refetchType?: string }).refetchType).toBeUndefined(); + client.clear(); + }); it.each(["ai_connection.default_changed", "ai_connection.reconnected", "connection_grant.revoked"])( "refreshes company AI account previews after %s", (action) => { const invalidateQueries = vi.fn(); diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index eab4e693063..e72a39c4fab 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -1720,6 +1720,11 @@ function invalidateActivityQueries( }, }); } + const visibleConversation = !!options?.pathname && action === "issue.updated" + && (() => { + const context = resolveVisibleIssueRouteContext(queryClient, options.pathname!, { isForegrounded: options.isForegrounded }); + return !!context?.conversation && context.issueRefs.has(entityId); + })(); for (const ref of issueRefs) { const invalidationOptions = selfCommentActivity || @@ -1729,8 +1734,8 @@ function invalidateActivityQueries( : undefined; queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref), ...invalidationOptions }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref), ...invalidationOptions }); - if (action === "issue.comment_added" || action === "issue.conversation_session_started") { - queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref), ...invalidationOptions }); + if (action === "issue.comment_added" || action === "issue.conversation_session_started" || visibleConversation) { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref), ...(visibleConversation ? undefined : invalidationOptions) }); } if (action?.startsWith("issue.attachment_") || action?.startsWith("issue.work_product_")) { // These cards are durable API objects, not streamed text. Refresh the