diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 2ac3744f828..e0d8ec05d0d 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -97,6 +97,8 @@ export const issuesApi = { const qs = params.toString(); return api.get(`/issues/${id}/comments${qs ? `?${qs}` : ""}`); }, + getComment: (id: string, commentId: string) => + api.get(`/issues/${id}/comments/${commentId}`), listFeedbackVotes: (id: string) => api.get(`/issues/${id}/feedback-votes`), listFeedbackTraces: (id: string, filters?: Record) => { const params = new URLSearchParams(); diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index ad0c02bcc14..559f80df1e9 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -1,5 +1,15 @@ // @vitest-environment node +const { getCommentMock } = vi.hoisted(() => ({ + getCommentMock: vi.fn(), +})); + +vi.mock("../api/issues", () => ({ + issuesApi: { + getComment: getCommentMock, + }, +})); + import { describe, expect, it, vi } from "vitest"; import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider"; import { queryKeys } from "../lib/queryKeys"; @@ -257,6 +267,150 @@ describe("LiveUpdatesProvider issue invalidation", () => { refetchType: "inactive", }); }); + + it("keeps visible issue comment updates inactive-only instead of active refetching", () => { + const invalidations: unknown[] = []; + const queryClient = { + invalidateQueries: (input: unknown) => { + invalidations.push(input); + }, + getQueryData: (key: unknown) => { + if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) { + return { + id: "issue-1", + identifier: "PAP-759", + assigneeAgentId: "agent-1", + }; + } + return undefined; + }, + }; + + __liveUpdatesTestUtils.invalidateActivityQueries( + queryClient as never, + "company-1", + { + entityType: "issue", + entityId: "issue-1", + action: "issue.comment_added", + actorType: "agent", + actorId: "agent-1", + details: { + identifier: "PAP-759", + commentId: "comment-1", + bodySnippet: "New agent comment", + }, + }, + { userId: null, agentId: null }, + { pathname: "/PAP/issues/PAP-759", isForegrounded: true }, + ); + + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.detail("issue-1"), + refetchType: "inactive", + }); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.activity("issue-1"), + refetchType: "inactive", + }); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.comments("issue-1"), + refetchType: "inactive", + }); + }); +}); + +describe("LiveUpdatesProvider visible issue comment hydration", () => { + it("hydrates the visible issue comments cache with only the new comment", async () => { + getCommentMock.mockResolvedValueOnce({ + id: "comment-2", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: "agent-1", + authorUserId: null, + body: "Second comment", + createdAt: "2026-04-13T15:00:00.000Z", + updatedAt: "2026-04-13T15:00:00.000Z", + }); + + const setCalls: Array<{ key: unknown; value: unknown }> = []; + const queryClient = { + getQueryData: (key: unknown) => { + if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) { + return { + id: "issue-1", + identifier: "PAP-759", + assigneeAgentId: "agent-1", + }; + } + if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.comments("PAP-759"))) { + return { + pages: [[{ + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-1", + body: "First comment", + createdAt: "2026-04-13T14:00:00.000Z", + updatedAt: "2026-04-13T14:00:00.000Z", + }]], + pageParams: [null], + }; + } + return undefined; + }, + setQueryData: (key: unknown, updater: (value: unknown) => unknown) => { + const current = queryClient.getQueryData(key); + setCalls.push({ key, value: updater(current) }); + }, + invalidateQueries: vi.fn(), + }; + + await __liveUpdatesTestUtils.hydrateVisibleIssueComment( + queryClient as never, + "/PAP/issues/PAP-759", + { + entityType: "issue", + entityId: "issue-1", + action: "issue.comment_added", + details: { + identifier: "PAP-759", + commentId: "comment-2", + }, + }, + { isForegrounded: true }, + ); + + expect(getCommentMock).toHaveBeenCalledWith("PAP-759", "comment-2"); + expect(setCalls).toHaveLength(1); + expect(setCalls[0]?.key).toEqual(queryKeys.issues.comments("PAP-759")); + expect(setCalls[0]?.value).toEqual({ + pages: [[ + { + id: "comment-2", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: "agent-1", + authorUserId: null, + body: "Second comment", + createdAt: "2026-04-13T15:00:00.000Z", + updatedAt: "2026-04-13T15:00:00.000Z", + }, + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-1", + body: "First comment", + createdAt: "2026-04-13T14:00:00.000Z", + updatedAt: "2026-04-13T14:00:00.000Z", + }, + ]], + pageParams: [null], + }); + }); }); describe("LiveUpdatesProvider visible issue toast suppression", () => { diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 33efd5238dd..c26df965ad6 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -1,12 +1,14 @@ import { useEffect, useRef, type ReactNode } from "react"; -import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; -import type { Agent, Issue, LiveEvent } from "@paperclipai/shared"; +import { useQuery, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query"; +import type { Agent, Issue, IssueComment, LiveEvent } from "@paperclipai/shared"; import type { RunForIssue } from "../api/activity"; import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; +import { issuesApi } from "../api/issues"; import { authApi } from "../api/auth"; import { useCompany } from "./CompanyContext"; import type { ToastInput } from "./ToastContext"; import { useToast } from "./ToastContext"; +import { upsertIssueCommentInPages } from "../lib/optimistic-issue-comments"; import { queryKeys } from "../lib/queryKeys"; import { toCompanyRelativePath } from "../lib/company-routes"; import { useLocation } from "../lib/router"; @@ -83,6 +85,7 @@ interface VisibleRouteOptions { } interface VisibleIssueRouteContext { + routeIssueRef: string; issueRefs: Set; assigneeAgentId: string | null; runIds: Set; @@ -189,6 +192,7 @@ function resolveVisibleIssueRouteContext( } return { + routeIssueRef: issueRef, issueRefs, assigneeAgentId: issue?.assigneeAgentId ?? null, runIds, @@ -277,6 +281,72 @@ function shouldDeferIssueRefetchForVisibleAgentActivity( return overlaps(context.issueRefs, buildIssueRefsForPayload(entityId, details)); } +function shouldDeferVisibleIssueCommentActivity( + queryClient: QueryClient, + pathname: string, + payload: Record, + options?: VisibleRouteOptions, +): boolean { + const entityType = readString(payload.entityType); + const entityId = readString(payload.entityId); + const action = readString(payload.action); + const details = readRecord(payload.details); + + if (entityType !== "issue" || !entityId) return false; + if (action !== "issue.comment_added") return false; + + const context = resolveVisibleIssueRouteContext(queryClient, pathname, options); + if (!context) return false; + + return overlaps(context.issueRefs, buildIssueRefsForPayload(entityId, details)); +} + +async function hydrateVisibleIssueComment( + queryClient: QueryClient, + pathname: string, + payload: Record, + options?: VisibleRouteOptions, +) { + const entityType = readString(payload.entityType); + const action = readString(payload.action); + const details = readRecord(payload.details); + const commentId = readString(details?.commentId); + + if (entityType !== "issue" || action !== "issue.comment_added" || !commentId) return false; + + const context = resolveVisibleIssueRouteContext(queryClient, pathname, options); + if (!context) return false; + + const entityId = readString(payload.entityId); + if (!entityId || !overlaps(context.issueRefs, buildIssueRefsForPayload(entityId, details))) { + return false; + } + + try { + const comment = await issuesApi.getComment(context.routeIssueRef, commentId); + queryClient.setQueryData | undefined>( + queryKeys.issues.comments(context.routeIssueRef), + (current) => { + if (!current) { + return { + pages: [[comment]], + pageParams: [null], + }; + } + + return { + ...current, + pages: upsertIssueCommentInPages(current.pages, comment), + }; + }, + ); + return true; + } catch { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(context.routeIssueRef) }); + return false; + } +} + const ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]); const AGENT_TOAST_STATUSES = new Set(["error"]); const RUN_TOAST_STATUSES = new Set(["failed", "timed_out", "cancelled"]); @@ -536,10 +606,18 @@ function invalidateActivityQueries( payload, { isForegrounded: options.isForegrounded }, ); + const visibleIssueCommentActivity = + !!options?.pathname && + shouldDeferVisibleIssueCommentActivity( + queryClient, + options.pathname, + payload, + { isForegrounded: options.isForegrounded }, + ); const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details); for (const ref of issueRefs) { const invalidationOptions = - (selfCommentActivity || visibleIssueAgentActivity) + (selfCommentActivity || visibleIssueAgentActivity || visibleIssueCommentActivity) ? { refetchType: "inactive" as const } : undefined; queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref), ...invalidationOptions }); @@ -691,6 +769,9 @@ function handleLiveEvent( if (event.type === "activity.logged") { invalidateActivityQueries(queryClient, expectedCompanyId, payload, currentActor, { pathname }); + if (shouldDeferVisibleIssueCommentActivity(queryClient, pathname, payload)) { + void hydrateVisibleIssueComment(queryClient, pathname, payload); + } const action = readString(payload.action); const toast = buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ?? @@ -747,9 +828,11 @@ export const __liveUpdatesTestUtils = { buildAgentStatusToast, buildRunStatusToast, closeSocketQuietly, + hydrateVisibleIssueComment, invalidateActivityQueries, resolveLiveCompanyId, shouldDeferIssueRefetchForVisibleAgentActivity, + shouldDeferVisibleIssueCommentActivity, shouldSuppressActivityToastForVisibleIssue, shouldSuppressRunStatusToastForVisibleIssue, shouldSuppressAgentStatusToastForVisibleIssue,