From c18db122e1535637908b6d8ced015d06b3663ac5 Mon Sep 17 00:00:00 2001 From: Hyu Date: Mon, 28 Sep 2026 19:37:46 +0800 Subject: [PATCH] fix(web): keep the beta banner clear of the app shell - render the banner through the Alert API so its grid keeps one full-width text column instead of wrapping inside the empty icon column - point the cloud link at space.langbot.app/cloud?environment=dedicated and the self-hosted link at the GitHub repository - keep the last known state and retry the probe (1/2/4/8s plus a re-check on window focus) so a restarting backend cannot hide the banner for the session - publish --beta-banner-height so the fixed sidebar container, the sidebar wrapper and the wizard shell start below the banner --- web/src/app/wizard/page.tsx | 10 +- web/src/components/BetaBanner.tsx | 162 ++++++++++++++++++++---------- web/src/components/ui/sidebar.tsx | 7 ++ 3 files changed, 124 insertions(+), 55 deletions(-) diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index 8e9f51dee..f2c83e1dd 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -1061,7 +1061,10 @@ export default function WizardPage() { if (isLoading) { return ( -
+
); @@ -1075,7 +1078,10 @@ export default function WizardPage() { ]; return ( -
+
{/* Top bar: Skip button */}
diff --git a/web/src/components/BetaBanner.tsx b/web/src/components/BetaBanner.tsx index a6b1dcc5d..87e09a2fa 100644 --- a/web/src/components/BetaBanner.tsx +++ b/web/src/components/BetaBanner.tsx @@ -1,80 +1,136 @@ import { useTranslation } from 'react-i18next'; import { AlertCircle, X } from 'lucide-react'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { backendClient } from '@/app/infra/http'; +const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000]; +const CLOUD_BETA_RETRY_LIMIT = RETRY_DELAYS_MS.length; + export function BetaBanner() { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); + const bannerRef = useRef(null); const [show, setShow] = useState(false); const [dismissed, setDismissed] = useState(false); + const visible = show && !dismissed; + + // Publish the banner height so the fixed app shell (sidebar + inset) starts + // below the notice instead of painting over it. + useEffect(() => { + const root = document.documentElement; + if (!visible) { + root.style.removeProperty('--beta-banner-height'); + return; + } + const element = bannerRef.current; + if (!element) { + return; + } + const sync = () => { + root.style.setProperty( + '--beta-banner-height', + `${element.getBoundingClientRect().height}px`, + ); + }; + sync(); + const observer = new ResizeObserver(sync); + observer.observe(element); + return () => { + observer.disconnect(); + root.style.removeProperty('--beta-banner-height'); + }; + }, [visible]); useEffect(() => { - const checkBetaStatus = async () => { + let cancelled = false; + let retryTimer: number | undefined; + + const checkBetaStatus = async (attempt = 0) => { try { const info = await backendClient.getSystemInfo(); - const shouldShow = - info.deployment_mode === 'cloud' && info.beta === true; - setShow(shouldShow); + if (cancelled) { + return; + } + setShow(info.deployment_mode === 'cloud' && info.beta === true); } catch { - setShow(false); + if (cancelled) { + return; + } + // A single failed probe (cold start, restarting backend, flaky + // network) must not hide the banner for the rest of the session, so + // keep the last known state and retry with backoff. + if (attempt < CLOUD_BETA_RETRY_LIMIT) { + retryTimer = window.setTimeout( + () => void checkBetaStatus(attempt + 1), + RETRY_DELAYS_MS[attempt], + ); + } } }; - checkBetaStatus(); + void checkBetaStatus(); + + const recheck = () => { + if (!cancelled) { + void checkBetaStatus(); + } + }; + window.addEventListener('focus', recheck); + + return () => { + cancelled = true; + clearTimeout(retryTimer); + window.removeEventListener('focus', recheck); + }; }, []); - if (!show || dismissed) { + if (!visible) { return null; } - const cloudUrl = - i18n.language === 'zh-Hans' || i18n.language === 'zh_CN' - ? 'https://langbot.app/zh/cloud' - : i18n.language === 'ja' || i18n.language === 'ja_JP' - ? 'https://langbot.app/ja/cloud' - : 'https://langbot.app/cloud'; - - const ossUrl = - i18n.language === 'zh-Hans' || i18n.language === 'zh_CN' - ? 'https://langbot.app/zh/docs/quick-start/deploy' - : i18n.language === 'ja' || i18n.language === 'ja_JP' - ? 'https://langbot.app/ja/docs/quick-start/deploy' - : 'https://langbot.app/docs/quick-start/deploy'; + // The cloud product page opens the dedicated (paid v1) environment tab; + // space.langbot.app resolves the locale itself, so no path prefix applies. + const cloudUrl = 'https://space.langbot.app/cloud?environment=dedicated'; + const ossUrl = 'https://github.com/langbot-app/LangBot'; return ( - -
- - - {t('beta_banner.message')}{' '} - + + +
+ + {t('beta_banner.message')}{' '} + + {t('beta_banner.cloud_link')} + {' '} + {t('beta_banner.or')}{' '} + + {t('beta_banner.oss_link')} + + {t('beta_banner.period')} + + -
+ + +
+
); } diff --git a/web/src/components/ui/sidebar.tsx b/web/src/components/ui/sidebar.tsx index fd0daa9dc..3ef69ff30 100644 --- a/web/src/components/ui/sidebar.tsx +++ b/web/src/components/ui/sidebar.tsx @@ -203,6 +203,9 @@ function SidebarProvider({ { '--sidebar-width': `${sidebarWidth}px`, '--sidebar-width-icon': SIDEBAR_WIDTH_ICON, + // Global notices (e.g. the beta banner) publish their height so + // the shell starts below them instead of underneath. + height: 'calc(100svh - var(--beta-banner-height, 0px))', ...style, } as React.CSSProperties } @@ -298,6 +301,10 @@ function Sidebar({ />