Improve mobile connection recovery guidance

This commit is contained in:
Brennan Benson
2026-08-04 19:51:20 -07:00
parent 68dcf2e18a
commit 3063cefe91
24 changed files with 438 additions and 69 deletions
+1 -1
View File
@@ -116,7 +116,7 @@ export default function ConnectionLogScreen() {
<View style={styles.statusRow}>
<Text style={styles.statusText}>
{state === 'connected' && rpcUnresponsiveSince != null
? 'Connected, not responding'
? 'Desktop not responding'
: state}
{reconnectAttempts > 0 ? ` · attempt ${reconnectAttempts}` : ''}
</Text>
+10 -6
View File
@@ -28,7 +28,7 @@ import {
useCloseHost,
useForceReconnect
} from '../../../src/transport/client-context'
import { showForceReconnectError } from '../../../src/transport/force-reconnect-feedback'
import { startForceReconnectWithFeedback } from '../../../src/transport/force-reconnect-feedback'
import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
import { startHostWorktreeRefresh } from '../../../src/worktree/host-worktree-refresh'
import {
@@ -147,6 +147,12 @@ export function HostScreen({
const newWorktreeModalVisibleRef = useRef(false)
const closeHostClient = useCloseHost()
const forceReconnectHost = useForceReconnect()
const reconnectHostWithFeedback = useCallback(() => {
if (!hostId) {
return
}
startForceReconnectWithFeedback(() => forceReconnectHost(hostId))
}, [forceReconnectHost, hostId])
const [worktrees, setWorktrees] = useState<Worktree[]>(initialCache ?? [])
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
// Why (STA-3123): error code of the last failed worktree.ps, so a broken catalog
@@ -836,12 +842,10 @@ export function HostScreen({
return (
<Pressable
style={styles.reconnectButton}
onPress={() =>
void forceReconnectHost(hostId!).catch(showForceReconnectError)
}
onPress={reconnectHostWithFeedback}
hitSlop={8}
>
<Text style={styles.reconnectButtonText}>Reconnect</Text>
<Text style={styles.reconnectButtonText}>Reconnect now</Text>
</Pressable>
)
})()}
@@ -1099,7 +1103,7 @@ export function HostScreen({
{connState === 'auth-failed' && (
<AuthFailedBanner
canRetry={!!hostId}
onRetry={() => hostId && void forceReconnectHost(hostId).catch(showForceReconnectError)}
onRetry={reconnectHostWithFeedback}
onRepair={() => router.push('/pair-scan')}
onRemove={() => setConfirmRemoveHost(true)}
/>
@@ -59,7 +59,7 @@ import {
type MobileTerminalLinkOpenMode
} from '../../../../src/storage/preferences'
import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
import { showForceReconnectError } from '../../../../src/transport/force-reconnect-feedback'
import { startForceReconnectWithFeedback } from '../../../../src/transport/force-reconnect-feedback'
import {
useLastConnectedAt,
useReconnectAttempt,
@@ -4428,7 +4428,7 @@ export default function SessionScreen() {
disabled={!showConnectionRetry}
onPress={() => {
if (hostId) {
void forceReconnectHost(hostId).catch(showForceReconnectError)
startForceReconnectWithFeedback(() => forceReconnectHost(hostId))
}
}}
accessibilityRole={showConnectionRetry ? 'button' : undefined}
+31 -2
View File
@@ -31,7 +31,7 @@ import {
useForceReconnect,
usePrimeHosts
} from '../src/transport/client-context'
import { showForceReconnectError } from '../src/transport/force-reconnect-feedback'
import { startForceReconnectWithFeedback } from '../src/transport/force-reconnect-feedback'
import { useRpcUnresponsiveByHost } from '../src/transport/client-context-connection-metrics'
import { classifyConnection } from '../src/transport/connection-health'
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
@@ -244,6 +244,12 @@ export default function HomeScreen() {
)
const closeHostClient = useCloseHost()
const forceReconnectHost = useForceReconnect()
const reconnectHostWithFeedback = useCallback(
(hostId: string) => {
startForceReconnectWithFeedback(() => forceReconnectHost(hostId))
},
[forceReconnectHost]
)
const primeHosts = usePrimeHosts()
const allClientsRef = useRef<Array<{ hostId: string; client: RpcClient }>>([])
// Why: keep the focus callback stable (no refetch per render) while still exposing the latest host clients.
@@ -718,6 +724,28 @@ export default function HomeScreen() {
rpcUnresponsiveSince: hostRpcUnresponsive[item.id] ?? null,
endpoint: item.endpoint
})
const reconnectAction = {
label: state === 'disconnected' ? 'Connect' : 'Reconnect now',
onPress: () => reconnectHostWithFeedback(item.id)
}
let statusActions: { label: string; onPress: () => void }[] | undefined
if (verdict.kind === 'auth-failed') {
statusActions = [
{ label: 'Retry', onPress: reconnectAction.onPress },
{ label: 'Re-pair', onPress: () => router.push('/pair-scan') }
]
} else if (verdict.kind === 'unreachable') {
statusActions = [
reconnectAction,
{ label: 'Re-pair', onPress: () => router.push('/pair-scan') }
]
} else if (
verdict.kind === 'warning' ||
state === 'reconnecting' ||
state === 'disconnected'
) {
statusActions = [reconnectAction]
}
return (
<MobileHostCard
host={item}
@@ -725,6 +753,7 @@ export default function HomeScreen() {
verdict={verdict}
path={hostPaths[item.id] ?? 'lan'}
worktreeInfo={worktreeInfo[item.id]}
statusActions={statusActions}
onPress={() => router.push(`/h/${item.id}`)}
onLongPress={() => {
triggerMediumImpact()
@@ -908,7 +937,7 @@ export default function HomeScreen() {
? (hostLastConnected[actionTarget.id] ?? null) != null
: false,
onDismiss: () => setActionTarget(null),
onReconnect: (hostId) => void forceReconnectHost(hostId).catch(showForceReconnectError),
onReconnect: reconnectHostWithFeedback,
onDisconnect: closeHostClient,
onEdit: openMobileHostEdit,
onRemove: setConfirmRemove
+7 -7
View File
@@ -4,6 +4,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import { resolvePairConfirmRouteState } from '../src/transport/pair-confirm-state'
import { pairingErrorMessage } from '../src/transport/pairing-error-presentation'
import {
startPreProfilePairing,
type PreProfilePairingAttempt
@@ -133,11 +134,7 @@ export default function PairConfirmScreen() {
}
console.warn('[pair-confirm] connect failed', err)
setStatus('error')
setErrorMessage(
timedOut
? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled`
: `Pairing failed: ${err instanceof Error ? err.message : String(err)}`
)
setErrorMessage(pairingErrorMessage(err, timedOut, PAIRING_OVERALL_TIMEOUT_MS / 1000))
}
}
@@ -186,8 +183,11 @@ export default function PairConfirmScreen() {
</View>
)}
<View style={styles.actionStack}>
<Pressable style={styles.primaryButton} onPress={cancel}>
<Text style={styles.primaryButtonText}>Back to home</Text>
<Pressable style={styles.primaryButton} onPress={() => router.replace('/pair-scan')}>
<Text style={styles.primaryButtonText}>Scan latest QR code</Text>
</Pressable>
<Pressable style={styles.secondaryButton} onPress={cancel}>
<Text style={styles.secondaryButtonText}>Back to home</Text>
</Pressable>
</View>
</>
+2 -5
View File
@@ -13,6 +13,7 @@ import { CameraView, useCameraPermissions } from 'expo-camera'
import { useRouter } from 'expo-router'
import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native'
import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing'
import { pairingErrorMessage } from '../src/transport/pairing-error-presentation'
import {
startPreProfilePairing,
type PreProfilePairingAttempt
@@ -177,11 +178,7 @@ export default function PairScanScreen() {
}
console.warn('[pair] connect failed', err)
setStatus('error')
setErrorMessage(
timedOut
? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled`
: `Pairing failed: ${err instanceof Error ? err.message : String(err)}`
)
setErrorMessage(pairingErrorMessage(err, timedOut, PAIRING_OVERALL_TIMEOUT_MS / 1000))
processingRef.current = false
}
}
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useHostClient, useForceReconnect } from '../transport/client-context'
import { showForceReconnectError } from '../transport/force-reconnect-feedback'
import { startForceReconnectWithFeedback } from '../transport/force-reconnect-feedback'
import type { RpcSuccess } from '../transport/types'
import type {
AiVaultListResult,
@@ -170,7 +170,7 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams
const retry = useCallback(() => {
if (connState !== 'connected' && hostId) {
void forceReconnect(hostId).catch(showForceReconnectError)
startForceReconnectWithFeedback(() => forceReconnect(hostId))
return
}
void loadSessions({ scope, force: false })
+2 -1
View File
@@ -19,7 +19,8 @@ export function AuthFailedBanner({
return (
<View style={styles.banner}>
<Text style={styles.text}>
Authentication failed — try reconnecting first; if it keeps failing, re-pair from desktop.
This desktop no longer accepts your phone&apos;s pairing. Try reconnecting once; if it still
fails, re-pair from the desktop.
</Text>
<View style={styles.actions}>
{canRetry && (
@@ -90,4 +90,37 @@ describe('MobileHostCard', () => {
expect(lines).not.toContain('0 worktrees')
expect(lines).not.toContain('Worktree list unavailable')
})
it('explains automatic recovery and exposes Reconnect now', async () => {
const reconnect = vi.fn()
const stopPropagation = vi.fn()
await act(async () => {
renderer = create(
createElement(MobileHostCard, {
host,
state: 'connected',
verdict: {
kind: 'warning',
label: 'Desktop not responding',
reason: 'unresponsive'
},
path: 'lan',
statusActions: [{ label: 'Reconnect now', onPress: reconnect }],
onPress: () => {},
onLongPress: () => {}
})
)
})
const text = renderer!.root
.findAllByType('Text')
.flatMap((node) => node.children.filter((child) => typeof child === 'string'))
expect(text).toContain(
"The connection is open, but desktop Orca isn't answering. Orca is checking the connection and will retry automatically."
)
const action = renderer!.root.findByProps({ accessibilityLabel: 'Reconnect now' })
act(() => action.props.onPress({ stopPropagation }))
expect(stopPropagation).toHaveBeenCalledOnce()
expect(reconnect).toHaveBeenCalledOnce()
})
})
+53 -2
View File
@@ -1,11 +1,11 @@
import { ChevronRight, Monitor } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import type { ConnectionVerdict } from '../transport/connection-health'
import { verdictDisplayLabel } from '../transport/connection-health'
import { verdictDisplayLabel, verdictSupportingMessage } from '../transport/connection-health'
import { mobileConnectionPathLabel } from '../transport/mobile-connection-path-label'
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from '../transport/types'
import { colors, radii, spacing } from '../theme/mobile-theme'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { homeHostWorktreeSummary, type HostWorktreeInfo } from '../worktree/home-worktree-info'
import { StatusDot } from './StatusDot'
@@ -17,12 +17,14 @@ export function MobileHostCard(props: {
// Why: the card owns the fresh/stale/unavailable wording so no caller can re-gate the counts
// away (STA-3123 shipped that bug once already).
worktreeInfo?: HostWorktreeInfo
statusActions?: { label: string; onPress: () => void }[]
onPress: () => void
onLongPress: () => void
}) {
const connected = props.state === 'connected'
const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind)
const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo)
const supportingMessage = verdictSupportingMessage(props.verdict)
return (
<Pressable
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
@@ -52,11 +54,35 @@ export function MobileHostCard(props: {
{worktreeSummary}
</Text>
) : null}
{supportingMessage ? (
<Text style={styles.supportingMessage} numberOfLines={3}>
{supportingMessage}
</Text>
) : null}
{props.verdict.kind === 'unreachable' && !props.host.relay ? (
<Text style={styles.discoveryHint} numberOfLines={2}>
Update desktop Orca and sign in to connect from anywhere
</Text>
) : null}
{props.statusActions?.length ? (
<View style={styles.statusActions}>
{props.statusActions.map((action) => (
<Pressable
key={action.label}
style={styles.statusAction}
accessibilityRole="button"
accessibilityLabel={action.label}
onPress={(event) => {
event.stopPropagation()
action.onPress()
}}
hitSlop={8}
>
<Text style={styles.statusActionText}>{action.label}</Text>
</Pressable>
))}
</View>
) : null}
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
@@ -94,10 +120,35 @@ const styles = StyleSheet.create({
fontSize: 12,
color: colors.textMuted
},
supportingMessage: {
marginTop: spacing.xs,
fontSize: typography.metaSize,
color: colors.textSecondary
},
discoveryHint: {
marginTop: spacing.xs,
fontSize: 11,
lineHeight: 15,
color: colors.textMuted
},
statusActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginTop: spacing.sm,
flexWrap: 'wrap'
},
statusAction: {
paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle
},
statusActionText: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '600'
}
})
+3 -3
View File
@@ -11,7 +11,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { ChevronLeft, X } from 'lucide-react-native'
import { useHostClient, useForceReconnect } from '../transport/client-context'
import { showForceReconnectError } from '../transport/force-reconnect-feedback'
import { startForceReconnectWithFeedback } from '../transport/force-reconnect-feedback'
import { getWorktreeLabel } from '../session/worktree-label'
import {
flattenDirectoryCache,
@@ -250,7 +250,7 @@ export function MobileFileExplorerPanel(props: {
(relativePath: string) => {
if (connState !== 'connected' && hostId) {
pendingDirectoryRetriesRef.current.add(relativePath)
void forceReconnect(hostId).catch(showForceReconnectError)
startForceReconnectWithFeedback(() => forceReconnect(hostId))
return
}
void loadDirectory(relativePath)
@@ -334,7 +334,7 @@ export function MobileFileExplorerPanel(props: {
style={styles.retryButton}
onPress={() =>
connState !== 'connected' && hostId
? void forceReconnect(hostId).catch(showForceReconnectError)
? startForceReconnectWithFeedback(() => forceReconnect(hostId))
: void loadDirectory('')
}
>
+5 -1
View File
@@ -305,7 +305,11 @@ describe('edit host handleSave', () => {
expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1', savedHost)
expect(dependencies.back).toHaveBeenCalledTimes(1)
expect(dependencies.alert).toHaveBeenCalledWith('Unable to reconnect', 'connect failed')
expect(dependencies.alert).toHaveBeenCalledWith(
"Couldn't reconnect to desktop",
'Make sure desktop Orca is open and both devices are online, then try again.',
[{ text: 'Dismiss', style: 'cancel' }]
)
act(() => renderer.unmount())
})
@@ -2,6 +2,7 @@ import type { Dispatch, RefObject, SetStateAction } from 'react'
import type { FlatList } from 'react-native'
import type { ConnectionState } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { forceReconnectErrorPresentation } from '../transport/force-reconnect-feedback'
import { triggerSelection } from '../platform/haptics'
import { findNextMobileDiffHunkIndex, findPreviousMobileDiffHunkIndex } from './mobile-diff-hunks'
import type {
@@ -197,7 +198,8 @@ export function useMobileDiffReviewInteractions(input: InteractionInput) {
retryAction: () => {
if (connState !== 'connected' && hostId) {
void Promise.resolve(onReconnect(hostId)).catch((error: unknown) => {
setActionError(error instanceof Error ? error.message : 'Unable to reconnect')
const presentation = forceReconnectErrorPresentation(error)
setActionError(`${presentation.title}. ${presentation.message}`)
})
return
}
@@ -5,7 +5,7 @@ import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { ConnectionState, RpcSuccess } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { useForceReconnect } from '../transport/client-context'
import { showForceReconnectError } from '../transport/force-reconnect-feedback'
import { startForceReconnectWithFeedback } from '../transport/force-reconnect-feedback'
import {
fetchMobileGitHistory,
mapMobileCommitRows,
@@ -90,7 +90,7 @@ export const MobileGitHistoryList = memo(function MobileGitHistoryList({
// MobileSourceControlPanel / issue #5049). The load effect re-runs via
// connState once the fresh client connects.
if (connState !== 'connected' && hostId) {
void forceReconnect(hostId).catch(showForceReconnectError)
startForceReconnectWithFeedback(() => forceReconnect(hostId))
return
}
setReloadNonce((n) => n + 1)
@@ -16,7 +16,7 @@ import type { SourceControlHubTab } from './mobile-source-control-hub-tab'
import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary'
import { isMobileConflictAborting } from './mobile-source-control-conflict-abort'
import { useMobilePrSidebarController } from '../session/use-mobile-pr-sidebar-controller'
import { showForceReconnectError } from '../transport/force-reconnect-feedback'
import { startForceReconnectWithFeedback } from '../transport/force-reconnect-feedback'
import { prSidebarDetailsNeedFetch } from '../session/mobile-pr-sidebar-state'
import { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel'
import { openMobilePrUrl } from '../components/MobilePrComposeSheet'
@@ -255,7 +255,7 @@ export function MobileSourceControlPanel({
onPress={() => {
// Why: a parked reconnect loop makes retry useless — revive the connection instead (issue #5049); loadStatus re-runs on reconnect.
if (connState !== 'connected' && hostId) {
void forceReconnect(hostId).catch(showForceReconnectError)
startForceReconnectWithFeedback(() => forceReconnect(hostId))
return
}
void loadStatus()
@@ -178,8 +178,8 @@ describe('issue #10119 — what a phone shows while it cannot reach the desktop'
// The failure counter survives ws.onopen, so both connection-health gates
// (warning >= 3, unreachable >= 12) are reachable.
expect(Math.max(...samples.map((s) => s.attempts))).toBeGreaterThanOrEqual(12)
expect(samples.some((s) => s.label === "Can't connect")).toBe(true)
expect(samples.some((s) => s.label === "Can't reach desktop")).toBe(true)
expect(samples.some((s) => s.label === 'Still trying to connect')).toBe(true)
expect(samples.some((s) => s.label === 'Desktop unreachable')).toBe(true)
// Once escalated, no later redial reverts the label to "Connecting…".
const after = labelsAfterFirstEscalation(samples)
@@ -196,7 +196,7 @@ describe('issue #10119 — what a phone shows while it cannot reach the desktop'
const samples = observe('ws://relay.example:443', 900_000)
expect(Math.max(...samples.map((s) => s.attempts))).toBeGreaterThanOrEqual(12)
expect(samples.some((s) => s.label === "Can't reach desktop")).toBe(true)
expect(samples.some((s) => s.label === 'Desktop unreachable')).toBe(true)
const after = labelsAfterFirstEscalation(samples)
expect(after.every((s) => s.label !== 'Connecting…')).toBe(true)
@@ -219,14 +219,14 @@ describe('issue #10119 — what a phone shows while it cannot reach the desktop'
carrier = { kind: 'blackhole' }
const samples = observe('ws://192.168.0.56:6769', 900_000)
const firstUnreachable = samples.findIndex((s) => s.label === "Can't reach desktop")
const firstUnreachable = samples.findIndex((s) => s.label === 'Desktop unreachable')
expect(firstUnreachable).toBeGreaterThan(0)
// The loop has given up internally (attempts held at the cap); the label
// must say so through the 12s of every 90s trickle dial, not just between them.
const after = samples.slice(firstUnreachable)
expect(after.every((s) => s.attempts >= 12)).toBe(true)
expect(after.every((s) => s.label === "Can't reach desktop")).toBe(true)
expect(after.every((s) => s.label === 'Desktop unreachable')).toBe(true)
})
it('resets the failure counter only once a handshake actually completes', () => {
@@ -269,7 +269,7 @@ describe('issue #10119 — what a phone shows while it cannot reach the desktop'
const samples = observe('ws://192.168.0.56:6769', 900_000)
expect(Math.max(...samples.map((s) => s.attempts))).toBeGreaterThanOrEqual(12)
expect(samples.some((s) => s.label === "Can't reach desktop")).toBe(true)
expect(samples.some((s) => s.label === 'Desktop unreachable')).toBe(true)
const after = labelsAfterFirstEscalation(samples)
expect(after.every((s) => s.label !== 'Connecting…')).toBe(true)
@@ -279,7 +279,7 @@ describe('issue #10119 — what a phone shows while it cannot reach the desktop'
carrier = { kind: 'refused' }
const samples = observe('ws://192.168.0.56:6769', 60_000)
expect(samples.some((s) => s.label === "Can't connect")).toBe(true)
expect(samples.some((s) => s.label === 'Still trying to connect')).toBe(true)
const after = labelsAfterFirstEscalation(samples)
expect(after.every((s) => s.label !== 'Connecting…')).toBe(true)
})
+68 -8
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { classifyConnection, verdictDisplayLabel } from './connection-health'
import {
classifyConnection,
verdictDisplayLabel,
verdictSupportingMessage
} from './connection-health'
describe('classifyConnection auth-failed verdict', () => {
it('tells the user to re-pair instead of showing a generic auth error', () => {
@@ -10,7 +14,8 @@ describe('classifyConnection auth-failed verdict', () => {
nowMs: 1_000_000
})
expect(verdict.kind).toBe('auth-failed')
expect(verdictDisplayLabel(verdict)).toBe('Pairing invalid — re-pair with your desktop')
expect(verdictDisplayLabel(verdict)).toBe('Pairing no longer works')
expect(verdictSupportingMessage(verdict)).toContain('Try reconnecting once')
})
})
@@ -80,7 +85,11 @@ describe('classifyConnection while dialing (issue #10119)', () => {
it('keeps the warning verdict through a redial instead of reverting to Connecting…', () => {
for (const state of ['connecting', 'handshaking'] as const) {
const verdict = classifyConnection({ ...base, state, reconnectAttempts: 3 })
expect(verdict).toMatchObject({ kind: 'warning', label: "Can't connect" })
expect(verdict).toMatchObject({
kind: 'warning',
label: 'Still trying to connect',
reason: 'retrying'
})
}
})
@@ -113,20 +122,71 @@ describe('classifyConnection while dialing (issue #10119)', () => {
describe('verdictDisplayLabel', () => {
it('appends the hint to warning and unreachable labels', () => {
expect(
verdictDisplayLabel({ kind: 'warning', label: "Can't connect", hint: 'check Tailscale' })
).toBe("Can't connect — check Tailscale")
verdictDisplayLabel({
kind: 'warning',
label: 'Still trying to connect',
reason: 'retrying',
hint: 'check Tailscale'
})
).toBe('Still trying to connect — check Tailscale')
expect(
verdictDisplayLabel({
kind: 'unreachable',
label: "Can't reach desktop",
label: 'Desktop unreachable',
reason: 'stale',
hint: 'check Tailscale'
})
).toBe("Can't reach desktop — check Tailscale")
).toBe("Can't reach desktop through Tailscale")
})
it('returns the bare label without a hint', () => {
expect(verdictDisplayLabel({ kind: 'warning', label: "Can't connect" })).toBe("Can't connect")
expect(
verdictDisplayLabel({
kind: 'warning',
label: 'Still trying to connect',
reason: 'retrying'
})
).toBe('Still trying to connect')
expect(verdictDisplayLabel({ kind: 'normal', label: 'Connected' })).toBe('Connected')
})
})
describe('verdictSupportingMessage', () => {
it('explains automatic recovery for an unresponsive desktop', () => {
const verdict = classifyConnection({
state: 'connected',
reconnectAttempts: 4,
lastConnectedAt: 900_000,
rpcUnresponsiveSince: 999_000,
nowMs: 1_000_000
})
expect(verdictSupportingMessage(verdict)).toBe(
"The connection is open, but desktop Orca isn't answering. Orca is checking the connection and will retry automatically."
)
})
it('explains the slower background retry cadence', () => {
const verdict = classifyConnection({
state: 'reconnecting',
reconnectAttempts: 12,
lastConnectedAt: null,
nowMs: 1_000_000
})
expect(verdictSupportingMessage(verdict)).toContain('retries have slowed to save battery')
})
it('gives specific Tailscale recovery steps', () => {
const verdict = classifyConnection({
state: 'reconnecting',
reconnectAttempts: 12,
lastConnectedAt: null,
endpoint: 'ws://100.65.9.106:6768',
nowMs: 1_000_000
})
expect(verdictSupportingMessage(verdict)).toContain('Open Tailscale')
expect(verdictSupportingMessage(verdict)).toContain('Orca will keep retrying')
})
})
+46 -10
View File
@@ -2,9 +2,9 @@ import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscal
import type { ConnectionState } from './types'
// Why: thresholds for escalating connection UX from neutral
// "Reconnecting…" to alarming "host appears unreachable, re-pair?".
// "Reconnecting…" to an actionable unreachable state.
//
// - WARNING_ATTEMPTS: 3 → label flips to "Can't connect" (existing
// - WARNING_ATTEMPTS: 3 → label flips to "Still trying to connect" (existing
// behavior). Calibrated to absorb a normal laptop wake / brief
// network blip without alarming the user.
// - UNREACHABLE_ATTEMPTS: 12 → with the tiered 0.5s→60s backoff this
@@ -25,12 +25,17 @@ const STALE_SINCE_LAST_CONNECT_MS = 60_000
// Why: a repeatedly-unreachable 100.x/*.ts.net endpoint almost always means
// the phone's Tailscale tunnel is down or wedged (a known iOS failure mode
// that only a manual toggle fixes) — not that the desktop moved. Say so
// instead of leaving the user staring at a generic "Can't connect".
// instead of leaving the user staring at a generic unreachable label.
const TAILSCALE_HINT = 'check Tailscale'
export type ConnectionVerdict =
| { kind: 'normal'; label: string }
| { kind: 'warning'; label: string; hint?: string } // "Can't connect"
| {
kind: 'warning'
label: string
reason: 'unresponsive' | 'retrying'
hint?: string
}
| {
kind: 'unreachable'
label: string
@@ -59,12 +64,12 @@ export function classifyConnection(args: {
// Why: auth-failed means the desktop no longer recognizes this pairing (e.g. it
// lost its device registry) — retrying can't fix it, only re-pairing can, so say so.
if (state === 'auth-failed') {
return { kind: 'auth-failed', label: 'Pairing invalid — re-pair with your desktop' }
return { kind: 'auth-failed', label: 'Pairing no longer works' }
}
if (state === 'connected') {
if (args.rpcUnresponsiveSince != null) {
return { kind: 'warning', label: 'Connected, not responding', hint }
return { kind: 'warning', label: 'Desktop not responding', reason: 'unresponsive', hint }
}
return { kind: 'normal', label: 'Connected' }
}
@@ -81,7 +86,7 @@ export function classifyConnection(args: {
if (lastConnectedAt == null) {
return {
kind: 'unreachable',
label: "Can't reach desktop",
label: 'Desktop unreachable',
reason: 'never-connected',
hint
}
@@ -89,7 +94,7 @@ export function classifyConnection(args: {
if (now - lastConnectedAt >= STALE_SINCE_LAST_CONNECT_MS) {
return {
kind: 'unreachable',
label: "Can't reach desktop",
label: 'Desktop unreachable',
reason: 'stale',
hint
}
@@ -97,7 +102,7 @@ export function classifyConnection(args: {
}
if (reconnectAttempts >= WARNING_ATTEMPTS) {
return { kind: 'warning', label: "Can't connect", hint }
return { kind: 'warning', label: 'Still trying to connect', reason: 'retrying', hint }
}
return { kind: 'normal', label: state === 'reconnecting' ? 'Reconnecting…' : 'Connecting…' }
@@ -106,8 +111,39 @@ export function classifyConnection(args: {
// Why: single place that turns a verdict into display text so every screen
// renders the Tailscale hint the same way.
export function verdictDisplayLabel(verdict: ConnectionVerdict): string {
if ((verdict.kind === 'warning' || verdict.kind === 'unreachable') && verdict.hint) {
if (verdict.kind === 'unreachable' && verdict.hint) {
return "Can't reach desktop through Tailscale"
}
if (verdict.kind === 'warning' && verdict.hint) {
return `${verdict.label} — ${verdict.hint}`
}
return verdict.label
}
export function verdictSupportingMessage(verdict: ConnectionVerdict): string | null {
if (verdict.kind === 'auth-failed') {
return "This desktop no longer accepts your phone's pairing. Try reconnecting once; if it still fails, re-pair from the desktop."
}
if (verdict.kind === 'warning') {
if (verdict.reason === 'unresponsive') {
return "The connection is open, but desktop Orca isn't answering. Orca is checking the connection and will retry automatically."
}
if (verdict.hint) {
return 'Open Tailscale on your phone and desktop and make sure both devices are connected. Orca will keep retrying.'
}
return "Orca can't reach your desktop yet and will keep retrying. Make sure Orca is open on the desktop and both devices are online."
}
if (verdict.kind === 'unreachable') {
if (verdict.hint) {
return 'Open Tailscale on your phone and desktop and make sure both devices are connected. Orca will keep retrying.'
}
return 'Orca will keep trying in the background, but retries have slowed to save battery. Check your desktop and network, then reconnect.'
}
if (verdict.label === 'Reconnecting…') {
return 'The connection was interrupted. Orca is retrying automatically.'
}
if (verdict.label === 'Disconnected') {
return 'Orca will not reconnect until you choose Connect.'
}
return null
}
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
forceReconnectErrorPresentation,
showForceReconnectError,
startForceReconnectWithFeedback
} from './force-reconnect-feedback'
const alert = vi.hoisted(() => vi.fn())
vi.mock('react-native', () => ({ Alert: { alert } }))
describe('forceReconnectErrorPresentation', () => {
it('explains that recovery continues after an application-health timeout', () => {
expect(forceReconnectErrorPresentation(new Error('Request timed out: worktree.ps'))).toEqual({
title: "Desktop still isn't responding",
message:
'Orca opened a new connection, but the desktop did not respond within 15 seconds. Orca will keep retrying automatically.'
})
})
it('turns a replacement-open failure into a concrete next step', () => {
expect(
forceReconnectErrorPresentation(new Error('Unable to open a replacement connection'))
).toEqual({
title: "Couldn't reconnect to desktop",
message: 'Make sure desktop Orca is open and both devices are online, then try again.'
})
})
it('explains how to recover an invalid pairing', () => {
expect(
forceReconnectErrorPresentation(new Error('Unauthorized — pairing may be revoked'))
).toMatchObject({ title: 'Pairing no longer works' })
})
})
describe('showForceReconnectError', () => {
beforeEach(() => alert.mockReset())
it('offers a retry when the caller can repeat the action', () => {
const retry = vi.fn()
showForceReconnectError(new Error('Request timed out: worktree.ps'), retry)
expect(alert).toHaveBeenCalledWith(
"Desktop still isn't responding",
expect.stringContaining('keep retrying automatically'),
[
{ text: 'Dismiss', style: 'cancel' },
{ text: 'Try again', onPress: retry }
]
)
})
it('never exposes an unknown internal error', () => {
showForceReconnectError(new Error('secure store exploded'))
expect(alert).toHaveBeenCalledWith(
"Couldn't reconnect to desktop",
expect.not.stringContaining('secure store'),
[{ text: 'Dismiss', style: 'cancel' }]
)
})
it('repeats the same reconnect action from Try again', async () => {
const reconnect = vi.fn().mockRejectedValue(new Error('connection failed'))
startForceReconnectWithFeedback(reconnect)
await vi.waitFor(() => expect(alert).toHaveBeenCalledOnce())
const buttons = alert.mock.calls[0]?.[2] as { text: string; onPress?: () => void }[]
buttons.find((button) => button.text === 'Try again')?.onPress?.()
await vi.waitFor(() => expect(reconnect).toHaveBeenCalledTimes(2))
})
})
@@ -1,5 +1,46 @@
import { Alert } from 'react-native'
import { Alert, type AlertButton } from 'react-native'
export function showForceReconnectError(error: unknown): void {
Alert.alert('Unable to reconnect', error instanceof Error ? error.message : 'Please try again.')
export type ForceReconnectErrorPresentation = {
title: string
message: string
}
export function forceReconnectErrorPresentation(error: unknown): ForceReconnectErrorPresentation {
const detail = error instanceof Error ? error.message : String(error)
if (/unauthorized|pairing may be revoked/i.test(detail)) {
return {
title: 'Pairing no longer works',
message:
"This desktop no longer accepts your phone's pairing. Try reconnecting once; if it still fails, re-pair from the desktop."
}
}
if (
/Request timed out: worktree\.ps|Application RPC channel is still not responding/.test(detail)
) {
return {
title: "Desktop still isn't responding",
message:
'Orca opened a new connection, but the desktop did not respond within 15 seconds. Orca will keep retrying automatically.'
}
}
return {
title: "Couldn't reconnect to desktop",
message: 'Make sure desktop Orca is open and both devices are online, then try again.'
}
}
export function showForceReconnectError(error: unknown, onRetry?: () => void): void {
const presentation = forceReconnectErrorPresentation(error)
const buttons: AlertButton[] = [{ text: 'Dismiss', style: 'cancel' }]
if (onRetry) {
buttons.push({ text: 'Try again', onPress: onRetry })
}
Alert.alert(presentation.title, presentation.message, buttons)
}
export function startForceReconnectWithFeedback(reconnect: () => Promise<void>): void {
const attempt = () => {
void reconnect().catch((error: unknown) => showForceReconnectError(error, attempt))
}
attempt()
}
@@ -216,7 +216,7 @@ describe('foregrounding a phone that was suspended mid-dial', () => {
}
// Four failed dials is past WARNING_ATTEMPTS, so the card reads "Can't
// connect — check Tailscale" rather than a neutral "Connecting…".
expect(label(client, TAILSCALE_ENDPOINT)).toBe("Can't connect — check Tailscale")
expect(label(client, TAILSCALE_ENDPOINT)).toBe('Still trying to connect — check Tailscale')
await advanceUntilDialing(client)
const doomed = latest()
@@ -247,7 +247,7 @@ describe('foregrounding a phone that was suspended mid-dial', () => {
}
expect(client.getReconnectAttempt()).toBeGreaterThanOrEqual(12)
expect(label(client, TAILSCALE_ENDPOINT)).toBe("Can't reach desktop — check Tailscale")
expect(label(client, TAILSCALE_ENDPOINT)).toBe("Can't reach desktop through Tailscale")
// And the escalation is not sticky: the desktop comes back, the card clears.
latest().authenticate()
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { pairingErrorMessage } from './pairing-error-presentation'
describe('pairingErrorMessage', () => {
it('turns a retired publication into a recovery instruction', () => {
expect(pairingErrorMessage(new Error('host profile publication was retired'), false, 25)).toBe(
"Pairing needs to be restarted. The desktop's pairing information changed before setup finished. Open Mobile settings on the desktop and scan the latest QR code."
)
})
it('explains how to retry a pairing timeout', () => {
expect(pairingErrorMessage(new Error('timeout'), true, 25)).toBe(
"Couldn't reach your desktop within 25 seconds. Make sure Orca is open on the desktop and both devices are online, then try scanning again."
)
})
it('does not expose an unknown internal pairing error', () => {
expect(pairingErrorMessage(new Error('relay credential write failed'), false, 25)).toBe(
'Pairing could not be completed. Make sure Orca is open on the desktop and both devices are online, then try again.'
)
})
})
@@ -0,0 +1,16 @@
const RETIRED_PUBLICATION_ERROR = 'host profile publication was retired'
export function pairingErrorMessage(
error: unknown,
timedOut: boolean,
timeoutSeconds: number
): string {
if (timedOut) {
return `Couldn't reach your desktop within ${timeoutSeconds} seconds. Make sure Orca is open on the desktop and both devices are online, then try scanning again.`
}
const detail = error instanceof Error ? error.message : String(error)
if (detail === RETIRED_PUBLICATION_ERROR) {
return "Pairing needs to be restarted. The desktop's pairing information changed before setup finished. Open Mobile settings on the desktop and scan the latest QR code."
}
return 'Pairing could not be completed. Make sure Orca is open on the desktop and both devices are online, then try again.'
}
@@ -109,7 +109,7 @@ describe('direct RPC application responsiveness', () => {
lastConnectedAt: client.getLastConnectedAt(),
rpcUnresponsiveSince
})
).toMatchObject({ kind: 'warning', label: 'Connected, not responding' })
).toMatchObject({ kind: 'warning', label: 'Desktop not responding' })
const second = client
.sendRequest('browser.screenshot', {}, { timeoutMs: 100, applicationHealthProbe: true })
@@ -217,7 +217,7 @@ describe('direct RPC application responsiveness', () => {
lastConnectedAt: client.getLastConnectedAt(),
rpcUnresponsiveSince
})
).toMatchObject({ kind: 'warning', label: 'Connected, not responding' })
).toMatchObject({ kind: 'warning', label: 'Desktop not responding' })
client.close()
})