feat: improve mobile model download UX and relay defaults

Add in-button model download progress plus a model reset control in mobile-voice, and switch APN relay defaults to apn.dev.opencode.ai in serve and docs.
This commit is contained in:
Ryan Vogel 2026-03-28 14:03:18 -04:00
parent 56e0e5ce65
commit 0051b605ae
4 changed files with 91 additions and 7 deletions

View file

@ -22,7 +22,7 @@ When adding a server, provide:
- APN relay URL
- Relay shared secret
Default APN relay URL: `https://relay.opencode.ai`
Default APN relay URL: `https://apn.dev.opencode.ai`
The app uses these values to:

View file

@ -27,6 +27,7 @@ import { StatusBar } from "expo-status-bar"
import * as Haptics from "expo-haptics"
import { useAudioPlayer } from "expo-audio"
import { useSpeechToText, WHISPER_BASE_EN } from "react-native-executorch"
import { ExpoResourceFetcher } from "react-native-executorch-expo-resource-fetcher"
import { AudioManager, AudioRecorder } from "react-native-audio-api"
import * as Notifications from "expo-notifications"
import Constants from "expo-constants"
@ -56,7 +57,7 @@ const WAVEFORM_CELL_GAP = 2
const DROPDOWN_VISIBLE_ROWS = 6
// If the press duration is shorter than this, treat it as a tap (toggle)
const TAP_THRESHOLD_MS = 300
const DEFAULT_RELAY_URL = "https://relay.opencode.ai"
const DEFAULT_RELAY_URL = "https://apn.dev.opencode.ai"
type ServerItem = {
id: string
@ -107,8 +108,10 @@ function formatSessionUpdated(updatedMs: number): string {
type DropdownMode = "none" | "server" | "session"
export default function DictationScreen() {
const [modelReset, setModelReset] = useState(false)
const model = useSpeechToText({
model: WHISPER_BASE_EN,
preventLoad: modelReset,
})
const [transcribedText, setTranscribedText] = useState("")
@ -497,6 +500,35 @@ export default function DictationScreen() {
setIsSending(false)
}, [clearIconRotation, sendOutProgress, stopRecording])
const handleDeleteModel = useCallback(async () => {
if (modelReset) return
if (isRecordingRef.current) {
stopRecording()
}
setModelReset(true)
accumulatedRef.current = ""
baseTextRef.current = ""
setTranscribedText("")
setHasCompletedSession(false)
const cleared = new Array(waveformLevelsRef.current.length).fill(0)
waveformLevelsRef.current = cleared
setWaveformLevels(cleared)
setWaveformTick(Date.now())
sendOutProgress.value = 0
setIsSending(false)
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {})
try {
await ExpoResourceFetcher.deleteResources(WHISPER_BASE_EN.modelSource, WHISPER_BASE_EN.tokenizerSource)
} catch (err) {
console.error("Failed to delete model resources:", err)
}
setModelReset(false)
}, [modelReset, sendOutProgress, stopRecording])
const resetTranscriptState = useCallback(() => {
if (isRecordingRef.current) {
stopRecording()
@ -759,6 +791,9 @@ export default function DictationScreen() {
}, [stopRecording])
const modelLoading = !model.isReady
const prog = model.downloadProgress > 1 ? model.downloadProgress / 100 : model.downloadProgress
const load = Math.max(0, Math.min(1, Number.isFinite(prog) ? prog : 0))
const pct = Math.round(load * 100)
const hasTranscript = transcribedText.trim().length > 0
const shouldShowSend = hasCompletedSession && hasTranscript
const activeServer = servers.find((s) => s.id === activeServerId) ?? null
@ -1352,6 +1387,16 @@ export default function DictationScreen() {
{/* Transcription area */}
<View style={styles.transcriptionArea}>
<View style={styles.transcriptionTopActions} pointerEvents="box-none">
<Pressable
onPress={() => {
void handleDeleteModel()
}}
style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
hitSlop={8}
disabled={modelLoading || modelReset}
>
<Text style={styles.modelDeleteIcon}>DL</Text>
</Pressable>
<Pressable
onPress={handleClearTranscript}
style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
@ -1403,11 +1448,22 @@ export default function DictationScreen() {
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={!permissionGranted || modelLoading}
style={[styles.recordPressable, (!permissionGranted || modelLoading) && styles.recordButtonDisabled]}
style={[styles.recordPressable, !permissionGranted && styles.recordButtonDisabled]}
>
<View style={styles.recordButton}>
<Animated.View style={[styles.recordBorder, animatedBorderStyle]} pointerEvents="none" />
<Animated.View style={[styles.recordDot, animatedDotStyle]} />
{modelLoading ? (
<>
<View style={[styles.loadFill, { width: `${Math.max(pct, 3)}%` }]} />
<View style={styles.loadOverlay} pointerEvents="none">
<Text style={styles.loadText}>{`Downloading model ${pct}%`}</Text>
</View>
</>
) : (
<>
<Animated.View style={[styles.recordBorder, animatedBorderStyle]} pointerEvents="none" />
<Animated.View style={[styles.recordDot, animatedDotStyle]} />
</>
)}
</View>
</Pressable>
@ -1673,8 +1729,17 @@ const styles = StyleSheet.create({
transcriptionTopActions: {
position: "absolute",
top: 10,
left: 10,
right: 10,
zIndex: 4,
flexDirection: "row",
justifyContent: "space-between",
},
modelDeleteIcon: {
color: "#8FB4FF",
fontSize: 14,
fontWeight: "800",
letterSpacing: 0.6,
},
monitorBadge: {
alignSelf: "flex-start",
@ -1745,6 +1810,25 @@ const styles = StyleSheet.create({
width: "100%",
overflow: "hidden",
},
loadFill: {
position: "absolute",
left: 0,
top: 0,
bottom: 0,
backgroundColor: "#FF5B47",
},
loadOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 18,
},
loadText: {
color: "#FFF6F4",
fontSize: 14,
fontWeight: "700",
letterSpacing: 0.2,
},
sendSlot: {
height: CONTROL_HEIGHT,
overflow: "hidden",

View file

@ -31,7 +31,7 @@ export const ServeCommand = cmd({
const relayURL = (
args["relay-url"] ??
process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ??
"https://relay.opencode.ai"
"https://apn.dev.opencode.ai"
).trim()
const relaySecret = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
if (relayURL && relaySecret) {

View file

@ -142,7 +142,7 @@ This is the minimum setup to get reliable mobile background notifications workin
- Add serve options:
- `--relay-url`
- `--relay-secret` (optional; generate random if missing)
- Default relay URL: `https://relay.opencode.ai`
- Default relay URL: `https://apn.dev.opencode.ai`
- If relay is configured, print QR payload in terminal:
- `hosts` (local LAN and configured host, including Tailscale IP when present)
- `relayURL`