diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 4192f81f..620cf6ca 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -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); + }); +}); diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 00b2ab36..6acb9af8 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -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; + assigneeAgentId: string | null; + runIds: Set; +} + 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(queryKeys.issues.detail(issueRef)) ?? null; + const issueRefs = new Set([issueRef]); + if (issue?.id) issueRefs.add(issue.id); + if (issue?.identifier) issueRefs.add(issue.identifier); + + const runIds = new Set(); + const activeRun = queryClient.getQueryData(queryKeys.issues.activeRun(issueRef)); + const liveRuns = queryClient.getQueryData(queryKeys.issues.liveRuns(issueRef)) ?? []; + const linkedRuns = queryClient.getQueryData(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 | null): Set { + const refs = new Set([entityId]); + const identifier = readString(details?.identifier) ?? readString(details?.issueIdentifier); + if (identifier) refs.add(identifier); + return refs; +} + +function overlaps(a: Set, b: Set): boolean { + for (const value of a) { + if (b.has(value)) return true; + } + return false; +} + +function shouldSuppressActivityToastForVisibleIssue( + queryClient: QueryClient, + pathname: string, + payload: Record, + 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, + 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, + 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({ 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}; }