From c8b875dcf16d191f61e26a04311ed4d27b8419c9 Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Wed, 26 Aug 2026 10:18:41 -0700 Subject: [PATCH 1/5] feat(notices): notices button in the header Adds a Megaphone button to the top bar that opens the full list of notices, with an unread count badge. Opening the list marks everything in it as read; notices the host marked dismissible can be removed one at a time from here. The list is also the way back once the strip has been hidden, which until now was a one-way door for the rest of the session. Its toggle mirrors the strip's, and the strip's own controls stay out of the way while the list is open. --- src/components/layout/AppMenu.tsx | 2 + .../shared/Notices/NoticeInbox.test.tsx | 165 ++++++++++++++++++ src/components/shared/Notices/NoticeInbox.tsx | 117 +++++++++++++ .../v2/shared/components/AppMenuActions.tsx | 2 + 4 files changed, 286 insertions(+) create mode 100644 src/components/shared/Notices/NoticeInbox.test.tsx create mode 100644 src/components/shared/Notices/NoticeInbox.tsx diff --git a/src/components/layout/AppMenu.tsx b/src/components/layout/AppMenu.tsx index 195aa373bd..2a00155c8e 100644 --- a/src/components/layout/AppMenu.tsx +++ b/src/components/layout/AppMenu.tsx @@ -9,6 +9,7 @@ import logo from "/Tangle_white.png"; import { OnboardingNavPill } from "@/components/Onboarding/OnboardingNavPill"; import { isAuthorizationRequired } from "@/components/shared/Authentication/helpers"; import { TopBarAuthentication } from "@/components/shared/Authentication/TopBarAuthentication"; +import { NoticeInbox } from "@/components/shared/Notices/NoticeInbox"; import { CopyText } from "@/components/shared/CopyText/CopyText"; import { EditorVersionToggle } from "@/components/shared/EditorVersionToggle"; import ImportPipeline from "@/components/shared/ImportPipeline"; @@ -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..e9583442ff --- /dev/null +++ b/src/components/shared/Notices/NoticeInbox.test.tsx @@ -0,0 +1,165 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { installSource } from "@/config/noticeTestSource"; + +import { NoticeInbox } from "./NoticeInbox"; + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +vi.stubGlobal("ResizeObserver", ResizeObserverMock); + +describe("", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + delete window.__TANGLE_NOTICE_SOURCE__; + }); + + it("renders nothing when there are no notices", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + 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", + ); + }); + + // A dismiss that cannot be persisted is held in memory for the lifetime of the + // module, so this case needs an id no other test reuses. + it("removes a dismissed notice even when it cannot be persisted", () => { + installSource([ + { id: "unpersistable", 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("puts hidden notices back on the page", () => { + localStorage.setItem("hidden-notices", JSON.stringify(["a"])); + installSource([{ id: "a", title: "One", body: "", dismissible: true }]); + + render(); + fireEvent.click(screen.getByTestId("notice-inbox-trigger")); + fireEvent.click(screen.getByTestId("notice-inbox-show")); + + expect(screen.queryByTestId("notice-inbox-show")).not.toBeInTheDocument(); + expect(screen.getByTestId("notice-inbox-hide")).toBeInTheDocument(); + expect(localStorage.getItem("hidden-notices")).toBe("[]"); + }); + + it("hides a banners strip from the centre, and offers to bring it back", () => { + installSource([{ id: "a", title: "One", body: "", dismissible: true }]); + + render(); + fireEvent.click(screen.getByTestId("notice-inbox-trigger")); + + expect(screen.queryByTestId("notice-inbox-show")).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId("notice-inbox-hide")); + + expect(screen.getByTestId("notice-inbox-show")).toBeInTheDocument(); + expect(localStorage.getItem("hidden-notices")).toContain("a"); + }); + + 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..bd636788ce --- /dev/null +++ b/src/components/shared/Notices/NoticeInbox.tsx @@ -0,0 +1,117 @@ +import { useEffect } from "react"; + +import { NoticeCard } from "@/components/shared/Notices/NoticeCard"; +import TooltipButton from "@/components/shared/Buttons/TooltipButton"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } 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; + +export const NoticeInbox = () => { + const { + notices, + unreadCount, + isStripHidden, + isOpen, + setOpen, + hideBanners, + showBanners, + dismiss, + } = useNoticeInbox(); + + useEffect(() => closeNoticeInbox, []); + + if (notices.length === 0) return null; + + const label = + unreadCount > 0 + ? `Notices, ${unreadCount} unread` + : `Notices, ${notices.length} active`; + + const bannerToggle = isStripHidden + ? ({ + icon: "Eye", + label: "Show notices", + onClick: showBanners, + testId: "notice-inbox-show", + } as const) + : ({ + icon: "EyeOff", + label: "Hide notices", + onClick: hideBanners, + testId: "notice-inbox-hide", + } as const); + + return ( + + + + + {unreadCount > 0 && ( + + {unreadCount} + + )} + + + + + + + Notices + + + + {notices.map((notice) => ( + dismiss(notice) : undefined} + /> + ))} + + + + ); +}; diff --git a/src/routes/v2/shared/components/AppMenuActions.tsx b/src/routes/v2/shared/components/AppMenuActions.tsx index 20de56208f..198a57caef 100644 --- a/src/routes/v2/shared/components/AppMenuActions.tsx +++ b/src/routes/v2/shared/components/AppMenuActions.tsx @@ -4,6 +4,7 @@ import { AiModelQuickSelect } from "@/components/layout/AiModelQuickSelect"; import { OnboardingNavPill } from "@/components/Onboarding/OnboardingNavPill"; import { isAuthorizationRequired } from "@/components/shared/Authentication/helpers"; import { TopBarAuthentication } from "@/components/shared/Authentication/TopBarAuthentication"; +import { NoticeInbox } from "@/components/shared/Notices/NoticeInbox"; import TooltipButton from "@/components/shared/Buttons/TooltipButton"; import { EditorVersionToggle } from "@/components/shared/EditorVersionToggle"; import { RunVersionToggle } from "@/components/shared/RunVersionToggle"; @@ -30,6 +31,7 @@ export function AppMenuActions() { + {tourMode ? ( Date: Wed, 26 Aug 2026 15:42:48 -0700 Subject: [PATCH 2/5] fix(notices): always-available inbox, capped badge, restore-banners toggle The notices button now stays in the header whether or not there is anything to read, and opening it with nothing to show gives a "No notices" empty state. Previously the whole affordance unmounted when the list emptied, which also meant dismissing the last notice pulled the open popover out from under the reader. Cap the unread badge at 9+ so a host-supplied count cannot clip inside the badge; the trigger's label still carries the exact number. The banner toggle follows hasHiddenNotices, so it offers to bring the banners back as soon as one notice is hidden rather than only when all of them are. Move the viewport-fit sizing onto the PopoverContent primitive so no caller has to name Radix custom properties, and let the header row use the fill prop rather than a width class. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/layout/AppMenu.tsx | 2 +- .../shared/Notices/NoticeInbox.test.tsx | 78 +++++++++++++++++-- src/components/shared/Notices/NoticeInbox.tsx | 77 ++++++++++-------- src/components/ui/popover.tsx | 2 +- .../v2/shared/components/AppMenuActions.tsx | 2 +- 5 files changed, 118 insertions(+), 43 deletions(-) diff --git a/src/components/layout/AppMenu.tsx b/src/components/layout/AppMenu.tsx index 2a00155c8e..7560158c36 100644 --- a/src/components/layout/AppMenu.tsx +++ b/src/components/layout/AppMenu.tsx @@ -9,10 +9,10 @@ import logo from "/Tangle_white.png"; import { OnboardingNavPill } from "@/components/Onboarding/OnboardingNavPill"; import { isAuthorizationRequired } from "@/components/shared/Authentication/helpers"; import { TopBarAuthentication } from "@/components/shared/Authentication/TopBarAuthentication"; -import { NoticeInbox } from "@/components/shared/Notices/NoticeInbox"; 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"; diff --git a/src/components/shared/Notices/NoticeInbox.test.tsx b/src/components/shared/Notices/NoticeInbox.test.tsx index e9583442ff..67b657c4e4 100644 --- a/src/components/shared/Notices/NoticeInbox.test.tsx +++ b/src/components/shared/Notices/NoticeInbox.test.tsx @@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { installSource } from "@/config/noticeTestSource"; +import { resetNoticeStateForTests } from "@/hooks/useNoticeInbox"; import { NoticeInbox } from "./NoticeInbox"; @@ -21,12 +22,22 @@ describe("", () => { afterEach(() => { cleanup(); delete window.__TANGLE_NOTICE_SOURCE__; + resetNoticeStateForTests(); }); - it("renders nothing when there are no notices", () => { - const { container } = render(); + 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(); - expect(container).toBeEmptyDOMElement(); + 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", () => { @@ -101,11 +112,9 @@ describe("", () => { ); }); - // A dismiss that cannot be persisted is held in memory for the lifetime of the - // module, so this case needs an id no other test reuses. it("removes a dismissed notice even when it cannot be persisted", () => { installSource([ - { id: "unpersistable", title: "Optional", body: "", dismissible: true }, + { id: "a", title: "Optional", body: "", dismissible: true }, ]); render(); @@ -122,6 +131,47 @@ describe("", () => { expect(screen.queryByText("Optional")).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("puts hidden notices back on the page", () => { localStorage.setItem("hidden-notices", JSON.stringify(["a"])); installSource([{ id: "a", title: "One", body: "", dismissible: true }]); @@ -135,7 +185,21 @@ describe("", () => { expect(localStorage.getItem("hidden-notices")).toBe("[]"); }); - it("hides a banners strip from the centre, and offers to bring it back", () => { + it("offers to restore the banners as soon as one notice is hidden", () => { + localStorage.setItem("hidden-notices", JSON.stringify(["a"])); + installSource([ + { id: "a", title: "Hidden", body: "", dismissible: true }, + { id: "b", title: "Still banners", body: "", dismissible: true }, + ]); + + render(); + fireEvent.click(screen.getByTestId("notice-inbox-trigger")); + + expect(screen.getByTestId("notice-inbox-show")).toBeInTheDocument(); + expect(screen.getAllByTestId("info-box-title")).toHaveLength(2); + }); + + it("hides the banners from the centre, and offers to bring them back", () => { installSource([{ id: "a", title: "One", body: "", dismissible: true }]); render(); diff --git a/src/components/shared/Notices/NoticeInbox.tsx b/src/components/shared/Notices/NoticeInbox.tsx index bd636788ce..9cf6dba99f 100644 --- a/src/components/shared/Notices/NoticeInbox.tsx +++ b/src/components/shared/Notices/NoticeInbox.tsx @@ -1,7 +1,7 @@ import { useEffect } from "react"; -import { NoticeCard } from "@/components/shared/Notices/NoticeCard"; import TooltipButton from "@/components/shared/Buttons/TooltipButton"; +import { NoticeCard } from "@/components/shared/Notices/NoticeCard"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; @@ -17,11 +17,17 @@ 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, - isStripHidden, + hasHiddenNotices, isOpen, setOpen, hideBanners, @@ -31,14 +37,9 @@ export const NoticeInbox = () => { useEffect(() => closeNoticeInbox, []); - if (notices.length === 0) return null; - - const label = - unreadCount > 0 - ? `Notices, ${unreadCount} unread` - : `Notices, ${notices.length} active`; + const label = triggerLabel(unreadCount, notices.length); - const bannerToggle = isStripHidden + const bannerToggle = hasHiddenNotices ? ({ icon: "Eye", label: "Show notices", @@ -72,7 +73,7 @@ export const NoticeInbox = () => { position="topright" data-testid="notice-inbox-unread" > - {unreadCount} + {unreadCount > 9 ? "9+" : unreadCount} )} @@ -80,36 +81,46 @@ export const NoticeInbox = () => { - + Notices - + {notices.length > 0 && ( + + )} - {notices.map((notice) => ( - dismiss(notice) : undefined} - /> - ))} + {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/routes/v2/shared/components/AppMenuActions.tsx b/src/routes/v2/shared/components/AppMenuActions.tsx index 198a57caef..6706c58a7d 100644 --- a/src/routes/v2/shared/components/AppMenuActions.tsx +++ b/src/routes/v2/shared/components/AppMenuActions.tsx @@ -4,9 +4,9 @@ import { AiModelQuickSelect } from "@/components/layout/AiModelQuickSelect"; import { OnboardingNavPill } from "@/components/Onboarding/OnboardingNavPill"; import { isAuthorizationRequired } from "@/components/shared/Authentication/helpers"; import { TopBarAuthentication } from "@/components/shared/Authentication/TopBarAuthentication"; -import { NoticeInbox } from "@/components/shared/Notices/NoticeInbox"; 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"; From e208100bcc48054280dc4688b071ec0cb1ae61ca Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Fri, 28 Aug 2026 16:41:49 -0700 Subject: [PATCH 3/5] refactor(notices): drop the banner restore toggle from the inbox --- .../shared/Notices/NoticeInbox.test.tsx | 40 ---------------- src/components/shared/Notices/NoticeInbox.tsx | 47 ++----------------- 2 files changed, 5 insertions(+), 82 deletions(-) diff --git a/src/components/shared/Notices/NoticeInbox.test.tsx b/src/components/shared/Notices/NoticeInbox.test.tsx index 67b657c4e4..73458b002d 100644 --- a/src/components/shared/Notices/NoticeInbox.test.tsx +++ b/src/components/shared/Notices/NoticeInbox.test.tsx @@ -172,46 +172,6 @@ describe("", () => { ); }); - it("puts hidden notices back on the page", () => { - localStorage.setItem("hidden-notices", JSON.stringify(["a"])); - installSource([{ id: "a", title: "One", body: "", dismissible: true }]); - - render(); - fireEvent.click(screen.getByTestId("notice-inbox-trigger")); - fireEvent.click(screen.getByTestId("notice-inbox-show")); - - expect(screen.queryByTestId("notice-inbox-show")).not.toBeInTheDocument(); - expect(screen.getByTestId("notice-inbox-hide")).toBeInTheDocument(); - expect(localStorage.getItem("hidden-notices")).toBe("[]"); - }); - - it("offers to restore the banners as soon as one notice is hidden", () => { - localStorage.setItem("hidden-notices", JSON.stringify(["a"])); - installSource([ - { id: "a", title: "Hidden", body: "", dismissible: true }, - { id: "b", title: "Still banners", body: "", dismissible: true }, - ]); - - render(); - fireEvent.click(screen.getByTestId("notice-inbox-trigger")); - - expect(screen.getByTestId("notice-inbox-show")).toBeInTheDocument(); - expect(screen.getAllByTestId("info-box-title")).toHaveLength(2); - }); - - it("hides the banners from the centre, and offers to bring them back", () => { - installSource([{ id: "a", title: "One", body: "", dismissible: true }]); - - render(); - fireEvent.click(screen.getByTestId("notice-inbox-trigger")); - - expect(screen.queryByTestId("notice-inbox-show")).not.toBeInTheDocument(); - fireEvent.click(screen.getByTestId("notice-inbox-hide")); - - expect(screen.getByTestId("notice-inbox-show")).toBeInTheDocument(); - expect(localStorage.getItem("hidden-notices")).toContain("a"); - }); - it("orders the list by severity", () => { installSource([ { id: "a", title: "Info", body: "", variant: "info" }, diff --git a/src/components/shared/Notices/NoticeInbox.tsx b/src/components/shared/Notices/NoticeInbox.tsx index 9cf6dba99f..17de58aad8 100644 --- a/src/components/shared/Notices/NoticeInbox.tsx +++ b/src/components/shared/Notices/NoticeInbox.tsx @@ -3,9 +3,8 @@ 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 { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; -import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { BlockStack } from "@/components/ui/layout"; import { Popover, PopoverContent, @@ -24,35 +23,12 @@ function triggerLabel(unreadCount: number, total: number): string { } export const NoticeInbox = () => { - const { - notices, - unreadCount, - hasHiddenNotices, - isOpen, - setOpen, - hideBanners, - showBanners, - dismiss, - } = useNoticeInbox(); + const { notices, unreadCount, isOpen, setOpen, dismiss } = useNoticeInbox(); useEffect(() => closeNoticeInbox, []); const label = triggerLabel(unreadCount, notices.length); - const bannerToggle = hasHiddenNotices - ? ({ - icon: "Eye", - label: "Show notices", - onClick: showBanners, - testId: "notice-inbox-show", - } as const) - : ({ - icon: "EyeOff", - label: "Hide notices", - onClick: hideBanners, - testId: "notice-inbox-hide", - } as const); - return ( @@ -85,22 +61,9 @@ export const NoticeInbox = () => { data-testid="notice-inbox" > - - - Notices - - {notices.length > 0 && ( - - )} - + + Notices + {notices.length === 0 ? ( Date: Fri, 28 Aug 2026 17:03:13 -0700 Subject: [PATCH 4/5] feat(notices): unread state for the header inbox --- react-compiler.config.js | 1 + .../shared/Notices/NoticeInbox.test.tsx | 4 +- src/hooks/useNoticeInbox.ts | 102 ++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useNoticeInbox.ts 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/shared/Notices/NoticeInbox.test.tsx b/src/components/shared/Notices/NoticeInbox.test.tsx index 73458b002d..6f5015825e 100644 --- a/src/components/shared/Notices/NoticeInbox.test.tsx +++ b/src/components/shared/Notices/NoticeInbox.test.tsx @@ -2,7 +2,8 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { installSource } from "@/config/noticeTestSource"; -import { resetNoticeStateForTests } from "@/hooks/useNoticeInbox"; +import { resetNoticeInboxForTests } from "@/hooks/useNoticeInbox"; +import { resetNoticeStateForTests } from "@/hooks/useNotices"; import { NoticeInbox } from "./NoticeInbox"; @@ -22,6 +23,7 @@ describe("", () => { afterEach(() => { cleanup(); delete window.__TANGLE_NOTICE_SOURCE__; + resetNoticeInboxForTests(); resetNoticeStateForTests(); }); diff --git a/src/hooks/useNoticeInbox.ts b/src/hooks/useNoticeInbox.ts new file mode 100644 index 0000000000..a682c568d0 --- /dev/null +++ b/src/hooks/useNoticeInbox.ts @@ -0,0 +1,102 @@ +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 +>(); + +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()); + + 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); + + isOpen = true; + publish(); +} + +export function resetNoticeInboxForTests(): void { + isOpen = false; + 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, + }; +} From 11d0e10e5a39c652df2d047f1ad43473ee04c06d Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Fri, 4 Sep 2026 10:48:47 -0700 Subject: [PATCH 5/5] fix(notices): keep the inbox read for the session when storage fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typedStorage swallows a failed setItem, so persisting the read ids could silently do nothing and every notice stayed unread for the rest of the session — the badge came back the moment the trigger remounted. Read now falls back to an in-memory set the way dismissal already does, so the degradation is the same one the reader gets there: cleared until reload. Co-Authored-By: Claude Opus 5 (1M context) --- .../shared/Notices/NoticeInbox.test.tsx | 21 +++++++++++++++++++ src/hooks/useNoticeInbox.ts | 10 ++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/components/shared/Notices/NoticeInbox.test.tsx b/src/components/shared/Notices/NoticeInbox.test.tsx index 6f5015825e..6849d1a535 100644 --- a/src/components/shared/Notices/NoticeInbox.test.tsx +++ b/src/components/shared/Notices/NoticeInbox.test.tsx @@ -133,6 +133,27 @@ describe("", () => { 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 }, diff --git a/src/hooks/useNoticeInbox.ts b/src/hooks/useNoticeInbox.ts index a682c568d0..178fc462eb 100644 --- a/src/hooks/useNoticeInbox.ts +++ b/src/hooks/useNoticeInbox.ts @@ -11,6 +11,8 @@ const storage = getStorage< Record >(); +const readForThisSession = new Set(); + let isOpen = false; let revision = 0; const listeners = new Set<() => void>(); @@ -54,7 +56,7 @@ function getReadIds(revision: number): Set { if (cachedReadIds && cachedRevision === revision) return cachedReadIds; cachedRevision = revision; - cachedReadIds = new Set(readIds()); + cachedReadIds = new Set([...readIds(), ...readForThisSession]); return cachedReadIds; } @@ -69,12 +71,18 @@ function openNoticeInbox(notices: readonly TangleNotice[]) { 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; }