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() })