diff --git a/react-compiler.config.js b/react-compiler.config.js
index 1651641962..f71879296a 100644
--- a/react-compiler.config.js
+++ b/react-compiler.config.js
@@ -42,6 +42,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [
"src/hooks/useContainerLog.ts",
"src/hooks/usePipelineRunList.ts",
"src/hooks/useNotices.ts",
+ "src/hooks/useNoticeInbox.ts",
"src/components/shared/FavoriteToggle.tsx",
"src/components/shared/FloatingSelectionBar.tsx",
"src/components/shared/ComponentLifecycleBadges.tsx",
diff --git a/src/components/layout/AppMenu.tsx b/src/components/layout/AppMenu.tsx
index 195aa373bd..7560158c36 100644
--- a/src/components/layout/AppMenu.tsx
+++ b/src/components/layout/AppMenu.tsx
@@ -12,6 +12,7 @@ import { TopBarAuthentication } from "@/components/shared/Authentication/TopBarA
import { CopyText } from "@/components/shared/CopyText/CopyText";
import { EditorVersionToggle } from "@/components/shared/EditorVersionToggle";
import ImportPipeline from "@/components/shared/ImportPipeline";
+import { NoticeInbox } from "@/components/shared/Notices/NoticeInbox";
import { RunVersionToggle } from "@/components/shared/RunVersionToggle";
import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
@@ -128,6 +129,7 @@ const DefaultAppMenu = () => {
+
{/* Settings & status */}
{isOnSettingsRoute ? (
diff --git a/src/components/shared/Notices/NoticeInbox.test.tsx b/src/components/shared/Notices/NoticeInbox.test.tsx
new file mode 100644
index 0000000000..6849d1a535
--- /dev/null
+++ b/src/components/shared/Notices/NoticeInbox.test.tsx
@@ -0,0 +1,212 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { installSource } from "@/config/noticeTestSource";
+import { resetNoticeInboxForTests } from "@/hooks/useNoticeInbox";
+import { resetNoticeStateForTests } from "@/hooks/useNotices";
+
+import { NoticeInbox } from "./NoticeInbox";
+
+class ResizeObserverMock {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+vi.stubGlobal("ResizeObserver", ResizeObserverMock);
+
+describe("", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ cleanup();
+ delete window.__TANGLE_NOTICE_SOURCE__;
+ resetNoticeInboxForTests();
+ resetNoticeStateForTests();
+ });
+
+ it("stays in the header with nothing to show", () => {
+ render();
+
+ const trigger = screen.getByTestId("notice-inbox-trigger");
+ expect(trigger).toHaveAttribute("aria-label", "Notices, none");
+ expect(screen.queryByTestId("notice-inbox-unread")).not.toBeInTheDocument();
+
+ fireEvent.click(trigger);
+
+ expect(screen.getByTestId("notice-inbox-empty")).toHaveTextContent(
+ "No notices",
+ );
+ expect(screen.queryByTestId("notice-inbox-hide")).not.toBeInTheDocument();
+ });
+
+ it("counts the notices the reader has not opened yet", () => {
+ installSource([
+ { id: "a", title: "One", body: "" },
+ { id: "b", title: "Two", body: "" },
+ ]);
+
+ render();
+
+ expect(screen.getByTestId("notice-inbox-unread")).toHaveTextContent("2");
+ expect(screen.getByTestId("notice-inbox-trigger")).toHaveAttribute(
+ "aria-label",
+ "Notices, 2 unread",
+ );
+ });
+
+ it("lists every notice in full once opened", () => {
+ installSource([
+ { id: "a", title: "One", body: "Body of **one**" },
+ { id: "b", title: "Two", body: "Body of two" },
+ ]);
+
+ render();
+ fireEvent.click(screen.getByTestId("notice-inbox-trigger"));
+
+ expect(screen.getByTestId("notice-inbox")).toBeInTheDocument();
+ expect(screen.getAllByTestId("info-box-title")).toHaveLength(2);
+ expect(screen.getByText("one").tagName).toBe("STRONG");
+ expect(screen.getByText(/Body of two/)).toBeInTheDocument();
+ });
+
+ it("clears the unread count once opened, and keeps it clear across a remount", () => {
+ installSource([{ id: "a", title: "One", body: "" }]);
+
+ render();
+ fireEvent.click(screen.getByTestId("notice-inbox-trigger"));
+
+ expect(screen.queryByTestId("notice-inbox-unread")).not.toBeInTheDocument();
+
+ cleanup();
+ render();
+
+ expect(screen.queryByTestId("notice-inbox-unread")).not.toBeInTheDocument();
+ expect(screen.getByTestId("notice-inbox-trigger")).toHaveAttribute(
+ "aria-label",
+ "Notices, 1 active",
+ );
+ });
+
+ it("lets a dismissible notice be removed from the centre for good", () => {
+ installSource([
+ { id: "a", title: "Optional", body: "", dismissible: true },
+ { id: "b", title: "Mandatory", body: "" },
+ ]);
+
+ render();
+ fireEvent.click(screen.getByTestId("notice-inbox-trigger"));
+
+ expect(screen.getAllByLabelText("Dismiss")).toHaveLength(1);
+ fireEvent.click(screen.getByLabelText("Dismiss"));
+
+ expect(screen.queryByText("Optional")).not.toBeInTheDocument();
+ expect(screen.getByText("Mandatory")).toBeInTheDocument();
+
+ cleanup();
+ render();
+
+ expect(screen.getByTestId("notice-inbox-trigger")).toHaveAttribute(
+ "aria-label",
+ "Notices, 1 active",
+ );
+ });
+
+ it("removes a dismissed notice even when it cannot be persisted", () => {
+ installSource([
+ { id: "a", title: "Optional", body: "", dismissible: true },
+ ]);
+
+ render();
+ fireEvent.click(screen.getByTestId("notice-inbox-trigger"));
+
+ const setItem = vi
+ .spyOn(Storage.prototype, "setItem")
+ .mockImplementation(() => {
+ throw new Error("storage is unavailable");
+ });
+ fireEvent.click(screen.getByLabelText("Dismiss"));
+ setItem.mockRestore();
+
+ expect(screen.queryByText("Optional")).not.toBeInTheDocument();
+ });
+
+ it("clears the unread count for the session even when it cannot be persisted", () => {
+ installSource([{ id: "a", title: "One", body: "" }]);
+
+ const setItem = vi
+ .spyOn(Storage.prototype, "setItem")
+ .mockImplementation(() => {
+ throw new Error("storage is unavailable");
+ });
+
+ render();
+ fireEvent.click(screen.getByTestId("notice-inbox-trigger"));
+
+ expect(screen.queryByTestId("notice-inbox-unread")).not.toBeInTheDocument();
+
+ cleanup();
+ render();
+ setItem.mockRestore();
+
+ expect(screen.queryByTestId("notice-inbox-unread")).not.toBeInTheDocument();
+ });
+
+ it("keeps the centre open and reachable after the last notice goes", () => {
+ installSource([
+ { id: "a", title: "Optional", body: "", dismissible: true },
+ ]);
+
+ render();
+ const trigger = screen.getByTestId("notice-inbox-trigger");
+ fireEvent.click(trigger);
+ fireEvent.click(screen.getByLabelText("Dismiss"));
+
+ expect(screen.getByTestId("notice-inbox-empty")).toHaveTextContent(
+ "No notices",
+ );
+ expect(screen.queryByTestId("notice-inbox-hide")).not.toBeInTheDocument();
+
+ fireEvent.keyDown(screen.getByTestId("notice-inbox"), { key: "Escape" });
+ expect(trigger).toBeInTheDocument();
+
+ fireEvent.click(trigger);
+
+ expect(screen.getByTestId("notice-inbox-empty")).toBeInTheDocument();
+ });
+
+ it("caps the unread badge, keeping the exact count on the trigger", () => {
+ installSource(
+ Array.from({ length: 12 }, (_, index) => ({
+ id: `notice-${index}`,
+ title: `Notice ${index}`,
+ body: "",
+ })),
+ );
+
+ render();
+
+ expect(screen.getByTestId("notice-inbox-unread")).toHaveTextContent("9+");
+ expect(screen.getByTestId("notice-inbox-trigger")).toHaveAttribute(
+ "aria-label",
+ "Notices, 12 unread",
+ );
+ });
+
+ it("orders the list by severity", () => {
+ installSource([
+ { id: "a", title: "Info", body: "", variant: "info" },
+ { id: "b", title: "Error", body: "", variant: "error" },
+ { id: "c", title: "Warning", body: "", variant: "warning" },
+ ]);
+
+ render();
+ fireEvent.click(screen.getByTestId("notice-inbox-trigger"));
+
+ expect(
+ screen.getAllByTestId("info-box-title").map((el) => el.textContent),
+ ).toEqual(["Error", "Warning", "Info"]);
+ });
+});
diff --git a/src/components/shared/Notices/NoticeInbox.tsx b/src/components/shared/Notices/NoticeInbox.tsx
new file mode 100644
index 0000000000..17de58aad8
--- /dev/null
+++ b/src/components/shared/Notices/NoticeInbox.tsx
@@ -0,0 +1,91 @@
+import { useEffect } from "react";
+
+import TooltipButton from "@/components/shared/Buttons/TooltipButton";
+import { NoticeCard } from "@/components/shared/Notices/NoticeCard";
+import { Badge } from "@/components/ui/badge";
+import { Icon } from "@/components/ui/icon";
+import { BlockStack } from "@/components/ui/layout";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Text } from "@/components/ui/typography";
+import { closeNoticeInbox, useNoticeInbox } from "@/hooks/useNoticeInbox";
+import { tracking } from "@/utils/tracking";
+
+const VIEWPORT_GUTTER = 16;
+
+function triggerLabel(unreadCount: number, total: number): string {
+ if (unreadCount > 0) return `Notices, ${unreadCount} unread`;
+ if (total > 0) return `Notices, ${total} active`;
+ return "Notices, none";
+}
+
+export const NoticeInbox = () => {
+ const { notices, unreadCount, isOpen, setOpen, dismiss } = useNoticeInbox();
+
+ useEffect(() => closeNoticeInbox, []);
+
+ const label = triggerLabel(unreadCount, notices.length);
+
+ return (
+
+
+
+
+ {unreadCount > 0 && (
+
+ {unreadCount > 9 ? "9+" : unreadCount}
+
+ )}
+
+
+
+
+
+ Notices
+
+ {notices.length === 0 ? (
+
+ No notices
+
+ ) : (
+ notices.map((notice) => (
+ dismiss(notice) : undefined
+ }
+ />
+ ))
+ )}
+
+
+
+ );
+};
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
index 611b1d333b..130c189e5b 100644
--- a/src/components/ui/popover.tsx
+++ b/src/components/ui/popover.tsx
@@ -39,7 +39,7 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
className={cn(
- "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 max-w-(--radix-popover-content-available-width) max-h-(--radix-popover-content-available-height) origin-(--radix-popover-content-transform-origin) overflow-y-auto rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
diff --git a/src/hooks/useNoticeInbox.ts b/src/hooks/useNoticeInbox.ts
new file mode 100644
index 0000000000..178fc462eb
--- /dev/null
+++ b/src/hooks/useNoticeInbox.ts
@@ -0,0 +1,110 @@
+import { useSyncExternalStore } from "react";
+
+import type { TangleNotice } from "@/config/notices";
+import { useNotices } from "@/hooks/useNotices";
+import { getStorage } from "@/utils/typedStorage";
+
+const READ_KEY = "read-notices";
+
+const storage = getStorage<
+ typeof READ_KEY,
+ Record
+>();
+
+const readForThisSession = new Set();
+
+let isOpen = false;
+let revision = 0;
+const listeners = new Set<() => void>();
+
+function publish() {
+ revision += 1;
+ listeners.forEach((listener) => listener());
+}
+
+function handleStorage(event: StorageEvent) {
+ // typedStorage re-dispatches a synthetic same-tab event on every write, which
+ // arrives without a storageArea. Those writes already publish themselves.
+ if (event.storageArea === null) return;
+ if (event.key === READ_KEY) publish();
+}
+
+function subscribe(listener: () => void): () => void {
+ if (listeners.size === 0) window.addEventListener("storage", handleStorage);
+ listeners.add(listener);
+
+ return () => {
+ listeners.delete(listener);
+ if (listeners.size === 0)
+ window.removeEventListener("storage", handleStorage);
+ };
+}
+
+function getRevision(): number {
+ return revision;
+}
+
+function readIds(): string[] {
+ const stored = storage.getItem(READ_KEY);
+ return Array.isArray(stored) ? stored : [];
+}
+
+let cachedReadIds: Set | null = null;
+let cachedRevision = -1;
+
+function getReadIds(revision: number): Set {
+ if (cachedReadIds && cachedRevision === revision) return cachedReadIds;
+
+ cachedRevision = revision;
+ cachedReadIds = new Set([...readIds(), ...readForThisSession]);
+
+ return cachedReadIds;
+}
+
+export function closeNoticeInbox() {
+ isOpen = false;
+ publish();
+}
+
+function openNoticeInbox(notices: readonly TangleNotice[]) {
+ const stored = readIds();
+ const merged = [...new Set([...stored, ...notices.map(({ id }) => id)])];
+ if (merged.length !== stored.length) storage.setItem(READ_KEY, merged);
+
+ const persisted = new Set(readIds());
+ notices.forEach(({ id }) => {
+ if (!persisted.has(id)) readForThisSession.add(id);
+ });
+
+ isOpen = true;
+ publish();
+}
+
+export function resetNoticeInboxForTests(): void {
+ isOpen = false;
+ readForThisSession.clear();
+ cachedReadIds = null;
+ cachedRevision = -1;
+}
+
+export interface NoticeInbox {
+ notices: readonly TangleNotice[];
+ unreadCount: number;
+ isOpen: boolean;
+ setOpen: (open: boolean) => void;
+ dismiss: (notice: TangleNotice) => void;
+}
+
+export function useNoticeInbox(): NoticeInbox {
+ const { notices, dismiss } = useNotices();
+ const revision = useSyncExternalStore(subscribe, getRevision);
+ const readIdSet = getReadIds(revision);
+
+ return {
+ notices,
+ unreadCount: notices.filter((notice) => !readIdSet.has(notice.id)).length,
+ isOpen,
+ setOpen: (open) => (open ? openNoticeInbox(notices) : closeNoticeInbox()),
+ dismiss,
+ };
+}
diff --git a/src/routes/v2/shared/components/AppMenuActions.tsx b/src/routes/v2/shared/components/AppMenuActions.tsx
index 20de56208f..6706c58a7d 100644
--- a/src/routes/v2/shared/components/AppMenuActions.tsx
+++ b/src/routes/v2/shared/components/AppMenuActions.tsx
@@ -6,6 +6,7 @@ import { isAuthorizationRequired } from "@/components/shared/Authentication/help
import { TopBarAuthentication } from "@/components/shared/Authentication/TopBarAuthentication";
import TooltipButton from "@/components/shared/Buttons/TooltipButton";
import { EditorVersionToggle } from "@/components/shared/EditorVersionToggle";
+import { NoticeInbox } from "@/components/shared/Notices/NoticeInbox";
import { RunVersionToggle } from "@/components/shared/RunVersionToggle";
import { Icon } from "@/components/ui/icon";
import { InlineStack } from "@/components/ui/layout";
@@ -30,6 +31,7 @@ export function AppMenuActions() {
+
{tourMode ? (