feat: add syncing banner and smart scroll behavior for chat thread

Implement two key UX improvements:

1. Syncing banner with visibility tracking:
   - New useSyncingState hook manages syncing state with safety timeout
   - Banner shows when SSE connects/reconnects
   - Auto-hides after 10s to prevent stuck spinner
   - Smart visibility detection to suppress banner when returning from background

2. Smart scroll behavior for chat thread:
   - Only auto-scrolls when user is near bottom (<120px threshold)
   - Shows "X new messages" indicator when user is reading history
   - Smooth scroll to bottom with indicator button
   - Resets state on session change

Files:
- New: useSyncingState hook for syncing state management
- New: SyncingBanner component for non-blocking indicator
- Modified: App.tsx integrates syncing with SSE handling
- Modified: HappyThread implements smart scroll with dynamic autoScroll
- Modified: index.css adds spinner and bounce-in animations
This commit is contained in:
weishu
2025-12-24 14:05:55 +08:00
parent 61ae777c3d
commit 6d401fb9ec
5 changed files with 244 additions and 10 deletions
+27 -7
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Outlet, useLocation, useMatchRoute } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { getTelegramWebApp } from '@/hooks/useTelegram'
@@ -6,12 +6,14 @@ import { initializeTheme } from '@/hooks/useTheme'
import { useAuth } from '@/hooks/useAuth'
import { useAuthSource } from '@/hooks/useAuthSource'
import { useSSE } from '@/hooks/useSSE'
import { useSyncingState } from '@/hooks/useSyncingState'
import { queryKeys } from '@/lib/query-keys'
import { AppContextProvider } from '@/lib/app-context'
import { useAppGoBack } from '@/hooks/useAppGoBack'
import { LoginPrompt } from '@/components/LoginPrompt'
import { InstallPrompt } from '@/components/InstallPrompt'
import { OfflineBanner } from '@/components/OfflineBanner'
import { SyncingBanner } from '@/components/SyncingBanner'
export function App() {
const { authSource, isLoading: isAuthSourceLoading, setAccessToken } = useAuthSource()
@@ -84,14 +86,31 @@ export function App() {
const queryClient = useQueryClient()
const sessionMatch = matchRoute({ to: '/sessions/$sessionId' })
const selectedSessionId = sessionMatch ? sessionMatch.sessionId : null
const { isSyncing, startSync, endSync } = useSyncingState()
const syncTokenRef = useRef(0)
const handleSseConnect = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: queryKeys.sessions })
if (selectedSessionId) {
void queryClient.invalidateQueries({ queryKey: queryKeys.session(selectedSessionId) })
void queryClient.invalidateQueries({ queryKey: queryKeys.messages(selectedSessionId) })
}
}, [queryClient, selectedSessionId])
// Increment token to track this specific connection
const token = ++syncTokenRef.current
startSync({ force: true })
const invalidations = [
queryClient.invalidateQueries({ queryKey: queryKeys.sessions }),
...(selectedSessionId ? [
queryClient.invalidateQueries({ queryKey: queryKeys.session(selectedSessionId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.messages(selectedSessionId) })
] : [])
]
Promise.all(invalidations)
.catch((error) => {
console.error('Failed to invalidate queries on SSE connect:', error)
})
.finally(() => {
// Only end sync if this is still the latest connection
if (syncTokenRef.current === token) {
endSync()
}
})
}, [queryClient, selectedSessionId, startSync, endSync])
const handleSseEvent = useCallback(() => {}, [])
@@ -161,6 +180,7 @@ export function App() {
return (
<AppContextProvider value={{ api, token }}>
<SyncingBanner isSyncing={isSyncing} />
<OfflineBanner />
<div className="h-full flex flex-col">
<Outlet />
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { ThreadPrimitive } from '@assistant-ui/react'
import type { ApiClient } from '@/api/client'
import type { SessionMetadataSummary } from '@/types/api'
@@ -8,6 +8,21 @@ import { HappyUserMessage } from '@/components/AssistantChat/messages/UserMessag
import { HappySystemMessage } from '@/components/AssistantChat/messages/SystemMessage'
import { Button } from '@/components/ui/button'
function NewMessagesIndicator(props: { count: number; onClick: () => void }) {
if (props.count === 0) {
return null
}
return (
<button
onClick={props.onClick}
className="absolute bottom-20 left-1/2 -translate-x-1/2 bg-[var(--app-button)] text-[var(--app-button-text)] px-3 py-1.5 rounded-full text-sm font-medium shadow-lg animate-bounce-in z-10"
>
{props.count} new message{props.count > 1 ? 's' : ''} &#8595;
</button>
)
}
const THREAD_MESSAGE_COMPONENTS = {
UserMessage: HappyUserMessage,
AssistantMessage: HappyAssistantMessage,
@@ -37,6 +52,86 @@ export function HappyThread(props: {
const loadStartedRef = useRef(false)
const isLoadingMoreRef = useRef(props.isLoadingMoreMessages)
// Smart scroll state: autoScroll enabled when user is near bottom
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true)
const [newMessageCount, setNewMessageCount] = useState(0)
const prevNormalizedCountRef = useRef(props.normalizedMessagesCount)
const autoScrollEnabledRef = useRef(autoScrollEnabled)
const newMessageCountRef = useRef(newMessageCount)
// Keep refs in sync with state
useEffect(() => {
autoScrollEnabledRef.current = autoScrollEnabled
}, [autoScrollEnabled])
useEffect(() => {
newMessageCountRef.current = newMessageCount
}, [newMessageCount])
// Track scroll position to toggle autoScroll (stable listener using refs)
useEffect(() => {
const viewport = viewportRef.current
if (!viewport) return
const THRESHOLD_PX = 120
const handleScroll = () => {
const distanceFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight
const isNearBottom = distanceFromBottom < THRESHOLD_PX
if (isNearBottom) {
if (!autoScrollEnabledRef.current) setAutoScrollEnabled(true)
if (newMessageCountRef.current > 0) setNewMessageCount(0)
} else {
if (autoScrollEnabledRef.current) setAutoScrollEnabled(false)
}
}
viewport.addEventListener('scroll', handleScroll, { passive: true })
return () => viewport.removeEventListener('scroll', handleScroll)
}, []) // Stable: no dependencies, reads from refs
// Track new messages when autoScroll is disabled
const wasLoadingMoreRef = useRef(props.isLoadingMoreMessages)
useEffect(() => {
const prevCount = prevNormalizedCountRef.current
const currentCount = props.normalizedMessagesCount
const wasLoadingMore = wasLoadingMoreRef.current
wasLoadingMoreRef.current = props.isLoadingMoreMessages
prevNormalizedCountRef.current = currentCount
// Skip during loading states
if (props.isLoadingMoreMessages || props.isLoadingMessages) {
return
}
// Skip if load-more just finished (older messages, not new ones)
if (wasLoadingMore) {
return
}
const newCount = currentCount - prevCount
if (newCount > 0 && !autoScrollEnabled) {
setNewMessageCount((prev) => prev + newCount)
}
}, [props.normalizedMessagesCount, props.isLoadingMoreMessages, props.isLoadingMessages, autoScrollEnabled])
// Scroll to bottom handler for the indicator button
const scrollToBottom = useCallback(() => {
const viewport = viewportRef.current
if (viewport) {
viewport.scrollTo({ top: viewport.scrollHeight, behavior: 'smooth' })
}
setAutoScrollEnabled(true)
setNewMessageCount(0)
}, [])
// Reset state when session changes
useEffect(() => {
setAutoScrollEnabled(true)
setNewMessageCount(0)
prevNormalizedCountRef.current = 0
}, [props.sessionId])
const handleLoadMore = useCallback(() => {
if (props.isLoadingMessages || !props.hasMoreMessages || props.isLoadingMoreMessages || loadLockRef.current) {
return
@@ -132,8 +227,8 @@ export function HappyThread(props: {
onRefresh: props.onRefresh,
onRetryMessage: props.onRetryMessage
}}>
<ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col">
<ThreadPrimitive.Viewport asChild autoScroll>
<ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col relative">
<ThreadPrimitive.Viewport asChild autoScroll={autoScrollEnabled}>
<div ref={viewportRef} className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
<div className="mx-auto w-full max-w-content min-w-0 p-3">
<div ref={topSentinelRef} className="h-px w-full" aria-hidden="true" />
@@ -175,6 +270,7 @@ export function HappyThread(props: {
</div>
</div>
</ThreadPrimitive.Viewport>
<NewMessagesIndicator count={newMessageCount} onClick={scrollToBottom} />
</ThreadPrimitive.Root>
</HappyChatProvider>
)
+17
View File
@@ -0,0 +1,17 @@
import { useOnlineStatus } from '@/hooks/useOnlineStatus'
export function SyncingBanner({ isSyncing }: { isSyncing: boolean }) {
const isOnline = useOnlineStatus()
// Don't show syncing banner when offline (OfflineBanner takes precedence)
if (!isSyncing || !isOnline) {
return null
}
return (
<div className="fixed top-0 left-0 right-0 bg-[var(--app-button)] text-[var(--app-button-text)] text-center py-2 text-sm font-medium z-50 flex items-center justify-center gap-2">
<span className="inline-block animate-spin">&#8635;</span>
Syncing...
</div>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { useCallback, useEffect, useRef, useState } from 'react'
let lastHiddenTimestamp: number | null = null
const MAX_SYNC_DURATION_MS = 10_000 // Auto-clear after 10 seconds
const BACKGROUND_THRESHOLD_MS = 30_000 // Consider "returning from background" if hidden within 30s
export function useSyncingState() {
const [isSyncing, setIsSyncing] = useState(false)
const endSyncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const maxDurationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
lastHiddenTimestamp = Date.now()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
}, [])
const clearAllTimeouts = useCallback(() => {
if (endSyncTimeoutRef.current) {
clearTimeout(endSyncTimeoutRef.current)
endSyncTimeoutRef.current = null
}
if (maxDurationTimeoutRef.current) {
clearTimeout(maxDurationTimeoutRef.current)
maxDurationTimeoutRef.current = null
}
}, [])
const doStartSync = useCallback(() => {
clearAllTimeouts()
setIsSyncing(true)
// Safety timeout: auto-clear after max duration to prevent stuck spinner
maxDurationTimeoutRef.current = setTimeout(() => {
setIsSyncing(false)
}, MAX_SYNC_DURATION_MS)
}, [clearAllTimeouts])
const startSync = useCallback((options?: { force?: boolean }) => {
if (options?.force) {
// Force show syncing banner (for any reconnect)
doStartSync()
return
}
// Only show syncing state when returning from background
if (lastHiddenTimestamp && Date.now() - lastHiddenTimestamp < BACKGROUND_THRESHOLD_MS) {
doStartSync()
}
}, [doStartSync])
const endSync = useCallback(() => {
// Delay ending to avoid flicker
clearAllTimeouts()
endSyncTimeoutRef.current = setTimeout(() => setIsSyncing(false), 300)
}, [clearAllTimeouts])
useEffect(() => {
return () => {
clearAllTimeouts()
}
}, [clearAllTimeouts])
return { isSyncing, startSync, endSync }
}
+33
View File
@@ -195,3 +195,36 @@ html[data-theme="dark"] .shiki span {
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
/* Syncing spinner animation */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* New messages indicator bounce animation */
@keyframes bounce-in {
0% {
transform: translateX(-50%) scale(0.8);
opacity: 0;
}
50% {
transform: translateX(-50%) scale(1.05);
}
100% {
transform: translateX(-50%) scale(1);
opacity: 1;
}
}
.animate-bounce-in {
animation: bounce-in 0.3s ease-out;
}