diff --git a/web/src/components/CloudBanner.tsx b/web/src/components/CloudBanner.tsx
new file mode 100644
index 000000000..119ea5dca
--- /dev/null
+++ b/web/src/components/CloudBanner.tsx
@@ -0,0 +1,224 @@
+import { useTranslation } from 'react-i18next';
+import { AlertCircle, Info, Wrench, X } from 'lucide-react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { backendClient } from '@/app/infra/http';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import type { ApiRespCloudBanner } from '@/app/infra/entities/api';
+import {
+ cloudBannerSignature,
+ resolveCloudBannerCopy,
+} from '@/lib/cloud-banner';
+
+const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000];
+// The dismissal is bound to the copy that was dismissed, so publishing a new
+// notice (or editing the current one) brings the banner back instead of
+// leaving every returning reader permanently blind to it.
+const DISMISSED_SIGNATURE_KEY = 'langbot_cloud_banner_dismissed';
+
+const TONE_CLASSES: Record
= {
+ info: 'bg-sky-50 dark:bg-sky-950/25 border-sky-200 dark:border-sky-900/50 text-sky-900 dark:text-sky-100 [&>svg]:text-sky-600 dark:[&>svg]:text-sky-400',
+ warning:
+ 'bg-amber-50 dark:bg-amber-950/25 border-amber-200 dark:border-amber-900/50 text-amber-900 dark:text-amber-100 [&>svg]:text-amber-600 dark:[&>svg]:text-amber-400',
+ maintenance:
+ 'bg-rose-50 dark:bg-rose-950/25 border-rose-200 dark:border-rose-900/50 text-rose-900 dark:text-rose-100 [&>svg]:text-rose-600 dark:[&>svg]:text-rose-400',
+};
+
+const TONE_ICONS: Record = {
+ info: Info,
+ warning: AlertCircle,
+ maintenance: Wrench,
+};
+
+const LOCALE_KEYS = [
+ 'zh-Hans',
+ 'zh-Hant',
+ 'en-US',
+ 'ja-JP',
+ 'th-TH',
+ 'vi-VN',
+ 'es-ES',
+ 'ru-RU',
+] as const;
+
+function currentLanguage(): string {
+ const candidates = [
+ typeof document !== 'undefined' ? document.documentElement.lang : '',
+ typeof navigator !== 'undefined' ? navigator.language : '',
+ ...LOCALE_KEYS,
+ ];
+ return (
+ candidates.find(
+ (value) => typeof value === 'string' && value.trim() !== '',
+ ) || 'en-US'
+ );
+}
+
+/**
+ * Instance notice banner for Cloud deployments.
+ *
+ * Rendered once by the root layout so every route (login included) sees the
+ * same announcement. It publishes its own height as ``--cloud-banner-height``
+ * so the fixed app shell starts below it rather than underneath it.
+ */
+export function CloudBanner() {
+ const { t, i18n } = useTranslation();
+ const bannerRef = useRef(null);
+ const [banner, setBanner] = useState(null);
+ const [dismissedSignature, setDismissedSignature] = useState(
+ () =>
+ typeof window === 'undefined'
+ ? null
+ : window.sessionStorage.getItem(DISMISSED_SIGNATURE_KEY),
+ );
+
+ useEffect(() => {
+ let cancelled = false;
+ let retryTimer: number | undefined;
+
+ const probe = async (attempt = 0) => {
+ try {
+ const info = await backendClient.getSystemInfo();
+ if (cancelled) {
+ return;
+ }
+ setBanner(
+ info.deployment_mode === 'cloud' && info.cloud_banner?.enabled
+ ? info.cloud_banner
+ : null,
+ );
+ } catch {
+ if (cancelled) {
+ return;
+ }
+ // A cold start or a restarting backend must not hide an active notice
+ // for the rest of the session, so retry with backoff and keep the last
+ // known state in between.
+ if (attempt < RETRY_DELAYS_MS.length) {
+ retryTimer = window.setTimeout(
+ () => void probe(attempt + 1),
+ RETRY_DELAYS_MS[attempt],
+ );
+ }
+ }
+ };
+
+ void probe();
+
+ const recheck = () => {
+ if (!cancelled) {
+ void probe();
+ }
+ };
+ window.addEventListener('focus', recheck);
+
+ return () => {
+ cancelled = true;
+ clearTimeout(retryTimer);
+ window.removeEventListener('focus', recheck);
+ };
+ }, []);
+
+ const language = i18n?.language || currentLanguage();
+ const customCopy = banner ? resolveCloudBannerCopy(banner, language) : null;
+ const message = customCopy ?? (banner ? t('cloud_banner.message') : '');
+ const signature = banner ? cloudBannerSignature(banner, message) : '';
+ const visible =
+ Boolean(banner && message) && signature !== dismissedSignature;
+
+ // Publish the occupied height so the sidebar/shell can offset itself.
+ useEffect(() => {
+ const root = document.documentElement;
+ if (!visible) {
+ root.style.removeProperty('--cloud-banner-height');
+ return;
+ }
+ const element = bannerRef.current;
+ if (!element) {
+ return;
+ }
+ const sync = () => {
+ root.style.setProperty(
+ '--cloud-banner-height',
+ `${element.getBoundingClientRect().height}px`,
+ );
+ };
+ sync();
+ const observer = new ResizeObserver(sync);
+ observer.observe(element);
+ return () => {
+ observer.disconnect();
+ root.style.removeProperty('--cloud-banner-height');
+ };
+ }, [visible, signature]);
+
+ const dismiss = useCallback(() => {
+ setDismissedSignature(signature);
+ try {
+ window.sessionStorage.setItem(DISMISSED_SIGNATURE_KEY, signature);
+ } catch {
+ // Private-mode storage failures must not break the dismissal itself.
+ }
+ }, [signature]);
+
+ if (!banner || !visible) {
+ return null;
+ }
+
+ const tone = banner.level in TONE_CLASSES ? banner.level : 'info';
+ const Icon = TONE_ICONS[tone];
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/ui/sidebar.tsx b/web/src/components/ui/sidebar.tsx
index d142aaf03..4277ff65e 100644
--- a/web/src/components/ui/sidebar.tsx
+++ b/web/src/components/ui/sidebar.tsx
@@ -203,7 +203,9 @@ function SidebarProvider({
{
'--sidebar-width': `${sidebarWidth}px`,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
- height: '100svh',
+ // A Cloud notice banner publishes its height so the shell starts
+ // below the announcement instead of underneath it.
+ height: 'calc(100svh - var(--cloud-banner-height, 0px))',
...style,
} as React.CSSProperties
}
@@ -300,8 +302,8 @@ function Sidebar({
,
+ language: string,
+): string | null {
+ const content = banner?.content || {};
+ for (const key of [cloudBannerLanguageKey(language), 'en_us']) {
+ const value = content[key];
+ if (typeof value === 'string' && value.trim() !== '') {
+ return value.trim();
+ }
+ }
+ return null;
+}
+
+/**
+ * Identity of the notice currently on screen. A dismissal is bound to this
+ * value so publishing different copy brings the banner back, while a reload
+ * of the same notice stays dismissed.
+ */
+export function cloudBannerSignature(
+ banner: Pick
,
+ message: string,
+): string {
+ return `${banner.level}:${message}`;
+}
diff --git a/web/tests/unit/cloud-banner.test.mjs b/web/tests/unit/cloud-banner.test.mjs
new file mode 100644
index 000000000..ab899bbae
--- /dev/null
+++ b/web/tests/unit/cloud-banner.test.mjs
@@ -0,0 +1,104 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { readFileSync } from 'node:fs';
+import ts from 'typescript';
+
+function loadTs(relativePath) {
+ const source = readFileSync(new URL(relativePath, import.meta.url), 'utf8');
+ const m = { exports: {} };
+ // Locale catalogs import their own sub-catalogs; those are irrelevant to the
+ // keys asserted here, so every relative import resolves to an empty module.
+ const requireShim = () => ({});
+ new Function(
+ 'module',
+ 'exports',
+ 'require',
+ ts.transpileModule(source, {
+ compilerOptions: {
+ module: ts.ModuleKind.CommonJS,
+ target: ts.ScriptTarget.ES2022,
+ },
+ }).outputText,
+ )(m, m.exports, requireShim);
+ return m.exports;
+}
+
+const { cloudBannerLanguageKey, resolveCloudBannerCopy, cloudBannerSignature } =
+ loadTs('../../src/lib/cloud-banner.ts');
+
+test('language keys follow the config.yaml underscore form', () => {
+ assert.equal(cloudBannerLanguageKey('zh-Hans'), 'zh_hans');
+ assert.equal(cloudBannerLanguageKey('zh-Hant'), 'zh_hant');
+ assert.equal(cloudBannerLanguageKey('en-US'), 'en_us');
+ assert.equal(cloudBannerLanguageKey(' EN_us '), 'en_us');
+ assert.equal(cloudBannerLanguageKey('ja-JP'), 'ja_jp');
+});
+
+test('configured copy wins over the built-in text for that language', () => {
+ const banner = {
+ content: { zh_hans: '服务维护中', en_us: 'Maintenance in progress' },
+ };
+ assert.equal(resolveCloudBannerCopy(banner, 'zh-Hans'), '服务维护中');
+ assert.equal(
+ resolveCloudBannerCopy(banner, 'zh-Hant'),
+ 'Maintenance in progress',
+ );
+});
+
+test('missing or blank entries fall back to the translated default', () => {
+ assert.equal(
+ resolveCloudBannerCopy({ content: { en_us: ' ' } }, 'zh-Hans'),
+ null,
+ );
+ assert.equal(resolveCloudBannerCopy({ content: {} }, 'en-US'), null);
+ assert.equal(resolveCloudBannerCopy({ content: null }, 'en-US'), null);
+});
+
+test('a dismissal is bound to the notice that was dismissed', () => {
+ const first = cloudBannerSignature({ level: 'info' }, 'alpha');
+ const second = cloudBannerSignature({ level: 'maintenance' }, 'alpha');
+ assert.notEqual(first, second);
+ assert.equal(first, cloudBannerSignature({ level: 'info' }, 'alpha'));
+});
+
+test('every locale carries the cloud_banner copy', () => {
+ const locales = [
+ 'en-US',
+ 'zh-Hans',
+ 'zh-Hant',
+ 'ja-JP',
+ 'th-TH',
+ 'vi-VN',
+ 'es-ES',
+ 'ru-RU',
+ ];
+ const expectedKeys = [
+ 'message',
+ 'dedicated_link',
+ 'or',
+ 'self_hosted_link',
+ 'period',
+ 'dismiss',
+ ];
+ for (const locale of locales) {
+ const translations = loadTs(`../../src/i18n/locales/${locale}.ts`).default;
+ const banner = translations.cloud_banner;
+ assert.ok(banner, `${locale} is missing cloud_banner`);
+ assert.deepEqual(Object.keys(banner).sort(), [...expectedKeys].sort());
+ for (const key of expectedKeys) {
+ assert.equal(
+ typeof banner[key],
+ 'string',
+ `${locale}.cloud_banner.${key} must be a string`,
+ );
+ }
+ assert.notEqual(banner.message.trim(), '', `${locale} message is empty`);
+ // The message is followed by inline links, so a trailing period would
+ // render as a stray full stop before them.
+ assert.ok(
+ !banner.message.trim().endsWith('.') &&
+ !banner.message.trim().endsWith('。'),
+ `${locale} message must not end the sentence before its links`,
+ );
+ }
+});