@@ -253,6 +253,27 @@ type PairHostProbe = {
253253 note ?: string
254254}
255255
256+ type ReaderBlock =
257+ | {
258+ type : "text"
259+ content : string
260+ }
261+ | {
262+ type : "code"
263+ language : string
264+ content : string
265+ }
266+
267+ type ReaderInlineSegment =
268+ | {
269+ type : "text"
270+ content : string
271+ }
272+ | {
273+ type : "inline_code"
274+ content : string
275+ }
276+
256277const AUDIO_SESSION_BUSY_MESSAGE = "Microphone is unavailable while another call is active. End the call and try again."
257278
258279type Scan = {
@@ -412,6 +433,96 @@ function pairProbeSummary(probe: PairHostProbe | undefined): string {
412433 return `Health check: ${ probe . note ?? "Unavailable" } `
413434}
414435
436+ function parseReaderBlocks ( input : string ) : ReaderBlock [ ] {
437+ const normalized = input . replace ( / \r \n / g, "\n" )
438+ const lines = normalized . split ( "\n" )
439+
440+ const blocks : ReaderBlock [ ] = [ ]
441+ const prose : string [ ] = [ ]
442+ const code : string [ ] = [ ]
443+ let fence : "```" | "~~~" | null = null
444+ let language = ""
445+
446+ const flushProse = ( ) => {
447+ const content = prose . join ( "\n" ) . trim ( )
448+ if ( content . length > 0 ) {
449+ blocks . push ( { type : "text" , content } )
450+ }
451+ prose . length = 0
452+ }
453+
454+ const flushCode = ( ) => {
455+ const content = code . join ( "\n" ) . replace ( / \n + $ / , "" )
456+ blocks . push ( { type : "code" , language, content } )
457+ code . length = 0
458+ language = ""
459+ fence = null
460+ }
461+
462+ for ( const line of lines ) {
463+ if ( ! fence ) {
464+ const match = / ^ \s * ( ` ` ` | ~ ~ ~ ) ( .* ) $ / . exec ( line )
465+ if ( match ) {
466+ flushProse ( )
467+ fence = match [ 1 ] as "```" | "~~~"
468+ language = match [ 2 ] ?. trim ( ) . split ( / \s + / ) [ 0 ] ?? ""
469+ } else {
470+ prose . push ( line )
471+ }
472+ continue
473+ }
474+
475+ if ( line . trimStart ( ) . startsWith ( fence ) ) {
476+ flushCode ( )
477+ continue
478+ }
479+
480+ code . push ( line )
481+ }
482+
483+ if ( fence ) {
484+ prose . push ( `${ fence } ${ language ? language : "" } ` )
485+ prose . push ( ...code )
486+ }
487+
488+ flushProse ( )
489+
490+ return blocks
491+ }
492+
493+ function parseReaderInlineSegments ( input : string ) : ReaderInlineSegment [ ] {
494+ const segments : ReaderInlineSegment [ ] = [ ]
495+ const pattern = / ( ` + | ~ + ) ( [ ^ ` ~ \n ] + ?) \1/ g
496+ let cursor = 0
497+
498+ for ( const match of input . matchAll ( pattern ) ) {
499+ const full = match [ 0 ] ?? ""
500+ const code = match [ 2 ] ?? ""
501+ const start = match . index ?? 0
502+ const end = start + full . length
503+
504+ if ( start > cursor ) {
505+ segments . push ( { type : "text" , content : input . slice ( cursor , start ) } )
506+ }
507+
508+ if ( code . length > 0 ) {
509+ segments . push ( { type : "inline_code" , content : code } )
510+ }
511+
512+ cursor = end
513+ }
514+
515+ if ( cursor < input . length ) {
516+ segments . push ( { type : "text" , content : input . slice ( cursor ) } )
517+ }
518+
519+ if ( segments . length === 0 ) {
520+ segments . push ( { type : "text" , content : input } )
521+ }
522+
523+ return segments
524+ }
525+
415526function isAudioSessionBusyError ( error : unknown ) : boolean {
416527 const message = error instanceof Error ? `${ error . name } ${ error . message } ` : String ( error ?? "" )
417528 return (
@@ -461,6 +572,8 @@ export default function DictationScreen() {
461572 const [ hasCompletedSession , setHasCompletedSession ] = useState ( false )
462573 const [ isSending , setIsSending ] = useState ( false )
463574 const [ agentStateDismissed , setAgentStateDismissed ] = useState ( false )
575+ const [ readerModeOpen , setReaderModeOpen ] = useState ( false )
576+ const [ readerModeRendered , setReaderModeRendered ] = useState ( false )
464577 const [ dropdownMode , setDropdownMode ] = useState < DropdownMode > ( "none" )
465578 const [ dropdownRenderMode , setDropdownRenderMode ] = useState < Exclude < DropdownMode , "none" > > ( "server" )
466579 const [ sessionCreateMode , setSessionCreateMode ] = useState < "same" | "root" | null > ( null )
@@ -1265,9 +1378,21 @@ export default function DictationScreen() {
12651378
12661379 const handleHideAgentState = useCallback ( ( ) => {
12671380 void Haptics . selectionAsync ( ) . catch ( ( ) => { } )
1381+ setReaderModeOpen ( false )
12681382 setAgentStateDismissed ( true )
12691383 } , [ ] )
12701384
1385+ const handleOpenReaderMode = useCallback ( ( ) => {
1386+ void Haptics . selectionAsync ( ) . catch ( ( ) => { } )
1387+ setReaderModeRendered ( true )
1388+ setReaderModeOpen ( true )
1389+ } , [ ] )
1390+
1391+ const handleCloseReaderMode = useCallback ( ( ) => {
1392+ void Haptics . selectionAsync ( ) . catch ( ( ) => { } )
1393+ setReaderModeOpen ( false )
1394+ } , [ ] )
1395+
12711396 const handlePermissionDecision = useCallback (
12721397 ( reply : PermissionDecision ) => {
12731398 if ( ! activePermissionRequest || ! activeServerId ) return
@@ -1615,8 +1740,11 @@ export default function DictationScreen() {
16151740 : WHISPER_MODEL_LABELS [ defaultWhisperModel ]
16161741 const hasTranscript = transcribedText . trim ( ) . length > 0
16171742 const hasAssistantResponse = latestAssistantResponse . trim ( ) . length > 0
1743+ const readerBlocks = useMemo ( ( ) => parseReaderBlocks ( latestAssistantResponse ) , [ latestAssistantResponse ] )
16181744 const activePermissionCard = activePermissionRequest ? buildPermissionCardModel ( activePermissionRequest ) : null
16191745 const hasPendingPermission = activePermissionRequest !== null && activePermissionCard !== null
1746+ const readerModeEnabled = readerModeOpen && hasAssistantResponse && ! hasPendingPermission
1747+ const readerModeVisible = readerModeEnabled || readerModeRendered
16201748 const hasAgentActivity = hasAssistantResponse || monitorStatus . trim ( ) . length > 0 || monitorJob !== null
16211749 const shouldShowAgentStateCard = ! hasPendingPermission && hasAgentActivity && ! agentStateDismissed
16221750 const showsCompleteState = monitorStatus . toLowerCase ( ) . includes ( "complete" )
@@ -1664,6 +1792,7 @@ export default function DictationScreen() {
16641792 const sendVisibility = useSharedValue ( hasTranscript ? 1 : 0 )
16651793 const waveformVisibility = useSharedValue ( 0 )
16661794 const serverMenuProgress = useSharedValue ( 0 )
1795+ const readerExpandProgress = useSharedValue ( 0 )
16671796
16681797 useEffect ( ( ) => {
16691798 recordingProgress . value = withSpring ( isRecording ? 1 : 0 , {
@@ -1688,6 +1817,34 @@ export default function DictationScreen() {
16881817 } )
16891818 } , [ isDropdownOpen , serverMenuProgress ] )
16901819
1820+ useEffect ( ( ) => {
1821+ if ( readerModeEnabled ) {
1822+ readerExpandProgress . value = withTiming ( 1 , {
1823+ duration : 260 ,
1824+ easing : Easing . bezier ( 0.2 , 0.8 , 0.2 , 1 ) ,
1825+ } )
1826+ return
1827+ }
1828+
1829+ if ( ! readerModeRendered ) {
1830+ readerExpandProgress . value = 0
1831+ return
1832+ }
1833+
1834+ readerExpandProgress . value = withTiming (
1835+ 0 ,
1836+ {
1837+ duration : 180 ,
1838+ easing : Easing . bezier ( 0.22 , 0.61 , 0.36 , 1 ) ,
1839+ } ,
1840+ ( finished ) => {
1841+ if ( finished ) {
1842+ runOnJS ( setReaderModeRendered ) ( false )
1843+ }
1844+ } ,
1845+ )
1846+ } , [ readerExpandProgress , readerModeEnabled , readerModeRendered ] )
1847+
16911848 useEffect ( ( ) => {
16921849 if ( dropdownMode !== "none" ) {
16931850 setDropdownRenderMode ( dropdownMode )
@@ -1831,6 +1988,18 @@ export default function DictationScreen() {
18311988 elevation : interpolate ( serverMenuProgress . value , [ 0 , 1 ] , [ 0 , 16 ] , Extrapolation . CLAMP ) ,
18321989 } ) )
18331990
1991+ const animatedReaderExpandStyle = useAnimatedStyle ( ( ) => ( {
1992+ opacity : interpolate ( readerExpandProgress . value , [ 0 , 1 ] , [ 0 , 1 ] , Extrapolation . CLAMP ) ,
1993+ transform : [
1994+ {
1995+ translateY : interpolate ( readerExpandProgress . value , [ 0 , 1 ] , [ 16 , 0 ] , Extrapolation . CLAMP ) ,
1996+ } ,
1997+ {
1998+ scale : interpolate ( readerExpandProgress . value , [ 0 , 1 ] , [ 0.985 , 1 ] , Extrapolation . CLAMP ) ,
1999+ } ,
2000+ ] ,
2001+ } ) )
2002+
18342003 const waveformColumnMeta = useMemo (
18352004 ( ) =>
18362005 Array . from ( { length : waveformLevels . length } , ( ) => ( {
@@ -2098,6 +2267,12 @@ export default function DictationScreen() {
20982267 void handleStartScan ( )
20992268 } , [ closePairSelection , handleStartScan ] )
21002269
2270+ useEffect ( ( ) => {
2271+ if ( latestAssistantResponse . trim ( ) . length === 0 || activePermissionRequest !== null ) {
2272+ setReaderModeOpen ( false )
2273+ }
2274+ } , [ activePermissionRequest , latestAssistantResponse ] )
2275+
21012276 const connectPairPayload = useCallback ( ( rawData : string , source : "scan" | "link" ) => {
21022277 const fromScan = source === "scan"
21032278 if ( fromScan && scanLockRef . current ) return
@@ -2725,6 +2900,51 @@ export default function DictationScreen() {
27252900 </ View >
27262901 </ View >
27272902 </ View >
2903+ ) : readerModeVisible ? (
2904+ < Animated . View style = { [ styles . splitCard , styles . readerCard , animatedReaderExpandStyle ] } >
2905+ < View style = { styles . readerHeaderRow } >
2906+ < View style = { styles . agentStateTitleWrap } >
2907+ < View style = { styles . agentStateIconWrap } >
2908+ { agentStateIcon === "loading" ? (
2909+ < ActivityIndicator size = "small" color = "#91A0C0" />
2910+ ) : (
2911+ < SymbolView
2912+ name = { { ios : "checkmark.circle.fill" , android : "check_circle" , web : "check_circle" } }
2913+ size = { 16 }
2914+ tintColor = "#91C29D"
2915+ />
2916+ ) }
2917+ </ View >
2918+ < Text style = { styles . replyCardLabel } > Agent</ Text >
2919+ </ View >
2920+ < Pressable onPress = { handleCloseReaderMode } hitSlop = { 8 } >
2921+ < Text style = { styles . agentStateClose } > ✕</ Text >
2922+ </ Pressable >
2923+ </ View >
2924+
2925+ < ScrollView style = { styles . readerScroll } contentContainerStyle = { styles . readerContent } >
2926+ { readerBlocks . map ( ( block , index ) =>
2927+ block . type === "code" ? (
2928+ < View key = { `reader-code-${ index } ` } style = { styles . readerCodeBlock } >
2929+ { block . language ? < Text style = { styles . readerCodeLanguage } > { block . language } </ Text > : null }
2930+ < Text style = { styles . readerCodeText } > { block . content } </ Text >
2931+ </ View >
2932+ ) : (
2933+ < Text key = { `reader-text-${ index } ` } style = { styles . readerParagraph } >
2934+ { parseReaderInlineSegments ( block . content ) . map ( ( segment , segmentIndex ) =>
2935+ segment . type === "inline_code" ? (
2936+ < Text key = { `reader-inline-${ index } -${ segmentIndex } ` } style = { styles . readerInlineCode } >
2937+ { segment . content }
2938+ </ Text >
2939+ ) : (
2940+ < Text key = { `reader-copy-${ index } -${ segmentIndex } ` } > { segment . content } </ Text >
2941+ ) ,
2942+ ) }
2943+ </ Text >
2944+ ) ,
2945+ ) }
2946+ </ ScrollView >
2947+ </ Animated . View >
27282948 ) : shouldShowAgentStateCard ? (
27292949 < View style = { styles . splitCardStack } >
27302950 < View style = { [ styles . splitCard , styles . replyCard ] } >
@@ -2743,9 +2963,16 @@ export default function DictationScreen() {
27432963 </ View >
27442964 < Text style = { styles . replyCardLabel } > Agent</ Text >
27452965 </ View >
2746- < Pressable onPress = { handleHideAgentState } hitSlop = { 8 } >
2747- < Text style = { styles . agentStateClose } > ✕</ Text >
2748- </ Pressable >
2966+ < View style = { styles . agentStateActions } >
2967+ { hasAssistantResponse ? (
2968+ < Pressable onPress = { handleOpenReaderMode } hitSlop = { 8 } >
2969+ < Text style = { styles . agentStateReader } > Reader</ Text >
2970+ </ Pressable >
2971+ ) : null }
2972+ < Pressable onPress = { handleHideAgentState } hitSlop = { 8 } >
2973+ < Text style = { styles . agentStateClose } > ✕</ Text >
2974+ </ Pressable >
2975+ </ View >
27492976 </ View >
27502977 < ScrollView style = { styles . replyScroll } contentContainerStyle = { styles . replyContent } >
27512978 < Text style = { styles . replyText } > { agentStateText } </ Text >
@@ -3842,6 +4069,63 @@ const styles = StyleSheet.create({
38424069 fontSize : 15 ,
38434070 fontWeight : "600" ,
38444071 } ,
4072+ readerCard : {
4073+ paddingTop : 14 ,
4074+ } ,
4075+ readerHeaderRow : {
4076+ flexDirection : "row" ,
4077+ alignItems : "center" ,
4078+ justifyContent : "space-between" ,
4079+ marginHorizontal : 20 ,
4080+ marginBottom : 8 ,
4081+ } ,
4082+ readerScroll : {
4083+ flex : 1 ,
4084+ } ,
4085+ readerContent : {
4086+ paddingHorizontal : 20 ,
4087+ paddingBottom : 18 ,
4088+ gap : 14 ,
4089+ } ,
4090+ readerParagraph : {
4091+ color : "#E8EDF8" ,
4092+ fontSize : 22 ,
4093+ fontWeight : "500" ,
4094+ lineHeight : 32 ,
4095+ } ,
4096+ readerInlineCode : {
4097+ color : "#F9E5C8" ,
4098+ backgroundColor : "#262321" ,
4099+ borderWidth : 1 ,
4100+ borderColor : "#3B332D" ,
4101+ borderRadius : 6 ,
4102+ paddingHorizontal : 5 ,
4103+ fontSize : 22 ,
4104+ lineHeight : 32 ,
4105+ fontFamily : Platform . select ( { ios : "Menlo" , android : "monospace" , web : "monospace" } ) ,
4106+ } ,
4107+ readerCodeBlock : {
4108+ borderRadius : 14 ,
4109+ borderWidth : 1 ,
4110+ borderColor : "#2D2F35" ,
4111+ backgroundColor : "#161A1E" ,
4112+ paddingHorizontal : 14 ,
4113+ paddingVertical : 12 ,
4114+ gap : 8 ,
4115+ } ,
4116+ readerCodeLanguage : {
4117+ color : "#97A5C2" ,
4118+ fontSize : 11 ,
4119+ fontWeight : "700" ,
4120+ letterSpacing : 0.8 ,
4121+ textTransform : "uppercase" ,
4122+ } ,
4123+ readerCodeText : {
4124+ color : "#DDE6F7" ,
4125+ fontSize : 22 ,
4126+ lineHeight : 32 ,
4127+ fontFamily : Platform . select ( { ios : "Menlo" , android : "monospace" , web : "monospace" } ) ,
4128+ } ,
38454129 agentStateHeaderRow : {
38464130 flexDirection : "row" ,
38474131 alignItems : "center" ,
@@ -3860,6 +4144,17 @@ const styles = StyleSheet.create({
38604144 alignItems : "center" ,
38614145 justifyContent : "center" ,
38624146 } ,
4147+ agentStateActions : {
4148+ flexDirection : "row" ,
4149+ alignItems : "center" ,
4150+ gap : 14 ,
4151+ } ,
4152+ agentStateReader : {
4153+ color : "#8FA4CC" ,
4154+ fontSize : 13 ,
4155+ fontWeight : "700" ,
4156+ letterSpacing : 0.2 ,
4157+ } ,
38634158 agentStateClose : {
38644159 color : "#8D97AB" ,
38654160 fontSize : 18 ,
0 commit comments