Skip to content

Commit 6494f48

Browse files
committed
update
1 parent 15fae6c commit 6494f48

1 file changed

Lines changed: 298 additions & 3 deletions

File tree

packages/mobile-voice/src/app/index.tsx

Lines changed: 298 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
256277
const AUDIO_SESSION_BUSY_MESSAGE = "Microphone is unavailable while another call is active. End the call and try again."
257278

258279
type 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+
415526
function 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

Comments
 (0)