mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-20 15:37:27 +08:00
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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user