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 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 type { ChatMessage, ToolStatus } from '../hooks/useChat';
|
||||||
import { MessageBubble } from './MessageBubble';
|
import { MessageBubble } from './MessageBubble';
|
||||||
import { ToolCallCard } from './ToolCallCard';
|
import { ToolCallCard } from './ToolCallCard';
|
||||||
@@ -65,10 +65,19 @@ export function ChatBody({
|
|||||||
const scrollRef = scrollContainerRef || internalRef;
|
const scrollRef = scrollContainerRef || internalRef;
|
||||||
const [userScrolled, setUserScrolled] = useState(false);
|
const [userScrolled, setUserScrolled] = useState(false);
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
const isAutoScrolling = useRef(false);
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback((smooth = false) => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const el = scrollRef.current;
|
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]);
|
}, [scrollRef]);
|
||||||
|
|
||||||
@@ -80,6 +89,7 @@ export function ChatBody({
|
|||||||
}, [messages, streaming, userScrolled, scrollToBottom]);
|
}, [messages, streaming, userScrolled, scrollToBottom]);
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
|
if (isAutoScrolling.current) return;
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const scrolled = el.scrollHeight - el.scrollTop - el.clientHeight >= 100;
|
const scrolled = el.scrollHeight - el.scrollTop - el.clientHeight >= 100;
|
||||||
@@ -168,7 +178,7 @@ export function ChatBody({
|
|||||||
return (
|
return (
|
||||||
<div className={className ? `flex flex-col min-h-0 ${className}` : 'flex flex-col min-h-0 flex-1'}>
|
<div className={className ? `flex flex-col min-h-0 ${className}` : 'flex flex-col min-h-0 flex-1'}>
|
||||||
{/* Scroll container */}
|
{/* 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 && (
|
{messages.length === 0 && !streaming && (
|
||||||
<div className="text-text-dim text-sm text-center py-20 font-mono">Send a message to start</div>
|
<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 */}
|
{/* Scroll-to-bottom button */}
|
||||||
{userScrolled && (
|
{userScrolled && (
|
||||||
<button
|
<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"
|
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"
|
aria-label="Scroll to bottom"
|
||||||
>
|
>
|
||||||
<ChevronDown className="w-4 h-4 text-text-secondary" />
|
<ArrowDownToLine className="w-4 h-4 text-text-secondary" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+25
-12
@@ -1,6 +1,5 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback, useMemo, Fragment, type RefObject } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo, Fragment, type RefObject } from 'react';
|
||||||
import { useChat } from '../hooks/useChat';
|
import { useChat } from '../hooks/useChat';
|
||||||
import { useVisualViewport } from '../hooks/useVisualViewport';
|
|
||||||
import { PLAN_OPTION } from '../lib/ws-types';
|
import { PLAN_OPTION } from '../lib/ws-types';
|
||||||
import { InteractivePromptOverlay } from './InteractivePromptOverlay';
|
import { InteractivePromptOverlay } from './InteractivePromptOverlay';
|
||||||
import { StatusBar } from './StatusBar';
|
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>) {
|
function useAutoHideHeader(scrollRef: RefObject<HTMLDivElement | null>) {
|
||||||
const [hidden, setHidden] = useState(false);
|
const [hidden, setHidden] = useState(false);
|
||||||
const lastScrollTop = useRef(0);
|
const lastScrollTop = useRef(0);
|
||||||
|
const stopTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
const st = el!.scrollTop;
|
const st = el!.scrollTop;
|
||||||
const delta = st - lastScrollTop.current;
|
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;
|
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 });
|
el.addEventListener('scroll', onScroll, { passive: true });
|
||||||
return () => el.removeEventListener('scroll', onScroll);
|
return () => {
|
||||||
|
el.removeEventListener('scroll', onScroll);
|
||||||
|
if (stopTimer.current) clearTimeout(stopTimer.current);
|
||||||
|
};
|
||||||
}, [scrollRef]);
|
}, [scrollRef]);
|
||||||
|
|
||||||
return hidden;
|
return hidden;
|
||||||
@@ -139,7 +154,6 @@ export function ChatView({
|
|||||||
const reviewPanelRef = useRef<ReviewPanelHandle>(null);
|
const reviewPanelRef = useRef<ReviewPanelHandle>(null);
|
||||||
const reviewRefetchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reviewRefetchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const headerHidden = useAutoHideHeader(chatScrollRef);
|
const headerHidden = useAutoHideHeader(chatScrollRef);
|
||||||
const viewportHeight = useVisualViewport();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages, toolStatuses, streaming, pendingResponse, wsStatus, sessionId, liveStatus,
|
messages, toolStatuses, streaming, pendingResponse, wsStatus, sessionId, liveStatus,
|
||||||
@@ -484,11 +498,10 @@ export function ChatView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex flex-col bg-bg relative overflow-hidden safe-top"
|
className="flex flex-col h-dvh bg-bg relative overflow-hidden safe-top"
|
||||||
style={{ height: viewportHeight ? `${viewportHeight}px` : '100dvh' }}
|
|
||||||
>
|
>
|
||||||
{/* Header — auto-hides when scrolling up to view history */}
|
{/* Header — overlays content, slides up when scrolling */}
|
||||||
<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'}`}>
|
<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}>
|
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
<ChevronLeft className="w-5 h-5" />
|
<ChevronLeft className="w-5 h-5" />
|
||||||
</Button>
|
</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;
|
--font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
|
|||||||
Reference in New Issue
Block a user