From bf298ca63dd2626fd3c24b662b639ed4655c531a Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Wed, 7 Jan 2026 10:23:15 -0600 Subject: [PATCH 1/6] feat: add simple pull-to-refresh to chat history sidebar Adds pull-to-refresh functionality using a unified approach: - Works on all platforms (mobile, desktop, web) with same behavior - Touch events for mobile devices - Mouse click-and-drag for desktop/web - Visual feedback with animated refresh icon - 60px pull threshold with resistance factor Fixes #366 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- frontend/src/components/ChatHistoryList.tsx | 142 +++++++++++++++++++- frontend/src/components/Sidebar.tsx | 7 +- 2 files changed, 146 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/ChatHistoryList.tsx b/frontend/src/components/ChatHistoryList.tsx index 3d2e8e41..919e05d7 100644 --- a/frontend/src/components/ChatHistoryList.tsx +++ b/frontend/src/components/ChatHistoryList.tsx @@ -6,7 +6,8 @@ import { Pencil, ChevronDown, ChevronRight, - CheckSquare + CheckSquare, + RefreshCw } from "lucide-react"; import { DropdownMenu, @@ -31,6 +32,7 @@ interface ChatHistoryListProps { onExitSelectionMode?: () => void; selectedIds: Set; onSelectionChange: (ids: Set) => void; + containerRef?: React.RefObject; } interface Conversation { @@ -57,7 +59,8 @@ export function ChatHistoryList({ isSelectionMode = false, onExitSelectionMode, selectedIds, - onSelectionChange + onSelectionChange, + containerRef }: ChatHistoryListProps) { const openai = useOpenAI(); const opensecret = useOpenSecret(); @@ -79,6 +82,14 @@ export function ChatHistoryList({ const lastConversationRef = useRef(null); const [conversations, setConversations] = useState([]); + // Pull-to-refresh states + const [isPullRefreshing, setIsPullRefreshing] = useState(false); + const [pullDistance, setPullDistance] = useState(0); + const pullStartY = useRef(0); + const isPulling = useRef(false); + const pullDistanceRef = useRef(0); + const isRefreshingRef = useRef(false); + // Fetch initial conversations from API using the OpenSecret SDK const { isPending, error } = useQuery({ queryKey: ["conversations"], @@ -170,6 +181,115 @@ export function ChatHistoryList({ } }, [opensecret]); + // Pull-to-refresh handler + const handleRefresh = useCallback(async () => { + isRefreshingRef.current = true; + setIsPullRefreshing(true); + try { + await pollForUpdates(); + } catch (error) { + console.error("Refresh failed:", error); + } finally { + setTimeout(() => { + setIsPullRefreshing(false); + setPullDistance(0); + isRefreshingRef.current = false; + }, 300); + } + }, [pollForUpdates]); + + // Pull-to-refresh event handlers - unified for all platforms (touch + mouse drag only) + useEffect(() => { + const container = containerRef?.current; + if (!container) return; + + const handleTouchStart = (e: TouchEvent) => { + if (container.scrollTop === 0 && !isRefreshingRef.current) { + pullStartY.current = e.touches[0].clientY; + isPulling.current = true; + } + }; + + const handleTouchMove = (e: TouchEvent) => { + if (!isPulling.current || isRefreshingRef.current) return; + + const currentY = e.touches[0].clientY; + const distance = currentY - pullStartY.current; + + if (distance > 0 && container.scrollTop === 0) { + e.preventDefault(); + const resistanceFactor = 0.4; + const adjustedDistance = Math.min(distance * resistanceFactor, 80); + pullDistanceRef.current = adjustedDistance; + setPullDistance(adjustedDistance); + } + }; + + const handleTouchEnd = () => { + if (!isPulling.current) return; + isPulling.current = false; + + if (pullDistanceRef.current > 60) { + handleRefresh(); + } else { + setPullDistance(0); + } + pullDistanceRef.current = 0; + }; + + const handleMouseDown = (e: MouseEvent) => { + if (container.scrollTop === 0 && !isRefreshingRef.current) { + pullStartY.current = e.clientY; + isPulling.current = true; + } + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!isPulling.current || isRefreshingRef.current) return; + + const currentY = e.clientY; + const distance = currentY - pullStartY.current; + + if (distance > 0 && container.scrollTop === 0) { + const resistanceFactor = 0.4; + const adjustedDistance = Math.min(distance * resistanceFactor, 80); + pullDistanceRef.current = adjustedDistance; + setPullDistance(adjustedDistance); + } + }; + + const handleMouseUp = () => { + if (!isPulling.current) return; + isPulling.current = false; + + if (pullDistanceRef.current > 60) { + handleRefresh(); + } else { + setPullDistance(0); + } + pullDistanceRef.current = 0; + }; + + // Touch events for mobile + container.addEventListener("touchstart", handleTouchStart, { passive: true }); + container.addEventListener("touchmove", handleTouchMove, { passive: false }); + container.addEventListener("touchend", handleTouchEnd); + + // Mouse events for desktop (click and drag) + container.addEventListener("mousedown", handleMouseDown); + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + container.removeEventListener("touchstart", handleTouchStart); + container.removeEventListener("touchmove", handleTouchMove); + container.removeEventListener("touchend", handleTouchEnd); + container.removeEventListener("mousedown", handleMouseDown); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [containerRef, handleRefresh]); + // Set up polling every 60 seconds useEffect(() => { if (!opensecret || conversations.length === 0) return; @@ -591,6 +711,24 @@ export function ChatHistoryList({ return ( <> + {/* Pull-to-refresh indicator */} + {pullDistance > 0 && ( +
+ +
+ )} + {filteredConversations.map((conv: Conversation, index: number) => { const title = conv.metadata?.title || "Untitled Chat"; const isActive = conv.id === currentChatId; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index b91ae2ef..45beceec 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -103,6 +103,7 @@ export function Sidebar({ }; const sidebarRef = useRef(null); + const historyContainerRef = useRef(null); // Use the centralized hook for mobile detection const isMobile = useIsMobile(); @@ -253,7 +254,10 @@ export function Sidebar({ )} )} -