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
This commit is contained in:
Hyu
2026-09-28 19:38:09 +08:00
parent 36661442ad
commit c18db122e1
3 changed files with 124 additions and 55 deletions
+8 -2
View File
@@ -1061,7 +1061,10 @@ export default function WizardPage() {
if (isLoading) {
return (
<div className="fixed inset-0 z-50 bg-background flex items-center justify-center">
<div
style={{ top: 'var(--beta-banner-height, 0px)' }}
className="fixed inset-x-0 bottom-0 z-50 bg-background flex items-center justify-center"
>
<LoadingSpinner text={t('wizard.loading')} />
</div>
);
@@ -1075,7 +1078,10 @@ export default function WizardPage() {
];
return (
<div className="fixed inset-0 z-50 bg-background flex flex-col">
<div
style={{ top: 'var(--beta-banner-height, 0px)' }}
className="fixed inset-x-0 bottom-0 z-50 bg-background flex flex-col"
>
{/* Top bar: Skip button */}
<div className="shrink-0 flex items-center justify-between px-4 sm:px-6 py-3 border-b">
<div className="flex items-center gap-2">
+109 -53
View File
@@ -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<HTMLDivElement>(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 (
<Alert className="rounded-none border-x-0 border-t-0 bg-amber-50 dark:bg-amber-950/20 border-amber-200 dark:border-amber-900/50 py-2">
<div className="flex items-start gap-3">
<AlertCircle className="h-4 w-4 mt-0.5 text-amber-600 dark:text-amber-500 shrink-0" />
<AlertDescription className="text-sm text-amber-900 dark:text-amber-100 flex-1">
{t('beta_banner.message')}{' '}
<a
href={cloudUrl}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-amber-700 dark:hover:text-amber-300 font-medium"
<Alert
ref={bannerRef}
className="relative z-30 rounded-none border-x-0 border-t-0 bg-amber-50 dark:bg-amber-950/20 border-amber-200 dark:border-amber-900/50 py-2"
>
<AlertCircle className="text-amber-600 dark:text-amber-500" />
<AlertDescription className="text-sm text-amber-900 dark:text-amber-100">
<div className="flex w-full items-start gap-3">
<span className="flex-1">
{t('beta_banner.message')}{' '}
<a
href={cloudUrl}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-amber-700 dark:hover:text-amber-300 font-medium"
>
{t('beta_banner.cloud_link')}
</a>{' '}
{t('beta_banner.or')}{' '}
<a
href={ossUrl}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-amber-700 dark:hover:text-amber-300 font-medium"
>
{t('beta_banner.oss_link')}
</a>
{t('beta_banner.period')}
</span>
<button
onClick={() => setDismissed(true)}
className="shrink-0 text-amber-600 dark:text-amber-500 hover:text-amber-800 dark:hover:text-amber-300 p-1 -mt-1 -mr-2"
aria-label={t('beta_banner.dismiss')}
>
{t('beta_banner.cloud_link')}
</a>{' '}
{t('beta_banner.or')}{' '}
<a
href={ossUrl}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-amber-700 dark:hover:text-amber-300 font-medium"
>
{t('beta_banner.oss_link')}
</a>
{t('beta_banner.period')}
</AlertDescription>
<button
onClick={() => setDismissed(true)}
className="shrink-0 text-amber-600 dark:text-amber-500 hover:text-amber-800 dark:hover:text-amber-300 p-1 -mt-1 -mr-2"
aria-label={t('beta_banner.dismiss')}
>
<X className="h-4 w-4" />
</button>
</div>
<X className="h-4 w-4" />
</button>
</div>
</AlertDescription>
</Alert>
);
}
+7
View File
@@ -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({
/>
<div
data-slot="sidebar-container"
style={{
top: 'var(--beta-banner-height, 0px)',
height: 'calc(100svh - var(--beta-banner-height, 0px))',
}}
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
isSidebarResizing && 'transition-none',