From 5b27f6b41e4f3e330f60ee92bacc07d1ffc82cd4 Mon Sep 17 00:00:00 2001 From: weishu Date: Sun, 1 Feb 2026 19:40:39 +0800 Subject: [PATCH] feat(web): Add reconnecting feedback when SSE connection is lost (#125) - Add ReconnectingBanner component to show "Reconnecting..." status - Track SSE connection state in App.tsx with onDisconnect handler - Add onBlocked callback to useSendMessage for handling blocked send attempts - Provide haptic feedback when send is blocked due to no API or session - Show toast notification when message cannot be sent due to server disconnection - Add translation keys for reconnecting message and send blocked feedback in en/zh-CN close #125 --- web/src/App.tsx | 16 ++++++++++- web/src/components/ReconnectingBanner.tsx | 19 ++++++++++++ web/src/hooks/mutations/useSendMessage.ts | 35 ++++++++++++++++++++--- web/src/lib/locales/en.ts | 5 ++++ web/src/lib/locales/zh-CN.ts | 5 ++++ web/src/router.tsx | 12 ++++++++ 6 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 web/src/components/ReconnectingBanner.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 30c37b42..24521358 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Outlet, useLocation, useMatchRoute, useRouter } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { getTelegramWebApp, isTelegramApp } from '@/hooks/useTelegram' @@ -20,6 +20,7 @@ import { LoginPrompt } from '@/components/LoginPrompt' import { InstallPrompt } from '@/components/InstallPrompt' import { OfflineBanner } from '@/components/OfflineBanner' import { SyncingBanner } from '@/components/SyncingBanner' +import { ReconnectingBanner } from '@/components/ReconnectingBanner' import { VoiceErrorBanner } from '@/components/VoiceErrorBanner' import { LoadingState } from '@/components/LoadingState' import { ToastContainer } from '@/components/ToastContainer' @@ -112,6 +113,7 @@ function AppInner() { const sessionMatch = matchRoute({ to: '/sessions/$sessionId' }) const selectedSessionId = sessionMatch ? sessionMatch.sessionId : null const { isSyncing, startSync, endSync } = useSyncingState() + const [sseDisconnected, setSseDisconnected] = useState(false) const syncTokenRef = useRef(0) const isFirstConnectRef = useRef(true) const baseUrlRef = useRef(baseUrl) @@ -174,6 +176,9 @@ function AppInner() { }, [api, isPushSupported, pushPermission, requestPermission, subscribe, token]) const handleSseConnect = useCallback(() => { + // Clear disconnected state on successful connection + setSseDisconnected(false) + // Increment token to track this specific connection const token = ++syncTokenRef.current @@ -207,6 +212,13 @@ function AppInner() { }) }, [api, queryClient, selectedSessionId, startSync, endSync]) + const handleSseDisconnect = useCallback(() => { + // Only show reconnecting banner if we've already connected once + if (!isFirstConnectRef.current) { + setSseDisconnected(true) + } + }, []) + const handleSseEvent = useCallback(() => {}, []) const handleToast = useCallback((event: ToastEvent) => { addToast({ @@ -230,6 +242,7 @@ function AppInner() { baseUrl, subscription: eventSubscription, onConnect: handleSseConnect, + onDisconnect: handleSseDisconnect, onEvent: handleSseEvent, onToast: handleToast }) @@ -319,6 +332,7 @@ function AppInner() { +
diff --git a/web/src/components/ReconnectingBanner.tsx b/web/src/components/ReconnectingBanner.tsx new file mode 100644 index 00000000..8d13cfbc --- /dev/null +++ b/web/src/components/ReconnectingBanner.tsx @@ -0,0 +1,19 @@ +import { useOnlineStatus } from '@/hooks/useOnlineStatus' +import { useTranslation } from '@/lib/use-translation' + +export function ReconnectingBanner({ isReconnecting }: { isReconnecting: boolean }) { + const { t } = useTranslation() + const isOnline = useOnlineStatus() + + // Don't show if offline (OfflineBanner takes precedence) or if not reconnecting + if (!isReconnecting || !isOnline) { + return null + } + + return ( +
+ + {t('reconnecting.message')} +
+ ) +} diff --git a/web/src/hooks/mutations/useSendMessage.ts b/web/src/hooks/mutations/useSendMessage.ts index 5e447223..6ca5ae63 100644 --- a/web/src/hooks/mutations/useSendMessage.ts +++ b/web/src/hooks/mutations/useSendMessage.ts @@ -18,9 +18,12 @@ type SendMessageInput = { attachments?: AttachmentMetadata[] } +type BlockedReason = 'no-api' | 'no-session' | 'pending' + type UseSendMessageOptions = { resolveSessionId?: (sessionId: string) => Promise onSessionResolved?: (sessionId: string) => void + onBlocked?: (reason: BlockedReason) => void } function findMessageByLocalId( @@ -88,8 +91,20 @@ export function useSendMessage( }) const sendMessage = (text: string, attachments?: AttachmentMetadata[]) => { - if (!api || !sessionId) return - if (mutation.isPending || resolveGuardRef.current) return + if (!api) { + options?.onBlocked?.('no-api') + haptic.notification('error') + return + } + if (!sessionId) { + options?.onBlocked?.('no-session') + haptic.notification('error') + return + } + if (mutation.isPending || resolveGuardRef.current) { + options?.onBlocked?.('pending') + return + } const localId = makeClientSideId('local') const createdAt = Date.now() void (async () => { @@ -123,8 +138,20 @@ export function useSendMessage( } const retryMessage = (localId: string) => { - if (!api || !sessionId) return - if (mutation.isPending || resolveGuardRef.current) return + if (!api) { + options?.onBlocked?.('no-api') + haptic.notification('error') + return + } + if (!sessionId) { + options?.onBlocked?.('no-session') + haptic.notification('error') + return + } + if (mutation.isPending || resolveGuardRef.current) { + options?.onBlocked?.('pending') + return + } const message = findMessageByLocalId(sessionId, localId) if (!message?.originalText) return diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index a5374c03..1b840033 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -219,6 +219,11 @@ export default { 'offline.message': 'You are currently offline. Some features may be limited.', 'syncing.title': 'Syncing…', 'syncing.message': 'Your data is being synchronized.', + 'reconnecting.message': 'Reconnecting...', + + // Send blocked + 'send.blocked.title': 'Cannot send message', + 'send.blocked.noConnection': 'Not connected to server', // Install prompt 'install.title': 'Install HAPI', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index f19e34cf..f4125c2c 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -221,6 +221,11 @@ export default { 'offline.message': '您当前处于离线状态。某些功能可能受限。', 'syncing.title': '同步中…', 'syncing.message': '正在同步您的数据。', + 'reconnecting.message': '正在重新连接...', + + // Send blocked + 'send.blocked.title': '无法发送消息', + 'send.blocked.noConnection': '未连接到服务器', // Install prompt 'install.title': '安装 HAPI', diff --git a/web/src/router.tsx b/web/src/router.tsx index 11641a0c..e2e75014 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -179,6 +179,7 @@ function SessionsIndexPage() { function SessionPage() { const { api } = useAppContext() + const { t } = useTranslation() const goBack = useAppGoBack() const navigate = useNavigate() const queryClient = useQueryClient() @@ -249,6 +250,17 @@ function SessionPage() { replace: true }) })() + }, + onBlocked: (reason) => { + if (reason === 'no-api') { + addToast({ + title: t('send.blocked.title'), + body: t('send.blocked.noConnection'), + sessionId: sessionId ?? '', + url: '' + }) + } + // 'no-session' and 'pending' don't need toast - either invalid state or expected behavior } })