fix(ui): append visible issue comments without thread reload

This commit is contained in:
Dotta committed 2026-04-14 11:46:39 -05:00
1 parent 38c0fb4e16
commit 83abffdf8b
3 files changed
+242 -3

No files matched your search

+2
View File
@@ -97,6 +97,8 @@ export const issuesApi = {
const qs = params.toString();
return api.get<IssueComment[]>(`/issues/${id}/comments${qs ? `?${qs}` : ""}`);
},
getComment: (id: string, commentId: string) =>
api.get<IssueComment>(`/issues/${id}/comments/${commentId}`),
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
const params = new URLSearchParams();
+154
View File
@@ -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", () => {
+86 -3
View File
@@ -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<string>;
assigneeAgentId: string | null;
runIds: Set<string>;
@@ -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<string, unknown>,
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<string, unknown>,
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<InfiniteData<IssueComment[], string | null> | 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,