fix(mobile): revert viewport hacking, fix header auto-hide + smooth scroll

- Remove useVisualViewport hook (keyboard gap is iOS browser behavior, not a bug in PWA)
- Header: absolute overlay with translateY animation (no content jump)
- Header: hide during scroll, show after 1s stop, stay visible at bottom
- Scroll-to-bottom: smooth animation with isAutoScrolling guard
- Icon: ArrowDownToLine replaces ChevronDown

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kuannnn
2026-03-29 09:19:03 +08:00
parent b4d55c4de3
commit 10c38ad2e4
4 changed files with 45 additions and 66 deletions
+16 -6
View File
@@ -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 (
<div className={className ? `flex flex-col min-h-0 ${className}` : 'flex flex-col min-h-0 flex-1'}>
{/* Scroll container */}
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto px-4 py-4">
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto px-4 pt-14 pb-4">
{messages.length === 0 && !streaming && (
<div className="text-text-dim text-sm text-center py-20 font-mono">Send a message to start</div>
)}
@@ -246,11 +256,11 @@ export function ChatBody({
{/* Scroll-to-bottom button */}
{userScrolled && (
<button
onClick={() => { setUserScrolled(false); scrollToBottom(); }}
onClick={() => { setUserScrolled(false); scrollToBottom(true); }}
className="absolute bottom-20 right-4 w-8 h-8 rounded-full bg-surface border border-border flex items-center justify-center shadow-lg hover:bg-surface-light transition-colors z-10"
aria-label="Scroll to bottom"
>
<ChevronDown className="w-4 h-4 text-text-secondary" />
<ArrowDownToLine className="w-4 h-4 text-text-secondary" />
</button>
)}
+25 -12
View File
@@ -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<HTMLDivElement | null>) {
const [hidden, setHidden] = useState(false);
const lastScrollTop = useRef(0);
const stopTimer = useRef<ReturnType<typeof setTimeout> | 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<ReviewPanelHandle>(null);
const reviewRefetchTimer = useRef<ReturnType<typeof setTimeout> | 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 (
<div
className="flex flex-col bg-bg relative overflow-hidden safe-top"
style={{ height: viewportHeight ? `${viewportHeight}px` : '100dvh' }}
className="flex flex-col h-dvh bg-bg relative overflow-hidden safe-top"
>
{/* Header — auto-hides when scrolling up to view history */}
<div className={`flex items-center gap-2 px-4 py-3 border-b border-border shrink-0 transition-all duration-200 ${headerHidden ? 'max-h-0 py-0 overflow-hidden opacity-0 border-b-0' : 'max-h-16 opacity-100'}`}>
{/* Header — overlays content, slides up when scrolling */}
<div className={`absolute top-0 left-0 right-0 flex items-center gap-2 px-4 py-3 border-b border-border bg-bg z-10 safe-top transition-transform duration-200 ${headerHidden ? '-translate-y-full' : 'translate-y-0'}`}>
<Button variant="ghost" size="icon" onClick={onBack}>
<ChevronLeft className="w-5 h-5" />
</Button>
-48
View File
@@ -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<number | undefined>(() =>
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;
}
+4
View File
@@ -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);