mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
+27
-7
@@ -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' : ''} ↓
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -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">↻</span>
|
||||
Syncing...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user