refactor(mobile): extract settings, diagnostics and editor-document screens from their routes (#19675)

This commit is contained in:
Jinwoo Hong
2026-09-10 16:10:36 -07:00
committed by GitHub
parent fb85f88d64
commit 4e1681338c
48 changed files with 3017 additions and 2187 deletions
+8 -147
View File
@@ -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 (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</Svg>
)
}
function XIcon({ size = 16, color = colors.textSecondary }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</Svg>
)
}
export default function AboutScreen() {
export default function NativeAboutRoute() {
const router = useRouter()
const insets = useSafeAreaInsets()
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>About</Text>
</View>
<View style={styles.brand}>
<OrcaLogo size={28} />
<Text style={styles.brandName}>Orca</Text>
<Text style={styles.brandSub}>Open-source agent IDE for 100x builders</Text>
</View>
<View style={styles.section}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void Linking.openURL('https://onOrca.dev')}
>
<Globe size={16} color={colors.textSecondary} />
<Text style={styles.rowValue}>onOrca.dev</Text>
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void Linking.openURL('https://github.com/stablyai/orca')}
>
<GithubIcon />
<Text style={styles.rowValue}>stablyai/orca</Text>
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void Linking.openURL('https://x.com/orca_build')}
>
<XIcon />
<Text style={styles.rowValue}>@orca_build</Text>
</Pressable>
</View>
<Text style={styles.versionText}>{getVersionLabel()}</Text>
</View>
<AboutScreen
onBack={() => 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
}
})
+1 -163
View File
@@ -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<MobileTerminalLinkOpenMode>[] = [
{
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<MobileTerminalLinkOpenMode>('orca-browser')
const [pickerOpen, setPickerOpen] = useState(false)
useEffect(() => {
void loadTerminalLinkOpenMode().then(setLinkMode)
}, [])
const selectLinkMode = useCallback((mode: MobileTerminalLinkOpenMode) => {
setLinkMode(mode)
void saveTerminalLinkOpenMode(mode)
}, [])
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Browser</Text>
</View>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<Text style={styles.groupHeading}>LINKS</Text>
<Text style={styles.groupDescription}>
Choose where HTTP(S) links tapped in terminal output open.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setPickerOpen(true)}
>
<Globe size={16} color={colors.textSecondary} />
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Open terminal links</Text>
<Text style={styles.rowSublabel}>{linkModeLabel(linkMode)}</Text>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
</ScrollView>
<PickerModal<MobileTerminalLinkOpenMode>
visible={pickerOpen}
title="Open terminal links"
options={LINK_MODE_OPTIONS}
selected={linkMode}
onSelect={selectLinkMode}
onClose={() => setPickerOpen(false)}
/>
</View>
)
}
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'
+51 -338
View File
@@ -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<HostProfile[]>([])
const [manualSelection, setManualSelection] = useState<{
hostId: string
requestedHostId: string | undefined
routeKey: object
} | null>(null)
const [copiedHostId, setCopiedHostId] = useState<string | null>(null)
const [submissionStates, setSubmissionStates] = useState<DiagnosticsSubmissionStates>({})
const [manualSelection, setManualSelection] = useState<DiagnosticsHostSelection | null>(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 (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Network diagnostics</Text>
</View>
{hosts.length > 1 && (
<View style={styles.hostPicker}>
{hosts.map((host) => (
<Pressable
key={host.id}
style={[styles.hostChip, host.id === selectedId && styles.hostChipActive]}
onPress={() =>
setManualSelection({ hostId: host.id, requestedHostId: params.hostId, routeKey })
}
>
<Text
style={[styles.hostChipText, host.id === selectedId && styles.hostChipTextActive]}
numberOfLines={1}
<ConnectionDiagnosticsScreen
device={device}
host={selected}
state={state}
reconnectAttempts={reconnectAttempts}
activePath={activePath}
pendingPath={pendingPath}
entries={entries}
writeClipboard={(report) => Clipboard.setStringAsync(report)}
onBack={() => router.back()}
hostPicker={
hosts.length > 1 ? (
<View style={styles.hostPicker}>
{hosts.map((host) => (
<Pressable
key={host.id}
style={[styles.hostChip, host.id === selectedId && styles.hostChipActive]}
onPress={() =>
setManualSelection({ hostId: host.id, requestedHostId: params.hostId, routeKey })
}
>
{host.name}
</Text>
</Pressable>
))}
</View>
)}
{selected ? (
<>
<View style={styles.statusRow}>
<Text style={styles.statusText}>
{state}
{reconnectAttempts > 0 ? ` · attempt ${reconnectAttempts}` : ''}
</Text>
<Pressable style={styles.copyButton} onPress={() => void copyDiagnostics()}>
{copied ? (
<Check size={14} color={colors.statusGreen} />
) : (
<Copy size={14} color={colors.textSecondary} />
)}
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy report'}</Text>
</Pressable>
<Text
style={[styles.hostChipText, host.id === selectedId && styles.hostChipTextActive]}
numberOfLines={1}
>
{host.name}
</Text>
</Pressable>
))}
</View>
{diagnosis && (
<View style={styles.diagnosisCard}>
<Text style={styles.diagnosisHeading}>What this suggests</Text>
<Text style={styles.diagnosisText}>{diagnosis.likelyCause}</Text>
<Text style={styles.diagnosisNext}>{diagnosis.nextStep}</Text>
{diagnosis.reportability === 'orca-relay' && (
<>
<Text style={styles.privacyHint}>
Sends a size-limited redacted report including host name, endpoint, versions,
connection state, and eventsnever terminal contents or credentials.
</Text>
<Pressable
style={styles.sendButton}
onPress={() => void sendDiagnostics()}
disabled={submissionState === 'sending'}
>
{submissionState === 'sent' ? (
<Check size={14} color={colors.statusGreen} />
) : (
<Send size={14} color={colors.textPrimary} />
)}
<Text style={styles.sendButtonText}>
{submissionState === 'sending'
? 'Sending…'
: submissionState === 'sent'
? 'Diagnostics sent'
: submissionState === 'failed'
? 'Retry sending'
: 'Send diagnostics to Orca'}
</Text>
</Pressable>
</>
)}
</View>
)}
{entries.length > 0 ? (
<ConnectionLog entries={[...entries]} title={selected.name} fillAvailableHeight />
) : (
<Text style={styles.emptyText}>
No connection events yet. Events appear as the app dials this host.
</Text>
)}
</>
) : (
<Text style={styles.emptyText}>No paired hosts.</Text>
)}
</View>
) : 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
}
})
+1 -126
View File
@@ -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 (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Back"
style={styles.backButton}
onPress={() => router.back()}
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Chat UI</Text>
</View>
<ScrollView
contentContainerStyle={{ paddingBottom: insets.bottom + spacing.lg }}
showsVerticalScrollIndicator={false}
>
<Text style={styles.groupHeading}>DEFAULT VIEW</Text>
<Text style={styles.groupDescription}>
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.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Open sessions in Chat UI</Text>
<Text style={styles.rowSublabel}>{chatDefault ? 'On' : 'Off'}</Text>
</View>
<Switch
accessibilityLabel="Open sessions in Chat UI"
value={chatDefault}
onValueChange={(next) => setDefaultView(next ? 'chat' : 'terminal')}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
</View>
</ScrollView>
</View>
)
}
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'
+8 -174
View File
@@ -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 (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Notifications</Text>
</View>
<View style={styles.section}>
<View style={styles.row}>
<Text style={styles.rowLabel}>Agent notifications</Text>
<Switch
value={switchEnabled}
disabled={notificationsBlocked}
onValueChange={(v) => void togglePush(v)}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
<Text style={styles.hint}>{hint}</Text>
{notificationsBlocked && (
<Pressable
style={({ pressed }) => [
styles.settingsButton,
pressed && styles.settingsButtonPressed
]}
onPress={() => void Linking.openSettings()}
>
<Text style={styles.settingsButtonText}>Open Settings</Text>
</Pressable>
)}
</View>
</View>
<NotificationsScreen
operations={nativeNotificationSettingsOperations}
onBack={() => 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'
}
})
+11 -316
View File
@@ -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<string[]>([])
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 (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Settings</Text>
</View>
<ScrollView
contentContainerStyle={{ paddingBottom: insets.bottom + spacing.lg }}
showsVerticalScrollIndicator={false}
>
<View style={styles.section}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/terminal-settings')}
>
<TerminalIcon size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Terminal</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/native-chat-settings')}
>
<MessageSquare size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Chat UI</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/browser-settings')}
>
<Globe size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Browser</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/voice-settings')}
>
<Mic size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Voice</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/notifications')}
>
<Bell size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Notifications</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/troubleshoot')}
>
<Wrench size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Troubleshooting</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/about')}
>
<Info size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>About</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
{showCredentialCleanup ? (
<View style={[styles.section, styles.sectionSpacer]}>
<View style={styles.credentialCleanupRow}>
<KeyRound size={16} color={colors.statusAmber} />
<View style={styles.credentialCleanupCopy}>
<Text style={styles.credentialCleanupTitle}>Pairing credential cleanup</Text>
<Text accessibilityLiveRegion="polite" style={styles.rowHint}>
{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."}
</Text>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel="Retry clearing pairing credentials"
accessibilityState={{
busy: retryingCredentialCleanup,
disabled: retryingCredentialCleanup
}}
disabled={retryingCredentialCleanup}
hitSlop={8}
style={({ pressed }) => [
styles.retryButton,
pressed && !retryingCredentialCleanup && styles.rowPressed
]}
onPress={() => void retryCredentialCleanup()}
>
{retryingCredentialCleanup ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Text style={styles.retryButtonText}>Retry</Text>
)}
</Pressable>
</View>
</View>
) : null}
<View style={[styles.section, styles.sectionSpacer]}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void Linking.openURL('https://www.onorca.dev/privacy')}
>
<Shield size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Privacy Policy</Text>
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void Linking.openURL('https://github.com/stablyai/orca/issues')}
>
<LifeBuoy size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Support</Text>
</Pressable>
</View>
</ScrollView>
</View>
<SettingsMenuScreen
push={(route) => router.push(route)}
openExternal={(url) => Linking.openURL(url)}
>
<PendingCredentialCleanupCard />
</SettingsMenuScreen>
)
}
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
}
})
+12 -270
View File
@@ -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 <CheckCircle2 size={14} color={colors.statusGreen} />
case 'fail':
return <XCircle size={14} color={colors.statusRed} />
case 'warn':
return <AlertTriangle size={14} color={colors.textMuted} />
}
}
export default function TroubleshootScreen() {
export default function NativeTroubleshootRoute() {
const router = useRouter()
const insets = useSafeAreaInsets()
const [expandedId, setExpandedId] = useState<string | null>(null)
const [diagnosticStatus, setDiagnosticStatus] = useState<DiagnosticStatus>('idle')
const [checks, setChecks] = useState<CheckResult[]>([])
const abortRef = useRef(false)
const diagnosticRunRef = useRef(0)
const activeInternetCheckRef = useRef<DiagnosticFetchTimeout | null>(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 (
<View
ref={setTroubleshootRootRef}
style={[styles.container, { paddingTop: insets.top + spacing.sm }]}
>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Troubleshooting</Text>
</View>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<Pressable
style={({ pressed }) => [
styles.diagnosticButton,
pressed && styles.diagnosticButtonPressed,
diagnosticStatus === 'running' && styles.diagnosticButtonDisabled
]}
onPress={runDiagnostics}
disabled={diagnosticStatus === 'running'}
>
{diagnosticStatus === 'running' ? (
<ActivityIndicator size="small" color={colors.textPrimary} />
) : (
<Activity size={16} color={colors.textPrimary} />
)}
<Text style={styles.diagnosticButtonLabel}>
{diagnosticStatus === 'running'
? 'Running…'
: diagnosticStatus === 'done'
? 'Run again'
: 'Run diagnostics'}
</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.diagnosticButton,
pressed && styles.diagnosticButtonPressed
]}
onPress={() => router.push('/connection-log')}
>
<ScrollText size={16} color={colors.textPrimary} />
<Text style={styles.diagnosticButtonLabel}>View network diagnostics</Text>
</Pressable>
{checks.length > 0 && (
<View style={styles.section}>
{checks.map((check, i) => (
<View key={i}>
{i > 0 && <View style={styles.separator} />}
<View style={styles.checkRow}>
<StatusIcon status={check.status} />
<Text style={styles.checkLabel}>{check.label}</Text>
<Text
style={[styles.checkDetail, check.status === 'fail' && styles.checkDetailFail]}
>
{check.detail}
</Text>
</View>
</View>
))}
</View>
)}
<Text style={styles.sectionHeading}>Common issues</Text>
<View style={styles.section}>
{troubleshootCommonIssues.map((section, i) => (
<View key={section.id}>
{i > 0 && <View style={styles.separator} />}
<Pressable
style={({ pressed }) => [styles.accordionHeader, pressed && styles.rowPressed]}
onPress={() => toggleSection(section.id)}
>
{section.icon}
<Text style={styles.accordionTitle}>{section.title}</Text>
{expandedId === section.id ? (
<ChevronUp size={16} color={colors.textMuted} />
) : (
<ChevronDown size={16} color={colors.textMuted} />
)}
</Pressable>
{expandedId === section.id && (
<View style={styles.accordionBody}>
{section.steps.map((step, j) => (
<View key={j} style={styles.stepRow}>
<Text style={styles.bullet}></Text>
<Text style={styles.stepText}>{step}</Text>
</View>
))}
</View>
)}
</View>
))}
</View>
<View style={{ height: spacing.xl }} />
</ScrollView>
</View>
<TroubleshootView
rootRef={rootRef}
diagnosticStatus={diagnosticStatus}
checks={checks}
runDiagnostics={() => void runDiagnostics()}
onBack={() => router.back()}
onConnectionLog={() => router.push('/connection-log')}
/>
)
}
+10 -396
View File
@@ -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<HostProfile[]>([])
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<MobileSpeechSetup | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<ModelBusyAction | null>(null)
const [modelDrawerOpen, setModelDrawerOpen] = useState(false)
const refresh = useCallback(async (): Promise<boolean | undefined> => {
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 (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Voice</Text>
</View>
{!client ? (
<View style={[styles.section, styles.sectionTopGap]}>
<Text style={styles.emptyText}>Connect to a desktop to manage voice settings.</Text>
</View>
) : loading && setup === null ? (
<View style={styles.loading}>
<ActivityIndicator color={colors.textSecondary} />
</View>
) : setup === null ? (
<View style={[styles.section, styles.sectionTopGap]}>
<Text style={styles.errorText}>{error ?? 'Failed to load voice settings.'}</Text>
</View>
) : (
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<Text style={styles.groupHeading}>DICTATION</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Enable Voice Dictation</Text>
<Text style={styles.rowSublabel}>
Dictate text into any focused pane on your desktop.
</Text>
</View>
<Switch
value={enabled}
onValueChange={(v) => void handleToggleEnabled(v)}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
<View style={styles.separator} />
<View
style={[styles.row, !enabled && styles.disabled]}
pointerEvents={enabled ? 'auto' : 'none'}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Dictation Mode</Text>
<Text style={styles.rowSublabel}>
Toggle: press once to start, again to stop. Hold: dictate while held.
</Text>
</View>
<View style={styles.segmented}>
{DICTATION_MODES.map((mode) => {
const active = setup.dictationMode === mode.value
return (
<Pressable
key={mode.value}
onPress={() => void handleSelectMode(mode.value)}
style={[styles.segment, active && styles.segmentActive]}
>
<Text style={[styles.segmentText, active && styles.segmentTextActive]}>
{mode.label}
</Text>
</Pressable>
)
})}
</View>
</View>
</View>
<Text style={[styles.groupHeading, styles.inputGroupGap]}>SPEECH MODEL</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<Pressable
style={({ pressed }) => [
styles.row,
!enabled && styles.disabled,
pressed && styles.rowPressed
]}
disabled={!enabled}
onPress={() => setModelDrawerOpen(true)}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Speech Model</Text>
<Text style={styles.rowSublabel} numberOfLines={1}>
{selectedModelLabel}
</Text>
</View>
<ChevronRight size={18} color={colors.textMuted} />
</Pressable>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
</ScrollView>
)}
<BottomDrawer visible={modelDrawerOpen} onClose={() => setModelDrawerOpen(false)}>
<Text style={styles.drawerTitle}>Speech Model</Text>
{setup ? (
<VoiceModelList
setup={setup}
disabled={false}
busyAction={busyAction}
onUseModel={(m) => void handleUseModel(m)}
onDownload={(m) => void handleDownload(m)}
onDelete={(m) => void handleDelete(m)}
/>
) : null}
</BottomDrawer>
</View>
<VoiceSettingsScreen operations={operations} focused={focused} onBack={() => 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 }
})
@@ -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<MobileRichMarkdownEditorProps, 'onOpenLink'> & {
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<MobileRichMarkdownEditorHandle>
) {
const webViewRef = useRef<WebView>(null)
const readyRef = useRef(false)
const documentGenerationRef = useRef(0)
const currentWebViewContentRef = useRef<string | null>(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<EditorWebViewMessage>
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<MobileRichMarkdownEditorMessage>)
},
[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
@@ -43,6 +43,7 @@ export function NewWorktreeFormSheet(props: {
creating: boolean
canCreate: boolean
onClose: () => void
onOpenExternalUrl: (url: string) => Promise<unknown>
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}
/>
+11 -2
View File
@@ -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}
@@ -13,6 +13,7 @@ type Props = {
hostId?: string
existingWorktreePaths?: readonly string[]
existingWorktrees?: readonly { repoId: string; branch: string }[]
openExternalUrl: (url: string) => Promise<unknown>
onVisibleChange?: (visible: boolean) => void
onRouteVisibleChange: (visible: boolean) => void
onCreated: (worktreeId: string, name: string) => void
@@ -26,6 +27,7 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
hostId,
existingWorktreePaths,
existingWorktrees,
openExternalUrl,
onVisibleChange,
onRouteVisibleChange,
onCreated
@@ -61,6 +63,7 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
hostId={hostId}
existingWorktreePaths={existingWorktreePaths}
existingWorktrees={existingWorktrees}
openExternalUrl={openExternalUrl}
onCreated={onCreated}
onClose={close}
/>
@@ -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<unknown>
// 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({
</Text>
{selection.url ? (
<Pressable
accessibilityRole="link"
accessibilityLabel="Open selected source"
hitSlop={6}
onPress={() => selection.url && void Linking.openURL(selection.url).catch(() => {})}
onPress={() => selection.url && void onOpenExternalUrl(selection.url).catch(() => {})}
>
<ExternalLink size={15} color={colors.textMuted} />
</Pressable>
@@ -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
}
@@ -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 = [
' </style>',
'</head>',
'<body>',
' <main id="editor" contenteditable="true" data-placeholder="Start writing..."></main>',
' <script>',
' (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 ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' })[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 += '<img src=\"' + escapeAttr(image[2]) + '\" alt=\"' + escapeAttr(image[1] || '') + '\" />';",
' } else if (link && isSafeUrl(link[2])) {',
" output += '<a href=\"' + escapeAttr(link[2]) + '\">' + renderInline(link[1]) + '</a>';",
' } else if (/^https?:\\/\\//i.test(token)) {',
" output += '<a href=\"' + escapeAttr(token) + '\">' + escapeHtml(token) + '</a>';",
" } else if (token.indexOf('`') === 0) {",
" output += '<code>' + escapeHtml(token.slice(1, -1)) + '</code>';",
" } else if (token.indexOf('~~') === 0) {",
" output += '<s>' + renderInline(token.slice(2, -2)) + '</s>';",
" } else if (token.indexOf('**') === 0 || token.indexOf('__') === 0) {",
" output += '<strong>' + renderInline(token.slice(2, -2)) + '</strong>';",
' } else {',
" output += '<em>' + renderInline(token.slice(1, -1)) + '</em>';",
' }',
' 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);'
' <main id="editor" contenteditable="true" data-placeholder="Start writing..."></main>'
].join('\n')
@@ -83,11 +83,8 @@ export const MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS = [
''
].join('\n')
export const MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END = [
export const MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_END = [
'',
" post({ type: 'ready' });",
' })();',
' </script>',
'</body>',
'</html>'
' })();'
].join('\n')
@@ -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
)
})
})
@@ -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 `<!doctype html>
@@ -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}
<script>
${MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT}
</script>
</body>
</html>`
}
@@ -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 ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' })[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 += '<img src=\"' + escapeAttr(image[2]) + '\" alt=\"' + escapeAttr(image[1] || '') + '\" />';",
' } else if (link && isSafeUrl(link[2])) {',
" output += '<a href=\"' + escapeAttr(link[2]) + '\">' + renderInline(link[1]) + '</a>';",
' } else if (/^https?:\\/\\//i.test(token)) {',
" output += '<a href=\"' + escapeAttr(token) + '\">' + escapeHtml(token) + '</a>';",
" } else if (token.indexOf('`') === 0) {",
" output += '<code>' + escapeHtml(token.slice(1, -1)) + '</code>';",
" } else if (token.indexOf('~~') === 0) {",
" output += '<s>' + renderInline(token.slice(2, -2)) + '</s>';",
" } else if (token.indexOf('**') === 0 || token.indexOf('__') === 0) {",
" output += '<strong>' + renderInline(token.slice(2, -2)) + '</strong>';",
' } else {',
" output += '<em>' + renderInline(token.slice(1, -1)) + '</em>';",
' }',
' 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')
@@ -1,4 +1,4 @@
export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY = [
export const MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_SECONDARY = [
' }',
'',
' function indentationWidth(value) {',
@@ -0,0 +1,3 @@
export function escapeInjectedJavaScriptString(value: string): string {
return JSON.stringify(value).replace(/<\/script/gi, '<\\/script')
}
@@ -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}`
@@ -23,6 +23,7 @@ export type NewWorktreeModalProps = {
hostId?: string
existingWorktreePaths?: readonly string[]
existingWorktrees?: readonly { repoId: string; branch: string }[]
openExternalUrl: (url: string) => Promise<unknown>
onCreated: (worktreeId: string, name: string) => void
onClose: () => void
}
@@ -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<string | null>(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<MobileRichMarkdownEditorMessage>) => {
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 }
}
@@ -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 }
})
@@ -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<unknown>
onBack: () => void
hostPicker?: ReactNode
}) {
const [copiedHostId, setCopiedHostId] = useState<string | null>(null)
const [submissionStates, setSubmissionStates] = useState<DiagnosticsSubmissionStates>({})
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 (
<ConnectionDiagnosticsView
hasHost={host !== null}
hostName={host?.name ?? ''}
state={state}
reconnectAttempts={reconnectAttempts}
entries={entries}
diagnosis={diagnosis}
copied={copied}
copyDiagnostics={copyDiagnostics}
submissionState={submissionState}
sendDiagnostics={sendDiagnostics}
onBack={onBack}
hostPicker={hostPicker}
/>
)
}
@@ -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<void>
diagnosis: ConnectionDiagnosis | null
submissionState: DiagnosticsSubmissionState | 'idle'
sendDiagnostics: () => Promise<void>
entries: readonly ConnectionLogEntry[]
onBack: () => void
}) {
const insets = useSafeAreaInsets()
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Back"
style={styles.backButton}
onPress={onBack}
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Network diagnostics</Text>
</View>
{hostPicker}
{hasHost ? (
<>
<View style={styles.statusRow}>
<Text style={styles.statusText}>
{state}
{reconnectAttempts > 0 ? ` · attempt ${reconnectAttempts}` : ''}
</Text>
<Pressable style={styles.copyButton} onPress={() => void copyDiagnostics()}>
{copied ? (
<Check size={14} color={colors.statusGreen} />
) : (
<Copy size={14} color={colors.textSecondary} />
)}
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy report'}</Text>
</Pressable>
</View>
{diagnosis && (
<View style={styles.diagnosisCard}>
<Text style={styles.diagnosisHeading}>What this suggests</Text>
<Text style={styles.diagnosisText}>{diagnosis.likelyCause}</Text>
<Text style={styles.diagnosisNext}>{diagnosis.nextStep}</Text>
{diagnosis.reportability === 'orca-relay' && (
<>
<Text style={styles.privacyHint}>
Sends a size-limited redacted report including host name, endpoint, versions,
connection state, and eventsnever terminal contents or credentials.
</Text>
<Pressable
style={styles.sendButton}
onPress={() => void sendDiagnostics()}
disabled={submissionState === 'sending'}
>
{submissionState === 'sent' ? (
<Check size={14} color={colors.statusGreen} />
) : (
<Send size={14} color={colors.textPrimary} />
)}
<Text style={styles.sendButtonText}>
{submissionState === 'sending'
? 'Sending…'
: submissionState === 'sent'
? 'Diagnostics sent'
: submissionState === 'failed'
? 'Retry sending'
: 'Send diagnostics to Orca'}
</Text>
</Pressable>
</>
)}
</View>
)}
{entries.length > 0 ? (
<ConnectionLog entries={[...entries]} title={hostName} fillAvailableHeight />
) : (
<Text style={styles.emptyText}>
No connection events yet. Events appear as the app dials this host.
</Text>
)}
</>
) : (
<Text style={styles.emptyText}>No paired hosts.</Text>
)}
</View>
)
}
@@ -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<ConnectionDiagnosticsReport>
submit(
submission: ConnectionDiagnosticsSubmission
): Promise<ConnectionDiagnosticsSubmissionResult>
}
@@ -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
}
}
@@ -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 <CheckCircle2 size={14} color={colors.statusGreen} />
case 'fail':
return <XCircle size={14} color={colors.statusRed} />
case 'warn':
return <AlertTriangle size={14} color={colors.textMuted} />
}
}
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<string | null>(null)
const toggleSection = useCallback(
(id: string) => setExpandedId((prev) => (prev === id ? null : id)),
[]
)
return (
<View ref={rootRef} style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Back"
style={styles.backButton}
onPress={onBack}
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Troubleshooting</Text>
</View>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<Pressable
style={({ pressed }) => [
styles.diagnosticButton,
pressed && styles.diagnosticButtonPressed,
diagnosticStatus === 'running' && styles.diagnosticButtonDisabled
]}
testID="diagnostics-run"
onPress={runDiagnostics}
disabled={diagnosticStatus === 'running'}
>
{diagnosticStatus === 'running' ? (
<ActivityIndicator size="small" color={colors.textPrimary} />
) : (
<Activity size={16} color={colors.textPrimary} />
)}
<Text style={styles.diagnosticButtonLabel}>
{diagnosticStatus === 'running'
? 'Running…'
: diagnosticStatus === 'done'
? 'Run again'
: 'Run diagnostics'}
</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.diagnosticButton,
pressed && styles.diagnosticButtonPressed
]}
onPress={onConnectionLog}
>
<ScrollText size={16} color={colors.textPrimary} />
<Text style={styles.diagnosticButtonLabel}>View network diagnostics</Text>
</Pressable>
{checks.length > 0 && (
<View style={styles.section}>
{checks.map((check, i) => (
<View key={i}>
{i > 0 && <View style={styles.separator} />}
<View style={styles.checkRow}>
<StatusIcon status={check.status} />
<Text style={styles.checkLabel}>{check.label}</Text>
<Text
style={[styles.checkDetail, check.status === 'fail' && styles.checkDetailFail]}
>
{check.detail}
</Text>
</View>
</View>
))}
</View>
)}
<Text style={styles.sectionHeading}>Common issues</Text>
<View style={styles.section}>
{troubleshootCommonIssues.map((section, i) => (
<View key={section.id}>
{i > 0 && <View style={styles.separator} />}
<Pressable
style={({ pressed }) => [styles.accordionHeader, pressed && styles.rowPressed]}
onPress={() => toggleSection(section.id)}
>
{section.icon}
<Text style={styles.accordionTitle}>{section.title}</Text>
{expandedId === section.id ? (
<ChevronUp size={16} color={colors.textMuted} />
) : (
<ChevronDown size={16} color={colors.textMuted} />
)}
</Pressable>
{expandedId === section.id && (
<View style={styles.accordionBody}>
{section.steps.map((step, j) => (
<View key={j} style={styles.stepRow}>
<Text style={styles.bullet}></Text>
<Text style={styles.stepText}>{step}</Text>
</View>
))}
</View>
)}
</View>
))}
</View>
<View style={{ height: spacing.xl }} />
</ScrollView>
</View>
)
}
@@ -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<DiagnosticStatus>('idle')
const [checks, setChecks] = useState<CheckResult[]>([])
const abortRef = useRef(false)
const diagnosticRunRef = useRef(0)
const activeInternetCheckRef = useRef<DiagnosticFetchTimeout | null>(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 }
}
@@ -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
}}
+187
View File
@@ -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 (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</Svg>
)
}
function XIcon({ size = 16, color = colors.textSecondary }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</Svg>
)
}
export default function AboutScreen({
onBack,
openExternal,
versionLabel
}: {
onBack: () => void
openExternal: (url: string) => Promise<unknown>
versionLabel: string
}) {
const [error, setError] = useState<string | null>(null)
const openLink = (url: string) => {
setError(null)
void openExternal(url).catch(() => setError('Could not open the link. Try again.'))
}
const insets = useSafeAreaInsets()
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable
style={styles.backButton}
accessibilityRole="button"
accessibilityLabel="Back"
onPress={onBack}
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>About</Text>
</View>
<View style={styles.brand}>
<OrcaLogo size={28} />
<Text style={styles.brandName}>Orca</Text>
<Text style={styles.brandSub}>Open-source agent IDE for 100x builders</Text>
</View>
<View style={styles.section}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
accessibilityRole="button"
accessibilityLabel="Orca website"
onPress={() => openLink('https://onOrca.dev')}
>
<Globe size={16} color={colors.textSecondary} />
<Text style={styles.rowValue}>onOrca.dev</Text>
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
accessibilityRole="button"
accessibilityLabel="Orca source code"
onPress={() => openLink('https://github.com/stablyai/orca')}
>
<GithubIcon />
<Text style={styles.rowValue}>stablyai/orca</Text>
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
accessibilityRole="button"
accessibilityLabel="Orca on X"
onPress={() => openLink('https://x.com/orca_build')}
>
<XIcon />
<Text style={styles.rowValue}>@orca_build</Text>
</Pressable>
</View>
<Text style={styles.versionText}>{versionLabel}</Text>
{error && (
<Text accessibilityRole="alert" style={styles.errorText}>
{error}
</Text>
)}
</View>
)
}
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
}
})
@@ -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<MobileTerminalLinkOpenMode>[] = [
{
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<MobileTerminalLinkOpenMode>('orca-browser')
const [pickerOpen, setPickerOpen] = useState(false)
const [error, setError] = useState<string | null>(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 (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Back"
style={styles.backButton}
onPress={onBack ?? (() => router.back())}
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Browser</Text>
</View>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<Text style={styles.groupHeading}>LINKS</Text>
<Text style={styles.groupDescription}>
Choose where HTTP(S) links tapped in terminal output open.
</Text>
{error && (
<Text accessibilityRole="alert" style={styles.groupDescription}>
{error}
</Text>
)}
<View style={[styles.section, styles.sectionTopGap]}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Open terminal links"
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setPickerOpen(true)}
>
<Globe size={16} color={colors.textSecondary} />
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Open terminal links</Text>
<Text style={styles.rowSublabel}>{linkModeLabel(linkMode)}</Text>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
</ScrollView>
<PickerModal<MobileTerminalLinkOpenMode>
visible={pickerOpen}
title="Open terminal links"
options={LINK_MODE_OPTIONS}
selected={linkMode}
onSelect={selectLinkMode}
onClose={() => setPickerOpen(false)}
/>
</View>
)
}
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
}
})
@@ -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') }
]
}
@@ -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 (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Back"
style={styles.backButton}
onPress={onBack ?? (() => router.back())}
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Settings</Text>
</View>
<ScrollView
contentContainerStyle={{ paddingBottom: insets.bottom + spacing.lg }}
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</View>
)
}
export type MobileSettingsMenuItem = {
label: string
icon: LucideIcon
onPress: () => void
external?: boolean
disabled?: boolean
}
export function MobileSettingsSection({
items,
spaced = false
}: {
items: MobileSettingsMenuItem[]
spaced?: boolean
}) {
return (
<View style={[styles.section, spaced && styles.sectionSpacer]}>
{items.map(({ label, icon: Icon, onPress, external, disabled }, index) => (
<View key={label}>
{index > 0 && <View style={styles.separator} />}
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ disabled: Boolean(disabled) }}
disabled={disabled}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onPress}
>
<Icon size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>{label}</Text>
{!external && <ChevronRight size={16} color={colors.textMuted} />}
</Pressable>
</View>
))}
</View>
)
}
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
}
})
@@ -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 (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Back"
style={styles.backButton}
onPress={onBack ?? (() => router.back())}
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Chat UI</Text>
</View>
<ScrollView
contentContainerStyle={{ paddingBottom: insets.bottom + spacing.lg }}
showsVerticalScrollIndicator={false}
>
<Text style={styles.groupHeading}>DEFAULT VIEW</Text>
<Text style={styles.groupDescription}>
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.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Open sessions in Chat UI</Text>
<Text style={styles.rowSublabel}>{chatDefault ? 'On' : 'Off'}</Text>
</View>
<Switch
accessibilityLabel="Open sessions in Chat UI"
value={chatDefault}
onValueChange={(next) => setDefaultView(next ? 'chat' : 'terminal')}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
</View>
</ScrollView>
</View>
)
}
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
}
})
@@ -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()
}
@@ -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<RpcClient, 'sendRequest'>
): VoiceSettingsOperations {
return {
load: () => fetchDictationSetup(client),
configure: (params) => setDictationConfig(client, params),
download: (modelId) => downloadDictationModel(client, modelId),
delete: (modelId) => deleteDictationModel(client, modelId)
}
}
@@ -0,0 +1,7 @@
import type { NotificationPermissionState } from '../notifications/notification-permissions'
export interface NotificationSettingsOperations {
permission(request?: boolean): Promise<NotificationPermissionState>
preference(enabled?: boolean): Promise<{ enabled: boolean }>
openSettings(): Promise<unknown>
}
@@ -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<string | null>(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 (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Back"
style={styles.backButton}
onPress={onBack}
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Notifications</Text>
</View>
{error && (
<Text accessibilityRole="alert" style={styles.hint}>
{error}
</Text>
)}
<View style={styles.section}>
<View style={styles.row}>
<Text style={styles.rowLabel}>Agent notifications</Text>
<Switch
value={switchEnabled}
testID="notification-enabled"
accessibilityLabel="Agent notifications"
disabled={notificationsBlocked}
onValueChange={(v) => void togglePush(v)}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
<Text style={styles.hint}>{hint}</Text>
{notificationsBlocked && (
<Pressable
style={({ pressed }) => [
styles.settingsButton,
pressed && styles.settingsButtonPressed
]}
testID="notification-system-settings"
onPress={() =>
void operations
.openSettings()
.catch(() => setError('Could not open system settings. Try again.'))
}
>
<Text style={styles.settingsButtonText}>Open Settings</Text>
</Pressable>
)}
</View>
</View>
)
}
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'
}
})
@@ -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<string[]>([])
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 (
<View style={[styles.section, styles.sectionSpacer]}>
<View style={styles.credentialCleanupRow}>
<KeyRound size={16} color={colors.statusAmber} />
<View style={styles.credentialCleanupCopy}>
<Text style={styles.credentialCleanupTitle}>Pairing credential cleanup</Text>
<Text accessibilityLiveRegion="polite" style={styles.rowHint}>
{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."}
</Text>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel="Retry clearing pairing credentials"
accessibilityState={{
busy: retryingCredentialCleanup,
disabled: retryingCredentialCleanup
}}
disabled={retryingCredentialCleanup}
hitSlop={8}
style={({ pressed }) => [
styles.retryButton,
pressed && !retryingCredentialCleanup && styles.rowPressed
]}
onPress={() => void retryCredentialCleanup()}
>
{retryingCredentialCleanup ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Text style={styles.retryButtonText}>Retry</Text>
)}
</Pressable>
</View>
</View>
)
}
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
}
})
@@ -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<unknown>
children?: ReactNode
}) {
return (
<MobileSettingsFrame onBack={onBack}>
<MobileSettingsSection items={mobileSettingsMenuItems(push)} />
{children}
<MobileSettingsSection
spaced
items={[
{
label: 'Privacy Policy',
icon: Shield,
external: true,
onPress: () => void openExternal('https://www.onorca.dev/privacy')
},
{
label: 'Support',
icon: LifeBuoy,
external: true,
onPress: () => void openExternal('https://github.com/stablyai/orca/issues')
}
]}
/>
</MobileSettingsFrame>
)
}
@@ -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<unknown> }) => {
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')
})
})
@@ -0,0 +1,12 @@
import type { MobileSpeechSetup } from '../dictation/mobile-dictation-setup'
export interface VoiceSettingsOperations {
load(): Promise<MobileSpeechSetup>
configure(params: {
enabled?: boolean
modelId?: string
dictationMode?: 'toggle' | 'hold'
}): Promise<MobileSpeechSetup>
download(modelId: string): Promise<void>
delete(modelId: string): Promise<MobileSpeechSetup>
}
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock.calls.length).toBe(
loadsBeforeRejection
)
})
})
@@ -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<MobileSpeechSetup | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<ModelBusyAction | null>(null)
const requestEpoch = useRef(0)
const [modelDrawerOpen, setModelDrawerOpen] = useState(false)
const refresh = useCallback(async (): Promise<boolean | undefined> => {
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<VoiceSettingsOperations['configure']>[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 (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Back"
style={styles.backButton}
onPress={onBack}
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Voice</Text>
</View>
{!operations ? (
<View style={[styles.section, styles.sectionTopGap]}>
<Text style={styles.emptyText}>Connect to a desktop to manage voice settings.</Text>
</View>
) : loading && setup === null ? (
<View style={styles.loading}>
<ActivityIndicator color={colors.textSecondary} />
</View>
) : setup === null ? (
<View style={[styles.section, styles.sectionTopGap]}>
<Text style={styles.errorText}>{error ?? 'Failed to load voice settings.'}</Text>
</View>
) : (
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<Text style={styles.groupHeading}>DICTATION</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Enable Voice Dictation</Text>
<Text style={styles.rowSublabel}>
Dictate text into any focused pane on your desktop.
</Text>
</View>
<Switch
testID="voice-enabled"
accessibilityLabel="Enable Voice Dictation"
value={enabled}
onValueChange={(enabled) => void configure({ enabled })}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
<View style={styles.separator} />
<View
style={[styles.row, !enabled && styles.disabled]}
pointerEvents={enabled ? 'auto' : 'none'}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Dictation Mode</Text>
<Text style={styles.rowSublabel}>
Toggle: press once to start, again to stop. Hold: dictate while held.
</Text>
</View>
<View style={styles.segmented}>
{DICTATION_MODES.map((mode) => {
const active = setup.dictationMode === mode.value
return (
<Pressable
key={mode.value}
accessibilityRole="radio"
aria-checked={active}
testID={`voice-mode-${mode.value}`}
onPress={() => void configure({ dictationMode: mode.value })}
style={[styles.segment, active && styles.segmentActive]}
>
<Text style={[styles.segmentText, active && styles.segmentTextActive]}>
{mode.label}
</Text>
</Pressable>
)
})}
</View>
</View>
</View>
<Text style={[styles.groupHeading, styles.inputGroupGap]}>SPEECH MODEL</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<Pressable
style={({ pressed }) => [
styles.row,
!enabled && styles.disabled,
pressed && styles.rowPressed
]}
disabled={!enabled}
testID="voice-model-picker"
onPress={() => setModelDrawerOpen(true)}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Speech Model</Text>
<Text style={styles.rowSublabel} numberOfLines={1}>
{selectedModelLabel}
</Text>
</View>
<ChevronRight size={18} color={colors.textMuted} />
</Pressable>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
</ScrollView>
)}
<BottomDrawer visible={modelDrawerOpen} onClose={() => setModelDrawerOpen(false)}>
<Text style={styles.drawerTitle}>Speech Model</Text>
{setup ? (
<VoiceModelList
setup={setup}
disabled={false}
busyAction={busyAction}
onUseModel={(m) => void handleUseModel(m)}
onDownload={(m) => void handleDownload(m)}
onDelete={(m) => void handleDelete(m)}
/>
) : null}
</BottomDrawer>
</View>
)
}
@@ -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 }
})