fix(ui): avoid visible issue detail refreshes during agent updates

This commit is contained in:
Dotta committed 2026-04-14 11:46:39 -05:00
1 parent f377bd32e4
commit 54c0a6572f
3 files changed
+133 -38

No files matched your search

@@ -163,6 +163,100 @@ describe("LiveUpdatesProvider issue invalidation", () => {
refetchType: "inactive",
});
});
it("keeps visible issue detail refetches inactive for downstream agent updates", () => {
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.updated",
actorType: "system",
actorId: "heartbeat",
details: {
identifier: "PAP-759",
source: "deferred_comment_wake",
},
},
{ 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",
});
});
it("still actively refetches visible issue detail for board-authored updates", () => {
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.updated",
actorType: "user",
actorId: "user-2",
details: {
identifier: "PAP-759",
status: "in_progress",
},
},
{ userId: "user-1", agentId: null },
{ pathname: "/PAP/issues/PAP-759", isForegrounded: true },
);
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.detail("issue-1"),
});
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.activity("issue-1"),
});
expect(invalidations).not.toContainEqual({
queryKey: queryKeys.issues.detail("issue-1"),
refetchType: "inactive",
});
});
});
describe("LiveUpdatesProvider visible issue toast suppression", () => {
+38 -2
View File
@@ -254,6 +254,29 @@ function shouldSuppressAgentStatusToastForVisibleIssue(
return !!agentId && agentId === context.assigneeAgentId;
}
function shouldDeferIssueRefetchForVisibleAgentActivity(
queryClient: QueryClient,
pathname: string,
payload: Record<string, unknown>,
options?: VisibleRouteOptions,
): boolean {
const entityType = readString(payload.entityType);
const entityId = readString(payload.entityId);
const actorType = readString(payload.actorType);
const action = readString(payload.action);
const details = readRecord(payload.details);
if (entityType !== "issue" || !entityId) return false;
if (actorType !== "agent" && actorType !== "system") return false;
if (action !== "issue.updated") return false;
if (readString(details?.source) === "comment") return false;
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
if (!context) return false;
return overlaps(context.issueRefs, buildIssueRefsForPayload(entityId, details));
}
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"]);
@@ -481,6 +504,7 @@ function invalidateActivityQueries(
companyId: string,
payload: Record<string, unknown>,
currentActor: { userId: string | null; agentId: string | null },
options?: { pathname?: string; isForegrounded?: boolean },
) {
queryClient.invalidateQueries({ queryKey: queryKeys.activity(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) });
@@ -504,9 +528,20 @@ function invalidateActivityQueries(
(action === "issue.updated" && readString(details?.source) === "comment")) &&
((actorType === "user" && !!currentActor.userId && actorId === currentActor.userId) ||
(actorType === "agent" && !!currentActor.agentId && actorId === currentActor.agentId));
const visibleIssueAgentActivity =
!!options?.pathname &&
shouldDeferIssueRefetchForVisibleAgentActivity(
queryClient,
options.pathname,
payload,
{ isForegrounded: options.isForegrounded },
);
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details);
for (const ref of issueRefs) {
const invalidationOptions = selfCommentActivity ? { refetchType: "inactive" as const } : undefined;
const invalidationOptions =
(selfCommentActivity || visibleIssueAgentActivity)
? { refetchType: "inactive" as const }
: undefined;
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref), ...invalidationOptions });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref), ...invalidationOptions });
if (action === "issue.comment_added") {
@@ -655,7 +690,7 @@ function handleLiveEvent(
}
if (event.type === "activity.logged") {
invalidateActivityQueries(queryClient, expectedCompanyId, payload, currentActor);
invalidateActivityQueries(queryClient, expectedCompanyId, payload, currentActor, { pathname });
const action = readString(payload.action);
const toast =
buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ??
@@ -714,6 +749,7 @@ export const __liveUpdatesTestUtils = {
closeSocketQuietly,
invalidateActivityQueries,
resolveLiveCompanyId,
shouldDeferIssueRefetchForVisibleAgentActivity,
shouldSuppressActivityToastForVisibleIssue,
shouldSuppressRunStatusToastForVisibleIssue,
shouldSuppressAgentStatusToastForVisibleIssue,
+1 -36
View File
@@ -56,7 +56,6 @@ import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils"
import { ApprovalCard } from "../components/ApprovalCard";
import { InlineEditor } from "../components/InlineEditor";
import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread";
import { useLiveRunTranscripts } from "../components/transcript/useLiveRunTranscripts";
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
import { IssuesList } from "../components/IssuesList";
import { IssueProperties } from "../components/IssueProperties";
@@ -79,7 +78,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatIssueActivityAction } from "@/lib/activity-format";
import { buildIssuePropertiesPanelKey } from "../lib/issue-properties-panel-key";
import { shouldRenderRichSubIssuesSection } from "../lib/issue-detail-subissues";
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
import {
Activity as ActivityIcon,
@@ -485,7 +483,6 @@ export function IssueDetail() {
const [galleryIndex, setGalleryIndex] = useState(0);
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0);
const [issueChatInitialTranscriptReady, setIssueChatInitialTranscriptReady] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
const commentComposerRef = useRef<IssueChatComposerHandle | null>(null);
@@ -509,10 +506,6 @@ export function IssueDetail() {
[issueHeaderSeed, issueId, queryClient],
);
useEffect(() => {
setIssueChatInitialTranscriptReady(false);
}, [issueId]);
const { data: issue, isLoading, error } = useQuery({
queryKey: queryKeys.issues.detail(issueId!),
queryFn: () => fetchIssueDetail(queryClient, issueId!),
@@ -694,24 +687,6 @@ export function IssueDetail() {
for (const a of agents ?? []) map.set(a.id, a);
return map;
}, [agents]);
const transcriptRuns = useMemo(
() =>
resolveIssueChatTranscriptRuns({
linkedRuns: timelineRuns,
liveRuns: liveRuns ?? [],
activeRun,
}),
[activeRun, liveRuns, timelineRuns],
);
const {
transcriptByRun: issueChatTranscriptByRun,
hasOutputForRun: issueChatHasOutputForRun,
isInitialHydrating: issueChatTranscriptHydrating,
} = useLiveRunTranscripts({
runs: transcriptRuns,
companyId: issue?.companyId ?? selectedCompanyId,
});
const mentionOptions = useMemo<MentionOption[]>(() => {
const options: MentionOption[] = [];
const activeAgents = [...(agents ?? [])]
@@ -1803,14 +1778,7 @@ export function IssueDetail() {
|| (linkedRunsLoading && linkedRuns === undefined)
|| (liveRunsLoading && liveRuns === undefined)
|| (activeRunLoading && activeRun === undefined);
useEffect(() => {
if (issueChatInitialTranscriptReady) return;
if (issueChatCoreInitialLoading || issueChatTranscriptHydrating) return;
setIssueChatInitialTranscriptReady(true);
}, [issueChatCoreInitialLoading, issueChatInitialTranscriptReady, issueChatTranscriptHydrating]);
const issueChatInitialLoading =
issueChatCoreInitialLoading
|| (!issueChatInitialTranscriptReady && issueChatTranscriptHydrating);
const issueChatInitialLoading = issueChatCoreInitialLoading;
const activityInitialLoading =
(activityLoading && activity === undefined)
|| (linkedRunsLoading && linkedRuns === undefined);
@@ -2371,9 +2339,6 @@ export function IssueDetail() {
issueStatus={issue.status}
agentMap={agentMap}
currentUserId={currentUserId}
enableLiveTranscriptPolling={false}
transcriptsByRunId={issueChatTranscriptByRun}
hasOutputForRun={issueChatHasOutputForRun}
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
enableReassign
reassignOptions={commentReassignOptions}