mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 08:02:37 +00:00
fix(mobile): unreachable UX cleanup + ws diagnostics
- Drop the unreachable banner (Re-pair / Retry / Remove) from the worktree-list view; the same actions are reachable from the host action sheet, and the home screen surfaces the verdict text per host. - Header now shows just the status dot + Reconnect button (when applicable). The dot color reflects the verdict severity (red for unreachable / auth-failed, amber for warning / reconnecting) so it matches the home-screen label color. - Resume + Account-usage cards only render for currently-connected hosts. Showing stale cached data for a disconnected host implied live data; now both sections drop out and reappear when the host reconnects. - Drop the Resume skeleton — Account usage handles absence by simply not rendering, Resume now does the same. Less asymmetric UX. - rpc-client: inline rich diagnostic logs in ws.onclose / ws.onerror (constructToCloseMs, aliveMs, inboundIdleMs, eventKeys, eventStr) + connection-construction context (wsCount, msSinceLastConnected, msSinceLastClose, msSinceLastInbound) so we can distinguish the intermittent 'force-quit-fixes-it' failure modes (RN/OkHttp process-state poisoning vs router NAT wedge vs transient blip). Helpers are inlined per-handler, immune to hot-reload closure races. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -38,7 +38,6 @@ import {
|
||||
} from '../../../src/transport/client-context'
|
||||
import {
|
||||
classifyConnection,
|
||||
unreachableHint,
|
||||
type ConnectionVerdict
|
||||
} from '../../../src/transport/connection-health'
|
||||
import type { RpcSuccess } from '../../../src/transport/types'
|
||||
@@ -691,42 +690,45 @@ export default function HostScreen() {
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
<View style={styles.hostIdentity}>
|
||||
<StatusDot state={connState} />
|
||||
<Text style={styles.hostNameText} numberOfLines={1}>
|
||||
{hostName || 'Host'}
|
||||
</Text>
|
||||
</View>
|
||||
{connState !== 'connected' &&
|
||||
(() => {
|
||||
const verdict = classifyConnection({
|
||||
state: connState,
|
||||
reconnectAttempts,
|
||||
lastConnectedAt
|
||||
})
|
||||
const isError = isErrorVerdict(verdict)
|
||||
const showReconnectButton =
|
||||
isError &&
|
||||
hostId &&
|
||||
verdict.kind !== 'auth-failed' &&
|
||||
verdict.kind !== 'unreachable'
|
||||
return (
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={[styles.statusText, isError && { color: colors.statusRed }]}>
|
||||
{verdict.label}
|
||||
{(() => {
|
||||
const headerVerdict = classifyConnection({
|
||||
state: connState,
|
||||
reconnectAttempts,
|
||||
lastConnectedAt
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<View style={styles.hostIdentity}>
|
||||
<StatusDot state={connState} verdict={headerVerdict} />
|
||||
<Text style={styles.hostNameText} numberOfLines={1}>
|
||||
{hostName || 'Host'}
|
||||
</Text>
|
||||
{showReconnectButton && (
|
||||
<Pressable
|
||||
style={styles.reconnectButton}
|
||||
onPress={() => void forceReconnectHost(hostId!)}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Text style={styles.reconnectButtonText}>Reconnect</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
})()}
|
||||
{connState !== 'connected' &&
|
||||
(() => {
|
||||
// Why: status label removed in favor of just the dot +
|
||||
// Reconnect button — the home screen already surfaces the
|
||||
// verdict text per host, and the dot color already
|
||||
// signals severity here. Auth-failed routes through its
|
||||
// dedicated banner so we still want to suppress the
|
||||
// Reconnect button for that case.
|
||||
const verdict = headerVerdict
|
||||
const isError = isErrorVerdict(verdict)
|
||||
const showReconnectButton = isError && hostId && verdict.kind !== 'auth-failed'
|
||||
if (!showReconnectButton) return null
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.reconnectButton}
|
||||
onPress={() => void forceReconnectHost(hostId!)}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Text style={styles.reconnectButtonText}>Reconnect</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</View>
|
||||
|
||||
{/* Filter/sort/group toolbar */}
|
||||
@@ -811,41 +813,6 @@ export default function HostScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Unreachable banner: surfaces the "host moved / network changed"
|
||||
case after enough hard-fail reconnects. We keep retrying in the
|
||||
background, but the user gets an actionable affordance instead
|
||||
of an indefinite "Reconnecting…". */}
|
||||
{connState !== 'auth-failed' &&
|
||||
(() => {
|
||||
const verdict = classifyConnection({
|
||||
state: connState,
|
||||
reconnectAttempts,
|
||||
lastConnectedAt
|
||||
})
|
||||
if (verdict.kind !== 'unreachable') return null
|
||||
return (
|
||||
<View style={styles.authBanner}>
|
||||
<Text style={styles.authBannerText}>{unreachableHint(verdict.reason)}</Text>
|
||||
<View style={styles.authActions}>
|
||||
<Pressable style={styles.authAction} onPress={() => router.push('/pair-scan')}>
|
||||
<Text style={styles.authActionText}>Re-pair</Text>
|
||||
</Pressable>
|
||||
{hostId && (
|
||||
<Pressable
|
||||
style={styles.authAction}
|
||||
onPress={() => void forceReconnectHost(hostId)}
|
||||
>
|
||||
<Text style={styles.authActionText}>Retry</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable style={styles.authAction} onPress={() => setConfirmRemoveHost(true)}>
|
||||
<Text style={[styles.authActionText, { color: colors.statusRed }]}>Remove</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Search bar */}
|
||||
{showSearch && (
|
||||
<View style={styles.searchBar}>
|
||||
@@ -1212,15 +1179,6 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
statusText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
reconnectButton: {
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
|
||||
+11
-53
@@ -446,12 +446,16 @@ export default function HomeScreen() {
|
||||
// tap target is still the same and a fresher snapshot from the live RPC
|
||||
// overwrites the card's contents in place when it lands.
|
||||
const resumeWorktree = useMemo(() => {
|
||||
if (lastVisited && sortedHosts.some((h) => h.id === lastVisited.hostId)) {
|
||||
// Why: only surface Resume for hosts that are currently connected.
|
||||
// Showing a stale cached worktree for a disconnected host is
|
||||
// misleading — the user would tap into a session route that can't
|
||||
// load anything until the host reconnects. Once the host reconnects,
|
||||
// the card reappears with fresh data.
|
||||
if (lastVisited && hostStates[lastVisited.hostId] === 'connected') {
|
||||
const cached = getCachedWorktrees(lastVisited.hostId) as WorktreeSummary[] | null
|
||||
const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId)
|
||||
if (match) return { hostId: lastVisited.hostId, worktree: match }
|
||||
}
|
||||
// Prefer a currently-connected host's data when we have it.
|
||||
for (const host of sortedHosts) {
|
||||
if (hostStates[host.id] !== 'connected') continue
|
||||
const info = worktreeInfo[host.id]
|
||||
@@ -459,39 +463,16 @@ export default function HomeScreen() {
|
||||
return { hostId: host.id, worktree: info.lastActiveWorktree }
|
||||
}
|
||||
}
|
||||
// Fall back to whichever known host has cached data, regardless of
|
||||
// current connection state.
|
||||
for (const host of sortedHosts) {
|
||||
const info = worktreeInfo[host.id]
|
||||
if (info?.lastActiveWorktree) {
|
||||
return { hostId: host.id, worktree: info.lastActiveWorktree }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [sortedHosts, hostStates, worktreeInfo, lastVisited])
|
||||
|
||||
const resumeLoading = useMemo(
|
||||
() =>
|
||||
sortedHosts.some((host) => {
|
||||
const state = hostStates[host.id] ?? 'connecting'
|
||||
return (
|
||||
state === 'connecting' ||
|
||||
state === 'handshaking' ||
|
||||
state === 'reconnecting' ||
|
||||
(state === 'connected' && !worktreeInfo[host.id])
|
||||
)
|
||||
}),
|
||||
[sortedHosts, hostStates, worktreeInfo]
|
||||
)
|
||||
|
||||
// Why: only show the Account usage section for hosts that have at least
|
||||
// one Claude or Codex account configured. Render whenever cached data
|
||||
// exists, regardless of current connection state, so the cards don't
|
||||
// disappear for ~1s on resume while the WebSocket reconnects. Streamed
|
||||
// updates from the live RPC overwrite the snapshot in place when ready.
|
||||
// Why: only show the Account usage section for hosts that are currently
|
||||
// connected. Showing stale cached usage for a disconnected host implies
|
||||
// live data; better to hide until the host reconnects and we can refresh.
|
||||
const accountsHosts = useMemo(() => {
|
||||
const items: Array<{ host: HostProfile; snapshot: AccountsSnapshot }> = []
|
||||
for (const host of sortedHosts) {
|
||||
if (hostStates[host.id] !== 'connected') continue
|
||||
const snap = accountsByHost[host.id]
|
||||
if (!snap) continue
|
||||
const hasClaude = snap.claude.accounts.length > 0
|
||||
@@ -660,7 +641,7 @@ export default function HomeScreen() {
|
||||
{item.name}
|
||||
</Text>
|
||||
<View style={styles.hostMeta}>
|
||||
<StatusDot state={state} />
|
||||
<StatusDot state={state} verdict={verdict} />
|
||||
<Text style={[styles.hostMetaItem, isError && { color: colors.statusRed }]}>
|
||||
{verdict.label}
|
||||
{connected && info
|
||||
@@ -711,17 +692,6 @@ export default function HomeScreen() {
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</>
|
||||
) : hosts.length > 0 && resumeLoading ? (
|
||||
<>
|
||||
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Resume</Text>
|
||||
<View style={styles.resumeCard}>
|
||||
<View style={[styles.resumeIcon, styles.skeletonBlock]} />
|
||||
<View style={styles.resumeMain}>
|
||||
<View style={[styles.skeletonLine, { width: '55%' }]} />
|
||||
<View style={[styles.skeletonLine, { width: '35%', marginTop: 6 }]} />
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* ─── Account usage ─── */}
|
||||
@@ -1198,18 +1168,6 @@ const styles = StyleSheet.create({
|
||||
marginTop: 4
|
||||
},
|
||||
|
||||
/* ─── Skeleton ─── */
|
||||
skeletonBlock: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
opacity: 0.5
|
||||
},
|
||||
skeletonLine: {
|
||||
height: 12,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.bgRaised,
|
||||
opacity: 0.5
|
||||
},
|
||||
|
||||
/* ─── Quick actions ─── */
|
||||
quickActions: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import type { ConnectionVerdict } from '../transport/connection-health'
|
||||
|
||||
const stateColors: Record<ConnectionState, string> = {
|
||||
connected: colors.statusGreen,
|
||||
@@ -11,8 +12,25 @@ const stateColors: Record<ConnectionState, string> = {
|
||||
'auth-failed': colors.statusRed
|
||||
}
|
||||
|
||||
export function StatusDot({ state }: { state: ConnectionState }) {
|
||||
return <View style={[styles.dot, { backgroundColor: stateColors[state] ?? colors.textMuted }]} />
|
||||
// Why: when caller passes a verdict, the dot color reflects the verdict's
|
||||
// severity instead of the raw transport state. This avoids the "amber dot
|
||||
// next to red 'Can't reach desktop' label" mismatch — the underlying
|
||||
// transport is still 'reconnecting' (amber) but the user-visible meaning
|
||||
// has escalated to error (red).
|
||||
export function StatusDot({
|
||||
state,
|
||||
verdict
|
||||
}: {
|
||||
state: ConnectionState
|
||||
verdict?: ConnectionVerdict
|
||||
}) {
|
||||
const color =
|
||||
verdict?.kind === 'unreachable' || verdict?.kind === 'auth-failed'
|
||||
? colors.statusRed
|
||||
: verdict?.kind === 'warning'
|
||||
? colors.statusAmber
|
||||
: (stateColors[state] ?? colors.textMuted)
|
||||
return <View style={[styles.dot, { backgroundColor: color }]} />
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
||||
@@ -88,6 +88,18 @@ export function connect(
|
||||
let activityProbeTimer: ReturnType<typeof setInterval> | null = null
|
||||
let intentionallyClosed = false
|
||||
let lastConnectedAt: number | null = null
|
||||
// Why: diagnostic — when the rpc-client gets stuck in a state where every
|
||||
// openConnection fails with code 1006 and only a force-quit recovers, we
|
||||
// need to see whether (a) the new attempts even differ from the old ones,
|
||||
// (b) anything is happening at the OS / RN-bridge layer between attempts,
|
||||
// and (c) what the timing pattern is (instant 1006 = port closed / route
|
||||
// dead, slow 1006 = packet drop / timeout). These three timestamps + the
|
||||
// ws-construction counter are the cheapest visibility into RN/OkHttp
|
||||
// process-state poisoning hypotheses.
|
||||
let lastInboundAt: number | null = null
|
||||
let lastWsClosedAt: number | null = null
|
||||
let wsConstructionCounter = 0
|
||||
let currentWsOpenedAt: number | null = null
|
||||
|
||||
// Why: fresh ephemeral keypair per connection provides forward secrecy.
|
||||
// The shared key is derived from our ephemeral secret + server's static public key.
|
||||
@@ -160,13 +172,25 @@ export function connect(
|
||||
function openConnection() {
|
||||
if (intentionallyClosed) return
|
||||
|
||||
const now = Date.now()
|
||||
wsConstructionCounter++
|
||||
console.log('[net] openConnection', {
|
||||
attempt: reconnectAttempt,
|
||||
endpoint: redactedEndpoint(endpoint)
|
||||
endpoint: redactedEndpoint(endpoint),
|
||||
// Why: process-poisoning diagnostic. If wsCount is high (e.g. >50)
|
||||
// and every recent open fails with 1006, suspect RN/OkHttp internal
|
||||
// pool corruption that only force-quit clears. Compare msSinceLast*
|
||||
// values to the failure cadence: instant repeated fails with no
|
||||
// inbound traffic between them = process-state stuck.
|
||||
wsCount: wsConstructionCounter,
|
||||
msSinceLastConnected: lastConnectedAt != null ? now - lastConnectedAt : null,
|
||||
msSinceLastClose: lastWsClosedAt != null ? now - lastWsClosedAt : null,
|
||||
msSinceLastInbound: lastInboundAt != null ? now - lastInboundAt : null
|
||||
})
|
||||
setState('connecting')
|
||||
sharedKey = null
|
||||
|
||||
currentWsOpenedAt = now
|
||||
ws = new WebSocket(endpoint)
|
||||
const openingWs = ws
|
||||
|
||||
@@ -215,6 +239,9 @@ export function connect(
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Why: track last-inbound for the openConnection diagnostic. Server
|
||||
// pongs and stream events both bump this — anything from the wire.
|
||||
lastInboundAt = Date.now()
|
||||
const raw = typeof event.data === 'string' ? event.data : String(event.data)
|
||||
|
||||
// Why: during handshaking, e2ee_ready is plaintext because it precedes
|
||||
@@ -335,6 +362,45 @@ export function connect(
|
||||
|
||||
ws.onclose = (event) => {
|
||||
const e = event as { code?: number; reason?: string; wasClean?: boolean } | undefined
|
||||
const closeAt = Date.now()
|
||||
// Why: time-since-construct distinguishes failure modes. Instant
|
||||
// close (<300ms) = TCP RST / port closed / route unreachable / RN
|
||||
// synchronous reject. Mid (300ms–3s) = DNS/connect attempt + reset.
|
||||
// Slow (>3s) = TCP SYN timeout / packet loss / NAT wedge. If an
|
||||
// entire reconnect burst is all instant, the problem is local
|
||||
// process state or routing, not packet loss.
|
||||
const constructToCloseMs = currentWsOpenedAt != null ? closeAt - currentWsOpenedAt : null
|
||||
const aliveMs =
|
||||
currentWsOpenedAt != null && state === 'connected' ? closeAt - currentWsOpenedAt : null
|
||||
const inboundIdleMs = lastInboundAt != null ? closeAt - lastInboundAt : null
|
||||
// Why: inline the diagnostic dump. Earlier hot-reload tripped
|
||||
// `Property 'enumKeys' doesn't exist` because a stale closure
|
||||
// captured a half-loaded module. Inlining keeps the handler's
|
||||
// behavior fully decided at construction time.
|
||||
let closeEventKeys: string[] = []
|
||||
let closeEventStr = ''
|
||||
try {
|
||||
closeEventKeys = event && typeof event === 'object' ? Object.keys(event as object) : []
|
||||
} catch {
|
||||
closeEventKeys = []
|
||||
}
|
||||
try {
|
||||
const seen = new WeakSet<object>()
|
||||
closeEventStr = JSON.stringify(
|
||||
event,
|
||||
(_k, v) => {
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
if (seen.has(v as object)) return '[circular]'
|
||||
seen.add(v as object)
|
||||
}
|
||||
if (typeof v === 'function') return '[fn]'
|
||||
return v
|
||||
},
|
||||
0
|
||||
).slice(0, 500)
|
||||
} catch {
|
||||
closeEventStr = '[unstringifiable]'
|
||||
}
|
||||
console.log('[net] ws.onclose', {
|
||||
code: e?.code,
|
||||
reason: e?.reason,
|
||||
@@ -342,8 +408,15 @@ export function connect(
|
||||
state,
|
||||
attempt: reconnectAttempt,
|
||||
intentionallyClosed,
|
||||
endpoint: redactedEndpoint(endpoint)
|
||||
endpoint: redactedEndpoint(endpoint),
|
||||
constructToCloseMs,
|
||||
aliveMs,
|
||||
inboundIdleMs,
|
||||
eventKeys: closeEventKeys,
|
||||
eventStr: closeEventStr
|
||||
})
|
||||
lastWsClosedAt = closeAt
|
||||
currentWsOpenedAt = null
|
||||
handleSocketClosed(openingWs)
|
||||
}
|
||||
|
||||
@@ -352,10 +425,37 @@ export function connect(
|
||||
// onclose fires right after, but logging the error message gives us
|
||||
// the original cause that the close code alone can hide.
|
||||
const e = event as { message?: string } | undefined
|
||||
// Why: inlined defensively — see ws.onclose comment.
|
||||
let errEventKeys: string[] = []
|
||||
let errEventStr = ''
|
||||
try {
|
||||
errEventKeys = event && typeof event === 'object' ? Object.keys(event as object) : []
|
||||
} catch {
|
||||
errEventKeys = []
|
||||
}
|
||||
try {
|
||||
const seen = new WeakSet<object>()
|
||||
errEventStr = JSON.stringify(
|
||||
event,
|
||||
(_k, v) => {
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
if (seen.has(v as object)) return '[circular]'
|
||||
seen.add(v as object)
|
||||
}
|
||||
if (typeof v === 'function') return '[fn]'
|
||||
return v
|
||||
},
|
||||
0
|
||||
).slice(0, 500)
|
||||
} catch {
|
||||
errEventStr = '[unstringifiable]'
|
||||
}
|
||||
console.log('[net] ws.onerror', {
|
||||
message: e?.message,
|
||||
state,
|
||||
attempt: reconnectAttempt
|
||||
attempt: reconnectAttempt,
|
||||
eventKeys: errEventKeys,
|
||||
eventStr: errEventStr
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user