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
This commit is contained in:
weishu
2026-02-01 19:40:43 +08:00
parent 8dfc8749eb
commit 5b27f6b41e
6 changed files with 87 additions and 5 deletions
+15 -1
View File
@@ -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() {
<AppContextProvider value={{ api, token, baseUrl }}>
<VoiceProvider>
<SyncingBanner isSyncing={isSyncing} />
<ReconnectingBanner isReconnecting={sseDisconnected && !isSyncing} />
<VoiceErrorBanner />
<OfflineBanner />
<div className="h-full flex flex-col">
+19
View File
@@ -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 (
<div className="fixed top-0 left-0 right-0 bg-amber-500 text-white text-center py-2 text-sm font-medium z-50 flex items-center justify-center gap-2">
<span className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full" />
{t('reconnecting.message')}
</div>
)
}
+31 -4
View File
@@ -18,9 +18,12 @@ type SendMessageInput = {
attachments?: AttachmentMetadata[]
}
type BlockedReason = 'no-api' | 'no-session' | 'pending'
type UseSendMessageOptions = {
resolveSessionId?: (sessionId: string) => Promise<string>
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
+5
View File
@@ -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',
+5
View File
@@ -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',
+12
View File
@@ -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
}
})