Skip to content

Commit e0894d7

Browse files
author
Ryan Vogel
committed
feat(mobile-voice): add swipeable prompt history pager in transcription panel
Swipe right-to-left on the transcription area to browse previous prompts sent in the current session. Includes haptic feedback on page snap, auto-snap to live page on record start, and layout-measured page width for correct alignment.
1 parent cd3a58a commit e0894d7

2 files changed

Lines changed: 282 additions & 52 deletions

File tree

packages/mobile-voice/src/app/index.tsx

Lines changed: 198 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
View,
66
Pressable,
77
ScrollView,
8+
FlatList,
89
Modal,
910
Alert,
1011
ActivityIndicator,
@@ -38,7 +39,12 @@ import { fetch as expoFetch } from "expo/fetch"
3839
import { buildPermissionCardModel } from "@/lib/pending-permissions"
3940
import { unregisterRelayDevice } from "@/lib/relay-client"
4041
import { useMdnsDiscovery } from "@/hooks/use-mdns-discovery"
41-
import { useMonitoring, type MonitorJob, type PermissionDecision } from "@/hooks/use-monitoring"
42+
import {
43+
useMonitoring,
44+
type MonitorJob,
45+
type PermissionDecision,
46+
type PromptHistoryEntry,
47+
} from "@/hooks/use-monitoring"
4248
import { DEFAULT_RELAY_URL, looksLikeLocalHost, useServerSessions } from "@/hooks/use-server-sessions"
4349
import { ensureNotificationPermissions, getDevicePushToken } from "@/notifications/monitoring-notifications"
4450

@@ -728,6 +734,8 @@ export default function DictationScreen() {
728734
const scanLockRef = useRef(false)
729735
const pairProbeRunRef = useRef(0)
730736
const whisperRestoredRef = useRef(false)
737+
const promptPagerRef = useRef<FlatList<PromptHistoryEntry | "live">>(null)
738+
const promptPagerPageRef = useRef(-1)
731739

732740
const closeDropdown = useCallback(() => {
733741
setDropdownMode("none")
@@ -766,13 +774,17 @@ export default function DictationScreen() {
766774
activePermissionRequest,
767775
devicePushToken,
768776
latestAssistantContext,
777+
latestPromptText,
769778
latestAssistantResponse,
770779
monitorJob,
771780
monitorStatus,
772781
pendingPermissionCount,
782+
promptHistory,
773783
respondingPermissionID,
774784
respondToPermission,
775785
setDevicePushToken,
786+
setLatestPromptText,
787+
setPromptHistory,
776788
setMonitorStatus,
777789
} = useMonitoring({
778790
completePlayer,
@@ -1766,6 +1778,8 @@ export default function DictationScreen() {
17661778
throw new Error(`Prompt request failed (${response.status})`)
17671779
}
17681780

1781+
setLatestPromptText(text)
1782+
17691783
const nextJob: MonitorJob = {
17701784
id: `job-${Date.now()}`,
17711785
sessionID: session.id,
@@ -1813,6 +1827,7 @@ export default function DictationScreen() {
18131827
isSending,
18141828
serversRef,
18151829
setMonitorStatus,
1830+
setLatestPromptText,
18161831
sendOutProgress,
18171832
sendPlayer,
18181833
transcribedText,
@@ -1828,6 +1843,14 @@ export default function DictationScreen() {
18281843
setDropdownMode("none")
18291844
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {})
18301845
isHoldingRef.current = true
1846+
// Snap pager to live page (index 0) so user sees their transcription
1847+
if (promptPagerRef.current) {
1848+
try {
1849+
promptPagerRef.current.scrollToIndex({ index: 0, animated: true })
1850+
} catch {
1851+
// FlatList may not have items yet
1852+
}
1853+
}
18311854
void startRecording()
18321855
}, [startRecording])
18331856

@@ -1910,6 +1933,32 @@ export default function DictationScreen() {
19101933
const isReplyingToActivePermission =
19111934
activePermissionRequest !== null && respondingPermissionID === activePermissionRequest.id
19121935
const displayedTranscript = isSending ? "" : transcribedText
1936+
const [transcriptionPanelWidth, setTranscriptionPanelWidth] = useState(0)
1937+
const handleTranscriptionPanelLayout = useCallback((e: LayoutChangeEvent) => {
1938+
setTranscriptionPanelWidth(e.nativeEvent.layout.width)
1939+
}, [])
1940+
const pagerPageWidth = transcriptionPanelWidth || 1
1941+
1942+
// Prompt history pager: "live" at index 0 (leftmost), then history newest-first to the right.
1943+
// Swipe right-to-left to browse older prompts, swipe left-to-right to return to live.
1944+
const promptPagerData = useMemo<(PromptHistoryEntry | "live")[]>(
1945+
() => (promptHistory.length > 0 ? ["live" as const, ...[...promptHistory].reverse()] : []),
1946+
[promptHistory],
1947+
)
1948+
const promptPagerKeyExtractor = useCallback(
1949+
(item: PromptHistoryEntry | "live") => (item === "live" ? "live" : item.userMessageID),
1950+
[],
1951+
)
1952+
const handlePromptPagerSnap = useCallback(
1953+
(e: { nativeEvent: { contentOffset: { x: number } } }) => {
1954+
const pageIndex = Math.round(e.nativeEvent.contentOffset.x / pagerPageWidth)
1955+
if (pageIndex !== promptPagerPageRef.current) {
1956+
promptPagerPageRef.current = pageIndex
1957+
void Haptics.selectionAsync().catch(() => {})
1958+
}
1959+
},
1960+
[pagerPageWidth],
1961+
)
19131962
const isDropdownOpen = dropdownMode !== "none"
19141963
const effectiveDropdownMode = isDropdownOpen ? dropdownMode : dropdownRenderMode
19151964
const isCreatingSession = sessionCreateMode !== null
@@ -2768,7 +2817,7 @@ export default function DictationScreen() {
27682817
body: "Control only listens while you hold the record button.",
27692818
primaryLabel: microphonePermissionState === "pending" ? "Requesting microphone access..." : "Continue",
27702819
primaryDisabled: microphonePermissionState === "pending",
2771-
secondaryLabel: "Continue without granting",
2820+
secondaryLabel: undefined,
27722821
visualTag: "MIC",
27732822
visualSurfaceStyle: styles.onboardingVisualSurfaceMic,
27742823
visualOrbStyle: styles.onboardingVisualOrbMic,
@@ -2779,7 +2828,7 @@ export default function DictationScreen() {
27792828
body: "Get alerts when your OpenCode run finishes, fails, or needs your attention.",
27802829
primaryLabel: notificationPermissionState === "pending" ? "Requesting notification access..." : "Continue",
27812830
primaryDisabled: notificationPermissionState === "pending",
2782-
secondaryLabel: "Continue without granting",
2831+
secondaryLabel: undefined,
27832832
visualTag: "PUSH",
27842833
visualSurfaceStyle: styles.onboardingVisualSurfaceNotifications,
27852834
visualOrbStyle: styles.onboardingVisualOrbNotifications,
@@ -2790,7 +2839,7 @@ export default function DictationScreen() {
27902839
body: "This lets Control discover your machine on the same network.",
27912840
primaryLabel: localNetworkPermissionState === "pending" ? "Requesting local network access..." : "Continue",
27922841
primaryDisabled: localNetworkPermissionState === "pending",
2793-
secondaryLabel: "Continue without granting",
2842+
secondaryLabel: undefined,
27942843
visualTag: "LAN",
27952844
visualSurfaceStyle: styles.onboardingVisualSurfaceNetwork,
27962845
visualOrbStyle: styles.onboardingVisualOrbNetwork,
@@ -2918,19 +2967,21 @@ export default function DictationScreen() {
29182967
/>
29192968
</Pressable>
29202969

2921-
<Pressable
2922-
onPress={() => {
2923-
if (clampedOnboardingStep < onboardingStepCount - 1) {
2924-
setOnboardingStep((step) => Math.min(step + 1, onboardingStepCount - 1))
2925-
return
2926-
}
2927-
2928-
completeOnboarding(false)
2929-
}}
2930-
style={({ pressed }) => [styles.onboardingSecondaryButton, pressed && styles.clearButtonPressed]}
2931-
>
2932-
<Text style={styles.onboardingSecondaryText}>{onboardingSecondaryLabel}</Text>
2933-
</Pressable>
2970+
{onboardingSecondaryLabel ? (
2971+
<Pressable
2972+
onPress={() => {
2973+
if (clampedOnboardingStep < onboardingStepCount - 1) {
2974+
setOnboardingStep((step) => Math.min(step + 1, onboardingStepCount - 1))
2975+
return
2976+
}
2977+
2978+
completeOnboarding(false)
2979+
}}
2980+
style={({ pressed }) => [styles.onboardingSecondaryButton, pressed && styles.clearButtonPressed]}
2981+
>
2982+
<Text style={styles.onboardingSecondaryText}>{onboardingSecondaryLabel}</Text>
2983+
</Pressable>
2984+
) : null}
29342985
</View>
29352986
</View>
29362987
</SafeAreaView>
@@ -3335,7 +3386,7 @@ export default function DictationScreen() {
33353386
</ScrollView>
33363387
</View>
33373388

3338-
<View style={styles.transcriptionPanel}>
3389+
<View style={styles.transcriptionPanel} onLayout={handleTranscriptionPanelLayout}>
33393390
<View style={styles.transcriptionTopActions} pointerEvents="box-none">
33403391
<Pressable
33413392
onPress={handleOpenWhisperSettings}
@@ -3364,20 +3415,66 @@ export default function DictationScreen() {
33643415
</View>
33653416
) : null}
33663417

3367-
<ScrollView
3368-
ref={scrollViewRef}
3369-
style={styles.transcriptionScroll}
3370-
contentContainerStyle={styles.transcriptionContent}
3371-
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
3372-
>
3373-
<Animated.View style={animatedTranscriptSendStyle}>
3374-
{displayedTranscript ? (
3375-
<Text style={styles.transcriptionText}>{displayedTranscript}</Text>
3376-
) : isSending ? null : (
3377-
<Text style={styles.placeholderText}>Your transcription will appear here…</Text>
3378-
)}
3379-
</Animated.View>
3380-
</ScrollView>
3418+
{promptPagerData.length > 1 ? (
3419+
<FlatList
3420+
ref={promptPagerRef}
3421+
data={promptPagerData}
3422+
keyExtractor={promptPagerKeyExtractor}
3423+
horizontal
3424+
pagingEnabled
3425+
bounces={false}
3426+
showsHorizontalScrollIndicator={false}
3427+
onMomentumScrollEnd={handlePromptPagerSnap}
3428+
initialScrollIndex={0}
3429+
getItemLayout={(_data, index) => ({
3430+
length: pagerPageWidth,
3431+
offset: pagerPageWidth * index,
3432+
index,
3433+
})}
3434+
style={styles.transcriptionScroll}
3435+
renderItem={({ item }) =>
3436+
item === "live" ? (
3437+
<ScrollView
3438+
ref={scrollViewRef}
3439+
style={{ width: pagerPageWidth }}
3440+
contentContainerStyle={styles.transcriptionContent}
3441+
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
3442+
>
3443+
<Animated.View style={animatedTranscriptSendStyle}>
3444+
{displayedTranscript ? (
3445+
<Text style={styles.transcriptionText}>{displayedTranscript}</Text>
3446+
) : isSending ? null : (
3447+
<Text style={styles.placeholderText}>Your transcription will appear here…</Text>
3448+
)}
3449+
</Animated.View>
3450+
</ScrollView>
3451+
) : (
3452+
<ScrollView
3453+
style={{ width: pagerPageWidth }}
3454+
contentContainerStyle={styles.transcriptionContent}
3455+
>
3456+
<Text style={styles.promptHistoryLabel}>Previous prompt</Text>
3457+
<Text style={styles.promptHistoryText}>{item.promptText}</Text>
3458+
</ScrollView>
3459+
)
3460+
}
3461+
/>
3462+
) : (
3463+
<ScrollView
3464+
ref={scrollViewRef}
3465+
style={styles.transcriptionScroll}
3466+
contentContainerStyle={styles.transcriptionContent}
3467+
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
3468+
>
3469+
<Animated.View style={animatedTranscriptSendStyle}>
3470+
{displayedTranscript ? (
3471+
<Text style={styles.transcriptionText}>{displayedTranscript}</Text>
3472+
) : isSending ? null : (
3473+
<Text style={styles.placeholderText}>Your transcription will appear here…</Text>
3474+
)}
3475+
</Animated.View>
3476+
</ScrollView>
3477+
)}
33813478

33823479
<Animated.View
33833480
style={[styles.waveformBoxesRow, animatedWaveformRowStyle]}
@@ -3451,7 +3548,7 @@ export default function DictationScreen() {
34513548
) : null}
34523549
</>
34533550
) : (
3454-
<View style={styles.transcriptionPanel}>
3551+
<View style={styles.transcriptionPanel} onLayout={handleTranscriptionPanelLayout}>
34553552
<View style={styles.transcriptionTopActions} pointerEvents="box-none">
34563553
<Pressable
34573554
onPress={handleOpenWhisperSettings}
@@ -3480,20 +3577,59 @@ export default function DictationScreen() {
34803577
</View>
34813578
) : null}
34823579

3483-
<ScrollView
3484-
ref={scrollViewRef}
3485-
style={styles.transcriptionScroll}
3486-
contentContainerStyle={styles.transcriptionContent}
3487-
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
3488-
>
3489-
<Animated.View style={animatedTranscriptSendStyle}>
3490-
{displayedTranscript ? (
3491-
<Text style={styles.transcriptionText}>{displayedTranscript}</Text>
3492-
) : isSending ? null : (
3493-
<Text style={styles.placeholderText}>Your transcription will appear here…</Text>
3494-
)}
3495-
</Animated.View>
3496-
</ScrollView>
3580+
{promptPagerData.length > 1 ? (
3581+
<FlatList
3582+
ref={promptPagerRef}
3583+
data={promptPagerData}
3584+
keyExtractor={promptPagerKeyExtractor}
3585+
horizontal
3586+
pagingEnabled
3587+
bounces={false}
3588+
showsHorizontalScrollIndicator={false}
3589+
onMomentumScrollEnd={handlePromptPagerSnap}
3590+
initialScrollIndex={0}
3591+
getItemLayout={(_data, index) => ({ length: pagerPageWidth, offset: pagerPageWidth * index, index })}
3592+
style={styles.transcriptionScroll}
3593+
renderItem={({ item }) =>
3594+
item === "live" ? (
3595+
<ScrollView
3596+
ref={scrollViewRef}
3597+
style={{ width: pagerPageWidth }}
3598+
contentContainerStyle={styles.transcriptionContent}
3599+
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
3600+
>
3601+
<Animated.View style={animatedTranscriptSendStyle}>
3602+
{displayedTranscript ? (
3603+
<Text style={styles.transcriptionText}>{displayedTranscript}</Text>
3604+
) : isSending ? null : (
3605+
<Text style={styles.placeholderText}>Your transcription will appear here…</Text>
3606+
)}
3607+
</Animated.View>
3608+
</ScrollView>
3609+
) : (
3610+
<ScrollView style={{ width: pagerPageWidth }} contentContainerStyle={styles.transcriptionContent}>
3611+
<Text style={styles.promptHistoryLabel}>Previous prompt</Text>
3612+
<Text style={styles.promptHistoryText}>{item.promptText}</Text>
3613+
</ScrollView>
3614+
)
3615+
}
3616+
/>
3617+
) : (
3618+
<ScrollView
3619+
ref={scrollViewRef}
3620+
style={styles.transcriptionScroll}
3621+
contentContainerStyle={styles.transcriptionContent}
3622+
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
3623+
>
3624+
<Animated.View style={animatedTranscriptSendStyle}>
3625+
{displayedTranscript ? (
3626+
<Text style={styles.transcriptionText}>{displayedTranscript}</Text>
3627+
) : isSending ? null : (
3628+
<Text style={styles.placeholderText}>Your transcription will appear here…</Text>
3629+
)}
3630+
</Animated.View>
3631+
</ScrollView>
3632+
)}
34973633

34983634
<Animated.View
34993635
style={[styles.waveformBoxesRow, animatedWaveformRowStyle]}
@@ -4784,8 +4920,23 @@ const styles = StyleSheet.create({
47844920
right: 10,
47854921
zIndex: 4,
47864922
flexDirection: "row",
4923+
alignItems: "flex-start",
47874924
justifyContent: "space-between",
47884925
},
4926+
promptHistoryLabel: {
4927+
color: "#6B7A99",
4928+
fontSize: 13,
4929+
fontWeight: "700",
4930+
letterSpacing: 0.6,
4931+
textTransform: "uppercase",
4932+
marginBottom: 8,
4933+
},
4934+
promptHistoryText: {
4935+
fontSize: 24,
4936+
fontWeight: "500",
4937+
lineHeight: 34,
4938+
color: "#8B96AD",
4939+
},
47894940
modelErrorBadge: {
47904941
alignSelf: "flex-start",
47914942
marginLeft: 14,

0 commit comments

Comments
 (0)