refactor(mobile): run both connection log routes on one screen

The native route inlined 245 lines of orchestration the hosted screen had
already factored behind DiagnosticsDeviceOperations, so the two paths
gathered, redacted, and submitted the same report differently. The screen
now takes the device operations, a clipboard writer, and a host picker
slot; the native route supplies the existing native operations and its
host chips.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-07 04:46:31 -04:00
parent ade5b90ba5
commit 7d8a829f3d
4 changed files with 197 additions and 242 deletions
+42 -204
View File
@@ -1,39 +1,18 @@
import { ConnectionDiagnosticsView } from '../src/diagnostics/connection-diagnostics-view'
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { View, Text, Pressable, Platform } from 'react-native'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { View, Text, Pressable } from 'react-native'
import { useLocalSearchParams, useRouter } from 'expo-router'
import * as Clipboard from 'expo-clipboard'
import Constants from 'expo-constants'
import { loadHosts } from '../src/transport/host-store'
import { connectionLogStore } from '../src/transport/persisted-connection-log-store'
import { useHostClient, useRpcClientContext } from '../src/transport/client-context'
import {
useConnectionPathStatus,
useReconnectAttempt
} from '../src/transport/client-context-connection-metrics'
import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report'
import { mobileWebDiagnosticsStore } from '../src/mobile-web/mobile-web-diagnostics-store'
import {
diagnoseConnection,
getReportableConnectionIncidentId
} from '../src/diagnostics/connection-diagnostics-analysis'
import { submitConnectionDiagnostics } from '../src/diagnostics/connection-diagnostics-submission'
import {
readHydratedConnectionLog,
readConnectionDiagnosticsSnapshot,
resolveDiagnosticsHostId,
getDiagnosticsSubmissionState,
updateDiagnosticsSubmissionState,
type DiagnosticsSubmissionStates
} from '../src/diagnostics/connection-diagnostics-screen-data'
import { useHostStatusGates } from '../src/transport/host-status-gates'
import { loadHostAppVersion } from '../src/transport/host-app-version-store'
import { ConnectionDiagnosticsScreen } from '../src/diagnostics/connection-diagnostics-screen'
import { createNativeDiagnosticsOperations } from '../src/diagnostics/native-diagnostics-operations'
import {
resolveDiagnosticsHostId,
type DiagnosticsHostSelection
} from '../src/diagnostics/connection-diagnostics-screen-data'
import { connectionDiagnosticsScreenStyles as styles } from '../src/diagnostics/connection-diagnostics-screen-styles'
import type { ConnectionLogEntry, HostProfile } from '../src/transport/types'
// Why: getSnapshot must be referentially stable when there's no data —
// a fresh [] per call would make useSyncExternalStore re-render forever.
const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = []
import type { HostProfile } from '../src/transport/types'
// 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
@@ -44,21 +23,14 @@ export default function ConnectionLogScreen() {
const params = useLocalSearchParams<{ hostId?: string }>()
const routeKey = useMemo(() => ({}), [params.hostId])
const [hosts, setHosts] = useState<HostProfile[]>([])
const [manualSelection, setManualSelection] = useState<{
hostId: string
requestedHostId: string | undefined
routeKey: object
} | null>(null)
const [copiedHostId, setCopiedHostId] = useState<string | null>(null)
const [submissionStates, setSubmissionStates] = useState<DiagnosticsSubmissionStates>({})
const [manualSelection, setManualSelection] = useState<DiagnosticsHostSelection | null>(null)
useEffect(() => {
let stale = false
void loadHosts().then((loaded) => {
if (stale) {
return
if (!stale) {
setHosts(loaded)
}
setHosts(loaded)
})
return () => {
stale = true
@@ -66,179 +38,45 @@ export default function ConnectionLogScreen() {
}, [])
const selectedId = resolveDiagnosticsHostId(hosts, params.hostId, manualSelection, routeKey)
const selected = hosts.find((h) => h.id === selectedId) ?? null
const selected = hosts.find((host) => host.id === selectedId) ?? null
const { client, state } = useHostClient(selected?.id)
const { desktopAppVersion: liveDesktopAppVersion } = useHostStatusGates({
hostId: selected?.id,
client,
connState: state
})
const reconnectAttempts = useReconnectAttempt(selected?.id)
const { activePath, pendingPath } = useConnectionPathStatus(selected?.id)
useEffect(() => {
if (selectedId) {
void readHydratedConnectionLog(connectionLogStore, selectedId)
}
}, [selectedId])
const subscribe = useCallback(
(listener: () => void) =>
selectedId ? connectionLogStore.subscribe(selectedId, listener) : () => {},
[selectedId]
// Why: refreshes the persisted desktop version the diagnostics report reads back.
useHostStatusGates({ hostId: selected?.id, client, connState: state })
const device = useMemo(
() => (selected ? createNativeDiagnosticsOperations(selected, clientContext) : null),
[selected, clientContext]
)
const getSnapshot = useCallback(
() => (selectedId ? connectionLogStore.get(selectedId) : EMPTY_ENTRIES),
[selectedId]
const select = useCallback(
(hostId: string) => setManualSelection({ hostId, requestedHostId: params.hostId, routeKey }),
[params.hostId, routeKey]
)
const entries = useSyncExternalStore(subscribe, getSnapshot)
const subscribeMobileWeb = useCallback(
(listener: () => void) => mobileWebDiagnosticsStore.subscribe(listener),
[]
)
const getMobileWebSnapshot = useCallback(
() => mobileWebDiagnosticsStore.get(selectedId),
[selectedId]
)
const mobileWebDiagnostics = useSyncExternalStore(subscribeMobileWeb, getMobileWebSnapshot)
const diagnosis = selected
? diagnoseConnection({ endpoint: selected.endpoint, state, activePath, pendingPath, entries })
: null
const incidentId = selected
? getReportableConnectionIncidentId({
endpoint: selected.endpoint,
state,
activePath,
pendingPath,
entries
})
: null
const submissionKey = selected && incidentId ? `${selected.id}:${incidentId}` : null
const submissionState = getDiagnosticsSubmissionState(submissionStates, submissionKey)
const copied = copiedHostId === selectedId
const copyDiagnostics = useCallback(async () => {
if (!selected) {
return
}
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
const snapshot = await readConnectionDiagnosticsSnapshot(
clientContext,
connectionLogStore,
selected.id
)
const report = buildConnectionDiagnosticsReport({
endpoint: selected.endpoint,
state: snapshot.state,
reconnectAttempts: snapshot.reconnectAttempts,
lastConnectedAt: snapshot.lastConnectedAt,
platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(),
appVersion: Constants.expoConfig?.version ?? 'unknown',
desktopAppVersion,
entries: snapshot.entries,
activePath: snapshot.activePath,
pendingPath: snapshot.pendingPath,
mobileWeb: mobileWebDiagnostics
})
await Clipboard.setStringAsync(report)
setCopiedHostId(selected.id)
setTimeout(() => setCopiedHostId((hostId) => (hostId === selected.id ? null : hostId)), 2000)
}, [selected, liveDesktopAppVersion, clientContext, mobileWebDiagnostics])
const sendDiagnostics = useCallback(async () => {
if (!selected || !submissionKey || submissionState === 'sending') {
return
}
const startedKey = submissionKey
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, 'sending'))
const appVersion = Constants.expoConfig?.version ?? 'unknown'
const platform = `${Platform.OS} ${Platform.Version ?? ''}`.trim()
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
const snapshot = await readConnectionDiagnosticsSnapshot(
clientContext,
connectionLogStore,
selected.id
)
const currentIncidentId = getReportableConnectionIncidentId({
endpoint: selected.endpoint,
state: snapshot.state,
activePath: snapshot.activePath,
pendingPath: snapshot.pendingPath,
entries: snapshot.entries
})
if (`${selected.id}:${currentIncidentId ?? ''}` !== startedKey) {
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, null))
return
}
const report = buildConnectionDiagnosticsReport({
endpoint: selected.endpoint,
state: snapshot.state,
reconnectAttempts: snapshot.reconnectAttempts,
lastConnectedAt: snapshot.lastConnectedAt,
platform,
appVersion,
desktopAppVersion,
entries: snapshot.entries,
activePath: snapshot.activePath,
pendingPath: snapshot.pendingPath,
mobileWeb: mobileWebDiagnostics
})
const result = await submitConnectionDiagnostics({ report, appVersion, platform })
setSubmissionStates((states) =>
updateDiagnosticsSubmissionState(states, startedKey, result.ok ? 'sent' : 'failed')
)
}, [
selected,
submissionKey,
submissionState,
liveDesktopAppVersion,
clientContext,
mobileWebDiagnostics
])
return (
<ConnectionDiagnosticsView
<ConnectionDiagnosticsScreen
key={selectedId ?? 'none'}
device={device}
hostName={selected?.name ?? null}
state={state}
reconnectAttempts={reconnectAttempts}
copied={copied}
copyDiagnostics={copyDiagnostics}
diagnosis={diagnosis}
submissionState={submissionState}
sendDiagnostics={sendDiagnostics}
entries={entries}
writeClipboard={(report) => Clipboard.setStringAsync(report)}
onBack={() => router.back()}
hostPicker={
<>
{' '}
{hosts.length > 1 && (
<View style={styles.hostPicker}>
{hosts.map((host) => (
<Pressable
key={host.id}
style={[styles.hostChip, host.id === selectedId && styles.hostChipActive]}
onPress={() =>
setManualSelection({
hostId: host.id,
requestedHostId: params.hostId,
routeKey
})
}
hosts.length > 1 ? (
<View style={styles.hostPicker}>
{hosts.map((host) => (
<Pressable
key={host.id}
style={[styles.hostChip, host.id === selectedId && styles.hostChipActive]}
onPress={() => select(host.id)}
>
<Text
style={[styles.hostChipText, host.id === selectedId && styles.hostChipTextActive]}
numberOfLines={1}
>
<Text
style={[
styles.hostChipText,
host.id === selectedId && styles.hostChipTextActive
]}
numberOfLines={1}
>
{host.name}
</Text>
</Pressable>
))}
</View>
)}
</>
{host.name}
</Text>
</Pressable>
))}
</View>
) : null
}
/>
)
+6 -3
View File
@@ -1,6 +1,6 @@
import { useRouter } from 'expo-router'
import { useMobileWebNativeShell } from '../../src/mobile-web/src/native-shell-channel'
import { HostedConnectionDiagnosticsScreen } from '../src/diagnostics/hosted-connection-diagnostics-screen'
import { ConnectionDiagnosticsScreen } from '../src/diagnostics/connection-diagnostics-screen'
export default function HostedConnectionLogRoute() {
const router = useRouter()
@@ -8,10 +8,13 @@ export default function HostedConnectionLogRoute() {
if (!shell.client) {
return null
}
const client = shell.client
return (
<HostedConnectionDiagnosticsScreen
<ConnectionDiagnosticsScreen
key={shell.context?.shellSessionId ?? 'pending'}
client={shell.client}
device={client.native.diagnosticsDevice}
hostName="Paired desktop"
writeClipboard={(report) => client.native.clipboardWrite(report)}
onBack={() => (router.canGoBack() ? router.back() : router.replace('/settings'))}
/>
)
@@ -0,0 +1,106 @@
import { createElement, useEffect } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ConnectionDiagnosticsScreen } from './connection-diagnostics-screen'
import type { DiagnosticsDeviceOperations } from './diagnostics-device-operations'
import type { DiagnosticsSnapshot } from '../../../src/shared/mobile-web/diagnostics-device-contract'
vi.mock('react-native', () => ({
View: 'View',
Text: 'Text',
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (value: unknown) => value, hairlineWidth: 1 }
}))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0 })
}))
vi.mock('expo-router', () => ({
useFocusEffect: (callback: () => void | (() => void)) => useEffect(callback, [callback])
}))
vi.mock('lucide-react-native', () => ({
ChevronLeft: 'Icon',
Copy: 'Icon',
Check: 'Icon',
Send: 'Icon'
}))
vi.mock('../components/ConnectionLog', () => ({ ConnectionLog: () => null }))
const SNAPSHOT: DiagnosticsSnapshot = {
state: 'disconnected',
reconnectAttempts: 3,
lastConnectedAt: null,
activePath: 'lan',
pendingPath: null,
endpointIsTailscale: false,
platform: 'ios 18',
appVersion: '1.0.0',
desktopAppVersion: '2.0.0',
entries: [],
mobileWeb: {}
}
let renderer: ReactTestRenderer | undefined
afterEach(() => {
act(() => renderer?.unmount())
renderer = undefined
})
function device(overrides: Partial<DiagnosticsDeviceOperations> = {}) {
return {
snapshot: vi.fn().mockResolvedValue(SNAPSHOT),
probe: vi.fn().mockResolvedValue({ reachable: true }),
submit: vi.fn().mockResolvedValue({ ok: true }),
...overrides
} as unknown as DiagnosticsDeviceOperations
}
async function mount(props: Parameters<typeof ConnectionDiagnosticsScreen>[0]) {
await act(async () => {
renderer = create(createElement(ConnectionDiagnosticsScreen, props))
})
}
function texts(): string[] {
return renderer!.root
.findAllByType('Text')
.flatMap((node) => node.children.filter((child): child is string => typeof child === 'string'))
}
describe('shared connection diagnostics screen', () => {
it('reports either host through one device operations seam', async () => {
const write = vi.fn().mockResolvedValue(undefined)
const operations = device()
await mount({
device: operations,
hostName: 'Desk',
writeClipboard: write,
onBack: () => {}
})
expect(texts()).toContain('disconnected')
expect(texts()).toContain(' · attempt 3')
expect(operations.snapshot).toHaveBeenCalled()
await act(async () => {
await renderer!.root.findAllByType('Pressable')[1]!.props.onPress()
})
expect(write).toHaveBeenCalledWith(expect.stringContaining('2.0.0'))
})
it('surfaces a device failure instead of a permanent loading state', async () => {
await mount({
device: device({ snapshot: vi.fn().mockRejectedValue(new Error('unreachable')) }),
hostName: 'Desk',
writeClipboard: vi.fn(),
onBack: () => {}
})
expect(texts().some((text) => text.includes('Could not load network diagnostics'))).toBe(true)
})
it('renders no host rather than loading when no host is selected', async () => {
await mount({ device: null, hostName: null, writeClipboard: vi.fn(), onBack: () => {} })
expect(texts()).not.toContain('Loading network diagnostics…')
})
})
@@ -1,7 +1,6 @@
import { useCallback, useState } from 'react'
import { useCallback, useState, type ReactNode } from 'react'
import { Text } from 'react-native'
import { useFocusEffect } from 'expo-router'
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
import type { DiagnosticsSnapshot } from '../../../src/shared/mobile-web/diagnostics-device-contract'
import type { ConnectionLogEntry } from '../transport/types'
import type { MobileWebDiagnosticsSnapshot } from '../mobile-web/mobile-web-diagnostics-store'
@@ -17,21 +16,40 @@ import {
type DiagnosticsSubmissionStates
} from './connection-diagnostics-screen-data'
import { connectionDiagnosticsScreenStyles as styles } from './connection-diagnostics-screen-styles'
import type { DiagnosticsDeviceOperations } from './diagnostics-device-operations'
export function HostedConnectionDiagnosticsScreen({
client,
onBack
function reportable(snapshot: DiagnosticsSnapshot) {
return {
...snapshot,
entries: snapshot.entries as ConnectionLogEntry[],
mobileWeb: snapshot.mobileWeb as MobileWebDiagnosticsSnapshot
}
}
// Why: reading the log matters most while a host is failing, so the screen
// re-polls the device instead of rendering a snapshot taken on mount.
export function ConnectionDiagnosticsScreen({
device,
hostName,
writeClipboard,
onBack,
hostPicker
}: {
client: MobileWebBridgeClient
device: DiagnosticsDeviceOperations | null
hostName: string | null
writeClipboard: (report: string) => Promise<unknown>
onBack: () => void
hostPicker?: ReactNode
}) {
const device = client.native.diagnosticsDevice
const [snapshot, setSnapshot] = useState<DiagnosticsSnapshot | null>(null)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [submissions, setSubmissions] = useState<DiagnosticsSubmissionStates>({})
useFocusEffect(
useCallback(() => {
if (!device) {
return
}
let active = true
let pending = false
const refresh = async () => {
@@ -61,44 +79,31 @@ export function HostedConnectionDiagnosticsScreen({
}
}, [device])
)
const data = snapshot
? {
...snapshot,
entries: snapshot.entries as ConnectionLogEntry[],
mobileWeb: snapshot.mobileWeb as MobileWebDiagnosticsSnapshot
}
: null
const data = snapshot ? reportable(snapshot) : null
const diagnosis = data ? diagnoseConnection(data) : null
const incident = data ? getReportableConnectionIncidentId(data) : null
const submissionState = getDiagnosticsSubmissionState(submissions, incident)
const copyDiagnostics = async () => {
if (!device) {
return
}
try {
const fresh = await device.snapshot()
await client.native.clipboardWrite(
buildConnectionDiagnosticsReport({
...fresh,
entries: fresh.entries as ConnectionLogEntry[],
mobileWeb: fresh.mobileWeb as MobileWebDiagnosticsSnapshot
})
)
await writeClipboard(buildConnectionDiagnosticsReport(reportable(await device.snapshot())))
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
setError('Could not copy the report. Try again.')
}
}
const sendDiagnostics = async () => {
if (!incident || submissionState === 'sending') {
if (!device || !incident || submissionState === 'sending') {
return
}
const started = incident
setSubmissions((states) => updateDiagnosticsSubmissionState(states, started, 'sending'))
try {
const fresh = await device.snapshot()
const reportData = {
...fresh,
entries: fresh.entries as ConnectionLogEntry[],
mobileWeb: fresh.mobileWeb as MobileWebDiagnosticsSnapshot
}
const reportData = reportable(fresh)
if (getReportableConnectionIncidentId(reportData) !== started) {
setSubmissions((states) => updateDiagnosticsSubmissionState(states, started, null))
return
@@ -117,8 +122,8 @@ export function HostedConnectionDiagnosticsScreen({
}
return (
<ConnectionDiagnosticsView
loading={!snapshot}
hostName="Paired desktop"
loading={Boolean(device) && !snapshot}
hostName={hostName}
state={snapshot?.state ?? 'disconnected'}
reconnectAttempts={snapshot?.reconnectAttempts ?? 0}
entries={data?.entries ?? []}
@@ -129,11 +134,14 @@ export function HostedConnectionDiagnosticsScreen({
sendDiagnostics={sendDiagnostics}
onBack={onBack}
hostPicker={
error ? (
<Text accessibilityRole="alert" style={styles.emptyText}>
{error}
</Text>
) : null
<>
{hostPicker}
{error ? (
<Text accessibilityRole="alert" style={styles.emptyText}>
{error}
</Text>
) : null}
</>
}
/>
)