feat(announcements): show latest announcement once on harness init (#4125)

This commit is contained in:
Steven Enamakel
2026-06-25 12:41:34 -07:00
committed by GitHub
parent 58598da3a3
commit f4674078bc
29 changed files with 760 additions and 0 deletions
+2
View File
@@ -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 && <AppUpdatePrompt />}
<KeyringConsentOverlay />
<HarnessInitOverlay />
<AnnouncementGate />
<SecretPromptDialog />
</ServiceBlockingGate>
</CommandProvider>
@@ -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 }) => (
<div data-testid="modal">
<span>{announcement.title}</span>
<button type="button" data-testid="dismiss" onClick={onDismiss}>
dismiss
</button>
</div>
),
}));
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(<AnnouncementGate />);
expect(await screen.findByTestId('modal')).toBeInTheDocument();
expect(screen.getByText('Hello')).toBeInTheDocument();
});
it('does not fetch or render when unauthenticated', async () => {
authState.isAuthenticated = false;
render(<AnnouncementGate />);
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(<AnnouncementGate />);
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(<AnnouncementGate />);
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(<AnnouncementGate />);
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(<AnnouncementGate />);
await waitFor(() => expect(fetchLatestAnnouncement).toHaveBeenCalled());
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
});
@@ -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<Announcement | null>(null);
// One fetch per (user) auth session — keyed by userId so a user switch refetches.
const fetchedForRef = useRef<string | null>(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 <AnnouncementModal announcement={announcement} onDismiss={handleDismiss} />;
}
@@ -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> = {}): 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(<AnnouncementModal announcement={announcement()} onDismiss={vi.fn()} />);
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(
<AnnouncementModal announcement={announcement({ severity })} onDismiss={vi.fn()} />
);
expect(screen.getByText(label)).toBeInTheDocument();
});
it('calls onDismiss when the dismiss button is clicked', () => {
const onDismiss = vi.fn();
renderWithProviders(<AnnouncementModal announcement={announcement()} onDismiss={onDismiss} />);
fireEvent.click(screen.getByTestId('announcement-dismiss'));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it('renders no CTA button when there is no cta', () => {
renderWithProviders(
<AnnouncementModal announcement={announcement({ cta: null })} onDismiss={vi.fn()} />
);
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(
<AnnouncementModal
announcement={announcement({ cta: { label: 'Read more', url: 'https://x.test/' } })}
onDismiss={onDismiss}
/>
);
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);
});
});
@@ -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<AnnouncementSeverity, string> = {
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<AnnouncementSeverity, string> = {
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 (
<div
className="fixed inset-0 z-[9998] flex items-center justify-center bg-stone-950/90 p-4 backdrop-blur-sm"
data-testid="announcement-overlay">
<div
role="dialog"
aria-modal="true"
aria-labelledby="announcement-title"
className="w-full max-w-md rounded-2xl border border-stone-700/60 bg-stone-900 p-6 shadow-2xl">
<span
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-[11px] font-medium ${accent}`}>
{severityLabel[announcement.severity]}
</span>
<h2 id="announcement-title" className="mt-3 text-lg font-semibold text-white">
{announcement.title}
</h2>
<p className="mt-2 whitespace-pre-wrap text-sm text-stone-300">{announcement.body}</p>
<div className="mt-6 flex justify-end gap-2">
<button
type="button"
onClick={onDismiss}
data-testid="announcement-dismiss"
className="rounded-lg border border-stone-700 px-3 py-1.5 text-sm text-stone-300 hover:bg-stone-800 hover:text-white">
{t('announcement.gotIt')}
</button>
{announcement.cta && (
<button
type="button"
onClick={openCta}
data-testid="announcement-cta"
className="rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-500/90">
{announcement.cta.label}
</button>
)}
</div>
</div>
</div>
);
}
+2
View File
@@ -5630,6 +5630,8 @@ const messages: TranslationMap = {
'memory.tab.cohesion': 'Cohesion',
'harnessInit.title': 'جارٍ الإعداد',
'announcement.gotIt': 'حسناً',
'harnessInit.subtitle': 'يقوم OpenHuman بتجهيز المكونات التي يحتاجها عند التشغيل الأول.',
'harnessInit.stepPython': 'بيئة تشغيل Python',
'harnessInit.stepSpacy': 'النموذج اللغوي',
+1
View File
@@ -5747,6 +5747,7 @@ const messages: TranslationMap = {
// Keyring consent & security
'harnessInit.title': 'সেটআপ করা হচ্ছে',
'announcement.gotIt': 'বুঝেছি',
'harnessInit.subtitle': 'প্রথম চালুর সময় OpenHuman প্রয়োজনীয় উপাদানগুলো প্রস্তুত করছে।',
'harnessInit.stepPython': 'Python রানটাইম',
'harnessInit.stepSpacy': 'ভাষা মডেল',
+2
View File
@@ -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',
+1
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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",
+2
View File
@@ -5750,6 +5750,8 @@ const messages: TranslationMap = {
'memory.tab.cohesion': 'Cohesion',
'harnessInit.title': 'सेटअप किया जा रहा है',
'announcement.gotIt': 'समझ गया',
'harnessInit.subtitle': 'OpenHuman पहली बार शुरू होने पर आवश्यक घटक तैयार कर रहा है।',
'harnessInit.stepPython': 'Python रनटाइम',
'harnessInit.stepSpacy': 'भाषा मॉडल',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -5692,6 +5692,8 @@ const messages: TranslationMap = {
'memory.tab.cohesion': 'Cohesion',
'harnessInit.title': '설정하는 중',
'announcement.gotIt': '확인',
'harnessInit.subtitle': 'OpenHuman이 처음 실행에 필요한 구성 요소를 준비하고 있습니다.',
'harnessInit.stepPython': 'Python 런타임',
'harnessInit.stepSpacy': '언어 모델',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -5801,6 +5801,8 @@ const messages: TranslationMap = {
'memory.tab.cohesion': 'Cohesion',
'harnessInit.title': 'Идёт настройка',
'announcement.gotIt': 'Понятно',
'harnessInit.subtitle': 'OpenHuman готовит компоненты, необходимые при первом запуске.',
'harnessInit.stepPython': 'Среда выполнения Python',
'harnessInit.stepSpacy': 'Языковая модель',
+2
View File
@@ -5458,6 +5458,8 @@ const messages: TranslationMap = {
'memory.tab.cohesion': 'Cohesion',
'harnessInit.title': '正在进行设置',
'announcement.gotIt': '知道了',
'harnessInit.subtitle': 'OpenHuman 正在准备首次启动所需的组件。',
'harnessInit.stepPython': 'Python 运行时',
'harnessInit.stepSpacy': '语言模型',
@@ -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();
});
});
+85
View File
@@ -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<string, unknown>;
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<string, unknown>;
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<Announcement | null> {
const payload = await callCoreRpc<unknown>({
method: 'openhuman.announcements_get_latest',
timeoutMs: 8000,
});
return parseAnnouncement(payload);
}
+32
View File
@@ -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');
});
});
+36
View File
@@ -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<string>) {
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;
+7
View File
@@ -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.
+6
View File
@@ -247,6 +247,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
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<ControllerSchema> {
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.",
+14
View File
@@ -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,
};
+39
View File
@@ -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<String, String> {
crate::openhuman::credentials::session_support::require_live_session_token(config)
}
async fn get_authed_value(config: &Config, method: Method, path: &str) -> Result<Value, String> {
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<RpcOutcome<Value>, String> {
let data = get_authed_value(config, Method::GET, "/announcements/latest").await?;
Ok(RpcOutcome::single_log(data, "latest announcement fetched"))
}
+91
View File
@@ -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<ControllerSchema> {
vec![announcements_schemas("announcements_get_latest")]
}
pub fn all_announcements_registered_controllers() -> Vec<RegisteredController> {
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<String, Value>) -> 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<Value>) -> Result<Value, String> {
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);
}
}
+1
View File
@@ -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;