From f224033191998f83120b736f07970ed95e98888e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E6=9D=A1=E5=9B=BA=E6=89=A7=E7=9A=84=E9=B1=BC?= <1504947133@qq.com> Date: Thu, 20 Aug 2026 11:49:03 +0800 Subject: [PATCH 1/5] feat(assistant): refresh the continuous-call UI around live bubbles Replace the status-list hands-free screen with a GPT Voice-style layout: chat bubbles above a voiceprint orb, and keep the current call readable while scrolling up. --- .../AssistantContinuousConversationService.ts | 68 ++- .../AssistantConversationService.ts | 11 +- .../interfaces/AssistantApplicationPort.ts | 7 +- .../assistant/domain/ConversationTurn.ts | 12 +- .../presentation/AssistantVoiceOverlay.tsx | 19 +- .../presentation/VoiceCallScreen.tsx | 500 +++++++++++++----- .../presentation/useAssistantConversation.ts | 16 +- .../presentation/usePinnedTranscriptScroll.ts | 76 +++ ...stantContinuousConversationService.test.ts | 47 +- .../AssistantVoiceOverlay.test.tsx | 9 +- .../presentation/VoiceCallScreen.test.tsx | 253 ++++++++- .../useAssistantConversation.test.tsx | 34 +- .../usePinnedTranscriptScroll.test.ts | 154 ++++++ .../tests/unit/screens/HomeScreen.test.tsx | 1 + 14 files changed, 982 insertions(+), 225 deletions(-) create mode 100644 frontend/src/features/assistant/presentation/usePinnedTranscriptScroll.ts create mode 100644 frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts diff --git a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts index 09ea00fb..8ca92cbc 100644 --- a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts +++ b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts @@ -3,8 +3,8 @@ import type { ScheduleCategory } from '../../../contracts/schedule'; import type { AppLifecycleStatus } from '../../../infrastructure/appState/AppStateProvider'; import type { AppliedCommand, - ConversationTurnRecord, ConversationTurnState, + VoiceChatMessage, } from '../domain/ConversationTurn'; import type { @@ -64,9 +64,8 @@ export class AssistantContinuousConversationService implements AssistantApplicat private unsubscribeConnection: (() => void) | null = null; private replyText: string | null = null; private soundLevel: number | null = null; - /** 本次开麦以来的问答历史,追加不覆盖;startTurn() 清空,跟 replyText 各管各的 - * ——replyText 是当前这一轮的气泡内容,turns 是完整历史。 */ - private turns: ConversationTurnRecord[] = []; + /** 本通免提电话里已经落屏的用户/助手对白,挂断后下一通 startTurn 会清空。 */ + private messages: VoiceChatMessage[] = []; /** endTurn() 主动关闭连接期间为 true,让 handleClose 认出这是预期内的挂断。 */ private endingCall = false; private idleTimer: ReturnType | null = null; @@ -122,12 +121,12 @@ export class AssistantContinuousConversationService implements AssistantApplicat return this.replyText; } - getSoundLevel(): number | null { - return this.soundLevel; + getMessages(): readonly VoiceChatMessage[] { + return this.messages; } - getTurns(): readonly ConversationTurnRecord[] { - return this.turns; + getSoundLevel(): number | null { + return this.soundLevel; } /** 打开连续会话:建连、开一次流、开始持续推流麦克风。 */ @@ -139,7 +138,7 @@ export class AssistantContinuousConversationService implements AssistantApplicat try { this.replyText = null; this.soundLevel = null; - this.turns = []; + this.messages = []; // 上一通电话可能是在暂停期间被空闲超时兜底挂断的,muted 只在用户手动 // togglePause() 里恢复;不在这里清一次,新开的电话会继承上一通的静音, // UI 显示 listening 但麦克风帧全被吞掉。 @@ -353,16 +352,8 @@ export class AssistantContinuousConversationService implements AssistantApplicat return; case 'voice.asr.completed': // 听到一句真实语音:这是空闲计时器真正要等的信号,重新给一个完整窗口。 + this.appendUserTranscript(message.payload.transcript); this.armIdleTimer(); - this.turns = [ - ...this.turns, - { - id: message.request_id ?? `turn-${this.turns.length}`, - replyText: null, - transcript: message.payload.transcript, - }, - ]; - this.notifyListeners(); return; case 'voice.command.result': { const command: AppliedCommand = { @@ -382,9 +373,7 @@ export class AssistantContinuousConversationService implements AssistantApplicat void this.applyCategoryUpdate(message.payload.schedule_id, message.payload.category); return; case 'voice.dialogue.question': - // 缺字段/地点歧义之类的追问,对当前这轮来说就是系统的回复——记进历史, - // 不然标题过了这一阵子就变回通用文案,这句追问在记录里再也找不到。 - this.updateLastTurnReply(message.payload.speech_text); + this.upsertAssistantTranscript(message.payload.question_id, message.payload.speech_text); this.setState({ conversationId: message.conversation_id, phase: 'asking', @@ -393,7 +382,11 @@ export class AssistantContinuousConversationService implements AssistantApplicat return; case 'voice.dialogue.reply': this.replyText = message.payload.speech_text; - this.updateLastTurnReply(message.payload.speech_text); + this.upsertAssistantTranscript( + message.payload.reply_id, + message.payload.speech_text, + !message.payload.done, + ); this.notifyListeners(); return; case 'voice.tts.start': @@ -542,15 +535,32 @@ export class AssistantContinuousConversationService implements AssistantApplicat return this.connection; } - /** speech_text 是累计到目前为止的完整文字(不是增量),直接覆盖最后一轮即可。 - * 没有轮次可更新时(理论上不会发生,reply 总跟在 asr.completed 后面)不做 - * 任何事,不新建一条没有 transcript 的记录。 */ - private updateLastTurnReply(replyText: string): void { - if (this.turns.length === 0) { + private appendUserTranscript(transcript: string): void { + const text = transcript.trim(); + if (text.length === 0) { + return; + } + this.messages = [ + ...this.messages, + { id: `user-${this.messages.length + 1}`, role: 'user', text }, + ]; + this.notifyListeners(); + } + + private upsertAssistantTranscript(id: string, speechText: string, pending = false): void { + const text = speechText.trim(); + if (text.length === 0) { + return; + } + const next: VoiceChatMessage = pending + ? { id, role: 'assistant', text, pending: true } + : { id, role: 'assistant', text }; + const existing = this.messages.findIndex((message) => message.id === id); + if (existing >= 0) { + this.messages = this.messages.map((message, index) => (index === existing ? next : message)); return; } - const last = this.turns[this.turns.length - 1]; - this.turns = [...this.turns.slice(0, -1), { ...last, replyText }]; + this.messages = [...this.messages, next]; } private armIdleTimer(): void { diff --git a/frontend/src/features/assistant/application/AssistantConversationService.ts b/frontend/src/features/assistant/application/AssistantConversationService.ts index 3f27ac60..f83e0a2e 100644 --- a/frontend/src/features/assistant/application/AssistantConversationService.ts +++ b/frontend/src/features/assistant/application/AssistantConversationService.ts @@ -1,6 +1,10 @@ import { isTransportError, type AssistantServerMessage } from '../../../contracts/conversation'; import type { ScheduleCategory } from '../../../contracts/schedule'; -import type { AppliedCommand, ConversationTurnState } from '../domain/ConversationTurn'; +import type { + AppliedCommand, + ConversationTurnState, + VoiceChatMessage, +} from '../domain/ConversationTurn'; import type { AssistantApplicationDependencies, @@ -12,6 +16,7 @@ import type { VoiceTransportConnection } from './interfaces/VoiceTransportPort'; const AUDIO_FORMAT = 'pcm_s16le'; const SAMPLE_RATE_HZ = 16000; const CHANNELS = 1; +const EMPTY_MESSAGES: readonly VoiceChatMessage[] = []; // 共享连接的握手超时(AuthenticatedWebSocketClient 内部固定 5s)已经不归这里管; // 这个只是给定位单独留的预算,拿不到就不带,不能让 connect() 本身被定位拖住。 const LOCATION_TIMEOUT_MS = 2000; @@ -70,6 +75,10 @@ export class AssistantConversationService implements AssistantApplicationPort { return this.replyText; } + getMessages(): readonly VoiceChatMessage[] { + return EMPTY_MESSAGES; + } + getSoundLevel(): number | null { return this.soundLevel; } diff --git a/frontend/src/features/assistant/application/interfaces/AssistantApplicationPort.ts b/frontend/src/features/assistant/application/interfaces/AssistantApplicationPort.ts index 9ea22e44..28f48cfc 100644 --- a/frontend/src/features/assistant/application/interfaces/AssistantApplicationPort.ts +++ b/frontend/src/features/assistant/application/interfaces/AssistantApplicationPort.ts @@ -1,7 +1,7 @@ import type { AppliedCommand, - ConversationTurnRecord, ConversationTurnState, + VoiceChatMessage, } from '../../domain/ConversationTurn'; export type AssistantApplicationDependencies = { @@ -33,9 +33,8 @@ export interface AssistantApplicationPort { getReplyText(): string | null; /** 当前这一帧麦克风音量(dBFS),给录音中的波形展示;不在录音时是 null。 */ getSoundLevel(): number | null; - /** 连续模式独有:本次开麦以来的完整问答历史,新一轮追加、断开重开时清空。 - * 按住说话不实现这个方法(本来就是单轮)。 */ - getTurns?(): readonly ConversationTurnRecord[]; + /** 免提长对话的用户/助手气泡;按住说话没有多轮记录,返回空数组。 */ + getMessages(): readonly VoiceChatMessage[]; startTurn(): Promise; endTurn(): Promise; /** 用户主动关掉回复气泡:清空气泡内容并打断正在播放的 TTS。 */ diff --git a/frontend/src/features/assistant/domain/ConversationTurn.ts b/frontend/src/features/assistant/domain/ConversationTurn.ts index ab7b82f0..1419d0d6 100644 --- a/frontend/src/features/assistant/domain/ConversationTurn.ts +++ b/frontend/src/features/assistant/domain/ConversationTurn.ts @@ -26,11 +26,13 @@ export interface AppliedOccurrenceOverride { replacement_schedule_id: string | null; } -/** 连续对话历史里的一轮:一次用户发言 + 对应的系统回复(可能还没到)。 */ -export interface ConversationTurnRecord { - readonly id: string; - readonly transcript: string; - readonly replyText: string | null; +/** 免提长对话里一条已落屏的对白:用户来自 ASR,助手来自回复/追问。 */ +export interface VoiceChatMessage { + id: string; + role: 'user' | 'assistant'; + text: string; + /** 流式还没收完时为 true,通话页用来画正在说的波形。 */ + pending?: boolean; } export interface AppliedCommand { diff --git a/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx b/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx index 6ac2ac02..15dab2bb 100644 --- a/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx +++ b/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx @@ -51,22 +51,20 @@ const PTT_BUSY_PHASES: ReadonlySet = new Set([ 'awaiting_result', ]); -// 只返回通用状态文案,不带具体说了什么/回复了什么——那些内容都在 -// VoiceCallScreen 的聊天记录区里,标题只负责报状态。 -function titleFor(state: ConversationTurnState): string { - if (state.phase === 'asking') return state.speechText; +function statusLabelFor(state: ConversationTurnState): string { if (state.phase === 'error') return state.message; switch (state.phase) { case 'connecting': return '连接中…'; case 'listening': - return '聆听中…'; + return '正在听'; case 'interrupted': return '已打断'; case 'speaking': - return '回答中…'; + case 'asking': + return '正在回复'; case 'paused': - return '已暂停,点一下继续'; + return '已暂停,点击圆圈继续'; default: return ''; } @@ -74,7 +72,7 @@ function titleFor(state: ConversationTurnState): string { /** * 叠在日历屏上的语音入口:底部一条长条状控件,左边一个圆形电话按钮进入免提 - * 通话(沉浸式全屏层,仿豆包语音模式),右边一条长按说话的语音条——两条编排 + * 通话(沉浸式全屏层,对白与底部声纹球的布局参考 GPT Voice),右边一条长按说话的语音条——两条编排 * 路径(AssistantConversationService/AssistantContinuousConversationService) * 各自独立连接(各自绑定不同 voiceMode 的 AuthenticatedVoiceTransport 实例, * 共用同一个 AuthenticatedWebSocketClient),不共享一个 application 实例, @@ -110,12 +108,13 @@ export function AssistantVoiceOverlay({ if (expanded) { return ( setExpanded(false)} onEnd={() => void call.endTurn()} onTogglePause={() => call.togglePause()} + soundLevel={call.soundLevel} status={callStatusFor(call.state.phase)} - title={titleFor(call.state)} - turns={call.turns} + title={statusLabelFor(call.state)} /> ); } diff --git a/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx b/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx index 7a601fc1..c697a2c4 100644 --- a/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx +++ b/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Animated, Easing, @@ -8,18 +8,24 @@ import { StyleSheet, Text, View, + type StyleProp, + type ViewStyle, } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Svg, { Defs, Path, RadialGradient, Rect, Stop } from 'react-native-svg'; import { colors, spacing } from '../../../shared/ui/theme'; -import type { ConversationTurnRecord } from '../domain/ConversationTurn'; +import type { VoiceChatMessage } from '../domain/ConversationTurn'; import type { CallStatus } from './AssistantVoiceOverlay'; import { PhoneCallIcon } from './PhoneCallIcon'; +import { usePinnedTranscriptScroll } from './usePinnedTranscriptScroll'; interface VoiceCallScreenProps { status: CallStatus; title: string; - turns?: readonly ConversationTurnRecord[]; + messages: readonly VoiceChatMessage[]; + soundLevel?: number | null; onCollapse: () => void; onEnd: () => void; onTogglePause: () => void; @@ -27,25 +33,32 @@ interface VoiceCallScreenProps { const BREATH_SCALE = { duration: 1600, from: 1, to: 1.06 }; const TALK_SCALE = { duration: 650, from: 1, to: 1.14 }; +const VOICEPRINT_BAR_COUNT = 9; +const VOICEPRINT_TICK_MS = 80; +const BUBBLE_VOICEPRINT_BARS = 5; +const CALL_BACKGROUND = '#0E241F'; +const CALL_GLOW = '#18443A'; +const CALL_VIGNETTE = '#071310'; +const END_BUTTON_SIZE = 72; /** - * 免提通话的沉浸式全屏层:主体是一份可回看的完整问答记录(每轮一条用户话 - * +对应回复),下方是一个跟着状态变化的小状态点+文字,只报通用状态(聆听中 - * /回答中/已打断……),具体说了什么、回复了什么都在上面的记录里,标题不 - * 重复展示。底部只保留“结束对话”(真正挂断)。左上角收起不挂断,回到底部长 - * 条状入口,连接和麦克风都还开着。状态点这一整行可点:点一下暂停/恢复麦克风 - * 推流,是“用户点击暂停”的唯一入口。 + * 免提通话的沉浸式全屏层,布局贴近 GPT Voice:对白从声纹球上方长出来, + * 球本身靠底部。左上角收起不挂断;点圆圈暂停/恢复麦克风;底部红色按钮才是 + * 真正挂断。 */ export function VoiceCallScreen({ status, title, - turns = [], + messages, + soundLevel = null, onCollapse, onEnd, onTogglePause, }: VoiceCallScreenProps) { + const insets = useSafeAreaInsets(); + const { fitsViewport, onContentSizeChange, onLayout, onScroll, transcriptRef } = + usePinnedTranscriptScroll(); const [scale] = useState(() => new Animated.Value(1)); - const historyRef = useRef(null); useEffect(() => { scale.stopAnimation(); @@ -76,186 +89,435 @@ export function VoiceCallScreen({ }, [status, scale]); return ( - + + [styles.collapseButton, pressed && styles.buttonPressed]} + style={({ pressed }) => [ + styles.collapseButton, + { top: Math.max(spacing.md, insets.top) }, + pressed && styles.buttonPressed, + ]} > - + historyRef.current?.scrollToEnd({ animated: true })} - style={styles.history} + onContentSizeChange={onContentSizeChange} + onLayout={onLayout} + onScroll={onScroll} + scrollEventThrottle={16} + showsVerticalScrollIndicator={false} + style={styles.transcript} + testID="voice-call-transcript" > - {turns.length > 0 ? ( - turns.map((turn) => ( - - {turn.transcript} - {turn.replyText !== null ? ( - {turn.replyText} + {messages.map((message, index) => ( + + + + {message.text} + + {message.pending ? ( + ) : null} - )) - ) : ( - 对话开始后,这里会显示完整记录 - )} + + ))} - + + {title ? {title} : null} - - {title} + + + + + {status === 'listening' || status === 'speaking' ? ( + + ) : null} + + - - - [ - styles.actionButton, - styles.endButton, - pressed && styles.buttonPressed, - ]} + style={({ pressed }) => [styles.endButton, pressed && styles.buttonPressed]} > - 结束对话 + + + + 结束对话 ); } +function CallBackdrop() { + return ( + + + + + + + + + + + ); +} + +function CollapseChevron({ color }: { color: string }) { + return ( + + + + ); +} + +function normalizeLevel(dbfs: number | null): number { + if (dbfs === null) { + return 0; + } + const clamped = Math.max(-50, Math.min(0, dbfs)); + return (clamped + 50) / 50; +} + +function equalizerSamples(count: number, energy: number, tick: number): number[] { + if (count === 0) { + return []; + } + if (energy <= 0) { + return Array.from({ length: count }, () => 0); + } + const center = (count - 1) / 2; + return Array.from({ length: count }, (_, index) => { + const dist = center === 0 ? 0 : Math.abs(index - center) / center; + const envelope = 1 - dist * 0.58; + const phase = tick * (0.85 + index * 0.41) + index * 1.63; + const wobble = 0.42 + 0.58 * Math.abs(Math.sin(phase)); + return energy * envelope * wobble; + }); +} + +/** + * 球里的均衡器声纹:柱子位置不动,高度跟麦克风音量上下跳。中间柱更敏感, + * 两边稍弱,各自相位不同,所以看起来是声纹在跳,而不是一条波形在横着挪。 + */ +function Voiceprint({ + active, + barCount, + barStyle, + containerStyle, + maxHeight, + minHeight, + soundLevel, + testID, + testIDPrefix, +}: { + active: boolean; + barCount: number; + barStyle: StyleProp; + containerStyle?: StyleProp; + maxHeight: number; + minHeight: number; + soundLevel: number | null; + testID: string; + testIDPrefix?: string; +}) { + const [tick, setTick] = useState(0); + + useEffect(() => { + if (!active) { + return undefined; + } + const timer = setInterval(() => { + setTick((current) => current + 1); + }, VOICEPRINT_TICK_MS); + return () => { + clearInterval(timer); + }; + }, [active]); + + const energy = active ? normalizeLevel(soundLevel) : 0; + const samples = equalizerSamples(barCount, energy, tick); + + return ( + + {samples.map((level, index) => ( + + ))} + + ); +} + const styles = StyleSheet.create({ - actionButton: { - alignItems: 'center', - backgroundColor: 'rgba(255,255,255,0.12)', - borderRadius: 999, - flex: 1, - paddingVertical: spacing.md, + bubble: { + borderRadius: 22, + maxWidth: '100%', + paddingHorizontal: 16, + paddingVertical: 12, }, - actionText: { + bubbleAssistant: { + backgroundColor: 'rgba(255,255,255,0.1)', + }, + bubbleText: { + fontSize: 16, + lineHeight: 24, + }, + bubbleTextAssistant: { color: colors.onPrimary, - fontSize: 15, - fontWeight: '600', }, - actions: { - flexDirection: 'row', - gap: spacing.md, - paddingBottom: spacing.xl, - paddingHorizontal: spacing.xl, + bubbleTextUser: { + color: colors.text, }, - body: { - alignItems: 'center', - flexShrink: 0, - paddingBottom: spacing.md, - paddingHorizontal: spacing.xl, - paddingTop: spacing.sm, + bubbleUser: { + backgroundColor: colors.accent, }, buttonPressed: { opacity: 0.8, }, + circle: { + alignItems: 'center', + backgroundColor: colors.focus, + borderRadius: 999, + height: 96, + justifyContent: 'center', + width: 96, + }, + circleBusy: { + backgroundColor: 'rgba(184,216,117,0.55)', + }, + circleInterrupted: { + backgroundColor: colors.error, + }, + circlePaused: { + backgroundColor: colors.mutedText, + }, collapseButton: { alignItems: 'center', height: 40, justifyContent: 'center', - left: spacing.lg, + left: spacing.md, position: 'absolute', - top: spacing.xl, width: 40, + zIndex: 2, + }, + dock: { + alignItems: 'center', + gap: spacing.md, + paddingHorizontal: spacing.xl, + paddingTop: spacing.sm, }, endButton: { + alignItems: 'center', + gap: spacing.xs, + marginTop: spacing.xxl, + }, + endIcon: { + alignItems: 'center', backgroundColor: colors.error, + borderRadius: 999, + height: END_BUTTON_SIZE, + justifyContent: 'center', + transform: [{ rotate: '135deg' }], + width: END_BUTTON_SIZE, }, endText: { - color: colors.onPrimary, - }, - history: { - flex: 1, - paddingTop: spacing.xl * 2, + color: 'rgba(255,255,255,0.78)', + fontSize: 14, + fontWeight: '600', }, - historyContent: { - gap: spacing.md, - paddingBottom: spacing.md, - paddingHorizontal: spacing.xl, + orbHit: { + alignItems: 'center', + justifyContent: 'center', }, - historyContentEmpty: { + orbWave: { alignItems: 'center', - flexGrow: 1, + flexDirection: 'row', + gap: 3, + height: 36, justifyContent: 'center', }, - historyEmptyText: { - color: 'rgba(255,255,255,0.4)', - fontSize: 13, - textAlign: 'center', + orbWaveBar: { + backgroundColor: colors.text, + borderRadius: 999, + width: 3.5, }, - historyReply: { - color: 'rgba(255,255,255,0.7)', - fontSize: 15, - marginTop: spacing.xs, + speakingWave: { + alignItems: 'center', + alignSelf: 'flex-start', + height: 12, + marginTop: 8, }, - historyTranscript: { - color: colors.onPrimary, - fontSize: 15, - fontWeight: '600', + speakingWaveBar: { + borderRadius: 999, + width: 2.5, }, - historyTurn: { - gap: 2, + speakingWaveBarAssistant: { + backgroundColor: 'rgba(255,255,255,0.78)', }, - screen: { + speakingWaveBarUser: { backgroundColor: colors.text, + }, + voiceprintBar: { + alignSelf: 'center', + borderRadius: 999, + }, + orbWell: { + alignItems: 'center', + height: 168, + justifyContent: 'center', + width: 168, + }, + ring: { + borderRadius: 999, + position: 'absolute', + }, + ringInner: { + backgroundColor: 'rgba(184,216,117,0.22)', + height: 128, + width: 128, + }, + ringOuter: { + backgroundColor: 'rgba(184,216,117,0.12)', + height: 168, + width: 168, + }, + screen: { + backgroundColor: CALL_BACKGROUND, bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, }, - statusDot: { - backgroundColor: colors.focus, - borderRadius: 999, - height: 8, - width: 8, + title: { + color: 'rgba(255,255,255,0.78)', + fontSize: 14, + fontWeight: '600', + letterSpacing: 0.4, + textAlign: 'center', }, - statusDotInterrupted: { - backgroundColor: colors.error, + transcript: { + flex: 1, + marginTop: 56, }, - statusDotPaused: { - backgroundColor: colors.mutedText, + transcriptContent: { + flexGrow: 1, + gap: spacing.md, + justifyContent: 'flex-end', + paddingBottom: spacing.md, + paddingHorizontal: spacing.lg, + paddingTop: spacing.sm, }, - statusDotSpeaking: { - backgroundColor: colors.onPrimary, + transcriptContentOverflow: { + flexGrow: 0, + justifyContent: 'flex-start', }, - statusPill: { - alignItems: 'center', - flexDirection: 'row', - gap: spacing.sm, + turn: { + gap: 6, + maxWidth: '86%', }, - title: { - color: 'rgba(255,255,255,0.6)', - fontSize: 12, - fontWeight: '600', - letterSpacing: 0.2, + turnAssistant: { + alignItems: 'flex-start', + alignSelf: 'flex-start', + }, + turnPast: { + opacity: 0.62, + }, + turnUser: { + alignItems: 'flex-end', + alignSelf: 'flex-end', }, }); diff --git a/frontend/src/features/assistant/presentation/useAssistantConversation.ts b/frontend/src/features/assistant/presentation/useAssistantConversation.ts index 9ad6f074..c91dbbfc 100644 --- a/frontend/src/features/assistant/presentation/useAssistantConversation.ts +++ b/frontend/src/features/assistant/presentation/useAssistantConversation.ts @@ -3,12 +3,10 @@ import { useEffect, useState } from 'react'; import type { AssistantApplicationPort } from '../application/AssistantApplication'; import type { AppliedCommand, - ConversationTurnRecord, ConversationTurnState, + VoiceChatMessage, } from '../domain/ConversationTurn'; -const NO_TURNS: readonly ConversationTurnRecord[] = []; - /** 订阅编排服务的状态,展示层不用直接持有 AssistantConversationService。 */ export function useAssistantConversation(application: AssistantApplicationPort) { const [trackedApplication, setTrackedApplication] = useState(application); @@ -17,10 +15,10 @@ export function useAssistantConversation(application: AssistantApplicationPort) application.getLastAppliedCommand(), ); const [replyText, setReplyText] = useState(() => application.getReplyText()); - const [soundLevel, setSoundLevel] = useState(() => application.getSoundLevel()); - const [turns, setTurns] = useState( - () => application.getTurns?.() ?? NO_TURNS, + const [messages, setMessages] = useState(() => + application.getMessages(), ); + const [soundLevel, setSoundLevel] = useState(() => application.getSoundLevel()); // application 实例切换(如重新登录)时,渲染期间同步一次而不是在 effect 里 // setState,避免多触发一轮 commit。 @@ -29,8 +27,8 @@ export function useAssistantConversation(application: AssistantApplicationPort) setState(application.getState()); setLastAppliedCommand(application.getLastAppliedCommand()); setReplyText(application.getReplyText()); + setMessages(application.getMessages()); setSoundLevel(application.getSoundLevel()); - setTurns(application.getTurns?.() ?? NO_TURNS); } useEffect(() => { @@ -38,8 +36,8 @@ export function useAssistantConversation(application: AssistantApplicationPort) setState(next); setLastAppliedCommand(application.getLastAppliedCommand()); setReplyText(application.getReplyText()); + setMessages(application.getMessages()); setSoundLevel(application.getSoundLevel()); - setTurns(application.getTurns?.() ?? NO_TURNS); }); }, [application]); @@ -47,11 +45,11 @@ export function useAssistantConversation(application: AssistantApplicationPort) dismissReply: () => application.dismissReply(), endTurn: () => application.endTurn(), lastAppliedCommand, + messages, replyText, soundLevel, startTurn: () => application.startTurn(), state, togglePause: () => application.togglePause?.(), - turns, }; } diff --git a/frontend/src/features/assistant/presentation/usePinnedTranscriptScroll.ts b/frontend/src/features/assistant/presentation/usePinnedTranscriptScroll.ts new file mode 100644 index 00000000..08f10594 --- /dev/null +++ b/frontend/src/features/assistant/presentation/usePinnedTranscriptScroll.ts @@ -0,0 +1,76 @@ +import { useRef, useState } from 'react'; +import type { + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView, +} from 'react-native'; + +export const PINNED_TO_BOTTOM_THRESHOLD = 80; + +export function contentFitsViewport(contentHeight: number, viewportHeight: number): boolean { + if (viewportHeight <= 0) { + return true; + } + return contentHeight <= viewportHeight + 1; +} + +export function isPinnedToBottom({ + contentHeight, + offsetY, + viewportHeight, + threshold = PINNED_TO_BOTTOM_THRESHOLD, +}: { + contentHeight: number; + offsetY: number; + viewportHeight: number; + threshold?: number; +}): boolean { + const distanceFromBottom = contentHeight - viewportHeight - offsetY; + return distanceFromBottom <= threshold; +} + +export function usePinnedTranscriptScroll() { + const transcriptRef = useRef(null); + const pinnedRef = useRef(true); + const viewportHeightRef = useRef(0); + const contentHeightRef = useRef(0); + const [fitsViewport, setFitsViewport] = useState(true); + + const syncFits = () => { + const fits = contentFitsViewport(contentHeightRef.current, viewportHeightRef.current); + setFitsViewport((current) => (current === fits ? current : fits)); + }; + + const onLayout = (event: LayoutChangeEvent) => { + viewportHeightRef.current = event.nativeEvent.layout.height; + syncFits(); + }; + + const onScroll = (event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + viewportHeightRef.current = layoutMeasurement.height; + contentHeightRef.current = contentSize.height; + pinnedRef.current = isPinnedToBottom({ + contentHeight: contentSize.height, + offsetY: contentOffset.y, + viewportHeight: layoutMeasurement.height, + }); + }; + + const onContentSizeChange = (_width: number, height: number) => { + contentHeightRef.current = height; + syncFits(); + if (pinnedRef.current) { + transcriptRef.current?.scrollToEnd({ animated: true }); + } + }; + + return { + fitsViewport, + onContentSizeChange, + onLayout, + onScroll, + transcriptRef, + }; +} diff --git a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts index b36f55af..7d92c6c8 100644 --- a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts +++ b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts @@ -248,9 +248,37 @@ describe('AssistantContinuousConversationService', () => { } as AssistantServerMessage); await flushAsync(); - expect(service.getTurns()).toEqual([ - { id: 'req_1', replyText: '明天下午三点', transcript: '明天几点开会' }, - { id: 'req_2', replyText: null, transcript: '谁参加' }, + expect(service.getMessages()).toEqual([ + { id: 'user-1', role: 'user', text: '明天几点开会' }, + { id: 'reply_1', role: 'assistant', text: '明天下午三点' }, + { id: 'user-3', role: 'user', text: '谁参加' }, + ]); + }); + + it('keeps a streaming assistant reply pending until it is done', async () => { + const fake = createFakeConnection(); + const deps = createDeps({ connection: fake.connection }); + const service = createService(deps); + + await startListening(fake, service); + fake.emitMessage({ + conversation_id: 'conv_001', + payload: { done: false, reply_id: 'reply_1', speech_text: '明天' }, + type: 'voice.dialogue.reply', + } as AssistantServerMessage); + await flushAsync(); + expect(service.getMessages()).toEqual([ + { id: 'reply_1', pending: true, role: 'assistant', text: '明天' }, + ]); + + fake.emitMessage({ + conversation_id: 'conv_001', + payload: { done: true, reply_id: 'reply_1', speech_text: '明天下午三点' }, + type: 'voice.dialogue.reply', + } as AssistantServerMessage); + await flushAsync(); + expect(service.getMessages()).toEqual([ + { id: 'reply_1', role: 'assistant', text: '明天下午三点' }, ]); }); @@ -284,14 +312,15 @@ describe('AssistantContinuousConversationService', () => { } as AssistantServerMessage); await flushAsync(); - expect(service.getTurns()).toEqual([ - { id: 'req_1', replyText: speechText, transcript: '帮我订会议室' }, + expect(service.getMessages()).toEqual([ + { id: 'user-1', role: 'user', text: '帮我订会议室' }, + { id: 'q_1', role: 'assistant', text: speechText }, ]); expect(service.getState()).toMatchObject({ phase: 'asking' }); }, ); - it('does not create a history turn when a reply arrives before any transcript', async () => { + it('records an assistant bubble even when a reply arrives before any transcript', async () => { const fake = createFakeConnection(); const deps = createDeps({ connection: fake.connection }); const service = createService(deps); @@ -305,7 +334,7 @@ describe('AssistantContinuousConversationService', () => { await flushAsync(); expect(service.getReplyText()).toBe('好的'); - expect(service.getTurns()).toEqual([]); + expect(service.getMessages()).toEqual([{ id: 'reply_1', role: 'assistant', text: '好的' }]); }); it('clears turn history when a new call starts', async () => { @@ -321,12 +350,12 @@ describe('AssistantContinuousConversationService', () => { type: 'voice.asr.completed', } as AssistantServerMessage); await flushAsync(); - expect(service.getTurns()).toHaveLength(1); + expect(service.getMessages()).toHaveLength(1); await service.endTurn(); await startListening(fake, service); - expect(service.getTurns()).toHaveLength(0); + expect(service.getMessages()).toHaveLength(0); }); it('resets the idle timer after a reply finishes playing', async () => { diff --git a/frontend/tests/unit/features/assistant/presentation/AssistantVoiceOverlay.test.tsx b/frontend/tests/unit/features/assistant/presentation/AssistantVoiceOverlay.test.tsx index 41f61c80..b131b956 100644 --- a/frontend/tests/unit/features/assistant/presentation/AssistantVoiceOverlay.test.tsx +++ b/frontend/tests/unit/features/assistant/presentation/AssistantVoiceOverlay.test.tsx @@ -16,7 +16,7 @@ jest.mock('../../../../../src/features/assistant/presentation/useAssistantConver startTurn: application.startTurn, state: application === mockPttApplication ? { phase: 'idle' as const } : mockCallState, togglePause: () => {}, - turns: [], + messages: [], }), })); jest.mock('react-native-safe-area-context', () => ({ @@ -35,6 +35,7 @@ function createApplication(): AssistantApplicationPort { endTurn: async () => {}, getLastAppliedCommand: () => null, getReplyText: () => null, + getMessages: () => [], getSoundLevel: () => null, getState: () => ({ phase: 'idle' }), startTurn: async () => {}, @@ -113,7 +114,7 @@ describe('AssistantVoiceOverlay layout', () => { fireEvent.press(screen.getByLabelText('进入免提通话')); - expect(screen.getByText('回答中…')).toBeTruthy(); + expect(screen.getByText('正在回复')).toBeTruthy(); }); it('shows a generic "已打断" label when the reply is interrupted', () => { @@ -145,6 +146,8 @@ describe('AssistantVoiceOverlay layout', () => { fireEvent.press(screen.getByLabelText('进入免提通话')); - expect(screen.getByText('已暂停,点一下继续')).toBeTruthy(); + expect(screen.getByText('已暂停,点击圆圈继续')).toBeTruthy(); + expect(screen.queryByLabelText('打断当前对话')).toBeNull(); + expect(screen.getByLabelText('结束对话')).toBeTruthy(); }); }); diff --git a/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx b/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx index 48c735d5..83cd5d0a 100644 --- a/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx +++ b/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx @@ -1,11 +1,17 @@ import { afterEach, describe, expect, it, jest } from '@jest/globals'; -import { fireEvent, render, screen } from '@testing-library/react-native'; -import { ScrollView } from 'react-native'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import { Platform, StyleSheet } from 'react-native'; +import { RadialGradient } from 'react-native-svg'; import { VoiceCallScreen } from '../../../../../src/features/assistant/presentation/VoiceCallScreen'; +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 16, left: 0, right: 0, top: 12 }), +})); + function renderScreen(overrides: Partial[0]> = {}) { const props = { + messages: [], onCollapse: jest.fn(), onEnd: jest.fn(), onTogglePause: jest.fn(), @@ -23,8 +29,40 @@ describe('VoiceCallScreen', () => { }); it('shows the status title', () => { - renderScreen({ title: '正在说话' }); - expect(screen.getByText('正在说话')).toBeTruthy(); + renderScreen({ title: '正在回复' }); + expect(screen.getByText('正在回复')).toBeTruthy(); + }); + + it('renders user and assistant turns in the live transcript', () => { + renderScreen({ + messages: [ + { id: 'u1', role: 'user', text: '明天下午三点开会' }, + { id: 'a1', role: 'assistant', text: '好,已经记下了' }, + ], + }); + + expect(screen.getByLabelText('你:明天下午三点开会')).toBeTruthy(); + expect(screen.getByLabelText('助手:好,已经记下了')).toBeTruthy(); + expect(screen.queryByText('你')).toBeNull(); + expect(screen.queryByText('助手')).toBeNull(); + expect(screen.getByText('明天下午三点开会')).toBeTruthy(); + expect(screen.getByText('好,已经记下了')).toBeTruthy(); + }); + + it.each(['ios', 'android', 'web'] as const)('keeps the transcript readable on %s', (os) => { + const original = Platform.OS; + Platform.OS = os; + try { + renderScreen({ + messages: [{ id: 'u1', role: 'user', text: '改到四点' }], + status: 'paused', + title: '已暂停,点击圆圈继续', + }); + expect(screen.getByText('改到四点')).toBeTruthy(); + expect(screen.getByText('已暂停,点击圆圈继续')).toBeTruthy(); + } finally { + Platform.OS = original; + } }); it('collapses back to the bottom bar without ending the call', () => { @@ -49,34 +87,205 @@ describe('VoiceCallScreen', () => { }); it('labels the center circle "继续" once paused', () => { - renderScreen({ status: 'paused' }); + renderScreen({ status: 'paused', title: '已暂停,点击圆圈继续' }); expect(screen.getByLabelText('继续')).toBeTruthy(); expect(screen.queryByLabelText('暂停')).toBeNull(); + expect(screen.getByText('已暂停,点击圆圈继续')).toBeTruthy(); }); - it('renders every past turn, not just the latest', () => { + it('shows a speaking waveform on the live user or assistant turn', () => { renderScreen({ - turns: [ - { id: 't1', replyText: '明天下午三点', transcript: '明天几点开会' }, - { id: 't2', replyText: null, transcript: '谁参加' }, - ], + messages: [{ id: 'u1', pending: true, role: 'user', text: '明天' }], + status: 'paused', + title: '已暂停,点击圆圈继续', }); - expect(screen.getByText('明天几点开会')).toBeTruthy(); - expect(screen.getByText('明天下午三点')).toBeTruthy(); - expect(screen.getByText('谁参加')).toBeTruthy(); + expect(screen.getByTestId('voice-call-speaking-wave-user')).toBeTruthy(); + expect(screen.queryByTestId('voice-call-orb-wave')).toBeNull(); + + renderScreen({ + messages: [{ id: 'a1', pending: true, role: 'assistant', text: '好' }], + status: 'paused', + title: '已暂停,点击圆圈继续', + }); + expect(screen.getByTestId('voice-call-speaking-wave-assistant')).toBeTruthy(); }); - it('shows a placeholder in the history area when the call has no turns yet', () => { - renderScreen({ turns: [] }); - expect(screen.queryByText('明天几点开会')).toBeNull(); - expect(screen.getByText('对话开始后,这里会显示完整记录')).toBeTruthy(); + it('puts a live waveform inside the orb while listening or speaking', () => { + renderScreen({ status: 'listening' }); + expect(screen.getByTestId('voice-call-orb-wave')).toBeTruthy(); + renderScreen({ status: 'speaking', title: '正在回复' }); + expect(screen.getAllByTestId('voice-call-orb-wave').length).toBeGreaterThan(0); }); - it('scrolls the history area when its content grows', () => { - renderScreen({ turns: [{ id: 't1', replyText: null, transcript: '明天几点开会' }] }); - const history = screen.UNSAFE_getByType(ScrollView); + it('raises the center bars when the microphone is loud', () => { + renderScreen({ soundLevel: -8, status: 'listening' }); + + const edge = StyleSheet.flatten(screen.getByTestId('voice-call-voiceprint-bar-0').props.style); + const center = StyleSheet.flatten( + screen.getByTestId('voice-call-voiceprint-bar-4').props.style, + ); + expect(center.height).toBeGreaterThan(edge.height as number); + }); + + it.each(['ios', 'android', 'web'] as const)( + 'keeps a vertically jumping voiceprint on %s', + (os) => { + const original = Platform.OS; + Platform.OS = os; + try { + renderScreen({ soundLevel: -12, status: 'listening' }); + const edge = StyleSheet.flatten( + screen.getByTestId('voice-call-voiceprint-bar-0').props.style, + ); + const center = StyleSheet.flatten( + screen.getByTestId('voice-call-voiceprint-bar-4').props.style, + ); + expect(center.height).toBeGreaterThan(edge.height as number); + } finally { + Platform.OS = original; + } + }, + ); + + it('grows the voiceprint taller when the microphone gets louder', () => { + const { rerender } = render( + , + ); + const quieter = StyleSheet.flatten( + screen.getByTestId('voice-call-voiceprint-bar-4').props.style, + ).height as number; + + rerender( + , + ); + const louder = StyleSheet.flatten(screen.getByTestId('voice-call-voiceprint-bar-4').props.style) + .height as number; + expect(louder).toBeGreaterThan(quieter); + }); + + it('jumps the same bar up and down over time instead of sliding sideways', () => { + jest.useFakeTimers(); + renderScreen({ soundLevel: -8, status: 'listening' }); + const before = StyleSheet.flatten(screen.getByTestId('voice-call-voiceprint-bar-4').props.style) + .height as number; + act(() => { + jest.advanceTimersByTime(80); + }); + const after = StyleSheet.flatten(screen.getByTestId('voice-call-voiceprint-bar-4').props.style) + .height as number; + expect(after).not.toBe(before); + }); + + it('keeps hangup available while connecting or after an interruption', () => { + renderScreen({ status: 'busy', title: '连接中…' }); + expect(screen.getByText('连接中…')).toBeTruthy(); + expect(screen.getByLabelText('结束对话')).toBeTruthy(); + + renderScreen({ status: 'interrupted', title: '已打断' }); + expect(screen.getByText('已打断')).toBeTruthy(); + }); + + it('uses a deeper forest backdrop and a larger hangup control', () => { + renderScreen({ status: 'paused', title: '已暂停,点击圆圈继续' }); + expect( + StyleSheet.flatten(screen.getByTestId('voice-call-screen').props.style).backgroundColor, + ).toBe('#0E241F'); + expect(screen.getByTestId('voice-call-backdrop')).toBeTruthy(); + expect(screen.UNSAFE_getByType(RadialGradient).props).toMatchObject({ + rx: '98%', + ry: '92%', + }); + expect(StyleSheet.flatten(screen.getByTestId('voice-call-end-icon').props.style)).toMatchObject( + { + height: 72, + width: 72, + }, + ); + }); + + it.each(['ios', 'android', 'web'] as const)( + 'lets a tall transcript scroll on %s instead of staying flex-end', + (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, 'contentSizeChange', 390, 2000); + expect(StyleSheet.flatten(transcript.props.contentContainerStyle)).toMatchObject({ + flexGrow: 0, + justifyContent: 'flex-start', + }); + fireEvent.scroll(transcript, { + nativeEvent: { + contentOffset: { x: 0, y: 0 }, + contentSize: { height: 2000, width: 390 }, + layoutMeasurement: { height: 400, width: 390 }, + }, + }); + expect(screen.getByLabelText('你:明天下午三点开会')).toBeTruthy(); + } finally { + Platform.OS = original; + } + }, + ); + + it('keeps a short transcript pinned above the dock', () => { + renderScreen({ + messages: [{ id: 'u1', role: 'user', 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, 'contentSizeChange', 390, 120); + expect(StyleSheet.flatten(transcript.props.contentContainerStyle)).toMatchObject({ + flexGrow: 1, + justifyContent: 'flex-end', + }); + }); - expect(history.props.onContentSizeChange).toEqual(expect.any(Function)); - history.props.onContentSizeChange(); + it.each(['ios', 'android', 'web'] as const)('keeps the hangup control enlarged on %s', (os) => { + const original = Platform.OS; + Platform.OS = os; + try { + renderScreen({ status: 'paused', title: '已暂停,点击圆圈继续' }); + expect(StyleSheet.flatten(screen.getByTestId('voice-call-end-icon').props.style).height).toBe( + 72, + ); + expect(screen.getByText('结束对话')).toBeTruthy(); + } finally { + Platform.OS = original; + } }); }); diff --git a/frontend/tests/unit/features/assistant/presentation/useAssistantConversation.test.tsx b/frontend/tests/unit/features/assistant/presentation/useAssistantConversation.test.tsx index 4b5486ab..6347f2cd 100644 --- a/frontend/tests/unit/features/assistant/presentation/useAssistantConversation.test.tsx +++ b/frontend/tests/unit/features/assistant/presentation/useAssistantConversation.test.tsx @@ -3,23 +3,23 @@ import { act, renderHook } from '@testing-library/react-native'; import type { AssistantApplicationPort } from '../../../../../src/features/assistant/application/AssistantApplication'; import type { - ConversationTurnRecord, ConversationTurnState, + VoiceChatMessage, } from '../../../../../src/features/assistant/domain/ConversationTurn'; import { useAssistantConversation } from '../../../../../src/features/assistant/presentation/useAssistantConversation'; -function createApplication(initialTurns: readonly ConversationTurnRecord[]) { - let turns = initialTurns; +function createApplication(initialMessages: readonly VoiceChatMessage[]) { + let messages = initialMessages; const listeners = new Set<(state: ConversationTurnState) => void>(); const application: AssistantApplicationPort = { dismissReply: async () => {}, dispose: () => {}, endTurn: async () => {}, getLastAppliedCommand: () => null, + getMessages: () => messages, getReplyText: () => null, getSoundLevel: () => null, getState: () => ({ phase: 'idle' }), - getTurns: () => turns, startTurn: async () => {}, subscribe: (listener) => { listeners.add(listener); @@ -29,30 +29,36 @@ function createApplication(initialTurns: readonly ConversationTurnRecord[]) { return { application, - setTurns(nextTurns: readonly ConversationTurnRecord[]) { - turns = nextTurns; + setMessages(nextMessages: readonly VoiceChatMessage[]) { + messages = nextMessages; for (const listener of listeners) listener({ phase: 'listening', conversationId: 'c1' }); }, }; } describe('useAssistantConversation', () => { - it('keeps turn history in sync after updates and application replacement', () => { - const first = createApplication([{ id: 't1', replyText: null, transcript: '第一句' }]); + it('keeps bubble history in sync after updates and application replacement', () => { + const first = createApplication([{ id: 'u1', role: 'user', text: '第一句' }]); const { result, rerender } = renderHook(useAssistantConversation, { initialProps: first.application, }); - expect(result.current.turns).toEqual([{ id: 't1', replyText: null, transcript: '第一句' }]); + expect(result.current.messages).toEqual([{ id: 'u1', role: 'user', text: '第一句' }]); - act(() => first.setTurns([{ id: 't1', replyText: '第一句回复', transcript: '第一句' }])); - expect(result.current.turns).toEqual([ - { id: 't1', replyText: '第一句回复', transcript: '第一句' }, + act(() => + first.setMessages([ + { id: 'u1', role: 'user', text: '第一句' }, + { id: 'a1', role: 'assistant', text: '第一句回复' }, + ]), + ); + expect(result.current.messages).toEqual([ + { id: 'u1', role: 'user', text: '第一句' }, + { id: 'a1', role: 'assistant', text: '第一句回复' }, ]); - const second = createApplication([{ id: 't2', replyText: null, transcript: '第二句' }]); + const second = createApplication([{ id: 'u2', role: 'user', text: '第二句' }]); rerender(second.application); - expect(result.current.turns).toEqual([{ id: 't2', replyText: null, transcript: '第二句' }]); + expect(result.current.messages).toEqual([{ id: 'u2', role: 'user', text: '第二句' }]); }); }); diff --git a/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts b/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts new file mode 100644 index 00000000..78aaec56 --- /dev/null +++ b/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { act, renderHook } from '@testing-library/react-native'; +import type { + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView, +} from 'react-native'; + +import { + PINNED_TO_BOTTOM_THRESHOLD, + contentFitsViewport, + isPinnedToBottom, + usePinnedTranscriptScroll, +} from '../../../../../src/features/assistant/presentation/usePinnedTranscriptScroll'; + +function layoutEvent(height: number): LayoutChangeEvent { + return { + nativeEvent: { + layout: { height, width: 390, x: 0, y: 0 }, + }, + } 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); + expect(contentFitsViewport(800, -10)).toBe(true); + }); + + it('fits when content is within one pixel of the viewport', () => { + expect(contentFitsViewport(400, 400)).toBe(true); + expect(contentFitsViewport(401, 400)).toBe(true); + expect(contentFitsViewport(402, 400)).toBe(false); + }); +}); + +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', () => { + 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, 240); + }); + + expect(result.current.fitsViewport).toBe(true); + expect(scrollToEnd).toHaveBeenCalledWith({ animated: true }); + }); + + it('lets a tall transcript overflow so the user can scroll up', () => { + const { result } = renderHook(() => usePinnedTranscriptScroll()); + + act(() => { + result.current.onLayout(layoutEvent(400)); + result.current.onContentSizeChange(390, 2000); + }); + + expect(result.current.fitsViewport).toBe(false); + }); + + it('does not yank the user back while they are reading earlier turns', () => { + 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.onScroll( + scrollEvent({ contentHeight: 2000, offsetY: 0, viewportHeight: 400 }), + ); + }); + scrollToEnd.mockClear(); + + act(() => { + result.current.onContentSizeChange(390, 2200); + }); + + expect(scrollToEnd).not.toHaveBeenCalled(); + expect(result.current.fitsViewport).toBe(false); + }); + + it('resumes sticking to the latest turn after the user returns to the bottom', () => { + 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, 2000); + }); + expect(scrollToEnd).not.toHaveBeenCalled(); + + act(() => { + result.current.onScroll( + scrollEvent({ contentHeight: 2000, offsetY: 1600, viewportHeight: 400 }), + ); + result.current.onContentSizeChange(390, 2100); + }); + + expect(scrollToEnd).toHaveBeenCalledWith({ animated: true }); + }); +}); diff --git a/frontend/tests/unit/screens/HomeScreen.test.tsx b/frontend/tests/unit/screens/HomeScreen.test.tsx index 7883e758..6b6669f6 100644 --- a/frontend/tests/unit/screens/HomeScreen.test.tsx +++ b/frontend/tests/unit/screens/HomeScreen.test.tsx @@ -27,6 +27,7 @@ class FakeAssistantApplication implements AssistantApplicationPort { endTurn = async () => {}; getLastAppliedCommand = () => this.command; getReplyText = () => null; + getMessages = () => []; getSoundLevel = () => null; getState = (): ConversationTurnState => ({ phase: 'idle' }); startTurn = async () => {}; From 37c3ca9c73e0c5d541c26730316a4298abdf478d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E6=9D=A1=E5=9B=BA=E6=89=A7=E7=9A=84=E9=B1=BC?= <1504947133@qq.com> Date: Thu, 20 Aug 2026 12:00:06 +0800 Subject: [PATCH 2/5] fix(assistant): follow new turns only while the user is idle Keep the live transcript still during a scroll gesture, then snap back to the latest message when the user stops interacting. --- .../presentation/VoiceCallScreen.tsx | 17 ++- .../presentation/usePinnedTranscriptScroll.ts | 105 +++++++++++++++--- .../usePinnedTranscriptScroll.test.ts | 84 ++++++++++---- 3 files changed, 169 insertions(+), 37 deletions(-) diff --git a/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx b/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx index c697a2c4..ea18c58e 100644 --- a/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx +++ b/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx @@ -56,8 +56,17 @@ export function VoiceCallScreen({ onTogglePause, }: VoiceCallScreenProps) { const insets = useSafeAreaInsets(); - const { fitsViewport, onContentSizeChange, onLayout, onScroll, transcriptRef } = - usePinnedTranscriptScroll(); + const { + fitsViewport, + onContentSizeChange, + onLayout, + onMomentumScrollBegin, + onMomentumScrollEnd, + onScroll, + onScrollBeginDrag, + onScrollEndDrag, + transcriptRef, + } = usePinnedTranscriptScroll(); const [scale] = useState(() => new Animated.Value(1)); useEffect(() => { @@ -112,7 +121,11 @@ export function VoiceCallScreen({ ]} onContentSizeChange={onContentSizeChange} onLayout={onLayout} + onMomentumScrollBegin={onMomentumScrollBegin} + onMomentumScrollEnd={onMomentumScrollEnd} onScroll={onScroll} + onScrollBeginDrag={onScrollBeginDrag} + onScrollEndDrag={onScrollEndDrag} scrollEventThrottle={16} showsVerticalScrollIndicator={false} style={styles.transcript} diff --git a/frontend/src/features/assistant/presentation/usePinnedTranscriptScroll.ts b/frontend/src/features/assistant/presentation/usePinnedTranscriptScroll.ts index 08f10594..08319daa 100644 --- a/frontend/src/features/assistant/presentation/usePinnedTranscriptScroll.ts +++ b/frontend/src/features/assistant/presentation/usePinnedTranscriptScroll.ts @@ -1,12 +1,14 @@ -import { useRef, useState } from 'react'; -import type { - LayoutChangeEvent, - NativeScrollEvent, - NativeSyntheticEvent, - ScrollView, +import { useEffect, useRef, useState } from 'react'; +import { + Platform, + type LayoutChangeEvent, + type NativeScrollEvent, + type NativeSyntheticEvent, + type ScrollView, } from 'react-native'; export const PINNED_TO_BOTTOM_THRESHOLD = 80; +export const TRANSCRIPT_IDLE_MS = 180; export function contentFitsViewport(contentHeight: number, viewportHeight: number): boolean { if (viewportHeight <= 0) { @@ -32,7 +34,10 @@ export function isPinnedToBottom({ export function usePinnedTranscriptScroll() { const transcriptRef = useRef(null); - const pinnedRef = useRef(true); + const interactingRef = useRef(false); + 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); @@ -42,35 +47,103 @@ export function usePinnedTranscriptScroll() { 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 followLatest = () => { + if (interactingRef.current) { + return; + } + 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; + 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; + const { contentSize, layoutMeasurement } = event.nativeEvent; viewportHeightRef.current = layoutMeasurement.height; contentHeightRef.current = contentSize.height; - pinnedRef.current = isPinnedToBottom({ - contentHeight: contentSize.height, - offsetY: contentOffset.y, - viewportHeight: layoutMeasurement.height, - }); + if (ignoreProgrammaticScrollRef.current) { + return; + } + if (Platform.OS === 'web') { + markInteracting(); + idleTimerRef.current = setTimeout(markIdle, TRANSCRIPT_IDLE_MS); + } }; const onContentSizeChange = (_width: number, height: number) => { contentHeightRef.current = height; syncFits(); - if (pinnedRef.current) { - transcriptRef.current?.scrollToEnd({ animated: true }); - } + followLatest(); }; return { fitsViewport, onContentSizeChange, onLayout, + onMomentumScrollBegin, + onMomentumScrollEnd, onScroll, + onScrollBeginDrag, + onScrollEndDrag, transcriptRef, }; } diff --git a/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts b/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts index 78aaec56..9e9145cd 100644 --- a/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts +++ b/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts @@ -1,4 +1,4 @@ -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, @@ -6,14 +6,18 @@ import type { 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: { @@ -83,7 +87,12 @@ describe('isPinnedToBottom', () => { }); 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; @@ -108,47 +117,84 @@ describe('usePinnedTranscriptScroll', () => { expect(result.current.fitsViewport).toBe(false); }); - it('does not yank the user back while they are reading earlier turns', () => { + 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(); + }, + ); + + it.each(['ios', 'android'] as const)( + 'follows the latest turn on %s after the user stops scrolling', + (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('follows the latest turn on web after the user stops scrolling', () => { + Platform.OS = 'web'; + 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.onScroll( scrollEvent({ contentHeight: 2000, offsetY: 0, viewportHeight: 400 }), ); + result.current.onContentSizeChange(390, 2200); }); - scrollToEnd.mockClear(); + expect(scrollToEnd).not.toHaveBeenCalled(); act(() => { - result.current.onContentSizeChange(390, 2200); + jest.advanceTimersByTime(TRANSCRIPT_IDLE_MS); }); - - expect(scrollToEnd).not.toHaveBeenCalled(); - expect(result.current.fitsViewport).toBe(false); + expect(scrollToEnd).toHaveBeenCalledWith({ animated: true }); }); - it('resumes sticking to the latest turn after the user returns to the bottom', () => { + 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.onScroll( - scrollEvent({ contentHeight: 2000, offsetY: 0, viewportHeight: 400 }), - ); - result.current.onContentSizeChange(390, 2000); + result.current.onScrollBeginDrag(); + result.current.onScrollEndDrag(); + result.current.onContentSizeChange(390, 2100); }); expect(scrollToEnd).not.toHaveBeenCalled(); act(() => { - result.current.onScroll( - scrollEvent({ contentHeight: 2000, offsetY: 1600, viewportHeight: 400 }), - ); - result.current.onContentSizeChange(390, 2100); + jest.advanceTimersByTime(TRANSCRIPT_IDLE_MS); }); - expect(scrollToEnd).toHaveBeenCalledWith({ animated: true }); }); }); From 44d42c5b9a09faea3494fc852c9a1dd2da7c709e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E6=9D=A1=E5=9B=BA=E6=89=A7=E7=9A=84=E9=B1=BC?= <1504947133@qq.com> Date: Thu, 20 Aug 2026 12:08:57 +0800 Subject: [PATCH 3/5] fix(assistant): keep the continuous-call PR scoped to visual UI Leave follow-latest / jump-to-latest scroll behavior for a separate change so this PR only ships the bubble layout. --- .../presentation/VoiceCallScreen.tsx | 19 +-- .../presentation/usePinnedTranscriptScroll.ts | 116 +------------- .../usePinnedTranscriptScroll.test.ts | 151 +----------------- 3 files changed, 10 insertions(+), 276 deletions(-) diff --git a/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx b/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx index ea18c58e..4daaa290 100644 --- a/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx +++ b/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx @@ -56,17 +56,8 @@ export function VoiceCallScreen({ onTogglePause, }: VoiceCallScreenProps) { const insets = useSafeAreaInsets(); - const { - fitsViewport, - onContentSizeChange, - onLayout, - onMomentumScrollBegin, - onMomentumScrollEnd, - onScroll, - onScrollBeginDrag, - onScrollEndDrag, - transcriptRef, - } = usePinnedTranscriptScroll(); + const { fitsViewport, onContentSizeChange, onLayout, transcriptRef } = + usePinnedTranscriptScroll(); const [scale] = useState(() => new Animated.Value(1)); useEffect(() => { @@ -121,12 +112,6 @@ export function VoiceCallScreen({ ]} onContentSizeChange={onContentSizeChange} onLayout={onLayout} - onMomentumScrollBegin={onMomentumScrollBegin} - onMomentumScrollEnd={onMomentumScrollEnd} - onScroll={onScroll} - onScrollBeginDrag={onScrollBeginDrag} - onScrollEndDrag={onScrollEndDrag} - scrollEventThrottle={16} showsVerticalScrollIndicator={false} style={styles.transcript} testID="voice-call-transcript" diff --git a/frontend/src/features/assistant/presentation/usePinnedTranscriptScroll.ts b/frontend/src/features/assistant/presentation/usePinnedTranscriptScroll.ts index 08319daa..6c6a2616 100644 --- a/frontend/src/features/assistant/presentation/usePinnedTranscriptScroll.ts +++ b/frontend/src/features/assistant/presentation/usePinnedTranscriptScroll.ts @@ -1,14 +1,5 @@ -import { useEffect, useRef, useState } from 'react'; -import { - Platform, - type LayoutChangeEvent, - type NativeScrollEvent, - type NativeSyntheticEvent, - type ScrollView, -} from 'react-native'; - -export const PINNED_TO_BOTTOM_THRESHOLD = 80; -export const TRANSCRIPT_IDLE_MS = 180; +import { useRef, useState } from 'react'; +import type { LayoutChangeEvent, ScrollView } from 'react-native'; export function contentFitsViewport(contentHeight: number, viewportHeight: number): boolean { if (viewportHeight <= 0) { @@ -17,27 +8,8 @@ export function contentFitsViewport(contentHeight: number, viewportHeight: numbe return contentHeight <= viewportHeight + 1; } -export function isPinnedToBottom({ - contentHeight, - offsetY, - viewportHeight, - threshold = PINNED_TO_BOTTOM_THRESHOLD, -}: { - contentHeight: number; - offsetY: number; - viewportHeight: number; - threshold?: number; -}): boolean { - const distanceFromBottom = contentHeight - viewportHeight - offsetY; - return distanceFromBottom <= threshold; -} - export function usePinnedTranscriptScroll() { const transcriptRef = useRef(null); - const interactingRef = useRef(false); - 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); @@ -47,103 +19,21 @@ export function usePinnedTranscriptScroll() { 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 followLatest = () => { - if (interactingRef.current) { - return; - } - 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; - 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 { contentSize, layoutMeasurement } = event.nativeEvent; - viewportHeightRef.current = layoutMeasurement.height; - contentHeightRef.current = contentSize.height; - if (ignoreProgrammaticScrollRef.current) { - return; - } - if (Platform.OS === 'web') { - markInteracting(); - idleTimerRef.current = setTimeout(markIdle, TRANSCRIPT_IDLE_MS); - } - }; - const onContentSizeChange = (_width: number, height: number) => { contentHeightRef.current = height; syncFits(); - followLatest(); + transcriptRef.current?.scrollToEnd({ animated: true }); }; return { fitsViewport, onContentSizeChange, onLayout, - onMomentumScrollBegin, - onMomentumScrollEnd, - onScroll, - onScrollBeginDrag, - onScrollEndDrag, transcriptRef, }; } diff --git a/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts b/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts index 9e9145cd..d9a2686c 100644 --- a/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts +++ b/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts @@ -1,23 +1,12 @@ -import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import { describe, expect, it, jest } from '@jest/globals'; import { act, renderHook } from '@testing-library/react-native'; -import type { - LayoutChangeEvent, - NativeScrollEvent, - NativeSyntheticEvent, - ScrollView, -} from 'react-native'; -import { Platform } from 'react-native'; +import type { LayoutChangeEvent, ScrollView } 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: { @@ -26,24 +15,6 @@ 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); @@ -57,42 +28,8 @@ 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', () => { - afterEach(() => { - Platform.OS = originalOs; - jest.useRealTimers(); - }); - - it('follows the latest turn when the user is not scrolling', () => { + it('keeps short content pinned to the dock and does not overflow', () => { const { result } = renderHook(() => usePinnedTranscriptScroll()); const scrollToEnd = jest.fn(); result.current.transcriptRef.current = { scrollToEnd } as unknown as ScrollView; @@ -108,6 +45,8 @@ describe('usePinnedTranscriptScroll', () => { 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)); @@ -115,86 +54,6 @@ 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(); - }, - ); - - it.each(['ios', 'android'] as const)( - 'follows the latest turn on %s after the user stops scrolling', - (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('follows the latest turn on web after the user stops scrolling', () => { - Platform.OS = 'web'; - jest.useFakeTimers(); - 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(scrollToEnd).not.toHaveBeenCalled(); - - act(() => { - jest.advanceTimersByTime(TRANSCRIPT_IDLE_MS); - }); - expect(scrollToEnd).toHaveBeenCalledWith({ animated: true }); - }); - - 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 }); }); }); From d683580151e32ac280b7147160d37e5b2784e687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E6=9D=A1=E5=9B=BA=E6=89=A7=E7=9A=84=E9=B1=BC?= <1504947133@qq.com> Date: Thu, 20 Aug 2026 12:11:23 +0800 Subject: [PATCH 4/5] feat(assistant): keep reading position and jump back to latest Leave the transcript still while scrolling up, and show a jump-to-latest chip so new turns stay reachable. --- .../presentation/VoiceCallScreen.tsx | 164 ++++++++---- .../presentation/usePinnedTranscriptScroll.ts | 147 ++++++++++- .../presentation/VoiceCallScreen.test.tsx | 44 +++- .../usePinnedTranscriptScroll.test.ts | 247 +++++++++++++++++- 4 files changed, 542 insertions(+), 60 deletions(-) diff --git a/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx b/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx index 4daaa290..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, 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,62 +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 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; + const atBottom = isPinnedToBottom({ + contentHeight: contentSize.height, + offsetY: contentOffset.y, + viewportHeight: layoutMeasurement.height, + }); + if (ignoreProgrammaticScrollRef.current) { + return; + } + setFollowing(atBottom); + if (Platform.OS === 'web') { + markInteracting(); + idleTimerRef.current = setTimeout(markIdle, TRANSCRIPT_IDLE_MS); + } + }; + const onContentSizeChange = (_width: number, height: number) => { contentHeightRef.current = height; syncFits(); - 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..89048e0f 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', 'web'] 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..5157083c 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,181 @@ 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('does not snap back on web after the user stops on earlier turns', () => { + Platform.OS = 'web'; + jest.useFakeTimers(); + 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(scrollToEnd).not.toHaveBeenCalled(); + expect(result.current.hasUnseenLatest).toBe(true); + + act(() => { + jest.advanceTimersByTime(TRANSCRIPT_IDLE_MS); + }); + expect(scrollToEnd).not.toHaveBeenCalled(); + expect(result.current.hasUnseenLatest).toBe(true); + }); + + it('follows the latest turn on web after idle if still at the bottom', () => { + Platform.OS = 'web'; + jest.useFakeTimers(); + 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: 1600, viewportHeight: 400 }), + ); + result.current.onContentSizeChange(390, 2100); + }); + expect(scrollToEnd).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(TRANSCRIPT_IDLE_MS); + }); expect(scrollToEnd).toHaveBeenCalledWith({ animated: true }); + expect(result.current.hasUnseenLatest).toBe(false); + }); + + 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); }); }); From 05423188b7eaa99521c0826a692280c9a7a186bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E6=9D=A1=E5=9B=BA=E6=89=A7=E7=9A=84=E9=B1=BC?= <1504947133@qq.com> Date: Thu, 20 Aug 2026 13:47:33 +0800 Subject: [PATCH 5/5] fix(assistant): drop the web idle timer from transcript follow Native drag and momentum handlers already debounce follow-latest; the extra web onScroll timer is out of scope for the mobile change. --- .../presentation/usePinnedTranscriptScroll.ts | 15 +++---- .../presentation/VoiceCallScreen.test.tsx | 2 +- .../usePinnedTranscriptScroll.test.ts | 45 ------------------- 3 files changed, 6 insertions(+), 56 deletions(-) diff --git a/frontend/src/features/assistant/presentation/usePinnedTranscriptScroll.ts b/frontend/src/features/assistant/presentation/usePinnedTranscriptScroll.ts index 7e51853a..eed19138 100644 --- a/frontend/src/features/assistant/presentation/usePinnedTranscriptScroll.ts +++ b/frontend/src/features/assistant/presentation/usePinnedTranscriptScroll.ts @@ -1,10 +1,9 @@ import { useEffect, useRef, useState } from 'react'; -import { - Platform, - type LayoutChangeEvent, - type NativeScrollEvent, - type NativeSyntheticEvent, - type ScrollView, +import type { + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView, } from 'react-native'; export const PINNED_TO_BOTTOM_THRESHOLD = 80; @@ -141,10 +140,6 @@ export function usePinnedTranscriptScroll() { return; } setFollowing(atBottom); - if (Platform.OS === 'web') { - markInteracting(); - idleTimerRef.current = setTimeout(markIdle, TRANSCRIPT_IDLE_MS); - } }; const onContentSizeChange = (_width: number, height: number) => { diff --git a/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx b/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx index 89048e0f..ed3c6405 100644 --- a/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx +++ b/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx @@ -259,7 +259,7 @@ describe('VoiceCallScreen', () => { }, ); - it.each(['ios', 'android', 'web'] as const)( + 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; diff --git a/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts b/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts index 5157083c..696850ff 100644 --- a/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts +++ b/frontend/tests/unit/features/assistant/presentation/usePinnedTranscriptScroll.test.ts @@ -206,51 +206,6 @@ describe('usePinnedTranscriptScroll', () => { expect(scrollToEnd).not.toHaveBeenCalled(); }); - it('does not snap back on web after the user stops on earlier turns', () => { - Platform.OS = 'web'; - jest.useFakeTimers(); - 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(scrollToEnd).not.toHaveBeenCalled(); - expect(result.current.hasUnseenLatest).toBe(true); - - act(() => { - jest.advanceTimersByTime(TRANSCRIPT_IDLE_MS); - }); - expect(scrollToEnd).not.toHaveBeenCalled(); - expect(result.current.hasUnseenLatest).toBe(true); - }); - - it('follows the latest turn on web after idle if still at the bottom', () => { - Platform.OS = 'web'; - jest.useFakeTimers(); - 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: 1600, viewportHeight: 400 }), - ); - result.current.onContentSizeChange(390, 2100); - }); - expect(scrollToEnd).not.toHaveBeenCalled(); - - act(() => { - jest.advanceTimersByTime(TRANSCRIPT_IDLE_MS); - }); - expect(scrollToEnd).toHaveBeenCalledWith({ animated: true }); - expect(result.current.hasUnseenLatest).toBe(false); - }); - it('uses the drag-end idle timer when momentum does not follow', () => { Platform.OS = 'ios'; jest.useFakeTimers();