From 6d401fb9ec738652aa536e7098013fbd49c348a7 Mon Sep 17 00:00:00 2001 From: weishu Date: Wed, 24 Dec 2025 14:04:34 +0800 Subject: [PATCH] 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 --- web/src/App.tsx | 34 ++++-- .../components/AssistantChat/HappyThread.tsx | 102 +++++++++++++++++- web/src/components/SyncingBanner.tsx | 17 +++ web/src/hooks/useSyncingState.ts | 68 ++++++++++++ web/src/index.css | 33 ++++++ 5 files changed, 244 insertions(+), 10 deletions(-) create mode 100644 web/src/components/SyncingBanner.tsx create mode 100644 web/src/hooks/useSyncingState.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index fb29653b..946b8712 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ( +
diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index e8f4795e..4eaf93e5 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -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 ( + + ) +} + 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 }}> - - + +
+ ) diff --git a/web/src/components/SyncingBanner.tsx b/web/src/components/SyncingBanner.tsx new file mode 100644 index 00000000..69540adb --- /dev/null +++ b/web/src/components/SyncingBanner.tsx @@ -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 ( +
+ + Syncing... +
+ ) +} diff --git a/web/src/hooks/useSyncingState.ts b/web/src/hooks/useSyncingState.ts new file mode 100644 index 00000000..cd96e03b --- /dev/null +++ b/web/src/hooks/useSyncingState.ts @@ -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 | null>(null) + const maxDurationTimeoutRef = useRef | 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 } +} diff --git a/web/src/index.css b/web/src/index.css index 53ba1c68..f6ebcc3f 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; +}