diff --git a/src/components/notifications/NotificationBell.test.tsx b/src/components/notifications/NotificationBell.test.tsx
index e5db931ef..f198b57a2 100644
--- a/src/components/notifications/NotificationBell.test.tsx
+++ b/src/components/notifications/NotificationBell.test.tsx
@@ -1,5 +1,5 @@
import { test, expect, afterEach, beforeEach, vi } from "vitest";
-import { act, cleanup, render, screen } from "@testing-library/react";
+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { NotificationBell } from "./NotificationBell";
import { useNotificationStore } from "@/stores/notificationStore";
import { useUIStore } from "@/stores/uiStore";
@@ -67,3 +67,71 @@ test("an id no longer in the inbox still leaves the popover open", () => {
expect(screen.getByText("notifications.bell.clearHistory")).toBeTruthy();
expect(useUIStore.getState().notificationFocusId).toBeNull();
});
+
+// The mobile shell swaps the foreground tab's bell while the SFTP tab's bell
+// stays mounted behind `invisible`.
+function TwoBells({ hostsTab }: { hostsTab: boolean }) {
+ return (
+ <>
+ {hostsTab &&
}
+
+ >
+ );
+}
+
+function stubHiddenSftpBell(hidden: () => boolean) {
+ const real = window.getComputedStyle;
+ window.getComputedStyle = ((el: Element) =>
+ el instanceof HTMLButtonElement && el.closest("[data-testid='sftp']") && hidden()
+ ? ({ visibility: "hidden" } as CSSStyleDeclaration)
+ : real(el)) as typeof window.getComputedStyle;
+ return () => {
+ window.getComputedStyle = real;
+ };
+}
+
+const popovers = () => screen.queryAllByText("notifications.bell.clearHistory").length;
+
+test("only the visible bell paints while both are mounted", () => {
+ const restore = stubHiddenSftpBell(() => true);
+ try {
+ render();
+ act(() => useUIStore.getState().openNotificationCenter(null));
+ expect(popovers()).toBe(1);
+ } finally {
+ restore();
+ }
+});
+
+test("the popover follows the bell a tab switch makes visible", () => {
+ let sftpHidden = true;
+ const restore = stubHiddenSftpBell(() => sftpHidden);
+ try {
+ const { rerender } = render();
+ act(() => useUIStore.getState().openNotificationCenter(null));
+ expect(popovers()).toBe(1);
+
+ sftpHidden = false;
+ rerender();
+
+ expect(popovers()).toBe(1);
+ fireEvent.click(screen.getAllByRole("button")[0]);
+ expect(useUIStore.getState().notificationCenterOpen).toBe(false);
+ } finally {
+ restore();
+ }
+});
+
+test("a link raised with no visible bell paints once one mounts", () => {
+ const restore = stubHiddenSftpBell(() => true);
+ try {
+ const { rerender } = render();
+ act(() => useUIStore.getState().openNotificationCenter(null));
+ expect(popovers()).toBe(0);
+
+ rerender();
+ expect(popovers()).toBe(1);
+ } finally {
+ restore();
+ }
+});
diff --git a/src/components/notifications/NotificationBell.tsx b/src/components/notifications/NotificationBell.tsx
index ef8d86475..62a2d6bb0 100644
--- a/src/components/notifications/NotificationBell.tsx
+++ b/src/components/notifications/NotificationBell.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n";
@@ -21,6 +21,26 @@ const SEVERITY_COLORS: Record = {
error: "var(--t-status-error)",
};
+// Which bell is on screen changes when bells mount and unmount: the mobile
+// shell swaps the foreground tab's bell while the SFTP tab's stays mounted
+// behind `invisible`. Bells re-measure on this signal so the popover follows
+// the bell the user can actually see instead of staying with the one that was
+// visible when it opened.
+let mountVersion = 0;
+const mountListeners = new Set<() => void>();
+
+function bumpMounts() {
+ mountVersion += 1;
+ for (const listener of mountListeners) listener();
+}
+
+function subscribeMounts(listener: () => void) {
+ mountListeners.add(listener);
+ return () => {
+ mountListeners.delete(listener);
+ };
+}
+
function relativeTime(ms: number): string {
const diff = Date.now() - ms;
if (diff < 60_000) return i18n.t("notifications.bell.relativeTime.justNow");
@@ -165,6 +185,12 @@ export function NotificationBell() {
const buttonRef = useRef(null);
const dropdownRef = useRef(null);
+ const mounts = useSyncExternalStore(subscribeMounts, () => mountVersion);
+ useEffect(() => {
+ bumpMounts();
+ return bumpMounts;
+ }, []);
+
// Placement is measured on every open, not on click: a deep link opens the
// popover with no pointer event to measure from.
//
@@ -182,7 +208,7 @@ export function NotificationBell() {
}
const rect = button.getBoundingClientRect();
setPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
- }, [open]);
+ }, [open, mounts]);
// A stale id is normal — inbox entries are re-derived, not stored — so a miss
// leaves the popover open on the full list rather than reporting anything.