feat: refine mobile voice reader interactions

Make Reader open and close feel like one continuous control instead of a view swap. Render assistant markdown consistently in both the compact card and Reader so formatted responses stay readable in either mode.
pull/19545/head
Ryan Vogel 2026-04-02 20:38:49 +00:00
parent d1d3d420bf
commit 4abb464345
1 changed files with 493 additions and 162 deletions

View File

@ -47,6 +47,11 @@ const SEND_SETTLE_MS = 240
const WAVEFORM_ROWS = 5 const WAVEFORM_ROWS = 5
const WAVEFORM_CELL_SIZE = 8 const WAVEFORM_CELL_SIZE = 8
const WAVEFORM_CELL_GAP = 2 const WAVEFORM_CELL_GAP = 2
const READER_ACTION_SIZE = 20
const READER_ACTION_GAP = 14
const READER_ACTION_TRAVEL = READER_ACTION_SIZE + READER_ACTION_GAP
const READER_ACTION_RAIL_WIDTH = READER_ACTION_SIZE * 2 + READER_ACTION_GAP
const AGENT_SUCCESS_GREEN = "#91C29D"
const DROPDOWN_VISIBLE_ROWS = 6 const DROPDOWN_VISIBLE_ROWS = 6
const DROPDOWN_ROW_HEIGHT = 42 const DROPDOWN_ROW_HEIGHT = 42
const SERVER_MENU_SECTION_HEIGHT = 56 const SERVER_MENU_SECTION_HEIGHT = 56
@ -119,6 +124,18 @@ const WHISPER_MODEL_LABELS: Record<WhisperModelID, string> = {
"ggml-medium.bin": "medium", "ggml-medium.bin": "medium",
} }
const READER_OPEN_SYMBOL = {
ios: "arrow.up.left.and.arrow.down.right",
android: "open_in_full",
web: "open_in_full",
} as const
const READER_CLOSE_SYMBOL = {
ios: "arrow.down.right.and.arrow.up.left",
android: "close_fullscreen",
web: "close_fullscreen",
} as const
const WHISPER_MODEL_SIZES: Record<WhisperModelID, number> = { const WHISPER_MODEL_SIZES: Record<WhisperModelID, number> = {
"ggml-tiny.en-q5_1.bin": 32166155, "ggml-tiny.en-q5_1.bin": 32166155,
"ggml-tiny.en-q8_0.bin": 43550795, "ggml-tiny.en-q8_0.bin": 43550795,
@ -272,11 +289,18 @@ type PairHostProbe = {
note?: string note?: string
} }
type ReaderHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6
type ReaderBlock = type ReaderBlock =
| { | {
type: "text" type: "text"
content: string content: string
} }
| {
type: "heading"
level: ReaderHeadingLevel
content: string
}
| { | {
type: "code" type: "code"
language: string language: string
@ -292,6 +316,18 @@ type ReaderInlineSegment =
type: "inline_code" type: "inline_code"
content: string content: string
} }
| {
type: "italic"
content: string
}
| {
type: "bold"
content: string
}
| {
type: "bold_italic"
content: string
}
const AUDIO_SESSION_BUSY_MESSAGE = "Microphone is unavailable while another call is active. End the call and try again." const AUDIO_SESSION_BUSY_MESSAGE = "Microphone is unavailable while another call is active. End the call and try again."
@ -486,7 +522,17 @@ function parseReaderBlocks(input: string): ReaderBlock[] {
fence = match[1] as "```" | "~~~" fence = match[1] as "```" | "~~~"
language = match[2]?.trim().split(/\s+/)[0] ?? "" language = match[2]?.trim().split(/\s+/)[0] ?? ""
} else { } else {
prose.push(line) const headingMatch = /^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line)
if (headingMatch) {
flushProse()
blocks.push({
type: "heading",
level: headingMatch[1].length as ReaderHeadingLevel,
content: headingMatch[2].trim(),
})
} else {
prose.push(line)
}
} }
continue continue
} }
@ -509,6 +555,50 @@ function parseReaderBlocks(input: string): ReaderBlock[] {
return blocks return blocks
} }
function parseReaderAsteriskSegments(input: string): ReaderInlineSegment[] {
const segments: ReaderInlineSegment[] = []
let cursor = 0
let textStart = 0
while (cursor < input.length) {
if (input[cursor] !== "*") {
cursor += 1
continue
}
const marker = input.startsWith("***", cursor) ? "***" : input.startsWith("**", cursor) ? "**" : "*"
const end = input.indexOf(marker, cursor + marker.length)
if (end === -1) {
cursor += 1
continue
}
const content = input.slice(cursor + marker.length, end)
if (content.trim().length === 0 || content !== content.trim() || content.includes("\n")) {
cursor += 1
continue
}
if (cursor > textStart) {
segments.push({ type: "text", content: input.slice(textStart, cursor) })
}
segments.push({
type: marker === "***" ? "bold_italic" : marker === "**" ? "bold" : "italic",
content,
})
cursor = end + marker.length
textStart = cursor
}
if (textStart < input.length) {
segments.push({ type: "text", content: input.slice(textStart) })
}
return segments.length > 0 ? segments : [{ type: "text", content: input }]
}
function parseReaderInlineSegments(input: string): ReaderInlineSegment[] { function parseReaderInlineSegments(input: string): ReaderInlineSegment[] {
const segments: ReaderInlineSegment[] = [] const segments: ReaderInlineSegment[] = []
const pattern = /(`+|~+)([^`~\n]+?)\1/g const pattern = /(`+|~+)([^`~\n]+?)\1/g
@ -521,7 +611,7 @@ function parseReaderInlineSegments(input: string): ReaderInlineSegment[] {
const end = start + full.length const end = start + full.length
if (start > cursor) { if (start > cursor) {
segments.push({ type: "text", content: input.slice(cursor, start) }) segments.push(...parseReaderAsteriskSegments(input.slice(cursor, start)))
} }
if (code.length > 0) { if (code.length > 0) {
@ -532,7 +622,7 @@ function parseReaderInlineSegments(input: string): ReaderInlineSegment[] {
} }
if (cursor < input.length) { if (cursor < input.length) {
segments.push({ type: "text", content: input.slice(cursor) }) segments.push(...parseReaderAsteriskSegments(input.slice(cursor)))
} }
if (segments.length === 0) { if (segments.length === 0) {
@ -593,6 +683,8 @@ export default function DictationScreen() {
const [agentStateDismissed, setAgentStateDismissed] = useState(false) const [agentStateDismissed, setAgentStateDismissed] = useState(false)
const [readerModeOpen, setReaderModeOpen] = useState(false) const [readerModeOpen, setReaderModeOpen] = useState(false)
const [readerModeRendered, setReaderModeRendered] = useState(false) const [readerModeRendered, setReaderModeRendered] = useState(false)
const [transcriptionAreaHeight, setTranscriptionAreaHeight] = useState(0)
const [agentStateCardHeight, setAgentStateCardHeight] = useState(0)
const [dropdownMode, setDropdownMode] = useState<DropdownMode>("none") const [dropdownMode, setDropdownMode] = useState<DropdownMode>("none")
const [dropdownRenderMode, setDropdownRenderMode] = useState<Exclude<DropdownMode, "none">>("server") const [dropdownRenderMode, setDropdownRenderMode] = useState<Exclude<DropdownMode, "none">>("server")
const [serverMenuListHeight, setServerMenuListHeight] = useState(0) const [serverMenuListHeight, setServerMenuListHeight] = useState(0)
@ -1777,6 +1869,9 @@ export default function DictationScreen() {
const hasPendingPermission = activePermissionRequest !== null && activePermissionCard !== null const hasPendingPermission = activePermissionRequest !== null && activePermissionCard !== null
const readerModeEnabled = readerModeOpen && hasAssistantResponse && !hasPendingPermission const readerModeEnabled = readerModeOpen && hasAssistantResponse && !hasPendingPermission
const readerModeVisible = readerModeEnabled || readerModeRendered const readerModeVisible = readerModeEnabled || readerModeRendered
const fallbackAgentStateCardHeight = transcriptionAreaHeight > 0 ? Math.max(0, (transcriptionAreaHeight - 8) / 2) : 0
const collapsedReaderHeight = agentStateCardHeight > 0 ? agentStateCardHeight : fallbackAgentStateCardHeight
const expandedReaderHeight = Math.max(collapsedReaderHeight, transcriptionAreaHeight)
const hasAgentActivity = hasAssistantResponse || monitorStatus.trim().length > 0 || monitorJob !== null const hasAgentActivity = hasAssistantResponse || monitorStatus.trim().length > 0 || monitorJob !== null
const shouldShowAgentStateCard = !hasPendingPermission && hasAgentActivity && !agentStateDismissed const shouldShowAgentStateCard = !hasPendingPermission && hasAgentActivity && !agentStateDismissed
const showsCompleteState = monitorStatus.toLowerCase().includes("complete") const showsCompleteState = monitorStatus.toLowerCase().includes("complete")
@ -1785,6 +1880,7 @@ export default function DictationScreen() {
agentStateIcon = "done" agentStateIcon = "done"
} }
const agentStateText = hasAssistantResponse ? latestAssistantResponse : "Waiting for agent…" const agentStateText = hasAssistantResponse ? latestAssistantResponse : "Waiting for agent…"
const agentStateBlocks = useMemo(() => parseReaderBlocks(agentStateText), [agentStateText])
const shouldShowSend = hasCompletedSession && hasTranscript && !hasPendingPermission const shouldShowSend = hasCompletedSession && hasTranscript && !hasPendingPermission
const activeServer = servers.find((s) => s.id === activeServerId) ?? null const activeServer = servers.find((s) => s.id === activeServerId) ?? null
const discoveredServerOptions = useMemo(() => { const discoveredServerOptions = useMemo(() => {
@ -1874,8 +1970,8 @@ export default function DictationScreen() {
useEffect(() => { useEffect(() => {
if (readerModeEnabled) { if (readerModeEnabled) {
readerExpandProgress.value = withTiming(1, { readerExpandProgress.value = withTiming(1, {
duration: 260, duration: 320,
easing: Easing.bezier(0.2, 0.8, 0.2, 1), easing: Easing.bezier(0.22, 1, 0.36, 1),
}) })
return return
} }
@ -1888,8 +1984,8 @@ export default function DictationScreen() {
readerExpandProgress.value = withTiming( readerExpandProgress.value = withTiming(
0, 0,
{ {
duration: 180, duration: 240,
easing: Easing.bezier(0.22, 0.61, 0.36, 1), easing: Easing.bezier(0.4, 0, 0.2, 1),
}, },
(finished) => { (finished) => {
if (finished) { if (finished) {
@ -2055,17 +2151,37 @@ export default function DictationScreen() {
})) }))
const animatedReaderExpandStyle = useAnimatedStyle(() => ({ const animatedReaderExpandStyle = useAnimatedStyle(() => ({
opacity: interpolate(readerExpandProgress.value, [0, 1], [0, 1], Extrapolation.CLAMP), height: interpolate(
readerExpandProgress.value,
[0, 1],
[collapsedReaderHeight, expandedReaderHeight],
Extrapolation.CLAMP,
),
opacity: interpolate(readerExpandProgress.value, [0, 0.12, 1], [0, 1, 1], Extrapolation.CLAMP),
}))
const animatedAgentStateActionsStyle = useAnimatedStyle(() => ({
opacity: interpolate(readerExpandProgress.value, [0, 0.16, 1], [1, 0, 0], Extrapolation.CLAMP),
}))
const animatedReaderToggleTravelStyle = useAnimatedStyle(() => ({
transform: [ transform: [
{ {
translateY: interpolate(readerExpandProgress.value, [0, 1], [16, 0], Extrapolation.CLAMP), translateX: interpolate(readerExpandProgress.value, [0, 1], [-READER_ACTION_TRAVEL, 0], Extrapolation.CLAMP),
},
{
scale: interpolate(readerExpandProgress.value, [0, 1], [0.985, 1], Extrapolation.CLAMP),
}, },
], ],
})) }))
const animatedReaderToggleOpenIconStyle = useAnimatedStyle(() => ({
opacity: interpolate(readerExpandProgress.value, [0, 0.45, 1], [1, 0.18, 0], Extrapolation.CLAMP),
transform: [{ scale: interpolate(readerExpandProgress.value, [0, 1], [1, 0.92], Extrapolation.CLAMP) }],
}))
const animatedReaderToggleCloseIconStyle = useAnimatedStyle(() => ({
opacity: interpolate(readerExpandProgress.value, [0, 0.45, 1], [0, 0.35, 1], Extrapolation.CLAMP),
transform: [{ scale: interpolate(readerExpandProgress.value, [0, 1], [0.92, 1], Extrapolation.CLAMP) }],
}))
const waveformColumnMeta = useMemo( const waveformColumnMeta = useMemo(
() => () =>
Array.from({ length: waveformLevels.length }, () => ({ Array.from({ length: waveformLevels.length }, () => ({
@ -2115,6 +2231,16 @@ export default function DictationScreen() {
setControlsWidth(event.nativeEvent.layout.width) setControlsWidth(event.nativeEvent.layout.width)
}, []) }, [])
const handleTranscriptionAreaLayout = useCallback((event: LayoutChangeEvent) => {
const next = Math.ceil(event.nativeEvent.layout.height)
setTranscriptionAreaHeight((prev) => (prev === next ? prev : next))
}, [])
const handleAgentStateCardLayout = useCallback((event: LayoutChangeEvent) => {
const next = Math.ceil(event.nativeEvent.layout.height)
setAgentStateCardHeight((prev) => (prev === next ? prev : next))
}, [])
const handleWaveformLayout = useCallback((event: LayoutChangeEvent) => { const handleWaveformLayout = useCallback((event: LayoutChangeEvent) => {
const width = event.nativeEvent.layout.width const width = event.nativeEvent.layout.width
const columns = Math.max(14, Math.floor((width + WAVEFORM_CELL_GAP) / (WAVEFORM_CELL_SIZE + WAVEFORM_CELL_GAP))) const columns = Math.max(14, Math.floor((width + WAVEFORM_CELL_GAP) / (WAVEFORM_CELL_SIZE + WAVEFORM_CELL_GAP)))
@ -2146,6 +2272,76 @@ export default function DictationScreen() {
setSessionMenuFooterHeight((prev) => (prev === next ? prev : next)) setSessionMenuFooterHeight((prev) => (prev === next ? prev : next))
}, []) }, [])
const renderMarkdownBlocks = (blocks: ReaderBlock[], variant: "reader" | "reply") => {
const keyPrefix = variant === "reader" ? "reader" : "reply"
const paragraphStyle = variant === "reader" ? styles.readerParagraph : styles.replyText
const renderMarkdownInline = (input: string, blockIndex: number) =>
parseReaderInlineSegments(input).map((segment, segmentIndex) => {
const segmentKey = `${keyPrefix}-inline-${blockIndex}-${segmentIndex}`
switch (segment.type) {
case "inline_code":
return (
<Text key={segmentKey} style={styles.readerInlineCode}>
{segment.content}
</Text>
)
case "italic":
return (
<Text key={segmentKey} style={styles.markdownItalic}>
{segment.content}
</Text>
)
case "bold":
return (
<Text key={segmentKey} style={styles.markdownBold}>
{segment.content}
</Text>
)
case "bold_italic":
return (
<Text key={segmentKey} style={styles.markdownBoldItalic}>
{segment.content}
</Text>
)
default:
return <Text key={segmentKey}>{segment.content}</Text>
}
})
const getHeadingStyle = (level: ReaderHeadingLevel) => {
if (variant === "reader") {
return [
styles.readerHeading,
level === 1 ? styles.readerHeading1 : level === 2 ? styles.readerHeading2 : styles.readerHeading3,
]
}
return [
styles.replyHeading,
level === 1 ? styles.replyHeading1 : level === 2 ? styles.replyHeading2 : styles.replyHeading3,
]
}
return blocks.map((block, index) =>
block.type === "code" ? (
<View key={`${keyPrefix}-code-${index}`} style={styles.readerCodeBlock}>
{block.language ? <Text style={styles.readerCodeLanguage}>{block.language}</Text> : null}
<Text style={styles.readerCodeText}>{block.content}</Text>
</View>
) : block.type === "heading" ? (
<Text key={`${keyPrefix}-heading-${index}`} style={getHeadingStyle(block.level)}>
{renderMarkdownInline(block.content, index)}
</Text>
) : (
<Text key={`${keyPrefix}-text-${index}`} style={paragraphStyle}>
{renderMarkdownInline(block.content, index)}
</Text>
),
)
}
const toggleServerMenu = useCallback(() => { const toggleServerMenu = useCallback(() => {
void Haptics.selectionAsync().catch(() => {}) void Haptics.selectionAsync().catch(() => {})
setDropdownMode((prev) => { setDropdownMode((prev) => {
@ -2757,7 +2953,9 @@ export default function DictationScreen() {
style={({ pressed }) => [styles.headerSplitLeft, pressed && styles.clearButtonPressed]} style={({ pressed }) => [styles.headerSplitLeft, pressed && styles.clearButtonPressed]}
> >
<View style={styles.headerServerLabel}> <View style={styles.headerServerLabel}>
<View style={[styles.serverStatusDot, headerDotStyle]} /> <View style={styles.headerStatusIconWrap}>
<View style={[styles.serverStatusDot, headerDotStyle]} />
</View>
<Text <Text
style={[styles.workspaceHeaderText, styles.headerServerText]} style={[styles.workspaceHeaderText, styles.headerServerText]}
numberOfLines={1} numberOfLines={1}
@ -2789,7 +2987,9 @@ export default function DictationScreen() {
style={({ pressed }) => [styles.statusBarTapArea, pressed && styles.clearButtonPressed]} style={({ pressed }) => [styles.statusBarTapArea, pressed && styles.clearButtonPressed]}
> >
<View style={styles.headerServerLabel}> <View style={styles.headerServerLabel}>
<View style={[styles.serverStatusDot, headerDotStyle]} /> <View style={styles.headerStatusIconWrap}>
<View style={[styles.serverStatusDot, headerDotStyle]} />
</View>
<Text style={styles.workspaceHeaderText}>{headerTitle}</Text> <Text style={styles.workspaceHeaderText}>{headerTitle}</Text>
</View> </View>
</Pressable> </Pressable>
@ -2999,7 +3199,7 @@ export default function DictationScreen() {
</View> </View>
{/* Transcription area */} {/* Transcription area */}
<View style={styles.transcriptionArea}> <View style={styles.transcriptionArea} onLayout={handleTranscriptionAreaLayout}>
{hasPendingPermission && activePermissionCard ? ( {hasPendingPermission && activePermissionCard ? (
<View style={[styles.splitCard, styles.permissionCard]}> <View style={[styles.splitCard, styles.permissionCard]}>
<View style={styles.permissionHeaderRow}> <View style={styles.permissionHeaderRow}>
@ -3084,144 +3284,172 @@ export default function DictationScreen() {
</View> </View>
</View> </View>
</View> </View>
) : readerModeVisible ? (
<Animated.View style={[styles.splitCard, styles.readerCard, animatedReaderExpandStyle]}>
<View style={styles.readerHeaderRow}>
<View style={styles.agentStateTitleWrap}>
<View style={styles.agentStateIconWrap}>
{agentStateIcon === "loading" ? (
<ActivityIndicator size="small" color="#91A0C0" />
) : (
<SymbolView
name={{ ios: "checkmark.circle.fill", android: "check_circle", web: "check_circle" }}
size={16}
tintColor="#91C29D"
/>
)}
</View>
<Text style={styles.replyCardLabel}>Agent</Text>
</View>
<Pressable onPress={handleCloseReaderMode} hitSlop={8}>
<Text style={styles.agentStateClose}></Text>
</Pressable>
</View>
<ScrollView style={styles.readerScroll} contentContainerStyle={styles.readerContent}>
{readerBlocks.map((block, index) =>
block.type === "code" ? (
<View key={`reader-code-${index}`} style={styles.readerCodeBlock}>
{block.language ? <Text style={styles.readerCodeLanguage}>{block.language}</Text> : null}
<Text style={styles.readerCodeText}>{block.content}</Text>
</View>
) : (
<Text key={`reader-text-${index}`} style={styles.readerParagraph}>
{parseReaderInlineSegments(block.content).map((segment, segmentIndex) =>
segment.type === "inline_code" ? (
<Text key={`reader-inline-${index}-${segmentIndex}`} style={styles.readerInlineCode}>
{segment.content}
</Text>
) : (
<Text key={`reader-copy-${index}-${segmentIndex}`}>{segment.content}</Text>
),
)}
</Text>
),
)}
</ScrollView>
</Animated.View>
) : shouldShowAgentStateCard ? ( ) : shouldShowAgentStateCard ? (
<View style={styles.splitCardStack}> <>
<View style={[styles.splitCard, styles.replyCard]}> <View style={styles.splitCardStack} pointerEvents={readerModeRendered ? "none" : "auto"}>
<View style={styles.agentStateHeaderRow}> <View style={[styles.splitCard, styles.replyCard]} onLayout={handleAgentStateCardLayout}>
<View style={styles.agentStateTitleWrap}> <View style={styles.agentStateHeaderRow}>
<View style={styles.agentStateIconWrap}> <View style={styles.agentStateTitleWrap}>
{agentStateIcon === "loading" ? ( <View style={styles.agentStateIconWrap}>
<ActivityIndicator size="small" color="#91A0C0" /> {agentStateIcon === "loading" ? (
) : ( <ActivityIndicator size="small" color="#91A0C0" />
<SymbolView ) : (
name={{ ios: "checkmark.circle.fill", android: "check_circle", web: "check_circle" }} <SymbolView
size={16} name={{ ios: "checkmark.circle.fill", android: "check_circle", web: "check_circle" }}
tintColor="#91C29D" size={16}
/> tintColor={AGENT_SUCCESS_GREEN}
)} />
)}
</View>
<Text style={styles.replyCardLabel}>Agent</Text>
</View> </View>
<Text style={styles.replyCardLabel}>Agent</Text> <Animated.View
</View> style={[
<View style={styles.agentStateActions}> styles.agentStateActions,
{hasAssistantResponse ? ( !hasAssistantResponse && styles.agentStateActionsSingle,
<Pressable onPress={handleOpenReaderMode} hitSlop={8}> animatedAgentStateActionsStyle,
<Text style={styles.agentStateReader}>Reader</Text> ]}
>
{hasAssistantResponse ? (
<Pressable
onPress={handleOpenReaderMode}
hitSlop={8}
accessibilityLabel="Open Reader"
style={styles.agentStateActionButton}
>
<SymbolView
name={READER_OPEN_SYMBOL}
size={16}
tintColor="#8FA4CC"
style={styles.readerToggleSymbol}
/>
</Pressable>
) : null}
<Pressable onPress={handleHideAgentState} hitSlop={8} style={styles.agentStateActionButton}>
<Text style={styles.agentStateClose}></Text>
</Pressable> </Pressable>
) : null} </Animated.View>
<Pressable onPress={handleHideAgentState} hitSlop={8}> </View>
<Text style={styles.agentStateClose}></Text> <ScrollView style={styles.replyScroll} contentContainerStyle={styles.replyContent}>
{renderMarkdownBlocks(agentStateBlocks, "reply")}
</ScrollView>
</View>
<View style={styles.transcriptionPanel}>
<View style={styles.transcriptionTopActions} pointerEvents="box-none">
<Pressable
onPress={handleOpenWhisperSettings}
style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
hitSlop={8}
>
<SymbolView
name={{ ios: "gearshape.fill", android: "settings", web: "settings" }}
size={18}
weight="semibold"
tintColor="#B8BDC9"
/>
</Pressable>
<Pressable
onPress={handleClearTranscript}
style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
hitSlop={8}
>
<Animated.Text style={[styles.clearIcon, animatedClearIconStyle]}></Animated.Text>
</Pressable> </Pressable>
</View> </View>
</View>
<ScrollView style={styles.replyScroll} contentContainerStyle={styles.replyContent}>
<Text style={styles.replyText}>{agentStateText}</Text>
</ScrollView>
</View>
<View style={styles.transcriptionPanel}> {whisperError ? (
<View style={styles.transcriptionTopActions} pointerEvents="box-none"> <View style={styles.modelErrorBadge}>
<Pressable <Text style={styles.modelErrorText}>{whisperError}</Text>
onPress={handleOpenWhisperSettings}
style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
hitSlop={8}
>
<SymbolView
name={{ ios: "gearshape.fill", android: "settings", web: "settings" }}
size={18}
weight="semibold"
tintColor="#B8BDC9"
/>
</Pressable>
<Pressable
onPress={handleClearTranscript}
style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
hitSlop={8}
>
<Animated.Text style={[styles.clearIcon, animatedClearIconStyle]}></Animated.Text>
</Pressable>
</View>
{whisperError ? (
<View style={styles.modelErrorBadge}>
<Text style={styles.modelErrorText}>{whisperError}</Text>
</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>
<Animated.View
style={[styles.waveformBoxesRow, animatedWaveformRowStyle]}
pointerEvents="none"
onLayout={handleWaveformLayout}
>
{Array.from({ length: WAVEFORM_ROWS }).map((_, row) => (
<View key={`row-${row}`} style={styles.waveformGridRow}>
{waveformLevels.map((_, col) => (
<View key={`cell-${row}-${col}`} style={[styles.waveformBox, getWaveformCellStyle(row, col)]} />
))}
</View> </View>
))} ) : null}
</Animated.View>
<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]}
pointerEvents="none"
onLayout={handleWaveformLayout}
>
{Array.from({ length: WAVEFORM_ROWS }).map((_, row) => (
<View key={`row-${row}`} style={styles.waveformGridRow}>
{waveformLevels.map((_, col) => (
<View key={`cell-${row}-${col}`} style={[styles.waveformBox, getWaveformCellStyle(row, col)]} />
))}
</View>
))}
</Animated.View>
</View>
</View> </View>
</View>
{readerModeVisible ? (
<Animated.View
pointerEvents={readerModeRendered ? "auto" : "none"}
style={[styles.splitCard, styles.readerCard, styles.readerOverlayCard, animatedReaderExpandStyle]}
>
<View style={styles.readerHeaderRow}>
<View style={styles.agentStateTitleWrap}>
<View style={styles.agentStateIconWrap}>
{agentStateIcon === "loading" ? (
<ActivityIndicator size="small" color="#91A0C0" />
) : (
<SymbolView
name={{ ios: "checkmark.circle.fill", android: "check_circle", web: "check_circle" }}
size={16}
tintColor={AGENT_SUCCESS_GREEN}
/>
)}
</View>
<Text style={styles.replyCardLabel}>Agent</Text>
</View>
<View style={styles.readerActionRail}>
<Animated.View style={[styles.readerToggleFloatingAction, animatedReaderToggleTravelStyle]}>
<Pressable
onPress={handleCloseReaderMode}
hitSlop={8}
accessibilityLabel="Close Reader"
style={styles.agentStateActionButton}
>
<Animated.View style={[styles.readerToggleIconLayer, animatedReaderToggleOpenIconStyle]}>
<SymbolView
name={READER_OPEN_SYMBOL}
size={16}
tintColor="#8FA4CC"
style={styles.readerToggleSymbol}
/>
</Animated.View>
<Animated.View style={[styles.readerToggleIconLayer, animatedReaderToggleCloseIconStyle]}>
<SymbolView
name={READER_CLOSE_SYMBOL}
size={16}
tintColor="#8FA4CC"
style={styles.readerToggleSymbol}
/>
</Animated.View>
</Pressable>
</Animated.View>
</View>
</View>
<ScrollView style={styles.readerScroll} contentContainerStyle={styles.readerContent}>
{renderMarkdownBlocks(readerBlocks, "reader")}
</ScrollView>
</Animated.View>
) : null}
</>
) : ( ) : (
<View style={styles.transcriptionPanel}> <View style={styles.transcriptionPanel}>
<View style={styles.transcriptionTopActions} pointerEvents="box-none"> <View style={styles.transcriptionTopActions} pointerEvents="box-none">
@ -4023,7 +4251,7 @@ const styles = StyleSheet.create({
borderRadius: 5, borderRadius: 5,
}, },
serverStatusActive: { serverStatusActive: {
backgroundColor: "#4CC26A", backgroundColor: AGENT_SUCCESS_GREEN,
}, },
serverStatusChecking: { serverStatusChecking: {
backgroundColor: "#D2A542", backgroundColor: "#D2A542",
@ -4119,7 +4347,7 @@ const styles = StyleSheet.create({
width: 8, width: 8,
height: 8, height: 8,
borderRadius: 4, borderRadius: 4,
backgroundColor: "#4CAF50", backgroundColor: AGENT_SUCCESS_GREEN,
}, },
recordingDot: { recordingDot: {
width: 8, width: 8,
@ -4254,6 +4482,11 @@ const styles = StyleSheet.create({
fontSize: 12, fontSize: 12,
lineHeight: 18, lineHeight: 18,
color: "#D4D7DE", color: "#D4D7DE",
backgroundColor: "#17181B",
borderRadius: 8,
overflow: "hidden",
paddingHorizontal: 6,
paddingVertical: 4,
}, },
permissionFooter: { permissionFooter: {
gap: 10, gap: 10,
@ -4332,7 +4565,14 @@ const styles = StyleSheet.create({
fontWeight: "600", fontWeight: "600",
}, },
readerCard: { readerCard: {
paddingTop: 14, paddingTop: 16,
},
readerOverlayCard: {
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 2,
}, },
readerHeaderRow: { readerHeaderRow: {
flexDirection: "row", flexDirection: "row",
@ -4355,22 +4595,50 @@ const styles = StyleSheet.create({
fontWeight: "500", fontWeight: "500",
lineHeight: 32, lineHeight: 32,
}, },
readerInlineCode: { readerHeading: {
color: "#F9E5C8", color: "#F7F9FF",
backgroundColor: "#262321", fontWeight: "800",
borderWidth: 1, letterSpacing: -0.2,
borderColor: "#3B332D", },
borderRadius: 6, readerHeading1: {
paddingHorizontal: 5, fontSize: 29,
lineHeight: 38,
},
readerHeading2: {
fontSize: 25,
lineHeight: 34,
},
readerHeading3: {
fontSize: 22, fontSize: 22,
lineHeight: 32, lineHeight: 30,
},
markdownItalic: {
fontStyle: "italic",
},
markdownBold: {
fontWeight: "800",
},
markdownBoldItalic: {
fontStyle: "italic",
fontWeight: "800",
},
readerInlineCode: {
color: "#E7EBF5",
backgroundColor: "#17181B",
borderWidth: 1,
borderColor: "#292A2E",
borderRadius: 8,
paddingHorizontal: 6,
paddingVertical: 2,
fontSize: 18,
lineHeight: 26,
fontFamily: Platform.select({ ios: "Menlo", android: "monospace", web: "monospace" }), fontFamily: Platform.select({ ios: "Menlo", android: "monospace", web: "monospace" }),
}, },
readerCodeBlock: { readerCodeBlock: {
borderRadius: 14, borderRadius: 14,
borderWidth: 1, borderWidth: 1,
borderColor: "#2D2F35", borderColor: "#292A2E",
backgroundColor: "#161A1E", backgroundColor: "#17181B",
paddingHorizontal: 14, paddingHorizontal: 14,
paddingVertical: 12, paddingVertical: 12,
gap: 8, gap: 8,
@ -4384,8 +4652,8 @@ const styles = StyleSheet.create({
}, },
readerCodeText: { readerCodeText: {
color: "#DDE6F7", color: "#DDE6F7",
fontSize: 22, fontSize: 18,
lineHeight: 32, lineHeight: 28,
fontFamily: Platform.select({ ios: "Menlo", android: "monospace", web: "monospace" }), fontFamily: Platform.select({ ios: "Menlo", android: "monospace", web: "monospace" }),
}, },
agentStateHeaderRow: { agentStateHeaderRow: {
@ -4406,10 +4674,28 @@ const styles = StyleSheet.create({
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
headerStatusIconWrap: {
width: 16,
height: 16,
justifyContent: "center",
paddingLeft: 5,
},
agentStateActions: { agentStateActions: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: 14, justifyContent: "flex-end",
width: READER_ACTION_RAIL_WIDTH,
gap: READER_ACTION_GAP,
},
agentStateActionsSingle: {
width: READER_ACTION_SIZE,
gap: 0,
},
agentStateActionButton: {
width: READER_ACTION_SIZE,
height: READER_ACTION_SIZE,
alignItems: "center",
justifyContent: "center",
}, },
agentStateReader: { agentStateReader: {
color: "#8FA4CC", color: "#8FA4CC",
@ -4417,6 +4703,33 @@ const styles = StyleSheet.create({
fontWeight: "700", fontWeight: "700",
letterSpacing: 0.2, letterSpacing: 0.2,
}, },
readerActionRail: {
width: READER_ACTION_RAIL_WIDTH,
height: READER_ACTION_SIZE,
position: "relative",
alignItems: "flex-end",
justifyContent: "center",
},
readerToggleFloatingAction: {
position: "absolute",
right: 0,
width: READER_ACTION_SIZE,
height: READER_ACTION_SIZE,
alignItems: "center",
justifyContent: "center",
},
readerToggleIconLayer: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
alignItems: "center",
justifyContent: "center",
},
readerToggleSymbol: {
transform: [{ rotate: "-90deg" }],
},
agentStateClose: { agentStateClose: {
color: "#8D97AB", color: "#8D97AB",
fontSize: 18, fontSize: 18,
@ -4430,6 +4743,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 20, paddingHorizontal: 20,
paddingBottom: 18, paddingBottom: 18,
flexGrow: 1, flexGrow: 1,
gap: 14,
}, },
replyText: { replyText: {
fontSize: 22, fontSize: 22,
@ -4437,6 +4751,23 @@ const styles = StyleSheet.create({
lineHeight: 32, lineHeight: 32,
color: "#F4F7FF", color: "#F4F7FF",
}, },
replyHeading: {
color: "#F7F9FF",
fontWeight: "800",
letterSpacing: -0.2,
},
replyHeading1: {
fontSize: 27,
lineHeight: 36,
},
replyHeading2: {
fontSize: 24,
lineHeight: 32,
},
replyHeading3: {
fontSize: 21,
lineHeight: 29,
},
transcriptionScroll: { transcriptionScroll: {
flex: 1, flex: 1,
}, },
@ -4842,7 +5173,7 @@ const styles = StyleSheet.create({
backgroundColor: "#6F778A", backgroundColor: "#6F778A",
}, },
pairSelectDotOnline: { pairSelectDotOnline: {
backgroundColor: "#5CB76D", backgroundColor: AGENT_SUCCESS_GREEN,
}, },
pairSelectDotOffline: { pairSelectDotOffline: {
backgroundColor: "#E35B5B", backgroundColor: "#E35B5B",