diff --git a/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx b/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx
index 349cc74b3a..1db788b5bc 100644
--- a/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx
+++ b/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import { RuntimeFallbackBadge } from "../RuntimeFallbackBadge";
import { ToastProvider, useToast } from "../../hooks/useToast";
+import { __resetRuntimeFallbackToastDedupeStoreForTests } from "../../hooks/useRuntimeFallbackStatus";
import type { TaskRuntimeFallbackResponse } from "../../api/legacy";
const legacyMocks = vi.hoisted(() => ({
@@ -88,6 +89,10 @@ describe("RuntimeFallbackBadge", () => {
beforeEach(() => {
vi.useFakeTimers();
legacyMocks.fetchTaskRuntimeFallback.mockReset();
+ // The toast dedupe store is module-level/shared by design (that is the
+ // fix under test) — reset it between test cases so one test's "already
+ // toasted" state does not leak into the next.
+ __resetRuntimeFallbackToastDedupeStoreForTests();
});
afterEach(() => {
@@ -238,12 +243,46 @@ describe("RuntimeFallbackBadge", () => {
expect(legacyMocks.fetchTaskRuntimeFallback).toHaveBeenCalled();
expect(screen.getByTestId("runtime-fallback-badge")).toBeInTheDocument();
});
+
+ it("fires exactly one toast when the same task/event is observed by two simultaneously-mounted badge instances (e.g. ActiveAgentsPanel + AgentsView rendering the same task concurrently)", async () => {
+ legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackWithHint);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
+
+ // Both instances polled and both observed the same new eventId on their
+ // first poll, but the shared module-level dedupe store means only one of
+ // them should have won the "claim" and fired a toast.
+ expect(screen.getAllByTestId("toast-entry")).toHaveLength(1);
+ expect(screen.getAllByTestId("toast-entry")[0].textContent).toContain("hermes");
+
+ // Both badges still render independently (dedupe only affects the toast,
+ // not the per-instance badge display).
+ expect(screen.getAllByTestId("runtime-fallback-badge")).toHaveLength(2);
+
+ // Further polls with the same event on both instances must not add a
+ // second toast either.
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(30_000);
+ });
+ expect(screen.getAllByTestId("toast-entry")).toHaveLength(1);
+ });
});
describe("RuntimeFallbackBadge — mobile breakpoint", () => {
beforeEach(() => {
vi.useFakeTimers();
legacyMocks.fetchTaskRuntimeFallback.mockReset();
+ __resetRuntimeFallbackToastDedupeStoreForTests();
});
afterEach(() => {
diff --git a/packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts b/packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts
index 9a876f4978..cf65dff81e 100644
--- a/packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts
+++ b/packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts
@@ -14,11 +14,65 @@
* This hook only polls while `enabled` is true (callers should pass
* `isInViewport` so off-screen cards do not generate background traffic).
*/
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useState } from "react";
import { fetchTaskRuntimeFallback, type TaskRuntimeFallbackResponse } from "../api/legacy";
const POLL_INTERVAL_MS = 30_000;
+// Toast dedupe must be shared across ALL hook instances in the process, not
+// scoped per-instance: the same task/event can be observed simultaneously by
+// multiple mounted badges (e.g. ActiveAgentsPanel + AgentsView board/list +
+// TaskCard all rendering the same in-progress task at once), each running
+// its own useRuntimeFallbackStatus() call. A per-instance ref only dedupes
+// within one component instance's own poll history, so the same eventId
+// would independently look "newly observed" to every instance and fire one
+// toast each. Module-level state is shared across every call site because
+// there is exactly one copy of this module per process/bundle.
+//
+// Keyed by `${taskId}:${eventId}` (not eventId alone) so ids are unambiguous
+// even if two different tasks' audit logs ever produced colliding event ids.
+// Bounded via a simple FIFO eviction (insertion order === Map iteration
+// order) so a long-lived dashboard session touching many tasks over many
+// hours cannot grow this unboundedly; runtime-fallback events are rare
+// (at most one per agent session), so a few hundred entries comfortably
+// covers realistic session lengths without needing TTL bookkeeping.
+const MAX_TOASTED_EVENTS = 500;
+const toastedEventKeys = new Map();
+
+function toastKey(taskId: string, eventId: string): string {
+ return `${taskId}:${eventId}`;
+}
+
+/**
+ * Returns true and records the key the first time it is seen; returns false
+ * on every subsequent call for the same key, regardless of which hook
+ * instance/component asks. This is the single shared gate all simultaneously
+ * mounted badge instances for the same task funnel through.
+ */
+function claimToastOnce(taskId: string, eventId: string): boolean {
+ const key = toastKey(taskId, eventId);
+ if (toastedEventKeys.has(key)) {
+ return false;
+ }
+ toastedEventKeys.set(key, true);
+ if (toastedEventKeys.size > MAX_TOASTED_EVENTS) {
+ const oldestKey = toastedEventKeys.keys().next().value;
+ if (oldestKey !== undefined) {
+ toastedEventKeys.delete(oldestKey);
+ }
+ }
+ return true;
+}
+
+/**
+ * Test-only escape hatch: clears the shared module-level dedupe store between
+ * test cases so one test's "already toasted" state cannot leak into the
+ * next. Not used by production code paths.
+ */
+export function __resetRuntimeFallbackToastDedupeStoreForTests(): void {
+ toastedEventKeys.clear();
+}
+
export interface RuntimeFallbackStatus {
/** True only when the latest resolution has wasConfigured=false and a non-empty runtimeHint. */
showBadge: boolean;
@@ -55,10 +109,6 @@ export function useRuntimeFallbackStatus(
projectId?: string,
): RuntimeFallbackStatus {
const [status, setStatus] = useState(IDLE_STATUS);
- // Dedupe key for toasts: last audit event ID we already toasted for. Persists across
- // polls/re-renders for the lifetime of the component so the toast fires exactly once
- // per newly-observed fallback session, not on every poll.
- const lastToastedEventIdRef = useRef(null);
useEffect(() => {
if (!enabled || !taskId) {
@@ -83,10 +133,10 @@ export function useRuntimeFallbackStatus(
return;
}
- const isNewlyObserved = data.eventId !== null && data.eventId !== lastToastedEventIdRef.current;
- if (isNewlyObserved && data.eventId) {
- lastToastedEventIdRef.current = data.eventId;
- }
+ // Dedupe against the shared module-level store (not a per-instance ref)
+ // so a fallback event toasts exactly once across every simultaneously
+ // mounted badge instance for this task, not once per instance.
+ const isNewlyObserved = data.eventId !== null && taskId !== undefined && claimToastOnce(taskId, data.eventId);
setStatus({
showBadge: true,