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.
This commit is contained in:
Ryan Vogel 2026-04-04 18:59:02 +00:00
commit e0894d7b63
2 changed files with 282 additions and 52 deletions

View file

@ -5,6 +5,7 @@ import {
View, View,
Pressable, Pressable,
ScrollView, ScrollView,
FlatList,
Modal, Modal,
Alert, Alert,
ActivityIndicator, ActivityIndicator,
@ -38,7 +39,12 @@ import { fetch as expoFetch } from "expo/fetch"
import { buildPermissionCardModel } from "@/lib/pending-permissions" import { buildPermissionCardModel } from "@/lib/pending-permissions"
import { unregisterRelayDevice } from "@/lib/relay-client" import { unregisterRelayDevice } from "@/lib/relay-client"
import { useMdnsDiscovery } from "@/hooks/use-mdns-discovery" import { useMdnsDiscovery } from "@/hooks/use-mdns-discovery"
import { useMonitoring, type MonitorJob, type PermissionDecision } from "@/hooks/use-monitoring" import {
useMonitoring,
type MonitorJob,
type PermissionDecision,
type PromptHistoryEntry,
} from "@/hooks/use-monitoring"
import { DEFAULT_RELAY_URL, looksLikeLocalHost, useServerSessions } from "@/hooks/use-server-sessions" import { DEFAULT_RELAY_URL, looksLikeLocalHost, useServerSessions } from "@/hooks/use-server-sessions"
import { ensureNotificationPermissions, getDevicePushToken } from "@/notifications/monitoring-notifications" import { ensureNotificationPermissions, getDevicePushToken } from "@/notifications/monitoring-notifications"
@ -728,6 +734,8 @@ export default function DictationScreen() {
const scanLockRef = useRef(false) const scanLockRef = useRef(false)
const pairProbeRunRef = useRef(0) const pairProbeRunRef = useRef(0)
const whisperRestoredRef = useRef(false) const whisperRestoredRef = useRef(false)
const promptPagerRef = useRef<FlatList<PromptHistoryEntry | "live">>(null)
const promptPagerPageRef = useRef(-1)
const closeDropdown = useCallback(() => { const closeDropdown = useCallback(() => {
setDropdownMode("none") setDropdownMode("none")
@ -766,13 +774,17 @@ export default function DictationScreen() {
activePermissionRequest, activePermissionRequest,
devicePushToken, devicePushToken,
latestAssistantContext, latestAssistantContext,
latestPromptText,
latestAssistantResponse, latestAssistantResponse,
monitorJob, monitorJob,
monitorStatus, monitorStatus,
pendingPermissionCount, pendingPermissionCount,
promptHistory,
respondingPermissionID, respondingPermissionID,
respondToPermission, respondToPermission,
setDevicePushToken, setDevicePushToken,
setLatestPromptText,
setPromptHistory,
setMonitorStatus, setMonitorStatus,
} = useMonitoring({ } = useMonitoring({
completePlayer, completePlayer,
@ -1766,6 +1778,8 @@ export default function DictationScreen() {
throw new Error(`Prompt request failed (${response.status})`) throw new Error(`Prompt request failed (${response.status})`)
} }
setLatestPromptText(text)
const nextJob: MonitorJob = { const nextJob: MonitorJob = {
id: `job-${Date.now()}`, id: `job-${Date.now()}`,
sessionID: session.id, sessionID: session.id,
@ -1813,6 +1827,7 @@ export default function DictationScreen() {
isSending, isSending,
serversRef, serversRef,
setMonitorStatus, setMonitorStatus,
setLatestPromptText,
sendOutProgress, sendOutProgress,
sendPlayer, sendPlayer,
transcribedText, transcribedText,
@ -1828,6 +1843,14 @@ export default function DictationScreen() {
setDropdownMode("none") setDropdownMode("none")
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}) void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {})
isHoldingRef.current = true isHoldingRef.current = true
// Snap pager to live page (index 0) so user sees their transcription
if (promptPagerRef.current) {
try {
promptPagerRef.current.scrollToIndex({ index: 0, animated: true })
} catch {
// FlatList may not have items yet
}
}
void startRecording() void startRecording()
}, [startRecording]) }, [startRecording])
@ -1910,6 +1933,32 @@ export default function DictationScreen() {
const isReplyingToActivePermission = const isReplyingToActivePermission =
activePermissionRequest !== null && respondingPermissionID === activePermissionRequest.id activePermissionRequest !== null && respondingPermissionID === activePermissionRequest.id
const displayedTranscript = isSending ? "" : transcribedText const displayedTranscript = isSending ? "" : transcribedText
const [transcriptionPanelWidth, setTranscriptionPanelWidth] = useState(0)
const handleTranscriptionPanelLayout = useCallback((e: LayoutChangeEvent) => {
setTranscriptionPanelWidth(e.nativeEvent.layout.width)
}, [])
const pagerPageWidth = transcriptionPanelWidth || 1
// Prompt history pager: "live" at index 0 (leftmost), then history newest-first to the right.
// Swipe right-to-left to browse older prompts, swipe left-to-right to return to live.
const promptPagerData = useMemo<(PromptHistoryEntry | "live")[]>(
() => (promptHistory.length > 0 ? ["live" as const, ...[...promptHistory].reverse()] : []),
[promptHistory],
)
const promptPagerKeyExtractor = useCallback(
(item: PromptHistoryEntry | "live") => (item === "live" ? "live" : item.userMessageID),
[],
)
const handlePromptPagerSnap = useCallback(
(e: { nativeEvent: { contentOffset: { x: number } } }) => {
const pageIndex = Math.round(e.nativeEvent.contentOffset.x / pagerPageWidth)
if (pageIndex !== promptPagerPageRef.current) {
promptPagerPageRef.current = pageIndex
void Haptics.selectionAsync().catch(() => {})
}
},
[pagerPageWidth],
)
const isDropdownOpen = dropdownMode !== "none" const isDropdownOpen = dropdownMode !== "none"
const effectiveDropdownMode = isDropdownOpen ? dropdownMode : dropdownRenderMode const effectiveDropdownMode = isDropdownOpen ? dropdownMode : dropdownRenderMode
const isCreatingSession = sessionCreateMode !== null const isCreatingSession = sessionCreateMode !== null
@ -2768,7 +2817,7 @@ export default function DictationScreen() {
body: "Control only listens while you hold the record button.", body: "Control only listens while you hold the record button.",
primaryLabel: microphonePermissionState === "pending" ? "Requesting microphone access..." : "Continue", primaryLabel: microphonePermissionState === "pending" ? "Requesting microphone access..." : "Continue",
primaryDisabled: microphonePermissionState === "pending", primaryDisabled: microphonePermissionState === "pending",
secondaryLabel: "Continue without granting", secondaryLabel: undefined,
visualTag: "MIC", visualTag: "MIC",
visualSurfaceStyle: styles.onboardingVisualSurfaceMic, visualSurfaceStyle: styles.onboardingVisualSurfaceMic,
visualOrbStyle: styles.onboardingVisualOrbMic, visualOrbStyle: styles.onboardingVisualOrbMic,
@ -2779,7 +2828,7 @@ export default function DictationScreen() {
body: "Get alerts when your OpenCode run finishes, fails, or needs your attention.", body: "Get alerts when your OpenCode run finishes, fails, or needs your attention.",
primaryLabel: notificationPermissionState === "pending" ? "Requesting notification access..." : "Continue", primaryLabel: notificationPermissionState === "pending" ? "Requesting notification access..." : "Continue",
primaryDisabled: notificationPermissionState === "pending", primaryDisabled: notificationPermissionState === "pending",
secondaryLabel: "Continue without granting", secondaryLabel: undefined,
visualTag: "PUSH", visualTag: "PUSH",
visualSurfaceStyle: styles.onboardingVisualSurfaceNotifications, visualSurfaceStyle: styles.onboardingVisualSurfaceNotifications,
visualOrbStyle: styles.onboardingVisualOrbNotifications, visualOrbStyle: styles.onboardingVisualOrbNotifications,
@ -2790,7 +2839,7 @@ export default function DictationScreen() {
body: "This lets Control discover your machine on the same network.", body: "This lets Control discover your machine on the same network.",
primaryLabel: localNetworkPermissionState === "pending" ? "Requesting local network access..." : "Continue", primaryLabel: localNetworkPermissionState === "pending" ? "Requesting local network access..." : "Continue",
primaryDisabled: localNetworkPermissionState === "pending", primaryDisabled: localNetworkPermissionState === "pending",
secondaryLabel: "Continue without granting", secondaryLabel: undefined,
visualTag: "LAN", visualTag: "LAN",
visualSurfaceStyle: styles.onboardingVisualSurfaceNetwork, visualSurfaceStyle: styles.onboardingVisualSurfaceNetwork,
visualOrbStyle: styles.onboardingVisualOrbNetwork, visualOrbStyle: styles.onboardingVisualOrbNetwork,
@ -2918,19 +2967,21 @@ export default function DictationScreen() {
/> />
</Pressable> </Pressable>
<Pressable {onboardingSecondaryLabel ? (
onPress={() => { <Pressable
if (clampedOnboardingStep < onboardingStepCount - 1) { onPress={() => {
setOnboardingStep((step) => Math.min(step + 1, onboardingStepCount - 1)) if (clampedOnboardingStep < onboardingStepCount - 1) {
return setOnboardingStep((step) => Math.min(step + 1, onboardingStepCount - 1))
} return
}
completeOnboarding(false) completeOnboarding(false)
}} }}
style={({ pressed }) => [styles.onboardingSecondaryButton, pressed && styles.clearButtonPressed]} style={({ pressed }) => [styles.onboardingSecondaryButton, pressed && styles.clearButtonPressed]}
> >
<Text style={styles.onboardingSecondaryText}>{onboardingSecondaryLabel}</Text> <Text style={styles.onboardingSecondaryText}>{onboardingSecondaryLabel}</Text>
</Pressable> </Pressable>
) : null}
</View> </View>
</View> </View>
</SafeAreaView> </SafeAreaView>
@ -3335,7 +3386,7 @@ export default function DictationScreen() {
</ScrollView> </ScrollView>
</View> </View>
<View style={styles.transcriptionPanel}> <View style={styles.transcriptionPanel} onLayout={handleTranscriptionPanelLayout}>
<View style={styles.transcriptionTopActions} pointerEvents="box-none"> <View style={styles.transcriptionTopActions} pointerEvents="box-none">
<Pressable <Pressable
onPress={handleOpenWhisperSettings} onPress={handleOpenWhisperSettings}
@ -3364,20 +3415,66 @@ export default function DictationScreen() {
</View> </View>
) : null} ) : null}
<ScrollView {promptPagerData.length > 1 ? (
ref={scrollViewRef} <FlatList
style={styles.transcriptionScroll} ref={promptPagerRef}
contentContainerStyle={styles.transcriptionContent} data={promptPagerData}
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })} keyExtractor={promptPagerKeyExtractor}
> horizontal
<Animated.View style={animatedTranscriptSendStyle}> pagingEnabled
{displayedTranscript ? ( bounces={false}
<Text style={styles.transcriptionText}>{displayedTranscript}</Text> showsHorizontalScrollIndicator={false}
) : isSending ? null : ( onMomentumScrollEnd={handlePromptPagerSnap}
<Text style={styles.placeholderText}>Your transcription will appear here</Text> initialScrollIndex={0}
)} getItemLayout={(_data, index) => ({
</Animated.View> length: pagerPageWidth,
</ScrollView> offset: pagerPageWidth * index,
index,
})}
style={styles.transcriptionScroll}
renderItem={({ item }) =>
item === "live" ? (
<ScrollView
ref={scrollViewRef}
style={{ width: pagerPageWidth }}
contentContainerStyle={styles.transcriptionContent}
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
>
<Animated.View style={animatedTranscriptSendStyle}>
{displayedTranscript ? (
<Text style={styles.transcriptionText}>{displayedTranscript}</Text>
) : isSending ? null : (
<Text style={styles.placeholderText}>Your transcription will appear here</Text>
)}
</Animated.View>
</ScrollView>
) : (
<ScrollView
style={{ width: pagerPageWidth }}
contentContainerStyle={styles.transcriptionContent}
>
<Text style={styles.promptHistoryLabel}>Previous prompt</Text>
<Text style={styles.promptHistoryText}>{item.promptText}</Text>
</ScrollView>
)
}
/>
) : (
<ScrollView
ref={scrollViewRef}
style={styles.transcriptionScroll}
contentContainerStyle={styles.transcriptionContent}
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
>
<Animated.View style={animatedTranscriptSendStyle}>
{displayedTranscript ? (
<Text style={styles.transcriptionText}>{displayedTranscript}</Text>
) : isSending ? null : (
<Text style={styles.placeholderText}>Your transcription will appear here</Text>
)}
</Animated.View>
</ScrollView>
)}
<Animated.View <Animated.View
style={[styles.waveformBoxesRow, animatedWaveformRowStyle]} style={[styles.waveformBoxesRow, animatedWaveformRowStyle]}
@ -3451,7 +3548,7 @@ export default function DictationScreen() {
) : null} ) : null}
</> </>
) : ( ) : (
<View style={styles.transcriptionPanel}> <View style={styles.transcriptionPanel} onLayout={handleTranscriptionPanelLayout}>
<View style={styles.transcriptionTopActions} pointerEvents="box-none"> <View style={styles.transcriptionTopActions} pointerEvents="box-none">
<Pressable <Pressable
onPress={handleOpenWhisperSettings} onPress={handleOpenWhisperSettings}
@ -3480,20 +3577,59 @@ export default function DictationScreen() {
</View> </View>
) : null} ) : null}
<ScrollView {promptPagerData.length > 1 ? (
ref={scrollViewRef} <FlatList
style={styles.transcriptionScroll} ref={promptPagerRef}
contentContainerStyle={styles.transcriptionContent} data={promptPagerData}
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })} keyExtractor={promptPagerKeyExtractor}
> horizontal
<Animated.View style={animatedTranscriptSendStyle}> pagingEnabled
{displayedTranscript ? ( bounces={false}
<Text style={styles.transcriptionText}>{displayedTranscript}</Text> showsHorizontalScrollIndicator={false}
) : isSending ? null : ( onMomentumScrollEnd={handlePromptPagerSnap}
<Text style={styles.placeholderText}>Your transcription will appear here</Text> initialScrollIndex={0}
)} getItemLayout={(_data, index) => ({ length: pagerPageWidth, offset: pagerPageWidth * index, index })}
</Animated.View> style={styles.transcriptionScroll}
</ScrollView> renderItem={({ item }) =>
item === "live" ? (
<ScrollView
ref={scrollViewRef}
style={{ width: pagerPageWidth }}
contentContainerStyle={styles.transcriptionContent}
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
>
<Animated.View style={animatedTranscriptSendStyle}>
{displayedTranscript ? (
<Text style={styles.transcriptionText}>{displayedTranscript}</Text>
) : isSending ? null : (
<Text style={styles.placeholderText}>Your transcription will appear here</Text>
)}
</Animated.View>
</ScrollView>
) : (
<ScrollView style={{ width: pagerPageWidth }} contentContainerStyle={styles.transcriptionContent}>
<Text style={styles.promptHistoryLabel}>Previous prompt</Text>
<Text style={styles.promptHistoryText}>{item.promptText}</Text>
</ScrollView>
)
}
/>
) : (
<ScrollView
ref={scrollViewRef}
style={styles.transcriptionScroll}
contentContainerStyle={styles.transcriptionContent}
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
>
<Animated.View style={animatedTranscriptSendStyle}>
{displayedTranscript ? (
<Text style={styles.transcriptionText}>{displayedTranscript}</Text>
) : isSending ? null : (
<Text style={styles.placeholderText}>Your transcription will appear here</Text>
)}
</Animated.View>
</ScrollView>
)}
<Animated.View <Animated.View
style={[styles.waveformBoxesRow, animatedWaveformRowStyle]} style={[styles.waveformBoxesRow, animatedWaveformRowStyle]}
@ -4784,8 +4920,23 @@ const styles = StyleSheet.create({
right: 10, right: 10,
zIndex: 4, zIndex: 4,
flexDirection: "row", flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between", justifyContent: "space-between",
}, },
promptHistoryLabel: {
color: "#6B7A99",
fontSize: 13,
fontWeight: "700",
letterSpacing: 0.6,
textTransform: "uppercase",
marginBottom: 8,
},
promptHistoryText: {
fontSize: 24,
fontWeight: "500",
lineHeight: 34,
color: "#8B96AD",
},
modelErrorBadge: { modelErrorBadge: {
alignSelf: "flex-start", alignSelf: "flex-start",
marginLeft: 14, marginLeft: 14,

View file

@ -40,6 +40,11 @@ export type MonitorJob = {
export type PermissionDecision = "once" | "always" | "reject" export type PermissionDecision = "once" | "always" | "reject"
export type PromptHistoryEntry = {
promptText: string
userMessageID: string
}
type SessionRuntimeStatus = "idle" | "busy" | "retry" type SessionRuntimeStatus = "idle" | "busy" | "retry"
type PermissionPromptState = "idle" | "pending" | "granted" | "denied" type PermissionPromptState = "idle" | "pending" | "granted" | "denied"
@ -121,6 +126,8 @@ export function useMonitoring({
const [monitorJob, setMonitorJob] = useState<MonitorJob | null>(null) const [monitorJob, setMonitorJob] = useState<MonitorJob | null>(null)
const [monitorStatus, setMonitorStatus] = useState("") const [monitorStatus, setMonitorStatus] = useState("")
const [latestAssistantResponse, setLatestAssistantResponse] = useState("") const [latestAssistantResponse, setLatestAssistantResponse] = useState("")
const [latestPromptText, setLatestPromptText] = useState("")
const [promptHistory, setPromptHistory] = useState<PromptHistoryEntry[]>([])
const [latestAssistantContext, setLatestAssistantContext] = useState<LatestAssistantContext | null>(null) const [latestAssistantContext, setLatestAssistantContext] = useState<LatestAssistantContext | null>(null)
const [pendingPermissions, setPendingPermissions] = useState<PendingPermissionRequest[]>([]) const [pendingPermissions, setPendingPermissions] = useState<PendingPermissionRequest[]>([])
const [replyingPermissionID, setReplyingPermissionID] = useState<string | null>(null) const [replyingPermissionID, setReplyingPermissionID] = useState<string | null>(null)
@ -250,10 +257,14 @@ export function useMonitoring({
const payload = (await response.json()) as unknown const payload = (await response.json()) as unknown
const latest = findLatestAssistantCompletion(payload) const latest = findLatestAssistantCompletion(payload)
const promptText = findLatestUserPrompt(payload)
const history = buildPromptHistory(payload)
if (latestAssistantRequestRef.current !== requestID) return if (latestAssistantRequestRef.current !== requestID) return
if (activeSessionIdRef.current !== sessionID) return if (activeSessionIdRef.current !== sessionID) return
setLatestAssistantResponse(latest.text) setLatestAssistantResponse(latest.text)
setLatestPromptText(promptText)
setPromptHistory(history)
setLatestAssistantContext(latest.context) setLatestAssistantContext(latest.context)
if (latest.text) { if (latest.text) {
setAgentStateDismissed(false) setAgentStateDismissed(false)
@ -262,6 +273,8 @@ export function useMonitoring({
if (latestAssistantRequestRef.current !== requestID) return if (latestAssistantRequestRef.current !== requestID) return
if (activeSessionIdRef.current !== sessionID) return if (activeSessionIdRef.current !== sessionID) return
setLatestAssistantResponse("") setLatestAssistantResponse("")
setLatestPromptText("")
setPromptHistory([])
setLatestAssistantContext(null) setLatestAssistantContext(null)
} }
}, },
@ -446,6 +459,8 @@ export function useMonitoring({
useEffect(() => { useEffect(() => {
setLatestAssistantResponse("") setLatestAssistantResponse("")
setLatestPromptText("")
setPromptHistory([])
setLatestAssistantContext(null) setLatestAssistantContext(null)
setPendingPermissions([]) setPendingPermissions([])
setAgentStateDismissed(false) setAgentStateDismissed(false)
@ -790,6 +805,10 @@ export function useMonitoring({
monitorJob, monitorJob,
monitorStatus, monitorStatus,
setMonitorStatus, setMonitorStatus,
latestPromptText,
setLatestPromptText,
promptHistory,
setPromptHistory,
latestAssistantResponse, latestAssistantResponse,
latestAssistantContext, latestAssistantContext,
activePermissionRequest, activePermissionRequest,
@ -839,12 +858,76 @@ function cleanSessionText(text: string): string {
return cleanTranscriptText(text).trimStart() return cleanTranscriptText(text).trimStart()
} }
function extractMessageText(parts: SessionMessagePart[]): string {
const textParts: string[] = []
for (const part of parts) {
if (!part || part.type !== "text" || typeof part.text !== "string") continue
const text = cleanSessionText(part.text)
if (text.length > 0) {
textParts.push(text)
}
}
return textParts.join("\n\n")
}
function maybeString(value: unknown): string | null { function maybeString(value: unknown): string | null {
if (typeof value !== "string") return null if (typeof value !== "string") return null
const trimmed = value.trim() const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null return trimmed.length > 0 ? trimmed : null
} }
function buildPromptHistory(payload: unknown): PromptHistoryEntry[] {
if (!Array.isArray(payload)) return []
const entries: PromptHistoryEntry[] = []
for (const candidate of payload) {
const msg = candidate as SessionMessagePayload
if (!msg || typeof msg !== "object") continue
const info = msg.info as SessionMessageInfo
if (!info || typeof info !== "object") continue
if (info.role !== "user") continue
const id = (info as { id?: unknown }).id
if (typeof id !== "string") continue
const parts = Array.isArray(msg.parts) ? (msg.parts as SessionMessagePart[]) : []
const text = extractMessageText(parts)
if (text.length === 0) continue
entries.push({ promptText: text, userMessageID: id })
}
return entries
}
function findLatestUserPrompt(payload: unknown): string {
if (!Array.isArray(payload)) {
return ""
}
for (let index = payload.length - 1; index >= 0; index -= 1) {
const candidate = payload[index] as SessionMessagePayload
if (!candidate || typeof candidate !== "object") continue
const info = candidate.info as SessionMessageInfo
if (!info || typeof info !== "object") continue
if (info.role !== "user") continue
const parts = Array.isArray(candidate.parts) ? (candidate.parts as SessionMessagePart[]) : []
const text = extractMessageText(parts)
if (text.length > 0) {
return text
}
}
return ""
}
function extractAssistantContext(info: SessionMessageInfo): LatestAssistantContext | null { function extractAssistantContext(info: SessionMessageInfo): LatestAssistantContext | null {
const providerID = maybeString(info.providerID) const providerID = maybeString(info.providerID)
const modelID = maybeString(info.modelID) const modelID = maybeString(info.modelID)
@ -887,11 +970,7 @@ function findLatestAssistantCompletion(payload: unknown): LatestAssistantSnapsho
const context = extractAssistantContext(info) const context = extractAssistantContext(info)
const parts = Array.isArray(candidate.parts) ? (candidate.parts as SessionMessagePart[]) : [] const parts = Array.isArray(candidate.parts) ? (candidate.parts as SessionMessagePart[]) : []
const text = parts const text = extractMessageText(parts)
.filter((part) => part && part.type === "text" && typeof part.text === "string")
.map((part) => cleanSessionText(part.text as string))
.filter((part) => part.length > 0)
.join("\n\n")
if (text.length > 0 || context) { if (text.length > 0 || context) {
return { return {