diff --git a/packages/mobile-voice/src/app/index.tsx b/packages/mobile-voice/src/app/index.tsx index f31f0dd339..e749d7be60 100644 --- a/packages/mobile-voice/src/app/index.tsx +++ b/packages/mobile-voice/src/app/index.tsx @@ -253,6 +253,27 @@ type PairHostProbe = { note?: string } +type ReaderBlock = + | { + type: "text" + content: string + } + | { + type: "code" + language: string + content: string + } + +type ReaderInlineSegment = + | { + type: "text" + content: string + } + | { + type: "inline_code" + content: string + } + const AUDIO_SESSION_BUSY_MESSAGE = "Microphone is unavailable while another call is active. End the call and try again." type Scan = { @@ -412,6 +433,96 @@ function pairProbeSummary(probe: PairHostProbe | undefined): string { return `Health check: ${probe.note ?? "Unavailable"}` } +function parseReaderBlocks(input: string): ReaderBlock[] { + const normalized = input.replace(/\r\n/g, "\n") + const lines = normalized.split("\n") + + const blocks: ReaderBlock[] = [] + const prose: string[] = [] + const code: string[] = [] + let fence: "```" | "~~~" | null = null + let language = "" + + const flushProse = () => { + const content = prose.join("\n").trim() + if (content.length > 0) { + blocks.push({ type: "text", content }) + } + prose.length = 0 + } + + const flushCode = () => { + const content = code.join("\n").replace(/\n+$/, "") + blocks.push({ type: "code", language, content }) + code.length = 0 + language = "" + fence = null + } + + for (const line of lines) { + if (!fence) { + const match = /^\s*(```|~~~)(.*)$/.exec(line) + if (match) { + flushProse() + fence = match[1] as "```" | "~~~" + language = match[2]?.trim().split(/\s+/)[0] ?? "" + } else { + prose.push(line) + } + continue + } + + if (line.trimStart().startsWith(fence)) { + flushCode() + continue + } + + code.push(line) + } + + if (fence) { + prose.push(`${fence}${language ? language : ""}`) + prose.push(...code) + } + + flushProse() + + return blocks +} + +function parseReaderInlineSegments(input: string): ReaderInlineSegment[] { + const segments: ReaderInlineSegment[] = [] + const pattern = /(`+|~+)([^`~\n]+?)\1/g + let cursor = 0 + + for (const match of input.matchAll(pattern)) { + const full = match[0] ?? "" + const code = match[2] ?? "" + const start = match.index ?? 0 + const end = start + full.length + + if (start > cursor) { + segments.push({ type: "text", content: input.slice(cursor, start) }) + } + + if (code.length > 0) { + segments.push({ type: "inline_code", content: code }) + } + + cursor = end + } + + if (cursor < input.length) { + segments.push({ type: "text", content: input.slice(cursor) }) + } + + if (segments.length === 0) { + segments.push({ type: "text", content: input }) + } + + return segments +} + function isAudioSessionBusyError(error: unknown): boolean { const message = error instanceof Error ? `${error.name} ${error.message}` : String(error ?? "") return ( @@ -461,6 +572,8 @@ export default function DictationScreen() { const [hasCompletedSession, setHasCompletedSession] = useState(false) const [isSending, setIsSending] = useState(false) const [agentStateDismissed, setAgentStateDismissed] = useState(false) + const [readerModeOpen, setReaderModeOpen] = useState(false) + const [readerModeRendered, setReaderModeRendered] = useState(false) const [dropdownMode, setDropdownMode] = useState("none") const [dropdownRenderMode, setDropdownRenderMode] = useState>("server") const [sessionCreateMode, setSessionCreateMode] = useState<"same" | "root" | null>(null) @@ -1265,9 +1378,21 @@ export default function DictationScreen() { const handleHideAgentState = useCallback(() => { void Haptics.selectionAsync().catch(() => {}) + setReaderModeOpen(false) setAgentStateDismissed(true) }, []) + const handleOpenReaderMode = useCallback(() => { + void Haptics.selectionAsync().catch(() => {}) + setReaderModeRendered(true) + setReaderModeOpen(true) + }, []) + + const handleCloseReaderMode = useCallback(() => { + void Haptics.selectionAsync().catch(() => {}) + setReaderModeOpen(false) + }, []) + const handlePermissionDecision = useCallback( (reply: PermissionDecision) => { if (!activePermissionRequest || !activeServerId) return @@ -1615,8 +1740,11 @@ export default function DictationScreen() { : WHISPER_MODEL_LABELS[defaultWhisperModel] const hasTranscript = transcribedText.trim().length > 0 const hasAssistantResponse = latestAssistantResponse.trim().length > 0 + const readerBlocks = useMemo(() => parseReaderBlocks(latestAssistantResponse), [latestAssistantResponse]) const activePermissionCard = activePermissionRequest ? buildPermissionCardModel(activePermissionRequest) : null const hasPendingPermission = activePermissionRequest !== null && activePermissionCard !== null + const readerModeEnabled = readerModeOpen && hasAssistantResponse && !hasPendingPermission + const readerModeVisible = readerModeEnabled || readerModeRendered const hasAgentActivity = hasAssistantResponse || monitorStatus.trim().length > 0 || monitorJob !== null const shouldShowAgentStateCard = !hasPendingPermission && hasAgentActivity && !agentStateDismissed const showsCompleteState = monitorStatus.toLowerCase().includes("complete") @@ -1664,6 +1792,7 @@ export default function DictationScreen() { const sendVisibility = useSharedValue(hasTranscript ? 1 : 0) const waveformVisibility = useSharedValue(0) const serverMenuProgress = useSharedValue(0) + const readerExpandProgress = useSharedValue(0) useEffect(() => { recordingProgress.value = withSpring(isRecording ? 1 : 0, { @@ -1688,6 +1817,34 @@ export default function DictationScreen() { }) }, [isDropdownOpen, serverMenuProgress]) + useEffect(() => { + if (readerModeEnabled) { + readerExpandProgress.value = withTiming(1, { + duration: 260, + easing: Easing.bezier(0.2, 0.8, 0.2, 1), + }) + return + } + + if (!readerModeRendered) { + readerExpandProgress.value = 0 + return + } + + readerExpandProgress.value = withTiming( + 0, + { + duration: 180, + easing: Easing.bezier(0.22, 0.61, 0.36, 1), + }, + (finished) => { + if (finished) { + runOnJS(setReaderModeRendered)(false) + } + }, + ) + }, [readerExpandProgress, readerModeEnabled, readerModeRendered]) + useEffect(() => { if (dropdownMode !== "none") { setDropdownRenderMode(dropdownMode) @@ -1831,6 +1988,18 @@ export default function DictationScreen() { elevation: interpolate(serverMenuProgress.value, [0, 1], [0, 16], Extrapolation.CLAMP), })) + const animatedReaderExpandStyle = useAnimatedStyle(() => ({ + opacity: interpolate(readerExpandProgress.value, [0, 1], [0, 1], Extrapolation.CLAMP), + transform: [ + { + translateY: interpolate(readerExpandProgress.value, [0, 1], [16, 0], Extrapolation.CLAMP), + }, + { + scale: interpolate(readerExpandProgress.value, [0, 1], [0.985, 1], Extrapolation.CLAMP), + }, + ], + })) + const waveformColumnMeta = useMemo( () => Array.from({ length: waveformLevels.length }, () => ({ @@ -2098,6 +2267,12 @@ export default function DictationScreen() { void handleStartScan() }, [closePairSelection, handleStartScan]) + useEffect(() => { + if (latestAssistantResponse.trim().length === 0 || activePermissionRequest !== null) { + setReaderModeOpen(false) + } + }, [activePermissionRequest, latestAssistantResponse]) + const connectPairPayload = useCallback((rawData: string, source: "scan" | "link") => { const fromScan = source === "scan" if (fromScan && scanLockRef.current) return @@ -2725,6 +2900,51 @@ export default function DictationScreen() { + ) : readerModeVisible ? ( + + + + + {agentStateIcon === "loading" ? ( + + ) : ( + + )} + + Agent + + + + + + + + {readerBlocks.map((block, index) => + block.type === "code" ? ( + + {block.language ? {block.language} : null} + {block.content} + + ) : ( + + {parseReaderInlineSegments(block.content).map((segment, segmentIndex) => + segment.type === "inline_code" ? ( + + {segment.content} + + ) : ( + {segment.content} + ), + )} + + ), + )} + + ) : shouldShowAgentStateCard ? ( @@ -2743,9 +2963,16 @@ export default function DictationScreen() { Agent - - - + + {hasAssistantResponse ? ( + + Reader + + ) : null} + + + + {agentStateText} @@ -3842,6 +4069,63 @@ const styles = StyleSheet.create({ fontSize: 15, fontWeight: "600", }, + readerCard: { + paddingTop: 14, + }, + readerHeaderRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginHorizontal: 20, + marginBottom: 8, + }, + readerScroll: { + flex: 1, + }, + readerContent: { + paddingHorizontal: 20, + paddingBottom: 18, + gap: 14, + }, + readerParagraph: { + color: "#E8EDF8", + fontSize: 22, + fontWeight: "500", + lineHeight: 32, + }, + readerInlineCode: { + color: "#F9E5C8", + backgroundColor: "#262321", + borderWidth: 1, + borderColor: "#3B332D", + borderRadius: 6, + paddingHorizontal: 5, + fontSize: 22, + lineHeight: 32, + fontFamily: Platform.select({ ios: "Menlo", android: "monospace", web: "monospace" }), + }, + readerCodeBlock: { + borderRadius: 14, + borderWidth: 1, + borderColor: "#2D2F35", + backgroundColor: "#161A1E", + paddingHorizontal: 14, + paddingVertical: 12, + gap: 8, + }, + readerCodeLanguage: { + color: "#97A5C2", + fontSize: 11, + fontWeight: "700", + letterSpacing: 0.8, + textTransform: "uppercase", + }, + readerCodeText: { + color: "#DDE6F7", + fontSize: 22, + lineHeight: 32, + fontFamily: Platform.select({ ios: "Menlo", android: "monospace", web: "monospace" }), + }, agentStateHeaderRow: { flexDirection: "row", alignItems: "center", @@ -3860,6 +4144,17 @@ const styles = StyleSheet.create({ alignItems: "center", justifyContent: "center", }, + agentStateActions: { + flexDirection: "row", + alignItems: "center", + gap: 14, + }, + agentStateReader: { + color: "#8FA4CC", + fontSize: 13, + fontWeight: "700", + letterSpacing: 0.2, + }, agentStateClose: { color: "#8D97AB", fontSize: 18,