feat: toast dedupe alignment, per-type cooldown, and reconnect suppression
- Align local mutation dedupe keys with live event keys so the same action doesn't produce two toasts (local success + live event) - Add per-type cooldown gate (max 3 toasts per category in 10s) to suppress rapid-fire events from chatty sources - Suppress all live-event toasts for 2s after WebSocket reconnect to avoid burst floods from cached server events - TTL tuning by severity already applied externally (info=4s, success=3.5s, warn=8s, error=10s) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -126,7 +126,7 @@ export function NewIssueDialog() {
|
|||||||
reset();
|
reset();
|
||||||
closeNewIssue();
|
closeNewIssue();
|
||||||
pushToast({
|
pushToast({
|
||||||
dedupeKey: `issue-created-${issue.id}`,
|
dedupeKey: `activity:issue.created:${issue.id}`,
|
||||||
title: `${issue.identifier ?? "Issue"} created`,
|
title: `${issue.identifier ?? "Issue"} created`,
|
||||||
body: issue.title,
|
body: issue.title,
|
||||||
tone: "success",
|
tone: "success",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, type ReactNode } from "react";
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { LiveEvent } from "@paperclip/shared";
|
import type { LiveEvent } from "@paperclip/shared";
|
||||||
import { useCompany } from "./CompanyContext";
|
import { useCompany } from "./CompanyContext";
|
||||||
@@ -6,6 +6,10 @@ import type { ToastInput } from "./ToastContext";
|
|||||||
import { useToast } from "./ToastContext";
|
import { useToast } from "./ToastContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
|
||||||
|
const TOAST_COOLDOWN_WINDOW_MS = 10_000;
|
||||||
|
const TOAST_COOLDOWN_MAX = 3;
|
||||||
|
const RECONNECT_SUPPRESS_MS = 2000;
|
||||||
|
|
||||||
function readString(value: unknown): string | null {
|
function readString(value: unknown): string | null {
|
||||||
return typeof value === "string" && value.length > 0 ? value : null;
|
return typeof value === "string" && value.length > 0 ? value : null;
|
||||||
}
|
}
|
||||||
@@ -198,11 +202,47 @@ function invalidateActivityQueries(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ToastGate {
|
||||||
|
cooldownHits: Map<string, number[]>;
|
||||||
|
suppressUntil: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSuppressToast(gate: ToastGate, category: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now < gate.suppressUntil) return true;
|
||||||
|
|
||||||
|
const hits = gate.cooldownHits.get(category);
|
||||||
|
if (!hits) return false;
|
||||||
|
|
||||||
|
const recent = hits.filter((t) => now - t < TOAST_COOLDOWN_WINDOW_MS);
|
||||||
|
gate.cooldownHits.set(category, recent);
|
||||||
|
return recent.length >= TOAST_COOLDOWN_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordToastHit(gate: ToastGate, category: string) {
|
||||||
|
const now = Date.now();
|
||||||
|
const hits = gate.cooldownHits.get(category) ?? [];
|
||||||
|
hits.push(now);
|
||||||
|
gate.cooldownHits.set(category, hits);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gatedPushToast(
|
||||||
|
gate: ToastGate,
|
||||||
|
pushToast: (toast: ToastInput) => string | null,
|
||||||
|
category: string,
|
||||||
|
toast: ToastInput,
|
||||||
|
) {
|
||||||
|
if (shouldSuppressToast(gate, category)) return;
|
||||||
|
const id = pushToast(toast);
|
||||||
|
if (id !== null) recordToastHit(gate, category);
|
||||||
|
}
|
||||||
|
|
||||||
function handleLiveEvent(
|
function handleLiveEvent(
|
||||||
queryClient: ReturnType<typeof useQueryClient>,
|
queryClient: ReturnType<typeof useQueryClient>,
|
||||||
expectedCompanyId: string,
|
expectedCompanyId: string,
|
||||||
event: LiveEvent,
|
event: LiveEvent,
|
||||||
pushToast: (toast: ToastInput) => string | null,
|
pushToast: (toast: ToastInput) => string | null,
|
||||||
|
gate: ToastGate,
|
||||||
) {
|
) {
|
||||||
if (event.companyId !== expectedCompanyId) return;
|
if (event.companyId !== expectedCompanyId) return;
|
||||||
|
|
||||||
@@ -215,7 +255,7 @@ 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);
|
const toast = buildRunStatusToast(payload);
|
||||||
if (toast) pushToast(toast);
|
if (toast) gatedPushToast(gate, pushToast, "run-status", toast);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -231,14 +271,15 @@ 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);
|
const toast = buildAgentStatusToast(payload);
|
||||||
if (toast) pushToast(toast);
|
if (toast) gatedPushToast(gate, pushToast, "agent-status", toast);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "activity.logged") {
|
if (event.type === "activity.logged") {
|
||||||
invalidateActivityQueries(queryClient, expectedCompanyId, payload);
|
invalidateActivityQueries(queryClient, expectedCompanyId, payload);
|
||||||
|
const action = readString(payload.action);
|
||||||
const toast = buildActivityToast(payload);
|
const toast = buildActivityToast(payload);
|
||||||
if (toast) pushToast(toast);
|
if (toast) gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +287,7 @@ 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 gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCompanyId) return;
|
if (!selectedCompanyId) return;
|
||||||
@@ -279,6 +321,9 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
|||||||
socket = new WebSocket(url);
|
socket = new WebSocket(url);
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
|
if (reconnectAttempt > 0) {
|
||||||
|
gateRef.current.suppressUntil = Date.now() + RECONNECT_SUPPRESS_MS;
|
||||||
|
}
|
||||||
reconnectAttempt = 0;
|
reconnectAttempt = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -288,7 +333,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);
|
handleLiveEvent(queryClient, selectedCompanyId, parsed, pushToast, gateRef.current);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore non-JSON payloads.
|
// Ignore non-JSON payloads.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ export function IssueDetail() {
|
|||||||
onSuccess: (updated) => {
|
onSuccess: (updated) => {
|
||||||
invalidateIssue();
|
invalidateIssue();
|
||||||
pushToast({
|
pushToast({
|
||||||
dedupeKey: `issue-updated-${updated.id}`,
|
dedupeKey: `activity:issue.updated:${updated.id}`,
|
||||||
title: "Issue updated",
|
title: "Issue updated",
|
||||||
tone: "success",
|
tone: "success",
|
||||||
});
|
});
|
||||||
@@ -272,7 +272,7 @@ export function IssueDetail() {
|
|||||||
invalidateIssue();
|
invalidateIssue();
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||||
pushToast({
|
pushToast({
|
||||||
dedupeKey: `issue-comment-${issueId}`,
|
dedupeKey: `activity:issue.comment_added:${issueId}`,
|
||||||
title: "Comment posted",
|
title: "Comment posted",
|
||||||
tone: "success",
|
tone: "success",
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user