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
parent cd3a58a4c2
commit e0894d7b63
2 changed files with 281 additions and 51 deletions

View File

@@ -5,6 +5,7 @@ import {
View,
Pressable,
ScrollView,
FlatList,
Modal,
Alert,
ActivityIndicator,
@@ -38,7 +39,12 @@ import { fetch as expoFetch } from "expo/fetch"
import { buildPermissionCardModel } from "@/lib/pending-permissions"
import { unregisterRelayDevice } from "@/lib/relay-client"
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 { ensureNotificationPermissions, getDevicePushToken } from "@/notifications/monitoring-notifications"
@@ -728,6 +734,8 @@ export default function DictationScreen() {
const scanLockRef = useRef(false)
const pairProbeRunRef = useRef(0)
const whisperRestoredRef = useRef(false)
const promptPagerRef = useRef<FlatList<PromptHistoryEntry | "live">>(null)
const promptPagerPageRef = useRef(-1)
const closeDropdown = useCallback(() => {
setDropdownMode("none")
@@ -766,13 +774,17 @@ export default function DictationScreen() {
activePermissionRequest,
devicePushToken,
latestAssistantContext,
latestPromptText,
latestAssistantResponse,
monitorJob,
monitorStatus,
pendingPermissionCount,
promptHistory,
respondingPermissionID,
respondToPermission,
setDevicePushToken,
setLatestPromptText,
setPromptHistory,
setMonitorStatus,
} = useMonitoring({
completePlayer,
@@ -1766,6 +1778,8 @@ export default function DictationScreen() {
throw new Error(`Prompt request failed (${response.status})`)
}
setLatestPromptText(text)
const nextJob: MonitorJob = {
id: `job-${Date.now()}`,
sessionID: session.id,
@@ -1813,6 +1827,7 @@ export default function DictationScreen() {
isSending,
serversRef,
setMonitorStatus,
setLatestPromptText,
sendOutProgress,
sendPlayer,
transcribedText,
@@ -1828,6 +1843,14 @@ export default function DictationScreen() {
setDropdownMode("none")
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {})
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()
}, [startRecording])
@@ -1910,6 +1933,32 @@ export default function DictationScreen() {
const isReplyingToActivePermission =
activePermissionRequest !== null && respondingPermissionID === activePermissionRequest.id
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 effectiveDropdownMode = isDropdownOpen ? dropdownMode : dropdownRenderMode
const isCreatingSession = sessionCreateMode !== null
@@ -2768,7 +2817,7 @@ export default function DictationScreen() {
body: "Control only listens while you hold the record button.",
primaryLabel: microphonePermissionState === "pending" ? "Requesting microphone access..." : "Continue",
primaryDisabled: microphonePermissionState === "pending",
secondaryLabel: "Continue without granting",
secondaryLabel: undefined,
visualTag: "MIC",
visualSurfaceStyle: styles.onboardingVisualSurfaceMic,
visualOrbStyle: styles.onboardingVisualOrbMic,
@@ -2779,7 +2828,7 @@ export default function DictationScreen() {
body: "Get alerts when your OpenCode run finishes, fails, or needs your attention.",
primaryLabel: notificationPermissionState === "pending" ? "Requesting notification access..." : "Continue",
primaryDisabled: notificationPermissionState === "pending",
secondaryLabel: "Continue without granting",
secondaryLabel: undefined,
visualTag: "PUSH",
visualSurfaceStyle: styles.onboardingVisualSurfaceNotifications,
visualOrbStyle: styles.onboardingVisualOrbNotifications,
@@ -2790,7 +2839,7 @@ export default function DictationScreen() {
body: "This lets Control discover your machine on the same network.",
primaryLabel: localNetworkPermissionState === "pending" ? "Requesting local network access..." : "Continue",
primaryDisabled: localNetworkPermissionState === "pending",
secondaryLabel: "Continue without granting",
secondaryLabel: undefined,
visualTag: "LAN",
visualSurfaceStyle: styles.onboardingVisualSurfaceNetwork,
visualOrbStyle: styles.onboardingVisualOrbNetwork,
@@ -2918,19 +2967,21 @@ export default function DictationScreen() {
/>
</Pressable>
<Pressable
onPress={() => {
if (clampedOnboardingStep < onboardingStepCount - 1) {
setOnboardingStep((step) => Math.min(step + 1, onboardingStepCount - 1))
return
}
{onboardingSecondaryLabel ? (
<Pressable
onPress={() => {
if (clampedOnboardingStep < onboardingStepCount - 1) {
setOnboardingStep((step) => Math.min(step + 1, onboardingStepCount - 1))
return
}
completeOnboarding(false)
}}
style={({ pressed }) => [styles.onboardingSecondaryButton, pressed && styles.clearButtonPressed]}
>
<Text style={styles.onboardingSecondaryText}>{onboardingSecondaryLabel}</Text>
</Pressable>
completeOnboarding(false)
}}
style={({ pressed }) => [styles.onboardingSecondaryButton, pressed && styles.clearButtonPressed]}
>
<Text style={styles.onboardingSecondaryText}>{onboardingSecondaryLabel}</Text>
</Pressable>
) : null}
</View>
</View>
</SafeAreaView>
@@ -3335,7 +3386,7 @@ export default function DictationScreen() {
</ScrollView>
</View>
<View style={styles.transcriptionPanel}>
<View style={styles.transcriptionPanel} onLayout={handleTranscriptionPanelLayout}>
<View style={styles.transcriptionTopActions} pointerEvents="box-none">
<Pressable
onPress={handleOpenWhisperSettings}
@@ -3364,20 +3415,66 @@ export default function DictationScreen() {
</View>
) : null}
<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>
{promptPagerData.length > 1 ? (
<FlatList
ref={promptPagerRef}
data={promptPagerData}
keyExtractor={promptPagerKeyExtractor}
horizontal
pagingEnabled
bounces={false}
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={handlePromptPagerSnap}
initialScrollIndex={0}
getItemLayout={(_data, index) => ({
length: pagerPageWidth,
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
style={[styles.waveformBoxesRow, animatedWaveformRowStyle]}
@@ -3451,7 +3548,7 @@ export default function DictationScreen() {
) : null}
</>
) : (
<View style={styles.transcriptionPanel}>
<View style={styles.transcriptionPanel} onLayout={handleTranscriptionPanelLayout}>
<View style={styles.transcriptionTopActions} pointerEvents="box-none">
<Pressable
onPress={handleOpenWhisperSettings}
@@ -3480,20 +3577,59 @@ export default function DictationScreen() {
</View>
) : null}
<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>
{promptPagerData.length > 1 ? (
<FlatList
ref={promptPagerRef}
data={promptPagerData}
keyExtractor={promptPagerKeyExtractor}
horizontal
pagingEnabled
bounces={false}
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={handlePromptPagerSnap}
initialScrollIndex={0}
getItemLayout={(_data, index) => ({ length: pagerPageWidth, 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
style={[styles.waveformBoxesRow, animatedWaveformRowStyle]}
@@ -4784,8 +4920,23 @@ const styles = StyleSheet.create({
right: 10,
zIndex: 4,
flexDirection: "row",
alignItems: "flex-start",
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: {
alignSelf: "flex-start",
marginLeft: 14,

View File

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