diff --git a/packages/mobile-voice/src/app/index.tsx b/packages/mobile-voice/src/app/index.tsx index 7c89547111..7cadcfa2fc 100644 --- a/packages/mobile-voice/src/app/index.tsx +++ b/packages/mobile-voice/src/app/index.tsx @@ -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>(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() { /> - { - if (clampedOnboardingStep < onboardingStepCount - 1) { - setOnboardingStep((step) => Math.min(step + 1, onboardingStepCount - 1)) - return - } + {onboardingSecondaryLabel ? ( + { + if (clampedOnboardingStep < onboardingStepCount - 1) { + setOnboardingStep((step) => Math.min(step + 1, onboardingStepCount - 1)) + return + } - completeOnboarding(false) - }} - style={({ pressed }) => [styles.onboardingSecondaryButton, pressed && styles.clearButtonPressed]} - > - {onboardingSecondaryLabel} - + completeOnboarding(false) + }} + style={({ pressed }) => [styles.onboardingSecondaryButton, pressed && styles.clearButtonPressed]} + > + {onboardingSecondaryLabel} + + ) : null} @@ -3335,7 +3386,7 @@ export default function DictationScreen() { - + ) : null} - scrollViewRef.current?.scrollToEnd({ animated: true })} - > - - {displayedTranscript ? ( - {displayedTranscript} - ) : isSending ? null : ( - Your transcription will appear here… - )} - - + {promptPagerData.length > 1 ? ( + ({ + length: pagerPageWidth, + offset: pagerPageWidth * index, + index, + })} + style={styles.transcriptionScroll} + renderItem={({ item }) => + item === "live" ? ( + scrollViewRef.current?.scrollToEnd({ animated: true })} + > + + {displayedTranscript ? ( + {displayedTranscript} + ) : isSending ? null : ( + Your transcription will appear here… + )} + + + ) : ( + + Previous prompt + {item.promptText} + + ) + } + /> + ) : ( + scrollViewRef.current?.scrollToEnd({ animated: true })} + > + + {displayedTranscript ? ( + {displayedTranscript} + ) : isSending ? null : ( + Your transcription will appear here… + )} + + + )} ) : ( - + ) : null} - scrollViewRef.current?.scrollToEnd({ animated: true })} - > - - {displayedTranscript ? ( - {displayedTranscript} - ) : isSending ? null : ( - Your transcription will appear here… - )} - - + {promptPagerData.length > 1 ? ( + ({ length: pagerPageWidth, offset: pagerPageWidth * index, index })} + style={styles.transcriptionScroll} + renderItem={({ item }) => + item === "live" ? ( + scrollViewRef.current?.scrollToEnd({ animated: true })} + > + + {displayedTranscript ? ( + {displayedTranscript} + ) : isSending ? null : ( + Your transcription will appear here… + )} + + + ) : ( + + Previous prompt + {item.promptText} + + ) + } + /> + ) : ( + scrollViewRef.current?.scrollToEnd({ animated: true })} + > + + {displayedTranscript ? ( + {displayedTranscript} + ) : isSending ? null : ( + Your transcription will appear here… + )} + + + )} (null) const [monitorStatus, setMonitorStatus] = useState("") const [latestAssistantResponse, setLatestAssistantResponse] = useState("") + const [latestPromptText, setLatestPromptText] = useState("") + const [promptHistory, setPromptHistory] = useState([]) const [latestAssistantContext, setLatestAssistantContext] = useState(null) const [pendingPermissions, setPendingPermissions] = useState([]) const [replyingPermissionID, setReplyingPermissionID] = useState(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 {