From 4e1681338c076f01d99f021fb12eec59199e4f08 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:10:36 -0400 Subject: [PATCH] refactor(mobile): extract settings, diagnostics and editor-document screens from their routes (#19675) --- mobile/app/about.tsx | 155 +------ mobile/app/browser-settings.tsx | 164 +------ mobile/app/connection-log.tsx | 389 +++-------------- mobile/app/native-chat-settings.tsx | 127 +----- mobile/app/notifications.tsx | 182 +------- mobile/app/settings.tsx | 327 +------------- mobile/app/troubleshoot.tsx | 282 +----------- mobile/app/voice-settings.tsx | 406 +----------------- .../components/MobileRichMarkdownEditor.tsx | 180 ++------ .../src/components/NewWorktreeFormSheet.tsx | 2 + mobile/src/components/NewWorktreeModal.tsx | 13 +- .../components/NewWorktreeModalController.tsx | 3 + .../components/SmartWorkspaceSourceField.tsx | 8 +- .../mobile-rich-markdown-editor-contract.ts | 37 ++ ...ile-rich-markdown-editor-document-body.ts} | 99 +---- ...le-rich-markdown-editor-document-suffix.ts | 7 +- ...bile-rich-markdown-editor-document.test.ts | 19 + .../mobile-rich-markdown-editor-html.ts | 25 +- ...ile-rich-markdown-editor-script-primary.ts | 95 ++++ ...-rich-markdown-editor-script-secondary.ts} | 2 +- ...bile-rich-markdown-editor-script-string.ts | 3 + .../mobile-rich-markdown-editor-script.ts | 14 + .../components/new-worktree-modal-types.ts | 1 + ...-mobile-rich-markdown-editor-controller.ts | 114 +++++ .../connection-diagnostics-screen-styles.ts | 95 ++++ .../connection-diagnostics-screen.tsx | 103 +++++ .../connection-diagnostics-view.tsx | 119 +++++ .../diagnostics-device-operations.ts | 16 + .../native-diagnostics-operations.ts | 51 +++ mobile/src/diagnostics/troubleshoot-view.tsx | 166 +++++++ .../use-troubleshoot-diagnostics.ts | 128 ++++++ .../src/host-screen/host-screen-overlays.tsx | 3 +- mobile/src/settings/about-screen.tsx | 187 ++++++++ .../src/settings/browser-settings-screen.tsx | 199 +++++++++ .../settings/mobile-settings-menu-items.ts | 14 + mobile/src/settings/mobile-settings-menu.tsx | 111 +++++ .../settings/native-chat-settings-screen.tsx | 126 ++++++ ...native-notification-settings-operations.ts | 23 + .../native-voice-settings-operations.ts | 19 + .../notification-settings-operations.ts | 7 + .../settings/notification-settings-screen.tsx | 196 +++++++++ .../pending-credential-cleanup-card.tsx | 159 +++++++ mobile/src/settings/settings-menu-screen.tsx | 42 ++ .../settings/settings-screen-state.test.tsx | 210 +++++++++ .../src/settings/voice-settings-operations.ts | 12 + .../voice-settings-poller-refresh.test.tsx | 162 +++++++ mobile/src/settings/voice-settings-screen.tsx | 295 +++++++++++++ mobile/src/settings/voice-settings-styles.ts | 107 +++++ 48 files changed, 3017 insertions(+), 2187 deletions(-) create mode 100644 mobile/src/components/mobile-rich-markdown-editor-contract.ts rename mobile/src/components/{mobile-rich-markdown-editor-body-primary.ts => mobile-rich-markdown-editor-document-body.ts} (56%) create mode 100644 mobile/src/components/mobile-rich-markdown-editor-document.test.ts create mode 100644 mobile/src/components/mobile-rich-markdown-editor-script-primary.ts rename mobile/src/components/{mobile-rich-markdown-editor-body-secondary.ts => mobile-rich-markdown-editor-script-secondary.ts} (99%) create mode 100644 mobile/src/components/mobile-rich-markdown-editor-script-string.ts create mode 100644 mobile/src/components/mobile-rich-markdown-editor-script.ts create mode 100644 mobile/src/components/use-mobile-rich-markdown-editor-controller.ts create mode 100644 mobile/src/diagnostics/connection-diagnostics-screen-styles.ts create mode 100644 mobile/src/diagnostics/connection-diagnostics-screen.tsx create mode 100644 mobile/src/diagnostics/connection-diagnostics-view.tsx create mode 100644 mobile/src/diagnostics/diagnostics-device-operations.ts create mode 100644 mobile/src/diagnostics/native-diagnostics-operations.ts create mode 100644 mobile/src/diagnostics/troubleshoot-view.tsx create mode 100644 mobile/src/diagnostics/use-troubleshoot-diagnostics.ts create mode 100644 mobile/src/settings/about-screen.tsx create mode 100644 mobile/src/settings/browser-settings-screen.tsx create mode 100644 mobile/src/settings/mobile-settings-menu-items.ts create mode 100644 mobile/src/settings/mobile-settings-menu.tsx create mode 100644 mobile/src/settings/native-chat-settings-screen.tsx create mode 100644 mobile/src/settings/native-notification-settings-operations.ts create mode 100644 mobile/src/settings/native-voice-settings-operations.ts create mode 100644 mobile/src/settings/notification-settings-operations.ts create mode 100644 mobile/src/settings/notification-settings-screen.tsx create mode 100644 mobile/src/settings/pending-credential-cleanup-card.tsx create mode 100644 mobile/src/settings/settings-menu-screen.tsx create mode 100644 mobile/src/settings/settings-screen-state.test.tsx create mode 100644 mobile/src/settings/voice-settings-operations.ts create mode 100644 mobile/src/settings/voice-settings-poller-refresh.test.tsx create mode 100644 mobile/src/settings/voice-settings-screen.tsx create mode 100644 mobile/src/settings/voice-settings-styles.ts diff --git a/mobile/app/about.tsx b/mobile/app/about.tsx index 70a1effa44b..7328e6d9b8d 100644 --- a/mobile/app/about.tsx +++ b/mobile/app/about.tsx @@ -1,11 +1,7 @@ -import { View, Text, StyleSheet, Pressable, Linking, Platform } from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { Linking, Platform } from 'react-native' import { useRouter } from 'expo-router' -import { ChevronLeft, Globe } from 'lucide-react-native' -import Svg, { Path } from 'react-native-svg' import Constants from 'expo-constants' -import { OrcaLogo } from '../src/components/OrcaLogo' -import { colors, spacing, typography } from '../src/theme/mobile-theme' +import AboutScreen from '../src/settings/about-screen' // Why: read version + native build identifier from expo-constants at // runtime so the About screen never drifts out of sync with app.json. @@ -20,148 +16,13 @@ function getVersionLabel(): string { return build ? `v${version} (${build})` : `v${version}` } -function GithubIcon({ size = 16, color = colors.textSecondary }) { - return ( - - - - ) -} - -function XIcon({ size = 16, color = colors.textSecondary }) { - return ( - - - - ) -} - -export default function AboutScreen() { +export default function NativeAboutRoute() { const router = useRouter() - const insets = useSafeAreaInsets() - return ( - - - router.back()}> - - - About - - - - - Orca - Open-source agent IDE for 100x builders - - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => void Linking.openURL('https://onOrca.dev')} - > - - onOrca.dev - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => void Linking.openURL('https://github.com/stablyai/orca')} - > - - stablyai/orca - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => void Linking.openURL('https://x.com/orca_build')} - > - - @orca_build - - - - {getVersionLabel()} - + router.back()} + openExternal={(url) => Linking.openURL(url)} + versionLabel={getVersionLabel()} + /> ) } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase, - padding: spacing.lg - }, - topRow: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: spacing.xl - }, - backButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.sm - }, - heading: { - fontSize: 20, - fontWeight: '700', - color: colors.textPrimary - }, - brand: { - alignItems: 'center', - paddingVertical: spacing.xl, - marginBottom: spacing.lg - }, - brandName: { - fontSize: 22, - fontWeight: '800', - color: colors.textPrimary, - marginTop: spacing.sm - }, - brandSub: { - fontSize: 13, - color: colors.textMuted, - marginTop: spacing.xs - }, - section: { - backgroundColor: colors.bgPanel, - borderRadius: 12, - overflow: 'hidden' - }, - row: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm + 2, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2 - }, - rowPressed: { - backgroundColor: colors.bgRaised - }, - rowLabel: { - flex: 1, - fontSize: typography.bodySize, - fontWeight: '500', - color: colors.textPrimary - }, - rowValue: { - flex: 1, - textAlign: 'right', - fontSize: typography.bodySize, - color: colors.textSecondary - }, - separator: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle, - marginHorizontal: spacing.md - }, - versionText: { - marginTop: spacing.lg, - textAlign: 'center', - fontSize: typography.metaSize, - color: colors.textMuted - } -}) diff --git a/mobile/app/browser-settings.tsx b/mobile/app/browser-settings.tsx index a8e9c518fe4..d47944e5501 100644 --- a/mobile/app/browser-settings.tsx +++ b/mobile/app/browser-settings.tsx @@ -1,163 +1 @@ -import { useCallback, useEffect, useState } from 'react' -import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { useRouter } from 'expo-router' -import { ChevronLeft, ChevronRight, Globe } from 'lucide-react-native' -import { PickerModal, type PickerOption } from '../src/components/PickerModal' -import { - loadTerminalLinkOpenMode, - saveTerminalLinkOpenMode, - type MobileTerminalLinkOpenMode -} from '../src/storage/preferences' -import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' - -const LINK_MODE_OPTIONS: PickerOption[] = [ - { - value: 'orca-browser', - label: 'Orca browser on desktop', - subtitle: 'Open in the streamed browser from your paired desktop.' - }, - { - value: 'phone-browser', - label: 'Phone browser', - subtitle: 'Open in Safari, Chrome, or another browser on this phone.' - } -] - -function linkModeLabel(mode: MobileTerminalLinkOpenMode): string { - return ( - LINK_MODE_OPTIONS.find((option) => option.value === mode)?.label ?? LINK_MODE_OPTIONS[0]!.label - ) -} - -export default function BrowserSettingsScreen(): React.JSX.Element { - const router = useRouter() - const insets = useSafeAreaInsets() - const [linkMode, setLinkMode] = useState('orca-browser') - const [pickerOpen, setPickerOpen] = useState(false) - - useEffect(() => { - void loadTerminalLinkOpenMode().then(setLinkMode) - }, []) - - const selectLinkMode = useCallback((mode: MobileTerminalLinkOpenMode) => { - setLinkMode(mode) - void saveTerminalLinkOpenMode(mode) - }, []) - - return ( - - - router.back()}> - - - Browser - - - - LINKS - - Choose where HTTP(S) links tapped in terminal output open. - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => setPickerOpen(true)} - > - - - Open terminal links - {linkModeLabel(linkMode)} - - - - - - - - visible={pickerOpen} - title="Open terminal links" - options={LINK_MODE_OPTIONS} - selected={linkMode} - onSelect={selectLinkMode} - onClose={() => setPickerOpen(false)} - /> - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase, - paddingHorizontal: spacing.lg, - paddingTop: 0 - }, - topRow: { - flexDirection: 'row', - alignItems: 'center', - marginTop: spacing.sm, - marginBottom: spacing.lg - }, - backButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.sm - }, - heading: { - fontSize: 20, - fontWeight: '700', - color: colors.textPrimary - }, - scrollContent: { - paddingBottom: spacing.xl - }, - groupHeading: { - fontSize: 11, - fontWeight: '600', - color: colors.textMuted, - letterSpacing: 0.5, - marginBottom: spacing.xs, - paddingHorizontal: spacing.xs - }, - groupDescription: { - fontSize: typography.bodySize - 1, - color: colors.textSecondary, - lineHeight: 20, - paddingHorizontal: spacing.xs - }, - section: { - backgroundColor: colors.bgPanel, - borderRadius: radii.card, - overflow: 'hidden' - }, - sectionTopGap: { - marginTop: spacing.sm - }, - row: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm + 2, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2 - }, - rowPressed: { - backgroundColor: colors.bgRaised - }, - rowContent: { - flex: 1 - }, - rowLabel: { - fontSize: typography.bodySize, - fontWeight: '500', - color: colors.textPrimary - }, - rowSublabel: { - fontSize: typography.bodySize - 2, - color: colors.textSecondary, - marginTop: 2 - } -}) +export { default } from '../src/settings/browser-settings-screen' diff --git a/mobile/app/connection-log.tsx b/mobile/app/connection-log.tsx index 1ab79c50269..f7a1e80c231 100644 --- a/mobile/app/connection-log.tsx +++ b/mobile/app/connection-log.tsx @@ -1,12 +1,7 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react' -import { View, Text, StyleSheet, Pressable, Platform } from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { View, Text, Pressable } from 'react-native' import { useLocalSearchParams, useRouter } from 'expo-router' import * as Clipboard from 'expo-clipboard' -import Constants from 'expo-constants' -import { ChevronLeft, Copy, Check, Send } from 'lucide-react-native' -import { colors, spacing, typography } from '../src/theme/mobile-theme' -import { ConnectionLog } from '../src/components/ConnectionLog' import { loadHosts } from '../src/transport/host-store' import { connectionLogStore } from '../src/transport/persisted-connection-log-store' import { useHostClient, useRpcClientContext } from '../src/transport/client-context' @@ -14,22 +9,15 @@ import { useConnectionPathStatus, useReconnectAttempt } from '../src/transport/client-context-connection-metrics' -import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report' -import { - diagnoseConnection, - getReportableConnectionIncidentId -} from '../src/diagnostics/connection-diagnostics-analysis' -import { submitConnectionDiagnostics } from '../src/diagnostics/connection-diagnostics-submission' +import { useHostStatusGates } from '../src/transport/host-status-gates' +import { ConnectionDiagnosticsScreen } from '../src/diagnostics/connection-diagnostics-screen' +import { createNativeDiagnosticsOperations } from '../src/diagnostics/native-diagnostics-operations' import { readHydratedConnectionLog, - readConnectionDiagnosticsSnapshot, resolveDiagnosticsHostId, - getDiagnosticsSubmissionState, - updateDiagnosticsSubmissionState, - type DiagnosticsSubmissionStates + type DiagnosticsHostSelection } from '../src/diagnostics/connection-diagnostics-screen-data' -import { useHostStatusGates } from '../src/transport/host-status-gates' -import { loadHostAppVersion } from '../src/transport/host-app-version-store' +import { connectionDiagnosticsScreenStyles as styles } from '../src/diagnostics/connection-diagnostics-screen-styles' import type { ConnectionLogEntry, HostProfile } from '../src/transport/types' // Why: getSnapshot must be referentially stable when there's no data — @@ -37,30 +25,22 @@ import type { ConnectionLogEntry, HostProfile } from '../src/transport/types' const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = [] // Why: reading the log is most needed while a host is failing, so this -// screen also *acquires* the host client — opening it kicks a dial and the +// route also *acquires* the host client — opening it kicks a dial and the // log fills live instead of showing a stale tail. -export default function ConnectionLogScreen() { +export default function NativeConnectionLogRoute() { const clientContext = useRpcClientContext() const router = useRouter() const params = useLocalSearchParams<{ hostId?: string }>() - const insets = useSafeAreaInsets() const routeKey = useMemo(() => ({}), [params.hostId]) const [hosts, setHosts] = useState([]) - const [manualSelection, setManualSelection] = useState<{ - hostId: string - requestedHostId: string | undefined - routeKey: object - } | null>(null) - const [copiedHostId, setCopiedHostId] = useState(null) - const [submissionStates, setSubmissionStates] = useState({}) + const [manualSelection, setManualSelection] = useState(null) useEffect(() => { let stale = false void loadHosts().then((loaded) => { - if (stale) { - return + if (!stale) { + setHosts(loaded) } - setHosts(loaded) }) return () => { stale = true @@ -68,9 +48,9 @@ export default function ConnectionLogScreen() { }, []) const selectedId = resolveDiagnosticsHostId(hosts, params.hostId, manualSelection, routeKey) - const selected = hosts.find((h) => h.id === selectedId) ?? null + const selected = hosts.find((host) => host.id === selectedId) ?? null const { client, state } = useHostClient(selected?.id) - const { desktopAppVersion: liveDesktopAppVersion } = useHostStatusGates({ + const { desktopAppVersion } = useHostStatusGates({ hostId: selected?.id, client, connState: state @@ -94,314 +74,47 @@ export default function ConnectionLogScreen() { [selectedId] ) const entries = useSyncExternalStore(subscribe, getSnapshot) - const diagnosis = selected - ? diagnoseConnection({ endpoint: selected.endpoint, state, activePath, pendingPath, entries }) - : null - const incidentId = selected - ? getReportableConnectionIncidentId({ - endpoint: selected.endpoint, - state, - activePath, - pendingPath, - entries - }) - : null - const submissionKey = selected && incidentId ? `${selected.id}:${incidentId}` : null - const submissionState = getDiagnosticsSubmissionState(submissionStates, submissionKey) - const copied = copiedHostId === selectedId - - const copyDiagnostics = useCallback(async () => { - if (!selected) { - return - } - const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id)) - const snapshot = await readConnectionDiagnosticsSnapshot( - clientContext, - connectionLogStore, - selected.id - ) - const report = buildConnectionDiagnosticsReport({ - hostName: selected.name, - endpoint: selected.endpoint, - state: snapshot.state, - reconnectAttempts: snapshot.reconnectAttempts, - lastConnectedAt: snapshot.lastConnectedAt, - platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(), - appVersion: Constants.expoConfig?.version ?? 'unknown', - desktopAppVersion, - entries: snapshot.entries, - activePath: snapshot.activePath, - pendingPath: snapshot.pendingPath - }) - await Clipboard.setStringAsync(report) - setCopiedHostId(selected.id) - setTimeout(() => setCopiedHostId((hostId) => (hostId === selected.id ? null : hostId)), 2000) - }, [selected, liveDesktopAppVersion, clientContext]) - - const sendDiagnostics = useCallback(async () => { - if (!selected || !submissionKey || submissionState === 'sending') { - return - } - const startedKey = submissionKey - setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, 'sending')) - const appVersion = Constants.expoConfig?.version ?? 'unknown' - const platform = `${Platform.OS} ${Platform.Version ?? ''}`.trim() - const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id)) - const snapshot = await readConnectionDiagnosticsSnapshot( - clientContext, - connectionLogStore, - selected.id - ) - const currentIncidentId = getReportableConnectionIncidentId({ - endpoint: selected.endpoint, - state: snapshot.state, - activePath: snapshot.activePath, - pendingPath: snapshot.pendingPath, - entries: snapshot.entries - }) - if (`${selected.id}:${currentIncidentId ?? ''}` !== startedKey) { - setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, null)) - return - } - const report = buildConnectionDiagnosticsReport({ - hostName: selected.name, - endpoint: selected.endpoint, - state: snapshot.state, - reconnectAttempts: snapshot.reconnectAttempts, - lastConnectedAt: snapshot.lastConnectedAt, - platform, - appVersion, - desktopAppVersion, - entries: snapshot.entries, - activePath: snapshot.activePath, - pendingPath: snapshot.pendingPath - }) - const result = await submitConnectionDiagnostics({ report, appVersion, platform }) - setSubmissionStates((states) => - updateDiagnosticsSubmissionState(states, startedKey, result.ok ? 'sent' : 'failed') - ) - }, [selected, submissionKey, submissionState, liveDesktopAppVersion, clientContext]) + const device = useMemo( + () => + selected + ? createNativeDiagnosticsOperations(selected, clientContext, desktopAppVersion) + : null, + [selected, clientContext, desktopAppVersion] + ) return ( - - - router.back()}> - - - Network diagnostics - - - {hosts.length > 1 && ( - - {hosts.map((host) => ( - - setManualSelection({ hostId: host.id, requestedHostId: params.hostId, routeKey }) - } - > - Clipboard.setStringAsync(report)} + onBack={() => router.back()} + hostPicker={ + hosts.length > 1 ? ( + + {hosts.map((host) => ( + + setManualSelection({ hostId: host.id, requestedHostId: params.hostId, routeKey }) + } > - {host.name} - - - ))} - - )} - - {selected ? ( - <> - - - {state} - {reconnectAttempts > 0 ? ` · attempt ${reconnectAttempts}` : ''} - - void copyDiagnostics()}> - {copied ? ( - - ) : ( - - )} - {copied ? 'Copied' : 'Copy report'} - + + {host.name} + + + ))} - {diagnosis && ( - - What this suggests - {diagnosis.likelyCause} - {diagnosis.nextStep} - {diagnosis.reportability === 'orca-relay' && ( - <> - - Sends a size-limited redacted report including host name, endpoint, versions, - connection state, and events—never terminal contents or credentials. - - void sendDiagnostics()} - disabled={submissionState === 'sending'} - > - {submissionState === 'sent' ? ( - - ) : ( - - )} - - {submissionState === 'sending' - ? 'Sending…' - : submissionState === 'sent' - ? 'Diagnostics sent' - : submissionState === 'failed' - ? 'Retry sending' - : 'Send diagnostics to Orca'} - - - - )} - - )} - {entries.length > 0 ? ( - - ) : ( - - No connection events yet. Events appear as the app dials this host. - - )} - - ) : ( - No paired hosts. - )} - + ) : null + } + /> ) } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase, - padding: spacing.lg - }, - topRow: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: spacing.lg - }, - backButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.sm - }, - heading: { - fontSize: 20, - fontWeight: '700', - color: colors.textPrimary - }, - hostPicker: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: spacing.sm, - marginBottom: spacing.md - }, - hostChip: { - paddingVertical: spacing.xs + 2, - paddingHorizontal: spacing.md, - borderRadius: 16, - backgroundColor: colors.bgRaised - }, - hostChipActive: { - backgroundColor: colors.bgPanel, - borderWidth: 1, - borderColor: colors.borderSubtle - }, - hostChipText: { - fontSize: typography.metaSize, - color: colors.textSecondary, - maxWidth: 160 - }, - hostChipTextActive: { - color: colors.textPrimary, - fontWeight: '600' - }, - statusRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: spacing.sm - }, - statusText: { - fontSize: typography.metaSize, - color: colors.textSecondary - }, - diagnosisCard: { - backgroundColor: colors.bgPanel, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, - borderRadius: 10, - padding: spacing.md, - marginBottom: spacing.md - }, - diagnosisHeading: { - fontSize: typography.metaSize, - fontWeight: '600', - color: colors.textPrimary, - marginBottom: spacing.xs - }, - diagnosisText: { - fontSize: typography.metaSize, - color: colors.textPrimary, - lineHeight: 18 - }, - diagnosisNext: { - fontSize: typography.metaSize, - color: colors.textSecondary, - lineHeight: 18, - marginTop: spacing.xs - }, - privacyHint: { - marginTop: spacing.sm, - fontSize: 11, - lineHeight: 15, - color: colors.textMuted - }, - sendButton: { - marginTop: spacing.md, - alignSelf: 'flex-start', - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - paddingVertical: spacing.sm, - paddingHorizontal: spacing.md, - borderRadius: 8, - backgroundColor: colors.bgRaised - }, - sendButtonText: { - fontSize: typography.metaSize, - fontWeight: '600', - color: colors.textPrimary - }, - copyButton: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs + 2, - paddingVertical: spacing.xs + 2, - paddingHorizontal: spacing.md, - borderRadius: 8, - backgroundColor: colors.bgRaised - }, - copyButtonText: { - fontSize: typography.metaSize, - fontWeight: '600', - color: colors.textPrimary - }, - emptyText: { - fontSize: typography.metaSize, - color: colors.textMuted, - lineHeight: 18 - } -}) diff --git a/mobile/app/native-chat-settings.tsx b/mobile/app/native-chat-settings.tsx index 1e2ebadd9ef..dc428e78809 100644 --- a/mobile/app/native-chat-settings.tsx +++ b/mobile/app/native-chat-settings.tsx @@ -1,126 +1 @@ -import { View, Text, StyleSheet, Pressable, ScrollView, Switch } from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { useRouter } from 'expo-router' -import { ChevronLeft } from 'lucide-react-native' -import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' -import { useMobileDefaultSessionViewPreference } from '../src/session/use-mobile-default-session-view-preference' - -export default function NativeChatSettingsScreen() { - const router = useRouter() - const insets = useSafeAreaInsets() - - const { defaultView, setDefaultView } = useMobileDefaultSessionViewPreference() - const chatDefault = defaultView === 'chat' - - return ( - - - router.back()} - > - - - Chat UI - - - - DEFAULT VIEW - - Choose how supported agent sessions (Claude, Codex, and other chat-capable agents) open on - this device. Terminal shows the raw CLI; Chat UI shows a chat interface like the desktop - app. You can still switch any individual session from its long-press menu. - - - - - Open sessions in Chat UI - {chatDefault ? 'On' : 'Off'} - - setDefaultView(next ? 'chat' : 'terminal')} - trackColor={{ false: colors.bgRaised, true: colors.textSecondary }} - thumbColor={colors.textPrimary} - /> - - - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase, - paddingHorizontal: spacing.lg - }, - topRow: { - flexDirection: 'row', - alignItems: 'center', - marginTop: spacing.sm, - marginBottom: spacing.lg - }, - backButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.sm - }, - heading: { - fontSize: 20, - fontWeight: '700', - color: colors.textPrimary - }, - groupHeading: { - fontSize: 11, - fontWeight: '600', - color: colors.textMuted, - letterSpacing: 0.5, - marginBottom: spacing.xs, - paddingHorizontal: spacing.xs - }, - groupDescription: { - fontSize: typography.bodySize - 1, - color: colors.textSecondary, - lineHeight: 20, - paddingHorizontal: spacing.xs - }, - section: { - backgroundColor: colors.bgPanel, - borderRadius: radii.card, - overflow: 'hidden' - }, - sectionTopGap: { - marginTop: spacing.sm - }, - row: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm + 2, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2 - }, - rowContent: { - flex: 1 - }, - rowLabel: { - fontSize: typography.bodySize, - fontWeight: '500', - color: colors.textPrimary - }, - rowSublabel: { - fontSize: typography.bodySize - 2, - color: colors.textSecondary, - marginTop: 2 - } -}) +export { default } from '../src/settings/native-chat-settings-screen' diff --git a/mobile/app/notifications.tsx b/mobile/app/notifications.tsx index d9696251a94..d6566f66ac7 100644 --- a/mobile/app/notifications.tsx +++ b/mobile/app/notifications.tsx @@ -1,178 +1,12 @@ -import { useState, useCallback, useEffect } from 'react' -import { AppState, Linking, View, Text, StyleSheet, Pressable, Switch } from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { useRouter, useFocusEffect } from 'expo-router' -import { ChevronLeft } from 'lucide-react-native' -import { colors, spacing, typography } from '../src/theme/mobile-theme' -import { - loadPushNotificationsEnabled, - savePushNotificationsEnabled -} from '../src/storage/preferences' -import { - ensureNotificationPermissions, - getNotificationPermissionState, - type NotificationPermissionState -} from '../src/notifications/mobile-notifications' - -const DEFAULT_PERMISSION_STATE: NotificationPermissionState = { - granted: false, - status: 'undetermined', - canAskAgain: true, - authorizationReflectsUserChoice: false -} - -export default function NotificationsScreen() { +import { useRouter } from 'expo-router' +import NotificationsScreen from '../src/settings/notification-settings-screen' +import { nativeNotificationSettingsOperations } from '../src/settings/native-notification-settings-operations' +export default function NativeNotificationsRoute() { const router = useRouter() - const insets = useSafeAreaInsets() - const [pushEnabled, setPushEnabled] = useState(false) - const [permissionState, setPermissionState] = useState(DEFAULT_PERMISSION_STATE) - - const refreshSettings = useCallback(async () => { - const [enabled, permission] = await Promise.all([ - loadPushNotificationsEnabled(), - getNotificationPermissionState() - ]) - setPushEnabled(enabled) - setPermissionState(permission) - }, []) - - useFocusEffect( - useCallback(() => { - void refreshSettings() - }, [refreshSettings]) - ) - - useEffect(() => { - const subscription = AppState.addEventListener('change', (state) => { - if (state === 'active') { - void refreshSettings() - } - }) - return () => subscription.remove() - }, [refreshSettings]) - - const togglePush = async (value: boolean) => { - if (value) { - const granted = await ensureNotificationPermissions() - const permission = await getNotificationPermissionState() - setPermissionState(permission) - if (!granted) { - setPushEnabled(false) - await savePushNotificationsEnabled(false) - return - } - } - setPushEnabled(value) - await savePushNotificationsEnabled(value) - } - - const switchEnabled = pushEnabled && permissionState.granted - const notificationsBlocked = permissionState.status === 'denied' - const hint = notificationsBlocked - ? 'Notifications are disabled in system settings.' - : 'Get notified on this device when an agent needs your input or finishes a task.' - return ( - - - router.back()}> - - - Notifications - - - - - Agent notifications - void togglePush(v)} - trackColor={{ false: colors.bgRaised, true: colors.textSecondary }} - thumbColor={colors.textPrimary} - /> - - {hint} - {notificationsBlocked && ( - [ - styles.settingsButton, - pressed && styles.settingsButtonPressed - ]} - onPress={() => void Linking.openSettings()} - > - Open Settings - - )} - - + router.back()} + /> ) } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase, - padding: spacing.lg - }, - topRow: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: spacing.xl - }, - backButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.sm - }, - heading: { - fontSize: 20, - fontWeight: '700', - color: colors.textPrimary - }, - section: { - backgroundColor: colors.bgPanel, - borderRadius: 12, - overflow: 'hidden' - }, - row: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm + 2, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2 - }, - rowLabel: { - flex: 1, - fontSize: typography.bodySize, - fontWeight: '500', - color: colors.textPrimary - }, - hint: { - fontSize: typography.metaSize, - color: colors.textMuted, - lineHeight: 18, - paddingHorizontal: spacing.md + 2, - paddingBottom: spacing.md - }, - settingsButton: { - alignSelf: 'flex-start', - marginHorizontal: spacing.md + 2, - marginBottom: spacing.md, - paddingVertical: spacing.xs, - paddingHorizontal: spacing.sm, - borderRadius: 8, - backgroundColor: colors.bgRaised - }, - settingsButtonPressed: { - opacity: 0.6 - }, - settingsButtonText: { - color: colors.textPrimary, - fontSize: typography.metaSize, - fontWeight: '600' - } -}) diff --git a/mobile/app/settings.tsx b/mobile/app/settings.tsx index 0e74b516a87..eb954a8e83d 100644 --- a/mobile/app/settings.tsx +++ b/mobile/app/settings.tsx @@ -1,321 +1,16 @@ -import { useCallback, useRef, useState } from 'react' -import { - View, - Text, - StyleSheet, - Pressable, - Linking, - ActivityIndicator, - ScrollView -} from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { useFocusEffect, useRouter } from 'expo-router' -import { - ChevronLeft, - ChevronRight, - Info, - Bell, - Wrench, - Shield, - LifeBuoy, - Mic, - Globe, - MessageSquare, - Terminal as TerminalIcon, - KeyRound -} from 'lucide-react-native' -import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' -import { - loadPendingHostCredentialCleanup, - subscribePendingHostCredentialCleanup -} from '../src/transport/host-credential-cleanup' -import { retryPendingHostCredentialCleanup } from '../src/transport/host-store' +import { Linking } from 'react-native' +import { useRouter } from 'expo-router' +import SettingsMenuScreen from '../src/settings/settings-menu-screen' +import { PendingCredentialCleanupCard } from '../src/settings/pending-credential-cleanup-card' -export default function SettingsScreen() { +export default function NativeSettingsRoute() { const router = useRouter() - const insets = useSafeAreaInsets() - const [pendingCredentialIds, setPendingCredentialIds] = useState([]) - const [credentialStorageUnreadable, setCredentialStorageUnreadable] = useState(false) - const [retryingCredentialCleanup, setRetryingCredentialCleanup] = useState(false) - const [credentialRetryFailed, setCredentialRetryFailed] = useState(false) - const credentialRefreshGenerationRef = useRef(0) - - useFocusEffect( - useCallback(() => { - let active = true - setCredentialRetryFailed(false) - const refresh = () => { - const generation = ++credentialRefreshGenerationRef.current - void loadPendingHostCredentialCleanup().then((state) => { - if (active && generation === credentialRefreshGenerationRef.current) { - setPendingCredentialIds(state.ids) - setCredentialStorageUnreadable(state.storageUnreadable) - // Why: neutral copy once the queue is confirmed empty so a later - // pending set does not inherit a previous Retry failure message. - if (state.ids.length === 0 && !state.storageUnreadable) { - setCredentialRetryFailed(false) - } - } - }) - } - const unsubscribe = subscribePendingHostCredentialCleanup(refresh) - refresh() - return () => { - active = false - credentialRefreshGenerationRef.current += 1 - unsubscribe() - } - }, []) - ) - - const retryCredentialCleanup = useCallback(async () => { - if (retryingCredentialCleanup) { - return - } - setCredentialRetryFailed(false) - setRetryingCredentialCleanup(true) - try { - const result = await retryPendingHostCredentialCleanup() - setPendingCredentialIds(result.remainingIds) - setCredentialStorageUnreadable(result.storageUnreadable) - setCredentialRetryFailed(result.remainingIds.length > 0 || result.storageUnreadable) - } catch { - setCredentialRetryFailed(true) - } finally { - setRetryingCredentialCleanup(false) - } - }, [retryingCredentialCleanup]) - - const pendingCredentialCount = pendingCredentialIds.length - // Why: show the cleanup card whenever cleanup is pending OR the durable queue - // is unreadable — an unreadable queue can hide an orphaned token, so keep a - // retry affordance rather than a silently-empty (hidden) section. - const showCredentialCleanup = pendingCredentialCount > 0 || credentialStorageUnreadable - return ( - - - router.back()}> - - - Settings - - - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => router.push('/terminal-settings')} - > - - Terminal - - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => router.push('/native-chat-settings')} - > - - Chat UI - - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => router.push('/browser-settings')} - > - - Browser - - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => router.push('/voice-settings')} - > - - Voice - - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => router.push('/notifications')} - > - - Notifications - - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => router.push('/troubleshoot')} - > - - Troubleshooting - - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => router.push('/about')} - > - - About - - - - - {showCredentialCleanup ? ( - - - - - Pairing credential cleanup - - {credentialRetryFailed - ? "Cleanup still couldn't be confirmed. Try again later." - : pendingCredentialCount > 0 - ? `Couldn't confirm cleanup for ${pendingCredentialCount} credential${pendingCredentialCount === 1 ? '' : 's'} on this device.` - : "Couldn't check cleanup status on this device. Retry to be safe."} - - - [ - styles.retryButton, - pressed && !retryingCredentialCleanup && styles.rowPressed - ]} - onPress={() => void retryCredentialCleanup()} - > - {retryingCredentialCleanup ? ( - - ) : ( - Retry - )} - - - - ) : null} - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => void Linking.openURL('https://www.onorca.dev/privacy')} - > - - Privacy Policy - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => void Linking.openURL('https://github.com/stablyai/orca/issues')} - > - - Support - - - - + router.push(route)} + openExternal={(url) => Linking.openURL(url)} + > + + ) } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase, - paddingHorizontal: spacing.lg - }, - topRow: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: spacing.xl - }, - backButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.sm - }, - heading: { - fontSize: 20, - fontWeight: '700', - color: colors.textPrimary - }, - section: { - backgroundColor: colors.bgPanel, - borderRadius: 12, - overflow: 'hidden' - }, - sectionSpacer: { - marginTop: spacing.md - }, - row: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm + 2, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2 - }, - rowPressed: { - backgroundColor: colors.bgRaised - }, - rowLabel: { - flex: 1, - fontSize: typography.bodySize, - fontWeight: '500', - color: colors.textPrimary - }, - credentialCleanupRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm + 2, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2 - }, - credentialCleanupCopy: { - flex: 1, - gap: spacing.xs - }, - credentialCleanupTitle: { - fontSize: typography.bodySize, - fontWeight: '500', - color: colors.textPrimary - }, - rowHint: { - fontSize: typography.metaSize, - color: colors.textSecondary, - lineHeight: 17 - }, - retryButton: { - width: 72, - height: 32, - borderRadius: radii.button, - backgroundColor: colors.bgRaised, - alignItems: 'center', - justifyContent: 'center' - }, - retryButtonText: { - fontSize: typography.metaSize, - fontWeight: '600', - color: colors.textPrimary - }, - separator: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle, - marginHorizontal: spacing.md - } -}) diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx index 63fc0ddd477..d07368b21f9 100644 --- a/mobile/app/troubleshoot.tsx +++ b/mobile/app/troubleshoot.tsx @@ -1,276 +1,18 @@ -import { useState, useCallback, useRef } from 'react' -import { View, Text, Pressable, ScrollView, ActivityIndicator, Platform } from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useRouter } from 'expo-router' -import { - ChevronLeft, - ChevronDown, - ChevronUp, - Activity, - CheckCircle2, - ScrollText, - XCircle, - AlertTriangle -} from 'lucide-react-native' -import { colors, spacing } from '../src/theme/mobile-theme' -import { loadHosts } from '../src/transport/host-store' -import { - startDiagnosticFetchTimeout, - type DiagnosticFetchTimeout -} from '../src/diagnostics/diagnostic-fetch-timeout' -import { - formatEndpoint, - testHostReachability, - unreachableHostDetail -} from '../src/diagnostics/host-reachability' -import { troubleshootCommonIssues } from '../src/diagnostics/troubleshoot-common-issues' -import { troubleshootScreenStyles as styles } from '../src/diagnostics/troubleshoot-screen-styles' +import { TroubleshootView } from '../src/diagnostics/troubleshoot-view' +import { useTroubleshootDiagnostics } from '../src/diagnostics/use-troubleshoot-diagnostics' -type DiagnosticStatus = 'idle' | 'running' | 'done' - -type CheckResult = { - label: string - status: 'pass' | 'fail' | 'warn' - detail: string -} - -function StatusIcon({ status }: { status: CheckResult['status'] }) { - switch (status) { - case 'pass': - return - case 'fail': - return - case 'warn': - return - } -} - -export default function TroubleshootScreen() { +export default function NativeTroubleshootRoute() { const router = useRouter() - const insets = useSafeAreaInsets() - const [expandedId, setExpandedId] = useState(null) - const [diagnosticStatus, setDiagnosticStatus] = useState('idle') - const [checks, setChecks] = useState([]) - const abortRef = useRef(false) - const diagnosticRunRef = useRef(0) - const activeInternetCheckRef = useRef(null) - - const setTroubleshootRootRef = useCallback((node: View | null): void => { - if (node !== null) { - return - } - // Why: diagnostics can outlive the screen; cancel the active run when the - // route detaches without a passive cleanup-only Effect. - abortRef.current = true - diagnosticRunRef.current += 1 - activeInternetCheckRef.current?.dispose() - activeInternetCheckRef.current = null - }, []) - - const toggleSection = useCallback((id: string) => { - setExpandedId((prev) => (prev === id ? null : id)) - }, []) - - const runDiagnostics = useCallback(async () => { - const runId = diagnosticRunRef.current + 1 - diagnosticRunRef.current = runId - abortRef.current = false - activeInternetCheckRef.current?.dispose() - activeInternetCheckRef.current = null - setDiagnosticStatus('running') - setChecks([]) - - const results: CheckResult[] = [] - const isCurrentRun = () => !abortRef.current && diagnosticRunRef.current === runId - - try { - const hosts = await loadHosts() - results.push( - hosts.length > 0 - ? { label: 'Paired hosts', status: 'pass', detail: `${hosts.length} paired` } - : { label: 'Paired hosts', status: 'fail', detail: 'None — scan a QR to pair' } - ) - } catch { - results.push({ label: 'Paired hosts', status: 'warn', detail: 'Could not read host data' }) - } - - if (!isCurrentRun()) { - return - } - setChecks([...results]) - - const internetCheck = startDiagnosticFetchTimeout(5000) - activeInternetCheckRef.current = internetCheck - try { - const resp = await fetch('https://dns.google/resolve?name=example.com&type=A', { - signal: internetCheck.signal - }) - if (!isCurrentRun()) { - return - } - results.push( - resp.ok - ? { label: 'Internet', status: 'pass', detail: 'Connected' } - : { label: 'Internet', status: 'warn', detail: 'Unexpected response' } - ) - } catch { - if (!isCurrentRun()) { - return - } - results.push({ label: 'Internet', status: 'fail', detail: 'No connection' }) - } finally { - internetCheck.dispose() - if (activeInternetCheckRef.current === internetCheck) { - activeInternetCheckRef.current = null - } - } - - if (!isCurrentRun()) { - return - } - setChecks([...results]) - - try { - const hosts = await loadHosts() - for (const host of hosts) { - if (!isCurrentRun()) { - return - } - const reachable = await testHostReachability(host.endpoint) - if (!isCurrentRun()) { - return - } - results.push({ - label: host.name, - status: reachable ? 'pass' : 'fail', - detail: reachable - ? `Reachable at ${formatEndpoint(host.endpoint)}` - : unreachableHostDetail(host.endpoint) - }) - setChecks([...results]) - } - } catch { - results.push({ label: 'Hosts', status: 'warn', detail: 'Could not test' }) - } - - if (!isCurrentRun()) { - return - } - - results.push({ - label: 'Platform', - status: 'pass', - detail: `${Platform.OS} ${Platform.Version ?? ''}` - }) - - setChecks([...results]) - setDiagnosticStatus('done') - }, []) - + const { rootRef, diagnosticStatus, checks, runDiagnostics } = useTroubleshootDiagnostics() return ( - - - router.back()}> - - - Troubleshooting - - - - [ - styles.diagnosticButton, - pressed && styles.diagnosticButtonPressed, - diagnosticStatus === 'running' && styles.diagnosticButtonDisabled - ]} - onPress={runDiagnostics} - disabled={diagnosticStatus === 'running'} - > - {diagnosticStatus === 'running' ? ( - - ) : ( - - )} - - {diagnosticStatus === 'running' - ? 'Running…' - : diagnosticStatus === 'done' - ? 'Run again' - : 'Run diagnostics'} - - - - [ - styles.diagnosticButton, - pressed && styles.diagnosticButtonPressed - ]} - onPress={() => router.push('/connection-log')} - > - - View network diagnostics - - - {checks.length > 0 && ( - - {checks.map((check, i) => ( - - {i > 0 && } - - - {check.label} - - {check.detail} - - - - ))} - - )} - - Common issues - - - {troubleshootCommonIssues.map((section, i) => ( - - {i > 0 && } - [styles.accordionHeader, pressed && styles.rowPressed]} - onPress={() => toggleSection(section.id)} - > - {section.icon} - {section.title} - {expandedId === section.id ? ( - - ) : ( - - )} - - {expandedId === section.id && ( - - {section.steps.map((step, j) => ( - - - {step} - - ))} - - )} - - ))} - - - - - + void runDiagnostics()} + onBack={() => router.back()} + onConnectionLog={() => router.push('/connection-log')} + /> ) } diff --git a/mobile/app/voice-settings.tsx b/mobile/app/voice-settings.tsx index 8648a1d38e5..4ed812e960b 100644 --- a/mobile/app/voice-settings.tsx +++ b/mobile/app/voice-settings.tsx @@ -1,411 +1,25 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import { - ActivityIndicator, - Pressable, - ScrollView, - StyleSheet, - Switch, - Text, - View -} from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useEffect, useMemo, useState } from 'react' import { useRouter } from 'expo-router' -import { ChevronLeft, ChevronRight } from 'lucide-react-native' -import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections' -import type { RpcClient } from '../src/transport/rpc-client' -import { BottomDrawer } from '../src/components/BottomDrawer' -import { VoiceModelList } from '../src/components/VoiceModelList' -import { useDictationSetupPoller } from '../src/dictation/use-dictation-setup-poller' -import { - deleteDictationModel, - downloadDictationModel, - fetchDictationSetup, - isModelInFlight, - setDictationConfig, - type MobileSpeechModel, - type MobileSpeechSetup -} from '../src/dictation/mobile-dictation-setup' +import VoiceSettingsScreen from '../src/settings/voice-settings-screen' +import { nativeVoiceSettingsOperations } from '../src/settings/native-voice-settings-operations' -const POLL_INTERVAL_MS = 1500 - -const DICTATION_MODES = [ - { value: 'toggle', label: 'Toggle' }, - { value: 'hold', label: 'Hold' } -] as const - -type ModelBusyAction = { modelId: string; type: 'download' | 'select' | 'delete' } - -export default function VoiceSettingsScreen(): React.JSX.Element { +export default function NativeVoiceSettingsRoute() { const router = useRouter() - const insets = useSafeAreaInsets() - const [hosts, setHosts] = useState([]) useEffect(() => { void loadHosts().then(setHosts) }, []) - const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) - const { clients: hostClients, focused: routeFocused } = useFocusedSettingsHostClients(hostIds) - // Voice dictation runs on the paired desktop, so pick the first connected host. - const client: RpcClient | null = useMemo( - () => hostClients.find((entry) => entry.state === 'connected')?.client ?? null, - [hostClients] - ) - - const [setup, setSetup] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const [busyAction, setBusyAction] = useState(null) - const [modelDrawerOpen, setModelDrawerOpen] = useState(false) - const refresh = useCallback(async (): Promise => { - if (!client) { - return false - } - try { - const next = await fetchDictationSetup(client) - setSetup(next) - setError(null) - return next.models.some(isModelInFlight) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load voice settings') - return undefined - } finally { - setLoading(false) - } - }, [client]) - - const polling = setup?.models.some(isModelInFlight) ?? false - const refreshSetup = useDictationSetupPoller({ - visible: routeFocused && client !== null, - polling, - refresh, - intervalMs: POLL_INTERVAL_MS - }) - - useEffect(() => { - if (routeFocused && client && setup === null) { - setLoading(true) - } - }, [routeFocused, client, setup]) - - const handleToggleEnabled = useCallback( - async (enabled: boolean) => { - if (!client) { - return - } - setError(null) - // Optimistic flip so the switch responds instantly; reconcile below. - setSetup((prev) => (prev ? { ...prev, enabled } : prev)) - try { - setSetup(await setDictationConfig(client, { enabled })) - } catch (err) { - setError(err instanceof Error ? err.message : 'Could not update') - void refreshSetup() - } - }, - [client, refreshSetup] - ) - - const handleSelectMode = useCallback( - async (dictationMode: 'toggle' | 'hold') => { - if (!client) { - return - } - setError(null) - setSetup((prev) => (prev ? { ...prev, dictationMode } : prev)) - try { - setSetup(await setDictationConfig(client, { dictationMode })) - } catch (err) { - setError(err instanceof Error ? err.message : 'Could not update') - void refreshSetup() - } - }, - [client, refreshSetup] - ) - - const handleUseModel = useCallback( - async (model: MobileSpeechModel) => { - if (!client) { - return - } - setBusyAction({ modelId: model.id, type: 'select' }) - setError(null) - try { - setSetup(await setDictationConfig(client, { enabled: true, modelId: model.id })) - setModelDrawerOpen(false) - } catch (err) { - setError(err instanceof Error ? err.message : 'Could not select model') - } finally { - setBusyAction(null) - } - }, + const hostIds = useMemo(() => hosts.map((host) => host.id), [hosts]) + const { clients, focused } = useFocusedSettingsHostClients(hostIds) + const client = clients.find((entry) => entry.state === 'connected')?.client ?? null + const operations = useMemo( + () => (client ? nativeVoiceSettingsOperations(client) : null), [client] ) - - const handleDownload = useCallback( - async (model: MobileSpeechModel) => { - if (!client) { - return - } - setBusyAction({ modelId: model.id, type: 'download' }) - setError(null) - try { - await downloadDictationModel(client, model.id) - await refreshSetup() - } catch (err) { - setError(err instanceof Error ? err.message : 'Download failed') - } finally { - setBusyAction(null) - } - }, - [client, refreshSetup] - ) - - const handleDelete = useCallback( - async (model: MobileSpeechModel) => { - if (!client) { - return - } - const deletedSelectedModel = setup?.selectedModelId === model.id - setBusyAction({ modelId: model.id, type: 'delete' }) - setError(null) - try { - setSetup(await deleteDictationModel(client, model.id)) - if (deletedSelectedModel) { - setModelDrawerOpen(false) - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Delete failed') - } finally { - setBusyAction(null) - } - }, - [client, setup?.selectedModelId] - ) - - const enabled = setup?.enabled ?? false - const selectedModel = setup?.models.find((m) => m.id === setup.selectedModelId) - const selectedModelLabel = selectedModel?.label ?? 'None selected' - return ( - - - router.back()}> - - - Voice - - - {!client ? ( - - Connect to a desktop to manage voice settings. - - ) : loading && setup === null ? ( - - - - ) : setup === null ? ( - - {error ?? 'Failed to load voice settings.'} - - ) : ( - - DICTATION - - - - Enable Voice Dictation - - Dictate text into any focused pane on your desktop. - - - void handleToggleEnabled(v)} - trackColor={{ false: colors.bgRaised, true: colors.textSecondary }} - thumbColor={colors.textPrimary} - /> - - - - - - - Dictation Mode - - Toggle: press once to start, again to stop. Hold: dictate while held. - - - - {DICTATION_MODES.map((mode) => { - const active = setup.dictationMode === mode.value - return ( - void handleSelectMode(mode.value)} - style={[styles.segment, active && styles.segmentActive]} - > - - {mode.label} - - - ) - })} - - - - - SPEECH MODEL - - [ - styles.row, - !enabled && styles.disabled, - pressed && styles.rowPressed - ]} - disabled={!enabled} - onPress={() => setModelDrawerOpen(true)} - > - - Speech Model - - {selectedModelLabel} - - - - - - - {error ? {error} : null} - - )} - - setModelDrawerOpen(false)}> - Speech Model - {setup ? ( - void handleUseModel(m)} - onDownload={(m) => void handleDownload(m)} - onDelete={(m) => void handleDelete(m)} - /> - ) : null} - - + router.back()} /> ) } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase, - paddingHorizontal: spacing.lg - }, - topRow: { - flexDirection: 'row', - alignItems: 'center', - marginTop: spacing.sm, - marginBottom: spacing.lg - }, - backButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.sm - }, - heading: { - fontSize: 20, - fontWeight: '700', - color: colors.textPrimary - }, - scrollContent: { - paddingBottom: spacing.xl - }, - loading: { paddingVertical: spacing.xl, alignItems: 'center' }, - groupHeading: { - fontSize: 11, - fontWeight: '600', - color: colors.textMuted, - letterSpacing: 0.5, - marginBottom: spacing.xs, - paddingHorizontal: spacing.xs - }, - section: { - backgroundColor: colors.bgPanel, - borderRadius: radii.card, - overflow: 'hidden' - }, - sectionTopGap: { marginTop: spacing.sm }, - inputGroupGap: { marginTop: spacing.xl }, - disabled: { opacity: 0.5 }, - emptyText: { - fontSize: typography.bodySize, - color: colors.textSecondary, - padding: spacing.md - }, - errorText: { - fontSize: typography.bodySize, - color: colors.statusRed, - padding: spacing.md - }, - row: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm + 2, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2 - }, - rowPressed: { backgroundColor: colors.bgRaised }, - rowContent: { flex: 1 }, - rowLabel: { - fontSize: typography.bodySize, - fontWeight: '500', - color: colors.textPrimary - }, - drawerTitle: { - fontSize: typography.bodySize, - fontWeight: '700', - color: colors.textPrimary, - paddingHorizontal: spacing.md + 2, - paddingTop: spacing.sm, - paddingBottom: spacing.xs - }, - rowSublabel: { - fontSize: typography.bodySize - 2, - color: colors.textSecondary, - marginTop: 2 - }, - separator: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle, - marginHorizontal: spacing.md - }, - segmented: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.bgBase, - borderRadius: radii.button, - padding: 2 - }, - segment: { - paddingHorizontal: spacing.md, - paddingVertical: 6, - borderRadius: radii.button - 1 - }, - segmentActive: { backgroundColor: colors.bgRaised }, - segmentText: { fontSize: typography.metaSize, color: colors.textSecondary, fontWeight: '600' }, - segmentTextActive: { color: colors.textPrimary }, - error: { color: colors.statusRed, fontSize: typography.metaSize, marginTop: spacing.md } -}) diff --git a/mobile/src/components/MobileRichMarkdownEditor.tsx b/mobile/src/components/MobileRichMarkdownEditor.tsx index 8fbcaa149e7..2ad299170ea 100644 --- a/mobile/src/components/MobileRichMarkdownEditor.tsx +++ b/mobile/src/components/MobileRichMarkdownEditor.tsx @@ -2,7 +2,6 @@ import { forwardRef, memo, useCallback, - useEffect, useImperativeHandle, useMemo, useRef, @@ -29,7 +28,12 @@ import { } from 'lucide-react-native' import WebView, { type WebViewMessageEvent } from 'react-native-webview' import { colors, radii, spacing } from '../theme/mobile-theme' -import { normalizeMobileRichMarkdownKeyboardInset } from './mobile-rich-markdown-editor-keyboard-inset-script' +import type { + MobileRichMarkdownCommand, + MobileRichMarkdownEditorMessage, + MobileRichMarkdownEditorProps +} from './mobile-rich-markdown-editor-contract' +import { useMobileRichMarkdownEditorController } from './use-mobile-rich-markdown-editor-controller' import { buildMobileRichMarkdownEditorHtml, escapeInjectedJavaScriptString @@ -38,67 +42,16 @@ import { const EDITOR_DOCUMENT_ORIGIN = 'https://orca-mobile-editor.invalid' const EDITOR_DOCUMENT_URL = `${EDITOR_DOCUMENT_ORIGIN}/rich-markdown-editor` -function normalizeExternalEditorUrl(value: string): string | null { - const url = value.trim() - if (!url) { - return null - } - for (let index = 0; index < url.length; index += 1) { - const code = url.charCodeAt(index) - if (code <= 32 || code === 127) { - return null - } - } - if (/^mailto:/i.test(url)) { - return url - } - if (!/^https?:\/\//i.test(url)) { - return null - } - try { - const parsed = new URL(url) - return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : null - } catch { - return null - } -} - -type RichMarkdownCommand = - | 'paragraph' - | 'heading1' - | 'heading2' - | 'heading3' - | 'bold' - | 'italic' - | 'strike' - | 'bulletList' - | 'orderedList' - | 'taskList' - | 'quote' - | 'inlineCode' - | 'codeBlock' - | 'link' - | 'image' - -type Props = { - content: string - editable: boolean - onChange: (content: string) => void - onKeyboardInsetChange?: (bottom: number) => void +type Props = Omit & { + onOpenLink?: (url: string) => void } export type MobileRichMarkdownEditorHandle = { dismissKeyboard: () => void } -type EditorWebViewMessage = - | { type: 'ready' } - | { type: 'change'; markdown: string; generation: number } - | { type: 'openLink'; url: string } - | { type: 'keyboardInset'; bottom: number } - type ToolbarItem = { - command: RichMarkdownCommand + command: MobileRichMarkdownCommand label: string icon: ComponentType<{ size?: number; color?: string }> } @@ -122,61 +75,55 @@ const TOOLBAR_ITEMS: ToolbarItem[] = [ ] function MobileRichMarkdownEditorInner( - { content, editable, onChange, onKeyboardInsetChange }: Props, + { content, editable, onChange, onKeyboardInsetChange, onOpenLink }: Props, ref: ForwardedRef ) { const webViewRef = useRef(null) - const readyRef = useRef(false) - const documentGenerationRef = useRef(0) - const currentWebViewContentRef = useRef(null) const html = useMemo(() => buildMobileRichMarkdownEditorHtml(), []) const inject = useCallback((script: string) => { webViewRef.current?.injectJavaScript(`${script}\ntrue;`) }, []) - const applyContent = useCallback( - (nextContent: string) => { - documentGenerationRef.current += 1 - currentWebViewContentRef.current = nextContent - inject( - `window.__orcaRichMarkdown && window.__orcaRichMarkdown.setMarkdown(${escapeInjectedJavaScriptString(nextContent)}, ${documentGenerationRef.current});` - ) - }, + const transport = useMemo( + () => ({ + setMarkdown: (markdown: string, generation: number) => + inject( + `window.__orcaRichMarkdown && window.__orcaRichMarkdown.setMarkdown(${escapeInjectedJavaScriptString(markdown)}, ${generation});` + ), + setEditable: (nextEditable: boolean) => + inject( + `window.__orcaRichMarkdown && window.__orcaRichMarkdown.setEditable(${nextEditable ? 'true' : 'false'});` + ), + runCommand: (command: MobileRichMarkdownCommand) => + inject( + `window.__orcaRichMarkdown && window.__orcaRichMarkdown.runCommand(${escapeInjectedJavaScriptString(command)});` + ) + }), [inject] ) - const applyEditable = useCallback( - (nextEditable: boolean) => { - inject( - `window.__orcaRichMarkdown && window.__orcaRichMarkdown.setEditable(${nextEditable ? 'true' : 'false'});` - ) + const openLink = useCallback( + (url: string) => { + if (onOpenLink) { + onOpenLink(url) + return + } + void Linking.openURL(url).catch(() => {}) }, - [inject] + [onOpenLink] ) - useEffect(() => { - if (!readyRef.current) { - return - } - if (currentWebViewContentRef.current !== content) { - applyContent(content) - } - }, [applyContent, content]) + const { handleMessage, runCommand } = useMobileRichMarkdownEditorController({ + content, + editable, + onChange, + onKeyboardInsetChange, + onOpenLink: openLink, + transport + }) - useEffect(() => { - if (readyRef.current) { - applyEditable(editable) - } - }, [applyEditable, editable]) - - // Clear any reported keyboard inset when the editor unmounts so a lifted - // Save/Discard bar settles back once the tab closes. - useEffect(() => { - return () => onKeyboardInsetChange?.(0) - }, [onKeyboardInsetChange]) - - const handleMessage = useCallback( + const handleWebViewMessage = useCallback( (event: WebViewMessageEvent) => { let message: unknown try { @@ -187,37 +134,9 @@ function MobileRichMarkdownEditorInner( if (!message || typeof message !== 'object') { return } - const editorMessage = message as Partial - if ('type' in message && message.type === 'ready') { - readyRef.current = true - applyContent(content) - applyEditable(editable) - return - } - if ( - editorMessage.type === 'change' && - typeof editorMessage.markdown === 'string' && - editorMessage.generation === documentGenerationRef.current - ) { - currentWebViewContentRef.current = editorMessage.markdown - onChange(editorMessage.markdown) - return - } - if (editorMessage.type === 'openLink' && typeof editorMessage.url === 'string') { - const url = normalizeExternalEditorUrl(editorMessage.url) - if (url) { - void Linking.openURL(url).catch(() => {}) - } - return - } - if (editorMessage.type === 'keyboardInset' && typeof editorMessage.bottom === 'number') { - const bottom = normalizeMobileRichMarkdownKeyboardInset(editorMessage.bottom) - if (bottom !== null) { - onKeyboardInsetChange?.(bottom) - } - } + handleMessage(message as Partial) }, - [applyContent, applyEditable, content, editable, onChange, onKeyboardInsetChange] + [handleMessage] ) const handleShouldStartLoadWithRequest = useCallback((request: { url?: string }) => { @@ -230,15 +149,6 @@ function MobileRichMarkdownEditorInner( return isEditorDocument }, []) - const runCommand = useCallback( - (command: RichMarkdownCommand) => { - inject( - `window.__orcaRichMarkdown && window.__orcaRichMarkdown.runCommand(${escapeInjectedJavaScriptString(command)});` - ) - }, - [inject] - ) - const dismissKeyboard = useCallback(() => { // Why: the caret lives in the WebView, so the injected blur is what closes the keyboard; // Keyboard.dismiss only clears a native TextInput that stole focus first. @@ -286,7 +196,7 @@ function MobileRichMarkdownEditorInner( domStorageEnabled={false} hideKeyboardAccessoryView keyboardDisplayRequiresUserAction={false} - onMessage={handleMessage} + onMessage={handleWebViewMessage} onShouldStartLoadWithRequest={handleShouldStartLoadWithRequest} style={styles.webView} scrollEnabled diff --git a/mobile/src/components/NewWorktreeFormSheet.tsx b/mobile/src/components/NewWorktreeFormSheet.tsx index 2a6587ffa87..f202d084c82 100644 --- a/mobile/src/components/NewWorktreeFormSheet.tsx +++ b/mobile/src/components/NewWorktreeFormSheet.tsx @@ -43,6 +43,7 @@ export function NewWorktreeFormSheet(props: { creating: boolean canCreate: boolean onClose: () => void + onOpenExternalUrl: (url: string) => Promise onOpenProject: () => void onOpenRunTarget: () => void onOpenSource: () => void @@ -84,6 +85,7 @@ export function NewWorktreeFormSheet(props: { label={props.selectedRepoIsGit ? "Name or 'Create From'" : 'Workspace name'} disabled={props.sshGate.requiresConnection} interactive={props.interactive} + onOpenExternalUrl={props.onOpenExternalUrl} onBeforeOpen={props.onClearError} onOpenDrawer={props.onOpenSource} /> diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index a986d347782..511b8276be5 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -55,8 +55,16 @@ export function NewWorktreeModal(props: NewWorktreeModalProps) { } function NewWorktreeModalContent(props: NewWorktreeModalProps) { - const { visible, client, hostId, existingWorktreePaths, existingWorktrees, onCreated, onClose } = - props + const { + visible, + client, + hostId, + existingWorktreePaths, + existingWorktrees, + openExternalUrl, + onCreated, + onClose + } = props const { repos, selectedRepo, setSelectedRepo, loading } = useNewWorkspaceRepositories({ client, hostId, @@ -217,6 +225,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) { creating={createSubmit.creating} canCreate={canCreate} onClose={onClose} + onOpenExternalUrl={openExternalUrl} onOpenProject={() => openPicker('project')} onOpenRunTarget={() => openPicker('runTarget')} onOpenSource={navigation.openSourceDrawer} diff --git a/mobile/src/components/NewWorktreeModalController.tsx b/mobile/src/components/NewWorktreeModalController.tsx index 9a062692d17..4b6812fc9ff 100644 --- a/mobile/src/components/NewWorktreeModalController.tsx +++ b/mobile/src/components/NewWorktreeModalController.tsx @@ -13,6 +13,7 @@ type Props = { hostId?: string existingWorktreePaths?: readonly string[] existingWorktrees?: readonly { repoId: string; branch: string }[] + openExternalUrl: (url: string) => Promise onVisibleChange?: (visible: boolean) => void onRouteVisibleChange: (visible: boolean) => void onCreated: (worktreeId: string, name: string) => void @@ -26,6 +27,7 @@ export const NewWorktreeModalController = forwardRef diff --git a/mobile/src/components/SmartWorkspaceSourceField.tsx b/mobile/src/components/SmartWorkspaceSourceField.tsx index 9b8972b16be..23eb04ecb33 100644 --- a/mobile/src/components/SmartWorkspaceSourceField.tsx +++ b/mobile/src/components/SmartWorkspaceSourceField.tsx @@ -1,4 +1,4 @@ -import { Linking, Pressable, StyleSheet, Text, TextInput, View } from 'react-native' +import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' import { CircleDot, ExternalLink, @@ -16,6 +16,7 @@ type Props = { composer: MobileComposerSource label: string disabled?: boolean + onOpenExternalUrl: (url: string) => Promise // Why: only the active form view may focus this field. While the source drawer // is open/closing this stays non-focusable so the drawer's dismiss (which // restores native focus back here) can't re-fire onFocus and reopen the drawer. @@ -44,6 +45,7 @@ export function SmartWorkspaceSourceField({ composer, label, disabled, + onOpenExternalUrl, interactive, onBeforeOpen, onOpenDrawer @@ -71,8 +73,10 @@ export function SmartWorkspaceSourceField({ {selection.url ? ( selection.url && void Linking.openURL(selection.url).catch(() => {})} + onPress={() => selection.url && void onOpenExternalUrl(selection.url).catch(() => {})} > diff --git a/mobile/src/components/mobile-rich-markdown-editor-contract.ts b/mobile/src/components/mobile-rich-markdown-editor-contract.ts new file mode 100644 index 00000000000..5e133e6e23b --- /dev/null +++ b/mobile/src/components/mobile-rich-markdown-editor-contract.ts @@ -0,0 +1,37 @@ +export type MobileRichMarkdownCommand = + | 'paragraph' + | 'heading1' + | 'heading2' + | 'heading3' + | 'bold' + | 'italic' + | 'strike' + | 'bulletList' + | 'orderedList' + | 'taskList' + | 'quote' + | 'inlineCode' + | 'codeBlock' + | 'link' + | 'image' + +export type MobileRichMarkdownEditorMessage = + | { type: 'ready' } + | { type: 'change'; markdown: string; generation: number } + | { type: 'openLink'; url: string } + | { type: 'keyboardInset'; bottom: number } + +export type MobileRichMarkdownEditorProps = { + content: string + editable: boolean + onChange: (content: string) => void + onKeyboardInsetChange?: (bottom: number) => void + onOpenLink: (url: string) => void +} + +/** How a host delivers a command into whatever surface renders the editor document. */ +export type MobileRichMarkdownEditorTransport = { + setMarkdown: (markdown: string, generation: number) => void + setEditable: (editable: boolean) => void + runCommand: (command: MobileRichMarkdownCommand) => void +} diff --git a/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts b/mobile/src/components/mobile-rich-markdown-editor-document-body.ts similarity index 56% rename from mobile/src/components/mobile-rich-markdown-editor-body-primary.ts rename to mobile/src/components/mobile-rich-markdown-editor-document-body.ts index c4348ad15a2..a381b7d38e4 100644 --- a/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts +++ b/mobile/src/components/mobile-rich-markdown-editor-document-body.ts @@ -1,4 +1,5 @@ -export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY = [ +// Head of the editor document through the editable surface; the script follows it. +export const MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_BODY = [ ';', ' --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;', ' --font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;', @@ -181,99 +182,5 @@ export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY = [ ' ', '', '', - '
', - ' ', - '', - '' + ' })();' ].join('\n') diff --git a/mobile/src/components/mobile-rich-markdown-editor-document.test.ts b/mobile/src/components/mobile-rich-markdown-editor-document.test.ts new file mode 100644 index 00000000000..f45d45139c6 --- /dev/null +++ b/mobile/src/components/mobile-rich-markdown-editor-document.test.ts @@ -0,0 +1,19 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { buildMobileRichMarkdownEditorHtml } from './mobile-rich-markdown-editor-html' + +// Digest of main's document at e80fae0c4d, captured before the body/script split. Splitting the +// constants must not move a single byte of what the WebView loads. A hash rather than a +// checked-in HTML file, because the formatter would rewrite the file and defeat the check. +const PRE_SPLIT_DOCUMENT_SHA256 = '1ef29c8802170800011e8accf1966bc542cdd7dd5c9600bacb6e0860f77b6df8' +const PRE_SPLIT_DOCUMENT_BYTES = 29852 + +describe('mobile rich markdown editor document', () => { + it('reproduces the pre-split document byte for byte', () => { + const document = buildMobileRichMarkdownEditorHtml() + expect(Buffer.byteLength(document, 'utf8')).toBe(PRE_SPLIT_DOCUMENT_BYTES) + expect(createHash('sha256').update(document, 'utf8').digest('hex')).toBe( + PRE_SPLIT_DOCUMENT_SHA256 + ) + }) +}) diff --git a/mobile/src/components/mobile-rich-markdown-editor-html.ts b/mobile/src/components/mobile-rich-markdown-editor-html.ts index 493fea3dd02..a98a77d6e9d 100644 --- a/mobile/src/components/mobile-rich-markdown-editor-html.ts +++ b/mobile/src/components/mobile-rich-markdown-editor-html.ts @@ -1,17 +1,9 @@ import { colors } from '../theme/mobile-theme' -import { MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT } from './mobile-rich-markdown-keyboard-dismiss-script' -import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script' -import { MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT } from './mobile-rich-markdown-selection-script' -import { MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY } from './mobile-rich-markdown-editor-body-primary' -import { MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY } from './mobile-rich-markdown-editor-body-secondary' -import { - MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS, - MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END -} from './mobile-rich-markdown-editor-document-suffix' +import { MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_BODY } from './mobile-rich-markdown-editor-document-body' +import { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT } from './mobile-rich-markdown-editor-script' -export function escapeInjectedJavaScriptString(value: string): string { - return JSON.stringify(value).replace(/<\/script/gi, '<\\/script') -} +export { escapeInjectedJavaScriptString } from './mobile-rich-markdown-editor-script-string' +export { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT } from './mobile-rich-markdown-editor-script' export function buildMobileRichMarkdownEditorHtml(): string { return ` @@ -30,7 +22,10 @@ export function buildMobileRichMarkdownEditorHtml(): string { --border: ${colors.borderSubtle}; --primary: ${colors.textPrimary}; --primary-foreground: ${colors.bgBase}; - --accent-link: ${colors.accentBlue}${MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY} -${MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY}${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT} -${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS}${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END}` + --accent-link: ${colors.accentBlue}${MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_BODY} + + +` } diff --git a/mobile/src/components/mobile-rich-markdown-editor-script-primary.ts b/mobile/src/components/mobile-rich-markdown-editor-script-primary.ts new file mode 100644 index 00000000000..1f0e5aa7b3b --- /dev/null +++ b/mobile/src/components/mobile-rich-markdown-editor-script-primary.ts @@ -0,0 +1,95 @@ +export const MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_PRIMARY = [ + ' (function () {', + " var editor = document.getElementById('editor');", + " var lastMarkdown = '';", + ' var inputTimer = null;', + ' var documentGeneration = 0;', + ' var editable = true;', + ' var suppressInput = false;', + '', + ' function post(message) {', + ' window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));', + ' }', + '', + ' function decodeMarkdownEntities(value) {', + ' return String(value).replace(/&(#x[0-9a-f]+|#\\d+|amp|lt|gt|quot|apos);/gi, function (match, entity) {', + ' var lower = String(entity).toLowerCase();', + " if (lower === 'amp') return '&';", + " if (lower === 'lt') return '<';", + " if (lower === 'gt') return '>';", + " if (lower === 'quot') return '\"';", + " if (lower === 'apos') return \"'\";", + " if (lower.indexOf('#x') === 0) {", + ' var hex = Number.parseInt(lower.slice(2), 16);', + ' return Number.isFinite(hex) && hex >= 0 && hex <= 0x10ffff ? String.fromCodePoint(hex) : match;', + ' }', + " if (lower.indexOf('#') === 0) {", + ' var code = Number.parseInt(lower.slice(1), 10);', + ' return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;', + ' }', + ' return match;', + ' });', + ' }', + '', + ' function escapeHtml(value) {', + ' return decodeMarkdownEntities(value).replace(/[&<>"\']/g, function (char) {', + " return ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' })[char];", + ' });', + ' }', + '', + ' function escapeAttr(value) {', + " return escapeHtml(value).replace(/\\n/g, ' ');", + ' }', + '', + ' function isSafeUrl(value) {', + " var trimmed = String(value || '').trim();", + ' return !/^javascript:/i.test(trimmed);', + ' }', + '', + ' function splitTableRow(line) {', + " return line.trim().replace(/^\\|/, '').replace(/\\|$/, '').split('|').map(function (cell) {", + ' return cell.trim();', + ' });', + ' }', + '', + ' function isTableSeparator(line) {', + ' var cells = splitTableRow(line);', + ' return cells.length > 0 && cells.every(function (cell) {', + ' return /^:?-{3,}:?$/.test(cell);', + ' });', + ' }', + '', + ' function renderInline(text) {', + " var output = '';", + ' var pattern = /(!\\[[^\\]]*\\]\\([^)]+\\)|`[^`]+`|~~[^~]+~~|\\*\\*[^*]+\\*\\*|__[^_]+__|\\*[^*\\n]+\\*|_[^_\\n]+_|\\[[^\\]]+\\]\\([^)]+\\)|https?:\\/\\/[^\\s<]+)/g;', + ' var lastIndex = 0;', + ' var match;', + ' while ((match = pattern.exec(text))) {', + ' output += escapeHtml(text.slice(lastIndex, match.index));', + ' var token = match[0];', + ' var image = token.match(/^!\\[([^\\]]*)\\]\\(([^)]+)\\)$/);', + ' var link = token.match(/^\\[([^\\]]+)\\]\\(([^)]+)\\)$/);', + ' if (image && isSafeUrl(image[2])) {', + " output += '\"'';", + ' } else if (link && isSafeUrl(link[2])) {', + " output += '' + renderInline(link[1]) + '';", + ' } else if (/^https?:\\/\\//i.test(token)) {', + " output += '' + escapeHtml(token) + '';", + " } else if (token.indexOf('`') === 0) {", + " output += '' + escapeHtml(token.slice(1, -1)) + '';", + " } else if (token.indexOf('~~') === 0) {", + " output += '' + renderInline(token.slice(2, -2)) + '';", + " } else if (token.indexOf('**') === 0 || token.indexOf('__') === 0) {", + " output += '' + renderInline(token.slice(2, -2)) + '';", + ' } else {', + " output += '' + renderInline(token.slice(1, -1)) + '';", + ' }', + ' lastIndex = pattern.lastIndex;', + ' }', + ' output += escapeHtml(text.slice(lastIndex));', + ' return output;', + ' }', + '', + ' function isBlockStart(line) {', + ' return /^(```|#{1,6}\\s+|>\\s?|\\s*(?:[-*+]|\\d+[.)])\\s+|\\s*(-{3,}|\\*{3,}|_{3,})\\s*$)/.test(line);' +].join('\n') diff --git a/mobile/src/components/mobile-rich-markdown-editor-body-secondary.ts b/mobile/src/components/mobile-rich-markdown-editor-script-secondary.ts similarity index 99% rename from mobile/src/components/mobile-rich-markdown-editor-body-secondary.ts rename to mobile/src/components/mobile-rich-markdown-editor-script-secondary.ts index 70de1c33692..9578bdc49ea 100644 --- a/mobile/src/components/mobile-rich-markdown-editor-body-secondary.ts +++ b/mobile/src/components/mobile-rich-markdown-editor-script-secondary.ts @@ -1,4 +1,4 @@ -export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY = [ +export const MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_SECONDARY = [ ' }', '', ' function indentationWidth(value) {', diff --git a/mobile/src/components/mobile-rich-markdown-editor-script-string.ts b/mobile/src/components/mobile-rich-markdown-editor-script-string.ts new file mode 100644 index 00000000000..4b5671d0331 --- /dev/null +++ b/mobile/src/components/mobile-rich-markdown-editor-script-string.ts @@ -0,0 +1,3 @@ +export function escapeInjectedJavaScriptString(value: string): string { + return JSON.stringify(value).replace(/<\/script/gi, '<\\/script') +} diff --git a/mobile/src/components/mobile-rich-markdown-editor-script.ts b/mobile/src/components/mobile-rich-markdown-editor-script.ts new file mode 100644 index 00000000000..b8644705463 --- /dev/null +++ b/mobile/src/components/mobile-rich-markdown-editor-script.ts @@ -0,0 +1,14 @@ +import { + MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS, + MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_END +} from './mobile-rich-markdown-editor-document-suffix' +import { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_PRIMARY } from './mobile-rich-markdown-editor-script-primary' +import { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_SECONDARY } from './mobile-rich-markdown-editor-script-secondary' +import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script' +import { MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT } from './mobile-rich-markdown-keyboard-dismiss-script' +import { MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT } from './mobile-rich-markdown-selection-script' + +/** The editor's whole program, independent of how a host delivers it to a WebView. */ +export const MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT = `${MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_PRIMARY} +${MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_SECONDARY}${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT} +${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS}${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_END}` diff --git a/mobile/src/components/new-worktree-modal-types.ts b/mobile/src/components/new-worktree-modal-types.ts index 4246dd6f29f..8dae250cd71 100644 --- a/mobile/src/components/new-worktree-modal-types.ts +++ b/mobile/src/components/new-worktree-modal-types.ts @@ -23,6 +23,7 @@ export type NewWorktreeModalProps = { hostId?: string existingWorktreePaths?: readonly string[] existingWorktrees?: readonly { repoId: string; branch: string }[] + openExternalUrl: (url: string) => Promise onCreated: (worktreeId: string, name: string) => void onClose: () => void } diff --git a/mobile/src/components/use-mobile-rich-markdown-editor-controller.ts b/mobile/src/components/use-mobile-rich-markdown-editor-controller.ts new file mode 100644 index 00000000000..8b154680b38 --- /dev/null +++ b/mobile/src/components/use-mobile-rich-markdown-editor-controller.ts @@ -0,0 +1,114 @@ +import { useCallback, useEffect, useRef } from 'react' +import { normalizeMobileRichMarkdownKeyboardInset } from './mobile-rich-markdown-editor-keyboard-inset-script' +import type { + MobileRichMarkdownCommand, + MobileRichMarkdownEditorMessage, + MobileRichMarkdownEditorProps, + MobileRichMarkdownEditorTransport +} from './mobile-rich-markdown-editor-contract' + +export function normalizeExternalEditorUrl(value: string): string | null { + const url = value.trim() + if (!url) { + return null + } + for (let index = 0; index < url.length; index += 1) { + const code = url.charCodeAt(index) + if (code <= 32 || code === 127) { + return null + } + } + if (/^mailto:/i.test(url)) { + return url + } + if (!/^https?:\/\//i.test(url)) { + return null + } + try { + const parsed = new URL(url) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : null + } catch { + return null + } +} + +export function useMobileRichMarkdownEditorController({ + content, + editable, + onChange, + onKeyboardInsetChange, + onOpenLink, + transport +}: MobileRichMarkdownEditorProps & { transport: MobileRichMarkdownEditorTransport }) { + const readyRef = useRef(false) + const documentGenerationRef = useRef(0) + const currentEditorContentRef = useRef(null) + + const applyContent = useCallback( + (nextContent: string) => { + documentGenerationRef.current += 1 + currentEditorContentRef.current = nextContent + transport.setMarkdown(nextContent, documentGenerationRef.current) + }, + [transport] + ) + + useEffect(() => { + if (readyRef.current && currentEditorContentRef.current !== content) { + applyContent(content) + } + }, [applyContent, content]) + + useEffect(() => { + if (readyRef.current) { + transport.setEditable(editable) + } + }, [editable, transport]) + + // Clear any reported keyboard inset when the editor unmounts so a lifted + // Save/Discard bar settles back once the tab closes. + useEffect(() => { + return () => onKeyboardInsetChange?.(0) + }, [onKeyboardInsetChange]) + + const handleMessage = useCallback( + (message: Partial) => { + if (message.type === 'ready') { + readyRef.current = true + applyContent(content) + transport.setEditable(editable) + return + } + if ( + message.type === 'change' && + typeof message.markdown === 'string' && + message.generation === documentGenerationRef.current + ) { + currentEditorContentRef.current = message.markdown + onChange(message.markdown) + return + } + if (message.type === 'openLink' && typeof message.url === 'string') { + const url = normalizeExternalEditorUrl(message.url) + if (url) { + onOpenLink(url) + } + return + } + if (message.type === 'keyboardInset' && typeof message.bottom === 'number') { + const bottom = normalizeMobileRichMarkdownKeyboardInset(message.bottom) + if (bottom !== null) { + onKeyboardInsetChange?.(bottom) + } + } + }, + [applyContent, content, editable, onChange, onKeyboardInsetChange, onOpenLink, transport] + ) + + const runCommand = useCallback( + (command: MobileRichMarkdownCommand) => transport.runCommand(command), + [transport] + ) + + return { handleMessage, runCommand } +} diff --git a/mobile/src/diagnostics/connection-diagnostics-screen-styles.ts b/mobile/src/diagnostics/connection-diagnostics-screen-styles.ts new file mode 100644 index 00000000000..67a08050711 --- /dev/null +++ b/mobile/src/diagnostics/connection-diagnostics-screen-styles.ts @@ -0,0 +1,95 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +export const connectionDiagnosticsScreenStyles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bgBase, padding: spacing.lg }, + topRow: { flexDirection: 'row', alignItems: 'center', marginBottom: spacing.lg }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { fontSize: 20, fontWeight: '700', color: colors.textPrimary }, + hostPicker: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + marginBottom: spacing.md + }, + hostChip: { + paddingVertical: spacing.xs + 2, + paddingHorizontal: spacing.md, + borderRadius: 16, + backgroundColor: colors.bgRaised + }, + hostChipActive: { + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + hostChipText: { fontSize: typography.metaSize, color: colors.textSecondary, maxWidth: 160 }, + hostChipTextActive: { color: colors.textPrimary, fontWeight: '600' }, + statusRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.sm + }, + statusText: { fontSize: typography.metaSize, color: colors.textSecondary }, + diagnosisCard: { + backgroundColor: colors.bgPanel, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + borderRadius: 10, + padding: spacing.md, + marginBottom: spacing.md + }, + diagnosisHeading: { + fontSize: typography.metaSize, + fontWeight: '600', + color: colors.textPrimary, + marginBottom: spacing.xs + }, + diagnosisText: { fontSize: typography.metaSize, color: colors.textPrimary, lineHeight: 18 }, + diagnosisNext: { + fontSize: typography.metaSize, + color: colors.textSecondary, + lineHeight: 18, + marginTop: spacing.xs + }, + privacyHint: { marginTop: spacing.sm, fontSize: 11, lineHeight: 15, color: colors.textMuted }, + sendButton: { + marginTop: spacing.md, + alignSelf: 'flex-start', + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: 8, + backgroundColor: colors.bgRaised + }, + sendButtonText: { + fontSize: typography.metaSize, + fontWeight: '600', + color: colors.textPrimary + }, + copyButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs + 2, + paddingVertical: spacing.xs + 2, + paddingHorizontal: spacing.md, + borderRadius: 8, + backgroundColor: colors.bgRaised + }, + copyButtonText: { + fontSize: typography.metaSize, + fontWeight: '600', + color: colors.textPrimary + }, + emptyText: { fontSize: typography.metaSize, color: colors.textMuted, lineHeight: 18 } +}) diff --git a/mobile/src/diagnostics/connection-diagnostics-screen.tsx b/mobile/src/diagnostics/connection-diagnostics-screen.tsx new file mode 100644 index 00000000000..063f2d0dcad --- /dev/null +++ b/mobile/src/diagnostics/connection-diagnostics-screen.tsx @@ -0,0 +1,103 @@ +import { useCallback, useState, type ReactNode } from 'react' +import { ConnectionDiagnosticsView } from './connection-diagnostics-view' +import { + diagnoseConnection, + getReportableConnectionIncidentId +} from './connection-diagnostics-analysis' +import { + getDiagnosticsSubmissionState, + updateDiagnosticsSubmissionState, + type DiagnosticsSubmissionStates +} from './connection-diagnostics-screen-data' +import type { DiagnosticsDeviceOperations } from './diagnostics-device-operations' +import type { + ConnectionLogEntry, + ConnectionState, + MobileConnectionDiagnosticPath +} from '../transport/types' + +export function ConnectionDiagnosticsScreen({ + device, + host, + state, + reconnectAttempts, + activePath, + pendingPath, + entries, + writeClipboard, + onBack, + hostPicker +}: { + device: DiagnosticsDeviceOperations | null + /** Null only when no host is paired; an empty name or endpoint is still a host. */ + host: { id: string; name: string; endpoint: string } | null + state: ConnectionState + reconnectAttempts: number + activePath?: MobileConnectionDiagnosticPath + pendingPath?: MobileConnectionDiagnosticPath | null + entries: readonly ConnectionLogEntry[] + writeClipboard: (report: string) => Promise + onBack: () => void + hostPicker?: ReactNode +}) { + const [copiedHostId, setCopiedHostId] = useState(null) + const [submissionStates, setSubmissionStates] = useState({}) + + const diagnosisArgs = host + ? { endpoint: host.endpoint, state, activePath, pendingPath, entries } + : null + const diagnosis = diagnosisArgs ? diagnoseConnection(diagnosisArgs) : null + const incidentId = diagnosisArgs ? getReportableConnectionIncidentId(diagnosisArgs) : null + const hostId = host?.id ?? null + const submissionKey = hostId && incidentId ? `${hostId}:${incidentId}` : null + const submissionState = getDiagnosticsSubmissionState(submissionStates, submissionKey) + const copied = copiedHostId !== null && copiedHostId === hostId + + const copyDiagnostics = useCallback(async () => { + if (!device || !hostId) { + return + } + const { report } = await device.report() + await writeClipboard(report) + setCopiedHostId(hostId) + setTimeout(() => setCopiedHostId((current) => (current === hostId ? null : current)), 2000) + }, [device, hostId, writeClipboard]) + + const sendDiagnostics = useCallback(async () => { + if (!device || !hostId || !submissionKey || submissionState === 'sending') { + return + } + const startedKey = submissionKey + setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, 'sending')) + const fresh = await device.report() + if (`${hostId}:${fresh.incidentId ?? ''}` !== startedKey) { + setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, null)) + return + } + const result = await device.submit({ + report: fresh.report, + appVersion: fresh.appVersion, + platform: fresh.platform + }) + setSubmissionStates((states) => + updateDiagnosticsSubmissionState(states, startedKey, result.ok ? 'sent' : 'failed') + ) + }, [device, hostId, submissionKey, submissionState]) + + return ( + + ) +} diff --git a/mobile/src/diagnostics/connection-diagnostics-view.tsx b/mobile/src/diagnostics/connection-diagnostics-view.tsx new file mode 100644 index 00000000000..c084c761e8b --- /dev/null +++ b/mobile/src/diagnostics/connection-diagnostics-view.tsx @@ -0,0 +1,119 @@ +import type { ReactNode } from 'react' +import { View, Text, Pressable } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { ChevronLeft, Copy, Check, Send } from 'lucide-react-native' +import { colors, spacing } from '../theme/mobile-theme' +import { ConnectionLog } from '../components/ConnectionLog' +import { connectionDiagnosticsScreenStyles as styles } from './connection-diagnostics-screen-styles' +import type { ConnectionLogEntry, ConnectionState } from '../transport/types' +import type { ConnectionDiagnosis } from './connection-diagnostics-analysis' +import type { DiagnosticsSubmissionState } from './connection-diagnostics-screen-data' + +export function ConnectionDiagnosticsView({ + hostPicker, + hasHost, + hostName, + state, + reconnectAttempts, + copied, + copyDiagnostics, + diagnosis, + submissionState, + sendDiagnostics, + entries, + onBack +}: { + hostPicker?: ReactNode + hasHost: boolean + hostName: string + state: ConnectionState + reconnectAttempts: number + copied: boolean + copyDiagnostics: () => Promise + diagnosis: ConnectionDiagnosis | null + submissionState: DiagnosticsSubmissionState | 'idle' + sendDiagnostics: () => Promise + entries: readonly ConnectionLogEntry[] + onBack: () => void +}) { + const insets = useSafeAreaInsets() + return ( + + + + + + Network diagnostics + + + {hostPicker} + {hasHost ? ( + <> + + + {state} + {reconnectAttempts > 0 ? ` · attempt ${reconnectAttempts}` : ''} + + void copyDiagnostics()}> + {copied ? ( + + ) : ( + + )} + {copied ? 'Copied' : 'Copy report'} + + + {diagnosis && ( + + What this suggests + {diagnosis.likelyCause} + {diagnosis.nextStep} + {diagnosis.reportability === 'orca-relay' && ( + <> + + Sends a size-limited redacted report including host name, endpoint, versions, + connection state, and events—never terminal contents or credentials. + + void sendDiagnostics()} + disabled={submissionState === 'sending'} + > + {submissionState === 'sent' ? ( + + ) : ( + + )} + + {submissionState === 'sending' + ? 'Sending…' + : submissionState === 'sent' + ? 'Diagnostics sent' + : submissionState === 'failed' + ? 'Retry sending' + : 'Send diagnostics to Orca'} + + + + )} + + )} + {entries.length > 0 ? ( + + ) : ( + + No connection events yet. Events appear as the app dials this host. + + )} + + ) : ( + No paired hosts. + )} + + ) +} diff --git a/mobile/src/diagnostics/diagnostics-device-operations.ts b/mobile/src/diagnostics/diagnostics-device-operations.ts new file mode 100644 index 00000000000..7f85655c723 --- /dev/null +++ b/mobile/src/diagnostics/diagnostics-device-operations.ts @@ -0,0 +1,16 @@ +import type { + ConnectionDiagnosticsSubmission, + ConnectionDiagnosticsSubmissionResult +} from './connection-diagnostics-submission' + +/** A fresh report plus the incident it describes, so a stale send can be dropped. */ +export type ConnectionDiagnosticsReport = ConnectionDiagnosticsSubmission & { + incidentId: string | null +} + +export interface DiagnosticsDeviceOperations { + report(): Promise + submit( + submission: ConnectionDiagnosticsSubmission + ): Promise +} diff --git a/mobile/src/diagnostics/native-diagnostics-operations.ts b/mobile/src/diagnostics/native-diagnostics-operations.ts new file mode 100644 index 00000000000..2b7b7fe6f0b --- /dev/null +++ b/mobile/src/diagnostics/native-diagnostics-operations.ts @@ -0,0 +1,51 @@ +import { Platform } from 'react-native' +import Constants from 'expo-constants' +import type { HostProfile } from '../transport/types' +import type { RpcClientContextValue } from '../transport/rpc-client-context-contract' +import { connectionLogStore } from '../transport/persisted-connection-log-store' +import { loadHostAppVersion } from '../transport/host-app-version-store' +import { readConnectionDiagnosticsSnapshot } from './connection-diagnostics-screen-data' +import { getReportableConnectionIncidentId } from './connection-diagnostics-analysis' +import { buildConnectionDiagnosticsReport } from './connection-diagnostics-report' +import { submitConnectionDiagnostics } from './connection-diagnostics-submission' +import type { DiagnosticsDeviceOperations } from './diagnostics-device-operations' + +export function createNativeDiagnosticsOperations( + host: HostProfile, + context: RpcClientContextValue, + liveDesktopAppVersion?: string | null +): DiagnosticsDeviceOperations { + return { + async report() { + const appVersion = Constants.expoConfig?.version ?? 'unknown' + const platform = `${Platform.OS} ${Platform.Version ?? ''}`.trim() + const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(host.id)) + const snapshot = await readConnectionDiagnosticsSnapshot(context, connectionLogStore, host.id) + return { + report: buildConnectionDiagnosticsReport({ + hostName: host.name, + endpoint: host.endpoint, + state: snapshot.state, + reconnectAttempts: snapshot.reconnectAttempts, + lastConnectedAt: snapshot.lastConnectedAt, + platform, + appVersion, + desktopAppVersion, + entries: snapshot.entries, + activePath: snapshot.activePath, + pendingPath: snapshot.pendingPath + }), + appVersion, + platform, + incidentId: getReportableConnectionIncidentId({ + endpoint: host.endpoint, + state: snapshot.state, + activePath: snapshot.activePath, + pendingPath: snapshot.pendingPath, + entries: snapshot.entries + }) + } + }, + submit: submitConnectionDiagnostics + } +} diff --git a/mobile/src/diagnostics/troubleshoot-view.tsx b/mobile/src/diagnostics/troubleshoot-view.tsx new file mode 100644 index 00000000000..0fd86605f13 --- /dev/null +++ b/mobile/src/diagnostics/troubleshoot-view.tsx @@ -0,0 +1,166 @@ +import { useCallback, useState } from 'react' +import { View, Text, Pressable, ScrollView, ActivityIndicator } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { + ChevronLeft, + ChevronDown, + ChevronUp, + Activity, + CheckCircle2, + ScrollText, + XCircle, + AlertTriangle +} from 'lucide-react-native' +import { colors, spacing } from '../theme/mobile-theme' +import { troubleshootCommonIssues } from './troubleshoot-common-issues' +import { troubleshootScreenStyles as styles } from './troubleshoot-screen-styles' +export type DiagnosticStatus = 'idle' | 'running' | 'done' + +export type CheckResult = { + label: string + status: 'pass' | 'fail' | 'warn' + detail: string +} + +function StatusIcon({ status }: { status: CheckResult['status'] }) { + switch (status) { + case 'pass': + return + case 'fail': + return + case 'warn': + return + } +} + +export function TroubleshootView({ + rootRef, + diagnosticStatus, + checks, + runDiagnostics, + onBack, + onConnectionLog +}: { + rootRef?: (node: View | null) => void + diagnosticStatus: DiagnosticStatus + checks: CheckResult[] + runDiagnostics: () => void + onBack: () => void + onConnectionLog: () => void +}) { + const insets = useSafeAreaInsets() + const [expandedId, setExpandedId] = useState(null) + const toggleSection = useCallback( + (id: string) => setExpandedId((prev) => (prev === id ? null : id)), + [] + ) + return ( + + + + + + Troubleshooting + + + + [ + styles.diagnosticButton, + pressed && styles.diagnosticButtonPressed, + diagnosticStatus === 'running' && styles.diagnosticButtonDisabled + ]} + testID="diagnostics-run" + onPress={runDiagnostics} + disabled={diagnosticStatus === 'running'} + > + {diagnosticStatus === 'running' ? ( + + ) : ( + + )} + + {diagnosticStatus === 'running' + ? 'Running…' + : diagnosticStatus === 'done' + ? 'Run again' + : 'Run diagnostics'} + + + + [ + styles.diagnosticButton, + pressed && styles.diagnosticButtonPressed + ]} + onPress={onConnectionLog} + > + + View network diagnostics + + + {checks.length > 0 && ( + + {checks.map((check, i) => ( + + {i > 0 && } + + + {check.label} + + {check.detail} + + + + ))} + + )} + + Common issues + + + {troubleshootCommonIssues.map((section, i) => ( + + {i > 0 && } + [styles.accordionHeader, pressed && styles.rowPressed]} + onPress={() => toggleSection(section.id)} + > + {section.icon} + {section.title} + {expandedId === section.id ? ( + + ) : ( + + )} + + {expandedId === section.id && ( + + {section.steps.map((step, j) => ( + + + {step} + + ))} + + )} + + ))} + + + + + + ) +} diff --git a/mobile/src/diagnostics/use-troubleshoot-diagnostics.ts b/mobile/src/diagnostics/use-troubleshoot-diagnostics.ts new file mode 100644 index 00000000000..0645a34331e --- /dev/null +++ b/mobile/src/diagnostics/use-troubleshoot-diagnostics.ts @@ -0,0 +1,128 @@ +import { useCallback, useRef, useState } from 'react' +import type { View } from 'react-native' +import { Platform } from 'react-native' +import { loadHosts } from '../transport/host-store' +import { + startDiagnosticFetchTimeout, + type DiagnosticFetchTimeout +} from './diagnostic-fetch-timeout' +import { formatEndpoint, testHostReachability, unreachableHostDetail } from './host-reachability' +import type { CheckResult, DiagnosticStatus } from './troubleshoot-view' + +export function useTroubleshootDiagnostics() { + const [diagnosticStatus, setDiagnosticStatus] = useState('idle') + const [checks, setChecks] = useState([]) + const abortRef = useRef(false) + const diagnosticRunRef = useRef(0) + const activeInternetCheckRef = useRef(null) + + const rootRef = useCallback((node: View | null): void => { + if (node !== null) { + return + } + // Why: diagnostics can outlive the screen; cancel the active run when the + // route detaches without a passive cleanup-only Effect. + abortRef.current = true + diagnosticRunRef.current += 1 + activeInternetCheckRef.current?.dispose() + activeInternetCheckRef.current = null + }, []) + + const runDiagnostics = useCallback(async () => { + const runId = diagnosticRunRef.current + 1 + diagnosticRunRef.current = runId + abortRef.current = false + activeInternetCheckRef.current?.dispose() + activeInternetCheckRef.current = null + setDiagnosticStatus('running') + setChecks([]) + + const results: CheckResult[] = [] + const isCurrentRun = () => !abortRef.current && diagnosticRunRef.current === runId + + try { + const hosts = await loadHosts() + results.push( + hosts.length > 0 + ? { label: 'Paired hosts', status: 'pass', detail: `${hosts.length} paired` } + : { label: 'Paired hosts', status: 'fail', detail: 'None — scan a QR to pair' } + ) + } catch { + results.push({ label: 'Paired hosts', status: 'warn', detail: 'Could not read host data' }) + } + + if (!isCurrentRun()) { + return + } + setChecks([...results]) + + const internetCheck = startDiagnosticFetchTimeout(5000) + activeInternetCheckRef.current = internetCheck + try { + const resp = await fetch('https://dns.google/resolve?name=example.com&type=A', { + signal: internetCheck.signal + }) + if (!isCurrentRun()) { + return + } + results.push( + resp.ok + ? { label: 'Internet', status: 'pass', detail: 'Connected' } + : { label: 'Internet', status: 'warn', detail: 'Unexpected response' } + ) + } catch { + if (!isCurrentRun()) { + return + } + results.push({ label: 'Internet', status: 'fail', detail: 'No connection' }) + } finally { + internetCheck.dispose() + if (activeInternetCheckRef.current === internetCheck) { + activeInternetCheckRef.current = null + } + } + + if (!isCurrentRun()) { + return + } + setChecks([...results]) + + try { + const hosts = await loadHosts() + for (const host of hosts) { + if (!isCurrentRun()) { + return + } + const reachable = await testHostReachability(host.endpoint) + if (!isCurrentRun()) { + return + } + results.push({ + label: host.name, + status: reachable ? 'pass' : 'fail', + detail: reachable + ? `Reachable at ${formatEndpoint(host.endpoint)}` + : unreachableHostDetail(host.endpoint) + }) + setChecks([...results]) + } + } catch { + results.push({ label: 'Hosts', status: 'warn', detail: 'Could not test' }) + } + + if (!isCurrentRun()) { + return + } + + results.push({ + label: 'Platform', + status: 'pass', + detail: `${Platform.OS} ${Platform.Version ?? ''}` + }) + + setChecks([...results]) + setDiagnosticStatus('done') + }, []) + + return { rootRef, diagnosticStatus, checks, runDiagnostics } +} diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx index 07cbbd58fc5..0f15f9d4e61 100644 --- a/mobile/src/host-screen/host-screen-overlays.tsx +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -1,4 +1,4 @@ -import { Pressable, Text, View } from 'react-native' +import { Linking, Pressable, Text, View } from 'react-native' import { Check, Moon } from 'lucide-react-native' import { buildWorktreeNavigationActions } from '../agent-history/worktree-navigation-actions' import { ActionSheetContent } from '../components/ActionSheetModal' @@ -215,6 +215,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr hostId={hostId} existingWorktreePaths={existingWorktreePaths} existingWorktrees={state.worktrees} + openExternalUrl={(url) => Linking.openURL(url)} onVisibleChange={(visible) => { state.newWorktreeModalVisibleRef.current = visible }} diff --git a/mobile/src/settings/about-screen.tsx b/mobile/src/settings/about-screen.tsx new file mode 100644 index 00000000000..3212343fee6 --- /dev/null +++ b/mobile/src/settings/about-screen.tsx @@ -0,0 +1,187 @@ +import { useState } from 'react' +import { View, Text, StyleSheet, Pressable } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { ChevronLeft, Globe } from 'lucide-react-native' +import Svg, { Path } from 'react-native-svg' +import { OrcaLogo } from '../components/OrcaLogo' +import { colors, spacing, typography } from '../theme/mobile-theme' + +function GithubIcon({ size = 16, color = colors.textSecondary }) { + return ( + + + + ) +} + +function XIcon({ size = 16, color = colors.textSecondary }) { + return ( + + + + ) +} + +export default function AboutScreen({ + onBack, + openExternal, + versionLabel +}: { + onBack: () => void + openExternal: (url: string) => Promise + versionLabel: string +}) { + const [error, setError] = useState(null) + const openLink = (url: string) => { + setError(null) + void openExternal(url).catch(() => setError('Could not open the link. Try again.')) + } + const insets = useSafeAreaInsets() + + return ( + + + + + + About + + + + + Orca + Open-source agent IDE for 100x builders + + + + [styles.row, pressed && styles.rowPressed]} + accessibilityRole="button" + accessibilityLabel="Orca website" + onPress={() => openLink('https://onOrca.dev')} + > + + onOrca.dev + + + [styles.row, pressed && styles.rowPressed]} + accessibilityRole="button" + accessibilityLabel="Orca source code" + onPress={() => openLink('https://github.com/stablyai/orca')} + > + + stablyai/orca + + + [styles.row, pressed && styles.rowPressed]} + accessibilityRole="button" + accessibilityLabel="Orca on X" + onPress={() => openLink('https://x.com/orca_build')} + > + + @orca_build + + + + {versionLabel} + {error && ( + + {error} + + )} + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + padding: spacing.lg + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.xl + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + brand: { + alignItems: 'center', + paddingVertical: spacing.xl, + marginBottom: spacing.lg + }, + brandName: { + fontSize: 22, + fontWeight: '800', + color: colors.textPrimary, + marginTop: spacing.sm + }, + brandSub: { + fontSize: 13, + color: colors.textMuted, + marginTop: spacing.xs + }, + section: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowLabel: { + flex: 1, + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + rowValue: { + flex: 1, + textAlign: 'right', + fontSize: typography.bodySize, + color: colors.textSecondary + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + versionText: { + marginTop: spacing.lg, + textAlign: 'center', + fontSize: typography.metaSize, + color: colors.textMuted + }, + errorText: { + marginTop: spacing.sm, + textAlign: 'center', + fontSize: typography.metaSize, + color: colors.statusRed + } +}) diff --git a/mobile/src/settings/browser-settings-screen.tsx b/mobile/src/settings/browser-settings-screen.tsx new file mode 100644 index 00000000000..924f7a7660b --- /dev/null +++ b/mobile/src/settings/browser-settings-screen.tsx @@ -0,0 +1,199 @@ +import { useCallback, useEffect, useState } from 'react' +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useRouter } from 'expo-router' +import { ChevronLeft, ChevronRight, Globe } from 'lucide-react-native' +import { PickerModal, type PickerOption } from '../components/PickerModal' +import { + loadTerminalLinkOpenMode, + saveTerminalLinkOpenMode, + type MobileTerminalLinkOpenMode +} from '../storage/preferences' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +const LINK_MODE_OPTIONS: PickerOption[] = [ + { + value: 'orca-browser', + label: 'Orca browser on desktop', + subtitle: 'Open in the streamed browser from your paired desktop.' + }, + { + value: 'phone-browser', + label: 'Phone browser', + subtitle: 'Open in Safari, Chrome, or another browser on this phone.' + } +] + +function linkModeLabel(mode: MobileTerminalLinkOpenMode): string { + return ( + LINK_MODE_OPTIONS.find((option) => option.value === mode)?.label ?? LINK_MODE_OPTIONS[0]!.label + ) +} + +export default function BrowserSettingsScreen({ + onBack +}: { + onBack?: () => void +}): React.JSX.Element { + const router = useRouter() + const insets = useSafeAreaInsets() + const [linkMode, setLinkMode] = useState('orca-browser') + const [pickerOpen, setPickerOpen] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + let active = true + void loadTerminalLinkOpenMode().then( + (mode) => { + if (active) { + setLinkMode(mode) + } + }, + () => { + if (active) { + setError('Could not load browser preferences. Try again.') + } + } + ) + return () => { + active = false + } + }, []) + + const selectLinkMode = useCallback((mode: MobileTerminalLinkOpenMode) => { + setError(null) + // Optimistic, as base was: the row shows the tapped mode before the write lands. + setLinkMode(mode) + void saveTerminalLinkOpenMode(mode).catch(() => + setError('Could not save browser preferences. Try again.') + ) + }, []) + + return ( + + + router.back())} + > + + + Browser + + + + LINKS + + Choose where HTTP(S) links tapped in terminal output open. + + {error && ( + + {error} + + )} + + [styles.row, pressed && styles.rowPressed]} + onPress={() => setPickerOpen(true)} + > + + + Open terminal links + {linkModeLabel(linkMode)} + + + + + + + + visible={pickerOpen} + title="Open terminal links" + options={LINK_MODE_OPTIONS} + selected={linkMode} + onSelect={selectLinkMode} + onClose={() => setPickerOpen(false)} + /> + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + paddingHorizontal: spacing.lg, + paddingTop: 0 + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.sm, + marginBottom: spacing.lg + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + scrollContent: { + paddingBottom: spacing.xl + }, + groupHeading: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + letterSpacing: 0.5, + marginBottom: spacing.xs, + paddingHorizontal: spacing.xs + }, + groupDescription: { + fontSize: typography.bodySize - 1, + color: colors.textSecondary, + lineHeight: 20, + paddingHorizontal: spacing.xs + }, + section: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden' + }, + sectionTopGap: { + marginTop: spacing.sm + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowContent: { + flex: 1 + }, + rowLabel: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + rowSublabel: { + fontSize: typography.bodySize - 2, + color: colors.textSecondary, + marginTop: 2 + } +}) diff --git a/mobile/src/settings/mobile-settings-menu-items.ts b/mobile/src/settings/mobile-settings-menu-items.ts new file mode 100644 index 00000000000..baf7faf7b3d --- /dev/null +++ b/mobile/src/settings/mobile-settings-menu-items.ts @@ -0,0 +1,14 @@ +import { Bell, Globe, Info, MessageSquare, Mic, Terminal, Wrench } from 'lucide-react-native' +import type { MobileSettingsMenuItem } from './mobile-settings-menu' + +export function mobileSettingsMenuItems(push: (route: string) => void): MobileSettingsMenuItem[] { + return [ + { label: 'Terminal', icon: Terminal, onPress: () => push('/terminal-settings') }, + { label: 'Chat UI', icon: MessageSquare, onPress: () => push('/native-chat-settings') }, + { label: 'Browser', icon: Globe, onPress: () => push('/browser-settings') }, + { label: 'Voice', icon: Mic, onPress: () => push('/voice-settings') }, + { label: 'Notifications', icon: Bell, onPress: () => push('/notifications') }, + { label: 'Troubleshooting', icon: Wrench, onPress: () => push('/troubleshoot') }, + { label: 'About', icon: Info, onPress: () => push('/about') } + ] +} diff --git a/mobile/src/settings/mobile-settings-menu.tsx b/mobile/src/settings/mobile-settings-menu.tsx new file mode 100644 index 00000000000..23f989e073c --- /dev/null +++ b/mobile/src/settings/mobile-settings-menu.tsx @@ -0,0 +1,111 @@ +import type { ReactNode } from 'react' +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useRouter } from 'expo-router' +import { ChevronLeft, ChevronRight, type LucideIcon } from 'lucide-react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +export function MobileSettingsFrame({ + children, + onBack +}: { + children: ReactNode + onBack?: () => void +}) { + const router = useRouter() + const insets = useSafeAreaInsets() + return ( + + + router.back())} + > + + + Settings + + + {children} + + + ) +} + +export type MobileSettingsMenuItem = { + label: string + icon: LucideIcon + onPress: () => void + external?: boolean + disabled?: boolean +} + +export function MobileSettingsSection({ + items, + spaced = false +}: { + items: MobileSettingsMenuItem[] + spaced?: boolean +}) { + return ( + + {items.map(({ label, icon: Icon, onPress, external, disabled }, index) => ( + + {index > 0 && } + [styles.row, pressed && styles.rowPressed]} + onPress={onPress} + > + + {label} + {!external && } + + + ))} + + ) +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bgBase, paddingHorizontal: spacing.lg }, + topRow: { flexDirection: 'row', alignItems: 'center', marginBottom: spacing.xl }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { fontSize: 20, fontWeight: '700', color: colors.textPrimary }, + section: { backgroundColor: colors.bgPanel, borderRadius: 12, overflow: 'hidden' }, + sectionSpacer: { marginTop: spacing.md }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { backgroundColor: colors.bgRaised }, + rowLabel: { + flex: 1, + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + } +}) diff --git a/mobile/src/settings/native-chat-settings-screen.tsx b/mobile/src/settings/native-chat-settings-screen.tsx new file mode 100644 index 00000000000..fd7a6ac7f36 --- /dev/null +++ b/mobile/src/settings/native-chat-settings-screen.tsx @@ -0,0 +1,126 @@ +import { View, Text, StyleSheet, Pressable, ScrollView, Switch } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useRouter } from 'expo-router' +import { ChevronLeft } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { useMobileDefaultSessionViewPreference } from '../session/use-mobile-default-session-view-preference' + +export default function NativeChatSettingsScreen({ onBack }: { onBack?: () => void }) { + const router = useRouter() + const insets = useSafeAreaInsets() + + const { defaultView, setDefaultView } = useMobileDefaultSessionViewPreference() + const chatDefault = defaultView === 'chat' + + return ( + + + router.back())} + > + + + Chat UI + + + + DEFAULT VIEW + + Choose how supported agent sessions (Claude, Codex, and other chat-capable agents) open on + this device. Terminal shows the raw CLI; Chat UI shows a chat interface like the desktop + app. You can still switch any individual session from its long-press menu. + + + + + Open sessions in Chat UI + {chatDefault ? 'On' : 'Off'} + + setDefaultView(next ? 'chat' : 'terminal')} + trackColor={{ false: colors.bgRaised, true: colors.textSecondary }} + thumbColor={colors.textPrimary} + /> + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + paddingHorizontal: spacing.lg + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.sm, + marginBottom: spacing.lg + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + groupHeading: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + letterSpacing: 0.5, + marginBottom: spacing.xs, + paddingHorizontal: spacing.xs + }, + groupDescription: { + fontSize: typography.bodySize - 1, + color: colors.textSecondary, + lineHeight: 20, + paddingHorizontal: spacing.xs + }, + section: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden' + }, + sectionTopGap: { + marginTop: spacing.sm + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowContent: { + flex: 1 + }, + rowLabel: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + rowSublabel: { + fontSize: typography.bodySize - 2, + color: colors.textSecondary, + marginTop: 2 + } +}) diff --git a/mobile/src/settings/native-notification-settings-operations.ts b/mobile/src/settings/native-notification-settings-operations.ts new file mode 100644 index 00000000000..cd5da9584bf --- /dev/null +++ b/mobile/src/settings/native-notification-settings-operations.ts @@ -0,0 +1,23 @@ +import { Linking } from 'react-native' +import { + ensureNotificationPermissions, + getNotificationPermissionState +} from '../notifications/notification-permissions' +import { loadPushNotificationsEnabled, savePushNotificationsEnabled } from '../storage/preferences' +import type { NotificationSettingsOperations } from './notification-settings-operations' + +export const nativeNotificationSettingsOperations: NotificationSettingsOperations = { + async permission(request) { + if (request) { + await ensureNotificationPermissions() + } + return getNotificationPermissionState() + }, + async preference(enabled) { + if (enabled !== undefined) { + await savePushNotificationsEnabled(enabled) + } + return { enabled: await loadPushNotificationsEnabled() } + }, + openSettings: () => Linking.openSettings() +} diff --git a/mobile/src/settings/native-voice-settings-operations.ts b/mobile/src/settings/native-voice-settings-operations.ts new file mode 100644 index 00000000000..12903c72b85 --- /dev/null +++ b/mobile/src/settings/native-voice-settings-operations.ts @@ -0,0 +1,19 @@ +import type { RpcClient } from '../transport/rpc-client' +import { + fetchDictationSetup, + setDictationConfig, + downloadDictationModel, + deleteDictationModel +} from '../dictation/mobile-dictation-setup' +import type { VoiceSettingsOperations } from './voice-settings-operations' + +export function nativeVoiceSettingsOperations( + client: Pick +): VoiceSettingsOperations { + return { + load: () => fetchDictationSetup(client), + configure: (params) => setDictationConfig(client, params), + download: (modelId) => downloadDictationModel(client, modelId), + delete: (modelId) => deleteDictationModel(client, modelId) + } +} diff --git a/mobile/src/settings/notification-settings-operations.ts b/mobile/src/settings/notification-settings-operations.ts new file mode 100644 index 00000000000..669f8d4b074 --- /dev/null +++ b/mobile/src/settings/notification-settings-operations.ts @@ -0,0 +1,7 @@ +import type { NotificationPermissionState } from '../notifications/notification-permissions' + +export interface NotificationSettingsOperations { + permission(request?: boolean): Promise + preference(enabled?: boolean): Promise<{ enabled: boolean }> + openSettings(): Promise +} diff --git a/mobile/src/settings/notification-settings-screen.tsx b/mobile/src/settings/notification-settings-screen.tsx new file mode 100644 index 00000000000..7da9be8a813 --- /dev/null +++ b/mobile/src/settings/notification-settings-screen.tsx @@ -0,0 +1,196 @@ +import { useState, useCallback, useEffect } from 'react' +import { AppState, View, Text, StyleSheet, Pressable, Switch } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useFocusEffect } from 'expo-router' +import type { NotificationSettingsOperations } from './notification-settings-operations' +import { ChevronLeft } from 'lucide-react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' +import type { NotificationPermissionState } from '../notifications/notification-permissions' + +const DEFAULT_PERMISSION_STATE: NotificationPermissionState = { + granted: false, + status: 'undetermined', + canAskAgain: true, + authorizationReflectsUserChoice: false +} + +export default function NotificationsScreen({ + operations, + onBack +}: { + operations: NotificationSettingsOperations + onBack: () => void +}) { + const insets = useSafeAreaInsets() + const [error, setError] = useState(null) + const [pushEnabled, setPushEnabled] = useState(false) + const [permissionState, setPermissionState] = useState(DEFAULT_PERMISSION_STATE) + + const refreshSettings = useCallback(async () => { + const [enabled, permission] = await Promise.all([ + operations.preference(), + operations.permission() + ]) + setPushEnabled(enabled.enabled) + setPermissionState(permission) + setError(null) + }, [operations]) + + useFocusEffect( + useCallback(() => { + void refreshSettings().catch(() => + setError('Could not load notification settings. Try again.') + ) + }, [refreshSettings]) + ) + + useEffect(() => { + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') { + void refreshSettings().catch(() => + setError('Could not load notification settings. Try again.') + ) + } + }) + return () => subscription.remove() + }, [refreshSettings]) + + const togglePush = async (value: boolean) => { + setError(null) + try { + const permission = await operations.permission(value) + setPermissionState(permission) + const saved = await operations.preference(value && permission.granted) + setPushEnabled(saved.enabled) + } catch { + setError('Could not save notification settings. Try again.') + } + } + + const switchEnabled = pushEnabled && permissionState.granted + const notificationsBlocked = permissionState.status === 'denied' + const hint = notificationsBlocked + ? 'Notifications are disabled in system settings.' + : 'Get notified on this device when an agent needs your input or finishes a task.' + + return ( + + + + + + Notifications + + + {error && ( + + {error} + + )} + + + Agent notifications + void togglePush(v)} + trackColor={{ false: colors.bgRaised, true: colors.textSecondary }} + thumbColor={colors.textPrimary} + /> + + {hint} + {notificationsBlocked && ( + [ + styles.settingsButton, + pressed && styles.settingsButtonPressed + ]} + testID="notification-system-settings" + onPress={() => + void operations + .openSettings() + .catch(() => setError('Could not open system settings. Try again.')) + } + > + Open Settings + + )} + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + padding: spacing.lg + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.xl + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + section: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowLabel: { + flex: 1, + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + hint: { + fontSize: typography.metaSize, + color: colors.textMuted, + lineHeight: 18, + paddingHorizontal: spacing.md + 2, + paddingBottom: spacing.md + }, + settingsButton: { + alignSelf: 'flex-start', + marginHorizontal: spacing.md + 2, + marginBottom: spacing.md, + paddingVertical: spacing.xs, + paddingHorizontal: spacing.sm, + borderRadius: 8, + backgroundColor: colors.bgRaised + }, + settingsButtonPressed: { + opacity: 0.6 + }, + settingsButtonText: { + color: colors.textPrimary, + fontSize: typography.metaSize, + fontWeight: '600' + } +}) diff --git a/mobile/src/settings/pending-credential-cleanup-card.tsx b/mobile/src/settings/pending-credential-cleanup-card.tsx new file mode 100644 index 00000000000..48c668d12b8 --- /dev/null +++ b/mobile/src/settings/pending-credential-cleanup-card.tsx @@ -0,0 +1,159 @@ +import { useCallback, useRef, useState } from 'react' +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native' +import { useFocusEffect } from 'expo-router' +import { KeyRound } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { + loadPendingHostCredentialCleanup, + subscribePendingHostCredentialCleanup +} from '../transport/host-credential-cleanup' +import { retryPendingHostCredentialCleanup } from '../transport/host-store' + +export function PendingCredentialCleanupCard() { + const [pendingCredentialIds, setPendingCredentialIds] = useState([]) + const [credentialStorageUnreadable, setCredentialStorageUnreadable] = useState(false) + const [retryingCredentialCleanup, setRetryingCredentialCleanup] = useState(false) + const [credentialRetryFailed, setCredentialRetryFailed] = useState(false) + const credentialRefreshGenerationRef = useRef(0) + + useFocusEffect( + useCallback(() => { + let active = true + setCredentialRetryFailed(false) + const refresh = () => { + const generation = ++credentialRefreshGenerationRef.current + void loadPendingHostCredentialCleanup().then((state) => { + if (active && generation === credentialRefreshGenerationRef.current) { + setPendingCredentialIds(state.ids) + setCredentialStorageUnreadable(state.storageUnreadable) + // Why: neutral copy once the queue is confirmed empty so a later + // pending set does not inherit a previous Retry failure message. + if (state.ids.length === 0 && !state.storageUnreadable) { + setCredentialRetryFailed(false) + } + } + }) + } + const unsubscribe = subscribePendingHostCredentialCleanup(refresh) + refresh() + return () => { + active = false + credentialRefreshGenerationRef.current += 1 + unsubscribe() + } + }, []) + ) + + const retryCredentialCleanup = useCallback(async () => { + if (retryingCredentialCleanup) { + return + } + setCredentialRetryFailed(false) + setRetryingCredentialCleanup(true) + try { + const result = await retryPendingHostCredentialCleanup() + setPendingCredentialIds(result.remainingIds) + setCredentialStorageUnreadable(result.storageUnreadable) + setCredentialRetryFailed(result.remainingIds.length > 0 || result.storageUnreadable) + } catch { + setCredentialRetryFailed(true) + } finally { + setRetryingCredentialCleanup(false) + } + }, [retryingCredentialCleanup]) + + const pendingCredentialCount = pendingCredentialIds.length + // Why: show the cleanup card whenever cleanup is pending OR the durable queue + // is unreadable — an unreadable queue can hide an orphaned token, so keep a + // retry affordance rather than a silently-empty (hidden) section. + if (pendingCredentialCount === 0 && !credentialStorageUnreadable) { + return null + } + + return ( + + + + + Pairing credential cleanup + + {credentialRetryFailed + ? "Cleanup still couldn't be confirmed. Try again later." + : pendingCredentialCount > 0 + ? `Couldn't confirm cleanup for ${pendingCredentialCount} credential${pendingCredentialCount === 1 ? '' : 's'} on this device.` + : "Couldn't check cleanup status on this device. Retry to be safe."} + + + [ + styles.retryButton, + pressed && !retryingCredentialCleanup && styles.rowPressed + ]} + onPress={() => void retryCredentialCleanup()} + > + {retryingCredentialCleanup ? ( + + ) : ( + Retry + )} + + + + ) +} + +const styles = StyleSheet.create({ + section: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + sectionSpacer: { + marginTop: spacing.md + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + credentialCleanupRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + credentialCleanupCopy: { + flex: 1, + gap: spacing.xs + }, + credentialCleanupTitle: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + rowHint: { + fontSize: typography.metaSize, + color: colors.textSecondary, + lineHeight: 17 + }, + retryButton: { + width: 72, + height: 32, + borderRadius: radii.button, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center' + }, + retryButtonText: { + fontSize: typography.metaSize, + fontWeight: '600', + color: colors.textPrimary + } +}) diff --git a/mobile/src/settings/settings-menu-screen.tsx b/mobile/src/settings/settings-menu-screen.tsx new file mode 100644 index 00000000000..76d2207227e --- /dev/null +++ b/mobile/src/settings/settings-menu-screen.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from 'react' +import { Shield, LifeBuoy } from 'lucide-react-native' +import { MobileSettingsFrame, MobileSettingsSection } from './mobile-settings-menu' +import { mobileSettingsMenuItems } from './mobile-settings-menu-items' + +export default function SettingsMenuScreen({ + push, + onBack, + openExternal, + children +}: { + push: (route: string) => void + onBack?: () => void + openExternal: (url: string) => Promise + children?: ReactNode +}) { + return ( + + + + {children} + + void openExternal('https://www.onorca.dev/privacy') + }, + { + label: 'Support', + icon: LifeBuoy, + external: true, + onPress: () => void openExternal('https://github.com/stablyai/orca/issues') + } + ]} + /> + + ) +} diff --git a/mobile/src/settings/settings-screen-state.test.tsx b/mobile/src/settings/settings-screen-state.test.tsx new file mode 100644 index 00000000000..ba790d50abc --- /dev/null +++ b/mobile/src/settings/settings-screen-state.test.tsx @@ -0,0 +1,210 @@ +import { createElement, useEffect } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import NotificationsScreen from './notification-settings-screen' +import VoiceSettingsScreen from './voice-settings-screen' +import type { VoiceSettingsOperations } from './voice-settings-operations' + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', + Switch: 'Switch', + ScrollView: 'ScrollView', + ActivityIndicator: 'ActivityIndicator', + StyleSheet: { create: (value: unknown) => value, hairlineWidth: 1 }, + AppState: { addEventListener: () => ({ remove() {} }) } +})) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0 }) +})) +vi.mock('expo-router', () => ({ + useFocusEffect: (callback: () => void) => useEffect(callback, [callback]) +})) +vi.mock('lucide-react-native', () => ({ ChevronLeft: 'Icon', ChevronRight: 'Icon' })) +vi.mock('../components/BottomDrawer', () => ({ BottomDrawer: () => null })) +vi.mock('../components/VoiceModelList', () => ({ VoiceModelList: () => null })) +vi.mock('../dictation/use-dictation-setup-poller', () => ({ + useDictationSetupPoller: ({ refresh }: { refresh: () => Promise }) => { + useEffect(() => { + void refresh() + }, [refresh]) + return refresh + } +})) +let renderer: ReactTestRenderer + +afterEach(() => { + act(() => renderer?.unmount()) +}) +describe('shared settings screen state', () => { + it('flips the Voice switch before the desktop replies and surfaces a rejected save', async () => { + const loaded = { + enabled: true, + dictationMode: 'toggle', + selectedModelId: '', + models: [] + } + let rejectConfigure: (error: Error) => void = () => {} + const operations = { + // Why: the reconcile read stays pending so the optimistic value and the + // rejection message are both observable, as they are on a slow desktop. + load: vi + .fn() + .mockResolvedValueOnce(loaded) + .mockImplementation(() => new Promise(() => {})), + configure: vi.fn().mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectConfigure = reject + }) + ), + download: vi.fn(), + delete: vi.fn() + } as VoiceSettingsOperations + await act(async () => { + renderer = create( + createElement(VoiceSettingsScreen, { operations, focused: true, onBack: vi.fn() }) + ) + }) + const switchProps = () => renderer.root.findByProps({ testID: 'voice-enabled' }).props + expect(switchProps().value).toBe(true) + expect(switchProps().disabled).toBeUndefined() + + await act(async () => { + switchProps().onValueChange(false) + }) + // The switch moves on tap, before the desktop has answered. + expect(switchProps().value).toBe(false) + expect(switchProps().disabled).toBeUndefined() + expect(operations.configure).toHaveBeenCalledOnce() + + await act(async () => { + rejectConfigure(new Error('Desktop unavailable')) + await Promise.resolve() + }) + expect(JSON.stringify(renderer.toJSON())).toContain('Desktop unavailable') + expect(operations.load).toHaveBeenCalledTimes(2) + }) + it('shows the spinner, not the error card, while the first voice read is pending', async () => { + const operations = { + load: vi.fn().mockImplementation(() => new Promise(() => {})), + configure: vi.fn(), + download: vi.fn(), + delete: vi.fn() + } as VoiceSettingsOperations + await act(async () => { + renderer = create( + createElement(VoiceSettingsScreen, { operations, focused: true, onBack: vi.fn() }) + ) + }) + expect(JSON.stringify(renderer.toJSON())).not.toContain('Failed to load voice settings') + expect(renderer.root.findAllByType('ActivityIndicator')).toHaveLength(1) + }) + it('drops a poll that resolves after a toggle instead of clobbering it', async () => { + const loaded = { + enabled: true, + dictationMode: 'toggle', + selectedModelId: '', + models: [] + } + let resolveStalePoll: (value: typeof loaded) => void = () => {} + const shared = { + load: vi + .fn() + .mockResolvedValueOnce(loaded) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStalePoll = resolve + }) + ), + configure: vi.fn().mockImplementation(() => new Promise(() => {})), + download: vi.fn(), + delete: vi.fn() + } + const first = { ...shared } as VoiceSettingsOperations + await act(async () => { + renderer = create( + createElement(VoiceSettingsScreen, { operations: first, focused: true, onBack: vi.fn() }) + ) + }) + const switchProps = () => renderer.root.findByProps({ testID: 'voice-enabled' }).props + expect(switchProps().value).toBe(true) + + // A new operations identity restarts the poller, so a read is in flight below. + const second = { ...shared } as VoiceSettingsOperations + await act(async () => { + renderer.update( + createElement(VoiceSettingsScreen, { operations: second, focused: true, onBack: vi.fn() }) + ) + }) + expect(shared.load).toHaveBeenCalledTimes(2) + + await act(async () => { + switchProps().onValueChange(false) + }) + expect(switchProps().value).toBe(false) + + await act(async () => { + resolveStalePoll(loaded) + await Promise.resolve() + }) + // Without the request-epoch fence the stale read would flip the switch back on. + expect(switchProps().value).toBe(false) + }) + it('does not enable notifications after denied OS permission', async () => { + const denied = { + granted: false, + status: 'undetermined', + canAskAgain: true, + authorizationReflectsUserChoice: false + } + const operations = { + permission: vi.fn().mockResolvedValue(denied), + preference: vi.fn().mockResolvedValue({ enabled: false }), + openSettings: vi.fn() + } + await act(async () => { + renderer = create(createElement(NotificationsScreen, { operations, onBack: vi.fn() })) + }) + await act(async () => { + await renderer.root.findByProps({ testID: 'notification-enabled' }).props.onValueChange(true) + }) + expect(operations.permission).toHaveBeenLastCalledWith(true) + expect(operations.preference).toHaveBeenLastCalledWith(false) + expect(renderer.root.findByProps({ testID: 'notification-enabled' }).props.value).toBe(false) + expect(operations.openSettings).not.toHaveBeenCalled() + }) + it('keeps the notification switch live while the read is pending and after it fails', async () => { + let rejectPreference: (error: Error) => void = () => {} + const operations = { + permission: vi.fn().mockResolvedValue({ + granted: true, + status: 'granted', + canAskAgain: true, + authorizationReflectsUserChoice: true + }), + preference: vi.fn().mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectPreference = reject + }) + ), + openSettings: vi.fn() + } + await act(async () => { + renderer = create(createElement(NotificationsScreen, { operations, onBack: vi.fn() })) + }) + const switchProps = () => renderer.root.findByProps({ testID: 'notification-enabled' }).props + // Base gates only on a denied OS permission, so the control is live from first paint. + expect(switchProps().disabled).toBe(false) + + await act(async () => { + rejectPreference(new Error('storage failed')) + await Promise.resolve() + }) + expect(switchProps().disabled).toBe(false) + expect(JSON.stringify(renderer.toJSON())).toContain('Could not load notification settings') + }) +}) diff --git a/mobile/src/settings/voice-settings-operations.ts b/mobile/src/settings/voice-settings-operations.ts new file mode 100644 index 00000000000..fc5fced208d --- /dev/null +++ b/mobile/src/settings/voice-settings-operations.ts @@ -0,0 +1,12 @@ +import type { MobileSpeechSetup } from '../dictation/mobile-dictation-setup' + +export interface VoiceSettingsOperations { + load(): Promise + configure(params: { + enabled?: boolean + modelId?: string + dictationMode?: 'toggle' | 'hold' + }): Promise + download(modelId: string): Promise + delete(modelId: string): Promise +} diff --git a/mobile/src/settings/voice-settings-poller-refresh.test.tsx b/mobile/src/settings/voice-settings-poller-refresh.test.tsx new file mode 100644 index 00000000000..ec3b4370e19 --- /dev/null +++ b/mobile/src/settings/voice-settings-poller-refresh.test.tsx @@ -0,0 +1,162 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import VoiceSettingsScreen from './voice-settings-screen' +import type { VoiceSettingsOperations } from './voice-settings-operations' + +// Why: the sibling state test stubs the poller, so it can only prove the screen's local +// machine. This one runs the real useDictationSetupPoller, whose refreshNow is gated on +// visible && foreground, to prove the rejected-save recovery actually reaches the wire. +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', + Switch: 'Switch', + ScrollView: 'ScrollView', + ActivityIndicator: 'ActivityIndicator', + StyleSheet: { create: (value: unknown) => value, hairlineWidth: 1 }, + AppState: { currentState: 'active', addEventListener: () => ({ remove() {} }) } +})) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0 }) +})) +vi.mock('lucide-react-native', () => ({ ChevronLeft: 'Icon', ChevronRight: 'Icon' })) +vi.mock('../components/BottomDrawer', () => ({ BottomDrawer: () => null })) +vi.mock('../components/VoiceModelList', () => ({ VoiceModelList: () => null })) + +let renderer: ReactTestRenderer + +afterEach(() => { + act(() => renderer?.unmount()) +}) + +describe('voice settings poller integration', () => { + it('reaches the real poller refresh after a rejected save', async () => { + let rejectConfigure: (error: Error) => void = () => {} + const operations = { + load: vi.fn().mockResolvedValue({ + enabled: true, + dictationMode: 'toggle', + selectedModelId: '', + models: [] + }), + configure: vi.fn().mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectConfigure = reject + }) + ), + download: vi.fn(), + delete: vi.fn() + } as VoiceSettingsOperations + await act(async () => { + renderer = create( + createElement(VoiceSettingsScreen, { operations, focused: true, onBack: vi.fn() }) + ) + }) + const loadsAfterMount = (operations.load as ReturnType).mock.calls.length + expect(loadsAfterMount).toBeGreaterThan(0) + + await act(async () => { + renderer.root.findByProps({ testID: 'voice-enabled' }).props.onValueChange(false) + }) + await act(async () => { + rejectConfigure(new Error('Desktop unavailable')) + await Promise.resolve() + await Promise.resolve() + }) + expect((operations.load as ReturnType).mock.calls.length).toBe( + loadsAfterMount + 1 + ) + }) + + it('shows the spinner, not the error card, when it mounts before focus lands', async () => { + const operations = { + load: vi.fn(), + configure: vi.fn(), + download: vi.fn(), + delete: vi.fn() + } as VoiceSettingsOperations + await act(async () => { + renderer = create( + createElement(VoiceSettingsScreen, { operations, focused: false, onBack: vi.fn() }) + ) + }) + // The poller is gated on focus, so no read runs and only the initial flag decides + // this paint. Base started it false and flashed the error card here. + expect(operations.load).not.toHaveBeenCalled() + expect(renderer.root.findAllByType('ActivityIndicator')).toHaveLength(1) + expect(JSON.stringify(renderer.toJSON())).not.toContain('Failed to load voice settings') + }) + + it('re-shows the spinner when a refocus retries a failed load', async () => { + const operations = { + load: vi + .fn() + .mockRejectedValueOnce(new Error('Desktop unavailable')) + .mockImplementationOnce(() => new Promise(() => {})), + configure: vi.fn(), + download: vi.fn(), + delete: vi.fn() + } as VoiceSettingsOperations + const onBack = vi.fn() + await act(async () => { + renderer = create(createElement(VoiceSettingsScreen, { operations, focused: true, onBack })) + }) + // The failed read leaves the error card up, exactly as base did. + expect(JSON.stringify(renderer.toJSON())).toContain('Desktop unavailable') + expect(renderer.root.findAllByType('ActivityIndicator')).toHaveLength(0) + + await act(async () => { + renderer.update(createElement(VoiceSettingsScreen, { operations, focused: false, onBack })) + }) + await act(async () => { + renderer.update(createElement(VoiceSettingsScreen, { operations, focused: true, onBack })) + }) + expect((operations.load as ReturnType).mock.calls.length).toBe(2) + // Base re-showed the spinner on re-entry; the stale error must not sit there during the retry. + expect(renderer.root.findAllByType('ActivityIndicator')).toHaveLength(1) + expect(JSON.stringify(renderer.toJSON())).not.toContain('Desktop unavailable') + }) + + it('drops the recovery read once the screen is no longer focused', async () => { + let rejectConfigure: (error: Error) => void = () => {} + const operations = { + load: vi.fn().mockResolvedValue({ + enabled: true, + dictationMode: 'toggle', + selectedModelId: '', + models: [] + }), + configure: vi.fn().mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectConfigure = reject + }) + ), + download: vi.fn(), + delete: vi.fn() + } as VoiceSettingsOperations + const onBack = vi.fn() + await act(async () => { + renderer = create(createElement(VoiceSettingsScreen, { operations, focused: true, onBack })) + }) + await act(async () => { + renderer.root.findByProps({ testID: 'voice-enabled' }).props.onValueChange(false) + }) + await act(async () => { + renderer.update(createElement(VoiceSettingsScreen, { operations, focused: false, onBack })) + }) + const loadsBeforeRejection = (operations.load as ReturnType).mock.calls.length + + await act(async () => { + rejectConfigure(new Error('Desktop unavailable')) + await Promise.resolve() + await Promise.resolve() + }) + // The controller's visible && foreground gate is base's behaviour, preserved here. + expect((operations.load as ReturnType).mock.calls.length).toBe( + loadsBeforeRejection + ) + }) +}) diff --git a/mobile/src/settings/voice-settings-screen.tsx b/mobile/src/settings/voice-settings-screen.tsx new file mode 100644 index 00000000000..1e2c33c7a4c --- /dev/null +++ b/mobile/src/settings/voice-settings-screen.tsx @@ -0,0 +1,295 @@ +import { useCallback, useRef, useState } from 'react' +import { ActivityIndicator, Pressable, ScrollView, Switch, Text, View } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import type { VoiceSettingsOperations } from './voice-settings-operations' +import { voiceSettingsStyles as styles } from './voice-settings-styles' +import { ChevronLeft, ChevronRight } from 'lucide-react-native' +import { colors, spacing } from '../theme/mobile-theme' +import { BottomDrawer } from '../components/BottomDrawer' +import { VoiceModelList } from '../components/VoiceModelList' +import { useDictationSetupPoller } from '../dictation/use-dictation-setup-poller' +import { + isModelInFlight, + type MobileSpeechModel, + type MobileSpeechSetup +} from '../dictation/mobile-dictation-setup' + +const POLL_INTERVAL_MS = 1500 + +const DICTATION_MODES = [ + { value: 'toggle', label: 'Toggle' }, + { value: 'hold', label: 'Hold' } +] as const + +type ModelBusyAction = { modelId: string; type: 'download' | 'select' | 'delete' } + +export default function VoiceSettingsScreen({ + operations, + focused, + onBack +}: { + operations: VoiceSettingsOperations | null + focused: boolean + onBack: () => void +}): React.JSX.Element { + const insets = useSafeAreaInsets() + const [setup, setSetup] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [busyAction, setBusyAction] = useState(null) + const requestEpoch = useRef(0) + const [modelDrawerOpen, setModelDrawerOpen] = useState(false) + const refresh = useCallback(async (): Promise => { + if (!operations) { + return false + } + const epoch = requestEpoch.current + // Own the spinner from the read that clears it, so a retry after a failed load shows + // the spinner again instead of the stale error card. Reads are serialised by + // DictationSetupPollController, so no in-flight read can clear another's flag. + setLoading(true) + try { + const next = await operations.load() + if (epoch !== requestEpoch.current) { + return undefined + } + setSetup(next) + setError(null) + return next.models.some(isModelInFlight) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load voice settings') + return undefined + } finally { + setLoading(false) + } + }, [operations]) + + const polling = setup?.models.some(isModelInFlight) ?? false + const refreshSetup = useDictationSetupPoller({ + visible: focused && operations !== null, + polling, + refresh, + intervalMs: POLL_INTERVAL_MS + }) + + const configure = useCallback( + async (params: Parameters[0]) => { + if (!operations) { + return + } + requestEpoch.current += 1 + setError(null) + // Optimistic flip so the control responds instantly; reconcile below. + const { enabled, dictationMode } = params + setSetup((prev) => + prev + ? { + ...prev, + ...(enabled === undefined ? {} : { enabled }), + ...(dictationMode === undefined ? {} : { dictationMode }) + } + : prev + ) + try { + setSetup(await operations.configure(params)) + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not update') + void refreshSetup() + } + }, + [operations, refreshSetup] + ) + + const handleUseModel = useCallback( + async (model: MobileSpeechModel) => { + if (!operations) { + return + } + requestEpoch.current += 1 + setBusyAction({ modelId: model.id, type: 'select' }) + setError(null) + try { + setSetup(await operations.configure({ enabled: true, modelId: model.id })) + setModelDrawerOpen(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not select model') + } finally { + setBusyAction(null) + } + }, + [operations] + ) + + const handleDownload = useCallback( + async (model: MobileSpeechModel) => { + if (!operations) { + return + } + requestEpoch.current += 1 + setBusyAction({ modelId: model.id, type: 'download' }) + setError(null) + try { + await operations.download(model.id) + await refreshSetup() + } catch (err) { + setError(err instanceof Error ? err.message : 'Download failed') + } finally { + setBusyAction(null) + } + }, + [operations, refreshSetup] + ) + + const handleDelete = useCallback( + async (model: MobileSpeechModel) => { + if (!operations) { + return + } + const deletedSelectedModel = setup?.selectedModelId === model.id + requestEpoch.current += 1 + setBusyAction({ modelId: model.id, type: 'delete' }) + setError(null) + try { + setSetup(await operations.delete(model.id)) + if (deletedSelectedModel) { + setModelDrawerOpen(false) + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Delete failed') + } finally { + setBusyAction(null) + } + }, + [operations, setup?.selectedModelId] + ) + + const enabled = setup?.enabled ?? false + const selectedModel = setup?.models.find((m) => m.id === setup.selectedModelId) + const selectedModelLabel = selectedModel?.label ?? 'None selected' + + return ( + + + + + + Voice + + + {!operations ? ( + + Connect to a desktop to manage voice settings. + + ) : loading && setup === null ? ( + + + + ) : setup === null ? ( + + {error ?? 'Failed to load voice settings.'} + + ) : ( + + DICTATION + + + + Enable Voice Dictation + + Dictate text into any focused pane on your desktop. + + + void configure({ enabled })} + trackColor={{ false: colors.bgRaised, true: colors.textSecondary }} + thumbColor={colors.textPrimary} + /> + + + + + + + Dictation Mode + + Toggle: press once to start, again to stop. Hold: dictate while held. + + + + {DICTATION_MODES.map((mode) => { + const active = setup.dictationMode === mode.value + return ( + void configure({ dictationMode: mode.value })} + style={[styles.segment, active && styles.segmentActive]} + > + + {mode.label} + + + ) + })} + + + + + SPEECH MODEL + + [ + styles.row, + !enabled && styles.disabled, + pressed && styles.rowPressed + ]} + disabled={!enabled} + testID="voice-model-picker" + onPress={() => setModelDrawerOpen(true)} + > + + Speech Model + + {selectedModelLabel} + + + + + + + {error ? {error} : null} + + )} + + setModelDrawerOpen(false)}> + Speech Model + {setup ? ( + void handleUseModel(m)} + onDownload={(m) => void handleDownload(m)} + onDelete={(m) => void handleDelete(m)} + /> + ) : null} + + + ) +} diff --git a/mobile/src/settings/voice-settings-styles.ts b/mobile/src/settings/voice-settings-styles.ts new file mode 100644 index 00000000000..19f3a8f06e8 --- /dev/null +++ b/mobile/src/settings/voice-settings-styles.ts @@ -0,0 +1,107 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const voiceSettingsStyles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + paddingHorizontal: spacing.lg + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.sm, + marginBottom: spacing.lg + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + scrollContent: { + paddingBottom: spacing.xl + }, + loading: { paddingVertical: spacing.xl, alignItems: 'center' }, + groupHeading: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + letterSpacing: 0.5, + marginBottom: spacing.xs, + paddingHorizontal: spacing.xs + }, + section: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden' + }, + sectionTopGap: { marginTop: spacing.sm }, + inputGroupGap: { marginTop: spacing.xl }, + disabled: { opacity: 0.5 }, + emptyText: { + fontSize: typography.bodySize, + color: colors.textSecondary, + padding: spacing.md + }, + errorText: { + fontSize: typography.bodySize, + color: colors.statusRed, + padding: spacing.md + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { backgroundColor: colors.bgRaised }, + rowContent: { flex: 1 }, + rowLabel: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + drawerTitle: { + fontSize: typography.bodySize, + fontWeight: '700', + color: colors.textPrimary, + paddingHorizontal: spacing.md + 2, + paddingTop: spacing.sm, + paddingBottom: spacing.xs + }, + rowSublabel: { + fontSize: typography.bodySize - 2, + color: colors.textSecondary, + marginTop: 2 + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + segmented: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.bgBase, + borderRadius: radii.button, + padding: 2 + }, + segment: { + paddingHorizontal: spacing.md, + paddingVertical: 6, + borderRadius: radii.button - 1 + }, + segmentActive: { backgroundColor: colors.bgRaised }, + segmentText: { fontSize: typography.metaSize, color: colors.textSecondary, fontWeight: '600' }, + segmentTextActive: { color: colors.textPrimary }, + error: { color: colors.statusRed, fontSize: typography.metaSize, marginTop: spacing.md } +})