Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions __tests__/integration/components/TerminalOutput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,14 @@ describe('TerminalOutput – controls', () => {
const { getByLabelText } = await render(
<TerminalOutput lines={['line']} isStreaming={false} />
)
expect(getByLabelText('Jump to bottom')).toBeTruthy()
expect(getByLabelText('Scroll to bottom')).toBeTruthy()
})

it('renders jump-to-top button', async () => {
const { getByLabelText } = await render(
<TerminalOutput lines={['line']} isStreaming={false} />
)
expect(getByLabelText('Jump to top')).toBeTruthy()
expect(getByLabelText('Scroll to top')).toBeTruthy()
})
})

Expand Down
43 changes: 30 additions & 13 deletions components/conversation/ConversationHistoryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -90,8 +91,13 @@ export const ConversationHistoryList = forwardRef<FlashListRef<Message>, 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
Expand Down Expand Up @@ -214,9 +220,9 @@ export const ConversationHistoryList = forwardRef<FlashListRef<Message>, Convers
const handleScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
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 })
Expand Down Expand Up @@ -256,15 +262,19 @@ export const ConversationHistoryList = forwardRef<FlashListRef<Message>, Convers
ListHeaderComponent={listHeader}
ListFooterComponent={listFooter}
/>
{showScrollTop ? (
<Animated.View
style={[styles.scrollBtn, styles.scrollBtnTop, topBtnStyle]}
pointerEvents="box-none"
>
<TouchableOpacity
style={[styles.scrollBtn, styles.scrollBtnTop]}
style={styles.scrollBtnInner}
onPress={scrollToTop}
accessibilityLabel="Scroll to top"
accessibilityLabel={t('common:nav.scrollToTop')}
testID="conversation-scroll-top"
>
<Text style={styles.scrollBtnText}>{t('common:nav.top')}</Text>
</TouchableOpacity>
) : null}
</Animated.View>
{showScrollBottom ? (
<TouchableOpacity
style={styles.scrollBtnBottom}
Expand Down Expand Up @@ -292,12 +302,19 @@ function makeStyles(theme: Theme) {
scrollBtn: {
position: 'absolute',
alignSelf: 'center',
backgroundColor: theme.text.accent,
borderRadius: 20,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
scrollBtnTop: { top: spacing.md },
// Translucent so message text stays legible underneath — this pill sits over
// the centered text column, not in a gutter. Matches ConversationList and
// TerminalOutput.
scrollBtnInner: {
backgroundColor: 'rgba(31, 111, 235, 0.14)',
borderRadius: 20,
borderWidth: 1,
borderColor: 'rgba(88, 166, 255, 0.2)',
paddingHorizontal: 14,
paddingVertical: 6,
},
scrollBtnBottom: {
position: 'absolute',
right: spacing.md,
Expand All @@ -314,6 +331,6 @@ function makeStyles(theme: Theme) {
shadowRadius: 4,
elevation: 4,
},
scrollBtnText: { color: '#fff', fontSize: font.sm, fontWeight: '600' },
scrollBtnText: { color: 'rgba(230, 237, 243, 0.6)', fontSize: 12, fontWeight: '500' },
})
}
4 changes: 2 additions & 2 deletions components/conversation/ConversationList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ export function ConversationList({
<Animated.View style={[styles.scrollBtn, styles.scrollBtnTop, topBtnStyle]} pointerEvents="box-none">
<TouchableOpacity
onPress={() => listRef.current?.scrollToOffset({ offset: 0, animated: true })}
accessibilityLabel="Scroll to top"
accessibilityLabel={t('common:nav.scrollToTop')}
style={styles.scrollBtnInner}
>
<Text style={styles.scrollBtnText}>{t('common:nav.top')}</Text>
Expand All @@ -251,7 +251,7 @@ export function ConversationList({
<Animated.View style={[styles.scrollBtn, styles.scrollBtnBottom, bottomBtnStyle]} pointerEvents="box-none">
<TouchableOpacity
onPress={() => listRef.current?.scrollToEnd({ animated: true })}
accessibilityLabel="Scroll to bottom"
accessibilityLabel={t('common:nav.scrollToBottom')}
style={styles.scrollBtnInner}
>
<Text style={styles.scrollBtnText}>{t('common:nav.bottom')}</Text>
Expand Down
4 changes: 2 additions & 2 deletions components/terminal/TerminalOutput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ export function TerminalOutput({ lines, isStreaming: _isStreaming, userMessageTe
<Animated.View style={[styles.jumpBtn, styles.jumpBtnTop, topBtnStyle]} pointerEvents="box-none">
<TouchableOpacity
onPress={() => listRef.current?.scrollToOffset({ offset: 0, animated: true })}
accessibilityLabel="Jump to top"
accessibilityLabel={t('nav.scrollToTop')}
style={styles.jumpBtnInner}
>
<Text style={styles.jumpBtnText}>{t('nav.top')}</Text>
Expand All @@ -271,7 +271,7 @@ export function TerminalOutput({ lines, isStreaming: _isStreaming, userMessageTe
<Animated.View style={[styles.jumpBtn, bottomBtnStyle]} pointerEvents="box-none">
<TouchableOpacity
onPress={jumpToBottom}
accessibilityLabel="Jump to bottom"
accessibilityLabel={t('nav.scrollToBottom')}
style={styles.jumpBtnInner}
>
<Text style={styles.jumpBtnText}>{t('nav.bottom')}</Text>
Expand Down
Binary file added docs/screenshots/top-fab-overlap-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/top-fab-overlap-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 31 additions & 0 deletions e2e/07_conversation_scroll_gaps.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions locales/ar/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"nav": {
"top": "↑ الأعلى",
"bottom": "↓ الأسفل",
"scrollToTop": "التمرير للأعلى",
"scrollToBottom": "التمرير للأسفل",
"copyAll": "نسخ الكل"
},
"biometricLock": {
Expand Down
2 changes: 2 additions & 0 deletions locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"nav": {
"top": "↑ Top",
"bottom": "↓ Bottom",
"scrollToTop": "Scroll to top",
"scrollToBottom": "Scroll to bottom",
"copyAll": "Copy all"
},
"biometricLock": {
Expand Down
2 changes: 2 additions & 0 deletions locales/he/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"nav": {
"top": "↑ למעלה",
"bottom": "↓ למטה",
"scrollToTop": "גלול למעלה",
"scrollToBottom": "גלול למטה",
"copyAll": "העתק הכל"
},
"biometricLock": {
Expand Down
2 changes: 2 additions & 0 deletions locales/ru/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"nav": {
"top": "↑ Вверх",
"bottom": "↓ Вниз",
"scrollToTop": "Прокрутить вверх",
"scrollToBottom": "Прокрутить вниз",
"copyAll": "Копировать всё"
},
"biometricLock": {
Expand Down
Loading