From d80972417ee2dcfbc8537b58f96d2fb62ac101da Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Tue, 19 May 2026 11:40:20 +0800 Subject: [PATCH] fix(web): improve backend retry and sidebar scrolling --- .../components/home-sidebar/HomeSidebar.tsx | 148 ++++++++++++++---- web/src/app/home/layout.tsx | 15 +- web/src/components/BackendUnavailablePage.tsx | 59 ++++++- web/src/components/ui/sidebar.tsx | 9 +- web/src/i18n/locales/en-US.ts | 4 + web/src/i18n/locales/es-ES.ts | 4 + web/src/i18n/locales/ja-JP.ts | 4 + web/src/i18n/locales/ru-RU.ts | 4 + web/src/i18n/locales/th-TH.ts | 4 + web/src/i18n/locales/vi-VN.ts | 4 + web/src/i18n/locales/zh-Hans.ts | 3 + web/src/i18n/locales/zh-Hant.ts | 3 + 12 files changed, 220 insertions(+), 41 deletions(-) diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 22321298..763969b1 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -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 = { // 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(''); const [starCount, setStarCount] = useState(null); const [userMenuOpen, setUserMenuOpen] = useState(false); + const navigationContentRef = useRef(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,37 +1779,57 @@ export default function HomeSidebar({ {/* Navigation items grouped by section */} - - - {t('sidebar.home')} - - - - - - - - {t('sidebar.extensions')} - - - - - - - - +
+ + + {t('sidebar.home')} + + + + + + + + {t('sidebar.extensions')} + + + + + + + + + +
{/* Footer */} diff --git a/web/src/app/home/layout.tsx b/web/src/app/home/layout.tsx index 0fc7022e..d48f7407 100644 --- a/web/src/app/home/layout.tsx +++ b/web/src/app/home/layout.tsx @@ -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 ( diff --git a/web/src/components/BackendUnavailablePage.tsx b/web/src/components/BackendUnavailablePage.tsx index fe133473..9acdd470 100644 --- a/web/src/components/BackendUnavailablePage.tsx +++ b/web/src/components/BackendUnavailablePage.tsx @@ -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(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 (
@@ -27,6 +66,12 @@ export default function BackendUnavailablePage() { {t('common.loginLoadErrorDesc')}

+ {retryError ? ( +

+ {t('errorPage.retryFailed')} +

+ ) : null} +
-
diff --git a/web/src/components/ui/sidebar.tsx b/web/src/components/ui/sidebar.tsx index 649c251b..8fad1287 100644 --- a/web/src/components/ui/sidebar.tsx +++ b/web/src/components/ui/sidebar.tsx @@ -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 (
) { {...props} /> ); -} +}); +SidebarContent.displayName = 'SidebarContent'; function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) { return ( diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index b1e80c36..530218db 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -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', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 1edad872..9034f461 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -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', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index c55409fa..d9e5c974 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -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: 'サイドバーからプラグインページを選択してください', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index c073ee31..69ece0d3 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -10,6 +10,7 @@ const ruRU = { pluginPagesTooltip: 'Визуальные страницы, предоставляемые установленными плагинами', quickStart: 'Быстрый старт', + scrollToBottom: 'Прокрутить вниз', }, common: { login: 'Войти', @@ -1435,6 +1436,9 @@ const ruRU = { goBack: 'Назад', backToHome: 'На главную', backToLogin: 'Вернуться к входу', + retrying: 'Повторяем', + retryFailed: + 'По-прежнему не удается подключиться к бэкенду. Запустите сервис и повторите попытку.', }, pluginPages: { selectFromSidebar: 'Выберите страницу плагина на боковой панели', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 73588eb4..4e760e7c 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -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: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 9256a189..b01010cb 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -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', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index f3411be5..cb2b5ae2 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -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: '从侧边栏选择一个插件页面', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 0f2fe7e1..45175829 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -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: '從側邊欄選擇一個插件頁面',