diff --git a/ui-react/apps/console/src/components/announcements/AnnouncementModal.css b/ui-react/apps/console/src/components/announcements/AnnouncementModal.css
new file mode 100644
index 00000000000..96e762fba4d
--- /dev/null
+++ b/ui-react/apps/console/src/components/announcements/AnnouncementModal.css
@@ -0,0 +1,121 @@
+/* Colors are manually synced with tailwind.preset.js dark theme tokens.
+ Update these if the theme palette changes. */
+
+.announcement-modal-content .ProseMirror {
+ outline: none;
+ color: #e1e4ea;
+ font-size: 0.875rem;
+ line-height: 1.625;
+}
+
+.announcement-modal-content .ProseMirror h1 {
+ font-size: 1.5rem;
+ font-weight: 700;
+ margin-top: 1.5rem;
+ margin-bottom: 0.5rem;
+ line-height: 1.3;
+}
+
+.announcement-modal-content .ProseMirror h1:first-child {
+ margin-top: 0;
+}
+
+.announcement-modal-content .ProseMirror h2 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin-top: 1.25rem;
+ margin-bottom: 0.5rem;
+ line-height: 1.3;
+}
+
+.announcement-modal-content .ProseMirror h3 {
+ font-size: 1.1rem;
+ font-weight: 600;
+ margin-top: 1rem;
+ margin-bottom: 0.5rem;
+ line-height: 1.3;
+}
+
+.announcement-modal-content .ProseMirror p {
+ margin-bottom: 0.75rem;
+}
+
+.announcement-modal-content .ProseMirror p:last-child {
+ margin-bottom: 0;
+}
+
+.announcement-modal-content .ProseMirror ul,
+.announcement-modal-content .ProseMirror ol {
+ padding-left: 1.5rem;
+ margin-bottom: 0.75rem;
+}
+
+.announcement-modal-content .ProseMirror ul {
+ list-style: disc;
+}
+
+.announcement-modal-content .ProseMirror ol {
+ list-style: decimal;
+}
+
+.announcement-modal-content .ProseMirror li {
+ margin-bottom: 0.25rem;
+}
+
+.announcement-modal-content .ProseMirror blockquote {
+ border-left: 3px solid #383d47;
+ padding-left: 1rem;
+ margin-left: 0;
+ margin-bottom: 0.75rem;
+ color: #8b8f99;
+ font-style: italic;
+}
+
+.announcement-modal-content .ProseMirror code {
+ background: #1e2127;
+ border: 1px solid #2c2f36;
+ border-radius: 0.25rem;
+ padding: 0.125rem 0.375rem;
+ font-family: "IBM Plex Mono", monospace;
+ font-size: 0.8125rem;
+}
+
+.announcement-modal-content .ProseMirror pre {
+ background: #1e2127;
+ border: 1px solid #2c2f36;
+ border-radius: 0.5rem;
+ padding: 0.75rem 1rem;
+ margin-bottom: 0.75rem;
+ overflow-x: auto;
+}
+
+.announcement-modal-content .ProseMirror pre code {
+ background: none;
+ border: none;
+ padding: 0;
+ font-size: 0.8125rem;
+ line-height: 1.5;
+}
+
+.announcement-modal-content .ProseMirror a {
+ color: #667acc;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+.announcement-modal-content .ProseMirror a:hover {
+ opacity: 0.8;
+}
+
+.announcement-modal-content .ProseMirror img {
+ max-width: 100%;
+ height: auto;
+ border-radius: 0.5rem;
+ margin: 0.75rem 0;
+}
+
+.announcement-modal-content .ProseMirror hr {
+ border: none;
+ border-top: 1px solid #2c2f36;
+ margin: 1.25rem 0;
+}
diff --git a/ui-react/apps/console/src/components/announcements/AnnouncementModal.tsx b/ui-react/apps/console/src/components/announcements/AnnouncementModal.tsx
new file mode 100644
index 00000000000..b72a94e2dcb
--- /dev/null
+++ b/ui-react/apps/console/src/components/announcements/AnnouncementModal.tsx
@@ -0,0 +1,104 @@
+import { useId } from "react";
+import { useEditor, EditorContent } from "@tiptap/react";
+import StarterKit from "@tiptap/starter-kit";
+import Link from "@tiptap/extension-link";
+import Image from "@tiptap/extension-image";
+import { Markdown } from "@tiptap/markdown";
+import { MegaphoneIcon, XMarkIcon } from "@heroicons/react/24/outline";
+import BaseDialog from "@/components/common/BaseDialog";
+import { formatDateShort } from "@/utils/date";
+import { isAllowedUrl } from "@/utils/url";
+import type { Announcement } from "@/client";
+import "./AnnouncementModal.css";
+
+interface AnnouncementContentProps {
+ content: string;
+}
+
+function AnnouncementContent({ content }: AnnouncementContentProps) {
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ Link.configure({
+ openOnClick: true,
+ validate: (url) => isAllowedUrl(url),
+ HTMLAttributes: {
+ rel: "noopener noreferrer",
+ target: "_blank",
+ },
+ }),
+ Image.configure({ allowBase64: false }),
+ Markdown,
+ ],
+ content,
+ contentType: "markdown",
+ editable: false,
+ });
+
+ if (!editor) return
;
+
+ return (
+
+
+
+ );
+}
+
+interface AnnouncementModalProps {
+ open: boolean;
+ onClose: () => void;
+ announcement: Announcement;
+}
+
+export default function AnnouncementModal({
+ open,
+ onClose,
+ announcement,
+}: AnnouncementModalProps) {
+ const titleId = useId();
+
+ return (
+
+
+
+
+
+
+
+
+ {announcement.title}
+
+
+ {formatDateShort(announcement.date)}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui-react/apps/console/src/components/announcements/AnnouncementModalTrigger.tsx b/ui-react/apps/console/src/components/announcements/AnnouncementModalTrigger.tsx
new file mode 100644
index 00000000000..c94536c4ccb
--- /dev/null
+++ b/ui-react/apps/console/src/components/announcements/AnnouncementModalTrigger.tsx
@@ -0,0 +1,51 @@
+import { useState } from "react";
+import { getConfig } from "@/env";
+import { useLatestAnnouncement } from "@/hooks/useLatestAnnouncement";
+import AnnouncementModal from "./AnnouncementModal";
+import type { Announcement } from "@/client";
+
+const STORAGE_KEY = "announcement";
+
+function computeHash(announcement: Announcement): string {
+ const json = JSON.stringify(announcement);
+ return btoa(
+ Array.from(new TextEncoder().encode(json), (b) =>
+ String.fromCharCode(b),
+ ).join(""),
+ );
+}
+
+function getStoredHash(): string {
+ return localStorage.getItem(STORAGE_KEY) ?? "";
+}
+
+function markSeen(announcement: Announcement): void {
+ localStorage.setItem(STORAGE_KEY, computeHash(announcement));
+}
+
+export default function AnnouncementModalTrigger() {
+ if (!getConfig().announcements) return null;
+
+ return ;
+}
+
+function AnnouncementModalInner() {
+ const { announcement } = useLatestAnnouncement();
+ const [dismissed, setDismissed] = useState(false);
+
+ const show =
+ !!announcement &&
+ !dismissed &&
+ computeHash(announcement) !== getStoredHash();
+
+ const handleClose = () => {
+ if (announcement) markSeen(announcement);
+ setDismissed(true);
+ };
+
+ if (!show || !announcement) return null;
+
+ return (
+
+ );
+}
diff --git a/ui-react/apps/console/src/components/announcements/__tests__/AnnouncementModal.test.tsx b/ui-react/apps/console/src/components/announcements/__tests__/AnnouncementModal.test.tsx
new file mode 100644
index 00000000000..1287a73dcce
--- /dev/null
+++ b/ui-react/apps/console/src/components/announcements/__tests__/AnnouncementModal.test.tsx
@@ -0,0 +1,166 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, cleanup } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { Announcement } from "@/client";
+
+// ── Dependency mocks ──────────────────────────────────────────────────────────
+
+// Silence the CSS import — jsdom cannot process it
+vi.mock("../AnnouncementModal.css", () => ({}));
+
+// Mock the focus trap so jsdom focus state doesn't interfere
+vi.mock("@/hooks/useFocusTrap", () => ({ useFocusTrap: vi.fn() }));
+
+// jsdom doesn't implement showModal/close — stub them
+HTMLDialogElement.prototype.showModal = vi.fn(function (
+ this: HTMLDialogElement,
+) {
+ this.setAttribute("open", "");
+});
+HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
+ this.removeAttribute("open");
+});
+
+// Tiptap uses real DOM APIs that aren't fully available in jsdom.
+// Mock the whole editor so AnnouncementContent renders a stable placeholder.
+vi.mock("@tiptap/react", () => ({
+ useEditor: vi.fn(() => null),
+ EditorContent: () => null,
+}));
+
+vi.mock("@tiptap/starter-kit", () => ({ default: {} }));
+vi.mock("@tiptap/extension-link", () => ({
+ default: { configure: vi.fn(() => ({})) },
+}));
+vi.mock("@tiptap/extension-image", () => ({
+ default: { configure: vi.fn(() => ({})) },
+}));
+vi.mock("@tiptap/markdown", () => ({ Markdown: {} }));
+vi.mock("@/utils/url", () => ({ isAllowedUrl: vi.fn(() => true) }));
+
+import AnnouncementModal from "../AnnouncementModal";
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+function makeAnnouncement(overrides: Partial = {}): Announcement {
+ return {
+ uuid: "ann-uuid-1",
+ title: "New Feature Released",
+ content: "## Hello\nThis is the content.",
+ date: "2024-06-15T00:00:00Z",
+ ...overrides,
+ };
+}
+
+function renderModal({
+ open = true,
+ onClose = vi.fn(),
+ announcement = makeAnnouncement(),
+}: {
+ open?: boolean;
+ onClose?: () => void;
+ announcement?: Announcement;
+} = {}) {
+ return {
+ onClose,
+ ...render(
+ ,
+ ),
+ };
+}
+
+// ── Setup / teardown ──────────────────────────────────────────────────────────
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+afterEach(cleanup);
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe("AnnouncementModal", () => {
+ describe("when open=false", () => {
+ it("renders nothing", () => {
+ renderModal({ open: false });
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("when open=true", () => {
+ it("renders the dialog element", () => {
+ renderModal();
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ });
+
+ it("shows the announcement title", () => {
+ renderModal({
+ announcement: makeAnnouncement({ title: "Big Announcement" }),
+ });
+ expect(screen.getByText("Big Announcement")).toBeInTheDocument();
+ });
+
+ it("shows the formatted announcement date", () => {
+ renderModal({
+ announcement: makeAnnouncement({ date: "2024-06-15T12:00:00Z" }),
+ });
+ // formatDateShort renders "Jun 15, 2024"
+ expect(screen.getByText("Jun 15, 2024")).toBeInTheDocument();
+ });
+
+ it("renders the close button with correct aria-label", () => {
+ renderModal();
+ expect(
+ screen.getByRole("button", { name: /close announcement/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("renders the 'Got it' button", () => {
+ renderModal();
+ expect(
+ screen.getByRole("button", { name: /got it/i }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe("accessibility", () => {
+ it("dialog is labelled by the title element", () => {
+ renderModal({
+ announcement: makeAnnouncement({ title: "My Announcement" }),
+ });
+ const dialog = screen.getByRole("dialog");
+ const labelledById = dialog.getAttribute("aria-labelledby");
+ expect(labelledById).toBeTruthy();
+
+ const titleEl = document.getElementById(labelledById!);
+ expect(titleEl).not.toBeNull();
+ expect(titleEl!.textContent).toBe("My Announcement");
+ });
+ });
+
+ describe("close interactions", () => {
+ it("calls onClose when the close button is clicked", async () => {
+ const user = userEvent.setup();
+ const { onClose } = renderModal();
+
+ await user.click(
+ screen.getByRole("button", { name: /close announcement/i }),
+ );
+
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it("calls onClose when 'Got it' is clicked", async () => {
+ const user = userEvent.setup();
+ const { onClose } = renderModal();
+
+ await user.click(screen.getByRole("button", { name: /got it/i }));
+
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+ });
+});
diff --git a/ui-react/apps/console/src/components/announcements/__tests__/AnnouncementModalTrigger.test.tsx b/ui-react/apps/console/src/components/announcements/__tests__/AnnouncementModalTrigger.test.tsx
new file mode 100644
index 00000000000..77529fb3582
--- /dev/null
+++ b/ui-react/apps/console/src/components/announcements/__tests__/AnnouncementModalTrigger.test.tsx
@@ -0,0 +1,233 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, cleanup, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { Announcement } from "@/client";
+
+// ── Dependency mocks ──────────────────────────────────────────────────────────
+
+vi.mock("@/env", () => ({ getConfig: vi.fn() }));
+
+vi.mock("@/hooks/useLatestAnnouncement", () => ({
+ useLatestAnnouncement: vi.fn(),
+}));
+
+// Replace AnnouncementModal with a simple stub that exposes the open state
+// and wires up onClose — we don't need to render the full modal here.
+vi.mock("../AnnouncementModal", () => ({
+ default: ({
+ open,
+ onClose,
+ announcement,
+ }: {
+ open: boolean;
+ onClose: () => void;
+ announcement: Announcement;
+ }) =>
+ open ? (
+
+ {announcement.title}
+
+
+ ) : null,
+}));
+
+import { getConfig } from "@/env";
+import { useLatestAnnouncement } from "@/hooks/useLatestAnnouncement";
+import AnnouncementModalTrigger from "../AnnouncementModalTrigger";
+
+const mockGetConfig = vi.mocked(getConfig);
+const mockUseLatestAnnouncement = vi.mocked(useLatestAnnouncement);
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+function makeAnnouncement(overrides: Partial = {}): Announcement {
+ return {
+ uuid: "ann-uuid-1",
+ title: "Test Announcement",
+ content: "## Content",
+ date: "2024-06-01T00:00:00Z",
+ ...overrides,
+ };
+}
+
+function computeHash(announcement: Announcement): string {
+ return btoa(JSON.stringify(announcement));
+}
+
+// ── Setup / teardown ──────────────────────────────────────────────────────────
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+
+ // Default: feature enabled, no announcement available
+ mockGetConfig.mockReturnValue({ announcements: true } as ReturnType<
+ typeof getConfig
+ >);
+ mockUseLatestAnnouncement.mockReturnValue({
+ announcement: null,
+ isLoading: false,
+ });
+});
+
+afterEach(cleanup);
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe("AnnouncementModalTrigger", () => {
+ describe("when announcements feature flag is disabled", () => {
+ it("renders nothing without calling any hooks", () => {
+ mockGetConfig.mockReturnValue({ announcements: false } as ReturnType<
+ typeof getConfig
+ >);
+
+ render();
+
+ expect(
+ screen.queryByTestId("announcement-modal"),
+ ).not.toBeInTheDocument();
+ // The inner component (which calls the hook) must not have mounted
+ expect(mockUseLatestAnnouncement).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("when no announcement is available", () => {
+ it("renders nothing", () => {
+ mockUseLatestAnnouncement.mockReturnValue({
+ announcement: null,
+ isLoading: false,
+ });
+
+ render();
+
+ expect(
+ screen.queryByTestId("announcement-modal"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe("when an announcement is available and already seen", () => {
+ it("renders nothing when the stored hash matches", () => {
+ const ann = makeAnnouncement();
+ localStorage.setItem("announcement", computeHash(ann));
+ mockUseLatestAnnouncement.mockReturnValue({
+ announcement: ann,
+ isLoading: false,
+ });
+
+ render();
+
+ expect(
+ screen.queryByTestId("announcement-modal"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe("when a new (unseen) announcement is available", () => {
+ it("shows the modal", () => {
+ const ann = makeAnnouncement();
+ mockUseLatestAnnouncement.mockReturnValue({
+ announcement: ann,
+ isLoading: false,
+ });
+
+ render();
+
+ expect(screen.getByTestId("announcement-modal")).toBeInTheDocument();
+ });
+
+ it("passes the announcement title to the modal", () => {
+ const ann = makeAnnouncement({ title: "Important Update" });
+ mockUseLatestAnnouncement.mockReturnValue({
+ announcement: ann,
+ isLoading: false,
+ });
+
+ render();
+
+ expect(screen.getByTestId("modal-title")).toHaveTextContent(
+ "Important Update",
+ );
+ });
+
+ it("shows the modal when localStorage has a hash for a different announcement", () => {
+ const old = makeAnnouncement({ uuid: "old-uuid", title: "Old" });
+ localStorage.setItem("announcement", computeHash(old));
+
+ const fresh = makeAnnouncement({ uuid: "new-uuid", title: "New" });
+ mockUseLatestAnnouncement.mockReturnValue({
+ announcement: fresh,
+ isLoading: false,
+ });
+
+ render();
+
+ expect(screen.getByTestId("announcement-modal")).toBeInTheDocument();
+ });
+ });
+
+ describe("on modal close", () => {
+ it("hides the modal after it is closed", async () => {
+ const user = userEvent.setup();
+ const ann = makeAnnouncement();
+ mockUseLatestAnnouncement.mockReturnValue({
+ announcement: ann,
+ isLoading: false,
+ });
+
+ render();
+ expect(screen.getByTestId("announcement-modal")).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: /close/i }));
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("announcement-modal"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it("stores the announcement hash in localStorage when closed", async () => {
+ const user = userEvent.setup();
+ const ann = makeAnnouncement();
+ mockUseLatestAnnouncement.mockReturnValue({
+ announcement: ann,
+ isLoading: false,
+ });
+
+ render();
+
+ await user.click(screen.getByRole("button", { name: /close/i }));
+
+ await waitFor(() => {
+ expect(localStorage.getItem("announcement")).toBe(computeHash(ann));
+ });
+ });
+
+ it("does not re-show the modal after dismiss within the same render", async () => {
+ const user = userEvent.setup();
+ const ann = makeAnnouncement();
+ mockUseLatestAnnouncement.mockReturnValue({
+ announcement: ann,
+ isLoading: false,
+ });
+
+ render();
+
+ await user.click(screen.getByRole("button", { name: /close/i }));
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("announcement-modal"),
+ ).not.toBeInTheDocument();
+ });
+
+ // Hash is now stored — a fresh render should not show the modal
+ cleanup();
+ render();
+ expect(
+ screen.queryByTestId("announcement-modal"),
+ ).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/ui-react/apps/console/src/components/layout/AppLayout.tsx b/ui-react/apps/console/src/components/layout/AppLayout.tsx
index fbc4e7f7486..00d9d331bdb 100644
--- a/ui-react/apps/console/src/components/layout/AppLayout.tsx
+++ b/ui-react/apps/console/src/components/layout/AppLayout.tsx
@@ -4,6 +4,7 @@ import AppBar from "./AppBar";
import TerminalManager from "../terminal/TerminalManager";
import ConnectivityBanner from "../common/ConnectivityBanner";
import WelcomeWizardTrigger from "../wizard/WelcomeWizardTrigger";
+import AnnouncementModalTrigger from "../announcements/AnnouncementModalTrigger";
import { SidebarMobileDrawer } from "./SidebarShell";
import ChatwootProvider from "./ChatwootProvider";
import { useNamespaces } from "@/hooks/useNamespaces";
@@ -47,10 +48,10 @@ export default function AppLayout() {
>
)}
@@ -69,6 +70,7 @@ export default function AppLayout() {
+
);
diff --git a/ui-react/apps/console/src/hooks/__tests__/useLatestAnnouncement.test.ts b/ui-react/apps/console/src/hooks/__tests__/useLatestAnnouncement.test.ts
new file mode 100644
index 00000000000..c44ea839cd6
--- /dev/null
+++ b/ui-react/apps/console/src/hooks/__tests__/useLatestAnnouncement.test.ts
@@ -0,0 +1,243 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderHook, waitFor } from "@testing-library/react";
+import React from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { useLatestAnnouncement } from "../useLatestAnnouncement";
+import type { Announcement } from "@/client";
+
+// ── Module mocks ─────────────────────────────────────────────────────────────
+
+vi.mock("@/env", () => ({ getConfig: vi.fn() }));
+
+// Mock the generated query-option factories so we control what queryFn runs
+vi.mock("@/client/@tanstack/react-query.gen", () => ({
+ listAnnouncementsOptions: vi.fn(),
+ getAnnouncementOptions: vi.fn(),
+}));
+
+import { getConfig } from "@/env";
+import {
+ listAnnouncementsOptions,
+ getAnnouncementOptions,
+} from "@/client/@tanstack/react-query.gen";
+
+const mockGetConfig = vi.mocked(getConfig);
+const mockListAnnouncementsOptions = vi.mocked(listAnnouncementsOptions);
+const mockGetAnnouncementOptions = vi.mocked(getAnnouncementOptions);
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function makeAnnouncement(overrides: Partial = {}): Announcement {
+ return {
+ uuid: "ann-uuid-1",
+ title: "Test Announcement",
+ content: "## Hello\nSome content",
+ date: "2024-06-01T00:00:00Z",
+ ...overrides,
+ };
+}
+
+/** Returns queryOptions-shaped objects that delegate to the provided fn. */
+function makeListOptions(fn: () => unknown) {
+ return {
+ queryKey: ["listAnnouncements"],
+ queryFn: fn,
+ };
+}
+
+function makeDetailOptions(uuid: string, fn: () => unknown) {
+ return {
+ queryKey: ["getAnnouncement", uuid],
+ queryFn: fn,
+ };
+}
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, retryDelay: 0 },
+ },
+ });
+ return ({ children }: { children: React.ReactNode }) =>
+ React.createElement(QueryClientProvider, { client: queryClient }, children);
+}
+
+// ── Setup ─────────────────────────────────────────────────────────────────────
+
+beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Default: feature enabled
+ mockGetConfig.mockReturnValue({ announcements: true } as ReturnType<
+ typeof getConfig
+ >);
+
+ // Default: both option factories return never-resolving promises (loading state)
+ mockListAnnouncementsOptions.mockReturnValue(
+ makeListOptions(() => new Promise(() => {})) as never,
+ );
+ mockGetAnnouncementOptions.mockReturnValue(
+ makeDetailOptions("", () => new Promise(() => {})) as never,
+ );
+});
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe("useLatestAnnouncement", () => {
+ describe("when announcements feature flag is disabled", () => {
+ beforeEach(() => {
+ mockGetConfig.mockReturnValue({ announcements: false } as ReturnType<
+ typeof getConfig
+ >);
+ });
+
+ it("returns null announcement immediately", () => {
+ const { result } = renderHook(() => useLatestAnnouncement(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.announcement).toBeNull();
+ });
+
+ it("returns isLoading false", () => {
+ const { result } = renderHook(() => useLatestAnnouncement(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it("does not call listAnnouncementsOptions queryFn", () => {
+ const listFn = vi.fn().mockResolvedValue([]);
+ mockListAnnouncementsOptions.mockReturnValue(
+ makeListOptions(listFn) as never,
+ );
+
+ renderHook(() => useLatestAnnouncement(), { wrapper: createWrapper() });
+
+ expect(listFn).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("loading state", () => {
+ it("returns isLoading true while list query is pending", () => {
+ mockListAnnouncementsOptions.mockReturnValue(
+ makeListOptions(() => new Promise(() => {})) as never,
+ );
+
+ const { result } = renderHook(() => useLatestAnnouncement(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.isLoading).toBe(true);
+ });
+
+ it("returns null announcement while queries are loading", () => {
+ const { result } = renderHook(() => useLatestAnnouncement(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.announcement).toBeNull();
+ });
+ });
+
+ describe("when list resolves but is empty", () => {
+ it("returns null announcement", async () => {
+ mockListAnnouncementsOptions.mockReturnValue(
+ makeListOptions(() => Promise.resolve([])) as never,
+ );
+
+ const { result } = renderHook(() => useLatestAnnouncement(), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.announcement).toBeNull();
+ });
+
+ it("does not call getAnnouncementOptions queryFn", async () => {
+ const detailFn = vi.fn().mockResolvedValue(makeAnnouncement());
+ mockListAnnouncementsOptions.mockReturnValue(
+ makeListOptions(() => Promise.resolve([])) as never,
+ );
+ mockGetAnnouncementOptions.mockReturnValue(
+ makeDetailOptions("", detailFn) as never,
+ );
+
+ const { result } = renderHook(() => useLatestAnnouncement(), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(detailFn).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("when both queries resolve successfully", () => {
+ it("returns the full announcement object", async () => {
+ const ann = makeAnnouncement({ uuid: "ann-abc", title: "Big Update" });
+
+ mockListAnnouncementsOptions.mockReturnValue(
+ makeListOptions(() => Promise.resolve([{ uuid: "ann-abc" }])) as never,
+ );
+ mockGetAnnouncementOptions.mockReturnValue(
+ makeDetailOptions("ann-abc", () => Promise.resolve(ann)) as never,
+ );
+
+ const { result } = renderHook(() => useLatestAnnouncement(), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.announcement).not.toBeNull());
+ expect(result.current.announcement).toEqual(ann);
+ });
+
+ it("returns isLoading false after both queries settle", async () => {
+ const ann = makeAnnouncement();
+
+ mockListAnnouncementsOptions.mockReturnValue(
+ makeListOptions(() =>
+ Promise.resolve([{ uuid: "ann-uuid-1" }]),
+ ) as never,
+ );
+ mockGetAnnouncementOptions.mockReturnValue(
+ makeDetailOptions("ann-uuid-1", () => Promise.resolve(ann)) as never,
+ );
+
+ const { result } = renderHook(() => useLatestAnnouncement(), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.announcement).toEqual(ann);
+ });
+ });
+
+ describe("when the list query resolves but the detail query is still loading", () => {
+ it("returns isLoading true", async () => {
+ mockListAnnouncementsOptions.mockReturnValue(
+ makeListOptions(() =>
+ Promise.resolve([{ uuid: "ann-uuid-1" }]),
+ ) as never,
+ );
+ // detail never resolves
+ mockGetAnnouncementOptions.mockReturnValue(
+ makeDetailOptions("ann-uuid-1", () => new Promise(() => {})) as never,
+ );
+
+ const { result } = renderHook(() => useLatestAnnouncement(), {
+ wrapper: createWrapper(),
+ });
+
+ // Let the list query settle so latestUuid is set and the detail query is enabled
+ await waitFor(() =>
+ expect(mockGetAnnouncementOptions).toHaveBeenCalledWith(
+ expect.objectContaining({ path: { uuid: "ann-uuid-1" } }),
+ ),
+ );
+
+ expect(result.current.isLoading).toBe(true);
+ });
+ });
+});
diff --git a/ui-react/apps/console/src/hooks/useLatestAnnouncement.ts b/ui-react/apps/console/src/hooks/useLatestAnnouncement.ts
new file mode 100644
index 00000000000..941a10c0972
--- /dev/null
+++ b/ui-react/apps/console/src/hooks/useLatestAnnouncement.ts
@@ -0,0 +1,36 @@
+import { useQuery } from "@tanstack/react-query";
+import { getConfig } from "@/env";
+import {
+ listAnnouncementsOptions,
+ getAnnouncementOptions,
+} from "@/client/@tanstack/react-query.gen";
+
+export function useLatestAnnouncement() {
+ const enabled = getConfig().announcements;
+
+ const listResult = useQuery({
+ ...listAnnouncementsOptions({
+ query: { page: 1, per_page: 1, order_by: "desc" },
+ }),
+ enabled,
+ staleTime: 5 * 60 * 1000,
+ refetchOnWindowFocus: false,
+ retry: false,
+ });
+
+ const latestUuid = listResult.data?.[0]?.uuid;
+
+ const detailResult = useQuery({
+ ...getAnnouncementOptions({ path: { uuid: latestUuid ?? "" } }),
+ enabled: enabled && !!latestUuid,
+ staleTime: 5 * 60 * 1000,
+ refetchOnWindowFocus: false,
+ retry: false,
+ });
+
+ return {
+ announcement: detailResult.data ?? null,
+ isLoading:
+ listResult.isLoading || (!!latestUuid && detailResult.isLoading),
+ };
+}