From 3063cefe91ab8f0cb956720a82aa26d47e42830f Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 4 Aug 2026 19:51:20 -0700
Subject: [PATCH] Improve mobile connection recovery guidance
---
mobile/app/connection-log.tsx | 2 +-
mobile/app/h/[hostId]/index.tsx | 16 ++--
.../app/h/[hostId]/session/[worktreeId].tsx | 4 +-
mobile/app/index.tsx | 33 +++++++-
mobile/app/pair-confirm.tsx | 14 ++--
mobile/app/pair-scan.tsx | 7 +-
.../use-mobile-agent-history-state.ts | 4 +-
mobile/src/components/AuthFailedBanner.tsx | 3 +-
mobile/src/components/MobileHostCard.test.tsx | 33 ++++++++
mobile/src/components/MobileHostCard.tsx | 55 +++++++++++++-
mobile/src/files/MobileFileExplorerPanel.tsx | 6 +-
mobile/src/host-edit-save-flow.test.ts | 6 +-
.../use-mobile-diff-review-interactions.ts | 4 +-
.../source-control/MobileGitHistoryList.tsx | 4 +-
.../MobileSourceControlPanel.tsx | 4 +-
.../cellular-connecting-label-stall.test.ts | 14 ++--
.../src/transport/connection-health.test.ts | 76 +++++++++++++++++--
mobile/src/transport/connection-health.ts | 56 +++++++++++---
.../force-reconnect-feedback.test.ts | 73 ++++++++++++++++++
.../src/transport/force-reconnect-feedback.ts | 47 +++++++++++-
.../foreground-stale-dial-restart.test.ts | 4 +-
.../pairing-error-presentation.test.ts | 22 ++++++
.../transport/pairing-error-presentation.ts | 16 ++++
...-client-application-responsiveness.test.ts | 4 +-
24 files changed, 438 insertions(+), 69 deletions(-)
create mode 100644 mobile/src/transport/force-reconnect-feedback.test.ts
create mode 100644 mobile/src/transport/pairing-error-presentation.test.ts
create mode 100644 mobile/src/transport/pairing-error-presentation.ts
diff --git a/mobile/app/connection-log.tsx b/mobile/app/connection-log.tsx
index 61db4ac09a5..e7d68274e52 100644
--- a/mobile/app/connection-log.tsx
+++ b/mobile/app/connection-log.tsx
@@ -116,7 +116,7 @@ export default function ConnectionLogScreen() {
{state === 'connected' && rpcUnresponsiveSince != null
- ? 'Connected, not responding'
+ ? 'Desktop not responding'
: state}
{reconnectAttempts > 0 ? ` · attempt ${reconnectAttempts}` : ''}
diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx
index 34033632c66..bdc69e8fe0c 100644
--- a/mobile/app/h/[hostId]/index.tsx
+++ b/mobile/app/h/[hostId]/index.tsx
@@ -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(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 (
- void forceReconnectHost(hostId!).catch(showForceReconnectError)
- }
+ onPress={reconnectHostWithFeedback}
hitSlop={8}
>
- Reconnect
+ Reconnect now
)
})()}
@@ -1099,7 +1103,7 @@ export function HostScreen({
{connState === 'auth-failed' && (
hostId && void forceReconnectHost(hostId).catch(showForceReconnectError)}
+ onRetry={reconnectHostWithFeedback}
onRepair={() => router.push('/pair-scan')}
onRemove={() => setConfirmRemoveHost(true)}
/>
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index d09afdbe139..9e15b8d57da 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -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}
diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx
index 17165a6c9f0..e1d868e5143 100644
--- a/mobile/app/index.tsx
+++ b/mobile/app/index.tsx
@@ -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>([])
// 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 (
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
diff --git a/mobile/app/pair-confirm.tsx b/mobile/app/pair-confirm.tsx
index 0621caa4f01..36b99946727 100644
--- a/mobile/app/pair-confirm.tsx
+++ b/mobile/app/pair-confirm.tsx
@@ -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() {
)}
-
- Back to home
+ router.replace('/pair-scan')}>
+ Scan latest QR code
+
+
+ Back to home
>
diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx
index 892c1b359a1..7e5419c7e35 100644
--- a/mobile/app/pair-scan.tsx
+++ b/mobile/app/pair-scan.tsx
@@ -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
}
}
diff --git a/mobile/src/agent-history/use-mobile-agent-history-state.ts b/mobile/src/agent-history/use-mobile-agent-history-state.ts
index 5d37ff4d647..172d79d319c 100644
--- a/mobile/src/agent-history/use-mobile-agent-history-state.ts
+++ b/mobile/src/agent-history/use-mobile-agent-history-state.ts
@@ -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 })
diff --git a/mobile/src/components/AuthFailedBanner.tsx b/mobile/src/components/AuthFailedBanner.tsx
index 29be8b85150..4b0c22ab1d4 100644
--- a/mobile/src/components/AuthFailedBanner.tsx
+++ b/mobile/src/components/AuthFailedBanner.tsx
@@ -19,7 +19,8 @@ export function AuthFailedBanner({
return (
- Authentication failed — try reconnecting first; if it keeps failing, re-pair from desktop.
+ This desktop no longer accepts your phone's pairing. Try reconnecting once; if it still
+ fails, re-pair from the desktop.
{canRetry && (
diff --git a/mobile/src/components/MobileHostCard.test.tsx b/mobile/src/components/MobileHostCard.test.tsx
index a1889793982..575d9e20d11 100644
--- a/mobile/src/components/MobileHostCard.test.tsx
+++ b/mobile/src/components/MobileHostCard.test.tsx
@@ -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()
+ })
})
diff --git a/mobile/src/components/MobileHostCard.tsx b/mobile/src/components/MobileHostCard.tsx
index 79a58bf760e..1a23bcb3a76 100644
--- a/mobile/src/components/MobileHostCard.tsx
+++ b/mobile/src/components/MobileHostCard.tsx
@@ -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 (
[styles.card, pressed && styles.cardPressed]}
@@ -52,11 +54,35 @@ export function MobileHostCard(props: {
{worktreeSummary}
) : null}
+ {supportingMessage ? (
+
+ {supportingMessage}
+
+ ) : null}
{props.verdict.kind === 'unreachable' && !props.host.relay ? (
Update desktop Orca and sign in to connect from anywhere
) : null}
+ {props.statusActions?.length ? (
+
+ {props.statusActions.map((action) => (
+ {
+ event.stopPropagation()
+ action.onPress()
+ }}
+ hitSlop={8}
+ >
+ {action.label}
+
+ ))}
+
+ ) : null}
@@ -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'
}
})
diff --git a/mobile/src/files/MobileFileExplorerPanel.tsx b/mobile/src/files/MobileFileExplorerPanel.tsx
index 1f8de4e9009..95d4aa49706 100644
--- a/mobile/src/files/MobileFileExplorerPanel.tsx
+++ b/mobile/src/files/MobileFileExplorerPanel.tsx
@@ -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('')
}
>
diff --git a/mobile/src/host-edit-save-flow.test.ts b/mobile/src/host-edit-save-flow.test.ts
index dba79ee1ced..56edb77a95a 100644
--- a/mobile/src/host-edit-save-flow.test.ts
+++ b/mobile/src/host-edit-save-flow.test.ts
@@ -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())
})
diff --git a/mobile/src/session/use-mobile-diff-review-interactions.ts b/mobile/src/session/use-mobile-diff-review-interactions.ts
index 37dcc1ee0a0..ab26a559406 100644
--- a/mobile/src/session/use-mobile-diff-review-interactions.ts
+++ b/mobile/src/session/use-mobile-diff-review-interactions.ts
@@ -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
}
diff --git a/mobile/src/source-control/MobileGitHistoryList.tsx b/mobile/src/source-control/MobileGitHistoryList.tsx
index 110a2fea5d0..a5d4a96926d 100644
--- a/mobile/src/source-control/MobileGitHistoryList.tsx
+++ b/mobile/src/source-control/MobileGitHistoryList.tsx
@@ -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)
diff --git a/mobile/src/source-control/MobileSourceControlPanel.tsx b/mobile/src/source-control/MobileSourceControlPanel.tsx
index edf4e459b8e..1bb85eeb0e8 100644
--- a/mobile/src/source-control/MobileSourceControlPanel.tsx
+++ b/mobile/src/source-control/MobileSourceControlPanel.tsx
@@ -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()
diff --git a/mobile/src/transport/cellular-connecting-label-stall.test.ts b/mobile/src/transport/cellular-connecting-label-stall.test.ts
index e9a46d3b406..23741271272 100644
--- a/mobile/src/transport/cellular-connecting-label-stall.test.ts
+++ b/mobile/src/transport/cellular-connecting-label-stall.test.ts
@@ -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)
})
diff --git a/mobile/src/transport/connection-health.test.ts b/mobile/src/transport/connection-health.test.ts
index 5ac61c06a93..9354f578c51 100644
--- a/mobile/src/transport/connection-health.test.ts
+++ b/mobile/src/transport/connection-health.test.ts
@@ -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')
+ })
+})
diff --git a/mobile/src/transport/connection-health.ts b/mobile/src/transport/connection-health.ts
index 7449506bfc3..36f0de6641c 100644
--- a/mobile/src/transport/connection-health.ts
+++ b/mobile/src/transport/connection-health.ts
@@ -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
+}
diff --git a/mobile/src/transport/force-reconnect-feedback.test.ts b/mobile/src/transport/force-reconnect-feedback.test.ts
new file mode 100644
index 00000000000..1367a863212
--- /dev/null
+++ b/mobile/src/transport/force-reconnect-feedback.test.ts
@@ -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))
+ })
+})
diff --git a/mobile/src/transport/force-reconnect-feedback.ts b/mobile/src/transport/force-reconnect-feedback.ts
index 5bea5901c7e..79503a23e17 100644
--- a/mobile/src/transport/force-reconnect-feedback.ts
+++ b/mobile/src/transport/force-reconnect-feedback.ts
@@ -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 {
+ const attempt = () => {
+ void reconnect().catch((error: unknown) => showForceReconnectError(error, attempt))
+ }
+ attempt()
}
diff --git a/mobile/src/transport/foreground-stale-dial-restart.test.ts b/mobile/src/transport/foreground-stale-dial-restart.test.ts
index 8c9ebd94d7e..d7977b69d69 100644
--- a/mobile/src/transport/foreground-stale-dial-restart.test.ts
+++ b/mobile/src/transport/foreground-stale-dial-restart.test.ts
@@ -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()
diff --git a/mobile/src/transport/pairing-error-presentation.test.ts b/mobile/src/transport/pairing-error-presentation.test.ts
new file mode 100644
index 00000000000..9816fbc6af7
--- /dev/null
+++ b/mobile/src/transport/pairing-error-presentation.test.ts
@@ -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.'
+ )
+ })
+})
diff --git a/mobile/src/transport/pairing-error-presentation.ts b/mobile/src/transport/pairing-error-presentation.ts
new file mode 100644
index 00000000000..08281066e13
--- /dev/null
+++ b/mobile/src/transport/pairing-error-presentation.ts
@@ -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.'
+}
diff --git a/mobile/src/transport/rpc-client-application-responsiveness.test.ts b/mobile/src/transport/rpc-client-application-responsiveness.test.ts
index 29d2ba85c62..9dd75a4c44f 100644
--- a/mobile/src/transport/rpc-client-application-responsiveness.test.ts
+++ b/mobile/src/transport/rpc-client-application-responsiveness.test.ts
@@ -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()
})