diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx
index f47ec5910..d1c5ebff1 100644
--- a/mobile/app/_layout.tsx
+++ b/mobile/app/_layout.tsx
@@ -177,6 +177,7 @@ export default function RootLayout() {
+
diff --git a/mobile/app/connection-log.tsx b/mobile/app/connection-log.tsx
new file mode 100644
index 000000000..cc19497e0
--- /dev/null
+++ b/mobile/app/connection-log.tsx
@@ -0,0 +1,221 @@
+import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
+import { View, Text, StyleSheet, Pressable, Platform } from 'react-native'
+import { useSafeAreaInsets } from 'react-native-safe-area-context'
+import { useRouter } from 'expo-router'
+import * as Clipboard from 'expo-clipboard'
+import Constants from 'expo-constants'
+import { ChevronLeft, Copy, Check } from 'lucide-react-native'
+import { colors, spacing, typography } from '../src/theme/mobile-theme'
+import { ConnectionLog } from '../src/components/ConnectionLog'
+import { loadHosts } from '../src/transport/host-store'
+import { connectionLogStore } from '../src/transport/connection-log-buffer'
+import {
+ useHostClient,
+ useLastConnectedAt,
+ useReconnectAttempt
+} from '../src/transport/client-context'
+import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report'
+import type { ConnectionLogEntry, HostProfile } from '../src/transport/types'
+
+// Why: getSnapshot must be referentially stable when there's no data —
+// a fresh [] per call would make useSyncExternalStore re-render forever.
+const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = []
+
+// Why: reading the log is most needed while a host is failing, so this
+// screen also *acquires* the host client — opening it kicks a dial and the
+// log fills live instead of showing a stale tail.
+export default function ConnectionLogScreen() {
+ const router = useRouter()
+ const insets = useSafeAreaInsets()
+ const [hosts, setHosts] = useState([])
+ const [selectedId, setSelectedId] = useState(null)
+ const [copied, setCopied] = useState(false)
+
+ useEffect(() => {
+ let stale = false
+ void loadHosts().then((loaded) => {
+ if (stale) {
+ return
+ }
+ setHosts(loaded)
+ setSelectedId((prev) => prev ?? loaded[0]?.id ?? null)
+ })
+ return () => {
+ stale = true
+ }
+ }, [])
+
+ const selected = hosts.find((h) => h.id === selectedId) ?? null
+ const { state } = useHostClient(selected?.id)
+ const reconnectAttempts = useReconnectAttempt(selected?.id)
+ const lastConnectedAt = useLastConnectedAt(selected?.id)
+
+ const subscribe = useCallback(
+ (listener: () => void) =>
+ selectedId ? connectionLogStore.subscribe(selectedId, listener) : () => {},
+ [selectedId]
+ )
+ const getSnapshot = useCallback(
+ () => (selectedId ? connectionLogStore.get(selectedId) : EMPTY_ENTRIES),
+ [selectedId]
+ )
+ const entries = useSyncExternalStore(subscribe, getSnapshot)
+
+ const copyDiagnostics = useCallback(async () => {
+ if (!selected) {
+ return
+ }
+ const report = buildConnectionDiagnosticsReport({
+ hostName: selected.name,
+ endpoint: selected.endpoint,
+ state,
+ reconnectAttempts,
+ lastConnectedAt,
+ platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(),
+ appVersion: Constants.expoConfig?.version ?? 'unknown',
+ entries
+ })
+ await Clipboard.setStringAsync(report)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }, [selected, state, reconnectAttempts, lastConnectedAt, entries])
+
+ return (
+
+
+ router.back()}>
+
+
+ Connection log
+
+
+ {hosts.length > 1 && (
+
+ {hosts.map((host) => (
+ setSelectedId(host.id)}
+ >
+
+ {host.name}
+
+
+ ))}
+
+ )}
+
+ {selected ? (
+ <>
+
+
+ {state}
+ {reconnectAttempts > 0 ? ` · attempt ${reconnectAttempts}` : ''}
+
+ void copyDiagnostics()}>
+ {copied ? (
+
+ ) : (
+
+ )}
+ {copied ? 'Copied' : 'Copy diagnostics'}
+
+
+ {entries.length > 0 ? (
+
+ ) : (
+
+ No connection events yet this session. Events appear as the app dials this host.
+
+ )}
+ >
+ ) : (
+ No paired hosts.
+ )}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bgBase,
+ padding: spacing.lg
+ },
+ topRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: spacing.lg
+ },
+ backButton: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginRight: spacing.sm
+ },
+ heading: {
+ fontSize: 20,
+ fontWeight: '700',
+ color: colors.textPrimary
+ },
+ hostPicker: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: spacing.sm,
+ marginBottom: spacing.md
+ },
+ hostChip: {
+ paddingVertical: spacing.xs + 2,
+ paddingHorizontal: spacing.md,
+ borderRadius: 16,
+ backgroundColor: colors.bgRaised
+ },
+ hostChipActive: {
+ backgroundColor: colors.bgPanel,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle
+ },
+ hostChipText: {
+ fontSize: typography.metaSize,
+ color: colors.textSecondary,
+ maxWidth: 160
+ },
+ hostChipTextActive: {
+ color: colors.textPrimary,
+ fontWeight: '600'
+ },
+ statusRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginBottom: spacing.sm
+ },
+ statusText: {
+ fontSize: typography.metaSize,
+ color: colors.textSecondary
+ },
+ copyButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs + 2,
+ paddingVertical: spacing.xs + 2,
+ paddingHorizontal: spacing.md,
+ borderRadius: 8,
+ backgroundColor: colors.bgRaised
+ },
+ copyButtonText: {
+ fontSize: typography.metaSize,
+ fontWeight: '600',
+ color: colors.textPrimary
+ },
+ emptyText: {
+ fontSize: typography.metaSize,
+ color: colors.textMuted,
+ lineHeight: 18
+ }
+})
diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx
index e9d7a34ee..5ae53e008 100644
--- a/mobile/app/troubleshoot.tsx
+++ b/mobile/app/troubleshoot.tsx
@@ -16,6 +16,7 @@ import {
ChevronUp,
Activity,
CheckCircle2,
+ ScrollText,
XCircle,
AlertTriangle
} from 'lucide-react-native'
@@ -213,6 +214,17 @@ export default function TroubleshootScreen() {
+ [
+ styles.diagnosticButton,
+ pressed && styles.diagnosticButtonPressed
+ ]}
+ onPress={() => router.push('/connection-log')}
+ >
+
+ View connection log
+
+
{checks.length > 0 && (
{checks.map((check, i) => (
diff --git a/mobile/src/diagnostics/connection-diagnostics-report.test.ts b/mobile/src/diagnostics/connection-diagnostics-report.test.ts
new file mode 100644
index 000000000..d2326ffa0
--- /dev/null
+++ b/mobile/src/diagnostics/connection-diagnostics-report.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest'
+import { buildConnectionDiagnosticsReport } from './connection-diagnostics-report'
+
+const NOW = Date.UTC(2026, 6, 9, 22, 0, 0)
+
+describe('buildConnectionDiagnosticsReport', () => {
+ it('summarizes a failing Tailscale host with its log', () => {
+ const report = buildConnectionDiagnosticsReport({
+ hostName: 'Host 1',
+ endpoint: 'ws://100.65.9.106:6768',
+ state: 'reconnecting',
+ reconnectAttempts: 12,
+ lastConnectedAt: NOW - 5 * 60_000,
+ platform: 'ios 26.5.1',
+ appVersion: '0.0.29',
+ entries: [
+ {
+ id: 'log-1',
+ ts: NOW - 60_000,
+ level: 'error',
+ message: 'WebSocket connect timeout',
+ detail: 'No TCP/WS handshake within 12s — endpoint unreachable?'
+ }
+ ],
+ nowMs: NOW
+ })
+
+ expect(report).toContain('App: Orca Mobile 0.0.29 · ios 26.5.1')
+ expect(report).toContain('Endpoint: 100.65.9.106:6768 (Tailscale)')
+ expect(report).toContain('State: reconnecting (reconnect attempts: 12)')
+ expect(report).toContain('(5m 0s ago)')
+ expect(report).toContain(
+ '[error] WebSocket connect timeout — No TCP/WS handshake within 12s — endpoint unreachable?'
+ )
+ })
+
+ it('marks never-connected sessions and empty logs', () => {
+ const report = buildConnectionDiagnosticsReport({
+ hostName: 'Host 2',
+ endpoint: 'ws://192.168.1.50:6768',
+ state: 'connecting',
+ reconnectAttempts: 0,
+ lastConnectedAt: null,
+ platform: 'android 15',
+ appVersion: '0.0.29',
+ entries: [],
+ nowMs: NOW
+ })
+
+ expect(report).toContain('Endpoint: 192.168.1.50:6768')
+ expect(report).not.toContain('(Tailscale)')
+ expect(report).toContain('Last connected: never this session')
+ expect(report).toContain('No connection events recorded this session.')
+ })
+})
diff --git a/mobile/src/diagnostics/connection-diagnostics-report.ts b/mobile/src/diagnostics/connection-diagnostics-report.ts
new file mode 100644
index 000000000..c762c6979
--- /dev/null
+++ b/mobile/src/diagnostics/connection-diagnostics-report.ts
@@ -0,0 +1,58 @@
+import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
+import type { ConnectionLogEntry, ConnectionState } from '../transport/types'
+import { formatEndpoint } from './host-reachability'
+
+// Why: one shareable text blob answering everything we historically had to
+// ask reporters one message at a time (endpoint type, state, attempt count,
+// last-connected, versions, and the reconnect lifecycle log).
+export function buildConnectionDiagnosticsReport(args: {
+ hostName: string
+ endpoint: string
+ state: ConnectionState
+ reconnectAttempts: number
+ lastConnectedAt: number | null
+ platform: string
+ appVersion: string
+ entries: readonly ConnectionLogEntry[]
+ nowMs?: number
+}): string {
+ const now = args.nowMs ?? Date.now()
+ const lines: string[] = []
+ lines.push('Orca Mobile connection diagnostics')
+ lines.push(`Generated: ${new Date(now).toISOString()}`)
+ lines.push(`App: Orca Mobile ${args.appVersion} · ${args.platform}`)
+ lines.push(`Host: ${args.hostName}`)
+ lines.push(
+ `Endpoint: ${formatEndpoint(args.endpoint)}${isTailscaleEndpoint(args.endpoint) ? ' (Tailscale)' : ''}`
+ )
+ lines.push(`State: ${args.state} (reconnect attempts: ${args.reconnectAttempts})`)
+ lines.push(
+ args.lastConnectedAt == null
+ ? 'Last connected: never this session'
+ : `Last connected: ${new Date(args.lastConnectedAt).toISOString()} (${formatAgo(now - args.lastConnectedAt)} ago)`
+ )
+ lines.push('')
+ if (args.entries.length === 0) {
+ lines.push('No connection events recorded this session.')
+ } else {
+ lines.push(`Connection log (${args.entries.length} events, oldest first):`)
+ for (const entry of args.entries) {
+ const detail = entry.detail ? ` — ${entry.detail}` : ''
+ lines.push(`${new Date(entry.ts).toISOString()} [${entry.level}] ${entry.message}${detail}`)
+ }
+ }
+ return lines.join('\n')
+}
+
+function formatAgo(ms: number): string {
+ const seconds = Math.max(0, Math.round(ms / 1000))
+ if (seconds < 60) {
+ return `${seconds}s`
+ }
+ const minutes = Math.floor(seconds / 60)
+ if (minutes < 60) {
+ return `${minutes}m ${seconds % 60}s`
+ }
+ const hours = Math.floor(minutes / 60)
+ return `${hours}h ${minutes % 60}m`
+}
diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx
index 2b8461c58..41f1b5092 100644
--- a/mobile/src/transport/client-context.tsx
+++ b/mobile/src/transport/client-context.tsx
@@ -20,6 +20,7 @@ import {
type ReactNode
} from 'react'
import { connect, type RpcClient } from './rpc-client'
+import { connectionLogStore } from './connection-log-buffer'
import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers'
import { loadHosts } from './host-store'
import type { ConnectionState, HostProfile } from './types'
@@ -150,7 +151,12 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
let client: RpcClient
try {
- client = connect(host.endpoint, host.deviceToken, host.publicKeyB64)
+ client = connect(host.endpoint, host.deviceToken, host.publicKeyB64, {
+ // Why: retain reconnect lifecycle events for the Connection Log
+ // screen — without this the reasons a host is stuck live only in
+ // console.log, which users can't see or share.
+ onLog: (entry) => connectionLogStore.append(hostId, entry)
+ })
} catch {
// Why: connect() can throw synchronously if the public key is
// malformed or the endpoint URL is invalid. Notify so the UI
diff --git a/mobile/src/transport/connection-log-buffer.test.ts b/mobile/src/transport/connection-log-buffer.test.ts
new file mode 100644
index 000000000..e7ce66253
--- /dev/null
+++ b/mobile/src/transport/connection-log-buffer.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it, vi } from 'vitest'
+import { createConnectionLogStore } from './connection-log-buffer'
+import type { ConnectionLogEntry } from './types'
+
+function entry(id: number): ConnectionLogEntry {
+ return { id: `log-${id}`, ts: 1_000 + id, level: 'info', message: `event ${id}` }
+}
+
+describe('connection log buffer', () => {
+ it('keeps entries per host without cross-talk', () => {
+ const store = createConnectionLogStore()
+ store.append('host-a', entry(1))
+ store.append('host-b', entry(2))
+
+ expect(store.get('host-a').map((e) => e.id)).toEqual(['log-1'])
+ expect(store.get('host-b').map((e) => e.id)).toEqual(['log-2'])
+ })
+
+ it('drops the oldest entries past the cap', () => {
+ const store = createConnectionLogStore(3)
+ for (let i = 1; i <= 5; i++) {
+ store.append('host-a', entry(i))
+ }
+
+ expect(store.get('host-a').map((e) => e.id)).toEqual(['log-3', 'log-4', 'log-5'])
+ })
+
+ it('returns a stable snapshot reference until the next append', () => {
+ const store = createConnectionLogStore()
+ store.append('host-a', entry(1))
+
+ const first = store.get('host-a')
+ expect(store.get('host-a')).toBe(first)
+
+ store.append('host-a', entry(2))
+ expect(store.get('host-a')).not.toBe(first)
+ // Empty hosts must also be referentially stable (useSyncExternalStore).
+ expect(store.get('host-b')).toBe(store.get('host-b'))
+ })
+
+ it('notifies only the host being appended to and stops after unsubscribe', () => {
+ const store = createConnectionLogStore()
+ const onA = vi.fn()
+ const onB = vi.fn()
+ const unsubA = store.subscribe('host-a', onA)
+ store.subscribe('host-b', onB)
+
+ store.append('host-a', entry(1))
+ expect(onA).toHaveBeenCalledTimes(1)
+ expect(onB).not.toHaveBeenCalled()
+
+ unsubA()
+ store.append('host-a', entry(2))
+ expect(onA).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/mobile/src/transport/connection-log-buffer.ts b/mobile/src/transport/connection-log-buffer.ts
new file mode 100644
index 000000000..1b223ad8e
--- /dev/null
+++ b/mobile/src/transport/connection-log-buffer.ts
@@ -0,0 +1,84 @@
+import type { ConnectionLogEntry } from './types'
+
+// Why: the rpc-client's onLog entries were only wired during pairing; for
+// long-lived host connections everything went to console.log, invisible to
+// users. This buffer retains the recent lifecycle events per host so a
+// "Connection log" screen (and copy-diagnostics) can show why a connection
+// is stuck without a debug build. Module-level so the log survives client
+// swaps (forceReconnect) and provider remounts (hot reload); bounded so an
+// all-night reconnect loop can't grow memory unbounded.
+const MAX_ENTRIES_PER_HOST = 200
+
+export type ConnectionLogStore = {
+ append: (hostId: string, entry: ConnectionLogEntry) => void
+ get: (hostId: string) => readonly ConnectionLogEntry[]
+ subscribe: (hostId: string, listener: () => void) => () => void
+}
+
+export function createConnectionLogStore(
+ maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST
+): ConnectionLogStore {
+ const entriesByHost = new Map()
+ const listenersByHost = new Map void>>()
+ // Why: useSyncExternalStore compares snapshots by reference — getSnapshot
+ // must return the SAME array until the data actually changes, or React
+ // loops re-rendering. Cache per host; invalidate on append.
+ const snapshotByHost = new Map()
+ const EMPTY: readonly ConnectionLogEntry[] = []
+
+ return {
+ append(hostId, entry) {
+ let entries = entriesByHost.get(hostId)
+ if (!entries) {
+ entries = []
+ entriesByHost.set(hostId, entries)
+ }
+ entries.push(entry)
+ if (entries.length > maxEntriesPerHost) {
+ entries.splice(0, entries.length - maxEntriesPerHost)
+ }
+ snapshotByHost.delete(hostId)
+ const listeners = listenersByHost.get(hostId)
+ if (listeners) {
+ for (const listener of listeners) {
+ listener()
+ }
+ }
+ },
+
+ get(hostId) {
+ const cached = snapshotByHost.get(hostId)
+ if (cached) {
+ return cached
+ }
+ const entries = entriesByHost.get(hostId)
+ if (!entries || entries.length === 0) {
+ return EMPTY
+ }
+ const snapshot = Object.freeze([...entries])
+ snapshotByHost.set(hostId, snapshot)
+ return snapshot
+ },
+
+ subscribe(hostId, listener) {
+ let listeners = listenersByHost.get(hostId)
+ if (!listeners) {
+ listeners = new Set()
+ listenersByHost.set(hostId, listeners)
+ }
+ listeners.add(listener)
+ return () => {
+ const set = listenersByHost.get(hostId)
+ if (!set) {
+ return
+ }
+ set.delete(listener)
+ if (set.size === 0) {
+ listenersByHost.delete(hostId)
+ }
+ }
+ }
+ }
+}
+
+export const connectionLogStore = createConnectionLogStore()