Suppress same-page issue toasts
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
bdecb1bad2
commit
0b960b0739
2 changed files with 225 additions and 5 deletions
|
|
@ -32,3 +32,85 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LiveUpdatesProvider visible issue toast suppression", () => {
|
||||
it("suppresses activity toasts for the issue page currently in view", () => {
|
||||
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",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.shouldSuppressActivityToastForVisibleIssue(
|
||||
queryClient as never,
|
||||
"/PAP/issues/PAP-759",
|
||||
{
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
details: { identifier: "PAP-759" },
|
||||
},
|
||||
{ isForegrounded: true },
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.shouldSuppressActivityToastForVisibleIssue(
|
||||
queryClient as never,
|
||||
"/PAP/issues/PAP-759",
|
||||
{
|
||||
entityType: "issue",
|
||||
entityId: "issue-2",
|
||||
details: { identifier: "PAP-760" },
|
||||
},
|
||||
{ isForegrounded: true },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses run and agent status toasts for the assignee of the visible issue", () => {
|
||||
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",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(
|
||||
queryClient as never,
|
||||
"/PAP/issues/PAP-759",
|
||||
{
|
||||
runId: "run-1",
|
||||
agentId: "agent-1",
|
||||
},
|
||||
{ isForegrounded: true },
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.shouldSuppressAgentStatusToastForVisibleIssue(
|
||||
queryClient as never,
|
||||
"/PAP/issues/PAP-759",
|
||||
{
|
||||
agentId: "agent-1",
|
||||
status: "running",
|
||||
},
|
||||
{ isForegrounded: true },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
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 type { RunForIssue } from "../api/activity";
|
||||
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||||
import { authApi } from "../api/auth";
|
||||
import { useCompany } from "./CompanyContext";
|
||||
import type { ToastInput } from "./ToastContext";
|
||||
import { useToast } from "./ToastContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { toCompanyRelativePath } from "../lib/company-routes";
|
||||
import { useLocation } from "../lib/router";
|
||||
|
||||
const TOAST_COOLDOWN_WINDOW_MS = 10_000;
|
||||
const TOAST_COOLDOWN_MAX = 3;
|
||||
|
|
@ -63,6 +67,16 @@ interface IssueToastContext {
|
|||
href: string;
|
||||
}
|
||||
|
||||
interface VisibleRouteOptions {
|
||||
isForegrounded?: boolean;
|
||||
}
|
||||
|
||||
interface VisibleIssueRouteContext {
|
||||
issueRefs: Set<string>;
|
||||
assigneeAgentId: string | null;
|
||||
runIds: Set<string>;
|
||||
}
|
||||
|
||||
function resolveIssueQueryRefs(
|
||||
queryClient: QueryClient,
|
||||
companyId: string,
|
||||
|
|
@ -125,6 +139,110 @@ function resolveIssueToastContext(
|
|||
};
|
||||
}
|
||||
|
||||
function isPageForegrounded(): boolean {
|
||||
if (typeof document === "undefined") return false;
|
||||
if (document.visibilityState !== "visible") return false;
|
||||
if (typeof document.hasFocus === "function" && !document.hasFocus()) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveVisibleIssueRouteContext(
|
||||
queryClient: QueryClient,
|
||||
pathname: string,
|
||||
options?: VisibleRouteOptions,
|
||||
): VisibleIssueRouteContext | null {
|
||||
const isForegrounded = options?.isForegrounded ?? isPageForegrounded();
|
||||
if (!isForegrounded) return null;
|
||||
|
||||
const relativePath = toCompanyRelativePath(pathname);
|
||||
const segments = relativePath.split("/").filter(Boolean);
|
||||
if (segments[0] !== "issues" || !segments[1]) return null;
|
||||
|
||||
const issueRef = decodeURIComponent(segments[1]);
|
||||
const issue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueRef)) ?? null;
|
||||
const issueRefs = new Set<string>([issueRef]);
|
||||
if (issue?.id) issueRefs.add(issue.id);
|
||||
if (issue?.identifier) issueRefs.add(issue.identifier);
|
||||
|
||||
const runIds = new Set<string>();
|
||||
const activeRun = queryClient.getQueryData<ActiveRunForIssue | null>(queryKeys.issues.activeRun(issueRef));
|
||||
const liveRuns = queryClient.getQueryData<LiveRunForIssue[]>(queryKeys.issues.liveRuns(issueRef)) ?? [];
|
||||
const linkedRuns = queryClient.getQueryData<RunForIssue[]>(queryKeys.issues.runs(issueRef)) ?? [];
|
||||
|
||||
if (activeRun?.id) runIds.add(activeRun.id);
|
||||
for (const run of liveRuns) {
|
||||
if (run.id) runIds.add(run.id);
|
||||
}
|
||||
for (const run of linkedRuns) {
|
||||
if (run.runId) runIds.add(run.runId);
|
||||
}
|
||||
|
||||
return {
|
||||
issueRefs,
|
||||
assigneeAgentId: issue?.assigneeAgentId ?? null,
|
||||
runIds,
|
||||
};
|
||||
}
|
||||
|
||||
function buildIssueRefsForPayload(entityId: string, details: Record<string, unknown> | null): Set<string> {
|
||||
const refs = new Set<string>([entityId]);
|
||||
const identifier = readString(details?.identifier) ?? readString(details?.issueIdentifier);
|
||||
if (identifier) refs.add(identifier);
|
||||
return refs;
|
||||
}
|
||||
|
||||
function overlaps(a: Set<string>, b: Set<string>): boolean {
|
||||
for (const value of a) {
|
||||
if (b.has(value)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldSuppressActivityToastForVisibleIssue(
|
||||
queryClient: QueryClient,
|
||||
pathname: string,
|
||||
payload: Record<string, unknown>,
|
||||
options?: VisibleRouteOptions,
|
||||
): boolean {
|
||||
const entityType = readString(payload.entityType);
|
||||
const entityId = readString(payload.entityId);
|
||||
if (entityType !== "issue" || !entityId) return false;
|
||||
|
||||
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
|
||||
if (!context) return false;
|
||||
|
||||
return overlaps(context.issueRefs, buildIssueRefsForPayload(entityId, readRecord(payload.details)));
|
||||
}
|
||||
|
||||
function shouldSuppressRunStatusToastForVisibleIssue(
|
||||
queryClient: QueryClient,
|
||||
pathname: string,
|
||||
payload: Record<string, unknown>,
|
||||
options?: VisibleRouteOptions,
|
||||
): boolean {
|
||||
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
|
||||
if (!context) return false;
|
||||
|
||||
const runId = readString(payload.runId);
|
||||
if (runId && context.runIds.has(runId)) return true;
|
||||
|
||||
const agentId = readString(payload.agentId);
|
||||
return !!agentId && !!context.assigneeAgentId && agentId === context.assigneeAgentId;
|
||||
}
|
||||
|
||||
function shouldSuppressAgentStatusToastForVisibleIssue(
|
||||
queryClient: QueryClient,
|
||||
pathname: string,
|
||||
payload: Record<string, unknown>,
|
||||
options?: VisibleRouteOptions,
|
||||
): boolean {
|
||||
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
|
||||
if (!context?.assigneeAgentId) return false;
|
||||
|
||||
const agentId = readString(payload.agentId);
|
||||
return !!agentId && agentId === context.assigneeAgentId;
|
||||
}
|
||||
|
||||
const ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]);
|
||||
const AGENT_TOAST_STATUSES = new Set(["running", "error"]);
|
||||
const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled"]);
|
||||
|
|
@ -470,6 +588,7 @@ function gatedPushToast(
|
|||
function handleLiveEvent(
|
||||
queryClient: QueryClient,
|
||||
expectedCompanyId: string,
|
||||
pathname: string,
|
||||
event: LiveEvent,
|
||||
pushToast: (toast: ToastInput) => string | null,
|
||||
gate: ToastGate,
|
||||
|
|
@ -487,7 +606,12 @@ function handleLiveEvent(
|
|||
invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload);
|
||||
if (event.type === "heartbeat.run.status") {
|
||||
const toast = buildRunStatusToast(payload, nameOf);
|
||||
if (toast) gatedPushToast(gate, pushToast, "run-status", toast);
|
||||
if (
|
||||
toast &&
|
||||
!shouldSuppressRunStatusToastForVisibleIssue(queryClient, pathname, payload)
|
||||
) {
|
||||
gatedPushToast(gate, pushToast, "run-status", toast);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -503,7 +627,12 @@ function handleLiveEvent(
|
|||
const agentId = readString(payload.agentId);
|
||||
if (agentId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
|
||||
const toast = buildAgentStatusToast(payload, nameOf, queryClient, expectedCompanyId);
|
||||
if (toast) gatedPushToast(gate, pushToast, "agent-status", toast);
|
||||
if (
|
||||
toast &&
|
||||
!shouldSuppressAgentStatusToastForVisibleIssue(queryClient, pathname, payload)
|
||||
) {
|
||||
gatedPushToast(gate, pushToast, "agent-status", toast);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -513,18 +642,27 @@ function handleLiveEvent(
|
|||
const toast =
|
||||
buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ??
|
||||
buildJoinRequestToast(payload);
|
||||
if (toast) gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast);
|
||||
if (
|
||||
toast &&
|
||||
!shouldSuppressActivityToastForVisibleIssue(queryClient, pathname, payload)
|
||||
) {
|
||||
gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const __liveUpdatesTestUtils = {
|
||||
invalidateActivityQueries,
|
||||
shouldSuppressActivityToastForVisibleIssue,
|
||||
shouldSuppressRunStatusToastForVisibleIssue,
|
||||
shouldSuppressAgentStatusToastForVisibleIssue,
|
||||
};
|
||||
|
||||
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const location = useLocation();
|
||||
const gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 });
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
|
|
@ -577,7 +715,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as LiveEvent;
|
||||
handleLiveEvent(queryClient, selectedCompanyId, parsed, pushToast, gateRef.current, {
|
||||
handleLiveEvent(queryClient, selectedCompanyId, location.pathname, parsed, pushToast, gateRef.current, {
|
||||
userId: currentUserId,
|
||||
agentId: null,
|
||||
});
|
||||
|
|
@ -609,7 +747,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
|||
socket.close(1000, "provider_unmount");
|
||||
}
|
||||
};
|
||||
}, [queryClient, selectedCompanyId, pushToast, currentUserId]);
|
||||
}, [queryClient, selectedCompanyId, pushToast, currentUserId, location.pathname]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue