fix(web): improve backend retry and sidebar scrolling

This commit is contained in:
Junyan Qin
2026-05-19 11:40:20 +08:00
parent 257d9d3a65
commit d80972417e
12 changed files with 220 additions and 41 deletions

View File

@@ -85,7 +85,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { ChevronRight, Plus } from 'lucide-react';
import { ChevronDown, ChevronRight, Plus } from 'lucide-react';
import {
Tooltip,
TooltipContent,
@@ -187,6 +187,7 @@ const ENTITY_ROUTE_MAP: Record<EntityCategoryId, string> = {
// localStorage key for collapsible section open/closed state
const SIDEBAR_SECTIONS_KEY = 'sidebar_sections';
const SIDEBAR_LIST_EXPANSION_KEY = 'sidebar_entity_list_expansion';
const SCROLL_HINT_BOTTOM_THRESHOLD = 40;
type SidebarNavSection = 'home' | 'extensions';
type SidebarListExpansionState = Record<
@@ -1519,6 +1520,27 @@ export default function HomeSidebar({
const [userEmail, setUserEmail] = useState<string>('');
const [starCount, setStarCount] = useState<number | null>(null);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const navigationContentRef = useRef<HTMLDivElement | null>(null);
const [showScrollHint, setShowScrollHint] = useState(false);
function scrollNavigationToBottom() {
const contentEl = navigationContentRef.current;
if (!contentEl) return;
const maxScrollTop = contentEl.scrollHeight - contentEl.clientHeight;
contentEl.scrollTo({
top: maxScrollTop,
behavior: 'smooth',
});
setShowScrollHint(false);
window.setTimeout(() => {
if (contentEl.scrollTop < maxScrollTop - 2) {
contentEl.scrollTop = maxScrollTop;
}
setShowScrollHint(false);
}, 250);
}
function handleModelsDialogChange(open: boolean) {
setModelsDialogOpen(open);
if (open) {
@@ -1622,6 +1644,48 @@ export default function HomeSidebar({
.catch(() => {});
}, []);
useEffect(() => {
const contentEl = navigationContentRef.current;
if (!contentEl) return;
let animationFrame = 0;
const updateScrollHint = () => {
cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(() => {
const hasHiddenContent =
contentEl.scrollTop + contentEl.clientHeight <
contentEl.scrollHeight - SCROLL_HINT_BOTTOM_THRESHOLD;
setShowScrollHint(hasHiddenContent);
});
};
updateScrollHint();
contentEl.addEventListener('scroll', updateScrollHint, { passive: true });
const resizeObserver = new ResizeObserver(updateScrollHint);
resizeObserver.observe(contentEl);
if (contentEl.firstElementChild) {
resizeObserver.observe(contentEl.firstElementChild);
}
const mutationObserver = new MutationObserver(updateScrollHint);
mutationObserver.observe(contentEl, {
childList: true,
subtree: true,
attributes: true,
});
window.addEventListener('resize', updateScrollHint);
return () => {
cancelAnimationFrame(animationFrame);
contentEl.removeEventListener('scroll', updateScrollHint);
resizeObserver.disconnect();
mutationObserver.disconnect();
window.removeEventListener('resize', updateScrollHint);
};
}, []);
// Update selected state + notify parent without navigating
function selectChild(child: SidebarChildVO) {
setSelectedChild(child);
@@ -1715,7 +1779,8 @@ export default function HomeSidebar({
</SidebarHeader>
{/* Navigation items grouped by section */}
<SidebarContent>
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<SidebarContent ref={navigationContentRef} className="min-h-0 pb-8">
<SidebarGroup>
<SidebarGroupLabel>{t('sidebar.home')}</SidebarGroupLabel>
<SidebarGroupContent>
@@ -1746,6 +1811,25 @@ export default function HomeSidebar({
</SidebarGroup>
<PluginPagesNav />
</SidebarContent>
<button
type="button"
onClick={scrollNavigationToBottom}
disabled={!showScrollHint}
aria-label={t('sidebar.scrollToBottom')}
aria-hidden={!showScrollHint}
tabIndex={showScrollHint ? 0 : -1}
className={cn(
'absolute inset-x-0 bottom-2 z-10 mx-auto flex w-fit justify-center rounded-full transition-opacity duration-200 group-data-[collapsible=icon]:hidden',
showScrollHint
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0',
)}
>
<span className="flex size-7 items-center justify-center rounded-full border border-sidebar-border bg-sidebar/95 text-sidebar-foreground/70 shadow-sm backdrop-blur transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground">
<ChevronDown className="size-4" />
</span>
</button>
</div>
{/* Footer */}
<SidebarFooter>

View File

@@ -59,6 +59,8 @@ function isExtensionsRoute(pathname: string): boolean {
}
const HOME_CONTENT_MAX_WIDTH = 'max-w-[1360px]';
const BACKEND_UNAVAILABLE_RETURN_TO_STORAGE_KEY =
'langbot_backend_unavailable_return_to';
export default function HomeLayout({
children,
@@ -66,6 +68,7 @@ export default function HomeLayout({
children: React.ReactNode;
}>) {
const navigate = useNavigate();
const location = useLocation();
// Initialize user info if not already initialized
useEffect(() => {
@@ -87,7 +90,15 @@ export default function HomeLayout({
}
} catch {
if (!cancelled) {
navigate('/backend-unavailable', { replace: true });
const returnTo = `${location.pathname}${location.search}${location.hash}`;
sessionStorage.setItem(
BACKEND_UNAVAILABLE_RETURN_TO_STORAGE_KEY,
returnTo,
);
navigate('/backend-unavailable', {
replace: true,
state: { from: returnTo },
});
}
}
};
@@ -96,7 +107,7 @@ export default function HomeLayout({
return () => {
cancelled = true;
};
}, [navigate]);
}, [location.hash, location.pathname, location.search, navigate]);
return (
<SidebarDataProvider>

View File

@@ -1,12 +1,51 @@
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { AlertCircle, Home, RefreshCw } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
import { AlertCircle, Home, Loader2, RefreshCw } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { initializeSystemInfo, systemInfo } from '@/app/infra/http';
const RETURN_TO_STORAGE_KEY = 'langbot_backend_unavailable_return_to';
type BackendUnavailableLocationState = {
from?: string;
};
export default function BackendUnavailablePage() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const [checking, setChecking] = useState(false);
const [retryError, setRetryError] = useState<string | null>(null);
async function handleRetry() {
setChecking(true);
setRetryError(null);
try {
await initializeSystemInfo({ throwOnError: true });
const state = location.state as BackendUnavailableLocationState | null;
const storedReturnTo = sessionStorage.getItem(RETURN_TO_STORAGE_KEY);
const returnTo = state?.from || storedReturnTo || '/home';
sessionStorage.removeItem(RETURN_TO_STORAGE_KEY);
if (systemInfo.wizard_status === 'none') {
navigate('/wizard', { replace: true });
return;
}
navigate(returnTo === '/backend-unavailable' ? '/home' : returnTo, {
replace: true,
});
} catch (error) {
setRetryError(
error instanceof Error ? error.message : t('errorPage.retryFailed'),
);
} finally {
setChecking(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
@@ -27,6 +66,12 @@ export default function BackendUnavailablePage() {
{t('common.loginLoadErrorDesc')}
</p>
{retryError ? (
<p className="mt-4 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{t('errorPage.retryFailed')}
</p>
) : null}
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Button
variant="outline"
@@ -36,9 +81,13 @@ export default function BackendUnavailablePage() {
<Home className="h-4 w-4" />
{t('errorPage.backToLogin')}
</Button>
<Button className="gap-2" onClick={() => window.location.reload()}>
<Button className="gap-2" onClick={handleRetry} disabled={checking}>
{checking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
{t('common.retry')}
)}
{checking ? t('errorPage.retrying') : t('common.retry')}
</Button>
</div>
</div>

View File

@@ -374,9 +374,13 @@ function SidebarSeparator({
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
@@ -386,7 +390,8 @@ function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
{...props}
/>
);
}
});
SidebarContent.displayName = 'SidebarContent';
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (

View File

@@ -9,6 +9,7 @@ const enUS = {
pluginPages: 'Plugin Pages',
pluginPagesTooltip: 'Visual pages provided by installed plugins',
quickStart: 'Quick Start',
scrollToBottom: 'Scroll to bottom',
},
common: {
login: 'Login',
@@ -1539,6 +1540,9 @@ const enUS = {
goBack: 'Go Back',
backToHome: 'Back to Home',
backToLogin: 'Back to Login',
retrying: 'Retrying',
retryFailed:
'Still cannot connect to the backend. Start the service and try again.',
},
pluginPages: {
selectFromSidebar: 'Select a plugin page from the sidebar',

View File

@@ -10,6 +10,7 @@ const esES = {
pluginPagesTooltip:
'Páginas visuales proporcionadas por los plugins instalados',
quickStart: 'Inicio rápido',
scrollToBottom: 'Desplazar al final',
},
common: {
login: 'Iniciar sesión',
@@ -1463,6 +1464,9 @@ const esES = {
goBack: 'Volver',
backToHome: 'Ir al inicio',
backToLogin: 'Volver al inicio de sesión',
retrying: 'Reintentando',
retryFailed:
'Aún no se puede conectar con el backend. Inicia el servicio e inténtalo de nuevo.',
},
pluginPages: {
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',

View File

@@ -9,6 +9,7 @@ const jaJP = {
pluginPages: 'プラグインページ',
pluginPagesTooltip: 'インストール済みプラグインが提供するビジュアルページ',
quickStart: 'クイックスタート',
scrollToBottom: '一番下までスクロール',
},
common: {
login: 'ログイン',
@@ -1454,6 +1455,9 @@ const jaJP = {
goBack: '戻る',
backToHome: 'ホームに戻る',
backToLogin: 'ログインに戻る',
retrying: '再試行中',
retryFailed:
'バックエンドにまだ接続できません。サービスを起動してからもう一度お試しください。',
},
pluginPages: {
selectFromSidebar: 'サイドバーからプラグインページを選択してください',

View File

@@ -10,6 +10,7 @@ const ruRU = {
pluginPagesTooltip:
'Визуальные страницы, предоставляемые установленными плагинами',
quickStart: 'Быстрый старт',
scrollToBottom: 'Прокрутить вниз',
},
common: {
login: 'Войти',
@@ -1435,6 +1436,9 @@ const ruRU = {
goBack: 'Назад',
backToHome: 'На главную',
backToLogin: 'Вернуться к входу',
retrying: 'Повторяем',
retryFailed:
'По-прежнему не удается подключиться к бэкенду. Запустите сервис и повторите попытку.',
},
pluginPages: {
selectFromSidebar: 'Выберите страницу плагина на боковой панели',

View File

@@ -9,6 +9,7 @@ const thTH = {
pluginPages: 'หน้าปลั๊กอิน',
pluginPagesTooltip: 'หน้าเว็บที่จัดทำโดยปลั๊กอินที่ติดตั้ง',
quickStart: 'เริ่มต้นอย่างรวดเร็ว',
scrollToBottom: 'เลื่อนไปด้านล่าง',
},
common: {
login: 'เข้าสู่ระบบ',
@@ -1403,6 +1404,9 @@ const thTH = {
goBack: 'ย้อนกลับ',
backToHome: 'กลับหน้าหลัก',
backToLogin: 'กลับไปหน้าเข้าสู่ระบบ',
retrying: 'กำลังลองใหม่',
retryFailed:
'ยังไม่สามารถเชื่อมต่อแบ็กเอนด์ได้ โปรดเริ่มบริการแล้วลองใหม่อีกครั้ง',
},
pluginPages: {
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',

View File

@@ -10,6 +10,7 @@ const viVN = {
pluginPagesTooltip:
'Các trang trực quan được cung cấp bởi plugin đã cài đặt',
quickStart: 'Bắt đầu nhanh',
scrollToBottom: 'Cuộn xuống cuối',
},
common: {
login: 'Đăng nhập',
@@ -1427,6 +1428,9 @@ const viVN = {
goBack: 'Quay lại',
backToHome: 'Về trang chủ',
backToLogin: 'Quay lại đăng nhập',
retrying: 'Đang thử lại',
retryFailed:
'Vẫn không thể kết nối backend. Hãy khởi động dịch vụ rồi thử lại.',
},
pluginPages: {
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',

View File

@@ -9,6 +9,7 @@ const zhHans = {
pluginPages: '插件页面',
pluginPagesTooltip: '由已安装的插件提供的可视化页面',
quickStart: '快速开始向导',
scrollToBottom: '滚动到底部',
},
common: {
login: '登录',
@@ -1474,6 +1475,8 @@ const zhHans = {
goBack: '返回上页',
backToHome: '返回首页',
backToLogin: '返回登录',
retrying: '正在重试',
retryFailed: '仍然无法连接后端,请确认服务已启动后再重试。',
},
pluginPages: {
selectFromSidebar: '从侧边栏选择一个插件页面',

View File

@@ -9,6 +9,7 @@ const zhHant = {
pluginPages: '插件頁面',
pluginPagesTooltip: '由已安裝的插件提供的視覺化頁面',
quickStart: '快速開始',
scrollToBottom: '捲動到底部',
},
common: {
login: '登入',
@@ -1384,6 +1385,8 @@ const zhHant = {
goBack: '返回上頁',
backToHome: '返回首頁',
backToLogin: '返回登入',
retrying: '正在重試',
retryFailed: '仍然無法連接後端,請確認服務已啟動後再重試。',
},
pluginPages: {
selectFromSidebar: '從側邊欄選擇一個插件頁面',