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 = [
' ',
'',
'',
- ' ',
- ' ',
- '',
- '