diff --git a/src/components/ChatBody.tsx b/src/components/ChatBody.tsx index e3a6bb6..3d3d9b6 100644 --- a/src/components/ChatBody.tsx +++ b/src/components/ChatBody.tsx @@ -1,5 +1,5 @@ import React, { useRef, useState, useEffect, useCallback, useMemo, Fragment } from 'react'; -import { ChevronDown } from 'lucide-react'; +import { ArrowDownToLine } from 'lucide-react'; import type { ChatMessage, ToolStatus } from '../hooks/useChat'; import { MessageBubble } from './MessageBubble'; import { ToolCallCard } from './ToolCallCard'; @@ -65,10 +65,19 @@ export function ChatBody({ const scrollRef = scrollContainerRef || internalRef; const [userScrolled, setUserScrolled] = useState(false); - const scrollToBottom = useCallback(() => { + const isAutoScrolling = useRef(false); + + const scrollToBottom = useCallback((smooth = false) => { requestAnimationFrame(() => { const el = scrollRef.current; - if (el) el.scrollTop = el.scrollHeight; + if (!el) return; + if (smooth) { + isAutoScrolling.current = true; + el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); + setTimeout(() => { isAutoScrolling.current = false; }, 800); + } else { + el.scrollTop = el.scrollHeight; + } }); }, [scrollRef]); @@ -80,6 +89,7 @@ export function ChatBody({ }, [messages, streaming, userScrolled, scrollToBottom]); function handleScroll() { + if (isAutoScrolling.current) return; const el = scrollRef.current; if (!el) return; const scrolled = el.scrollHeight - el.scrollTop - el.clientHeight >= 100; @@ -168,7 +178,7 @@ export function ChatBody({ return (
{/* Scroll container */} -
+
{messages.length === 0 && !streaming && (
Send a message to start
)} @@ -246,11 +256,11 @@ export function ChatBody({ {/* Scroll-to-bottom button */} {userScrolled && ( )} diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index fb29793..6bc7be2 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -1,6 +1,5 @@ import React, { useState, useRef, useEffect, useCallback, useMemo, Fragment, type RefObject } from 'react'; import { useChat } from '../hooks/useChat'; -import { useVisualViewport } from '../hooks/useVisualViewport'; import { PLAN_OPTION } from '../lib/ws-types'; import { InteractivePromptOverlay } from './InteractivePromptOverlay'; import { StatusBar } from './StatusBar'; @@ -98,25 +97,41 @@ function ChatHeader({ sessionId, cwd }: { sessionId?: string; cwd?: string }) { } -/** Tracks scroll direction inside a child scroll container to auto-hide/show the header. */ +/** Auto-hide header during scroll, show when scroll stops or at bottom. */ function useAutoHideHeader(scrollRef: RefObject) { const [hidden, setHidden] = useState(false); const lastScrollTop = useRef(0); + const stopTimer = useRef | null>(null); useEffect(() => { const el = scrollRef.current; if (!el) return; + function onScroll() { const st = el!.scrollTop; const delta = st - lastScrollTop.current; - // delta > 0 means scrollTop increased (user scrolled toward bottom/latest) - // delta < 0 means scrollTop decreased (user scrolled toward top/history) - if (delta > 8) setHidden(false); // toward latest → show header - else if (delta < -8) setHidden(true); // toward history → hide header lastScrollTop.current = st; + + // Don't hide when at the bottom (viewing latest messages) + const atBottom = el!.scrollHeight - st - el!.clientHeight < 50; + if (atBottom) { + if (stopTimer.current) clearTimeout(stopTimer.current); + setHidden(false); + return; + } + + if (Math.abs(delta) > 8) setHidden(true); + + // Show header after scroll stops + if (stopTimer.current) clearTimeout(stopTimer.current); + stopTimer.current = setTimeout(() => setHidden(false), 1000); } + el.addEventListener('scroll', onScroll, { passive: true }); - return () => el.removeEventListener('scroll', onScroll); + return () => { + el.removeEventListener('scroll', onScroll); + if (stopTimer.current) clearTimeout(stopTimer.current); + }; }, [scrollRef]); return hidden; @@ -139,7 +154,6 @@ export function ChatView({ const reviewPanelRef = useRef(null); const reviewRefetchTimer = useRef | null>(null); const headerHidden = useAutoHideHeader(chatScrollRef); - const viewportHeight = useVisualViewport(); const { messages, toolStatuses, streaming, pendingResponse, wsStatus, sessionId, liveStatus, @@ -484,11 +498,10 @@ export function ChatView({ return (
- {/* Header — auto-hides when scrolling up to view history */} -
+ {/* Header — overlays content, slides up when scrolling */} +
diff --git a/src/hooks/useVisualViewport.ts b/src/hooks/useVisualViewport.ts deleted file mode 100644 index cc1e8cd..0000000 --- a/src/hooks/useVisualViewport.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useState, useEffect, useRef } from 'react'; - -/** - * Tracks window.visualViewport.height to work around iOS PWA - * keyboard resize bugs where `dvh` units don't update correctly - * after the virtual keyboard dismisses. - * - * Also forces window.scrollTo(0, 0) when the keyboard closes, - * because iOS standalone PWA may leave the document scrolled up - * after keyboard dismissal, creating a gap at the bottom. - * - * Returns the current visual viewport height in pixels, or - * undefined on platforms that don't support the API (falls back to CSS dvh). - */ -export function useVisualViewport(): number | undefined { - const [height, setHeight] = useState(() => - typeof window !== 'undefined' && window.visualViewport - ? window.visualViewport.height - : undefined, - ); - const prevHeight = useRef(height); - - useEffect(() => { - const vv = window.visualViewport; - if (!vv) return; - - const KEYBOARD_CLOSE_THRESHOLD = 50; - const update = () => { - const h = vv.height; - if (prevHeight.current && h > prevHeight.current + KEYBOARD_CLOSE_THRESHOLD) { - window.scrollTo(0, 0); - } - if (h !== prevHeight.current) { - prevHeight.current = h; - setHeight(h); - } - }; - - vv.addEventListener('resize', update); - vv.addEventListener('scroll', update); - return () => { - vv.removeEventListener('resize', update); - vv.removeEventListener('scroll', update); - }; - }, []); - - return height; -} diff --git a/src/index.css b/src/index.css index 258dc06..613966c 100644 --- a/src/index.css +++ b/src/index.css @@ -21,6 +21,10 @@ --font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; } +html, body { + overscroll-behavior: none; +} + body { margin: 0; background: var(--color-bg);