diff --git a/__tests__/integration/components/TerminalOutput.test.tsx b/__tests__/integration/components/TerminalOutput.test.tsx index c396ce87..9f2edf6c 100644 --- a/__tests__/integration/components/TerminalOutput.test.tsx +++ b/__tests__/integration/components/TerminalOutput.test.tsx @@ -76,14 +76,14 @@ describe('TerminalOutput – controls', () => { const { getByLabelText } = await render( ) - expect(getByLabelText('Jump to bottom')).toBeTruthy() + expect(getByLabelText('Scroll to bottom')).toBeTruthy() }) it('renders jump-to-top button', async () => { const { getByLabelText } = await render( ) - expect(getByLabelText('Jump to top')).toBeTruthy() + expect(getByLabelText('Scroll to top')).toBeTruthy() }) }) diff --git a/components/conversation/ConversationHistoryList.tsx b/components/conversation/ConversationHistoryList.tsx index 0b4ad14e..8b57314b 100644 --- a/components/conversation/ConversationHistoryList.tsx +++ b/components/conversation/ConversationHistoryList.tsx @@ -10,11 +10,12 @@ import { type NativeSyntheticEvent, } from 'react-native' import { useTranslation } from 'react-i18next' +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated' import { FlashList, type FlashListRef } from '@shopify/flash-list' import { CaretDown } from 'phosphor-react-native' import { MessageItem } from '@/components/conversation/MessageItem' import type { Message } from '@/types/api' -import { font, spacing, type Theme } from '@/constants/theme' +import { spacing, type Theme } from '@/constants/theme' import { useTheme } from '@/contexts/ThemeContext' // The message list core, shared by the tail history view and the anchored @@ -90,8 +91,13 @@ export const ConversationHistoryList = forwardRef, Convers [], ) - const [showScrollTop, setShowScrollTop] = useState(false) const [showScrollBottom, setShowScrollBottom] = useState(false) + const showTopVal = useSharedValue(0) + + const topBtnStyle = useAnimatedStyle(() => ({ + opacity: withTiming(showTopVal.value, { duration: 220 }), + pointerEvents: showTopVal.value > 0 ? 'auto' : 'none', + })) // Ids that should play the fade-in-from-bottom entrance. A message id not yet // seen is new; it animates ONLY when it lands in the tail window (a live @@ -214,9 +220,9 @@ export const ConversationHistoryList = forwardRef, Convers const handleScroll = useCallback((e: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent const distFromBottom = contentSize.height - contentOffset.y - layoutMeasurement.height - setShowScrollTop(contentOffset.y > 200) + showTopVal.value = contentOffset.y > 200 ? 1 : 0 setShowScrollBottom(distFromBottom > 100) - }, []) + }, [showTopVal]) const scrollToTop = useCallback(() => { listRef.current?.scrollToOffset({ offset: 0, animated: true }) @@ -256,15 +262,19 @@ export const ConversationHistoryList = forwardRef, Convers ListHeaderComponent={listHeader} ListFooterComponent={listFooter} /> - {showScrollTop ? ( + {t('common:nav.top')} - ) : null} + {showScrollBottom ? ( listRef.current?.scrollToOffset({ offset: 0, animated: true })} - accessibilityLabel="Scroll to top" + accessibilityLabel={t('common:nav.scrollToTop')} style={styles.scrollBtnInner} > {t('common:nav.top')} @@ -251,7 +251,7 @@ export function ConversationList({ listRef.current?.scrollToEnd({ animated: true })} - accessibilityLabel="Scroll to bottom" + accessibilityLabel={t('common:nav.scrollToBottom')} style={styles.scrollBtnInner} > {t('common:nav.bottom')} diff --git a/components/terminal/TerminalOutput.tsx b/components/terminal/TerminalOutput.tsx index f27784ac..24c8f28c 100644 --- a/components/terminal/TerminalOutput.tsx +++ b/components/terminal/TerminalOutput.tsx @@ -252,7 +252,7 @@ export function TerminalOutput({ lines, isStreaming: _isStreaming, userMessageTe listRef.current?.scrollToOffset({ offset: 0, animated: true })} - accessibilityLabel="Jump to top" + accessibilityLabel={t('nav.scrollToTop')} style={styles.jumpBtnInner} > {t('nav.top')} @@ -271,7 +271,7 @@ export function TerminalOutput({ lines, isStreaming: _isStreaming, userMessageTe {t('nav.bottom')} diff --git a/docs/screenshots/top-fab-overlap-after.png b/docs/screenshots/top-fab-overlap-after.png new file mode 100644 index 00000000..63f777d8 Binary files /dev/null and b/docs/screenshots/top-fab-overlap-after.png differ diff --git a/docs/screenshots/top-fab-overlap-before.png b/docs/screenshots/top-fab-overlap-before.png new file mode 100644 index 00000000..bec62fbc Binary files /dev/null and b/docs/screenshots/top-fab-overlap-before.png differ diff --git a/e2e/07_conversation_scroll_gaps.yaml b/e2e/07_conversation_scroll_gaps.yaml index 8ba57562..9d503fcf 100644 --- a/e2e/07_conversation_scroll_gaps.yaml +++ b/e2e/07_conversation_scroll_gaps.yaml @@ -222,3 +222,34 @@ appId: com.ronenmars.threadbase text: ".*TOP-NEIGHBOR-MARKER.*" - takeScreenshot: e2e/_artifacts/screenshots/scroll-gaps-top-after-flings + +# ── The `↑ Top` FAB still works after the translucent restyle ───────────────── +# It is now always mounted and faded by opacity rather than conditionally +# mounted, so "still hittable" is a real risk: an opacity-0 overlay that keeps +# `pointerEvents: auto` would swallow list touches, and one that never regains +# `auto` would stop scrolling to the top. Scroll back down past the 200pt +# threshold, then drive the whole affordance through the FAB itself. +- swipe: + start: 50%, 80% + end: 50%, 25% + duration: 120 +- swipe: + start: 50%, 80% + end: 50%, 25% + duration: 120 +- swipe: + start: 50%, 80% + end: 50%, 25% + duration: 120 + +- extendedWaitUntil: + visible: + id: "conversation-scroll-top" + timeout: 4000 +- takeScreenshot: e2e/_artifacts/screenshots/scroll-gaps-top-fab-over-content +- tapOn: + id: "conversation-scroll-top" +- extendedWaitUntil: + visible: + text: ".*SCROLL-GAPS-TOP-MARKER.*" + timeout: 4000 diff --git a/locales/ar/common.json b/locales/ar/common.json index 1baeecf6..6f5bfef5 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -17,6 +17,8 @@ "nav": { "top": "↑ الأعلى", "bottom": "↓ الأسفل", + "scrollToTop": "التمرير للأعلى", + "scrollToBottom": "التمرير للأسفل", "copyAll": "نسخ الكل" }, "biometricLock": { diff --git a/locales/en/common.json b/locales/en/common.json index f9babe9f..828e6a3c 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -17,6 +17,8 @@ "nav": { "top": "↑ Top", "bottom": "↓ Bottom", + "scrollToTop": "Scroll to top", + "scrollToBottom": "Scroll to bottom", "copyAll": "Copy all" }, "biometricLock": { diff --git a/locales/he/common.json b/locales/he/common.json index da111aba..7711c23d 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -17,6 +17,8 @@ "nav": { "top": "↑ למעלה", "bottom": "↓ למטה", + "scrollToTop": "גלול למעלה", + "scrollToBottom": "גלול למטה", "copyAll": "העתק הכל" }, "biometricLock": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 46fa8a83..541a10b9 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -17,6 +17,8 @@ "nav": { "top": "↑ Вверх", "bottom": "↓ Вниз", + "scrollToTop": "Прокрутить вверх", + "scrollToBottom": "Прокрутить вниз", "copyAll": "Копировать всё" }, "biometricLock": {