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": {