diff --git a/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx b/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx
index c697a2c4..158a0f76 100644
--- a/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx
+++ b/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx
@@ -56,8 +56,20 @@ export function VoiceCallScreen({
onTogglePause,
}: VoiceCallScreenProps) {
const insets = useSafeAreaInsets();
- const { fitsViewport, onContentSizeChange, onLayout, onScroll, transcriptRef } =
- usePinnedTranscriptScroll();
+ const {
+ fitsViewport,
+ hasUnseenLatest,
+ jumpToLatest,
+ onContentSizeChange,
+ onLayout,
+ onMomentumScrollBegin,
+ onMomentumScrollEnd,
+ onScroll,
+ onScrollBeginDrag,
+ onScrollEndDrag,
+ transcriptRef,
+ } = usePinnedTranscriptScroll();
+ const latestText = messages[messages.length - 1]?.text;
const [scale] = useState(() => new Animated.Value(1));
useEffect(() => {
@@ -104,64 +116,86 @@ export function VoiceCallScreen({
-
- {messages.map((message, index) => (
-
+
+
+ {messages.map((message, index) => (
-
- {message.text}
-
- {message.pending ? (
-
- ) : null}
+
+ {message.text}
+
+ {message.pending ? (
+
+ ) : null}
+
-
- ))}
-
+ ))}
+
+ {hasUnseenLatest ? (
+ [styles.latestChip, pressed && styles.buttonPressed]}
+ testID="voice-call-latest"
+ >
+ 查看最新
+ {latestText ? (
+
+ {latestText}
+
+ ) : null}
+
+ ) : null}
+
(null);
- const pinnedRef = useRef(true);
+ const interactingRef = useRef(false);
+ const followingRef = useRef(true);
+ const ignoreProgrammaticScrollRef = useRef(false);
+ const idleTimerRef = useRef | null>(null);
+ const ignoreTimerRef = useRef | null>(null);
const viewportHeightRef = useRef(0);
const contentHeightRef = useRef(0);
const [fitsViewport, setFitsViewport] = useState(true);
+ const [hasUnseenLatest, setHasUnseenLatest] = useState(false);
const syncFits = () => {
const fits = contentFitsViewport(contentHeightRef.current, viewportHeightRef.current);
setFitsViewport((current) => (current === fits ? current : fits));
};
+ const clearIdleTimer = () => {
+ if (idleTimerRef.current == null) {
+ return;
+ }
+ clearTimeout(idleTimerRef.current);
+ idleTimerRef.current = null;
+ };
+
+ const clearIgnoreTimer = () => {
+ if (ignoreTimerRef.current === null) {
+ return;
+ }
+ clearTimeout(ignoreTimerRef.current);
+ ignoreTimerRef.current = null;
+ };
+
+ const setFollowing = (next: boolean) => {
+ followingRef.current = next;
+ if (next) {
+ setHasUnseenLatest(false);
+ }
+ };
+
+ const followLatest = () => {
+ if (interactingRef.current) {
+ return;
+ }
+ setFollowing(true);
+ ignoreProgrammaticScrollRef.current = true;
+ transcriptRef.current?.scrollToEnd({ animated: true });
+ clearIgnoreTimer();
+ ignoreTimerRef.current = setTimeout(() => {
+ ignoreProgrammaticScrollRef.current = false;
+ ignoreTimerRef.current = null;
+ }, TRANSCRIPT_IDLE_MS);
+ };
+
+ const markInteracting = () => {
+ interactingRef.current = true;
+ clearIdleTimer();
+ };
+
+ const markIdle = () => {
+ interactingRef.current = false;
+ if (followingRef.current) {
+ followLatest();
+ }
+ };
+
+ useEffect(() => {
+ return () => {
+ clearIdleTimer();
+ clearIgnoreTimer();
+ };
+ }, []);
+
const onLayout = (event: LayoutChangeEvent) => {
viewportHeightRef.current = event.nativeEvent.layout.height;
syncFits();
};
+ const onScrollBeginDrag = () => {
+ markInteracting();
+ };
+
+ const onScrollEndDrag = () => {
+ clearIdleTimer();
+ idleTimerRef.current = setTimeout(markIdle, TRANSCRIPT_IDLE_MS);
+ };
+
+ const onMomentumScrollBegin = () => {
+ markInteracting();
+ };
+
+ const onMomentumScrollEnd = () => {
+ clearIdleTimer();
+ markIdle();
+ };
+
const onScroll = (event: NativeSyntheticEvent) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
viewportHeightRef.current = layoutMeasurement.height;
contentHeightRef.current = contentSize.height;
- pinnedRef.current = isPinnedToBottom({
+ const atBottom = isPinnedToBottom({
contentHeight: contentSize.height,
offsetY: contentOffset.y,
viewportHeight: layoutMeasurement.height,
});
+ if (ignoreProgrammaticScrollRef.current) {
+ return;
+ }
+ setFollowing(atBottom);
};
const onContentSizeChange = (_width: number, height: number) => {
contentHeightRef.current = height;
syncFits();
- if (pinnedRef.current) {
- transcriptRef.current?.scrollToEnd({ animated: true });
+ if (interactingRef.current || !followingRef.current) {
+ if (!followingRef.current) {
+ setHasUnseenLatest(true);
+ }
+ return;
}
+ followLatest();
+ };
+
+ const jumpToLatest = () => {
+ interactingRef.current = false;
+ followLatest();
};
return {
fitsViewport,
+ hasUnseenLatest,
+ jumpToLatest,
onContentSizeChange,
onLayout,
+ onMomentumScrollBegin,
+ onMomentumScrollEnd,
onScroll,
+ onScrollBeginDrag,
+ onScrollEndDrag,
transcriptRef,
};
}
diff --git a/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx b/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx
index 83cd5d0a..ed3c6405 100644
--- a/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx
+++ b/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, jest } from '@jest/globals';
-import { act, fireEvent, render, screen } from '@testing-library/react-native';
+import { act, fireEvent, render, screen, within } from '@testing-library/react-native';
import { Platform, StyleSheet } from 'react-native';
import { RadialGradient } from 'react-native-svg';
@@ -252,6 +252,48 @@ describe('VoiceCallScreen', () => {
},
});
expect(screen.getByLabelText('你:明天下午三点开会')).toBeTruthy();
+ expect(screen.queryByLabelText('查看最新')).toBeNull();
+ } finally {
+ Platform.OS = original;
+ }
+ },
+ );
+
+ it.each(['ios', 'android'] as const)(
+ 'shows a jump-to-latest chip on %s after scrolling away from new turns',
+ (os) => {
+ const original = Platform.OS;
+ Platform.OS = os;
+ try {
+ renderScreen({
+ messages: [
+ { id: 'u1', role: 'user', text: '明天下午三点开会' },
+ { id: 'a1', role: 'assistant', text: '好,已经记下了' },
+ { id: 'u2', role: 'user', text: '改到四点' },
+ { id: 'a2', role: 'assistant', text: '已改到四点' },
+ ],
+ status: 'paused',
+ title: '已暂停,点击圆圈继续',
+ });
+ const transcript = screen.getByTestId('voice-call-transcript');
+ fireEvent(transcript, 'layout', {
+ nativeEvent: { layout: { height: 400, width: 390, x: 0, y: 0 } },
+ });
+ fireEvent(transcript, 'scrollBeginDrag');
+ fireEvent.scroll(transcript, {
+ nativeEvent: {
+ contentOffset: { x: 0, y: 0 },
+ contentSize: { height: 2000, width: 390 },
+ layoutMeasurement: { height: 400, width: 390 },
+ },
+ });
+ fireEvent(transcript, 'contentSizeChange', 390, 2300);
+ expect(screen.getByLabelText('查看最新')).toBeTruthy();
+ expect(
+ within(screen.getByTestId('voice-call-latest')).getByText('已改到四点'),
+ ).toBeTruthy();
+ fireEvent.press(screen.getByLabelText('查看最新'));
+ expect(screen.queryByLabelText('查看最新')).toBeNull();
} finally {
Platform.OS = original;
}
diff --git a/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts b/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts
index d9a2686c..696850ff 100644
--- a/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts
+++ b/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts
@@ -1,12 +1,23 @@
-import { describe, expect, it, jest } from '@jest/globals';
+import { afterEach, describe, expect, it, jest } from '@jest/globals';
import { act, renderHook } from '@testing-library/react-native';
-import type { LayoutChangeEvent, ScrollView } from 'react-native';
+import type {
+ LayoutChangeEvent,
+ NativeScrollEvent,
+ NativeSyntheticEvent,
+ ScrollView,
+} from 'react-native';
+import { Platform } from 'react-native';
import {
+ PINNED_TO_BOTTOM_THRESHOLD,
+ TRANSCRIPT_IDLE_MS,
contentFitsViewport,
+ isPinnedToBottom,
usePinnedTranscriptScroll,
} from '../../../../../src/features/assistant/presentation/usePinnedTranscriptScroll';
+const originalOs = Platform.OS;
+
function layoutEvent(height: number): LayoutChangeEvent {
return {
nativeEvent: {
@@ -15,6 +26,24 @@ function layoutEvent(height: number): LayoutChangeEvent {
} as LayoutChangeEvent;
}
+function scrollEvent({
+ contentHeight,
+ offsetY,
+ viewportHeight,
+}: {
+ contentHeight: number;
+ offsetY: number;
+ viewportHeight: number;
+}): NativeSyntheticEvent {
+ return {
+ nativeEvent: {
+ contentOffset: { x: 0, y: offsetY },
+ contentSize: { height: contentHeight, width: 390 },
+ layoutMeasurement: { height: viewportHeight, width: 390 },
+ },
+ } as NativeSyntheticEvent;
+}
+
describe('contentFitsViewport', () => {
it('treats an unmeasured viewport as fitting', () => {
expect(contentFitsViewport(800, 0)).toBe(true);
@@ -28,8 +57,42 @@ describe('contentFitsViewport', () => {
});
});
+describe('isPinnedToBottom', () => {
+ it('pins short content and the true bottom', () => {
+ expect(isPinnedToBottom({ contentHeight: 200, offsetY: 0, viewportHeight: 400 })).toBe(true);
+ expect(isPinnedToBottom({ contentHeight: 2000, offsetY: 1600, viewportHeight: 400 })).toBe(
+ true,
+ );
+ });
+
+ it('unpins once the user scrolls past the threshold', () => {
+ const viewportHeight = 400;
+ const contentHeight = 2000;
+ const bottomOffset = contentHeight - viewportHeight;
+ expect(
+ isPinnedToBottom({
+ contentHeight,
+ offsetY: bottomOffset - PINNED_TO_BOTTOM_THRESHOLD,
+ viewportHeight,
+ }),
+ ).toBe(true);
+ expect(
+ isPinnedToBottom({
+ contentHeight,
+ offsetY: bottomOffset - PINNED_TO_BOTTOM_THRESHOLD - 1,
+ viewportHeight,
+ }),
+ ).toBe(false);
+ });
+});
+
describe('usePinnedTranscriptScroll', () => {
- it('keeps short content pinned to the dock and does not overflow', () => {
+ afterEach(() => {
+ Platform.OS = originalOs;
+ jest.useRealTimers();
+ });
+
+ it('follows the latest turn when the user is not scrolling', () => {
const { result } = renderHook(() => usePinnedTranscriptScroll());
const scrollToEnd = jest.fn();
result.current.transcriptRef.current = { scrollToEnd } as unknown as ScrollView;
@@ -40,13 +103,12 @@ describe('usePinnedTranscriptScroll', () => {
});
expect(result.current.fitsViewport).toBe(true);
+ expect(result.current.hasUnseenLatest).toBe(false);
expect(scrollToEnd).toHaveBeenCalledWith({ animated: true });
});
it('lets a tall transcript overflow so the user can scroll up', () => {
const { result } = renderHook(() => usePinnedTranscriptScroll());
- const scrollToEnd = jest.fn();
- result.current.transcriptRef.current = { scrollToEnd } as unknown as ScrollView;
act(() => {
result.current.onLayout(layoutEvent(400));
@@ -54,6 +116,136 @@ describe('usePinnedTranscriptScroll', () => {
});
expect(result.current.fitsViewport).toBe(false);
+ });
+
+ it.each(['ios', 'android'] as const)(
+ 'does not follow the latest turn on %s while the user is dragging',
+ (os) => {
+ Platform.OS = os;
+ const { result } = renderHook(() => usePinnedTranscriptScroll());
+ const scrollToEnd = jest.fn();
+ result.current.transcriptRef.current = { scrollToEnd } as unknown as ScrollView;
+
+ act(() => {
+ result.current.onScrollBeginDrag();
+ result.current.onMomentumScrollBegin();
+ result.current.onContentSizeChange(390, 2200);
+ });
+
+ expect(scrollToEnd).not.toHaveBeenCalled();
+ expect(result.current.hasUnseenLatest).toBe(false);
+ },
+ );
+
+ it.each(['ios', 'android'] as const)(
+ 'follows the latest turn on %s after the user stops at the bottom',
+ (os) => {
+ Platform.OS = os;
+ jest.useFakeTimers();
+ const { result } = renderHook(() => usePinnedTranscriptScroll());
+ const scrollToEnd = jest.fn();
+ result.current.transcriptRef.current = { scrollToEnd } as unknown as ScrollView;
+
+ act(() => {
+ result.current.onScrollBeginDrag();
+ result.current.onContentSizeChange(390, 2000);
+ });
+ expect(scrollToEnd).not.toHaveBeenCalled();
+
+ act(() => {
+ result.current.onMomentumScrollEnd();
+ });
+ expect(scrollToEnd).toHaveBeenCalledWith({ animated: true });
+ },
+ );
+
+ it('keeps earlier turns in view and flags unseen latest after scrolling up', () => {
+ Platform.OS = 'ios';
+ const { result } = renderHook(() => usePinnedTranscriptScroll());
+ const scrollToEnd = jest.fn();
+ result.current.transcriptRef.current = { scrollToEnd } as unknown as ScrollView;
+
+ act(() => {
+ result.current.onScrollBeginDrag();
+ result.current.onScroll(
+ scrollEvent({ contentHeight: 2000, offsetY: 0, viewportHeight: 400 }),
+ );
+ result.current.onContentSizeChange(390, 2200);
+ });
+
+ expect(scrollToEnd).not.toHaveBeenCalled();
+ expect(result.current.hasUnseenLatest).toBe(true);
+
+ act(() => {
+ result.current.jumpToLatest();
+ });
expect(scrollToEnd).toHaveBeenCalledWith({ animated: true });
+ expect(result.current.hasUnseenLatest).toBe(false);
+ });
+
+ it('clears unseen latest after the user scrolls back to the bottom', () => {
+ Platform.OS = 'ios';
+ const { result } = renderHook(() => usePinnedTranscriptScroll());
+ const scrollToEnd = jest.fn();
+ result.current.transcriptRef.current = { scrollToEnd } as unknown as ScrollView;
+
+ act(() => {
+ result.current.onScroll(
+ scrollEvent({ contentHeight: 2000, offsetY: 0, viewportHeight: 400 }),
+ );
+ result.current.onContentSizeChange(390, 2200);
+ });
+ expect(result.current.hasUnseenLatest).toBe(true);
+
+ act(() => {
+ result.current.onScroll(
+ scrollEvent({ contentHeight: 2200, offsetY: 1800, viewportHeight: 400 }),
+ );
+ });
+ expect(result.current.hasUnseenLatest).toBe(false);
+ expect(scrollToEnd).not.toHaveBeenCalled();
+ });
+
+ it('uses the drag-end idle timer when momentum does not follow', () => {
+ Platform.OS = 'ios';
+ jest.useFakeTimers();
+ const { result } = renderHook(() => usePinnedTranscriptScroll());
+ const scrollToEnd = jest.fn();
+ result.current.transcriptRef.current = { scrollToEnd } as unknown as ScrollView;
+
+ act(() => {
+ result.current.onScrollBeginDrag();
+ result.current.onScrollEndDrag();
+ result.current.onContentSizeChange(390, 2100);
+ });
+ expect(scrollToEnd).not.toHaveBeenCalled();
+
+ act(() => {
+ jest.advanceTimersByTime(TRANSCRIPT_IDLE_MS);
+ });
+ expect(scrollToEnd).toHaveBeenCalledWith({ animated: true });
+ });
+
+ it('ignores programmatic scroll so follow-latest does not unpin itself', () => {
+ Platform.OS = 'ios';
+ jest.useFakeTimers();
+ const { result } = renderHook(() => usePinnedTranscriptScroll());
+ const scrollToEnd = jest.fn();
+ result.current.transcriptRef.current = { scrollToEnd } as unknown as ScrollView;
+
+ act(() => {
+ result.current.onLayout(layoutEvent(400));
+ result.current.onContentSizeChange(390, 2000);
+ });
+ expect(scrollToEnd).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ result.current.onScroll(
+ scrollEvent({ contentHeight: 2000, offsetY: 0, viewportHeight: 400 }),
+ );
+ result.current.onContentSizeChange(390, 2100);
+ });
+ expect(scrollToEnd).toHaveBeenCalledTimes(2);
+ expect(result.current.hasUnseenLatest).toBe(false);
});
});