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 { useEffect, useRef, type ReactNode } from "react";
|
||||||
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||||
import type { Agent, Issue, LiveEvent } from "@paperclipai/shared";
|
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 { authApi } from "../api/auth";
|
||||||
import { useCompany } from "./CompanyContext";
|
import { useCompany } from "./CompanyContext";
|
||||||
import type { ToastInput } from "./ToastContext";
|
import type { ToastInput } from "./ToastContext";
|
||||||
import { useToast } from "./ToastContext";
|
import { useToast } from "./ToastContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
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_WINDOW_MS = 10_000;
|
||||||
const TOAST_COOLDOWN_MAX = 3;
|
const TOAST_COOLDOWN_MAX = 3;
|
||||||
|
|
@ -63,6 +67,16 @@ interface IssueToastContext {
|
||||||
href: string;
|
href: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface VisibleRouteOptions {
|
||||||
|
isForegrounded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VisibleIssueRouteContext {
|
||||||
|
issueRefs: Set<string>;
|
||||||
|
assigneeAgentId: string | null;
|
||||||
|
runIds: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveIssueQueryRefs(
|
function resolveIssueQueryRefs(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
companyId: string,
|
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 ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]);
|
||||||
const AGENT_TOAST_STATUSES = new Set(["running", "error"]);
|
const AGENT_TOAST_STATUSES = new Set(["running", "error"]);
|
||||||
const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled"]);
|
const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled"]);
|
||||||
|
|
@ -470,6 +588,7 @@ function gatedPushToast(
|
||||||
function handleLiveEvent(
|
function handleLiveEvent(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
expectedCompanyId: string,
|
expectedCompanyId: string,
|
||||||
|
pathname: string,
|
||||||
event: LiveEvent,
|
event: LiveEvent,
|
||||||
pushToast: (toast: ToastInput) => string | null,
|
pushToast: (toast: ToastInput) => string | null,
|
||||||
gate: ToastGate,
|
gate: ToastGate,
|
||||||
|
|
@ -487,7 +606,12 @@ function handleLiveEvent(
|
||||||
invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload);
|
invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload);
|
||||||
if (event.type === "heartbeat.run.status") {
|
if (event.type === "heartbeat.run.status") {
|
||||||
const toast = buildRunStatusToast(payload, nameOf);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -503,7 +627,12 @@ function handleLiveEvent(
|
||||||
const agentId = readString(payload.agentId);
|
const agentId = readString(payload.agentId);
|
||||||
if (agentId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
|
if (agentId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
|
||||||
const toast = buildAgentStatusToast(payload, nameOf, queryClient, expectedCompanyId);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -513,18 +642,27 @@ function handleLiveEvent(
|
||||||
const toast =
|
const toast =
|
||||||
buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ??
|
buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ??
|
||||||
buildJoinRequestToast(payload);
|
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 = {
|
export const __liveUpdatesTestUtils = {
|
||||||
invalidateActivityQueries,
|
invalidateActivityQueries,
|
||||||
|
shouldSuppressActivityToastForVisibleIssue,
|
||||||
|
shouldSuppressRunStatusToastForVisibleIssue,
|
||||||
|
shouldSuppressAgentStatusToastForVisibleIssue,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { pushToast } = useToast();
|
const { pushToast } = useToast();
|
||||||
|
const location = useLocation();
|
||||||
const gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 });
|
const gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 });
|
||||||
const { data: session } = useQuery({
|
const { data: session } = useQuery({
|
||||||
queryKey: queryKeys.auth.session,
|
queryKey: queryKeys.auth.session,
|
||||||
|
|
@ -577,7 +715,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as LiveEvent;
|
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,
|
userId: currentUserId,
|
||||||
agentId: null,
|
agentId: null,
|
||||||
});
|
});
|
||||||
|
|
@ -609,7 +747,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||||
socket.close(1000, "provider_unmount");
|
socket.close(1000, "provider_unmount");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [queryClient, selectedCompanyId, pushToast, currentUserId]);
|
}, [queryClient, selectedCompanyId, pushToast, currentUserId, location.pathname]);
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue