update
parent
15fae6cb60
commit
6494f48136
|
|
@ -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<DropdownMode>("none")
|
||||
const [dropdownRenderMode, setDropdownRenderMode] = useState<Exclude<DropdownMode, "none">>("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() {
|
|||
</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 ? (
|
||||
<View style={styles.splitCardStack}>
|
||||
<View style={[styles.splitCard, styles.replyCard]}>
|
||||
|
|
@ -2743,9 +2963,16 @@ export default function DictationScreen() {
|
|||
</View>
|
||||
<Text style={styles.replyCardLabel}>Agent</Text>
|
||||
</View>
|
||||
<Pressable onPress={handleHideAgentState} hitSlop={8}>
|
||||
<Text style={styles.agentStateClose}>✕</Text>
|
||||
</Pressable>
|
||||
<View style={styles.agentStateActions}>
|
||||
{hasAssistantResponse ? (
|
||||
<Pressable onPress={handleOpenReaderMode} hitSlop={8}>
|
||||
<Text style={styles.agentStateReader}>Reader</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable onPress={handleHideAgentState} hitSlop={8}>
|
||||
<Text style={styles.agentStateClose}>✕</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView style={styles.replyScroll} contentContainerStyle={styles.replyContent}>
|
||||
<Text style={styles.replyText}>{agentStateText}</Text>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue