From 9e10046aeee196f83f1fc0703e1479e0eb945118 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Jan 2026 22:02:26 +0000 Subject: [PATCH] Optimize mobile scroll and viewport handling for keyboard states - Enhanced keyboard detection to work on all mobile browsers (iOS, Android) - Improved scroll-to-bottom behavior when keyboard opens/closes - Optimized viewport height calculations for mobile keyboard states - Adjusted main content padding dynamically based on keyboard state - Improved ConversationView scrolling with better anchor-based scrolling - Enhanced HomeActivityList scrolling performance on mobile - Added CSS optimizations for smooth mobile scrolling and viewport handling - Fixed composer positioning when keyboard is open on mobile devices --- src/app/globals.css | 31 ++++++- src/app/page.tsx | 49 ++++++++++- src/components/Composer.tsx | 130 ++++++++++++++++++++++------ src/components/ConversationView.tsx | 51 +++++++++-- src/components/HomeActivityList.tsx | 8 +- 5 files changed, 228 insertions(+), 41 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 41574b8..062f8ea 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -149,6 +149,10 @@ html { /* Fix 100vh bug on mobile - use fill-available as fallback */ height: 100%; height: -webkit-fill-available; + /* Prevent horizontal scroll on mobile */ + overflow-x: hidden; + /* Improve touch scrolling */ + -webkit-overflow-scrolling: touch; } body { @@ -160,6 +164,12 @@ body { /* Fix 100vh bug on mobile */ min-height: 100%; min-height: -webkit-fill-available; + /* Prevent horizontal scroll on mobile */ + overflow-x: hidden; + /* Improve touch scrolling */ + -webkit-overflow-scrolling: touch; + /* Prevent pull-to-refresh on mobile (can interfere with scrolling) */ + overscroll-behavior-y: contain; } /* Smooth animations */ @@ -360,13 +370,28 @@ a:active { /* ======================================== - iOS PWA Scroll & Viewport Optimizations + Mobile Scroll & Viewport Optimizations ======================================== */ -/* iOS PWA scroll improvements for conversation container */ +/* Mobile scroll improvements for all scroll containers */ [data-scroll-container] { - /* Smooth momentum scrolling on iOS - enables natural bounce */ + /* Smooth momentum scrolling on iOS/Android - enables natural bounce */ -webkit-overflow-scrolling: touch; + /* Prevent scroll chaining on mobile */ + overscroll-behavior: contain; + /* Improve scroll performance */ + will-change: scroll-position; + /* Ensure proper scrolling when keyboard is open */ + scroll-behavior: smooth; +} + +/* Optimize scrolling performance on mobile */ +@media (max-width: 768px) { + [data-scroll-container] { + /* Use GPU acceleration for smoother scrolling */ + transform: translateZ(0); + -webkit-transform: translateZ(0); + } } diff --git a/src/app/page.tsx b/src/app/page.tsx index be6dc84..4f5bdd9 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -10,6 +10,46 @@ import { CursorLoader } from '@/components/CursorLoader'; import { HomeActivityList } from '@/components/HomeActivityList'; import { RepoPicker } from '@/components/RepoPicker'; import { theme } from '@/lib/theme'; + +// Hook to detect mobile keyboard state for layout adjustments +function useMobileKeyboardState() { + const [keyboardHeight, setKeyboardHeight] = useState(0); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + // Detect mobile devices + const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) || + window.innerWidth < 768; + + if (!isMobile) return; + + const vv = window.visualViewport; + if (!vv) return; + + const handleViewportChange = () => { + const currentHeight = vv.height; + const windowHeight = window.innerHeight; + const heightDiff = windowHeight - currentHeight; + + // Keyboard is open if viewport shrunk significantly + if (heightDiff > 100) { + setKeyboardHeight(heightDiff); + setIsOpen(true); + } else { + setKeyboardHeight(0); + setIsOpen(false); + } + }; + + vv.addEventListener('resize', handleViewportChange); + return () => { + vv.removeEventListener('resize', handleViewportChange); + }; + }, []); + + return { keyboardHeight, isOpen }; +} import { validateApiKey, listRepositories, @@ -127,6 +167,9 @@ export default function Home() { // Track whether composer has input (for hiding empty state) const [hasComposerInput, setHasComposerInput] = useState(false); + // Track mobile keyboard state for layout adjustments + const { keyboardHeight: mobileKeyboardHeight, isOpen: isMobileKeyboardOpen } = useMobileKeyboardState(); + // Trigger to restart polling in ConversationView (for follow-ups to finished agents) const [refreshTrigger, setRefreshTrigger] = useState(0); @@ -999,7 +1042,11 @@ export default function Home() { paddingTop: isInChatView ? 'calc(env(safe-area-inset-top, 0px) + 3.5rem)' : 'calc(env(safe-area-inset-top, 0px) + 7rem)', - paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)', + // Adjust bottom padding when keyboard is open on mobile + // Reduce padding since composer moves up with keyboard + paddingBottom: isMobileKeyboardOpen && mobileKeyboardHeight > 100 + ? `calc(env(safe-area-inset-bottom, 0px) + ${Math.max(1.5, 5.5 - (mobileKeyboardHeight / 100))}rem)` + : 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)', }} > {isInChatView ? ( diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index 4c478f1..5c4db98 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -4,45 +4,99 @@ import { useState, useRef, useEffect } from 'react'; import { CursorLoader } from './CursorLoader'; import { trackComposerSubmit } from '@/lib/analytics'; -// Hook to handle iOS keyboard viewport issues -// Returns keyboard height so parent can adjust layout -function useIOSKeyboard(): number { +// Hook to handle mobile keyboard viewport issues (iOS, Android, etc.) +// Returns keyboard height and isOpen state so parent can adjust layout +function useMobileKeyboard(): { keyboardHeight: number; isOpen: boolean } { const [keyboardHeight, setKeyboardHeight] = useState(0); + const [isOpen, setIsOpen] = useState(false); const initialViewportHeight = useRef(0); + const lastHeight = useRef(0); useEffect(() => { - // Check if we're on iOS/mobile Safari - const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || - (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); + // Detect mobile devices (iOS, Android, etc.) + const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) || + window.innerWidth < 768; // Also treat small screens as mobile - if (!isIOS) return; + if (!isMobile) return; const vv = window.visualViewport; - if (!vv) return; + if (!vv) { + // Fallback for browsers without visualViewport API + const handleResize = () => { + const currentHeight = window.innerHeight; + if (initialViewportHeight.current === 0) { + initialViewportHeight.current = currentHeight; + } + + const heightDiff = initialViewportHeight.current - currentHeight; + // Keyboard is likely open if viewport shrunk significantly (>150px) + if (heightDiff > 150) { + setKeyboardHeight(heightDiff); + setIsOpen(true); + } else { + setKeyboardHeight(0); + setIsOpen(false); + // Reset initial height if keyboard closed (viewport returned to normal) + if (currentHeight > initialViewportHeight.current * 0.9) { + initialViewportHeight.current = currentHeight; + } + } + }; + + window.addEventListener('resize', handleResize); + initialViewportHeight.current = window.innerHeight; + + return () => { + window.removeEventListener('resize', handleResize); + }; + } // Store initial viewport height (full screen without keyboard) initialViewportHeight.current = window.innerHeight; + lastHeight.current = vv.height; const handleViewportChange = () => { - // Calculate keyboard height from difference between layout and visual viewport - const currentKeyboardHeight = window.innerHeight - vv.height; + const currentHeight = vv.height; + const windowHeight = window.innerHeight; - // Only set if keyboard is actually visible (threshold to avoid false positives) - if (currentKeyboardHeight > 100) { + // Calculate keyboard height from difference between window and visual viewport + const currentKeyboardHeight = windowHeight - currentHeight; + + // Detect keyboard state: significant height difference indicates keyboard is open + // Use a threshold to avoid false positives from small viewport changes + const threshold = 100; + + if (currentKeyboardHeight > threshold) { setKeyboardHeight(currentKeyboardHeight); + setIsOpen(true); } else { + // Keyboard closed - reset if viewport returned close to initial height + if (currentHeight > initialViewportHeight.current * 0.9) { + initialViewportHeight.current = windowHeight; + } setKeyboardHeight(0); + setIsOpen(false); } + + lastHeight.current = currentHeight; }; + // Listen to viewport changes vv.addEventListener('resize', handleViewportChange); + vv.addEventListener('scroll', handleViewportChange); + + // Also listen to window resize as fallback + window.addEventListener('resize', handleViewportChange); return () => { vv.removeEventListener('resize', handleViewportChange); + vv.removeEventListener('scroll', handleViewportChange); + window.removeEventListener('resize', handleViewportChange); }; }, []); - return keyboardHeight; + return { keyboardHeight, isOpen }; } // Default model to use @@ -69,21 +123,37 @@ export function Composer({ const textareaRef = useRef(null); const lastSubmitRef = useRef(0); - // Get iOS keyboard height to adjust composer position - const keyboardHeight = useIOSKeyboard(); + // Get mobile keyboard state to adjust composer position and trigger scroll + const { keyboardHeight, isOpen: isKeyboardOpen } = useMobileKeyboard(); - // Scroll conversation to bottom when keyboard opens + // Scroll conversation to bottom when keyboard opens (with delay for smooth animation) useEffect(() => { - if (keyboardHeight > 0) { - const scrollContainer = document.querySelector('[data-scroll-container]'); - if (scrollContainer) { - scrollContainer.scrollTo({ - top: scrollContainer.scrollHeight, - behavior: 'smooth' - }); - } + if (isKeyboardOpen && keyboardHeight > 0) { + // Use requestAnimationFrame to ensure DOM is updated before scrolling + const scrollToBottom = () => { + const scrollContainer = document.querySelector('[data-scroll-container]'); + if (scrollContainer) { + // Use scrollIntoView on bottom anchor if available, otherwise scroll container + const bottomAnchor = scrollContainer.querySelector('[data-bottom-anchor]'); + if (bottomAnchor) { + bottomAnchor.scrollIntoView({ behavior: 'smooth', block: 'end' }); + } else { + scrollContainer.scrollTo({ + top: scrollContainer.scrollHeight, + behavior: 'smooth' + }); + } + } + }; + + // Small delay to allow keyboard animation to start + const timer = setTimeout(scrollToBottom, 100); + // Also try immediately in case keyboard opens instantly + requestAnimationFrame(scrollToBottom); + + return () => clearTimeout(timer); } - }, [keyboardHeight]); + }, [isKeyboardOpen, keyboardHeight]); // Auto-resize textarea - grows with content useEffect(() => { @@ -146,12 +216,18 @@ export function Composer({ // Check if we have text content for styling changes const hasContent = value.trim().length > 0; + // Calculate safe transform value (avoid accessing window during SSR) + const transformValue = typeof window !== 'undefined' && keyboardHeight > 100 + ? `translateY(-${Math.min(keyboardHeight, window.innerHeight * 0.5)}px)` + : undefined; + return (
0 ? `translateY(-${keyboardHeight}px)` : undefined + // Move composer up when mobile keyboard is visible + // Only apply transform if keyboard is significantly open to avoid jitter + transform: transformValue, }} >
{ + // Optimized for mobile keyboard states + const scrollToBottom = useCallback((smooth = false) => { // 1) Prefer sentinel anchor (scrolls the nearest scrollable ancestor, including window) - bottomAnchorRef.current?.scrollIntoView({ block: 'end', behavior: 'auto' }); + if (bottomAnchorRef.current) { + bottomAnchorRef.current.scrollIntoView({ + block: 'end', + behavior: smooth ? 'smooth' : 'auto', + inline: 'nearest' + }); + } // 2) Also force the known scroll container, in case the browser chooses a different ancestor if (scrollRef.current) { const el = scrollRef.current; - el.scrollTop = el.scrollHeight; + // Use requestAnimationFrame for smoother scrolling on mobile + requestAnimationFrame(() => { + el.scrollTop = el.scrollHeight; + }); } // 3) Final fallback: if the page itself is scrolling, force the document scroller const scrollingEl = document.scrollingElement; if (scrollingEl) { - scrollingEl.scrollTop = scrollingEl.scrollHeight; + requestAnimationFrame(() => { + scrollingEl.scrollTop = scrollingEl.scrollHeight; + }); } }, []); @@ -988,7 +1000,7 @@ export function ConversationView({ if (isLoadingPastAgent) return; // Only scroll once we either have messages or loading has completed. if (messages.length === 0 && isLoading) return; - scrollToBottom(); + scrollToBottom(false); // Use instant scroll for initial load }, [agentId, isPending, isLoadingPastAgent, isLoading, messages.length, scrollToBottom]); // After-paint fallback for late layout changes (images/video/fonts) @@ -996,10 +1008,27 @@ export function ConversationView({ if (!agentId || isPending) return; if (isLoadingPastAgent) return; if (messages.length === 0 && isLoading) return; - const t = setTimeout(scrollToBottom, 0); - return () => clearTimeout(t); + // Use requestAnimationFrame for better mobile performance + const rafId = requestAnimationFrame(() => { + scrollToBottom(false); + }); + return () => cancelAnimationFrame(rafId); }, [agentId, isPending, isLoadingPastAgent, isLoading, messages.length, scrollToBottom]); + // Scroll to bottom when new messages arrive (smooth scroll for better UX) + useEffect(() => { + if (!agentId || isPending) return; + if (isLoadingPastAgent) return; + if (messages.length === 0) return; + + // Use a small delay to ensure DOM is updated, especially on mobile + const timer = setTimeout(() => { + scrollToBottom(true); // Smooth scroll for new messages + }, 50); + + return () => clearTimeout(timer); + }, [messages.length, agentId, isPending, isLoadingPastAgent, scrollToBottom]); + if (!agentId) { return null; } @@ -1017,7 +1046,11 @@ export function ConversationView({ ref={scrollRef} data-scroll-container className="flex-1 min-h-0 overflow-y-auto scrollbar-hidden flex flex-col" - style={{ WebkitOverflowScrolling: 'touch' }} + style={{ + WebkitOverflowScrolling: 'touch', + // Ensure proper scrolling on mobile, especially when keyboard is open + overscrollBehavior: 'contain', + }} > {/* Conversation content with consistent padding */}
@@ -1203,7 +1236,7 @@ export function ConversationView({ })()} {/* Bottom anchor element for reliable scrolling */} - ); diff --git a/src/components/HomeActivityList.tsx b/src/components/HomeActivityList.tsx index 4c6f468..be3502e 100644 --- a/src/components/HomeActivityList.tsx +++ b/src/components/HomeActivityList.tsx @@ -368,7 +368,13 @@ export function HomeActivityList({ key={selectedRepo?.repository || 'default'} data-scroll-container className="flex-1 overflow-y-auto scrollbar-hidden pt-4 pb-44" - style={{ WebkitOverflowScrolling: 'touch' }} + style={{ + WebkitOverflowScrolling: 'touch', + // Ensure proper scrolling on mobile, especially when keyboard is open + overscrollBehavior: 'contain', + // Improve scroll performance on mobile + willChange: 'scroll-position', + }} > {isLoading ? (