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:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user