From f4674078bcc8da47b411e400e604838b7b65baf3 Mon Sep 17 00:00:00 2001
From: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
Date: Thu, 25 Jun 2026 12:41:34 -0700
Subject: [PATCH] feat(announcements): show latest announcement once on harness
init (#4125)
---
app/src/App.tsx | 2 +
.../Announcement/AnnouncementGate.test.tsx | 109 ++++++++++++++++++
.../Announcement/AnnouncementGate.tsx | 74 ++++++++++++
.../Announcement/AnnouncementModal.test.tsx | 74 ++++++++++++
.../Announcement/AnnouncementModal.tsx | 81 +++++++++++++
app/src/lib/i18n/ar.ts | 2 +
app/src/lib/i18n/bn.ts | 1 +
app/src/lib/i18n/de.ts | 2 +
app/src/lib/i18n/en.ts | 1 +
app/src/lib/i18n/es.ts | 2 +
app/src/lib/i18n/fr.ts | 2 +
app/src/lib/i18n/hi.ts | 2 +
app/src/lib/i18n/id.ts | 2 +
app/src/lib/i18n/it.ts | 2 +
app/src/lib/i18n/ko.ts | 2 +
app/src/lib/i18n/pl.ts | 2 +
app/src/lib/i18n/pt.ts | 2 +
app/src/lib/i18n/ru.ts | 2 +
app/src/lib/i18n/zh-CN.ts | 2 +
app/src/services/announcementService.test.ts | 83 +++++++++++++
app/src/services/announcementService.ts | 85 ++++++++++++++
app/src/store/announcementSlice.test.ts | 32 +++++
app/src/store/announcementSlice.ts | 36 ++++++
app/src/store/index.ts | 7 ++
src/core/all.rs | 6 +
src/openhuman/announcements/mod.rs | 14 +++
src/openhuman/announcements/ops.rs | 39 +++++++
src/openhuman/announcements/schemas.rs | 91 +++++++++++++++
src/openhuman/mod.rs | 1 +
29 files changed, 760 insertions(+)
create mode 100644 app/src/components/Announcement/AnnouncementGate.test.tsx
create mode 100644 app/src/components/Announcement/AnnouncementGate.tsx
create mode 100644 app/src/components/Announcement/AnnouncementModal.test.tsx
create mode 100644 app/src/components/Announcement/AnnouncementModal.tsx
create mode 100644 app/src/services/announcementService.test.ts
create mode 100644 app/src/services/announcementService.ts
create mode 100644 app/src/store/announcementSlice.test.ts
create mode 100644 app/src/store/announcementSlice.ts
create mode 100644 src/openhuman/announcements/mod.rs
create mode 100644 src/openhuman/announcements/ops.rs
create mode 100644 src/openhuman/announcements/schemas.rs
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 990eea384..1572a0e17 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -11,6 +11,7 @@ import { PersistGate } from 'redux-persist/integration/react';
import AppRoutes from './AppRoutes';
import WebviewHost from './components/accounts/WebviewHost';
+import AnnouncementGate from './components/Announcement/AnnouncementGate';
import AppBackground from './components/AppBackground';
import AppUpdatePrompt from './components/AppUpdatePrompt';
import BootCheckGate from './components/BootCheckGate/BootCheckGate';
@@ -156,6 +157,7 @@ function App() {
{!onMobile && }
+
diff --git a/app/src/components/Announcement/AnnouncementGate.test.tsx b/app/src/components/Announcement/AnnouncementGate.test.tsx
new file mode 100644
index 000000000..379ee2efa
--- /dev/null
+++ b/app/src/components/Announcement/AnnouncementGate.test.tsx
@@ -0,0 +1,109 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { Announcement } from '../../services/announcementService';
+import { markAnnouncementShown } from '../../store/announcementSlice';
+import AnnouncementGate from './AnnouncementGate';
+
+// Controllable mock state shared across the mocked modules.
+const authState: { isAuthenticated: boolean; userId: string | null } = {
+ isAuthenticated: true,
+ userId: 'u1',
+};
+let shownIds: string[] = [];
+const dispatch = vi.fn();
+const fetchLatestAnnouncement = vi.fn();
+
+vi.mock('../../providers/CoreStateProvider', () => ({
+ useCoreState: () => ({ snapshot: { auth: authState } }),
+}));
+
+vi.mock('../../services/announcementService', () => ({
+ fetchLatestAnnouncement: () => fetchLatestAnnouncement(),
+}));
+
+vi.mock('../../store/hooks', () => ({
+ useAppDispatch: () => dispatch,
+ // Run the real selector against our controllable state.
+ useAppSelector: (sel: (s: unknown) => unknown) => sel({ announcement: { shownIds } }),
+}));
+
+// Stub the modal so this suite tests gate logic, not presentation.
+vi.mock('./AnnouncementModal', () => ({
+ default: ({ announcement, onDismiss }: { announcement: Announcement; onDismiss: () => void }) => (
+
+ {announcement.title}
+
+
+ ),
+}));
+
+const sample: Announcement = {
+ id: 'a1',
+ title: 'Hello',
+ body: 'World',
+ severity: 'INFO',
+ cta: null,
+ startsAt: null,
+ expiresAt: null,
+ createdAt: null,
+};
+
+describe('AnnouncementGate', () => {
+ beforeEach(() => {
+ authState.isAuthenticated = true;
+ authState.userId = 'u1';
+ shownIds = [];
+ dispatch.mockClear();
+ fetchLatestAnnouncement.mockReset();
+ });
+
+ afterEach(() => vi.clearAllMocks());
+
+ it('shows the announcement once fetched when authenticated and unseen', async () => {
+ fetchLatestAnnouncement.mockResolvedValue(sample);
+ render();
+ expect(await screen.findByTestId('modal')).toBeInTheDocument();
+ expect(screen.getByText('Hello')).toBeInTheDocument();
+ });
+
+ it('does not fetch or render when unauthenticated', async () => {
+ authState.isAuthenticated = false;
+ render();
+ await waitFor(() => expect(fetchLatestAnnouncement).not.toHaveBeenCalled());
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('renders nothing when the fetched announcement was already shown', async () => {
+ shownIds = ['a1'];
+ fetchLatestAnnouncement.mockResolvedValue(sample);
+ render();
+ await waitFor(() => expect(fetchLatestAnnouncement).toHaveBeenCalled());
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('renders nothing when there is no active announcement', async () => {
+ fetchLatestAnnouncement.mockResolvedValue(null);
+ render();
+ await waitFor(() => expect(fetchLatestAnnouncement).toHaveBeenCalled());
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('records the announcement as shown and hides it on dismiss', async () => {
+ fetchLatestAnnouncement.mockResolvedValue(sample);
+ render();
+ const dismiss = await screen.findByTestId('dismiss');
+ dismiss.click();
+ expect(dispatch).toHaveBeenCalledWith(markAnnouncementShown('a1'));
+ await waitFor(() => expect(screen.queryByTestId('modal')).not.toBeInTheDocument());
+ });
+
+ it('swallows a fetch error without rendering', async () => {
+ fetchLatestAnnouncement.mockRejectedValue(new Error('boom'));
+ render();
+ await waitFor(() => expect(fetchLatestAnnouncement).toHaveBeenCalled());
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/Announcement/AnnouncementGate.tsx b/app/src/components/Announcement/AnnouncementGate.tsx
new file mode 100644
index 000000000..8bbc598bb
--- /dev/null
+++ b/app/src/components/Announcement/AnnouncementGate.tsx
@@ -0,0 +1,74 @@
+import debugFactory from 'debug';
+import { useEffect, useRef, useState } from 'react';
+
+import { useCoreState } from '../../providers/CoreStateProvider';
+import { type Announcement, fetchLatestAnnouncement } from '../../services/announcementService';
+import { markAnnouncementShown } from '../../store/announcementSlice';
+import { useAppDispatch, useAppSelector } from '../../store/hooks';
+import AnnouncementModal from './AnnouncementModal';
+
+const log = debugFactory('announcement');
+
+/**
+ * Fetches the latest active announcement once the user is authenticated and,
+ * if it hasn't been seen before (per-user, persisted), shows it once over the
+ * app. The modal sits at z-[9998] — just below the harness-init overlay
+ * (z-[9999]) — so during first-run setup the init screen covers it and the
+ * announcement becomes visible only once init finishes.
+ */
+export default function AnnouncementGate() {
+ const dispatch = useAppDispatch();
+ const { snapshot } = useCoreState();
+ const isAuthenticated = snapshot.auth.isAuthenticated;
+ const userId = snapshot.auth.userId ?? null;
+ const shownIds = useAppSelector(state => state.announcement.shownIds);
+
+ const [announcement, setAnnouncement] = useState(null);
+ // One fetch per (user) auth session — keyed by userId so a user switch refetches.
+ const fetchedForRef = useRef(null);
+
+ useEffect(() => {
+ if (!isAuthenticated) {
+ // Reset on sign-out so the next sign-in fetches again. The render guard
+ // below hides any stale announcement while signed out (no setState here).
+ fetchedForRef.current = null;
+ return;
+ }
+
+ const key = userId ?? 'authenticated';
+ if (fetchedForRef.current === key) {
+ return;
+ }
+ fetchedForRef.current = key;
+
+ let cancelled = false;
+ void (async () => {
+ try {
+ const latest = await fetchLatestAnnouncement();
+ if (cancelled || !latest) {
+ return;
+ }
+ setAnnouncement(latest);
+ log('fetched announcement %s', latest.id);
+ } catch (err) {
+ // A missing/failed announcement is never fatal — just don't show one.
+ log('fetch failed: %O', err);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [isAuthenticated, userId]);
+
+ if (!isAuthenticated || !announcement || shownIds.includes(announcement.id)) {
+ return null;
+ }
+
+ const handleDismiss = () => {
+ dispatch(markAnnouncementShown(announcement.id));
+ setAnnouncement(null);
+ };
+
+ return ;
+}
diff --git a/app/src/components/Announcement/AnnouncementModal.test.tsx b/app/src/components/Announcement/AnnouncementModal.test.tsx
new file mode 100644
index 000000000..c4b8aa0a5
--- /dev/null
+++ b/app/src/components/Announcement/AnnouncementModal.test.tsx
@@ -0,0 +1,74 @@
+import { fireEvent, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import type { Announcement } from '../../services/announcementService';
+import { renderWithProviders } from '../../test/test-utils';
+import AnnouncementModal from './AnnouncementModal';
+
+function announcement(overrides: Partial = {}): Announcement {
+ return {
+ id: 'a1',
+ title: 'Scheduled maintenance',
+ body: 'We will be down tonight.',
+ severity: 'INFO',
+ cta: null,
+ startsAt: null,
+ expiresAt: null,
+ createdAt: null,
+ ...overrides,
+ };
+}
+
+describe('AnnouncementModal', () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ it('renders the title, body, and severity label', () => {
+ renderWithProviders();
+ expect(screen.getByText('Scheduled maintenance')).toBeInTheDocument();
+ expect(screen.getByText('We will be down tonight.')).toBeInTheDocument();
+ expect(screen.getByText('Info')).toBeInTheDocument();
+ });
+
+ it.each([
+ ['INFO', 'Info'],
+ ['WARNING', 'Important'],
+ ['CRITICAL', 'Critical'],
+ ] as const)('maps severity %s to the %s badge', (severity, label) => {
+ renderWithProviders(
+
+ );
+ expect(screen.getByText(label)).toBeInTheDocument();
+ });
+
+ it('calls onDismiss when the dismiss button is clicked', () => {
+ const onDismiss = vi.fn();
+ renderWithProviders();
+ fireEvent.click(screen.getByTestId('announcement-dismiss'));
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders no CTA button when there is no cta', () => {
+ renderWithProviders(
+
+ );
+ expect(screen.queryByTestId('announcement-cta')).not.toBeInTheDocument();
+ });
+
+ it('opens the CTA externally and dismisses when the CTA is clicked', () => {
+ const onDismiss = vi.fn();
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
+ renderWithProviders(
+
+ );
+
+ const cta = screen.getByTestId('announcement-cta');
+ expect(cta).toHaveTextContent('Read more');
+
+ fireEvent.click(cta);
+ expect(openSpy).toHaveBeenCalledWith('https://x.test/', '_blank', 'noopener,noreferrer');
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/app/src/components/Announcement/AnnouncementModal.tsx b/app/src/components/Announcement/AnnouncementModal.tsx
new file mode 100644
index 000000000..726bf3f23
--- /dev/null
+++ b/app/src/components/Announcement/AnnouncementModal.tsx
@@ -0,0 +1,81 @@
+import { useT } from '../../lib/i18n/I18nContext';
+import type { Announcement, AnnouncementSeverity } from '../../services/announcementService';
+
+interface AnnouncementModalProps {
+ announcement: Announcement;
+ onDismiss: () => void;
+}
+
+// Severity drives the accent band + icon tint. Tokens mirror the app palette
+// (primary/amber/coral) used elsewhere (see InitProgressScreen).
+const severityAccent: Record = {
+ INFO: 'border-primary-500/30 bg-primary-500/10 text-primary-300',
+ WARNING: 'border-amber-500/30 bg-amber-500/10 text-amber-300',
+ CRITICAL: 'border-coral-500/30 bg-coral-500/10 text-coral-300',
+};
+
+const severityLabel: Record = {
+ INFO: 'Info',
+ WARNING: 'Important',
+ CRITICAL: 'Critical',
+};
+
+/**
+ * One-shot announcement banner shown over the app after harness init. Title,
+ * body, and the optional CTA are backend-provided (not i18n); only the dismiss
+ * affordance is localized.
+ */
+export default function AnnouncementModal({ announcement, onDismiss }: AnnouncementModalProps) {
+ const { t } = useT();
+ const accent = severityAccent[announcement.severity];
+
+ const openCta = () => {
+ if (!announcement.cta) {
+ return;
+ }
+ // Open externally; never navigate the app shell to a backend-provided URL.
+ window.open(announcement.cta.url, '_blank', 'noopener,noreferrer');
+ onDismiss();
+ };
+
+ return (
+
+
+
+ {severityLabel[announcement.severity]}
+
+
+
+ {announcement.title}
+
+
{announcement.body}
+
+
+
+ {announcement.cta && (
+
+ )}
+
+
+
+ );
+}
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts
index 2bbf695e2..7e79bdfa6 100644
--- a/app/src/lib/i18n/ar.ts
+++ b/app/src/lib/i18n/ar.ts
@@ -5630,6 +5630,8 @@ const messages: TranslationMap = {
'memory.tab.cohesion': 'Cohesion',
'harnessInit.title': 'جارٍ الإعداد',
+
+ 'announcement.gotIt': 'حسناً',
'harnessInit.subtitle': 'يقوم OpenHuman بتجهيز المكونات التي يحتاجها عند التشغيل الأول.',
'harnessInit.stepPython': 'بيئة تشغيل Python',
'harnessInit.stepSpacy': 'النموذج اللغوي',
diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts
index 21ff7d350..935dd3d65 100644
--- a/app/src/lib/i18n/bn.ts
+++ b/app/src/lib/i18n/bn.ts
@@ -5747,6 +5747,7 @@ const messages: TranslationMap = {
// Keyring consent & security
'harnessInit.title': 'সেটআপ করা হচ্ছে',
+ 'announcement.gotIt': 'বুঝেছি',
'harnessInit.subtitle': 'প্রথম চালুর সময় OpenHuman প্রয়োজনীয় উপাদানগুলো প্রস্তুত করছে।',
'harnessInit.stepPython': 'Python রানটাইম',
'harnessInit.stepSpacy': 'ভাষা মডেল',
diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts
index 41f2bbe09..89de9cd26 100644
--- a/app/src/lib/i18n/de.ts
+++ b/app/src/lib/i18n/de.ts
@@ -5893,6 +5893,8 @@ const messages: TranslationMap = {
'memory.tab.cohesion': 'Cohesion',
'harnessInit.title': 'Einrichtung läuft',
+
+ 'announcement.gotIt': 'Verstanden',
'harnessInit.subtitle': 'OpenHuman bereitet beim ersten Start benötigte Komponenten vor.',
'harnessInit.stepPython': 'Python-Laufzeitumgebung',
'harnessInit.stepSpacy': 'Sprachmodell',
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index 53412ac6d..f51bda630 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -6271,6 +6271,7 @@ const en: TranslationMap = {
// First-run initialization (harness_init)
'harnessInit.title': 'Setting things up',
+ 'announcement.gotIt': 'Got it',
'harnessInit.subtitle': 'OpenHuman is preparing components it needs on first launch.',
'harnessInit.stepPython': 'Python runtime',
'harnessInit.stepSpacy': 'Language model',
diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts
index f8b4aa53d..5453b3da7 100644
--- a/app/src/lib/i18n/es.ts
+++ b/app/src/lib/i18n/es.ts
@@ -5857,6 +5857,8 @@ const messages: TranslationMap = {
'memory.tab.cohesion': 'Cohesion',
'harnessInit.title': 'Preparando todo',
+
+ 'announcement.gotIt': 'Entendido',
'harnessInit.subtitle':
'OpenHuman está preparando los componentes que necesita en el primer inicio.',
'harnessInit.stepPython': 'Entorno de ejecución de Python',
diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts
index 3636e4b55..c1d32d862 100644
--- a/app/src/lib/i18n/fr.ts
+++ b/app/src/lib/i18n/fr.ts
@@ -5875,6 +5875,8 @@ const messages: TranslationMap = {
'memory.tab.cohesion': 'Cohesion',
'harnessInit.title': 'Configuration en cours',
+
+ 'announcement.gotIt': 'Compris',
'harnessInit.subtitle':
'OpenHuman prépare les composants dont il a besoin lors du premier lancement.',
'harnessInit.stepPython': "Environnement d'exécution Python",
diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts
index 56f61fc92..4ac9c13f6 100644
--- a/app/src/lib/i18n/hi.ts
+++ b/app/src/lib/i18n/hi.ts
@@ -5750,6 +5750,8 @@ const messages: TranslationMap = {
'memory.tab.cohesion': 'Cohesion',
'harnessInit.title': 'सेटअप किया जा रहा है',
+
+ 'announcement.gotIt': 'समझ गया',
'harnessInit.subtitle': 'OpenHuman पहली बार शुरू होने पर आवश्यक घटक तैयार कर रहा है।',
'harnessInit.stepPython': 'Python रनटाइम',
'harnessInit.stepSpacy': 'भाषा मॉडल',
diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts
index e896cc4b3..c49afe3c6 100644
--- a/app/src/lib/i18n/id.ts
+++ b/app/src/lib/i18n/id.ts
@@ -5762,6 +5762,8 @@ const messages: TranslationMap = {
'memory.tab.cohesion': 'Cohesion',
'harnessInit.title': 'Menyiapkan semuanya',
+
+ 'announcement.gotIt': 'Mengerti',
'harnessInit.subtitle':
'OpenHuman sedang menyiapkan komponen yang dibutuhkan saat pertama kali dijalankan.',
'harnessInit.stepPython': 'Runtime Python',
diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts
index d3a5e65d9..cc466b166 100644
--- a/app/src/lib/i18n/it.ts
+++ b/app/src/lib/i18n/it.ts
@@ -5842,6 +5842,8 @@ const messages: TranslationMap = {
'memory.tab.cohesion': 'Cohesion',
'harnessInit.title': 'Preparazione in corso',
+
+ 'announcement.gotIt': 'Ho capito',
'harnessInit.subtitle': 'OpenHuman sta preparando i componenti necessari al primo avvio.',
'harnessInit.stepPython': 'Ambiente di esecuzione Python',
'harnessInit.stepSpacy': 'Modello linguistico',
diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts
index e23b41e3f..3077d5b08 100644
--- a/app/src/lib/i18n/ko.ts
+++ b/app/src/lib/i18n/ko.ts
@@ -5692,6 +5692,8 @@ const messages: TranslationMap = {
'memory.tab.cohesion': 'Cohesion',
'harnessInit.title': '설정하는 중',
+
+ 'announcement.gotIt': '확인',
'harnessInit.subtitle': 'OpenHuman이 처음 실행에 필요한 구성 요소를 준비하고 있습니다.',
'harnessInit.stepPython': 'Python 런타임',
'harnessInit.stepSpacy': '언어 모델',
diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts
index 2f2bdf210..50eed5d3a 100644
--- a/app/src/lib/i18n/pl.ts
+++ b/app/src/lib/i18n/pl.ts
@@ -5832,6 +5832,8 @@ const messages: TranslationMap = {
'chat.files.error.delete_failed': 'Nie udało się usunąć pliku. Prosimy spróbować ponownie.',
'harnessInit.title': 'Trwa konfiguracja',
+
+ 'announcement.gotIt': 'Rozumiem',
'harnessInit.subtitle':
'OpenHuman przygotowuje komponenty potrzebne przy pierwszym uruchomieniu.',
'harnessInit.stepPython': 'Środowisko uruchomieniowe Python',
diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts
index 29935cc5f..0a68f9f68 100644
--- a/app/src/lib/i18n/pt.ts
+++ b/app/src/lib/i18n/pt.ts
@@ -5838,6 +5838,8 @@ const messages: TranslationMap = {
'memory.tab.cohesion': 'Cohesion',
'harnessInit.title': 'Preparando tudo',
+
+ 'announcement.gotIt': 'Entendi',
'harnessInit.subtitle':
'O OpenHuman está preparando os componentes necessários na primeira inicialização.',
'harnessInit.stepPython': 'Ambiente de execução Python',
diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts
index c82e15881..154c4fcd5 100644
--- a/app/src/lib/i18n/ru.ts
+++ b/app/src/lib/i18n/ru.ts
@@ -5801,6 +5801,8 @@ const messages: TranslationMap = {
'memory.tab.cohesion': 'Cohesion',
'harnessInit.title': 'Идёт настройка',
+
+ 'announcement.gotIt': 'Понятно',
'harnessInit.subtitle': 'OpenHuman готовит компоненты, необходимые при первом запуске.',
'harnessInit.stepPython': 'Среда выполнения Python',
'harnessInit.stepSpacy': 'Языковая модель',
diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts
index a0a2281dc..ad0b44e2e 100644
--- a/app/src/lib/i18n/zh-CN.ts
+++ b/app/src/lib/i18n/zh-CN.ts
@@ -5458,6 +5458,8 @@ const messages: TranslationMap = {
'memory.tab.cohesion': 'Cohesion',
'harnessInit.title': '正在进行设置',
+
+ 'announcement.gotIt': '知道了',
'harnessInit.subtitle': 'OpenHuman 正在准备首次启动所需的组件。',
'harnessInit.stepPython': 'Python 运行时',
'harnessInit.stepSpacy': '语言模型',
diff --git a/app/src/services/announcementService.test.ts b/app/src/services/announcementService.test.ts
new file mode 100644
index 000000000..dfecb27d1
--- /dev/null
+++ b/app/src/services/announcementService.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { fetchLatestAnnouncement, parseAnnouncement } from './announcementService';
+import { callCoreRpc } from './coreRpcClient';
+
+vi.mock('./coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
+
+describe('parseAnnouncement', () => {
+ const valid = {
+ id: 'a1',
+ title: 'Heads up',
+ body: 'Maintenance tonight',
+ severity: 'WARNING',
+ cta: { label: 'Read more', url: 'https://x.test' },
+ startsAt: '2030-01-01T00:00:00.000Z',
+ expiresAt: null,
+ createdAt: '2030-01-01T00:00:00.000Z',
+ };
+
+ it('parses a complete announcement', () => {
+ expect(parseAnnouncement(valid)).toEqual({
+ id: 'a1',
+ title: 'Heads up',
+ body: 'Maintenance tonight',
+ severity: 'WARNING',
+ cta: { label: 'Read more', url: 'https://x.test' },
+ startsAt: '2030-01-01T00:00:00.000Z',
+ expiresAt: null,
+ createdAt: '2030-01-01T00:00:00.000Z',
+ });
+ });
+
+ it('accepts _id as the id (lean docs)', () => {
+ const { id: _omit, ...rest } = valid;
+ void _omit;
+ expect(parseAnnouncement({ ...rest, _id: 'a2' })?.id).toBe('a2');
+ });
+
+ it('returns null for the empty object the backend sends when none is active', () => {
+ // parse_api_response_json collapses {success:true,data:null} -> {}.
+ expect(parseAnnouncement({})).toBeNull();
+ });
+
+ it.each([null, undefined, 'nope', 42])('returns null for non-object %p', raw => {
+ expect(parseAnnouncement(raw)).toBeNull();
+ });
+
+ it('returns null when title or body is missing', () => {
+ expect(parseAnnouncement({ id: 'a1', body: 'b' })).toBeNull();
+ expect(parseAnnouncement({ id: 'a1', title: 't' })).toBeNull();
+ });
+
+ it('falls back to INFO for an unknown severity', () => {
+ expect(parseAnnouncement({ id: 'a1', title: 't', body: 'b', severity: 'LOUD' })?.severity).toBe(
+ 'INFO'
+ );
+ });
+
+ it('drops a partial CTA (label or url missing)', () => {
+ expect(
+ parseAnnouncement({ id: 'a1', title: 't', body: 'b', cta: { label: 'x' } })?.cta
+ ).toBeNull();
+ expect(
+ parseAnnouncement({ id: 'a1', title: 't', body: 'b', cta: { url: 'https://x.test' } })?.cta
+ ).toBeNull();
+ });
+});
+
+describe('fetchLatestAnnouncement', () => {
+ it('calls the announcements RPC and parses the result', async () => {
+ vi.mocked(callCoreRpc).mockResolvedValueOnce({ id: 'a1', title: 't', body: 'b' });
+ const result = await fetchLatestAnnouncement();
+ expect(callCoreRpc).toHaveBeenCalledWith(
+ expect.objectContaining({ method: 'openhuman.announcements_get_latest' })
+ );
+ expect(result?.id).toBe('a1');
+ });
+
+ it('returns null when the backend reports no active announcement', async () => {
+ vi.mocked(callCoreRpc).mockResolvedValueOnce({});
+ expect(await fetchLatestAnnouncement()).toBeNull();
+ });
+});
diff --git a/app/src/services/announcementService.ts b/app/src/services/announcementService.ts
new file mode 100644
index 000000000..d1ffdec88
--- /dev/null
+++ b/app/src/services/announcementService.ts
@@ -0,0 +1,85 @@
+/**
+ * Announcement service
+ *
+ * Thin RPC wrapper around the core `announcements_get_latest` controller, plus
+ * parsing into a typed announcement. The core proxies the backend's
+ * `GET /announcements/latest`, which returns the announcement object or, when
+ * nothing qualifies, an empty/`null` payload. We treat "no string id" as "no
+ * announcement" so the UI never renders an empty banner.
+ */
+import { callCoreRpc } from './coreRpcClient';
+
+export type AnnouncementSeverity = 'INFO' | 'WARNING' | 'CRITICAL';
+
+export interface AnnouncementCta {
+ label: string;
+ url: string;
+}
+
+export interface Announcement {
+ id: string;
+ title: string;
+ body: string;
+ severity: AnnouncementSeverity;
+ cta: AnnouncementCta | null;
+ startsAt: string | null;
+ expiresAt: string | null;
+ createdAt: string | null;
+}
+
+const SEVERITIES: AnnouncementSeverity[] = ['INFO', 'WARNING', 'CRITICAL'];
+
+function asString(value: unknown): string | null {
+ return typeof value === 'string' && value.length > 0 ? value : null;
+}
+
+function parseCta(raw: unknown): AnnouncementCta | null {
+ if (!raw || typeof raw !== 'object') {
+ return null;
+ }
+ const data = raw as Record;
+ const label = asString(data.label);
+ const url = asString(data.url);
+ if (!label || !url) {
+ return null;
+ }
+ return { label, url };
+}
+
+export function parseAnnouncement(raw: unknown): Announcement | null {
+ if (!raw || typeof raw !== 'object') {
+ return null;
+ }
+ const data = raw as Record;
+ const id = asString(data.id) ?? asString(data._id);
+ const title = asString(data.title);
+ const body = asString(data.body);
+ // The backend collapses "no active announcement" to an empty object; without
+ // an id (and the core fields) there is nothing to show.
+ if (!id || !title || !body) {
+ return null;
+ }
+ const severity = asString(data.severity) as AnnouncementSeverity | null;
+ return {
+ id,
+ title,
+ body,
+ severity: severity && SEVERITIES.includes(severity) ? severity : 'INFO',
+ cta: parseCta(data.cta),
+ startsAt: asString(data.startsAt),
+ expiresAt: asString(data.expiresAt),
+ createdAt: asString(data.createdAt),
+ };
+}
+
+/**
+ * Fetch the latest active announcement, or null. Network/auth failures resolve
+ * to null — a missing announcement is never worth surfacing an error for.
+ */
+export async function fetchLatestAnnouncement(): Promise {
+ const payload = await callCoreRpc({
+ method: 'openhuman.announcements_get_latest',
+ timeoutMs: 8000,
+ });
+ return parseAnnouncement(payload);
+}
diff --git a/app/src/store/announcementSlice.test.ts b/app/src/store/announcementSlice.test.ts
new file mode 100644
index 000000000..4b7fac24e
--- /dev/null
+++ b/app/src/store/announcementSlice.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest';
+
+import reducer, { type AnnouncementState, markAnnouncementShown } from './announcementSlice';
+
+const initial: AnnouncementState = { shownIds: [] };
+
+describe('announcementSlice', () => {
+ it('records a shown announcement id', () => {
+ const next = reducer(initial, markAnnouncementShown('a1'));
+ expect(next.shownIds).toEqual(['a1']);
+ });
+
+ it('does not duplicate an already-seen id', () => {
+ const once = reducer(initial, markAnnouncementShown('a1'));
+ const twice = reducer(once, markAnnouncementShown('a1'));
+ expect(twice.shownIds).toEqual(['a1']);
+ });
+
+ it('ignores an empty id', () => {
+ expect(reducer(initial, markAnnouncementShown('')).shownIds).toEqual([]);
+ });
+
+ it('caps the history at 200, dropping the oldest', () => {
+ let state = initial;
+ for (let i = 0; i < 205; i += 1) {
+ state = reducer(state, markAnnouncementShown(`id-${i}`));
+ }
+ expect(state.shownIds).toHaveLength(200);
+ expect(state.shownIds[0]).toBe('id-5');
+ expect(state.shownIds.at(-1)).toBe('id-204');
+ });
+});
diff --git a/app/src/store/announcementSlice.ts b/app/src/store/announcementSlice.ts
new file mode 100644
index 000000000..ba96ea31a
--- /dev/null
+++ b/app/src/store/announcementSlice.ts
@@ -0,0 +1,36 @@
+import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
+
+/**
+ * Tracks which announcements this user has already seen so the harness-init
+ * banner shows each one exactly once. Persisted through `userScopedStorage`
+ * (see store/index.ts) so the seen set is per-user and survives reloads.
+ */
+export interface AnnouncementState {
+ shownIds: string[];
+}
+
+const initialState: AnnouncementState = { shownIds: [] };
+
+const MAX_TRACKED = 200;
+
+const announcementSlice = createSlice({
+ name: 'announcement',
+ initialState,
+ reducers: {
+ markAnnouncementShown(state, action: PayloadAction) {
+ const id = action.payload;
+ if (!id || state.shownIds.includes(id)) {
+ return;
+ }
+ state.shownIds.push(id);
+ // Cap the history so a long-lived install can't grow it unbounded; the
+ // oldest ids fall off first (they're the least likely to reappear).
+ if (state.shownIds.length > MAX_TRACKED) {
+ state.shownIds.splice(0, state.shownIds.length - MAX_TRACKED);
+ }
+ },
+ },
+});
+
+export const { markAnnouncementShown } = announcementSlice.actions;
+export default announcementSlice.reducer;
diff --git a/app/src/store/index.ts b/app/src/store/index.ts
index 8d79df315..cb0019d41 100644
--- a/app/src/store/index.ts
+++ b/app/src/store/index.ts
@@ -15,6 +15,7 @@ import {
import { E2E_RESTART_APP_AS_RELOAD, IS_DEV } from '../utils/config';
import accountsReducer from './accountsSlice';
import agentProfileReducer from './agentProfileSlice';
+import announcementReducer from './announcementSlice';
import {
type ArtifactsByThread,
filterArtifactsForPersist,
@@ -208,6 +209,11 @@ const chatRuntimePersistConfig = {
};
const persistedChatRuntimeReducer = persistReducer(chatRuntimePersistConfig, chatRuntimeReducer);
+// Persist the set of announcement ids this user has already seen so the
+// harness-init banner shows each announcement exactly once (user-scoped).
+const announcementPersistConfig = { key: 'announcement', storage, whitelist: ['shownIds'] };
+const persistedAnnouncementReducer = persistReducer(announcementPersistConfig, announcementReducer);
+
export const store = configureStore({
reducer: {
backendMeet: backendMeetReducer,
@@ -228,6 +234,7 @@ export const store = configureStore({
persona: persistedPersonaReducer,
theme: persistedThemeReducer,
ptt: persistedPttReducer,
+ announcement: persistedAnnouncementReducer,
// In-memory only (not persisted): survives route changes / background-job
// completion, resets on restart + user switch. Durable storage is a #3931
// follow-up.
diff --git a/src/core/all.rs b/src/core/all.rs
index 3ccaf5da5..75dfc4552 100644
--- a/src/core/all.rs
+++ b/src/core/all.rs
@@ -247,6 +247,8 @@ fn build_registered_controllers() -> Vec {
controllers.extend(crate::openhuman::referral::all_referral_registered_controllers());
// Billing and subscription management
controllers.extend(crate::openhuman::billing::all_billing_registered_controllers());
+ // Announcements surfaced on harness init
+ controllers.extend(crate::openhuman::announcements::all_announcements_registered_controllers());
// Team and role management
controllers.extend(crate::openhuman::team::all_team_registered_controllers());
// E2E test support — `openhuman.test_reset` wipes sidecar state in-place.
@@ -422,6 +424,7 @@ fn build_declared_controller_schemas() -> Vec {
schemas.extend(crate::openhuman::redirect_links::all_redirect_links_controller_schemas());
schemas.extend(crate::openhuman::referral::all_referral_controller_schemas());
schemas.extend(crate::openhuman::billing::all_billing_controller_schemas());
+ schemas.extend(crate::openhuman::announcements::all_announcements_controller_schemas());
schemas.extend(crate::openhuman::team::all_team_controller_schemas());
#[cfg(feature = "e2e-test-support")]
schemas.extend(crate::openhuman::test_support::all_test_support_controller_schemas());
@@ -573,6 +576,9 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
"Durable agent-team coordination: teams, members, dependency-aware task claiming, and teammate messaging.",
),
"billing" => Some("Subscription plan, payment links, and credit top-up via the backend."),
+ "announcements" => {
+ Some("Latest active product announcement surfaced on harness init, via the backend.")
+ }
"team" => Some("Team member management, invites, and role changes via the backend."),
"tool_registry" => Some(
"Read-only discovery for MCP stdio tools and controller-backed tools, including routes, schemas, version, allowed agents, and health.",
diff --git a/src/openhuman/announcements/mod.rs b/src/openhuman/announcements/mod.rs
new file mode 100644
index 000000000..3bc0cdd47
--- /dev/null
+++ b/src/openhuman/announcements/mod.rs
@@ -0,0 +1,14 @@
+//! Announcements RPC adapter — thin-wraps the hosted API.
+//!
+//! Exposes the latest active announcement for the signed-in user through the
+//! standard controller registry (`openhuman.announcements_*`). The UI surfaces
+//! it once on harness init and tracks dismissal locally by id.
+
+mod ops;
+mod schemas;
+
+pub use ops::*;
+pub use schemas::{
+ all_announcements_controller_schemas, all_announcements_registered_controllers,
+ announcements_schemas,
+};
diff --git a/src/openhuman/announcements/ops.rs b/src/openhuman/announcements/ops.rs
new file mode 100644
index 000000000..11775a2db
--- /dev/null
+++ b/src/openhuman/announcements/ops.rs
@@ -0,0 +1,39 @@
+//! Announcements RPC ops — a thin adapter that calls the hosted API.
+//!
+//! # Security
+//! Requires a valid app-session JWT stored via `auth_store_session` (same guard
+//! as `billing/ops.rs`). The JWT is sent as `Authorization: Bearer …`; the
+//! backend decides what the user may see. No authorization is replicated here.
+//! A lapsed session surfaces the backend 401 verbatim via `flatten_authed_error`.
+
+use reqwest::Method;
+use serde_json::Value;
+
+use crate::api::config::effective_backend_api_url;
+use crate::api::BackendOAuthClient;
+use crate::openhuman::config::Config;
+use crate::rpc::RpcOutcome;
+
+/// Canonical authed-session guard — rejects an expired token locally instead of
+/// firing a doomed backend 401 (see `billing/ops.rs` / #3297).
+fn require_token(config: &Config) -> Result {
+ crate::openhuman::credentials::session_support::require_live_session_token(config)
+}
+
+async fn get_authed_value(config: &Config, method: Method, path: &str) -> Result {
+ let token = require_token(config)?;
+ let api_url = effective_backend_api_url(&config.api_url);
+ let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?;
+ client
+ .authed_json(&token, method, path, None)
+ .await
+ .map_err(crate::api::flatten_authed_error)
+}
+
+/// Fetch the latest active announcement for the signed-in user.
+/// Maps to `GET /announcements/latest`. The backend returns the announcement
+/// object or `null` when nothing qualifies; both pass through verbatim.
+pub async fn get_latest_announcement(config: &Config) -> Result, String> {
+ let data = get_authed_value(config, Method::GET, "/announcements/latest").await?;
+ Ok(RpcOutcome::single_log(data, "latest announcement fetched"))
+}
diff --git a/src/openhuman/announcements/schemas.rs b/src/openhuman/announcements/schemas.rs
new file mode 100644
index 000000000..891f5419f
--- /dev/null
+++ b/src/openhuman/announcements/schemas.rs
@@ -0,0 +1,91 @@
+use serde_json::{Map, Value};
+
+use crate::core::all::{ControllerFuture, RegisteredController};
+use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
+use crate::openhuman::config::rpc as config_rpc;
+use crate::rpc::RpcOutcome;
+
+pub fn all_announcements_controller_schemas() -> Vec {
+ vec![announcements_schemas("announcements_get_latest")]
+}
+
+pub fn all_announcements_registered_controllers() -> Vec {
+ vec![RegisteredController {
+ schema: announcements_schemas("announcements_get_latest"),
+ handler: handle_announcements_get_latest,
+ }]
+}
+
+pub fn announcements_schemas(function: &str) -> ControllerSchema {
+ match function {
+ "announcements_get_latest" => ControllerSchema {
+ namespace: "announcements",
+ function: "get_latest",
+ description: "Fetch the latest active announcement for the signed-in user (or null).",
+ inputs: vec![],
+ outputs: vec![json_output(
+ "announcement",
+ "Latest active announcement from backend /announcements/latest, or null when none.",
+ )],
+ },
+ _ => ControllerSchema {
+ namespace: "announcements",
+ function: "unknown",
+ description: "Unknown announcements controller.",
+ inputs: vec![],
+ outputs: vec![FieldSchema {
+ name: "error",
+ ty: TypeSchema::String,
+ comment: "Lookup error details.",
+ required: true,
+ }],
+ },
+ }
+}
+
+fn handle_announcements_get_latest(_params: Map) -> ControllerFuture {
+ Box::pin(async move {
+ let config = config_rpc::load_config_with_timeout().await?;
+ to_json(crate::openhuman::announcements::get_latest_announcement(&config).await?)
+ })
+}
+
+fn to_json(outcome: RpcOutcome) -> Result {
+ outcome.into_cli_compatible_json()
+}
+
+fn json_output(name: &'static str, comment: &'static str) -> FieldSchema {
+ FieldSchema {
+ name,
+ ty: TypeSchema::Json,
+ comment,
+ required: true,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn get_latest_schema_has_expected_namespace_and_no_inputs() {
+ let schema = announcements_schemas("announcements_get_latest");
+ assert_eq!(schema.namespace, "announcements");
+ assert_eq!(schema.function, "get_latest");
+ assert!(schema.inputs.is_empty());
+ assert_eq!(schema.outputs.len(), 1);
+ assert_eq!(schema.outputs[0].name, "announcement");
+ }
+
+ #[test]
+ fn unknown_function_falls_back() {
+ let schema = announcements_schemas("nope");
+ assert_eq!(schema.function, "unknown");
+ }
+
+ #[test]
+ fn registers_exactly_one_controller() {
+ assert_eq!(all_announcements_registered_controllers().len(), 1);
+ assert_eq!(all_announcements_controller_schemas().len(), 1);
+ }
+}
diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs
index a5fafce3a..92cdd4ddf 100644
--- a/src/openhuman/mod.rs
+++ b/src/openhuman/mod.rs
@@ -24,6 +24,7 @@ pub mod agent_orchestration;
pub mod agent_registry;
pub mod agent_tool_policy;
pub mod agentbox;
+pub mod announcements;
pub mod app_state;
pub mod approval;
pub mod artifacts;