mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
refactor(mobile): extract settings, diagnostics and editor-document screens from their routes (#19675)
This commit is contained in:
+8
-147
@@ -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 +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
@@ -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 events—never 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 +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'
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+3
-96
@@ -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 ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' })[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 ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' })[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
-1
@@ -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 events—never 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
|
||||
}}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
Reference in New Issue
Block a user