import React, { useEffect, useState, useRef, useCallback, useMemo } from "react" import { StyleSheet, Text, View, Pressable, ScrollView, Modal, Alert, ActivityIndicator, LayoutChangeEvent, Linking, Platform, } from "react-native" import Animated, { useSharedValue, useAnimatedStyle, withSpring, withSequence, withTiming, Easing, runOnJS, interpolate, Extrapolation, } from "react-native-reanimated" import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context" import { StatusBar } from "expo-status-bar" import { SymbolView } from "expo-symbols" import * as Haptics from "expo-haptics" import { useAudioPlayer } from "expo-audio" import { initWhisper, releaseAllWhisper, type WhisperContext } from "whisper.rn" import { RealtimeTranscriber, type RealtimeTranscribeEvent } from "whisper.rn/src/realtime-transcription" import { AudioPcmStreamAdapter } from "whisper.rn/src/realtime-transcription/adapters/AudioPcmStreamAdapter" import { AudioManager } from "react-native-audio-api" import * as FileSystem from "expo-file-system/legacy" import { fetch as expoFetch } from "expo/fetch" import { unregisterRelayDevice } from "@/lib/relay-client" import { useMonitoring, type MonitorJob } from "@/hooks/use-monitoring" import { looksLikeLocalHost, useServerSessions } from "@/hooks/use-server-sessions" import { ensureNotificationPermissions, getDevicePushToken } from "@/notifications/monitoring-notifications" const CONTROL_HEIGHT = 86 const SEND_SETTLE_MS = 240 const WAVEFORM_ROWS = 5 const WAVEFORM_CELL_SIZE = 8 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 SERVER_STATE_FILE = `${FileSystem.documentDirectory}mobile-voice-servers.json` const WHISPER_SETTINGS_FILE = `${FileSystem.documentDirectory}mobile-voice-whisper-settings.json` const ONBOARDING_STATE_FILE = `${FileSystem.documentDirectory}mobile-voice-onboarding.json` const WHISPER_MODELS_DIR = `${FileSystem.documentDirectory}whisper-models` const WHISPER_REPO = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main" const WHISPER_MODELS = [ "ggml-tiny.en-q5_1.bin", "ggml-tiny.en-q8_0.bin", "ggml-tiny.en.bin", "ggml-tiny-q5_1.bin", "ggml-tiny-q8_0.bin", "ggml-tiny.bin", "ggml-base.en-q5_1.bin", "ggml-base.en-q8_0.bin", "ggml-base.en.bin", "ggml-base-q5_1.bin", "ggml-base-q8_0.bin", "ggml-base.bin", "ggml-small.en-q5_1.bin", "ggml-small.en-q8_0.bin", "ggml-small.en.bin", "ggml-small-q5_1.bin", "ggml-small-q8_0.bin", "ggml-small.bin", "ggml-medium.en-q5_0.bin", "ggml-medium.en-q8_0.bin", "ggml-medium.en.bin", "ggml-medium-q5_0.bin", "ggml-medium-q8_0.bin", "ggml-medium.bin", "ggml-large-v1.bin", "ggml-large-v2-q5_0.bin", "ggml-large-v2-q8_0.bin", "ggml-large-v2.bin", "ggml-large-v3-q5_0.bin", "ggml-large-v3-turbo-q5_0.bin", "ggml-large-v3-turbo-q8_0.bin", "ggml-large-v3-turbo.bin", "ggml-large-v3.bin", ] as const type WhisperModelID = (typeof WHISPER_MODELS)[number] type TranscriptionMode = "bulk" | "realtime" type PermissionPromptState = "idle" | "pending" | "granted" | "denied" const DEFAULT_WHISPER_MODEL: WhisperModelID = "ggml-small-q8_0.bin" const DEFAULT_TRANSCRIPTION_MODE: TranscriptionMode = "bulk" const WHISPER_MODEL_LABELS: Record = { "ggml-tiny.en-q5_1.bin": "tiny.en q5_1", "ggml-tiny.en-q8_0.bin": "tiny.en q8_0", "ggml-tiny.en.bin": "tiny.en", "ggml-tiny-q5_1.bin": "tiny q5_1", "ggml-tiny-q8_0.bin": "tiny q8_0", "ggml-tiny.bin": "tiny", "ggml-base.en-q5_1.bin": "base.en q5_1", "ggml-base.en-q8_0.bin": "base.en q8_0", "ggml-base.en.bin": "base.en", "ggml-base-q5_1.bin": "base q5_1", "ggml-base-q8_0.bin": "base q8_0", "ggml-base.bin": "base", "ggml-small.en-q5_1.bin": "small.en q5_1", "ggml-small.en-q8_0.bin": "small.en q8_0", "ggml-small.en.bin": "small.en", "ggml-small-q5_1.bin": "small q5_1", "ggml-small-q8_0.bin": "small q8_0", "ggml-small.bin": "small", "ggml-medium.en-q5_0.bin": "medium.en q5_0", "ggml-medium.en-q8_0.bin": "medium.en q8_0", "ggml-medium.en.bin": "medium.en", "ggml-medium-q5_0.bin": "medium q5_0", "ggml-medium-q8_0.bin": "medium q8_0", "ggml-medium.bin": "medium", "ggml-large-v1.bin": "large-v1", "ggml-large-v2-q5_0.bin": "large-v2 q5_0", "ggml-large-v2-q8_0.bin": "large-v2 q8_0", "ggml-large-v2.bin": "large-v2", "ggml-large-v3-q5_0.bin": "large-v3 q5_0", "ggml-large-v3-turbo-q5_0.bin": "large-v3 turbo q5_0", "ggml-large-v3-turbo-q8_0.bin": "large-v3 turbo q8_0", "ggml-large-v3-turbo.bin": "large-v3 turbo", "ggml-large-v3.bin": "large-v3", } const WHISPER_MODEL_SIZES: Record = { "ggml-tiny.en-q5_1.bin": 32166155, "ggml-tiny.en-q8_0.bin": 43550795, "ggml-tiny.en.bin": 77704715, "ggml-tiny-q5_1.bin": 32152673, "ggml-tiny-q8_0.bin": 43537433, "ggml-tiny.bin": 77691713, "ggml-base.en-q5_1.bin": 59721011, "ggml-base.en-q8_0.bin": 81781811, "ggml-base.en.bin": 147964211, "ggml-base-q5_1.bin": 59707625, "ggml-base-q8_0.bin": 81768585, "ggml-base.bin": 147951465, "ggml-small.en-q5_1.bin": 190098681, "ggml-small.en-q8_0.bin": 264477561, "ggml-small.en.bin": 487614201, "ggml-small-q5_1.bin": 190085487, "ggml-small-q8_0.bin": 264464607, "ggml-small.bin": 487601967, "ggml-medium.en-q5_0.bin": 539225533, "ggml-medium.en-q8_0.bin": 823382461, "ggml-medium.en.bin": 1533774781, "ggml-medium-q5_0.bin": 539212467, "ggml-medium-q8_0.bin": 823369779, "ggml-medium.bin": 1533763059, "ggml-large-v1.bin": 3094623691, "ggml-large-v2-q5_0.bin": 1080732091, "ggml-large-v2-q8_0.bin": 1656129691, "ggml-large-v2.bin": 3094623691, "ggml-large-v3-q5_0.bin": 1081140203, "ggml-large-v3-turbo-q5_0.bin": 574041195, "ggml-large-v3-turbo-q8_0.bin": 874188075, "ggml-large-v3-turbo.bin": 1624555275, "ggml-large-v3.bin": 3095033483, } function isWhisperModelID(value: unknown): value is WhisperModelID { return typeof value === "string" && (WHISPER_MODELS as readonly string[]).includes(value) } function isEnglishOnlyWhisperModel(modelID: WhisperModelID): boolean { return modelID.includes(".en") } function isTranscriptionMode(value: unknown): value is TranscriptionMode { return value === "bulk" || value === "realtime" } function formatWhisperModelSize(bytes: number): string { const mib = bytes / (1024 * 1024) if (mib >= 1024) { return `${(mib / 1024).toFixed(1)} GB` } return `${Math.round(mib)} MB` } function cleanTranscriptText(text: string): string { return text.replace(/[ \t]+$/gm, "").trimEnd() } function cleanSessionText(text: string): string { return cleanTranscriptText(text).trimStart() } function normalizeTranscriptSessions(text: string): string { const cleaned = cleanTranscriptText(text) if (!cleaned) { return "" } return cleaned .split(/\n\n+/) .map((session) => cleanSessionText(session)) .filter((session) => session.length > 0) .join("\n\n") } function mergeTranscriptChunk(previous: string, chunk: string): string { const cleanPrevious = cleanTranscriptText(previous) const cleanChunk = cleanSessionText(chunk) if (!cleanChunk) { return cleanPrevious } if (!cleanPrevious) { return cleanChunk } const normalizedChunk = cleanChunk if (!normalizedChunk) { return cleanPrevious } if (/^[,.;:!?)]/.test(normalizedChunk)) { return `${cleanPrevious}${normalizedChunk}` } return `${cleanPrevious} ${normalizedChunk}` } function formatSessionUpdated(updatedMs: number): string { if (!updatedMs) return "" const now = Date.now() const deltaMs = Math.max(0, now - updatedMs) const deltaMin = Math.floor(deltaMs / 60000) if (deltaMin < 60) { return `${Math.max(1, deltaMin)} min` } const date = new Date(updatedMs) try { return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit", }).format(date) } catch { return date.toLocaleTimeString() } } type DropdownMode = "none" | "server" | "session" type Pair = { v: 1 serverID?: string relayURL: string relaySecret: string hosts: string[] } type Scan = { data: string } type WhisperSavedState = { defaultModel: WhisperModelID mode: TranscriptionMode } type OnboardingSavedState = { completed: boolean } type Cam = { CameraView: (typeof import("expo-camera"))["CameraView"] requestCameraPermissionsAsync: () => Promise<{ granted: boolean }> } function parsePairShape(data: unknown): Pair | undefined { if (!data || typeof data !== "object") return if ((data as { v?: unknown }).v !== 1) return if (typeof (data as { relayURL?: unknown }).relayURL !== "string") return if (typeof (data as { relaySecret?: unknown }).relaySecret !== "string") return if (!Array.isArray((data as { hosts?: unknown }).hosts)) return const hosts = (data as { hosts: unknown[] }).hosts.filter((item): item is string => typeof item === "string") if (!hosts.length) return const serverIDRaw = (data as { serverID?: unknown }).serverID const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : undefined return { v: 1, serverID, relayURL: (data as { relayURL: string }).relayURL, relaySecret: (data as { relaySecret: string }).relaySecret, hosts, } } function parsePair(input: string): Pair | undefined { const raw = input.trim() if (!raw) return const candidates: string[] = [raw] try { const url = new URL(raw) const query = url.searchParams.get("pair") ?? url.searchParams.get("payload") if (query) { candidates.unshift(query) } } catch { // Raw JSON payload is still supported. } const seen = new Set() for (const candidate of candidates) { if (!candidate || seen.has(candidate)) continue seen.add(candidate) try { const parsed = JSON.parse(candidate) const pair = parsePairShape(parsed) if (pair) { return pair } } catch { // keep trying fallbacks } } } function isLoopback(hostname: string): boolean { return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "0.0.0.0" || hostname === "::1" } /** * Probe all non-loopback hosts in parallel by hitting /health, then * choose the first reachable host based on the original ordering. * This preserves server-side preference (e.g. tailnet before LAN). */ async function pickHost(list: string[]): Promise { const candidates = list.filter((item) => { try { return !isLoopback(new URL(item).hostname) } catch { return false } }) if (!candidates.length) return list[0] const probes = candidates.map(async (host) => { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 3000) try { const res = await fetch(`${host.replace(/\/+$/, "")}/health`, { method: "GET", signal: controller.signal, }) return res.ok } catch { return false } finally { clearTimeout(timeout) } }) for (let index = 0; index < candidates.length; index += 1) { const reachable = await probes[index] if (reachable) { return candidates[index] } } // none reachable — keep first candidate as deterministic fallback try { return candidates[0] } catch { return list[0] } } export default function DictationScreen() { const insets = useSafeAreaInsets() const [camera, setCamera] = useState(null) const [defaultWhisperModel, setDefaultWhisperModel] = useState(DEFAULT_WHISPER_MODEL) const [onboardingReady, setOnboardingReady] = useState(false) const [onboardingComplete, setOnboardingComplete] = useState(false) const [onboardingStep, setOnboardingStep] = useState(0) const [microphonePermissionState, setMicrophonePermissionState] = useState("idle") const [notificationPermissionState, setNotificationPermissionState] = useState("idle") const [localNetworkPermissionState, setLocalNetworkPermissionState] = useState("idle") const [activeWhisperModel, setActiveWhisperModel] = useState(null) const [installedWhisperModels, setInstalledWhisperModels] = useState([]) const [whisperSettingsOpen, setWhisperSettingsOpen] = useState(false) const [downloadingModelID, setDownloadingModelID] = useState(null) const [downloadProgress, setDownloadProgress] = useState(0) const [isPreparingWhisperModel, setIsPreparingWhisperModel] = useState(true) const [transcriptionMode, setTranscriptionMode] = useState(DEFAULT_TRANSCRIPTION_MODE) const [isTranscribingBulk, setIsTranscribingBulk] = useState(false) const [whisperError, setWhisperError] = useState("") const [transcribedText, setTranscribedText] = useState("") const [isRecording, setIsRecording] = useState(false) const [permissionGranted, setPermissionGranted] = useState(false) const [controlsWidth, setControlsWidth] = useState(0) const [hasCompletedSession, setHasCompletedSession] = useState(false) const [isSending, setIsSending] = useState(false) const [agentStateDismissed, setAgentStateDismissed] = useState(false) const [dropdownMode, setDropdownMode] = useState("none") const [dropdownRenderMode, setDropdownRenderMode] = useState>("server") const [scanOpen, setScanOpen] = useState(false) const [camGranted, setCamGranted] = useState(false) const [waveformLevels, setWaveformLevels] = useState(Array.from({ length: 24 }, () => 0)) const [waveformTick, setWaveformTick] = useState(0) const waveformLevelsRef = useRef(Array.from({ length: 24 }, () => 0)) const lastWaveformCommitRef = useRef(0) const sendPlayer = useAudioPlayer(require("../../assets/sounds/send-whoosh.mp3")) const completePlayer = useAudioPlayer(require("../../assets/sounds/complete.wav")) const isRecordingRef = useRef(false) const isStartingRef = useRef(false) const activeSessionRef = useRef(0) const scrollViewRef = useRef(null) const isHoldingRef = useRef(false) const pressInTimeRef = useRef(0) const accumulatedRef = useRef("") const baseTextRef = useRef("") const whisperContextRef = useRef(null) const whisperContextModelRef = useRef(null) const whisperTranscriberRef = useRef(null) const bulkAudioStreamRef = useRef(null) const bulkAudioChunksRef = useRef([]) const bulkTranscriptionJobRef = useRef(0) const downloadProgressRef = useRef(0) const waveformPulseIntervalRef = useRef | null>(null) const sendSettleTimeoutRef = useRef | null>(null) const scanLockRef = useRef(false) const whisperRestoredRef = useRef(false) const closeDropdown = useCallback(() => { setDropdownMode("none") }, []) const { servers, serversRef, activeServerId, setActiveServerId, activeServerIdRef, activeSessionId, setActiveSessionId, activeSessionIdRef, restoredRef, refreshServerStatusAndSessions, refreshAllServerHealth, selectServer, selectSession, removeServer, addServer, findServerForSession, } = useServerSessions() const { beginMonitoring, devicePushToken, latestAssistantResponse, monitorJob, monitorStatus, setDevicePushToken, setMonitorStatus, } = useMonitoring({ completePlayer, closeDropdown, findServerForSession, refreshServerStatusAndSessions, servers, serversRef, restoredRef, activeServerId, activeSessionId, activeServerIdRef, activeSessionIdRef, setActiveServerId, setActiveSessionId, setAgentStateDismissed, setNotificationPermissionState, }) useEffect(() => { let mounted = true void (async () => { let complete = false try { const data = await FileSystem.readAsStringAsync(ONBOARDING_STATE_FILE) if (data) { const parsed = JSON.parse(data) as Partial complete = Boolean(parsed.completed) } } catch { // No onboarding state file yet. } if (!complete) { try { const [serverInfo, whisperInfo] = await Promise.all([ FileSystem.getInfoAsync(SERVER_STATE_FILE), FileSystem.getInfoAsync(WHISPER_SETTINGS_FILE), ]) if (serverInfo.exists || whisperInfo.exists) { complete = true } } catch { // Keep first-install behavior if metadata check fails. } if (complete) { void FileSystem.writeAsStringAsync(ONBOARDING_STATE_FILE, JSON.stringify({ completed: true })).catch(() => {}) } } if (mounted) { setOnboardingComplete(complete) setOnboardingReady(true) } })() return () => { mounted = false } }, []) const modelPath = useCallback((modelID: WhisperModelID) => `${WHISPER_MODELS_DIR}/${modelID}`, []) const refreshInstalledWhisperModels = useCallback(async () => { const next: WhisperModelID[] = [] for (const modelID of WHISPER_MODELS) { try { const info = await FileSystem.getInfoAsync(modelPath(modelID)) if (info.exists) { next.push(modelID) } } catch { // Ignore model metadata read errors. } } setInstalledWhisperModels(next) return next }, [modelPath]) const stopWaveformPulse = useCallback(() => { if (waveformPulseIntervalRef.current) { clearInterval(waveformPulseIntervalRef.current) waveformPulseIntervalRef.current = null } }, []) const clearWaveform = useCallback(() => { const cleared = new Array(waveformLevelsRef.current.length).fill(0) waveformLevelsRef.current = cleared setWaveformLevels(cleared) setWaveformTick(Date.now()) }, []) useEffect(() => { return () => { if (sendSettleTimeoutRef.current) { clearTimeout(sendSettleTimeoutRef.current) } stopWaveformPulse() } }, [stopWaveformPulse]) const ensureAudioInputRoute = useCallback(async () => { try { const devices = await AudioManager.getDevicesInfo() if (devices.currentInputs.length === 0 && devices.availableInputs.length > 0) { const pick = devices.availableInputs[0] await AudioManager.setInputDevice(pick.id) } } catch { // Input route setup is best-effort. } }, []) // Set up audio session and check microphone permissions on mount. useEffect(() => { void (async () => { try { AudioManager.setAudioSessionOptions({ iosCategory: "playAndRecord", iosMode: "spokenAudio", iosOptions: ["allowBluetoothHFP", "defaultToSpeaker"], }) await AudioManager.setAudioSessionActivity(true) const permission = await AudioManager.checkRecordingPermissions() const granted = permission === "Granted" setPermissionGranted(granted) setMicrophonePermissionState(granted ? "granted" : permission === "Denied" ? "denied" : "idle") if (granted) { await ensureAudioInputRoute() } } catch (e) { console.error("Failed to set up audio session:", e) } })() }, [ensureAudioInputRoute]) const loadWhisperContext = useCallback( async (modelID: WhisperModelID) => { if (whisperContextRef.current && whisperContextModelRef.current === modelID) { setActiveWhisperModel(modelID) return whisperContextRef.current } setIsPreparingWhisperModel(true) setWhisperError("") try { const existing = whisperContextRef.current whisperContextRef.current = null whisperContextModelRef.current = null if (existing) { await existing.release().catch(() => {}) } const context = await initWhisper({ filePath: modelPath(modelID), useGpu: Platform.OS === "ios", }) whisperContextRef.current = context whisperContextModelRef.current = modelID setActiveWhisperModel(modelID) return context } catch (error) { const message = error instanceof Error ? error.message : "Failed to load Whisper model" setWhisperError(message) throw error } finally { setIsPreparingWhisperModel(false) } }, [modelPath], ) const downloadWhisperModel = useCallback( async (modelID: WhisperModelID) => { if (downloadingModelID && downloadingModelID !== modelID) { return false } setDownloadingModelID(modelID) downloadProgressRef.current = 0 setDownloadProgress(0) setWhisperError("") try { await FileSystem.makeDirectoryAsync(WHISPER_MODELS_DIR, { intermediates: true }).catch(() => {}) const targetPath = modelPath(modelID) await FileSystem.deleteAsync(targetPath, { idempotent: true }).catch(() => {}) const download = FileSystem.createDownloadResumable( `${WHISPER_REPO}/${modelID}`, targetPath, {}, (event: FileSystem.DownloadProgressData) => { const total = event.totalBytesExpectedToWrite if (!total) return const rawProgress = Math.max(0, Math.min(1, event.totalBytesWritten / total)) const progress = Math.max(downloadProgressRef.current, rawProgress) downloadProgressRef.current = progress setDownloadProgress(progress) }, ) const result = await download.downloadAsync() if (!result?.uri) { throw new Error("Whisper model download did not complete") } await refreshInstalledWhisperModels() return true } catch (error) { const message = error instanceof Error ? error.message : "Failed to download Whisper model" setWhisperError(message) return false } finally { setDownloadingModelID((current) => (current === modelID ? null : current)) } }, [downloadingModelID, modelPath, refreshInstalledWhisperModels], ) const ensureWhisperModelReady = useCallback( async (modelID: WhisperModelID) => { const info = await FileSystem.getInfoAsync(modelPath(modelID)) if (!info.exists) { const downloaded = await downloadWhisperModel(modelID) if (!downloaded) { throw new Error(`Unable to download ${modelID}`) } } return loadWhisperContext(modelID) }, [downloadWhisperModel, loadWhisperContext, modelPath], ) useEffect(() => { let mounted = true void (async () => { await FileSystem.makeDirectoryAsync(WHISPER_MODELS_DIR, { intermediates: true }).catch(() => {}) let nextDefaultModel: WhisperModelID = DEFAULT_WHISPER_MODEL let nextMode: TranscriptionMode = DEFAULT_TRANSCRIPTION_MODE try { const data = await FileSystem.readAsStringAsync(WHISPER_SETTINGS_FILE) if (data) { const parsed = JSON.parse(data) as Partial if (isWhisperModelID(parsed.defaultModel)) { nextDefaultModel = parsed.defaultModel } if (isTranscriptionMode(parsed.mode)) { nextMode = parsed.mode } } } catch { // Use default settings if state file is missing or invalid. } if (!mounted) return whisperRestoredRef.current = true setDefaultWhisperModel(nextDefaultModel) setTranscriptionMode(nextMode) await refreshInstalledWhisperModels() try { await ensureWhisperModelReady(nextDefaultModel) } catch (error) { console.error("[Whisper] Failed to initialize default model:", error) } finally { if (mounted) { setIsPreparingWhisperModel(false) } } })() return () => { mounted = false } }, [ensureWhisperModelReady, refreshInstalledWhisperModels]) useEffect(() => { if (!whisperRestoredRef.current) return const payload: WhisperSavedState = { defaultModel: defaultWhisperModel, mode: transcriptionMode } void FileSystem.writeAsStringAsync(WHISPER_SETTINGS_FILE, JSON.stringify(payload)).catch(() => {}) }, [defaultWhisperModel, transcriptionMode]) useEffect(() => { return () => { const transcriber = whisperTranscriberRef.current whisperTranscriberRef.current = null if (transcriber) { void (async () => { await transcriber.stop().catch(() => {}) await transcriber.release().catch(() => {}) })() } const bulkStream = bulkAudioStreamRef.current bulkAudioStreamRef.current = null if (bulkStream) { void (async () => { await bulkStream.stop().catch(() => {}) await bulkStream.release().catch(() => {}) })() } const context = whisperContextRef.current whisperContextRef.current = null whisperContextModelRef.current = null if (context) { void context.release().catch(() => {}) } void releaseAllWhisper().catch(() => {}) } }, []) const startWaveformPulse = useCallback(() => { if (waveformPulseIntervalRef.current) return waveformPulseIntervalRef.current = setInterval(() => { if (!isRecordingRef.current) return const next = waveformLevelsRef.current.map((value) => { const decay = value * 0.45 const lift = Math.random() * 0.95 return Math.max(0.08, Math.min(1, decay + lift * 0.55)) }) waveformLevelsRef.current = next const now = Date.now() if (now - lastWaveformCommitRef.current > 45) { setWaveformLevels(next) setWaveformTick(now) lastWaveformCommitRef.current = now } }, 70) }, []) const finalizeRecordingState = useCallback(() => { isRecordingRef.current = false activeSessionRef.current = 0 isStartingRef.current = false setIsRecording(false) stopWaveformPulse() clearWaveform() }, [clearWaveform, stopWaveformPulse]) const startRecording = useCallback(async () => { if (isRecordingRef.current || isStartingRef.current || downloadingModelID || isTranscribingBulk) return isStartingRef.current = true const sessionID = Date.now() activeSessionRef.current = sessionID accumulatedRef.current = "" baseTextRef.current = normalizeTranscriptSessions(transcribedText) if (baseTextRef.current !== transcribedText) { setTranscribedText(baseTextRef.current) } isRecordingRef.current = true setIsRecording(true) setWhisperError("") const cancelled = () => !isRecordingRef.current || activeSessionRef.current !== sessionID try { const context = await ensureWhisperModelReady(defaultWhisperModel) if (cancelled()) { isStartingRef.current = false return } const previousTranscriber = whisperTranscriberRef.current whisperTranscriberRef.current = null if (previousTranscriber) { await previousTranscriber.stop().catch(() => {}) await previousTranscriber.release().catch(() => {}) } const previousBulkStream = bulkAudioStreamRef.current bulkAudioStreamRef.current = null if (previousBulkStream) { await previousBulkStream.stop().catch(() => {}) await previousBulkStream.release().catch(() => {}) } bulkAudioChunksRef.current = [] bulkTranscriptionJobRef.current = 0 startWaveformPulse() const englishOnlyModel = isEnglishOnlyWhisperModel(defaultWhisperModel) if (transcriptionMode === "bulk") { const audioStream = new AudioPcmStreamAdapter() audioStream.onData((packet: unknown) => { if (activeSessionRef.current !== sessionID) return const data = (packet as { data?: unknown }).data if (!(data instanceof Uint8Array) || data.length === 0) return bulkAudioChunksRef.current.push(new Uint8Array(data)) }) audioStream.onError((error: string) => { if (activeSessionRef.current !== sessionID) return setWhisperError(error) console.error("[Dictation] Bulk audio stream error:", error) }) await audioStream.initialize({ sampleRate: 16000, channels: 1, bitsPerSample: 16, bufferSize: 16 * 1024, audioSource: 6, }) await audioStream.start() bulkAudioStreamRef.current = audioStream if (cancelled()) { await audioStream.stop().catch(() => {}) await audioStream.release().catch(() => {}) if (bulkAudioStreamRef.current === audioStream) { bulkAudioStreamRef.current = null } finalizeRecordingState() return } isStartingRef.current = false return } const transcriber = new RealtimeTranscriber( { whisperContext: context, audioStream: new AudioPcmStreamAdapter(), }, { audioSliceSec: 4, audioMinSec: 0.8, maxSlicesInMemory: 6, transcribeOptions: { language: englishOnlyModel ? "en" : "auto", translate: !englishOnlyModel, maxLen: 1, }, logger: () => {}, }, { onTranscribe: (event: RealtimeTranscribeEvent) => { if (activeSessionRef.current !== sessionID) return if (event.type !== "transcribe") return const nextSessionText = mergeTranscriptChunk(accumulatedRef.current, event.data?.result ?? "") accumulatedRef.current = nextSessionText const base = normalizeTranscriptSessions(baseTextRef.current) const separator = base.length > 0 && nextSessionText.length > 0 ? "\n\n" : "" setTranscribedText(normalizeTranscriptSessions(base + separator + nextSessionText)) if (nextSessionText.length > 0) { setHasCompletedSession(true) } }, onError: (error: string) => { if (activeSessionRef.current !== sessionID) return console.error("[Dictation] Whisper realtime error:", error) setWhisperError(error) }, onStatusChange: (active: boolean) => { if (activeSessionRef.current !== sessionID) return if (!active) { if (whisperTranscriberRef.current === transcriber) { whisperTranscriberRef.current = null } finalizeRecordingState() } }, }, ) whisperTranscriberRef.current = transcriber await transcriber.start() if (cancelled()) { await transcriber.stop().catch(() => {}) await transcriber.release().catch(() => {}) if (whisperTranscriberRef.current === transcriber) { whisperTranscriberRef.current = null } finalizeRecordingState() return } isStartingRef.current = false } catch (error) { console.error("[Dictation] Failed to start realtime transcription:", error) const message = error instanceof Error ? error.message : "Unable to start transcription" setWhisperError(message) const activeTranscriber = whisperTranscriberRef.current whisperTranscriberRef.current = null if (activeTranscriber) { void (async () => { await activeTranscriber.stop().catch(() => {}) await activeTranscriber.release().catch(() => {}) })() } finalizeRecordingState() void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}) } }, [ defaultWhisperModel, downloadingModelID, ensureWhisperModelReady, finalizeRecordingState, isTranscribingBulk, startWaveformPulse, transcriptionMode, transcribedText, ]) const stopRecording = useCallback(() => { if (!isRecordingRef.current && !isStartingRef.current) return void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}) const baseAtStop = normalizeTranscriptSessions(baseTextRef.current) const englishOnlyModel = isEnglishOnlyWhisperModel(defaultWhisperModel) const transcriber = whisperTranscriberRef.current whisperTranscriberRef.current = null if (transcriber) { void (async () => { await transcriber.stop().catch((error: unknown) => { console.warn("[Dictation] Failed to stop realtime transcription:", error) }) await transcriber.release().catch(() => {}) })() } const bulkStream = bulkAudioStreamRef.current bulkAudioStreamRef.current = null const bulkChunks = bulkAudioChunksRef.current bulkAudioChunksRef.current = [] finalizeRecordingState() if (transcriptionMode !== "bulk") { return } const runID = Date.now() bulkTranscriptionJobRef.current = runID void (async () => { if (bulkStream) { await bulkStream.stop().catch((error: unknown) => { console.warn("[Dictation] Failed to stop bulk audio stream:", error) }) await bulkStream.release().catch(() => {}) } if (bulkChunks.length === 0) { return } const totalLength = bulkChunks.reduce((sum, chunk) => sum + chunk.length, 0) if (totalLength === 0) { return } const merged = new Uint8Array(totalLength) let offset = 0 for (const chunk of bulkChunks) { merged.set(chunk, offset) offset += chunk.length } const context = whisperContextRef.current if (!context) { setWhisperError("Whisper model is not loaded") return } setIsTranscribingBulk(true) try { const { promise } = context.transcribeData(merged.buffer, { language: englishOnlyModel ? "en" : "auto", translate: !englishOnlyModel, maxLen: 1, }) const result = await promise if (bulkTranscriptionJobRef.current !== runID) { return } const sessionText = cleanSessionText(result.result ?? "") if (!sessionText) { return } const separator = baseAtStop.length > 0 ? "\n\n" : "" setTranscribedText(normalizeTranscriptSessions(baseAtStop + separator + sessionText)) setHasCompletedSession(true) } catch (error) { if (bulkTranscriptionJobRef.current !== runID) { return } const message = error instanceof Error ? error.message : "Bulk transcription failed" setWhisperError(message) console.error("[Dictation] Bulk transcription failed:", error) } finally { if (bulkTranscriptionJobRef.current === runID) { setIsTranscribingBulk(false) } } })() }, [defaultWhisperModel, finalizeRecordingState, transcriptionMode]) const clearIconRotation = useSharedValue(0) const sendOutProgress = useSharedValue(0) const handleClearTranscript = useCallback(() => { void Haptics.selectionAsync().catch(() => {}) clearIconRotation.value = withSequence( withTiming(-30, { duration: 90 }), withTiming(30, { duration: 120 }), withTiming(0, { duration: 90 }), ) if (isRecordingRef.current) { stopRecording() } accumulatedRef.current = "" baseTextRef.current = "" setTranscribedText("") setHasCompletedSession(false) clearWaveform() sendOutProgress.value = 0 setIsSending(false) }, [clearIconRotation, clearWaveform, sendOutProgress, stopRecording]) const handleHideAgentState = useCallback(() => { void Haptics.selectionAsync().catch(() => {}) setAgentStateDismissed(true) }, []) const resetTranscriptState = useCallback(() => { if (isRecordingRef.current) { stopRecording() } accumulatedRef.current = "" baseTextRef.current = "" setTranscribedText("") setHasCompletedSession(false) clearWaveform() }, [clearWaveform, stopRecording]) const handleOpenWhisperSettings = useCallback(() => { void Haptics.selectionAsync().catch(() => {}) setDropdownMode("none") setWhisperSettingsOpen(true) }, []) const handleDownloadWhisperModel = useCallback( async (modelID: WhisperModelID) => { const ok = await downloadWhisperModel(modelID) if (ok) { void Haptics.selectionAsync().catch(() => {}) } }, [downloadWhisperModel], ) const handleSelectWhisperModel = useCallback( async (modelID: WhisperModelID) => { if (isRecordingRef.current || isStartingRef.current) { stopRecording() } try { await ensureWhisperModelReady(modelID) setDefaultWhisperModel(modelID) setWhisperError("") void Haptics.selectionAsync().catch(() => {}) } catch (error) { const message = error instanceof Error ? error.message : "Unable to switch Whisper model" setWhisperError(message) } }, [ensureWhisperModelReady, stopRecording], ) const handleDeleteWhisperModel = useCallback( async (modelID: WhisperModelID) => { if (downloadingModelID === modelID) return if (isRecordingRef.current || isStartingRef.current) { stopRecording() } if (whisperContextModelRef.current === modelID && whisperContextRef.current) { const activeContext = whisperContextRef.current whisperContextRef.current = null whisperContextModelRef.current = null setActiveWhisperModel(null) await activeContext.release().catch(() => {}) } await FileSystem.deleteAsync(modelPath(modelID), { idempotent: true }).catch(() => {}) const nextInstalled = await refreshInstalledWhisperModels() if (defaultWhisperModel === modelID) { const fallbackModel = nextInstalled[0] ?? DEFAULT_WHISPER_MODEL setDefaultWhisperModel(fallbackModel) try { await ensureWhisperModelReady(fallbackModel) } catch { // Keep UI responsive if fallback init fails. } } else if (activeWhisperModel == null && nextInstalled.includes(defaultWhisperModel)) { try { await ensureWhisperModelReady(defaultWhisperModel) } catch { // Keep UI responsive if default model init fails. } } void Haptics.selectionAsync().catch(() => {}) }, [ activeWhisperModel, defaultWhisperModel, downloadingModelID, ensureWhisperModelReady, modelPath, refreshInstalledWhisperModels, stopRecording, ], ) const handleRequestNotificationPermission = useCallback(async () => { if (notificationPermissionState === "pending") return setNotificationPermissionState("pending") try { const granted = await ensureNotificationPermissions() setNotificationPermissionState(granted ? "granted" : "denied") if (!granted) { return } const token = await getDevicePushToken() if (token) { setDevicePushToken(token) } } catch { setNotificationPermissionState("denied") } }, [notificationPermissionState, setDevicePushToken]) const handleRequestMicrophonePermission = useCallback(async () => { if (microphonePermissionState === "pending") return setMicrophonePermissionState("pending") try { const permission = await AudioManager.requestRecordingPermissions() const granted = permission === "Granted" setPermissionGranted(granted) setMicrophonePermissionState(granted ? "granted" : "denied") if (granted) { await ensureAudioInputRoute() } } catch { setPermissionGranted(false) setMicrophonePermissionState("denied") } }, [ensureAudioInputRoute, microphonePermissionState]) const handleRequestLocalNetworkPermission = useCallback(async () => { if (localNetworkPermissionState === "pending") return setLocalNetworkPermissionState("pending") const localProbes = new Set([ "http://192.168.1.1", "http://192.168.0.1", "http://10.0.0.1", "http://100.100.100.100", ]) for (const server of serversRef.current) { try { const url = new URL(server.url) if (looksLikeLocalHost(url.hostname)) { localProbes.add(`${url.protocol}//${url.host}`) } } catch { // Skip malformed saved server URL. } } const controller = new AbortController() const timeout = setTimeout(() => { controller.abort() }, 1800) try { await Promise.allSettled( [...localProbes].map((base) => expoFetch(`${base.replace(/\/+$/, "")}/health`, { method: "GET", signal: controller.signal, }), ), ) setLocalNetworkPermissionState("granted") } catch { setLocalNetworkPermissionState("denied") } finally { clearTimeout(timeout) } }, [localNetworkPermissionState, serversRef]) const completeSend = useCallback(() => { if (sendSettleTimeoutRef.current) { clearTimeout(sendSettleTimeoutRef.current) } sendSettleTimeoutRef.current = setTimeout(() => { resetTranscriptState() sendOutProgress.value = 0 setIsSending(false) sendSettleTimeoutRef.current = null }, SEND_SETTLE_MS) }, [resetTranscriptState, sendOutProgress]) const handleSendTranscript = useCallback(async () => { const text = transcribedText.trim() if (text.length === 0 || isSending || !activeServerId || !activeSessionId) return const server = serversRef.current.find((item) => item.id === activeServerId) if (!server) return const session = server.sessions.find((item) => item.id === activeSessionId) if (!session) return const base = server.url.replace(/\/+$/, "") setIsSending(true) setMonitorStatus("Sending prompt…") try { const response = await fetch(`${base}/session/${session.id}/prompt_async`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ parts: [ { type: "text", text, }, ], }), }) if (!response.ok) { throw new Error(`Prompt request failed (${response.status})`) } const nextJob: MonitorJob = { id: `job-${Date.now()}`, sessionID: session.id, opencodeBaseURL: base, startedAt: Date.now(), } await beginMonitoring(nextJob) if (server.relaySecret.trim().length === 0) { setMonitorStatus("Monitoring (foreground only)") } void sendPlayer.seekTo(0) void sendPlayer.play() void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy).catch(() => {}) setTimeout(() => { void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}) }, 70) sendOutProgress.value = withTiming( 1, { duration: 320, easing: Easing.bezier(0.2, 0.8, 0.2, 1), }, (finished) => { if (finished) { runOnJS(completeSend)() } }, ) } catch { setMonitorStatus("Failed to send prompt") void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}) setIsSending(false) sendOutProgress.value = 0 } }, [ activeServerId, activeSessionId, beginMonitoring, completeSend, isSending, serversRef, setMonitorStatus, sendOutProgress, sendPlayer, transcribedText, ]) // --- Gesture handling: tap vs hold --- const handlePressIn = useCallback(() => { pressInTimeRef.current = Date.now() if (isRecordingRef.current) return setDropdownMode("none") void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}) isHoldingRef.current = true void startRecording() }, [startRecording]) const handlePressOut = useCallback(() => { const pressDuration = Date.now() - pressInTimeRef.current if (pressDuration < TAP_THRESHOLD_MS) { if (isHoldingRef.current) { // Tap started recording on pressIn -- keep it running (toggle ON) isHoldingRef.current = false } else { // Already recording from a previous tap -- this tap stops it stopRecording() } } else { // Long press = hold-to-record, stop on release isHoldingRef.current = false stopRecording() } }, [stopRecording]) const modelDownloading = downloadingModelID !== null const modelLoading = isPreparingWhisperModel || activeWhisperModel == null || modelDownloading || isTranscribingBulk let modelLoadingState: "downloading" | "loading" | "ready" = "ready" if (modelDownloading) { modelLoadingState = "downloading" } else if (modelLoading) { modelLoadingState = "loading" } const pct = Math.round(Math.max(0, Math.min(1, downloadProgress)) * 100) const loadingModelLabel = downloadingModelID ? WHISPER_MODEL_LABELS[downloadingModelID] : WHISPER_MODEL_LABELS[defaultWhisperModel] const hasTranscript = transcribedText.trim().length > 0 const hasAssistantResponse = latestAssistantResponse.trim().length > 0 const hasAgentActivity = hasAssistantResponse || monitorStatus.trim().length > 0 || monitorJob !== null const shouldShowAgentStateCard = hasAgentActivity && !agentStateDismissed const showsCompleteState = monitorStatus.toLowerCase().includes("complete") let agentStateIcon: "loading" | "done" = "loading" if (monitorJob === null && (hasAssistantResponse || showsCompleteState)) { agentStateIcon = "done" } const agentStateText = hasAssistantResponse ? latestAssistantResponse : "Waiting for agent…" const shouldShowSend = hasCompletedSession && hasTranscript const activeServer = servers.find((s) => s.id === activeServerId) ?? null const activeSession = activeServer?.sessions.find((s) => s.id === activeSessionId) ?? null const canSendToSession = !!activeServer && activeServer.status === "online" && !!activeSession const isDropdownOpen = dropdownMode !== "none" const effectiveDropdownMode = isDropdownOpen ? dropdownMode : dropdownRenderMode const headerTitle = activeServer?.name ?? "No server configured" let headerDotStyle = styles.serverStatusOffline if (activeServer?.status === "online") { headerDotStyle = styles.serverStatusActive } else if (activeServer?.status === "checking") { headerDotStyle = styles.serverStatusChecking } const recordingProgress = useSharedValue(0) const sendVisibility = useSharedValue(hasTranscript ? 1 : 0) const waveformVisibility = useSharedValue(0) const serverMenuProgress = useSharedValue(0) useEffect(() => { recordingProgress.value = withSpring(isRecording ? 1 : 0, { damping: 14, stiffness: 140, mass: 0.8, }) }, [isRecording, recordingProgress]) useEffect(() => { const isGenerating = isRecording waveformVisibility.value = withTiming(isGenerating ? 1 : 0, { duration: isGenerating ? 180 : 240, easing: Easing.inOut(Easing.quad), }) }, [isRecording, waveformVisibility]) useEffect(() => { serverMenuProgress.value = withTiming(isDropdownOpen ? 1 : 0, { duration: isDropdownOpen ? 240 : 240, easing: isDropdownOpen ? Easing.bezier(0.2, 0.8, 0.2, 1) : Easing.bezier(0.4, 0, 0.2, 1), }) }, [isDropdownOpen, serverMenuProgress]) useEffect(() => { if (dropdownMode !== "none") { setDropdownRenderMode(dropdownMode) } }, [dropdownMode]) useEffect(() => { sendVisibility.value = shouldShowSend ? withTiming(1, { duration: 320, easing: Easing.bezier(0.2, 0.8, 0.2, 1), }) : withTiming(0, { duration: 360, easing: Easing.bezier(0.22, 0.61, 0.36, 1), }) }, [shouldShowSend, sendVisibility]) // Parent clips outer half of center-stroke, so only inner half is visible. // borderWidth 6 → 3px visible inward, borderWidth 12 → 6px visible inward. const animatedBorderStyle = useAnimatedStyle(() => { const progress = recordingProgress.value // Width: 3 → ~1.5px visible inward at rest (matches other cards), // 12 → ~6px visible inward when active const bw = interpolate(progress, [0, 1], [3, 12], Extrapolation.CLAMP) return { borderWidth: bw, borderColor: "#FF2E3F", } }) const animatedDotStyle = useAnimatedStyle(() => ({ borderRadius: interpolate(recordingProgress.value, [0, 1], [19, 2], Extrapolation.CLAMP), })) const animatedClearIconStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${clearIconRotation.value}deg` }], })) const animatedSendStyle = useAnimatedStyle(() => ({ width: interpolate(sendVisibility.value, [0, 1], [0, Math.max((controlsWidth - 8) / 2, 0)], Extrapolation.CLAMP), marginLeft: interpolate(sendVisibility.value, [0, 1], [0, 8], Extrapolation.CLAMP), opacity: sendVisibility.value, transform: [ { translateX: interpolate(sendVisibility.value, [0, 1], [14, 0], Extrapolation.CLAMP), }, { scale: interpolate(sendVisibility.value, [0, 1], [0.98, 1], Extrapolation.CLAMP), }, ], })) const animatedTranscriptSendStyle = useAnimatedStyle(() => ({ opacity: interpolate(sendOutProgress.value, [0, 1], [1, 0], Extrapolation.CLAMP), transform: [ { translateY: interpolate(sendOutProgress.value, [0, 1], [0, -44], Extrapolation.CLAMP), }, ], })) const animatedWaveformRowStyle = useAnimatedStyle(() => ({ opacity: waveformVisibility.value, transform: [ { translateY: interpolate(waveformVisibility.value, [0, 1], [6, 0], Extrapolation.CLAMP), }, ], })) const menuRows = effectiveDropdownMode === "server" ? Math.max(servers.length, 1) : Math.max(activeServer?.sessions.length ?? 0, 1) const expandedRowsHeight = Math.min(menuRows, DROPDOWN_VISIBLE_ROWS) * 42 const addServerExtraHeight = effectiveDropdownMode === "server" ? 38 : 8 const expandedHeaderHeight = 51 + 12 + expandedRowsHeight + addServerExtraHeight const animatedHeaderStyle = useAnimatedStyle(() => ({ height: interpolate(serverMenuProgress.value, [0, 1], [51, expandedHeaderHeight], Extrapolation.CLAMP), })) const animatedServerMenuStyle = useAnimatedStyle(() => ({ opacity: serverMenuProgress.value, transform: [ { translateY: interpolate(serverMenuProgress.value, [0, 1], [-8, 0], Extrapolation.CLAMP), }, ], })) const animatedHeaderShadowStyle = useAnimatedStyle(() => ({ shadowOpacity: interpolate(serverMenuProgress.value, [0, 1], [0, 0.35], Extrapolation.CLAMP), shadowRadius: interpolate(serverMenuProgress.value, [0, 1], [0, 18], Extrapolation.CLAMP), elevation: interpolate(serverMenuProgress.value, [0, 1], [0, 16], Extrapolation.CLAMP), })) const waveformColumnMeta = useMemo( () => Array.from({ length: waveformLevels.length }, () => ({ delay: Math.random() * 1.5, duration: 1 + Math.random(), phase: Math.random() * Math.PI * 2, })), [waveformLevels.length], ) const getWaveformCellStyle = useCallback( (row: number, col: number) => { const level = waveformLevels[col] ?? 0 const rowFromBottom = WAVEFORM_ROWS - 1 - row const intensity = Math.max(0, Math.min(1, level * WAVEFORM_ROWS - rowFromBottom)) const meta = waveformColumnMeta[col] const t = waveformTick / 1000 const basePhase = (Math.max(0, t - meta.delay) / meta.duration) * Math.PI * 2 + meta.phase + row * 0.35 const pulse = 0.5 + 0.5 * Math.sin(basePhase) let alpha = 0.08 if (intensity > 0) { alpha = (0.4 + intensity * 0.6) * (0.85 + pulse * 0.15) } else if (isRecording) { alpha = 0.1 + pulse * 0.07 } // Base palette around #78839A, with brighter/lower variants by intensity. const baseR = 120 const baseG = 131 const baseB = 154 const lift = Math.round(intensity * 28) const r = Math.min(255, baseR + lift) const g = Math.min(255, baseG + lift) const b = Math.min(255, baseB + lift) return { backgroundColor: `rgba(${r}, ${g}, ${b}, ${alpha})`, borderColor: `rgba(${Math.min(255, r + 8)}, ${Math.min(255, g + 8)}, ${Math.min(255, b + 8)}, ${Math.min(1, alpha + 0.16)})`, } }, [isRecording, waveformColumnMeta, waveformLevels, waveformTick], ) const handleControlsLayout = useCallback((event: LayoutChangeEvent) => { setControlsWidth(event.nativeEvent.layout.width) }, []) const handleWaveformLayout = useCallback((event: LayoutChangeEvent) => { const width = event.nativeEvent.layout.width const columns = Math.max(14, Math.floor((width + WAVEFORM_CELL_GAP) / (WAVEFORM_CELL_SIZE + WAVEFORM_CELL_GAP))) if (columns === waveformLevelsRef.current.length) return const next = Array.from({ length: columns }, () => 0) waveformLevelsRef.current = next setWaveformLevels(next) }, []) const toggleServerMenu = useCallback(() => { void Haptics.selectionAsync().catch(() => {}) setDropdownMode((prev) => { const next = prev === "server" ? "none" : "server" if (next === "server") { setDropdownRenderMode("server") } if (next === "server") { refreshAllServerHealth() } return next }) }, [refreshAllServerHealth]) const toggleSessionMenu = useCallback(() => { if (!activeServer || activeServer.status !== "online") return void Haptics.selectionAsync().catch(() => {}) void refreshServerStatusAndSessions(activeServer.id) setDropdownRenderMode("session") setDropdownMode((prev) => (prev === "session" ? "none" : "session")) }, [activeServer, refreshServerStatusAndSessions]) const handleSelectServer = useCallback( (id: string) => { selectServer(id) setDropdownMode("none") void refreshServerStatusAndSessions(id) }, [refreshServerStatusAndSessions, selectServer], ) const handleSelectSession = useCallback( (id: string) => { selectSession(id) setDropdownMode("none") }, [selectSession], ) const handleDeleteServer = useCallback( (id: string) => { const server = serversRef.current.find((s) => s.id === id) if (server && devicePushToken && server.relaySecret.trim().length > 0) { unregisterRelayDevice({ relayBaseURL: server.relayURL, secret: server.relaySecret.trim(), deviceToken: devicePushToken, }).catch(() => {}) } removeServer(id) }, [devicePushToken, removeServer, serversRef], ) const handleStartScan = useCallback(async () => { scanLockRef.current = false const current = camera ?? (await import("expo-camera") .catch(() => null) .then((mod) => { if (!mod) return null const direct = (mod as { requestCameraPermissionsAsync?: unknown }).requestCameraPermissionsAsync const fromCamera = (mod as { Camera?: { requestCameraPermissionsAsync?: unknown } }).Camera ?.requestCameraPermissionsAsync let requestCameraPermissionsAsync: (() => Promise<{ granted: boolean }>) | null = null if (typeof direct === "function") { requestCameraPermissionsAsync = direct as () => Promise<{ granted: boolean }> } else if (typeof fromCamera === "function") { requestCameraPermissionsAsync = fromCamera as () => Promise<{ granted: boolean }> } if (!requestCameraPermissionsAsync) { return null } const next = { CameraView: mod.CameraView, requestCameraPermissionsAsync, } setCamera(next) return next })) if (!current) { Alert.alert("Scanner unavailable", "This build does not include camera support. Reinstall the latest dev build.") return } if (camGranted) { setScanOpen(true) return } const res = await current.requestCameraPermissionsAsync() if (!res.granted) return setCamGranted(true) setScanOpen(true) }, [camGranted, camera]) const completeOnboarding = useCallback( (openScanner: boolean) => { setOnboardingComplete(true) void FileSystem.writeAsStringAsync(ONBOARDING_STATE_FILE, JSON.stringify({ completed: true })).catch(() => {}) if (openScanner) { void handleStartScan() } }, [handleStartScan], ) const handleReplayOnboarding = useCallback(() => { setWhisperSettingsOpen(false) setScanOpen(false) setDropdownMode("none") setOnboardingStep(0) setMicrophonePermissionState(permissionGranted ? "granted" : "idle") setNotificationPermissionState("idle") setLocalNetworkPermissionState("idle") setOnboardingReady(true) setOnboardingComplete(false) void FileSystem.deleteAsync(ONBOARDING_STATE_FILE, { idempotent: true }).catch(() => {}) }, [permissionGranted]) const connectPairPayload = useCallback( (rawData: string, source: "scan" | "link") => { const fromScan = source === "scan" if (fromScan && scanLockRef.current) return if (fromScan) { scanLockRef.current = true } const pair = parsePair(rawData) if (!pair) { if (fromScan) { void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}) setTimeout(() => { scanLockRef.current = false }, 750) } return } void pickHost(pair.hosts) .then((host) => { if (!host) { if (fromScan) { scanLockRef.current = false } return } const ok = addServer(host, pair.relayURL, pair.relaySecret, pair.serverID) if (!ok) { if (fromScan) { scanLockRef.current = false } return } setScanOpen(false) void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}) }) .catch(() => { if (fromScan) { scanLockRef.current = false } }) }, [addServer], ) const handleScan = useCallback( (event: Scan) => { connectPairPayload(event.data, "scan") }, [connectPairPayload], ) useEffect(() => { if (scanOpen) return scanLockRef.current = false }, [scanOpen]) useEffect(() => { let active = true const handleURL = async (url: string | null) => { if (!url) return if (!parsePair(url)) return if (!restoredRef.current) { for (let attempt = 0; attempt < 20; attempt += 1) { await new Promise((resolve) => setTimeout(resolve, 100)) if (restoredRef.current || !active) { break } } } if (!active) return connectPairPayload(url, "link") } void Linking.getInitialURL() .then((url) => handleURL(url)) .catch(() => {}) const sub = Linking.addEventListener("url", (event) => { void handleURL(event.url) }) return () => { active = false sub.remove() } }, [connectPairPayload, restoredRef]) useEffect(() => { if (!activeServerId) return void refreshServerStatusAndSessions(activeServerId) const timer = setInterval(() => { void refreshServerStatusAndSessions(activeServerId) }, 15000) return () => clearInterval(timer) }, [activeServerId, refreshServerStatusAndSessions]) const defaultModelInstalled = installedWhisperModels.includes(defaultWhisperModel) let onboardingProgressRaw = 0 if (downloadingModelID) { onboardingProgressRaw = downloadProgress } else if (defaultModelInstalled || activeWhisperModel === defaultWhisperModel) { onboardingProgressRaw = 1 } else if (isPreparingWhisperModel) { onboardingProgressRaw = 0.12 } const onboardingProgress = Math.max(0, Math.min(1, onboardingProgressRaw)) const onboardingProgressPct = Math.round(onboardingProgress * 100) let onboardingModelStatus = "Downloading model in background" if (downloadingModelID) { onboardingModelStatus = `Downloading model in background ${onboardingProgressPct}%` } else if (onboardingProgress >= 1) { onboardingModelStatus = "Model ready in background" } const onboardingSteps = [ { title: "Allow mic access.", body: "Control only listens while you hold the record button.", primaryLabel: microphonePermissionState === "pending" ? "Requesting microphone..." : "Allow microphone", primaryDisabled: microphonePermissionState === "pending", secondaryLabel: "Continue without granting", visualTag: "MIC", visualSurfaceStyle: styles.onboardingVisualSurfaceMic, visualOrbStyle: styles.onboardingVisualOrbMic, visualTagStyle: styles.onboardingVisualTagMic, }, { title: "Turn on notifications.", body: "Get alerts when your OpenCode run finishes, fails, or needs your attention.", primaryLabel: notificationPermissionState === "pending" ? "Requesting notifications..." : "Allow notifications", primaryDisabled: notificationPermissionState === "pending", secondaryLabel: "Continue without granting", visualTag: "PUSH", visualSurfaceStyle: styles.onboardingVisualSurfaceNotifications, visualOrbStyle: styles.onboardingVisualOrbNotifications, visualTagStyle: styles.onboardingVisualTagNotifications, }, { title: "Enable local network.", body: "This lets Control discover your machine on the same network.", primaryLabel: localNetworkPermissionState === "pending" ? "Requesting local network..." : "Allow local network", primaryDisabled: localNetworkPermissionState === "pending", secondaryLabel: "Continue without granting", visualTag: "LAN", visualSurfaceStyle: styles.onboardingVisualSurfaceNetwork, visualOrbStyle: styles.onboardingVisualOrbNetwork, visualTagStyle: styles.onboardingVisualTagNetwork, }, { title: "Pair your computer.", body: "Start `opencode serve` on your computer, then scan the QR code to pair.", primaryLabel: "Scan OpenCode QR", primaryDisabled: false, secondaryLabel: "I will do this later", visualTag: "PAIR", visualSurfaceStyle: styles.onboardingVisualSurfacePair, visualOrbStyle: styles.onboardingVisualOrbPair, visualTagStyle: styles.onboardingVisualTagPair, }, ] as const const onboardingStepCount = onboardingSteps.length const clampedOnboardingStep = Math.max(0, Math.min(onboardingStep, onboardingStepCount - 1)) const onboardingCurrentStep = onboardingSteps[clampedOnboardingStep] const { title: onboardingTitle, body: onboardingBody, primaryLabel: onboardingPrimaryLabel, primaryDisabled: onboardingPrimaryDisabled, secondaryLabel: onboardingSecondaryLabel, visualTag: onboardingVisualTag, visualSurfaceStyle: onboardingVisualSurfaceStyle, visualOrbStyle: onboardingVisualOrbStyle, visualTagStyle: onboardingVisualTagStyle, } = onboardingCurrentStep const onboardingSafeStyle = useMemo( () => [styles.onboardingRoot, { paddingTop: insets.top + 8, paddingBottom: Math.max(insets.bottom, 16) }], [insets.bottom, insets.top], ) if (!onboardingReady) { return ( ) } if (!onboardingComplete) { return ( {onboardingModelStatus} 0 ? 6 : 0)}%` }, ]} /> {onboardingVisualTag} {`STEP ${clampedOnboardingStep + 1} OF ${onboardingStepCount}`} {onboardingTitle} {onboardingBody} { if (clampedOnboardingStep === 0) { void (async () => { await handleRequestMicrophonePermission() setOnboardingStep(1) })() return } if (clampedOnboardingStep === 1) { void (async () => { await handleRequestNotificationPermission() setOnboardingStep(2) })() return } if (clampedOnboardingStep === 2) { void (async () => { await handleRequestLocalNetworkPermission() setOnboardingStep(3) })() return } completeOnboarding(true) }} style={({ pressed }) => [ styles.onboardingPrimaryButton, onboardingPrimaryDisabled && styles.onboardingPrimaryButtonDisabled, pressed && styles.clearButtonPressed, ]} disabled={onboardingPrimaryDisabled} > {onboardingPrimaryLabel} { if (clampedOnboardingStep < onboardingStepCount - 1) { setOnboardingStep((step) => Math.min(step + 1, onboardingStepCount - 1)) return } completeOnboarding(false) }} style={({ pressed }) => [styles.onboardingSecondaryButton, pressed && styles.clearButtonPressed]} > {onboardingSecondaryLabel} ) } return ( {isDropdownOpen ? : null} {/* Workspace header */} {activeServer ? ( [styles.headerSplitLeft, pressed && styles.clearButtonPressed]} > {activeServer.name} [styles.headerSplitRight, pressed && styles.clearButtonPressed]} > {activeSession?.title ?? "Select session"} ) : ( [styles.statusBarTapArea, pressed && styles.clearButtonPressed]} > {headerTitle} )} {effectiveDropdownMode === "server" ? ( servers.length === 0 ? ( No servers yet ) : ( servers.map((server) => ( handleSelectServer(server.id)} style={({ pressed }) => [styles.serverRow, pressed && styles.serverRowPressed]} > {server.name} handleDeleteServer(server.id)} hitSlop={8}> )) ) ) : activeServer ? ( activeServer.sessions.length === 0 ? ( activeServer.sessionsLoading ? null : ( No sessions available ) ) : ( activeServer.sessions.map((session, index) => ( handleSelectSession(session.id)} style={({ pressed }) => [ styles.serverRow, index === activeServer.sessions.length - 1 && styles.serverRowLast, pressed && styles.serverRowPressed, ]} > {session.title} {formatSessionUpdated(session.updated)} )) ) ) : ( Select a server first )} {effectiveDropdownMode === "server" ? ( void handleStartScan()} style={styles.addServerButton}> Add server by scanning QR code ) : null} {/* Transcription area */} {shouldShowAgentStateCard ? ( {agentStateIcon === "loading" ? ( ) : ( )} Agent {agentStateText} [styles.clearButton, pressed && styles.clearButtonPressed]} hitSlop={8} > [styles.clearButton, pressed && styles.clearButtonPressed]} hitSlop={8} > {whisperError ? ( {whisperError} ) : null} scrollViewRef.current?.scrollToEnd({ animated: true })} > {transcribedText ? ( {transcribedText} ) : ( Your transcription will appear here… )} {Array.from({ length: WAVEFORM_ROWS }).map((_, row) => ( {waveformLevels.map((_, col) => ( ))} ))} ) : ( [styles.clearButton, pressed && styles.clearButtonPressed]} hitSlop={8} > [styles.clearButton, pressed && styles.clearButtonPressed]} hitSlop={8} > {whisperError ? ( {whisperError} ) : null} scrollViewRef.current?.scrollToEnd({ animated: true })} > {transcribedText ? ( {transcribedText} ) : ( Your transcription will appear here… )} {Array.from({ length: WAVEFORM_ROWS }).map((_, row) => ( {waveformLevels.map((_, col) => ( ))} ))} )} {/* Record button */} {isTranscribingBulk ? ( ) : modelLoadingState !== "ready" ? ( <> {modelLoadingState === "downloading" ? `Downloading ${loadingModelLabel} ${pct}%` : `Loading ${loadingModelLabel}`} ) : ( <> )} [ styles.sendButton, (isSending || !hasTranscript || !canSendToSession) && styles.sendButtonDisabled, pressed && styles.clearButtonPressed, ]} disabled={isSending || !hasTranscript || !canSendToSession} hitSlop={8} > setWhisperSettingsOpen(false)} > Settings Default: {WHISPER_MODEL_LABELS[defaultWhisperModel]} setWhisperSettingsOpen(false)}> Done DEVELOPMENT: {__DEV__ ? ( [styles.settingsTextRow, pressed && styles.clearButtonPressed]} > Replay onboarding Run ) : ( Available in development builds. )} GENERAL: Default model {WHISPER_MODEL_LABELS[defaultWhisperModel]} setTranscriptionMode("bulk")} disabled={isRecording || isTranscribingBulk} style={({ pressed }) => [ styles.settingsTextRow, (isRecording || isTranscribingBulk) && styles.settingsInlinePressableDisabled, pressed && styles.clearButtonPressed, ]} > On Release Transcribe after release {transcriptionMode === "bulk" ? "Selected" : "Use"} setTranscriptionMode("realtime")} disabled={isRecording || isTranscribingBulk} style={({ pressed }) => [ styles.settingsTextRow, (isRecording || isTranscribingBulk) && styles.settingsInlinePressableDisabled, pressed && styles.clearButtonPressed, ]} > Realtime Transcribe while you speak {transcriptionMode === "realtime" ? "Selected" : "Use"} MODELS: {WHISPER_MODELS.map((modelID) => { const installed = installedWhisperModels.includes(modelID) const isDefault = defaultWhisperModel === modelID const isDownloading = downloadingModelID === modelID const actionDisabled = (downloadingModelID !== null && !isDownloading) || isTranscribingBulk const downloadPct = Math.round(Math.max(0, Math.min(1, downloadProgress)) * 100) const actionLabel = isDownloading ? `${downloadPct}%` : installed ? isDefault ? "Selected" : "Select" : "Download" const sizeLabel = formatWhisperModelSize(WHISPER_MODEL_SIZES[modelID]) const rowMeta = [sizeLabel, installed ? "installed" : null, isDefault ? "default" : null] .filter(Boolean) .join(" · ") return ( { if (installed) { void handleSelectWhisperModel(modelID) } }} onLongPress={() => { if (!installed || isDownloading) return Alert.alert("Delete model?", `Remove ${modelID} from this device?`, [ { text: "Cancel", style: "cancel" }, { text: "Delete", style: "destructive", onPress: () => { void handleDeleteWhisperModel(modelID) }, }, ]) }} delayLongPress={350} disabled={!installed || actionDisabled || isPreparingWhisperModel} style={({ pressed }) => [ styles.settingsInlineLabelPressable, (!installed || actionDisabled || isPreparingWhisperModel) && styles.settingsInlinePressableDisabled, pressed && styles.clearButtonPressed, ]} > {modelID} {rowMeta} { if (isDownloading) return if (installed) { void handleSelectWhisperModel(modelID) return } void handleDownloadWhisperModel(modelID) }} disabled={actionDisabled || (installed && isPreparingWhisperModel)} accessibilityLabel={actionLabel} style={({ pressed }) => [ styles.settingsInlineTextActionPressable, (actionDisabled || (installed && isPreparingWhisperModel)) && styles.settingsInlinePressableDisabled, pressed && styles.clearButtonPressed, ]} > {actionLabel} ) })} setScanOpen(false)} > Scan server QR setScanOpen(false)}> Close {camGranted && camera ? ( ) : ( Camera permission is required to scan setup QR codes. )} ) } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#121212", position: "relative", }, onboardingRoot: { flex: 1, backgroundColor: "#121212", paddingHorizontal: 16, }, onboardingShell: { flex: 1, }, onboardingTopRail: { gap: 8, marginBottom: 10, }, onboardingContent: { flex: 1, justifyContent: "center", alignItems: "stretch", gap: 22, paddingHorizontal: 2, }, onboardingModelRow: { gap: 6, }, onboardingModelText: { color: "#A9A9A9", fontSize: 11, fontWeight: "700", letterSpacing: 0.35, textTransform: "uppercase", }, onboardingModelTrack: { height: 4, width: "100%", borderRadius: 999, backgroundColor: "#2C2C2C", overflow: "hidden", }, onboardingModelFill: { height: "100%", borderRadius: 999, backgroundColor: "#FF5B47", }, onboardingVisualSurface: { width: "100%", minHeight: 176, borderRadius: 26, borderWidth: 1, alignItems: "center", justifyContent: "center", overflow: "hidden", backgroundColor: "#171717", borderColor: "#2B2B2B", }, onboardingVisualSurfaceMic: { backgroundColor: "#1A2118", borderColor: "#2F3D2D", }, onboardingVisualSurfaceNotifications: { backgroundColor: "#1A1D2A", borderColor: "#303A5A", }, onboardingVisualSurfaceNetwork: { backgroundColor: "#1A2218", borderColor: "#344930", }, onboardingVisualSurfacePair: { backgroundColor: "#1F1A27", borderColor: "#413157", }, onboardingVisualOrb: { position: "absolute", borderRadius: 999, opacity: 0.22, }, onboardingVisualOrbOne: { width: 130, height: 130, top: -28, left: -22, }, onboardingVisualOrbTwo: { width: 160, height: 160, bottom: -52, right: -44, }, onboardingVisualOrbMic: { backgroundColor: "#61C372", }, onboardingVisualOrbNotifications: { backgroundColor: "#4A6EE0", }, onboardingVisualOrbNetwork: { backgroundColor: "#78B862", }, onboardingVisualOrbPair: { backgroundColor: "#9B6CDC", }, onboardingVisualTag: { borderRadius: 20, paddingHorizontal: 24, paddingVertical: 12, borderWidth: 1, shadowColor: "#000000", shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.12, shadowRadius: 16, elevation: 3, }, onboardingVisualTagMic: { backgroundColor: "#253A25", borderColor: "#3A5C3A", }, onboardingVisualTagNotifications: { backgroundColor: "#223561", borderColor: "#38518C", }, onboardingVisualTagNetwork: { backgroundColor: "#284122", borderColor: "#3D6835", }, onboardingVisualTagPair: { backgroundColor: "#3B2859", borderColor: "#5A3D86", }, onboardingVisualTagText: { color: "#F6F7F8", fontSize: 22, fontWeight: "800", letterSpacing: 1.8, }, onboardingCopyBlock: { alignItems: "flex-start", gap: 10, width: "100%", }, onboardingEyebrow: { color: "#7F7F7F", fontSize: 11, fontWeight: "700", letterSpacing: 1.3, }, onboardingTitle: { color: "#F1F1F1", fontSize: 34, fontWeight: "800", textAlign: "left", letterSpacing: -1, lineHeight: 38, }, onboardingBody: { color: "#B4B4B4", fontSize: 18, lineHeight: 25, textAlign: "left", paddingHorizontal: 0, }, onboardingFooter: { gap: 10, paddingTop: 6, }, onboardingPrimaryButton: { height: 56, borderRadius: 14, alignItems: "center", justifyContent: "center", backgroundColor: "#1D6FF4", borderWidth: 2, borderColor: "#1557C3", flexDirection: "row", gap: 10, shadowColor: "#000000", shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.2, shadowRadius: 18, elevation: 4, }, onboardingPrimaryButtonDisabled: { opacity: 0.6, }, onboardingPrimaryButtonText: { color: "#FFFFFF", fontSize: 17, fontWeight: "700", letterSpacing: 0.2, }, onboardingSecondaryButton: { alignSelf: "flex-start", paddingVertical: 8, paddingHorizontal: 2, }, onboardingSecondaryText: { color: "#959CAA", fontSize: 14, fontWeight: "600", textAlign: "left", }, dismissOverlay: { ...StyleSheet.absoluteFillObject, zIndex: 15, }, headerAnchor: { marginHorizontal: 6, marginTop: 5, height: 51, zIndex: 30, }, statusBar: { position: "absolute", top: 0, left: 0, right: 0, backgroundColor: "#151515", borderRadius: 20, borderWidth: 3, borderColor: "#282828", paddingHorizontal: 14, paddingTop: 0, overflow: "hidden", shadowColor: "#000000", }, statusBarInner: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", minHeight: 30, }, statusBarTapArea: { height: 45, flexDirection: "row", alignItems: "center", justifyContent: "flex-start", }, headerServerLabel: { flexDirection: "row", alignItems: "center", gap: 8, width: "100%", }, headerSplitRow: { height: 45, flexDirection: "row", alignItems: "center", }, headerSplitLeft: { flex: 1, flexBasis: 0, minWidth: 0, height: "100%", justifyContent: "center", alignItems: "flex-start", paddingRight: 8, }, headerSplitDivider: { width: 4, height: 4, borderRadius: 2, backgroundColor: "#3F4556", marginHorizontal: 6, }, headerSplitRight: { flex: 1, flexBasis: 0, minWidth: 0, height: "100%", justifyContent: "center", alignItems: "flex-start", paddingLeft: 8, }, workspaceHeaderText: { color: "#8F8F8F", fontSize: 14, fontWeight: "600", }, headerServerText: { flex: 1, minWidth: 0, width: "100%", }, headerSessionText: { flexShrink: 1, minWidth: 0, width: "100%", textAlign: "left", }, serverMenuInline: { marginTop: 8, paddingBottom: 8, gap: 4, }, dropdownListViewport: { maxHeight: DROPDOWN_VISIBLE_ROWS * 42, }, dropdownListContent: { paddingBottom: 2, }, serverEmptyText: { color: "#6F7686", fontSize: 13, textAlign: "center", paddingVertical: 10, }, serverRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingHorizontal: 4, paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: "#222733", }, serverRowLast: { borderBottomWidth: 0, }, serverRowPressed: { opacity: 0.85, }, serverStatusDot: { width: 9, height: 9, borderRadius: 5, }, serverStatusActive: { backgroundColor: "#4CC26A", }, serverStatusChecking: { backgroundColor: "#D2A542", }, serverStatusOffline: { backgroundColor: "#D14C55", }, serverNameText: { flex: 1, color: "#D6DAE4", fontSize: 14, fontWeight: "500", }, sessionUpdatedText: { color: "#8E96A8", fontSize: 12, fontWeight: "500", marginLeft: 8, }, serverDeleteIcon: { color: "#8C93A3", fontSize: 15, fontWeight: "700", }, addServerButton: { marginTop: 10, alignSelf: "center", paddingHorizontal: 8, paddingVertical: 6, }, addServerButtonText: { color: "#B8BDC9", fontSize: 16, fontWeight: "600", }, statusLeft: { flexDirection: "row", alignItems: "center", gap: 8, }, readyDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: "#4CAF50", }, recordingDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: "#FF2E3F", }, statusText: { fontSize: 14, fontWeight: "500", color: "#666", }, statusActions: { flexDirection: "row", alignItems: "center", gap: 6, }, clearButton: { width: 32, height: 32, alignItems: "center", justifyContent: "center", alignSelf: "center", }, clearButtonPressed: { opacity: 0.75, }, clearIcon: { color: "#A0A0A0", fontSize: 20, fontWeight: "700", textAlign: "center", transform: [{ translateY: -0.5 }], }, transcriptionArea: { flex: 1, marginHorizontal: 6, marginTop: 6, }, splitCardStack: { flex: 1, gap: 8, }, splitCard: { flex: 1, backgroundColor: "#151515", borderRadius: 20, borderWidth: 3, borderColor: "#282828", overflow: "hidden", position: "relative", }, replyCard: { paddingTop: 16, }, transcriptionPanel: { flex: 1, position: "relative", overflow: "hidden", }, replyCardLabel: { color: "#AAB5CC", fontSize: 15, fontWeight: "600", }, agentStateHeaderRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", marginHorizontal: 20, marginBottom: 8, }, agentStateTitleWrap: { flexDirection: "row", alignItems: "center", gap: 8, }, agentStateIconWrap: { width: 16, height: 16, alignItems: "center", justifyContent: "center", }, agentStateClose: { color: "#8D97AB", fontSize: 18, fontWeight: "700", lineHeight: 18, }, replyScroll: { flex: 1, }, replyContent: { paddingHorizontal: 20, paddingBottom: 18, flexGrow: 1, }, replyText: { fontSize: 22, fontWeight: "500", lineHeight: 32, color: "#F4F7FF", }, transcriptionScroll: { flex: 1, }, transcriptionContent: { padding: 20, paddingTop: 54, paddingBottom: 54, flexGrow: 1, }, transcriptionTopActions: { position: "absolute", top: 10, left: 10, right: 10, zIndex: 4, flexDirection: "row", justifyContent: "space-between", }, modelErrorBadge: { alignSelf: "flex-start", marginLeft: 14, marginTop: 8, marginBottom: 2, paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, backgroundColor: "#3A1A1D", borderWidth: 1, borderColor: "#5D292F", }, modelErrorText: { color: "#FFB9BF", fontSize: 12, fontWeight: "600", letterSpacing: 0.1, }, transcriptionText: { fontSize: 28, fontWeight: "500", lineHeight: 38, color: "#FFFFFF", }, placeholderText: { fontSize: 28, fontWeight: "500", color: "#333", }, waveformBoxesRow: { position: "absolute", left: 20, right: 20, bottom: 14, height: WAVEFORM_ROWS * WAVEFORM_CELL_SIZE + (WAVEFORM_ROWS - 1) * WAVEFORM_CELL_GAP, pointerEvents: "none", }, waveformGridRow: { flexDirection: "row", gap: WAVEFORM_CELL_GAP, marginBottom: WAVEFORM_CELL_GAP, }, waveformBox: { width: WAVEFORM_CELL_SIZE, height: WAVEFORM_CELL_SIZE, borderRadius: 1, backgroundColor: "#78839A", borderWidth: 1, borderColor: "#818DA6", }, controlsRow: { paddingHorizontal: 6, paddingBottom: 6, paddingTop: 6, flexDirection: "row", alignItems: "center", }, recordPressable: { flex: 1, }, recordButton: { alignItems: "center", justifyContent: "center", backgroundColor: "#421B17", height: CONTROL_HEIGHT, borderRadius: 20, width: "100%", overflow: "hidden", }, recordBusyCenter: { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", }, loadFill: { position: "absolute", left: 0, top: 0, bottom: 0, backgroundColor: "#FF5B47", }, loadFillPending: { backgroundColor: "#66423C", }, loadOverlay: { ...StyleSheet.absoluteFillObject, alignItems: "center", justifyContent: "center", paddingHorizontal: 18, }, loadText: { color: "#FFF6F4", fontSize: 14, fontWeight: "700", letterSpacing: 0.2, }, settingsRoot: { flex: 1, backgroundColor: "#121212", paddingHorizontal: 16, paddingTop: 12, }, settingsTop: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 12, marginBottom: 4, }, settingsTitleBlock: { flex: 1, gap: 4, }, settingsTitle: { color: "#F1F1F1", fontSize: 20, fontWeight: "700", }, settingsSubtitle: { color: "#999999", fontSize: 13, fontWeight: "500", }, settingsClose: { color: "#C5C5C5", fontSize: 15, fontWeight: "700", }, settingsScroll: { flex: 1, }, settingsContent: { gap: 24, paddingBottom: 24, }, settingsSection: { gap: 0, }, settingsSectionLabel: { color: "#7D7D7D", fontSize: 11, fontWeight: "700", letterSpacing: 1.05, marginBottom: 6, }, settingsTextRow: { minHeight: 46, flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 12, borderBottomWidth: 1, borderBottomColor: "#242424", paddingVertical: 10, }, settingsMutedText: { color: "#868686", fontSize: 12, fontWeight: "500", }, settingsOptionCopy: { flex: 1, minWidth: 0, gap: 2, }, settingsTextRowTitle: { color: "#ECECEC", fontSize: 14, fontWeight: "600", }, settingsTextRowMeta: { color: "#8D8D8D", fontSize: 12, fontWeight: "500", }, settingsTextRowValue: { color: "#BDBDBD", fontSize: 13, fontWeight: "600", maxWidth: "55%", textAlign: "right", }, settingsTextRowAction: { color: "#B8B8B8", fontSize: 12, fontWeight: "700", letterSpacing: 0.2, }, settingsTextRowActionActive: { color: "#FFD8D2", }, settingsInlineRow: { flexDirection: "row", alignItems: "center", minHeight: 52, borderBottomWidth: 1, borderBottomColor: "#242424", }, settingsInlineLabelPressable: { flex: 1, minWidth: 0, paddingVertical: 10, paddingRight: 12, gap: 2, }, settingsInlinePressableDisabled: { opacity: 0.55, }, settingsInlineName: { color: "#E7E7E7", fontSize: 14, fontWeight: "600", }, settingsInlineMeta: { color: "#8F8F8F", fontSize: 12, fontWeight: "500", }, settingsInlineTextActionPressable: { marginLeft: 8, paddingVertical: 8, paddingHorizontal: 2, alignItems: "flex-end", justifyContent: "center", }, settingsInlineTextAction: { color: "#D0D0D0", fontSize: 12, fontWeight: "700", minWidth: 72, textAlign: "right", }, settingsInlineTextActionInstalled: { color: "#E2B1A8", }, settingsInlineTextActionDownloading: { color: "#FFD7CE", }, scanRoot: { flex: 1, backgroundColor: "#101014", paddingHorizontal: 16, paddingTop: 12, gap: 12, }, scanTop: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", }, scanTitle: { color: "#E8EAF0", fontSize: 18, fontWeight: "700", }, scanClose: { color: "#8FA4CC", fontSize: 15, fontWeight: "600", }, scanCam: { flex: 1, borderRadius: 18, overflow: "hidden", }, scanEmpty: { flex: 1, alignItems: "center", justifyContent: "center", paddingHorizontal: 24, }, scanHint: { color: "#A6ABBA", fontSize: 14, textAlign: "center", }, sendSlot: { height: CONTROL_HEIGHT, overflow: "hidden", }, sendButton: { width: "100%", height: "100%", borderRadius: 20, alignItems: "center", justifyContent: "center", backgroundColor: "#1D6FF4", borderWidth: 2, borderColor: "#1557C3", }, sendButtonDisabled: { opacity: 0.7, }, sendIcon: { color: "#FFFFFF", fontSize: 34, fontWeight: "700", lineHeight: 36, transform: [{ translateY: -1 }], }, recordBorder: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, borderRadius: 20, }, recordButtonDisabled: { opacity: 0.4, }, recordDot: { width: 38, height: 38, backgroundColor: "#FF2E3F", }, })