From e896ec9de100e240189855b176233efbe87466ff Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Tue, 18 Aug 2026 15:26:04 +0800 Subject: [PATCH 01/10] fix(assistant): respect safe areas in voice call --- .../presentation/AssistantVoiceOverlay.tsx | 37 +++++++++++-------- .../assistant/presentation/PushToTalkBar.tsx | 10 ++--- .../presentation/VoiceCallScreen.tsx | 34 +++++++++++------ .../AssistantVoiceOverlay.test.tsx | 12 +++--- .../presentation/PushToTalkBarLayout.test.tsx | 6 +-- .../presentation/VoiceCallScreen.test.tsx | 4 ++ 6 files changed, 62 insertions(+), 41 deletions(-) diff --git a/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx b/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx index 6ac2ac02..af75f348 100644 --- a/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx +++ b/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx @@ -2,6 +2,10 @@ import { useState } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { + FLOATING_VOICE_BAR_HEIGHT, + floatingVoiceBarBottomOffset, +} from '../../../shared/ui/floatingVoiceBarLayout'; import { colors, spacing } from '../../../shared/ui/theme'; import type { AssistantApplicationPort } from '../application/AssistantApplication'; import type { ConversationTurnState } from '../domain/ConversationTurn'; @@ -121,11 +125,19 @@ export function AssistantVoiceOverlay({ } return ( - - + + {ptt.replyText ? ( + + ) : null} + {ptt.replyText ? ( {ptt.replyText} @@ -170,14 +182,6 @@ const styles = StyleSheet.create({ gap: spacing.sm, width: '100%', }, - container: { - backgroundColor: colors.surface, - borderTopColor: colors.border, - borderTopWidth: StyleSheet.hairlineWidth, - paddingHorizontal: spacing.lg, - paddingTop: spacing.sm, - width: '100%', - }, bubble: { backgroundColor: colors.surface, borderColor: colors.border, @@ -195,7 +199,7 @@ const styles = StyleSheet.create({ alignItems: 'center', backgroundColor: colors.text, borderRadius: 999, - height: 52, + height: FLOATING_VOICE_BAR_HEIGHT, justifyContent: 'center', width: 52, }, @@ -207,6 +211,9 @@ const styles = StyleSheet.create({ }, overlay: { alignItems: 'center', - width: '100%', + left: 0, + paddingHorizontal: spacing.lg, + position: 'absolute', + right: 0, }, }); diff --git a/frontend/src/features/assistant/presentation/PushToTalkBar.tsx b/frontend/src/features/assistant/presentation/PushToTalkBar.tsx index 5d898c4b..16a570cc 100644 --- a/frontend/src/features/assistant/presentation/PushToTalkBar.tsx +++ b/frontend/src/features/assistant/presentation/PushToTalkBar.tsx @@ -101,10 +101,8 @@ function normalizeLevel(dbfs: number | null): number { const styles = StyleSheet.create({ bar: { alignItems: 'center', - backgroundColor: colors.input, - borderColor: colors.border, - borderRadius: 14, - borderWidth: 1, + backgroundColor: colors.text, + borderRadius: 999, flex: 1, height: 52, justifyContent: 'center', @@ -119,12 +117,12 @@ const styles = StyleSheet.create({ opacity: 0.86, }, label: { - color: colors.text, + color: colors.onPrimary, fontSize: 14, fontWeight: '600', }, labelDisabled: { - color: colors.mutedText, + color: colors.onPrimary, }, wave: { alignItems: 'center', diff --git a/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx b/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx index 7a601fc1..9b1135fc 100644 --- a/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx +++ b/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx @@ -9,6 +9,7 @@ import { Text, View, } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { colors, spacing } from '../../../shared/ui/theme'; import type { ConversationTurnRecord } from '../domain/ConversationTurn'; @@ -44,6 +45,7 @@ export function VoiceCallScreen({ onEnd, onTogglePause, }: VoiceCallScreenProps) { + const insets = useSafeAreaInsets(); const [scale] = useState(() => new Animated.Value(1)); const historyRef = useRef(null); @@ -77,14 +79,16 @@ export function VoiceCallScreen({ return ( - [styles.collapseButton, pressed && styles.buttonPressed]} - > - - + + [styles.collapseButton, pressed && styles.buttonPressed]} + > + + + - + { }); it.each([ - ['keeps the design spacing when the inset is smaller', 8, 16], - ['keeps the voice controls above the system navigation area', 34, 34], - ])('%s', (_name, bottomInset, expectedPadding) => { + ['keeps a comfortable offset on devices with a small inset', 8, 32], + ['moves controls above the system navigation area', 34, 50], + ])('%s', (_name, bottomInset, expectedBottom) => { mockBottomInset = bottomInset; mockPttApplication = createApplication(); const continuousApplication = createApplication(); @@ -96,8 +96,10 @@ describe('AssistantVoiceOverlay layout', () => { ); expect( - StyleSheet.flatten(screen.getByTestId('assistant-voice-overlay').props.style).paddingBottom, - ).toBe(expectedPadding); + StyleSheet.flatten(screen.getByTestId('assistant-voice-controls').props.style), + ).toMatchObject({ + bottom: expectedBottom, + }); }); it('shows only a generic status label while replying, never the reply content', () => { diff --git a/frontend/tests/unit/features/assistant/presentation/PushToTalkBarLayout.test.tsx b/frontend/tests/unit/features/assistant/presentation/PushToTalkBarLayout.test.tsx index a6b64cdc..064b3903 100644 --- a/frontend/tests/unit/features/assistant/presentation/PushToTalkBarLayout.test.tsx +++ b/frontend/tests/unit/features/assistant/presentation/PushToTalkBarLayout.test.tsx @@ -5,7 +5,7 @@ import { StyleSheet } from 'react-native'; import { PushToTalkBar } from '../../../../../src/features/assistant/presentation/PushToTalkBar'; describe('PushToTalkBar layout', () => { - it('uses a light bordered input treatment in the idle state', () => { + it('uses a dark pill treatment in the idle state', () => { render( { const button = screen.getByRole('button'); expect(StyleSheet.flatten(button.props.style)).toMatchObject({ - backgroundColor: '#F0F2EE', - borderWidth: 1, + backgroundColor: '#12352D', + borderRadius: 999, }); }); }); diff --git a/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx b/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx index 48c735d5..964c881f 100644 --- a/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx +++ b/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx @@ -4,6 +4,10 @@ import { ScrollView } from 'react-native'; import { VoiceCallScreen } from '../../../../../src/features/assistant/presentation/VoiceCallScreen'; +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 }), +})); + function renderScreen(overrides: Partial[0]> = {}) { const props = { onCollapse: jest.fn(), From c8aa073da023f33be092f0cc7e2e8370e42dfc2b Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Tue, 18 Aug 2026 17:23:10 +0800 Subject: [PATCH 02/10] fix(schedule): respect voice and system safe areas --- .../assistant/presentation/PushToTalkBar.tsx | 3 +- .../presentation/ScheduleCalendarScreen.tsx | 23 ++++++++++++-- .../presentation/ScheduleDetailSheet.tsx | 11 ++++++- .../src/shared/ui/floatingVoiceBarLayout.ts | 11 +++++++ .../AssistantVoiceOverlay.test.tsx | 5 +-- .../ScheduleCalendarScreen.test.tsx | 31 ++++++++++++++++++- .../presentation/ScheduleDetailSheet.test.tsx | 19 +++++++++++- 7 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 frontend/src/shared/ui/floatingVoiceBarLayout.ts diff --git a/frontend/src/features/assistant/presentation/PushToTalkBar.tsx b/frontend/src/features/assistant/presentation/PushToTalkBar.tsx index 16a570cc..a1a04d28 100644 --- a/frontend/src/features/assistant/presentation/PushToTalkBar.tsx +++ b/frontend/src/features/assistant/presentation/PushToTalkBar.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { Animated, Easing, Platform, Pressable, StyleSheet, Text, View } from 'react-native'; +import { FLOATING_VOICE_BAR_HEIGHT } from '../../../shared/ui/floatingVoiceBarLayout'; import { colors, spacing } from '../../../shared/ui/theme'; const WAVE_BAR_HEIGHTS = [10, 16, 22, 16, 10] as const; @@ -104,7 +105,7 @@ const styles = StyleSheet.create({ backgroundColor: colors.text, borderRadius: 999, flex: 1, - height: 52, + height: FLOATING_VOICE_BAR_HEIGHT, justifyContent: 'center', }, barActive: { diff --git a/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx b/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx index 079abd23..583e2ea7 100644 --- a/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx +++ b/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx @@ -1,7 +1,17 @@ import { useState } from 'react'; -import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { + ActivityIndicator, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Svg, { Path } from 'react-native-svg'; +import { floatingVoiceContentBottomInset } from '../../../shared/ui/floatingVoiceBarLayout'; import { colors, spacing } from '../../../shared/ui/theme'; import type { ScheduleCalendarReadService, ScheduleOccurrenceView } from '../application'; import { LocationScheduleDetailSheet } from './LocationScheduleDetailSheet'; @@ -41,6 +51,7 @@ export function ScheduleCalendarScreen({ refreshSignal, focusTarget, }: ScheduleCalendarScreenProps) { + const insets = useSafeAreaInsets(); const calendar = useScheduleCalendar( service, accountId, @@ -56,6 +67,7 @@ export function ScheduleCalendarScreen({ null; const selectedLocation = calendar.locationSchedules.find((item) => item.scheduleId === selectedLocationId) ?? null; + const topSafeAreaPadding = Platform.OS === 'android' ? insets.top : 0; const selectedLabel = SELECTED_DATE_FORMATTER.format(calendar.selectedDate); const agendaTitle = formatAgendaSectionTitle(calendar.selectedDate); const emptyAgenda = emptyAgendaMessage(calendar.selectedDate); @@ -65,9 +77,16 @@ export function ScheduleCalendarScreen({ return ( diff --git a/frontend/src/features/schedule/presentation/ScheduleDetailSheet.tsx b/frontend/src/features/schedule/presentation/ScheduleDetailSheet.tsx index 997233ec..7f85117a 100644 --- a/frontend/src/features/schedule/presentation/ScheduleDetailSheet.tsx +++ b/frontend/src/features/schedule/presentation/ScheduleDetailSheet.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { colors, spacing } from '../../../shared/ui/theme'; @@ -14,6 +15,7 @@ export function ScheduleDetailSheet({ title: string; visible: boolean; }) { + const insets = useSafeAreaInsets(); return ( @@ -31,7 +33,14 @@ export function ScheduleDetailSheet({ × - + {title} diff --git a/frontend/src/shared/ui/floatingVoiceBarLayout.ts b/frontend/src/shared/ui/floatingVoiceBarLayout.ts new file mode 100644 index 00000000..5f29faa4 --- /dev/null +++ b/frontend/src/shared/ui/floatingVoiceBarLayout.ts @@ -0,0 +1,11 @@ +import { spacing } from './theme'; + +export const FLOATING_VOICE_BAR_HEIGHT = 52; + +export function floatingVoiceBarBottomOffset(bottomInset: number): number { + return Math.max(spacing.xl, bottomInset + spacing.md); +} + +export function floatingVoiceContentBottomInset(bottomInset: number): number { + return floatingVoiceBarBottomOffset(bottomInset) + FLOATING_VOICE_BAR_HEIGHT + spacing.md; +} diff --git a/frontend/tests/unit/features/assistant/presentation/AssistantVoiceOverlay.test.tsx b/frontend/tests/unit/features/assistant/presentation/AssistantVoiceOverlay.test.tsx index 9a462ef9..1b003bb5 100644 --- a/frontend/tests/unit/features/assistant/presentation/AssistantVoiceOverlay.test.tsx +++ b/frontend/tests/unit/features/assistant/presentation/AssistantVoiceOverlay.test.tsx @@ -53,7 +53,7 @@ describe('AssistantVoiceOverlay layout', () => { mockCallState = { phase: 'idle' }; }); - it('does not add a fullscreen reply dismiss target over the calendar', () => { + it('dismisses the reply from the bubble or surrounding overlay', () => { mockPttApplication = createApplication(); const continuousApplication = createApplication(); render( @@ -63,7 +63,8 @@ describe('AssistantVoiceOverlay layout', () => { />, ); - expect(screen.queryByLabelText('关闭回复')).toBeNull(); + fireEvent.press(screen.getByLabelText('关闭回复')); + expect(mockDismissReply).toHaveBeenCalledTimes(1); expect(screen.getByText('按住说话')).toBeTruthy(); }); diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx index ab99107d..b9e07fe6 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; -import { describe, expect, it, jest } from '@jest/globals'; +import { afterEach, describe, expect, it, jest } from '@jest/globals'; import { StyleSheet } from 'react-native'; import type { @@ -8,6 +8,12 @@ import type { } from '../../../../../src/features/schedule/application'; import { ScheduleCalendarScreen } from '../../../../../src/features/schedule/presentation/ScheduleCalendarScreen'; +let mockBottomInset = 0; + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: mockBottomInset, left: 0, right: 0, top: 0 }), +})); + function occurrenceOnSelectedDay( hourUtc: number, overrides: Partial = {}, @@ -59,6 +65,29 @@ function createService( } describe('ScheduleCalendarScreen location schedules', () => { + afterEach(() => { + mockBottomInset = 0; + }); + + it('leaves enough scroll space for the floating voice controls and system navigation', async () => { + mockBottomInset = 34; + const service = createService(); + render( + {}} + service={service} + timezone="Asia/Shanghai" + username="Sarah" + />, + ); + + await waitFor(() => expect(service.getLocationSchedules).toHaveBeenCalled()); + expect( + StyleSheet.flatten(screen.getByTestId('schedule-calendar-scroll').props.contentContainerStyle), + ).toMatchObject({ paddingBottom: 118, paddingTop: 0 }); + }); + it('keeps accountId in the calendar data flow without rendering it', async () => { const service = createService(); const accountId = 'internal-account-id-not-for-display'; diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx index e13ca8f7..724b10a2 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react-native'; import { describe, expect, it, jest } from '@jest/globals'; -import { Modal } from 'react-native'; +import { Modal, ScrollView, StyleSheet } from 'react-native'; import type { LocationScheduleView, @@ -9,6 +9,12 @@ import type { import { LocationScheduleDetailSheet } from '../../../../../src/features/schedule/presentation/LocationScheduleDetailSheet'; import { ScheduleOccurrenceDetailSheet } from '../../../../../src/features/schedule/presentation/ScheduleOccurrenceDetailSheet'; +let mockBottomInset = 0; + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: mockBottomInset, left: 0, right: 0, top: 0 }), +})); + const timedOccurrence: ScheduleOccurrenceView = { scheduleId: 'schedule-a', scheduleCategory: 'time', @@ -40,6 +46,17 @@ const allDayOccurrence: ScheduleOccurrenceView = { }; describe('schedule detail sheets', () => { + it('keeps detail content above the system navigation area', () => { + mockBottomInset = 34; + render( {}} />); + + const content = screen.getByTestId('schedule-detail-content'); + expect(StyleSheet.flatten(content.props.contentContainerStyle)).toMatchObject({ + paddingBottom: 50, + }); + mockBottomInset = 0; + }); + it('prioritizes occurrence date and time while retaining optional information', () => { render( {}} />); From 1aeef7b49232a46f4bb0dfaadbc2328adb67dfce Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Wed, 19 Aug 2026 18:26:03 +0800 Subject: [PATCH 03/10] chore(frontend): fix safe-area test checks --- .../schedule/presentation/ScheduleCalendarScreen.test.tsx | 4 +++- .../schedule/presentation/ScheduleDetailSheet.test.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx index b9e07fe6..b77db97e 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx @@ -84,7 +84,9 @@ describe('ScheduleCalendarScreen location schedules', () => { await waitFor(() => expect(service.getLocationSchedules).toHaveBeenCalled()); expect( - StyleSheet.flatten(screen.getByTestId('schedule-calendar-scroll').props.contentContainerStyle), + StyleSheet.flatten( + screen.getByTestId('schedule-calendar-scroll').props.contentContainerStyle, + ), ).toMatchObject({ paddingBottom: 118, paddingTop: 0 }); }); diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx index 724b10a2..64b59f25 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react-native'; import { describe, expect, it, jest } from '@jest/globals'; -import { Modal, ScrollView, StyleSheet } from 'react-native'; +import { Modal, StyleSheet } from 'react-native'; import type { LocationScheduleView, From 1cad444e6a1c7454bf490d8e98e962db3f913057 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Wed, 19 Aug 2026 18:35:55 +0800 Subject: [PATCH 04/10] test(schedule): provide safe-area context in home screen test --- frontend/tests/unit/screens/HomeScreen.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/tests/unit/screens/HomeScreen.test.tsx b/frontend/tests/unit/screens/HomeScreen.test.tsx index 996f6f41..463a624e 100644 --- a/frontend/tests/unit/screens/HomeScreen.test.tsx +++ b/frontend/tests/unit/screens/HomeScreen.test.tsx @@ -9,6 +9,10 @@ import type { import type { ScheduleCalendarReadService } from '../../../src/features/schedule/application'; import { HomeScreen } from '../../../src/screens/HomeScreen'; +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 }), +})); + jest.mock('../../../src/features/assistant/presentation/AssistantVoiceOverlay', () => ({ AssistantVoiceOverlay: () => null, })); From a4cd6c8d3bbe34cf77b4e5beba3ab1877904adae Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Wed, 19 Aug 2026 18:43:58 +0800 Subject: [PATCH 05/10] test(assistant): cover voice overlay reply and start branches --- .../AssistantVoiceOverlay.test.tsx | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/frontend/tests/unit/features/assistant/presentation/AssistantVoiceOverlay.test.tsx b/frontend/tests/unit/features/assistant/presentation/AssistantVoiceOverlay.test.tsx index 1b003bb5..25055216 100644 --- a/frontend/tests/unit/features/assistant/presentation/AssistantVoiceOverlay.test.tsx +++ b/frontend/tests/unit/features/assistant/presentation/AssistantVoiceOverlay.test.tsx @@ -11,7 +11,7 @@ jest.mock('../../../../../src/features/assistant/presentation/useAssistantConver dismissReply: mockDismissReply, endTurn: application.endTurn, lastAppliedCommand: null, - replyText: application === mockPttApplication ? '已创建' : null, + replyText: application === mockPttApplication ? mockReplyText : null, soundLevel: null, startTurn: application.startTurn, state: application === mockPttApplication ? { phase: 'idle' as const } : mockCallState, @@ -25,6 +25,7 @@ jest.mock('react-native-safe-area-context', () => ({ let mockPttApplication: AssistantApplicationPort; let mockBottomInset = 0; +let mockReplyText: string | null = '已创建'; let mockCallState: ConversationTurnState = { phase: 'idle' }; const mockDismissReply = jest.fn(); @@ -45,6 +46,7 @@ function createApplication(): AssistantApplicationPort { describe('AssistantVoiceOverlay layout', () => { beforeEach(() => { mockBottomInset = 0; + mockReplyText = '已创建'; mockCallState = { phase: 'idle' }; mockDismissReply.mockClear(); }); @@ -82,6 +84,22 @@ describe('AssistantVoiceOverlay layout', () => { expect(mockDismissReply).toHaveBeenCalledTimes(1); }); + it('renders the controls without a reply bubble when there is no reply', () => { + mockReplyText = null; + mockPttApplication = createApplication(); + const continuousApplication = createApplication(); + + render( + , + ); + + expect(screen.queryByText('已创建')).toBeNull(); + expect(screen.getByText('按住说话')).toBeTruthy(); + }); + it.each([ ['keeps a comfortable offset on devices with a small inset', 8, 32], ['moves controls above the system navigation area', 34, 50], @@ -119,6 +137,22 @@ describe('AssistantVoiceOverlay layout', () => { expect(screen.getByText('回答中…')).toBeTruthy(); }); + it('starts the continuous conversation when entering from idle', () => { + mockPttApplication = createApplication(); + const startTurn = jest.fn(async () => {}); + const continuousApplication = { ...createApplication(), startTurn }; + render( + , + ); + + fireEvent.press(screen.getByLabelText('进入免提通话')); + + expect(startTurn).toHaveBeenCalledTimes(1); + }); + it('shows a generic "已打断" label when the reply is interrupted', () => { mockPttApplication = createApplication(); const continuousApplication = createApplication(); From 35be4c369c37617f2065b4ff6f6811e45c50d5cb Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Thu, 20 Aug 2026 09:56:00 +0800 Subject: [PATCH 06/10] test(schedule): cover non-zero safe-area insets and interaction reachability Code review (Wintercom): new tests mostly mocked useSafeAreaInsets as top=0/bottom=34 and only asserted the computed style numbers; VoiceCallScreen's test fixed every inset at 0, never exercising the PR's core notch/Home-Indicator behavior. Neither Platform.OS nor insets.top were ever varied anywhere, so ScheduleCalendarScreen's Android edge-to-edge branch (topSafeAreaPadding = Platform.OS === 'android' ? insets.top : 0) was never actually executed by any test. - VoiceCallScreen.test.tsx: added testID hooks on the nav/actions containers and it.each cases for both a small (clamped-to-fallback) and a large (real) top/bottom inset. - ScheduleCalendarScreen.test.tsx: added Android-applies-insets.top and iOS-stays-at-0 (native handles it) cases. - ScheduleCalendarScreen.test.tsx: strengthened the existing bottom-space test to also render a real occurrence and prove it's actually reachable and pressable (opens the detail sheet), not just that a padding number matches -- a padding number alone can't prove the last item isn't hidden behind the floating voice bar. Every new assertion was mutation-tested: broke the corresponding production line, confirmed the test failed, reverted. ScheduleDetailSheet's existing bottom-inset test (fixed field set, not a dynamic list) was left as-is -- its non-zero, non-clamped padding assertion already exercises the real computation, and there's no per-item occlusion risk to prove the way there is for the calendar's dynamic list. --- .../presentation/VoiceCallScreen.tsx | 6 +- .../presentation/VoiceCallScreen.test.tsx | 31 ++++++- .../ScheduleCalendarScreen.test.tsx | 89 ++++++++++++++++++- 3 files changed, 119 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx b/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx index 9b1135fc..3231a9a4 100644 --- a/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx +++ b/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx @@ -79,7 +79,10 @@ export function VoiceCallScreen({ return ( - + ({ - useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 }), + useSafeAreaInsets: () => ({ bottom: mockBottomInset, left: 0, right: 0, top: mockTopInset }), })); function renderScreen(overrides: Partial[0]> = {}) { @@ -24,6 +27,30 @@ function renderScreen(overrides: Partial[0]> describe('VoiceCallScreen', () => { afterEach(() => { jest.useRealTimers(); + mockTopInset = 0; + mockBottomInset = 0; + }); + + it.each([ + ['keeps the default top padding on devices with a small top inset', 4, 16], + ['pads the collapse button below a notch or status bar', 44, 44], + ])('%s', (_name, topInset, expectedPaddingTop) => { + mockTopInset = topInset; + renderScreen(); + expect( + StyleSheet.flatten(screen.getByTestId('voice-call-navigation').props.style), + ).toMatchObject({ paddingTop: expectedPaddingTop }); + }); + + it.each([ + ['keeps the default bottom padding on devices with a small home indicator', 4, 32], + ['moves the call actions above the home indicator / gesture nav area', 34, 50], + ])('%s', (_name, bottomInset, expectedPaddingBottom) => { + mockBottomInset = bottomInset; + renderScreen(); + expect(StyleSheet.flatten(screen.getByTestId('voice-call-actions').props.style)).toMatchObject({ + paddingBottom: expectedPaddingBottom, + }); }); it('shows the status title', () => { diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx index b77db97e..dd0b7b8d 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; -import { afterEach, describe, expect, it, jest } from '@jest/globals'; -import { StyleSheet } from 'react-native'; +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { Platform, StyleSheet } from 'react-native'; import type { ScheduleCalendarReadService, @@ -9,9 +9,10 @@ import type { import { ScheduleCalendarScreen } from '../../../../../src/features/schedule/presentation/ScheduleCalendarScreen'; let mockBottomInset = 0; +let mockTopInset = 0; jest.mock('react-native-safe-area-context', () => ({ - useSafeAreaInsets: () => ({ bottom: mockBottomInset, left: 0, right: 0, top: 0 }), + useSafeAreaInsets: () => ({ bottom: mockBottomInset, left: 0, right: 0, top: mockTopInset }), })); function occurrenceOnSelectedDay( @@ -65,13 +66,42 @@ function createService( } describe('ScheduleCalendarScreen location schedules', () => { + beforeEach(() => { + Platform.OS = 'ios'; + }); + afterEach(() => { mockBottomInset = 0; + mockTopInset = 0; + Platform.OS = 'ios'; }); - it('leaves enough scroll space for the floating voice controls and system navigation', async () => { + it('leaves enough scroll space for the floating voice controls and system navigation, and keeps the last occurrence reachable', async () => { mockBottomInset = 34; const service = createService(); + // 补上真正渲染出来的最后一项日程,不能只断言算出来的 padding 数字—— + // 那个数字本身不能证明这一项没被浮动语音条挡住、还是可以点开的。 + // selectedOccurrences 来自 getSchedulesByRange(不是 getSchedulesByDay), + // 按当前选中日期在客户端分组,所以要选到日程所在的那一天才会渲染出来。 + ( + service.getSchedulesByRange as jest.MockedFunction< + ScheduleCalendarReadService['getSchedulesByRange'] + > + ).mockResolvedValue([ + { + scheduleId: 'schedule-last', + scheduleCategory: 'time', + recurrenceMode: 'once', + title: '当日最后一条日程', + isAllDay: false, + timezone: 'Asia/Shanghai', + locationName: null, + reminderType: null, + reminderStrength: null, + occurrenceStart: '2026-08-13T09:00:00.000Z', + occurrenceEnd: '2026-08-13T10:00:00.000Z', + }, + ]); render( { screen.getByTestId('schedule-calendar-scroll').props.contentContainerStyle, ), ).toMatchObject({ paddingBottom: 118, paddingTop: 0 }); + + fireEvent.press(screen.getByLabelText(/月13日$/)); + const lastRow = await screen.findByLabelText(/当日最后一条日程$/); + fireEvent.press(lastRow); + // 点开之后详情抽屉真的弹出来了,证明这一行不只是渲染出来、还真的可以点击响应, + // 不是被浮动语音条盖住了个摆设。 + expect(screen.getByText('时间日程')).toBeTruthy(); + }); + + it('avoids the Android status bar in edge-to-edge mode', async () => { + Platform.OS = 'android'; + mockTopInset = 24; + const service = createService(); + render( + {}} + service={service} + timezone="Asia/Shanghai" + username="Sarah" + />, + ); + + await waitFor(() => expect(service.getLocationSchedules).toHaveBeenCalled()); + expect( + StyleSheet.flatten( + screen.getByTestId('schedule-calendar-scroll').props.contentContainerStyle, + ), + ).toMatchObject({ paddingTop: 24 }); + }); + + it('leaves iOS to its own automatic safe-area adjustment instead of double-padding the top', async () => { + Platform.OS = 'ios'; + mockTopInset = 44; + const service = createService(); + render( + {}} + service={service} + timezone="Asia/Shanghai" + username="Sarah" + />, + ); + + await waitFor(() => expect(service.getLocationSchedules).toHaveBeenCalled()); + expect( + StyleSheet.flatten( + screen.getByTestId('schedule-calendar-scroll').props.contentContainerStyle, + ), + ).toMatchObject({ paddingTop: 0 }); }); it('keeps accountId in the calendar data flow without rendering it', async () => { From a1a16e8b0ba60a30be945af74679429f0517b1f6 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Thu, 20 Aug 2026 22:19:36 +0800 Subject: [PATCH 07/10] test(schedule): update detail sheet assertion after main rebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 摆脱旧的固定类型标签「时间日程」(main 上 abd8193 已改为统一的 ScheduleDetailSheet,标题变成 occurrence 自身 title),改为断言 稳定存在的「日程详情」eyebrow 文案。 --- .../schedule/presentation/ScheduleCalendarScreen.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx index dd0b7b8d..679924e9 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx @@ -124,7 +124,7 @@ describe('ScheduleCalendarScreen location schedules', () => { fireEvent.press(lastRow); // 点开之后详情抽屉真的弹出来了,证明这一行不只是渲染出来、还真的可以点击响应, // 不是被浮动语音条盖住了个摆设。 - expect(screen.getByText('时间日程')).toBeTruthy(); + expect(screen.getByText('日程详情')).toBeTruthy(); }); it('avoids the Android status bar in edge-to-edge mode', async () => { From 40b62b7693833f0eceffdc1b986170d48bd87c65 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Thu, 20 Aug 2026 23:40:27 +0800 Subject: [PATCH 08/10] fix(assistant): keep call history fully visible while streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 流式回复每次刷新都会强制 scrollToEnd,用户中途上滑会被立刻拽回底 部,看起来像滑不动;onContentSizeChange 触发时原生 ScrollView 有 时还没提交新内容高度,内容越长越容易滚不到真正的底。 - 只在用户本来就贴底时才自动跟随(用 onScrollEndDrag/ onMomentumScrollEnd 判断,避免被自己触发的 onScroll 事件误判成 用户划走) - 自动滚动延后两帧、改用 animated:false,避免跟连续的内容变化互相 打断 - 用实测的状态行高度兜底 ScrollView 底部留白,保证最后一条记录不 会被状态行挡住 Co-Authored-By: Claude Sonnet 5 --- .../presentation/VoiceCallScreen.tsx | 44 +++++++++++++++++-- .../presentation/VoiceCallScreen.test.tsx | 12 +++-- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx b/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx index 3231a9a4..29398549 100644 --- a/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx +++ b/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; import { Animated, Easing, @@ -28,6 +29,7 @@ interface VoiceCallScreenProps { const BREATH_SCALE = { duration: 1600, from: 1, to: 1.06 }; const TALK_SCALE = { duration: 650, from: 1, to: 1.14 }; +const STICK_TO_BOTTOM_THRESHOLD = 48; /** * 免提通话的沉浸式全屏层:主体是一份可回看的完整问答记录(每轮一条用户话 @@ -47,7 +49,12 @@ export function VoiceCallScreen({ }: VoiceCallScreenProps) { const insets = useSafeAreaInsets(); const [scale] = useState(() => new Animated.Value(1)); + const [statusRowHeight, setStatusRowHeight] = useState(0); const historyRef = useRef(null); + // 回复是流式的(累计文字每收到一段就整段刷新一次),跟着一路自动滚会跟 + // 用户手动上滑打架——用户一滑走就不再强制拉回底部,直到他自己滑回底部 + // 附近才恢复跟随,不然会出现看似“滑不动”的情况。 + const stickToBottomRef = useRef(true); useEffect(() => { scale.stopAnimation(); @@ -77,6 +84,18 @@ export function VoiceCallScreen({ return undefined; }, [status, scale]); + // 只在用户真的拖动结束时才重新判断是否贴底——不能用 onScroll,流式内容 + // 一直在长,我们自己触发的 scrollToEnd 也会产生 onScroll 事件,那时候 + // contentSize 可能已经比滚动目标又长了一截,会被误判成“用户滑走了”, + // 之后就再也不会自动跟随,导致流式说完了还有一段没露出来。 + // onScrollEndDrag/onMomentumScrollEnd 只在手指真正划过之后才触发,不受 + // animated:false 的程序化跳转影响。 + function handleScrollSettled(event: NativeSyntheticEvent) { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + const distanceFromBottom = contentSize.height - contentOffset.y - layoutMeasurement.height; + stickToBottomRef.current = distanceFromBottom <= STICK_TO_BOTTOM_THRESHOLD; + } + return ( historyRef.current?.scrollToEnd({ animated: true })} + onContentSizeChange={() => { + if (!stickToBottomRef.current) { + return; + } + // 延后两帧再滚:Android 上 onContentSizeChange 触发时原生 ScrollView + // 有时还没把新内容高度提交完,内容越高时越容易滚不到底。用 + // animated:false 直接跳到底,流式刷新很密集,动画会跟下一次 + // 内容变化互相打断、显得卡住不动。 + requestAnimationFrame(() => + requestAnimationFrame(() => historyRef.current?.scrollToEnd({ animated: false })), + ); + }} + onMomentumScrollEnd={handleScrollSettled} + onScrollEndDrag={handleScrollSettled} style={styles.history} > {turns.length > 0 ? ( @@ -116,7 +152,10 @@ export function VoiceCallScreen({ )} - + setStatusRowHeight(event.nativeEvent.layout.height)} + style={styles.body} + > { expect(screen.getByText('对话开始后,这里会显示完整记录')).toBeTruthy(); }); - it('scrolls the history area when its content grows', () => { + it('scrolls the history area when its content grows', async () => { renderScreen({ turns: [{ id: 't1', replyText: null, transcript: '明天几点开会' }] }); const history = screen.UNSAFE_getByType(ScrollView); expect(history.props.onContentSizeChange).toEqual(expect.any(Function)); - history.props.onContentSizeChange(); + await act(async () => { + history.props.onContentSizeChange(); + // 滚动被延后了两帧,测试要等这两帧都跑完再收尾,不然回调会在 + // jest 环境已经卸载之后才触发。 + await new Promise((resolve) => requestAnimationFrame(resolve)); + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); }); }); From 6e97bcf20379ec7034500a2e04fea3ab16f923ac Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Thu, 20 Aug 2026 23:40:54 +0800 Subject: [PATCH 09/10] test(schedule): add missing category field to rebase-era test fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rebase 到最新 main 之后类型检查报错——main 上 ScheduleOccurrenceView 新增了必填的 category 字段,这里的测试 mock 数据是 rebase 时带过来 的,没跟上这个类型变化。 Co-Authored-By: Claude Sonnet 5 --- .../schedule/presentation/ScheduleCalendarScreen.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx index 679924e9..8f3166b0 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx @@ -91,6 +91,7 @@ describe('ScheduleCalendarScreen location schedules', () => { { scheduleId: 'schedule-last', scheduleCategory: 'time', + category: null, recurrenceMode: 'once', title: '当日最后一条日程', isAllDay: false, From 7fce26d714e23ab5510faaa9c8e2b8c5efa59c5d Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Thu, 20 Aug 2026 23:54:11 +0800 Subject: [PATCH 10/10] fix(assistant): attach dialogue replies to the turn they answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updateLastTurnReply 之前无脑写入 turns 数组的最后一条,没用服务端 其实一直带着的 request_id 做匹配。连续对话麦克风全程不关,如果上 一轮的回复还没到,用户已经开口问了下一句,asr.completed 先把新一 轮 push 进 turns、变成新的“最后一条”;这时候上一轮迟到的回复一到, 就被错误地记到了下一轮头上——下一轮自己的答案还没来,界面上先显 示的却是上一轮的内容。 改成按 request_id 匹配 turns 里对应 id 的那一轮,匹配不到(服务端 没带 request_id,理论上不该发生)时才退化成更新最后一轮。补了一个 复现这个时序的回归测试。 顺带把默认提醒时间从提前 200 分钟改成提前 15 分钟。 --- .../intelligence/realtime/instructions.py | 2 +- .../AssistantContinuousConversationService.ts | 22 +++++++----- ...stantContinuousConversationService.test.ts | 34 +++++++++++++++++++ 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/backend/src/timeflow/intelligence/realtime/instructions.py b/backend/src/timeflow/intelligence/realtime/instructions.py index 6b70ffdb..0640fbf6 100644 --- a/backend/src/timeflow/intelligence/realtime/instructions.py +++ b/backend/src/timeflow/intelligence/realtime/instructions.py @@ -45,7 +45,7 @@ - 没提到地点,就当时间型日程处理,不要为了填 latitude/longitude 去调用 location_search 或编一个地点——地点型日程的地点仍然必须问清楚,这条只管时间型日程不要凭空加地点。 - 没说提醒方式,默认建一个 reminder_strength 为 medium 的提醒:非全天日程用 - reminder_type=before_start、reminder_offset_minutes=200(开始前 200 分钟);全天日程用 + reminder_type=before_start、reminder_offset_minutes=15(开始前 15 分钟);全天日程用 reminder_type=at_time、reminder_trigger_at 填当天上午 10:00(带时区偏移)。 地点怎么定 diff --git a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts index 32850331..86a775c2 100644 --- a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts +++ b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts @@ -393,7 +393,7 @@ export class AssistantContinuousConversationService implements AssistantApplicat case 'voice.dialogue.question': // 缺字段/地点歧义之类的追问,对当前这轮来说就是系统的回复——记进历史, // 不然标题过了这一阵子就变回通用文案,这句追问在记录里再也找不到。 - this.updateLastTurnReply(message.payload.speech_text); + this.updateLastTurnReply(message.request_id, message.payload.speech_text); this.setState({ conversationId: message.conversation_id, phase: 'asking', @@ -402,7 +402,7 @@ export class AssistantContinuousConversationService implements AssistantApplicat return; case 'voice.dialogue.reply': this.replyText = message.payload.speech_text; - this.updateLastTurnReply(message.payload.speech_text); + this.updateLastTurnReply(message.request_id, message.payload.speech_text); this.notifyListeners(); return; case 'voice.tts.start': @@ -594,15 +594,21 @@ export class AssistantContinuousConversationService implements AssistantApplicat return this.connection; } - /** speech_text 是累计到目前为止的完整文字(不是增量),直接覆盖最后一轮即可。 - * 没有轮次可更新时(理论上不会发生,reply 总跟在 asr.completed 后面)不做 - * 任何事,不新建一条没有 transcript 的记录。 */ - private updateLastTurnReply(replyText: string): void { + /** speech_text 是累计到目前为止的完整文字(不是增量),直接覆盖对应轮次即可。 + * 必须按 request_id 找到它真正所属的那一轮,不能想当然地假设"最后一轮"—— + * 麦克风连续开着,用户可能在上一轮的回复还没到达前就已经开口问了下一句, + * asr.completed 先把新一轮 push 进 turns,这条迟到的回复到达时"最后一轮" + * 已经变成了下一轮,会把上一轮的回复错记成下一轮的(下一轮回复还没来时 + * 显示的会是上一轮内容)。找不到匹配 id(服务端没带 request_id 这种理论上 + * 不该发生的情况)时退化成更新最后一轮,好歹不丢内容。 */ + private updateLastTurnReply(requestId: string | undefined, replyText: string): void { if (this.turns.length === 0) { return; } - const last = this.turns[this.turns.length - 1]; - this.turns = [...this.turns.slice(0, -1), { ...last, replyText }]; + const targetIndex = + requestId !== undefined ? this.turns.findIndex((turn) => turn.id === requestId) : -1; + const index = targetIndex === -1 ? this.turns.length - 1 : targetIndex; + this.turns = this.turns.map((turn, i) => (i === index ? { ...turn, replyText } : turn)); } private armIdleTimer(): void { diff --git a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts index aff2c91d..9a1d105f 100644 --- a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts +++ b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts @@ -254,6 +254,40 @@ describe('AssistantContinuousConversationService', () => { ]); }); + it('attaches a late reply to the turn it answers, not whichever turn is now last', async () => { + const fake = createFakeConnection(); + const deps = createDeps({ connection: fake.connection }); + const service = createService(deps); + + await startListening(fake, service); + fake.emitMessage({ + conversation_id: 'conv_001', + request_id: 'req_1', + payload: { duration_ms: 800, language: 'zh', transcript: '明天几点开会' }, + type: 'voice.asr.completed', + } as AssistantServerMessage); + // 用户没等 req_1 的回复就开口问了下一句,新一轮先落地成了 turns 里最后一条。 + fake.emitMessage({ + conversation_id: 'conv_001', + request_id: 'req_2', + payload: { duration_ms: 500, language: 'zh', transcript: '谁参加' }, + type: 'voice.asr.completed', + } as AssistantServerMessage); + // req_1 的回复这时候才迟到到达。 + fake.emitMessage({ + conversation_id: 'conv_001', + request_id: 'req_1', + payload: { done: true, reply_id: 'reply_1', speech_text: '明天下午三点' }, + type: 'voice.dialogue.reply', + } as AssistantServerMessage); + await flushAsync(); + + expect(service.getTurns()).toEqual([ + { id: 'req_1', replyText: '明天下午三点', transcript: '明天几点开会' }, + { id: 'req_2', replyText: null, transcript: '谁参加' }, + ]); + }); + it.each([ ['missing_field', '你是想订哪一天的会议室?'], ['ambiguous_target', '你是指三楼小会议室还是五楼大会议室?'],