refactor(comments): slim verbose comments in mobile (#9547)

Collapse multi-line explanatory comment blocks into single-line "why" statements
per AGENTS.md ("Document the Why, Briefly"): drop restatements of the code and
mechanism narration; keep the non-obvious reason, external refs, and directives.

Comments-only — verified no code changed via a Babel/esbuild comment-strip
token-equality gate against origin/main; typecheck and oxlint clean.

Area: mobile. 11 files changed, 339 insertions(+), 1137 deletions(-).

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-07-20 03:18:35 -07:00 committed by GitHub
parent 0f7250879a
commit c6f0ac4040
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 339 additions and 1137 deletions

View File

@ -108,14 +108,9 @@ function isErrorVerdict(v: ConnectionVerdict): boolean {
const REPO_METADATA_REFRESH_MS = 60_000
type HostScreenProps = {
// Why: when true, this worktree list is rendered as the persistent tablet
// sidebar by the host layout rather than as its own routed screen. That
// swaps the back button for a hide-sidebar control, drives data fetching
// from a plain mount effect (the sidebar is never the "focused" route), and
// opens sessions into the detail pane instead of pushing a new full screen.
// When true, rendered as the persistent tablet sidebar by the host layout, not as its own routed screen.
embedded?: boolean
// Route params aren't in scope when rendered from the layout, so the caller
// passes hostId/action explicitly; falls back to the local route params.
// Route params aren't in scope when rendered from the layout, so the caller passes these explicitly.
hostId?: string
action?: string
onHideSidebar?: () => void
@ -133,16 +128,12 @@ export function HostScreen({
const router = useRouter()
const pathname = usePathname()
const insets = useSafeAreaInsets()
// Why: cap and center the worktree list on wide/tablet canvases; on phones
// isWideLayout is false so the list stays edge-to-edge as before. When
// embedded as the sidebar the list already lives in a narrow pane, so the
// cap is skipped (see the SectionList contentContainerStyle below).
// Why: cap and center the list on wide/tablet canvases; on phones isWideLayout is false so it stays edge-to-edge.
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
const [initialCache] = useState(() =>
hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
)
// Why: shared client per host owned by RpcClientProvider. See
// docs/mobile-shared-client-per-host.md.
// Shared client per host owned by RpcClientProvider. See docs/mobile-shared-client-per-host.md.
const { client, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
@ -156,9 +147,7 @@ export function HostScreen({
const forceReconnectHost = useForceReconnect()
const [worktrees, setWorktrees] = useState<Worktree[]>(initialCache ?? [])
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
// Why: opening a worktree activates it on the host, but the active-row
// highlight otherwise waits for the next worktree.ps poll to reflect it.
// Track the locally-opened worktree so the highlight moves instantly.
// Why: track the locally-opened worktree so the active-row highlight moves instantly instead of waiting for the next poll.
const [optimisticActiveWorktreeId, setOptimisticActiveWorktreeId] = useState<string | null>(null)
// One tick drives every visible agent row's relative timestamp.
const now = useNow(30_000)
@ -180,9 +169,7 @@ export function HostScreen({
const [workspaceStatuses, setWorkspaceStatuses] = useState<readonly WorkspaceStatusDefinition[]>(
DEFAULT_MOBILE_WORKSPACE_STATUSES
)
// displayName → repo id, populated from repo.list. The filter model keys on
// repo ids (desktop's PersistedUIState), but the section headers/rows key on
// displayName, so we bridge the two here.
// displayName → repo id: filters key on repo id, but section headers/rows key on displayName, so bridge the two.
const [repoIdsByName, setRepoIdsByName] = useState<Map<string, string>>(new Map())
const [showSortPicker, setShowSortPicker] = useState(false)
const [showGroupPicker, setShowGroupPicker] = useState(false)
@ -201,9 +188,7 @@ export function HostScreen({
}, [router])
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
// Why: snapshot of the synced view settings so the focus-effect ui.get merge
// and the optimistic ui.set writes read the latest values without forcing the
// callbacks to re-create on every state change.
// Why: ref so the ui.get merge and ui.set writes read the latest values without re-creating callbacks on every state change.
const viewStateRef = useRef<MobileViewState>({
groupMode: 'repo',
sortMode: 'recent',
@ -226,8 +211,7 @@ export function HostScreen({
}
}, [groupMode, sortMode, filters, collapsedGroups, workspaceStatuses])
// Apply a MobileViewState (e.g. from a desktop ui.get) onto the individual
// states and the snapshot ref in one shot.
// Apply a MobileViewState onto the individual states and the snapshot ref in one shot.
const applyViewState = useCallback((next: MobileViewState) => {
viewStateRef.current = next
setGroupMode(next.groupMode)
@ -241,8 +225,7 @@ export function HostScreen({
})
}, [])
// Optimistically apply a partial change locally, then push the full mapped
// settings to the desktop's shared store via ui.set so both apps stay in sync.
// Apply the change locally, then push full settings to the desktop's shared store (ui.set) so both apps stay in sync.
const persistViewSettings = useCallback(
(patch: Partial<MobileViewState>) => {
const next: MobileViewState = { ...viewStateRef.current, ...patch }
@ -275,8 +258,7 @@ export function HostScreen({
}, [])
const resolvedRouteActionState = resolveHostRouteActionState(routeActionState, action)
// Why: `action=newWorktree` is a route-derived open edge. Resolve it before
// commit, but don't reopen after the user closes while the same URL remains.
// Why: resolve `action=newWorktree` before commit, but don't reopen after the user closes while the URL persists.
if (resolvedRouteActionState !== routeActionState) {
setRouteActionState(resolvedRouteActionState)
}
@ -285,8 +267,7 @@ export function HostScreen({
setRouteActionState((current) => setHostRouteNewWorktreeVisible(current, visible))
}, [])
// Load persisted pins from the local cache. View settings are no longer
// stored locally — they sync from the desktop's shared store via ui.get.
// Load persisted pins from local cache; view settings are no longer local (they sync via ui.get).
useEffect(() => {
if (!hostId) {
return
@ -304,9 +285,7 @@ export function HostScreen({
}
}, [hostId])
// Read the desktop's shared view settings (PersistedUIState) and merge them
// onto local state. Runs on connect and on screen focus so changes made on
// desktop appear on the phone.
// Merge the desktop's shared view settings (PersistedUIState) onto local state so desktop changes appear here.
const syncViewSettingsFromDesktop = useCallback(async () => {
if (!client || connState !== 'connected') {
return
@ -328,9 +307,7 @@ export function HostScreen({
}
}, [client, connState, hostId, applyViewState])
// Why: keep clientRef in sync so existing imperative call sites work
// unchanged. Also re-seed the cached worktree list on hostId change
// since the useState initializer only runs on first mount.
// Why: mirror client into a ref so imperative call sites read it without re-subscribing.
useEffect(() => {
clientRef.current = client
}, [client])
@ -342,9 +319,7 @@ export function HostScreen({
setRepoColorsByName(new Map())
setRepoIconsByName(new Map())
repoMetadataFetchedAtRef.current = 0
// Why: re-seed from the current host's cache on every hostId change.
// The useState initializer only runs on first mount, so if Expo Router
// reuses this screen with a different hostId, we must reset here.
// Why: useState initializer runs only on first mount, so re-seed the cache when Expo Router reuses this screen for a new hostId.
const freshCache = hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
if (freshCache) {
setWorktrees(freshCache)
@ -432,8 +407,7 @@ export function HostScreen({
if (!options.allowDuringModal && newWorktreeModalVisibleRef.current) {
return
}
// The embedded sidebar polls for the whole split-view session; keep slow
// remote hosts from stacking overlapping expensive list requests.
// Why: prevent slow remote hosts from stacking overlapping worktree.ps requests during polling.
if (fetchWorktreesInFlightRef.current) {
return
}
@ -442,8 +416,7 @@ export function HostScreen({
const requestHostId = hostId
try {
// Why: worktree.ps defaults to 200 and silently truncates; match the
// desktop's high cap so large hosts don't drop workspaces on mobile.
// Why: worktree.ps silently truncates at 200; use a high cap so large hosts don't drop workspaces.
const response = await requestClient.sendRequest('worktree.ps', { limit: 10000 })
if (clientRef.current !== requestClient || hostId !== requestHostId) {
return
@ -453,9 +426,7 @@ export function HostScreen({
}
if (response.ok) {
const result = (response as RpcSuccess).result as { worktrees: Worktree[] }
// Why: large hosts can return identical worktree.ps snapshots every
// poll. Preserving the existing array keeps SectionList/sort rebuilds
// off the JS tap path unless something actually changed.
// Why: reuse the existing array on identical snapshots to keep SectionList/sort rebuilds off the tap path.
setWorktrees((current) =>
areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees
)
@ -463,25 +434,18 @@ export function HostScreen({
areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees
)
setWorktreesLoaded(true)
// Why (#8498): the host detail screen seeds its list from the
// home-written cache, so a partial home fetch could poison it until a
// focus poll corrected it. Write the confirmed snapshot back through
// the same cache so a reconnect refetch (or a remount) can't serve a
// stale worktree list.
// Why (#8498): overwrite the home-written cache with the confirmed snapshot so a reconnect/remount can't serve a stale list.
if (hostId) {
setCachedWorktrees(hostId, result.worktrees)
}
// Drop the optimistic active override once the host confirms it (the
// activate RPC has landed and worktree.ps now reports it active), so we
// stop overriding and respect any later desktop-driven change.
// Drop the optimistic active override once the host reports it active, so later desktop changes win.
setOptimisticActiveWorktreeId((pending) =>
pending && result.worktrees.some((w) => w.worktreeId === pending && w.isActive)
? null
: pending
)
// Clear optimistic sleep overrides once the server confirms the
// worktree is actually inactive (liveTerminalCount dropped to 0).
// Clear optimistic sleep overrides once the server confirms inactive (liveTerminalCount === 0).
setSleptIds((prev) => {
if (prev.size === 0) {
return prev
@ -496,8 +460,7 @@ export function HostScreen({
return still.size === prev.size ? prev : still
})
// Sync local pin state from server so desktop-initiated pins/unpins
// are reflected without relying on stale AsyncStorage.
// Sync pin state from server so desktop-initiated pins reflect without relying on stale AsyncStorage.
const serverPinned = new Set(
result.worktrees.filter((w) => w.isPinned).map((w) => w.worktreeId)
)
@ -520,17 +483,10 @@ export function HostScreen({
[client, connState, hostId]
)
// Why: read desktop's protocol version from status.get on every connect
// and re-evaluate compatibility. If the desktop declares this mobile
// build too old (or vice versa via the local minimum), the host detail
// screen swaps to a hard-block screen instead of the worktree list.
// Today's compat constants are wide-open so this never blocks; the wire
// format is in place to flip a switch in a future release.
// Why: re-evaluate protocol compat on connect; today's constants are wide-open so this never blocks yet.
useEffect(() => {
if (connState !== 'connected' || !client) {
// Why: drop the prior host's capabilities while disconnected/switching so
// a capability-gated action (e.g. Agent Session History) can't linger for
// a host that doesn't support it.
// Why: drop capabilities while disconnected/switching so a capability-gated action can't linger for a new host.
setHostCapabilities([])
return
}
@ -556,8 +512,7 @@ export function HostScreen({
})
setCompatVerdict(verdict)
if (verdict.kind === 'blocked') {
// Why: deterministic breadcrumb so support can confirm a block
// actually fired (vs a render bug). No PII — just version ints.
// Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints.
console.warn('[protocol-compat] blocked', {
reason: verdict.reason,
desktopVersion: verdict.desktopVersion,
@ -566,8 +521,7 @@ export function HostScreen({
})
}
} catch {
// Why: rare path — sendRequest can throw on transport tear-down.
// Treat as transient; verdict stays at previous value.
// Why: sendRequest can throw on transport tear-down; treat as transient, keep the prior verdict.
}
})()
return () => {
@ -577,29 +531,22 @@ export function HostScreen({
useFocusEffect(
useCallback(() => {
// Why: opening the host is a strong user signal — reset a backed-off or
// trickling reconnect loop (and probe a possibly half-open socket)
// immediately instead of waiting out its timer. Deps stay empty so this
// fires per focus transition, not per connection-state change; nudging
// on every reconnecting↔connecting flip would defeat the backoff.
// Why: focus nudges reconnect and probes a possibly half-open socket; empty deps fire per focus, not per state flip (which defeats backoff).
clientRef.current?.notifyForeground()
}, [])
)
useFocusEffect(
useCallback(() => {
// The embedded sidebar drives its own polling below; focus never fires
// for it since it isn't a routed screen.
// The embedded sidebar isn't a routed screen (focus never fires); it polls via the mount effect below.
if (embedded || connState !== 'connected') {
return
}
void fetchWorktrees()
void fetchRepoMetadata()
// Pull desktop's shared view settings on focus so desktop-side changes
// show up here without a manual refresh.
// Pull desktop's shared view settings on focus so desktop changes show up without a manual refresh.
void syncViewSettingsFromDesktop()
// Why: React Navigation keeps previous stack screens mounted; only
// poll the host list while this route is visible.
// Why: React Navigation keeps prior screens mounted; only poll while this route is visible.
const interval = setInterval(() => {
void fetchWorktrees()
void fetchRepoMetadata()
@ -608,9 +555,7 @@ export function HostScreen({
}, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
)
// Why: as the persistent tablet sidebar this list is never the focused
// route, so useFocusEffect won't fetch/poll. Mirror that behavior from a
// plain mount effect while connected instead.
// Why: the embedded sidebar is never the focused route, so useFocusEffect never polls; mirror it from a mount effect.
useEffect(() => {
if (!embedded || connState !== 'connected') {
return
@ -625,10 +570,7 @@ export function HostScreen({
return () => clearInterval(interval)
}, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
// Why (#8498): reconnect refetch + manual pull-to-refresh, extracted to
// useWorktreeResync so this screen stays under its max-lines budget. The
// steady-state focus/embedded polls don't cover the transition INTO
// 'connected' after a background/sleep, which is when the cache is stalest.
// Why (#8498): steady-state polls miss the transition INTO 'connected' after background/sleep, when the cache is stalest.
const { refreshing, onRefresh } = useWorktreeResync({
client,
connState,
@ -720,8 +662,7 @@ export function HostScreen({
await removeHostAndCloseClient(hostId, closeHostClient)
leaveHost()
} catch {
// Why: metadata commit can fail while the host is still paired; keep the
// screen mounted and re-open confirm (ConfirmModal closes on confirm).
// Why: removal can fail while still paired; re-open confirm (ConfirmModal closes on confirm).
setConfirmRemoveHost(true)
Alert.alert('Could not remove host', 'Please try again.')
}
@ -748,11 +689,9 @@ export function HostScreen({
const openWorktreeSession = useCallback(
(item: Worktree) => {
// Highlight the row immediately; the next worktree.ps poll confirms it.
setOptimisticActiveWorktreeId(item.worktreeId)
if (client && connState === 'connected') {
// Why: opening a mobile session should hydrate host-owned tabs without
// pulling other paired clients, especially desktop, into this worktree.
// Why: notifyClients:false hydrates host tabs without pulling desktop into this worktree.
void client
.sendRequest('worktree.activate', {
worktree: `id:${item.worktreeId}`,
@ -831,8 +770,7 @@ export function HostScreen({
const slept = sleptIds.has(w.worktreeId)
? { liveTerminalCount: 0, hasAttachedPty: false, status: 'inactive' as const }
: null
// Force the just-opened worktree active (and the rest inactive) until the
// next poll confirms it, so the highlight doesn't lag the navigation.
// Force the just-opened worktree active until the next poll confirms it, so the highlight doesn't lag.
const active =
optimisticActiveWorktreeId !== null
? { isActive: w.worktreeId === optimisticActiveWorktreeId }
@ -912,12 +850,7 @@ export function HostScreen({
</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.
// Why: auth-failed has its own banner, so suppress the Reconnect button for that verdict.
const verdict = headerVerdict
const isError = isErrorVerdict(verdict)
const showReconnectButton = isError && hostId && verdict.kind !== 'auth-failed'
@ -1166,8 +1099,7 @@ export function HostScreen({
onChangeText={setSearch}
placeholder="Search worktrees…"
autoFocus
// Why: new key each open remounts focus effect if the field stays mounted
// across rapid toggles; pairs with delayed focus so the keyboard appears.
// Why: new key per open remounts the focus effect across rapid toggles so the keyboard reappears.
focusKey={showSearch}
accessibilityLabel="Search worktrees"
/>
@ -1206,13 +1138,10 @@ export function HostScreen({
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
onScrollToIndexFailed={onScrollToIndexFailed}
// Why: edge-to-edge — the list scrolls under the system nav bar
// while reserving insets.bottom keeps the last worktree row reachable
// above the Samsung 3-button nav / iOS home indicator.
// Why: edge-to-edge under the system nav bar; insets.bottom keeps the last row above it.
contentContainerStyle={[
styles.list,
// Phone shows a floating "+" button bottom-right; reserve room so the
// last row stays tappable above it. Embedded sidebars keep the toolbar +.
// Reserve room so the last row stays tappable above the phone's floating "+" (embedded uses the toolbar +).
{ paddingBottom: (embedded ? spacing.lg : FAB_SIZE + spacing.xl) + insets.bottom },
isWideLayout &&
!embedded && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' }
@ -1252,8 +1181,7 @@ export function HostScreen({
)
}}
ItemSeparatorComponent={ListSeparator}
// Why (#8498): manual pull-to-refresh forces a fresh worktree
// snapshot after a reconnect or whenever the cache looks stale.
// Why (#8498): manual pull-to-refresh forces a fresh snapshot after a stale-cache reconnect.
refreshControl={
<RefreshControl
refreshing={refreshing}
@ -1478,10 +1406,7 @@ export function HostScreen({
)
}
// Default route export. On wide tablet/foldable canvases the worktree list is
// rendered as a persistent sidebar by the host layout, so the route itself
// becomes the empty detail pane until a workspace is opened. On phones it is
// the full-screen worktree list as before.
// On wide layouts the sidebar hosts the list, so this route is just the empty detail pane.
export default function HostWorktreeRoute() {
const { isWideLayout } = useResponsiveLayout()
if (isWideLayout) {

File diff suppressed because it is too large Load Diff

View File

@ -123,10 +123,7 @@ function formatDuration(ms: number): string {
return `${totalMinutes}m`
}
// Why: derive a stable per-instance identity for RpcClient so the wireUp
// effect's dep key changes when forceReconnect swaps the underlying client
// for a host (without this, listeners stay attached to the closed client
// and notifications/accounts subs never re-attach).
// Why: stable per-instance RpcClient identity so wireUp's dep key changes when forceReconnect swaps the client, re-attaching listeners.
const clientIdentities = new WeakMap<RpcClient, number>()
let nextClientIdentity = 1
function clientKey(client: RpcClient): number {
@ -164,11 +161,7 @@ function fetchWorktreeInfo(
) => void,
disposed: () => boolean
) {
// Why: only seed an empty zeroed entry when this host has no prior info
// at all (e.g., first ever load before any cache hydration). On a
// transient failure for a host that already has cached data, leave the
// cached entry alone so the Resume card and host-meta line don't
// momentarily flip to "0 worktrees" / disappear during reconnects.
// Why: only seed a zeroed entry when the host has no prior info; keep cached data on transient failure so counts don't flip to 0 during reconnects.
const markLoadedIfMissing = () => {
setInfo((prev) => {
if (prev[hostId]) {
@ -187,8 +180,7 @@ function fetchWorktreeInfo(
}
client
// Why: worktree.ps defaults to 200 and silently truncates; request the full
// set so the host worktree count and active count are accurate.
// Why: worktree.ps defaults to 200 and silently truncates; request all so counts are accurate.
.sendRequest('worktree.ps', { limit: 10000 })
.then((response) => {
if (disposed()) {
@ -286,8 +278,7 @@ function fetchTaskProviders(
})
}
// Why: repo names get a stable color derived from hashing, matching the
// host detail page's colored dots for visual consistency.
// Why: hash repo name to a stable color, matching the host detail page's dots.
const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4']
function repoColor(name: string): string {
let hash = 0
@ -300,8 +291,7 @@ function repoColor(name: string): string {
export default function HomeScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
// Why: cap and center content on wide/tablet canvases so cards don't stretch
// edge-to-edge on iPad; on phones isWideLayout is false and layout is unchanged.
// Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad.
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
const [hosts, setHosts] = useState<HostProfile[]>([])
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
@ -318,9 +308,7 @@ export default function HomeScreen() {
)
const notificationOptInCheckedRef = useRef(false)
// Why: read shared clients from the per-host store. Replaces the prior
// pattern of opening N independent WebSockets here. See
// docs/mobile-shared-client-per-host.md.
// Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md.
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
const allClients = useAllHostClients(hostIds)
const hostPaths = useMemo(
@ -330,27 +318,20 @@ export default function HomeScreen() {
const closeHostClient = useCloseHost()
const forceReconnectHost = useForceReconnect()
const primeHosts = usePrimeHosts()
// Why: feed the loaded HostProfiles into the provider's prime cache as
// soon as we have them. This avoids a second Keychain pass inside
// openEntry on cold start (which serialised behind the first one and
// showed up as multi-second connect latency).
// Why: prime the cache with loaded HostProfiles to avoid a second serialized Keychain pass (multi-second connect latency) on cold start.
useEffect(() => {
if (hosts.length > 0) {
primeHosts(hosts)
}
}, [hosts, primeHosts])
const allClientsRef = useRef<Array<{ hostId: string; client: RpcClient }>>([])
// Why: the focus callback stays stable to avoid refetching on every
// client-store render, but it still needs the latest host clients.
// Why: keep the focus callback stable (no refetch per render) while still exposing the latest host clients.
allClientsRef.current = allClients.map((entry) => ({
hostId: entry.hostId,
client: entry.client
}))
// Why: hydrate the home page from a persisted snapshot on cold-start so
// Resume + Account-usage cards paint immediately with last-known data
// instead of flashing empty for ~1s while the WebSocket reconnects.
// Stream/list responses overwrite this seed in place when they arrive.
// Why: hydrate from a persisted snapshot on cold-start so Resume + Account cards paint immediately instead of flashing empty.
const hydratedRef = useRef(false)
useEffect(() => {
if (hydratedRef.current) {
@ -367,8 +348,7 @@ export default function HomeScreen() {
for (const [hostId, info] of Object.entries(snap.worktreeInfo)) {
const wt = info.lastActiveWorktree
if (wt) {
// Why: also seed the in-memory worktree cache so resumeWorktree's
// lastVisited fast-path can find the cached worktree object.
// Why: seed the in-memory cache so resumeWorktree's lastVisited fast-path finds the worktree object.
setCachedWorktrees(hostId, [wt])
}
}
@ -378,9 +358,7 @@ export default function HomeScreen() {
}
}, [])
// Why: persist the merged snapshot whenever either piece updates so the
// next cold-start has fresh seed data. The cache module debounces writes
// internally so a flurry of streamed updates doesn't hammer disk.
// Why: persist the merged snapshot on each update so the next cold-start has fresh seed data (cache debounces writes).
useEffect(() => {
if (Object.keys(worktreeInfo).length === 0 && Object.keys(accountsByHost).length === 0) {
return
@ -436,8 +414,7 @@ export default function HomeScreen() {
[hosts]
)
// Why: mirror per-host connection state into hostStates so existing
// render code (status dots, connecting indicators) keeps working.
// Why: mirror per-host connection state into hostStates so existing render code (status dots) keeps working.
useEffect(() => {
setHostAttempts((prev) => {
const next: Record<string, number> = { ...prev }
@ -473,11 +450,7 @@ export default function HomeScreen() {
changed = true
}
}
// Why: when a paired host disappears from allClients (because the
// user tapped Disconnect, or the host record was invalid) the card
// must reflect that. We only force-update hosts whose state was
// already tracked — otherwise the initial-acquire frame (entry not
// yet materialised) would briefly flip every host to 'disconnected'.
// Why: reflect hosts that dropped from allClients, but only if already tracked — else the initial-acquire frame flips all to 'disconnected'.
for (const host of hosts) {
if (liveIds.has(host.id)) {
continue
@ -506,11 +479,7 @@ export default function HomeScreen() {
})
}, [allClients, hosts])
// Why: per-host streaming subscriptions (notifications + accounts) and
// one-shot stats fetches when each host transitions to 'connected'.
// Runs once per (hostId, client) pair and tears down when that pair
// changes. The provider keeps the underlying socket open across
// resubscription cycles so this is cheap.
// Per-host notif/accounts subs + one-shot stats on 'connected'; re-runs per (hostId, client) pair, socket stays open so it's cheap.
useEffect(() => {
const cleanups: Array<() => void> = []
for (const entry of allClients) {
@ -563,12 +532,7 @@ export default function HomeScreen() {
c()
}
}
// Why: depend on the host-id set AND each entry's client identity, so
// resubscriptions don't fire on every render that produces a new
// array reference, but DO fire when forceReconnect swaps the
// underlying client for a host (otherwise wireUp would keep firing
// on a closed client and never re-attach to the fresh one, leaving
// notifications/accounts subs broken until the user navigates).
// Why: key on host-id set + each client's identity so resubs fire when forceReconnect swaps a host's client, not on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
allClients
@ -577,22 +541,10 @@ export default function HomeScreen() {
.join(',')
])
// Why: prefer the worktree the user last opened on this device so the
// "Resume" card reflects their mobile session history, not just the
// desktop's most-recently-outputting worktree.
// Why: rendering used to be gated on hostStates === 'connected', which
// caused the Resume card to vanish for ~1s on every cold-start /
// resume-from-background while the WebSocket reconnected, even though we
// had perfectly good cached worktree data. Now the card stays visible as
// long as we have a cached lastActiveWorktree for any known host; the
// tap target is still the same and a fresher snapshot from the live RPC
// overwrites the card's contents in place when it lands.
// Why: prefer the worktree last opened on this device so Resume reflects mobile session history.
// Why: don't gate on 'connected' so the card doesn't flash empty for ~1s on cold-start; cached data holds until fresh RPC lands.
const resumeWorktree = useMemo(() => {
// 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.
// Why: only surface Resume for connected hosts; a stale worktree taps into a route that can't load.
if (lastVisited && hostStates[lastVisited.hostId] === 'connected') {
const cached = getCachedWorktrees(lastVisited.hostId) as WorktreeSummary[] | null
const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId)
@ -612,9 +564,7 @@ export default function HomeScreen() {
return null
}, [sortedHosts, hostStates, worktreeInfo, lastVisited])
// 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.
// Why: only show Account usage for connected hosts; stale cached usage would imply live data.
const accountsHosts = useMemo(() => {
const items: Array<{ host: HostProfile; snapshot: AccountsSnapshot }> = []
for (const host of sortedHosts) {
@ -625,9 +575,7 @@ export default function HomeScreen() {
if (!snap) {
continue
}
// Why: also show hosts whose only usage is the system-default login
// (no Orca-managed accounts but live rate-limit data for the active
// target), otherwise system-default users see no usage section at all.
// Why: also show hosts whose only usage is the system-default login, else those users see no usage section.
if (hasRenderableUsage(snap, 'claude') || hasRenderableUsage(snap, 'codex')) {
items.push({ host, snapshot: snap })
}
@ -716,8 +664,7 @@ export default function HomeScreen() {
setConfirmRemove(null)
setHosts(await loadHosts())
} catch {
// Why: ConfirmModal closes on confirm; re-open for retry and surface the
// failure instead of silently leaving the host listed.
// Why: ConfirmModal closes on confirm; re-open for retry so the failure isn't silent.
setConfirmRemove(hostToRemove)
Alert.alert('Could not remove host', 'Please try again.')
}
@ -782,9 +729,7 @@ export default function HomeScreen() {
<FlatList
data={sortedHosts}
keyExtractor={(h) => h.id}
// Why: edge-to-edge — let the list scroll under the system nav bar
// but reserve insets.bottom so the last row stays reachable above
// the Samsung 3-button nav / iOS home indicator.
// Why: reserve insets.bottom so the last row stays reachable above the system nav bar / home indicator.
contentContainerStyle={[
styles.list,
{ paddingBottom: spacing.xl + insets.bottom },
@ -961,10 +906,7 @@ export default function HomeScreen() {
? snapshot.claude.accounts
: snapshot.codex.accounts
const limits = getActiveProviderRateLimits(snapshot, provider)
// Why: with no managed accounts, still render a
// "System default" row when the active target has
// live usage data; the row label already falls back
// to "System default" below.
// Why: with no managed accounts, still render the row when the active target has live usage data.
if (accounts.length === 0 && !hasActiveProviderUsage(limits)) {
return null
}
@ -1027,11 +969,7 @@ export default function HomeScreen() {
state === 'connecting' ||
state === 'handshaking' ||
state === 'reconnecting'
// Why: "Reconnect" implies "you were connected, try again". If
// the client has never reached 'connected' this session (cold
// start, unreachable host, or after Disconnect) the action is
// functionally a fresh Connect — using the right verb makes
// the affordance match what tapping it actually does.
// Why: label "Connect" (not "Reconnect") when never connected this session, so the verb matches the action.
const hasEverConnected = (hostLastConnected[host.id] ?? null) != null
const items: ActionSheetAction[] = []
items.push({

View File

@ -17,9 +17,7 @@ type NotificationEvent = {
body: string
worktreeId?: string
notificationId?: string
// Mirrors the desktop-assigned MobileNotificationEvent.notificationSeq used
// for reconnect catch-up (#8129). Optional because older runtimes / non-
// replay events may omit it.
// Desktop-assigned seq for reconnect catch-up (#8129); optional since older runtimes may omit it.
notificationSeq?: number
}
@ -42,12 +40,7 @@ type ScheduledNotificationState = {
const scheduledNotificationsByHostAndNotificationId = new Map<string, ScheduledNotificationState>()
// Why: notificationId embeds a per-completion timestamp (buildAgentNotificationId),
// so every agent-task-complete inserts a new, never-reused key. Entries are only
// removed when the desktop sends a matching dismiss — which a remote mobile user
// (not at the desktop) frequently never gets — so the map grew for the app's whole
// life. Bound it; a settled entry only retains a small identifier used for later
// programmatic dismissal, unnecessary for long-past completions.
// Why: keys never repeat and are only freed on desktop dismiss (which remote users often miss), so bound the map to stop unbounded growth.
const MAX_SCHEDULED_NOTIFICATIONS = 256
let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS
@ -55,8 +48,7 @@ function getStoredNotificationKey(hostId: string, notificationId: string): strin
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
}
// Evict the oldest SETTLED entries (never one mid-schedule) until within the cap.
// Map iteration is insertion order, so the first match is the oldest.
// Evict oldest settled entries (never mid-schedule); Map iteration is insertion order so the first match is oldest.
function boundScheduledNotifications(): void {
while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) {
let evicted = false
@ -91,16 +83,13 @@ export async function getNotificationPermissionState(): Promise<NotificationPerm
granted: status === 'granted',
status,
canAskAgain,
// Why: Android before API 33 has no runtime notification permission, so
// Expo's default "granted" state is capability evidence, not user consent.
// Why: Android <33 has no runtime notification permission, so "granted" is capability, not user consent.
authorizationReflectsUserChoice:
status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33)
}
}
// Why: permissions must be requested before scheduling any local notification.
// Read the OS state every time because users can change it in Settings while
// Orca remains alive in the background.
// Why: re-read OS state every call — users can change it in Settings while Orca is backgrounded.
export async function ensureNotificationPermissions(): Promise<boolean> {
const existing = await getNotificationPermissionState()
if (existing.granted) {
@ -225,8 +214,7 @@ async function dismissLocalNotification(
return
}
if (state.pending) {
// Why: desktop can send dismiss while iOS/Android is still scheduling the
// matching local notification. Remember it so no stale banner survives.
// Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives.
state.dismissAfterSchedule = true
return
}
@ -237,29 +225,15 @@ async function dismissLocalNotification(
await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
}
// Why: each host connection gets its own notification subscription. When the
// connection drops, the unsubscribe function cleans up the streaming RPC.
// On reconnect the same subscribe stream is re-established by the RPC client;
// we use its `ready` event to trigger catch-up (#8129): fetch notifications
// dispatched while the socket was reaped, watermarked by the last seq we
// already delivered so the desktop never re-sends an already-pushed one.
// Returns an unsubscribe function.
// Per-connection subscription; a reconnect `ready` triggers watermarked catch-up (#8129) so already-pushed events aren't re-sent.
export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void {
configureNotificationChannel()
let subscriptionId: string | null = null
let disposed = false
// Highest seq delivered on the live stream or replay for this connection.
// Persisted per-host so a cold app start still resumes from the right cut.
// Highest seq delivered (live or replay) this connection; persisted per-host so cold start resumes from the right cut.
let lastDeliveredSeq = 0
// Why: per-connection dedup guard applied ONLY to the replay path
// (fetchMissed), never the live stream. The desktop already guarantees the
// replay cannot contain an event with seq <= lastDeliveredSeq (both live and
// replay advance the same watermark), so live + replay never overlap there.
// This set is defense-in-depth: if the desktop's bounded buffer evicted an
// old entry and a reconnect re-fetches across a boundary, an id delivered in
// the same connection isn't pushed twice. Bounded (RECENTLY_SEEN_CAP) so a
// long-lived session can't grow without limit.
// Why: defense-in-depth dedup for replayed events if the desktop's bounded buffer evicted across a reconnect boundary.
const seenReplay = createSeenNotificationGuard()
function deliverLive(
@ -270,10 +244,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
lastDeliveredSeq = event.notificationSeq
void saveLastSeenSeq(hostId, lastDeliveredSeq)
}
// Why (#8129 dedup): mark the event seen on EVERY delivery path (live AND
// replay) so a replay that re-includes an id already pushed live in this
// connection is dropped instead of double-pushed. fetchMissed also
// pre-checks seenReplay, but without this the live path never populated it.
// Why (#8129): mark seen on the live path too, so a later replay of an already-pushed id dedups instead of double-pushing.
const key = seenKeyForEvent(event)
if (key) {
seenReplay.add(key)
@ -284,12 +255,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
return dismissLocalNotification(event as DismissNotificationEvent, hostId)
}
// Why: on a reconnect `ready` the desktop has already dispatched whatever we
// missed; ask for it from our persisted watermark. Because the desktop cuts
// by seq > lastSeenSeq this is idempotent — we only ever get events we have
// not delivered before. The seenReplay guard is a second layer so a replay
// that somehow re-includes an id already delivered this connection is
// dropped instead of double-pushed.
// Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (seenReplay guards residual overlap).
async function fetchMissed(): Promise<void> {
if (disposed) {
return
@ -321,10 +287,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
}
}
// Why: lazily seed the watermark from durable storage on first use so we
// don't block subscribe() on an AsyncStorage read. The first `ready` (cold
// open) does NOT need catch-up — the live stream starts fresh; only
// subsequent reconnect `ready` events fetch missed notifications.
// Why: seed the watermark lazily so subscribe() doesn't block on an AsyncStorage read.
let watermarkLoaded = false
void loadLastSeenSeq(hostId).then((seq) => {
lastDeliveredSeq = Math.max(lastDeliveredSeq, seq)
@ -352,10 +315,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
unsubscribeStream()
return
}
// Why: first ready is the cold-open live stream — no catch-up needed.
// Every later ready is a reconnect; fetch what we missed from the
// watermark. Guard on watermarkLoaded so a fast reconnect doesn't
// fetch from a stale 0 watermark (which would re-push everything).
// Why: only reconnects fetch missed; watermarkLoaded guards against fetching from a stale 0 (which re-pushes everything).
if (reconnectReadyCount > 1 && watermarkLoaded) {
void fetchMissed()
}
@ -379,12 +339,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
return () => {
disposed = true
// Why: the client may already be closed when this cleanup runs (component
// unmount races with disconnect). sendRequest rejects immediately on a
// closed client — swallow it since server-side cleanup happens via
// connection-close anyway.
// Always drop the local stream first; readiness can race unmount and we
// must not retain the callback while waiting for a subscription id.
// Why: drop the local stream first — readiness can race unmount; don't hold the callback while a subscription id is pending.
unsubscribeStream()
if (subscriptionId) {
unsubscribeServer(subscriptionId)

View File

@ -24,21 +24,17 @@ type PrSidebarControllerInput = {
client: RpcClient | null
connState: ConnectionState
worktreeId: string
// Head branch + SHA come from git.status (`branch`/`head`) via the review screen,
// not the branchCompare base ref nor worktree metadata (which carries no branch).
// branch/headSha come from git.status (not the branchCompare base ref nor worktree metadata, which carries no branch).
branch: string | null
headSha: string | null
}
// Load options for the shared PR controller. The Source Control hub chip only needs
// phase 1 (PR + checks); phase 2 (comments/body) is heavy and should wait until the
// Pull Request segment is actually open.
// Load options: the hub chip needs only phase 1 (PR + checks); phase 2 (comments/body) is heavy and waits until the PR segment opens.
export type PrSidebarLoadOptions = {
includeDetails?: boolean
}
// Identity is worktree + branch only. Head SHA advances on every commit and must not
// wipe a ready chip/sidebar to "loading" — soft-refresh uses the new head instead.
// Identity is worktree + branch only: headSha advances every commit and must not wipe a ready chip to "loading" (soft-refresh handles it).
export function buildMobilePrSidebarIdentity(args: {
worktreeId: string
branch: string | null
@ -48,33 +44,23 @@ export function buildMobilePrSidebarIdentity(args: {
export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
const { client, connState, worktreeId, branch, headSha } = input
// The dedicated PR icon is available whenever the repo has a GitHub remote —
// independent of whether the branch has an open PR (a no-PR branch shows an
// empty state rather than hiding the icon).
// PR icon shows for any GitHub remote, regardless of an open PR — a no-PR branch shows an empty state rather than hiding the icon.
const [isGithubRepo, setIsGithubRepo] = useState(false)
// False until the probe resolves for this worktree. Consumers gate "unavailable
// for this provider" copy on it — isGithubRepo=false is meaningless mid-probe.
// False until the probe resolves — isGithubRepo=false is meaningless mid-probe, so consumers gate "unavailable" copy on this.
const [repoProbeLoaded, setRepoProbeLoaded] = useState(false)
const [state, setState] = useState<PrSidebarState>({ kind: 'hidden' })
const [showPRSidebar, setShowPRSidebar] = useState(false)
const loadSeqRef = useRef(0)
// Phase-2-only fetches use a separate sequence so they cannot cancel a concurrent
// phase-1 soft refresh (and vice versa) when chip bootstrap left details null.
// Separate seq for phase-2 fetches so they can't cancel a concurrent phase-1 soft refresh (and vice versa).
const detailsSeqRef = useRef(0)
// The (seq, prNumber) of the phase-2 fetch currently in flight. The hub's
// fill-in effect fires as soon as phase 1 renders ready with null details —
// exactly when load()'s own phase 2 just started. Without this claim, every
// cold PR-segment open fetched the heavy details payload twice.
// (seq, prNumber) of the in-flight phase-2 fetch; without this claim every cold PR-segment open fetched the heavy details twice.
const detailsInFlightRef = useRef<{ seq: number; prNumber: number } | null>(null)
const stateIdentityRef = useRef<string | null>(null)
const stateRef = useRef(state)
stateRef.current = state
const headShaRef = useRef(headSha)
// Repo eligibility (a GitHub remote) is independent of the branch, so the probe
// must not require one: a detached HEAD / mid-rebase worktree (branch === null)
// would otherwise never set repoProbeLoaded, stranding the PR segment on a
// forever spinner instead of the "Current branch unavailable" state.
// Probe is branch-independent (repo eligibility is): requiring a branch would strand a detached-HEAD worktree on a forever spinner.
const probeReady = client !== null && connState === 'connected'
const identity = buildMobilePrSidebarIdentity({ worktreeId, branch })
@ -91,9 +77,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
}
}, [client])
// Probe whether this is a GitHub repo to decide icon availability (GitHub-only).
// Worktree change must reset eligibility; a brief disconnect must not — otherwise
// the hub shows "unavailable for this provider" and hides the chip mid-session.
// Probe GitHub-repo eligibility for the icon; a worktree change resets it, a brief disconnect must not (else the chip hides mid-session).
useEffect(() => {
setIsGithubRepo(false)
setRepoProbeLoaded(false)
@ -112,8 +96,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
}
})
.catch(() => {
// Why: sendGithubPrRead already normalizes throws, but a cancelled
// unmount + any unexpected rejection must not surface as LogBox.
// Why: sendGithubPrRead normalizes throws, but a stray rejection on unmount must not surface as LogBox.
if (!cancelled) {
setIsGithubRepo(false)
setRepoProbeLoaded(true)
@ -133,8 +116,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
return
}
if (stateIdentityRef.current !== null && stateIdentityRef.current !== identity) {
// Why: ready/loading data is scoped to branch. A branch switch must not let
// the open panel keep rendering the previous PR as "fresh."
// Why: data is scoped to branch; a branch switch must not keep rendering the previous PR as "fresh."
loadSeqRef.current += 1
detailsSeqRef.current += 1
stateIdentityRef.current = null
@ -152,16 +134,10 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
}
const seq = loadSeqRef.current + 1
loadSeqRef.current = seq
// In-flight phase-2 work is NOT invalidated here: this load only takes phase-2
// ownership when its own phase 2 actually starts (the bump below), so a load
// superseded at the phase-1 guard can never orphan a detailsSeq and silently
// discard the only details fetch. A stale ensure applying mid-phase-1 is safe —
// its identity/number/kind guards only let matching details through.
// Don't invalidate in-flight phase 2 here: it's only claimed when this load's own phase 2 starts, so a superseded phase-1 load can't orphan the details fetch.
const previousIdentity = stateIdentityRef.current
stateIdentityRef.current = loadIdentity
// Soft refresh: same branch already showing ready/none stays visible while
// checks re-fetch (head advanced after commit). Hard loading only on first load
// or after a real identity wipe.
// Soft refresh: same-branch ready/none stays visible while checks re-fetch; hard "loading" only on first load or identity wipe.
const keepVisible =
previousIdentity === loadIdentity &&
(stateRef.current.kind === 'ready' ||
@ -170,8 +146,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
if (!keepVisible) {
setState({ kind: 'loading' })
}
// Phase 1: PR + checks (fast) — the worktree linkedPR read is parallelized with
// forBranch inside loadPrSidebarData so a closed/merged linked PR still resolves.
// Phase 1: PR + checks (fast); linkedPR read runs in parallel with forBranch so a closed/merged linked PR still resolves.
const next = await loadPrSidebarData(deps, { worktreeId, branch, headSha })
if (
!shouldApplyResult(seq, loadSeqRef.current) ||
@ -181,9 +156,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
}
stateIdentityRef.current = loadIdentity
// Keep prior comments/body visible across phase 1 when the same PR is still open.
// loadPrSidebarData always returns details:null; without this, soft refresh and
// PR-tab refresh blank the comment tree until phase 2 finishes.
// Preserve prior details across phase 1 (loadPrSidebarData returns details:null) so soft/PR-tab refresh doesn't blank the comment tree.
const priorDetails =
next.kind === 'ready' &&
stateRef.current.kind === 'ready' &&
@ -213,10 +186,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
if (detailsInFlightRef.current?.seq === detailsSeq) {
detailsInFlightRef.current = null
}
// Phase-2 ownership is encoded by detailsSeq + identity + PR number alone —
// deliberately NOT by loadSeq: a chip-only soft refresh bumps loadSeq without
// bumping detailsSeq, and must not discard the in-flight details it preserved
// (ensure dedupes against this claim, so nothing would re-fetch them).
// Ownership keyed on detailsSeq (not loadSeq): a chip-only soft refresh bumps loadSeq without detailsSeq and must not discard these details.
if (
detailsSeq !== detailsSeqRef.current ||
stateIdentityRef.current !== loadIdentity ||
@ -236,11 +206,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
[buildDeps, branch, headSha, identity, worktreeId]
)
// Phase-2 only — used when the hub opens the PR segment after a chip-only load.
// Uses detailsSeqRef (not loadSeqRef) so it cannot cancel a concurrent soft phase-1.
// Retries synthetic placeholders too: a failed phase-2 installs non-null empty
// details so Description/Comments leave the spinner, and without this ensure
// would never re-fetch on tab re-open.
// Phase-2-only fill-in; uses detailsSeqRef so it can't cancel a concurrent phase-1, and re-fetches non-null placeholders too.
const ensurePrSidebarDetails = useCallback(async () => {
const current = stateRef.current
if (current.kind !== 'ready' || !prSidebarDetailsNeedFetch(current.data.details)) {
@ -252,8 +218,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
return
}
const prNumber = current.data.pr.number
// A live phase-2 fetch for this PR is already in flight (its claim still owns
// the latest details seq) — do not start a duplicate.
// Skip if a live phase-2 fetch for this PR already owns the latest details seq (dedupe).
const inFlight = detailsInFlightRef.current
if (inFlight && inFlight.prNumber === prNumber && inFlight.seq === detailsSeqRef.current) {
return
@ -284,18 +249,13 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
})
}, [buildDeps, identity, worktreeId])
// Soft-refresh checks when HEAD advances on the same branch (post-commit).
// Also restarts an in-flight phase-1 load so a mid-flight head advance is not
// applied with a stale SHA (headShaRef would otherwise advance with no reload).
// Soft-refresh on same-branch HEAD advance; restart in-flight load so the advance isn't applied with a stale SHA.
useEffect(() => {
if (headShaRef.current === headSha) {
return
}
headShaRef.current = headSha
// Identity was just wiped (branch/worktree switch): stateRef still holds the
// pre-wipe state in this effect flush, which would start a load flavored for
// the OLD surface (e.g. heavy details on the Changes tab). Let the owning
// surface's hidden-state effects drive the first load for the new identity.
// Identity just wiped: stateRef still holds stale pre-wipe state, so let the surface's hidden-state effects drive the new load.
if (stateIdentityRef.current === null) {
return
}

View File

@ -46,16 +46,13 @@ export function MobileSourceControlPanel({
onOpenedFileDiff
}: MobileSourceControlPanelProps) {
const [activeTab, setActiveTab] = useState<SourceControlHubTab>(initialTab)
// Track first visit so Changes/History keep scroll state across segment switches
// without paying the mount cost until the user actually opens them. PR unmounts
// when inactive (WebViews / comment tree) but its controller state stays for the chip.
// Track first visit so Changes/History stay mounted (keep scroll) after first open; PR still unmounts when inactive.
const [visitedTabs, setVisitedTabs] = useState<ReadonlySet<SourceControlHubTab>>(
() => new Set<SourceControlHubTab>([initialTab])
)
const [historyRefreshNonce, setHistoryRefreshNonce] = useState(0)
// Deep-link / push with a different `tab` param should adopt the new segment
// (expo-router can reuse the screen instance when only query params change).
// expo-router reuses the screen instance when only query params change, so adopt the new tab.
useEffect(() => {
setActiveTab(initialTab)
setVisitedTabs((prev) => {
@ -119,13 +116,7 @@ export function MobileSourceControlPanel({
const ioBusy = busyAction !== null || openingPath !== null || openingBranchPath !== null
const ready = screenState.kind === 'ready'
// One PR controller feeds both the branch-card chip and the Pull Request
// segment, so the chip's rollup can never disagree with the checks list it
// links to. Branch + head come from the already-loaded git.status — no second
// status read. The chip loads independently, so it never blocks the file list.
// Keep last-known identity across a transient status unload (disconnect /
// failed refresh) so the controller does not wipe ready → hidden → cold start.
// Head SHA matches the review path: status.head ?? branchCompare headOid.
// Keep last-known branch/head across a transient status unload so the PR controller isn't wiped ready → hidden → cold start.
const lastPrBranchRef = useRef<string | null>(null)
const lastPrHeadRef = useRef<string | null>(null)
useEffect(() => {
@ -134,8 +125,7 @@ export function MobileSourceControlPanel({
}, [worktreeId])
const statusBranch = status?.branch ?? null
const statusHead = status?.head ?? branchCompareResult?.summary.headOid ?? null
// Write last-known identity in an effect, not the render body: a discarded
// concurrent render must not leave the fallback holding a never-committed value.
// Write last-known identity in an effect, not render: a discarded concurrent render must not leave the fallback stale.
useEffect(() => {
if (statusBranch) {
lastPrBranchRef.current = statusBranch
@ -163,8 +153,7 @@ export function MobileSourceControlPanel({
const ensurePrDetailsRef = useRef(ensurePrDetails)
ensurePrDetailsRef.current = ensurePrDetails
// Chip bootstrap: phase-1 only (PR + checks). Full comment payload waits until
// the Pull Request segment is open — opening SC to stage must not pull details.
// Chip bootstrap: phase-1 only (PR + checks); full payload waits for the PR segment so opening SC to stage doesn't pull details.
useEffect(() => {
if (activeTab === 'pr') {
return
@ -174,22 +163,14 @@ export function MobileSourceControlPanel({
}
}, [activeTab, prBranch, isHostedRepo, prSidebarKind])
// Number of the ready PR whose phase-2 details are still missing (null when
// none). Keyed by PR number — not a boolean — so a same-branch PR swap during a
// chip-only soft refresh re-arms phase 2 for the new PR instead of leaving its
// comments on a forever-spinner (a stale ensure bails on the number mismatch).
// Placeholder details (failed phase 2) also count as missing so reopening the
// PR tab retries instead of leaving empty Description/Comments forever.
// Keyed by PR number, not a boolean, so a same-branch PR swap re-arms phase 2 for the new PR.
const prDetailsMissingFor =
prController.prSidebarState.kind === 'ready' &&
prSidebarDetailsNeedFetch(prController.prSidebarState.data.details)
? prController.prSidebarState.data.pr.number
: null
// PR segment: full load or phase-2 fill-in. Body only mounts while active.
// Guarded (and keyed) on prBranch so a branch that arrives while the segment is
// already open — e.g. mounted on a detached HEAD, then checkout — still loads;
// kind stays 'hidden' through that transition, so kind alone can't re-fire this.
// PR segment full load / phase-2 fill-in; key on prBranch so a branch arriving while open still loads (kind stays 'hidden').
useEffect(() => {
if (activeTab !== 'pr' || !isHostedRepo || !prBranch) {
return
@ -204,9 +185,7 @@ export function MobileSourceControlPanel({
}, [activeTab, isHostedRepo, prBranch, prSidebarKind, prDetailsMissingFor])
const prChip = useMemo(() => {
// No branch (detached HEAD / mid-rebase) never runs a PR load, so the shared
// state stays 'hidden' — which the chip would render as a forever spinner.
// Hide the chip instead; the Pull Request segment shows "branch unavailable".
// No branch (detached HEAD / mid-rebase) never loads a PR, so state stays 'hidden'; hide the chip or it spins forever.
if (!isHostedRepo || !prBranch) {
return null
}
@ -217,9 +196,7 @@ export function MobileSourceControlPanel({
return buildMobilePrChipSummary(prController.prSidebarState, commentCount)
}, [isHostedRepo, prBranch, prController.prSidebarState])
// Design: refresh the active segment's body work, plus git.status for the shared
// branch card (counts/sync stay honest even while on History). Preserve ready
// status on a failed refresh so PR chip identity is not wiped to hidden.
// Refresh the active segment plus git.status (branch card stays honest on History); preserve ready on failure so the PR chip isn't wiped.
const onRefresh = useCallback(() => {
void loadStatus({ preserveReadyOnFailure: true })
if (activeTab === 'history') {
@ -237,11 +214,9 @@ export function MobileSourceControlPanel({
void refetchPr({ includeDetails: false })
}, [activeTab, isHostedRepo, loadStatus, refetchPr])
// Embedded mode docks beside the terminal: close the dock instead of popping
// a route, and skip the full-screen safe-area chrome (the dock column owns it).
// Embedded mode docks beside the terminal: close the dock instead of popping a route; skip safe-area chrome (the dock column owns it).
const onBack = embedded ? (onRequestClose ?? (() => router.back())) : () => router.back()
// Chromeless PR body has no panel header — surface open-on-web on the hub chrome
// while the Pull Request segment is active (same affordance as the old /pr route).
// Chromeless PR body has no header, so surface open-on-web on the hub chrome while the PR segment is active.
const prWebUrl =
activeTab === 'pr' &&
prController.prSidebarState.kind === 'ready' &&
@ -277,10 +252,7 @@ export function MobileSourceControlPanel({
<Pressable
style={styles.retryButton}
onPress={() => {
// Why: retrying the request is useless while the transport's
// reconnect loop is parked at its give-up cap — revive the
// connection instead (issue #5049). loadStatus re-runs via
// its connState effect once the new client connects.
// 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)
return
@ -294,13 +266,10 @@ export function MobileSourceControlPanel({
</View>
) : null
// History only needs the RPC client — do not block it behind git.status.
// Changes/PR need status (branch, file list, head SHA), so they stay gated.
// History only needs the RPC client, so it isn't gated on git.status; Changes/PR need status (branch, files, head).
const showChanges = ready && (activeTab === 'changes' || visitedTabs.has('changes'))
const showHistory = activeTab === 'history' || visitedTabs.has('history')
// Why: only mount the PR body while its segment is active. Keep-mounting retained
// Mermaid WebViews and re-rendered the comment tree on every commit keystroke.
// Controller + chip state still live for instant re-open without a full cold start.
// Only mount PR body while active: keep-mounting retained Mermaid WebViews and re-rendered the comment tree per keystroke.
const showPrBody = ready && activeTab === 'pr'
const conflictOperation = status?.conflictOperation ?? null
// Git status always reports a conflictOperation enum; 'unknown' means none.
@ -319,10 +288,7 @@ export function MobileSourceControlPanel({
<MobileSourceControlSegments active={activeTab} onSelect={selectTab} />
{/* Branch card + PR chip are the Changes/Commits glance layer. On the PR
tab they duplicate the ready PR body (#, state, checks rollup, branch
trajectory), so hide the whole card there and let the PR panel own it
unless a merge/rebase conflict is active, which only this card can abort. */}
{/* On the PR tab the branch card duplicates the ready PR body, so hide it there — unless a conflict is active, which only this card can abort. */}
{ready && (activeTab !== 'pr' || hasActiveConflict) ? (
<MobileSourceControlBranchCard
branchLabel={branchLabel}
@ -357,8 +323,7 @@ export function MobileSourceControlPanel({
headSha={prHeadSha}
gitStatus={status}
isGithubRepo={isHostedRepo}
// Gate on the probe too: isGithubRepo=false mid-probe must render as
// loading, not flash "unavailable for this provider" (old /pr parity).
// Gate on the probe too: isGithubRepo=false mid-probe must render loading, not flash "unavailable".
branchContextLoaded={ready && prController.prSidebarRepoProbeLoaded}
controller={prController}
/>

View File

@ -28,14 +28,9 @@ type TerminalViewportRefitOptions = {
initializedHandlesRef: RefObject<Set<string>>
connState: ConnectionState
tabStripVisible: boolean
// Why: terminal text size (font scale) — changing it changes the cell size, so
// the PTY must be re-fitted to a new column count and reflowed.
// Why: text size (font scale); changing it changes cell size, so the PTY must be re-fitted to a new column count.
textScale: number
// Why: the terminal's measured frame width changes when a side panel docks/undocks
// or EITHER sidebar is drag-resized (the left worktree sidebar shrinks the detail
// pane; the right dock takes a slice of the row) — all without any window-dim or
// tab-strip change. Carries that measured width so those resizes re-fit the PTY;
// the 150ms debounce coalesces the stream of drag widths into one settle-time refit.
// Why: measured frame width; panel dock/undock or sidebar resize changes it with no window/tab change, so it re-fits the PTY.
terminalFrameWidth: number
unsubscribeTerminal: (handle: string) => void
subscribeToTerminal: (handle: string) => void
@ -46,12 +41,7 @@ type TerminalViewportRefitNotifications = {
notifyKeyboardVisibility: (visible: boolean) => void
}
// Why: re-measure the phone viewport when layout-affecting state changes
// outside the subscribe path — the tab strip toggling visibility, and the
// window itself resizing (fold/unfold on foldables, orientation rotation,
// split-screen). Without the resize trigger, a PTY fitted on the folded
// cover screen stays at cover-screen cols after unfolding and the terminal
// renders in only part of the display (#4579's "cut in half" symptom).
// Why: re-measure on layout changes outside the subscribe path (tab strip, fold/rotate/resize), or a PTY renders "cut in half" (#4579).
export function useTerminalViewportRefit(
options: TerminalViewportRefitOptions
): TerminalViewportRefitNotifications {
@ -82,9 +72,7 @@ export function useTerminalViewportRefit(
keyboardVisible: false,
pending: false
})
// Why: marks the currently-armed timer as a height refit so its callback can
// re-check the keyboard at fire time. Non-height refits (width/rotation and
// the forced reconnect/foreground re-asserts) stay unguarded so they always run.
// Why: marks the armed timer as a height refit so its callback re-checks the keyboard; other refits always run unguarded.
const heightOriginatedRefitRef = useRef(false)
const scheduleViewportRefit = useCallback(
(options?: { heightOriginated?: boolean }) => {
@ -94,9 +82,7 @@ export function useTerminalViewportRefit(
heightOriginatedRefitRef.current = options?.heightOriginated ?? false
refitTimerRef.current = setTimeout(() => {
refitTimerRef.current = null
// Why: a height refit deferred at keyboard-close can fire after the keyboard
// reopened within the 150ms debounce; re-check and re-defer so we never
// reflow the PTY mid-keystroke. Scoped via the height-originated flag.
// Why: a height refit can fire after the keyboard reopened within the debounce; re-check so we never reflow the PTY mid-keystroke.
if (heightOriginatedRefitRef.current) {
heightOriginatedRefitRef.current = false
const decision = reduceTerminalFrameHeightRefit(frameHeightRefitStateRef.current, {
@ -143,11 +129,7 @@ export function useTerminalViewportRefit(
}
viewportRef.current = dims
viewportMeasuredRef.current = true
// Why: prefer the in-place viewport update RPC over the legacy
// unsubscribe → subscribe cycle. This keeps the server-side
// mobile subscriber record alive (no driver=idle blip on the
// desktop banner; no false phone-fit baseline capture on the
// re-subscribe). See docs/mobile-presence-lock.md.
// Why: prefer in-place updateViewport over resubscribe to keep the mobile subscriber record alive. See docs/mobile-presence-lock.md.
const rpc = clientRef.current
const deviceToken = deviceTokenRef.current
if (rpc && deviceToken && updateViewportCapabilityRef.current !== 'unsupported') {
@ -165,11 +147,7 @@ export function useTerminalViewportRefit(
if (isTerminalUpdateViewportUpdated(response)) {
rpc.updateTerminalSubscriptionViewport(handle, dims)
if (isTerminalUpdateViewportApplied(response)) {
// Why: updateViewport reflows the server PTY and re-streams only
// the visible screen, so the WebView's local xterm scrollback
// stays wrapped at the old width. Reflow it locally only when
// the server actually applied phone-fit; desktop mode records
// the viewport but leaves the PTY at desktop dims.
// Why: updateViewport re-streams only the visible screen, so local scrollback stays wrapped at the old width — reflow it locally.
ref.reflow(dims.cols, dims.rows)
}
return
@ -205,14 +183,7 @@ export function useTerminalViewportRefit(
scheduleViewportRefit()
}, [scheduleViewportRefit])
// Why: the tab strip is hidden when only one terminal exists and shown
// once a second is created. Crossing the 1↔2 boundary changes the
// visible terminal area by ~40px, so the cached viewport dims in
// viewportRef become stale. Mark the viewport as un-measured so the
// next subscribe path's self-correcting loop (init → measure →
// resubscribe-with-fresh-viewport) re-runs against the new layout.
// Also schedule an explicit refit to cover the case where no new
// subscribe is happening.
// Why: the tab strip toggles at the 1↔2 terminal boundary (~40px area change), so the cached viewport goes stale.
const prevTabStripVisibleRef = useRef(tabStripVisible)
useEffect(() => {
if (prevTabStripVisibleRef.current === tabStripVisible) {
@ -223,10 +194,7 @@ export function useTerminalViewportRefit(
scheduleViewportRefit()
}, [tabStripVisible, viewportMeasuredRef, scheduleViewportRefit])
// Why: fold/unfold and rotation change the window dimensions without any
// subscribe or tab-strip transition. The PTY must be re-fitted to the new
// viewport or the terminal keeps the old grid (fit scale is capped at 1,
// so a grown window leaves the surface pinned to a fraction of the screen).
// Why: fold/unfold and rotation change window dims with no subscribe/tab change; refit or the grid stays stale (fit capped at 1).
const { width: windowWidth, height: windowHeight } = useWindowDimensions()
const prevWindowDimsRef = useRef({ width: windowWidth, height: windowHeight })
useEffect(() => {
@ -235,8 +203,7 @@ export function useTerminalViewportRefit(
return
}
prevWindowDimsRef.current = { width: windowWidth, height: windowHeight }
// Why: adjustResize can change only window height while the IME is open;
// the frame-height notifier schedules one correction after it closes.
// Why: adjustResize can change only window height while the IME is open; the frame-height notifier corrects once it closes.
if (prev.width === windowWidth && frameHeightRefitStateRef.current.keyboardVisible) {
return
}
@ -244,10 +211,7 @@ export function useTerminalViewportRefit(
scheduleViewportRefit()
}, [windowWidth, windowHeight, viewportMeasuredRef, scheduleViewportRefit])
// Why: the text size changed, so the WebView is re-rendering at a new font/cell
// size. Re-measure and resize the PTY so the server reflows to the new column
// count. The refit's own 150ms debounce gives the WebView a frame to apply the
// new fontSize before we measure the resulting cell metrics.
// Why: on text-size change the refit's 150ms debounce lets the WebView apply the new fontSize before we re-measure cell metrics.
const prevTextScaleRef = useRef(textScale)
useEffect(() => {
if (prevTextScaleRef.current === textScale) {
@ -258,10 +222,7 @@ export function useTerminalViewportRefit(
scheduleViewportRefit()
}, [textScale, viewportMeasuredRef, scheduleViewportRefit])
// Why: the terminal's measured frame width changes when a panel docks/undocks or
// either sidebar is drag-resized — none of which touch the window dims or tab
// strip — so the cached viewport goes stale and the PTY keeps the pre-resize
// width. Mark un-measured and refit when the measured width changes.
// Why: panel dock/undock or sidebar resize changes frame width with no window/tab change, so the cached viewport goes stale.
const prevFrameWidthRef = useRef(terminalFrameWidth)
useEffect(() => {
if (prevFrameWidthRef.current === terminalFrameWidth) {
@ -284,8 +245,7 @@ export function useTerminalViewportRefit(
},
[viewportMeasuredRef, scheduleViewportRefit]
)
// Why: notify imperatively so layout churn does not rerender the full session;
// a height change during typing is coalesced into one refit after keyboard close.
// Why: notify imperatively so layout churn doesn't rerender the full session.
const notifyTerminalFrameHeight = useCallback(
(height: number) => notifyFrameHeightRefitEvent({ type: 'frame-height', height }),
[notifyFrameHeightRefitEvent]
@ -310,8 +270,7 @@ export function useTerminalViewportRefit(
if (!shouldRefit) {
return
}
// Why: the cached grid can match while the host PTY changed in background;
// reasserting equal dimensions is the convergence signal after iOS resume.
// Why: cached grid can match while the host PTY changed in background; reassert equal dims to converge after iOS resume.
viewportMeasuredRef.current = false
scheduleForcedViewportRefit()
})
@ -325,11 +284,9 @@ export function useTerminalViewportRefit(
if (previous === 'connected' || connState !== 'connected') {
return
}
// Why: an in-place desktop upgrade may add updateViewport; reconnect is the
// narrow boundary where an old-host method_not_found cache becomes stale.
// Why: an in-place desktop upgrade may add updateViewport; reconnect is where the cached method_not_found goes stale.
updateViewportCapabilityRef.current = 'unknown'
// Why: reconnect can restore a PTY whose host-side size changed while the
// socket was down, so equal cached dimensions still need reassertion.
// Why: reconnect can restore a PTY resized while the socket was down, so equal cached dims still need reassertion.
viewportMeasuredRef.current = false
scheduleForcedViewportRefit()
}, [connState, viewportMeasuredRef, scheduleForcedViewportRefit])

View File

@ -1,5 +1,4 @@
// xterm.js WebView document + default Tokyonight theme. Extracted from
// TerminalWebView.tsx to keep that file within the max-lines budget.
// xterm.js WebView document + default Tokyonight theme; extracted from TerminalWebView.tsx for the max-lines budget.
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import { colors } from '../theme/mobile-theme'
import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
@ -38,15 +37,7 @@ const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = {
brightWhite: '#c0caf5'
}
// Why: TUI apps (Claude Code / Ink) emit escape codes with absolute cursor
// positioning designed for the desktop's terminal dimensions (~150+ cols).
// We initialize xterm at the desktop's exact cols/rows so those escape codes
// render correctly, then use a measured CSS transform: scale() to fit the
// canvas into the phone viewport. The scale is computed after xterm opens
// by measuring the rendered surface width, not hardcoded, so it adapts to
// any terminal column count (80, 150, 200+). All touch gestures (scroll,
// pinch-to-zoom, pan) are handled by custom JS rather than native WebView
// behavior, so they work correctly with the CSS scale transform.
// Why: TUI escape codes assume the desktop's cols/rows, so init xterm at those dims and fit the phone via a measured CSS scale() instead of resizing.
export const XTERM_HTML = `<!DOCTYPE html>
<html>
<head>
@ -1892,6 +1883,5 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
</body>
</html>`
// Why: WebView treats source identity as page identity on some platforms; keep
// parent/session re-renders from reloading xterm and forcing fresh snapshots.
// Why: some WebViews treat source identity as page identity; keep this stable so re-renders don't reload xterm.
export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }

View File

@ -1,14 +1,5 @@
// Why: collapses the per-screen WebSocket connection model into a single
// shared RpcClient per host. Implements the design in
// docs/mobile-shared-client-per-host.md.
//
// Lifecycle rules:
// - First request for a host opens its client lazily.
// - Refcount tracks active subscribers; when it drops to zero we schedule
// a 30-second idle close timer. If a new subscriber arrives within that
// window we cancel and reuse the same client.
// - removeHost() forces an immediate close so re-pairing gets a fresh
// transport.
// Single shared RpcClient per host, collapsing the old per-screen WebSocket connections.
// Design: docs/mobile-shared-client-per-host.md.
import {
createContext,
useCallback,
@ -42,40 +33,28 @@ export type RpcClientContextValue = {
closeHost: (hostId: string) => void
getState: (hostId: string) => ConnectionState
getReconnectAttempt: (hostId: string) => number
// Why: timestamp (ms epoch) of the last successful 'connected' state
// transition for this host, or null if never connected this session.
// Used by the UI to escalate "Reconnecting…" into a "host appears
// unreachable, re-pair?" prompt.
// Why: ms-epoch of the last 'connected' (null if never this session); UI escalates "Reconnecting…" into a re-pair prompt.
getLastConnectedAt: (hostId: string) => number | null
getActivePath: (hostId: string) => MobileConnectionPath
subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void
getAllClients: () => Array<{ hostId: string; client: RpcClient }>
subscribeAllHosts: (listener: () => void) => () => void
// Why: lets the home screen feed already-loaded HostProfiles in so we
// don't pay loadHosts() latency twice (once in the focus-effect, again
// inside openEntry).
// Why: lets the home screen feed already-loaded HostProfiles so we don't pay loadHosts() latency twice.
primeHosts: (hosts: HostProfile[]) => void
}
const Ctx = createContext<RpcClientContextValue | null>(null)
export function RpcClientProvider({ children }: { children: ReactNode }) {
// Why: entries live in a ref so updates don't force re-renders of the
// entire tree on every connection state change. State propagation goes
// through per-host listener Sets instead.
// Why: entries in a ref so state changes don't re-render the whole tree; propagation goes through per-host listener Sets.
const storeRef = useRef<Map<string, StoreEntry>>(new Map())
const stateListenersRef = useRef<Map<string, Set<(state: ConnectionState) => void>>>(new Map())
const allHostsListenersRef = useRef<Set<() => void>>(new Set())
// Pending opens (avoid two acquire() callers in the same render racing the
// host lookup). Keyed by hostId, value is a sentinel resolved when the
// entry materialises.
// Pending opens keyed by hostId so two acquire() callers in the same render don't race the host lookup.
const pendingOpensRef = useRef(new HostClientOpenRegistry())
// Why: a fast-path cache of already-loaded HostProfiles. Screens that
// have run loadHosts() can call primeHosts() to populate this and skip
// the second loadHosts() inside openEntry. Without this we'd serialize
// two Keychain passes on cold start.
// Why: cache of already-loaded HostProfiles so openEntry can skip a second loadHosts()/Keychain pass on cold start.
const primedHostsRef = useRef<Map<string, HostProfile>>(new Map())
function notifyHostState(hostId: string, state: ConnectionState) {
@ -118,28 +97,20 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
const pendingOpen = pendingOpensRef.current.register(hostId, promise)
try {
// Why: prefer the primed cache (populated by primeHosts when the
// screen already ran loadHosts) so we don't serialize a second
// Keychain pass behind the first one on cold start.
// Why: prefer the primed cache so we don't serialize a second Keychain pass on cold start.
let host = primedHostsRef.current.get(hostId)
if (!host) {
try {
const hosts = await loadHosts()
host = hosts.find((h) => h.id === hostId)
} catch {
// Why: a Keychain failure on cold start (rare but observed —
// happens when iOS Keychain is mid-unlock or Android Keystore
// races the JS bridge). Surface it as 'disconnected' so the
// home card flips off the perma-spinner and the user can hit
// Reconnect from the action sheet to retry.
// Why: cold-start Keychain failure (iOS mid-unlock / Android Keystore race); surface 'disconnected' so the user can Reconnect.
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
return null
}
if (!host) {
// Why: returning silently leaves mounted screens on a permanent
// spinner (STA-1511) — surface 'disconnected' so they can render
// their waiting/retry affordance instead.
// Why: silent return leaves screens on a permanent spinner (STA-1511); surface 'disconnected' so they show a retry affordance.
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
return null
@ -160,9 +131,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
try {
client = openHostLogicalClient(host, (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
// doesn't sit on a stale 'connecting' label forever.
// Why: openHostLogicalClient can throw synchronously (bad public key / invalid URL); notify so the UI leaves 'connecting'.
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
return null
@ -191,12 +160,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
}
}, [])
// Why: `acquire` is the synchronous get-or-open. If the entry already
// exists, return its client immediately and bump the refcount. If not,
// kick off an async open (the consumer will subscribe via
// `subscribeHostState` and re-read once 'connecting' fires). Optionally
// accepts the HostProfile so the caller can avoid an extra loadHosts()
// pass inside openEntry.
// Synchronous get-or-open: returns an existing client immediately, else kicks off an async open and returns null this tick.
const acquire = useCallback(
(hostId: string, host?: HostProfile): RpcClient | null => {
if (host) {
@ -207,9 +171,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
existing.refCount += 1
return existing.client
}
// Trigger async open. The acquire-side will return null this tick and
// try again once the state listener fires; consumers are expected to
// call acquire() inside an effect that re-runs on state changes.
// Trigger async open; returns null this tick — consumers re-call acquire() from an effect that re-runs on state changes.
void openEntry(hostId).then((entry) => {
if (!entry) {
return
@ -227,14 +189,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
}
}, [])
// Why: refcount dropping to 0 no longer triggers an idle-close. The
// app deliberately keeps live WebSockets open while the app itself is
// foregrounded — closing on transient navigation gaps was producing
// false 'disconnected' flashes when the user navigated home → host →
// back to home faster than React could re-acquire on the home side.
// Connections still close on: explicit user Disconnect, host removal,
// app backgrounding (OS-level socket suspension), and provider
// unmount (app shutdown).
// Why: no idle-close on refcount→0 — transient nav gaps flashed false 'disconnected', so keep sockets alive while foregrounded.
const release = useCallback((hostId: string) => {
const entry = storeRef.current.get(hostId)
if (!entry) {
@ -246,10 +201,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
const forceReconnect = useCallback(
async (hostId: string) => {
const entry = storeRef.current.get(hostId)
// Why: preserve refcount across the swap. If the user reaches
// forceReconnect via the Disconnect → Reconnect path, the entry
// was already closed and refCount=0; fall back to active listener
// count as a proxy for "screens still watching this host."
// Why: preserve refcount across the swap; via Disconnect→Reconnect the entry is already gone, so fall back to listener count.
const listenerCount = stateListenersRef.current.get(hostId)?.size ?? 0
const savedRefCount = entry?.refCount ?? Math.max(1, listenerCount)
if (entry) {
@ -318,16 +270,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
}
}, [])
// Close all clients on provider unmount (app shutdown).
// Why: deps must be empty so this cleanup ONLY runs on real unmount.
// Hot-reload re-evaluates this module, which makes closeEntry a new
// function identity. With [closeEntry] as deps, every Fast Refresh
// would tear down all open WebSockets, leaving screens holding closed
// clients and the user staring at a 'Reconnecting…' card. Reading
// storeRef.current and the locally-scoped closeEntry inside the
// cleanup is safe — the ref is stable across renders, and the
// function captured here will be the one defined in the same
// closure as this effect.
// Close all clients on provider unmount. Empty deps: [closeEntry] would let Fast Refresh tear down all live sockets.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
const store = storeRef.current
@ -339,9 +282,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
}
}, [])
// Why: nudge every live client when the OS signals the link may be back
// (foreground, network restored/switched) so sessions recover without an
// app restart (issue #5049).
// Why: nudge live clients when the OS signals the link may be back so sessions recover without a restart (issue #5049).
useEffect(() => {
return subscribeConnectionRevivalTriggers(() => {
for (const entry of storeRef.current.values()) {
@ -392,9 +333,7 @@ export function useRpcClientContext(): RpcClientContextValue {
return ctx
}
// Why: the primary hook for screens. Acquires the shared client for a
// hostId on mount and releases on unmount. Re-renders when the host's
// connection state changes.
// Primary hook for screens: acquires the shared client on mount, releases on unmount, re-renders on state change.
export function useHostClient(hostId: string | undefined): {
client: RpcClient | null
state: ConnectionState
@ -419,17 +358,13 @@ export function useHostClient(hostId: string | undefined): {
return
}
setState(next)
// Why: the client materialises after an async open, and forceReconnect
// swaps in a fresh client object. Re-read on every state change so a
// mounted screen never keeps driving a stale (closed) client.
// Why: async open and forceReconnect swap the client object; re-read each state change so screens never drive a stale one.
const found = ctx.getAllClients().find((entry) => entry.hostId === hostId)
if (found && found.client !== clientRef.current) {
clientRef.current = found.client
force((n) => n + 1)
} else if (!found && clientRef.current) {
// Why: closeHost deletes the entry without a replacement; holding the
// closed client would let screens keep issuing requests that can never
// resolve (STA-1511). Null it so they render disconnected states.
// Why: closeHost deletes the entry with no replacement; null it so screens don't drive a dead client (STA-1511).
clientRef.current = null
force((n) => n + 1)
}
@ -450,9 +385,7 @@ export function useHostClient(hostId: string | undefined): {
return { client: clientRef.current, state }
}
// Why: home screen renders all paired hosts at once. Acquires each on
// mount, releases on unmount. The provider's refcounting ensures we
// don't double-open if a host-detail screen is also open.
// Why: refcounting prevents a double-open when a host-detail screen shares one of these hosts.
export function useAllHostClients(hostIds: string[]): Array<{
hostId: string
client: RpcClient
@ -510,9 +443,7 @@ export function useAllHostClients(hostIds: string[]): Array<{
}, [key, tick])
}
// Why: removeHost() in host-store.ts must close the live client, but
// host-store has no React-side handle. Expose a hook that lets callers
// close a host after removal.
// Why: host-store's removeHost() must close the live client but has no React-side handle; this hook bridges to it.
export function useCloseHost(): (hostId: string) => void {
const ctx = useRpcClientContext()
return ctx.closeHost
@ -524,9 +455,7 @@ export function useForceReconnect(): (hostId: string) => Promise<void> {
return ctx.forceReconnect
}
// Why: lets the home screen feed already-loaded HostProfiles in so the
// provider can skip its own loadHosts() pass when it eventually opens
// each host — collapses two serial Keychain reads on cold-start into one.
// Why: primes already-loaded HostProfiles so the provider can skip a second loadHosts()/Keychain pass on cold start.
export function usePrimeHosts(): (hosts: HostProfile[]) => void {
const ctx = useRpcClientContext()
return ctx.primeHosts

View File

@ -23,16 +23,12 @@ import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upg
import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup'
const STORAGE_KEY = 'orca:hosts'
// Why: SecureStore keys must match [A-Za-z0-9._-]; colons are rejected.
// Use dots as the separator so the key shape stays readable while
// satisfying the validator.
// Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator.
const TOKEN_KEY_PREFIX = 'orca.host-token.'
const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:'
// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the pairing token off
// iCloud Keychain and out of iCloud/iTunes backup restores onto a
// different physical device. Reads/writes are silent (no biometric
// prompt) since we don't request access control flags.
// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the pairing token off iCloud Keychain and out of backup restores onto another device.
// Reads/writes stay silent (no biometric prompt) because we don't request access control flags.
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
@ -46,8 +42,7 @@ function webTokenKey(hostId: string): string {
}
async function readDeviceToken(hostId: string): Promise<string | null> {
// Why: Expo SecureStore has no working web backend; keep this fallback
// web-only so native builds still keep pairing tokens in the keychain.
// Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain.
if (Platform.OS === 'web') {
return AsyncStorage.getItem(webTokenKey(hostId))
}
@ -76,18 +71,10 @@ async function deleteHostCredentials(hostId: string): Promise<void> {
await deleteMobileRelayDirectUpgradeJournal(hostId)
}
// Why: SecureStore reads on Android Keystore can take 50-200ms each, and
// loadHosts() is called from every screen mount + every useFocusEffect.
// Stack with N hosts and you get N*200ms blocking every navigation, which
// triggers connection-churn cycles in the home-screen useEffect. Cache
// per-hostId in memory; invalidate only on save/remove. The cache lives
// for the JS-runtime lifetime, which matches AsyncStorage semantics
// (cleared on app uninstall, persisted across foreground/background).
// Why: Keychain reads are slow (50-200ms) and loadHosts() runs on every screen mount; cache per-hostId in memory, invalidate on save/remove.
const tokenCache = new Map<string, string>()
let inflightLoad: Promise<HostProfile[]> | null = null
// Why: rename / lastConnected / remove / save all RMW the same hosts JSON.
// Without a queue, concurrent writers re-read a stale snapshot and the last
// setItem wins — resurrecting a removed host or dropping a rename.
// Why: serialize RMW of the shared hosts JSON; without a queue concurrent writers drop writes (resurrect a removed host, drop a rename).
let hostListMutation: Promise<void> = Promise.resolve()
function parseStoredHosts(raw: string | null): StoredHostProfile[] | null {
@ -100,9 +87,7 @@ function parseStoredHosts(raw: string | null): StoredHostProfile[] | null {
return null
}
return parsed.flatMap((item) => {
// Why: pre-v0.0.3 records carry the deviceToken in AsyncStorage.
// Drop them silently — the three pre-launch users will re-pair on
// first run rather than carry a migration shim through the auth path.
// Why: pre-v0.0.3 records stored deviceToken in AsyncStorage; drop them (users re-pair) rather than carry a migration shim.
if (item && typeof item === 'object' && 'deviceToken' in item) {
return []
}
@ -115,11 +100,9 @@ function parseStoredHosts(raw: string | null): StoredHostProfile[] | null {
}
export async function loadHosts(): Promise<HostProfile[]> {
// Why: writers hold the mutation chain across their full RMW; wait so a
// load right after rename/remove does not race a half-written list.
// Why: writers hold the mutation chain across their full RMW; wait so a load doesn't race a half-written list.
await hostListMutation
// Why: deduplicate concurrent loadHosts() calls so multiple screens
// mounting simultaneously share one Keychain read pass.
// Why: deduplicate concurrent loadHosts() calls so simultaneously mounting screens share one Keychain read pass.
if (inflightLoad) {
return inflightLoad
}
@ -152,16 +135,11 @@ async function doLoadHosts(): Promise<HostProfile[]> {
try {
fetched = await readDeviceToken(stored.id)
} catch {
// Why: a transient Keychain failure for one entry (e.g.
// errSecInteractionNotAllowed while the device is briefly locked,
// or a single corrupt record) must not blank the entire host list.
// Skip just this host — it'll reappear on the next load.
// Why: a transient Keychain failure for one entry (e.g. errSecInteractionNotAllowed while locked) must not blank the whole host list; skip it.
continue
}
if (!fetched) {
// Why: orphaned metadata with no matching keychain entry — most
// likely a stale record from a development install. Skip it
// rather than surface a half-broken host.
// Why: orphaned metadata with no matching keychain entry; skip rather than surface a half-broken host.
continue
}
token = fetched
@ -187,8 +165,7 @@ export async function resolvePairingHostIdentity(
publicKeyB64: string,
newHostId: string
): Promise<{ id: string; name: string }> {
// Why: one durable read both preserves an existing identity and names a new host,
// avoiding duplicate cards and a second serial storage read before connecting.
// Why: one durable read both preserves an existing identity and names a new host, avoiding duplicate cards.
await hostListMutation
const hosts = await readStoredHostsForMutation()
const match = hosts.find((host) => host.publicKeyB64 === publicKeyB64)
@ -201,8 +178,7 @@ async function readStoredHostsForMutation(): Promise<StoredHostProfile[]> {
try {
const parsed = parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY))
if (!parsed) {
// Why: refuse to RMW over unreadable payload — treating it as [] would
// wipe the durable host list on the next rename/remove/save.
// Why: refuse to RMW over unreadable payload — treating it as [] would wipe the durable host list on the next write.
throw new Error('host list storage unreadable')
}
return parsed
@ -260,8 +236,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
}
if (index >= 0) {
updatedExistingHost = true
// Why: affected installs may already contain duplicate rows; an authoritative
// save is the safe point to collapse them to the preserved host id.
// Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id.
return hosts
.filter(({ id }) => !duplicateHostIds.has(id))
.map((candidate) => (candidate.id === stored.id ? stored : candidate))
@ -272,11 +247,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
}
return [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored]
})
// Why: write metadata BEFORE the keychain token so a crash between the two
// leaves orphaned metadata (which loadHosts skips and removeHost can clean
// up) rather than an orphaned keychain token with no metadata pointer —
// the latter would persist forever since removeHost only deletes by hostId
// from current metadata.
// Why: write metadata before the keychain token so a crash leaves recoverable orphaned metadata, not an orphaned token that persists forever.
await writeDeviceToken(stored.id, validated.deviceToken)
tokenCache.set(stored.id, validated.deviceToken)
if (validated.endpoints) {
@ -293,8 +264,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
overlayRemovalIds.push(stored.id)
}
if (overlayRemovalIds.length > 0) {
// Why: reusing an id for direct-only re-pairing must not retain routing
// metadata from the host's previous transport state.
// Why: reusing an id for direct-only re-pairing must not retain routing metadata from the previous transport state.
await removeMobileRelayHostOverlays(overlayRemovalIds)
}
for (const duplicateHostId of duplicateHostIds) {
@ -313,11 +283,9 @@ export async function removeHost(hostId: string): Promise<void> {
try {
await removeMobileRelayHostOverlay(hostId)
} catch {
// The missing legacy base is authoritative, so a retained overlay cannot
// resurrect this host and can be cleaned on a later explicit retry.
// Base removal is authoritative; a retained overlay can't resurrect the host and is cleaned on a later retry.
}
// Why: await only the durable cleanup intent (AsyncStorage). Native keychain
// delete can reject or stall and must not freeze removeHost / the UI.
// Why: keychain delete can stall/reject; await only the durable cleanup intent so removeHost can't freeze the UI.
try {
await scheduleHostCredentialCleanup(hostId, deleteHostCredentials)
} catch {
@ -333,10 +301,7 @@ export async function retryPendingHostCredentialCleanup(): Promise<{
return retryPendingHostCredentialCleanups(deleteHostCredentials)
}
// Why: Edit host can change name and endpoint together; a single
// mutateStoredHosts pass keeps both fields committed atomically so a
// mid-save failure can never persist one change without the other, and a
// host removed mid-edit throws consistently instead of silently no-oping.
// Why: single mutation pass commits name + endpoint atomically so a mid-save failure can't persist one without the other.
export async function updateHostNameAndEndpoint(
hostId: string,
updates: { name?: string; endpoint?: string }
@ -368,9 +333,7 @@ export async function updateLastConnected(hostId: string): Promise<void> {
return next
})
} catch {
// Why: last-connected is a best-effort timestamp and callers fire it with
// `void`. Swallow unreadable-storage failures so they don't surface as an
// unhandled promise rejection.
// Why: best-effort timestamp fired with void; swallow so unreadable storage doesn't reject.
}
}

View File

@ -79,74 +79,36 @@ export type RpcClient = {
viewport: { cols: number; rows: number }
) => void
getState: () => ConnectionState
// Why: UI escalates "Reconnecting…" to "Can't connect" once attempts cross
// a threshold. 0 means never failed; counter is reset on successful open.
// 0 means never failed (reset on successful open); the UI escalates "Reconnecting…" to "Can't connect" past a threshold.
getReconnectAttempt: () => number
// Why: timestamp (ms epoch) of the last time we reached 'connected'.
// null = never connected since the client was created. Used by the UI
// to distinguish "host moved/never reachable" from "transient blip".
// Last 'connected' timestamp (ms epoch); null = never connected. Lets the UI tell "never reachable" from "transient blip".
getLastConnectedAt: () => number | null
onStateChange: (listener: (state: ConnectionState) => void) => () => void
// Why: app-resume hook. Android/iOS can kill the TCP path or park the
// reconnect loop while the app is backgrounded; callers invoke this on
// AppState 'active' so the session recovers without an app restart.
// Why: app-resume hook — iOS/Android can kill the TCP path while backgrounded; call on AppState 'active' to recover.
notifyForeground: () => void
close: () => void
}
// Why: tiered backoff. The first four entries (500ms→4s) keep
// auto-recovery snappy for the common case — a brief Wi-Fi blip,
// laptop wake, or AP-isolation cycle. Beyond that we slow down
// (8s→60s) so a phone whose desktop is genuinely unreachable doesn't
// burn a TCP SYN every 4s indefinitely while still healing on its
// own when the network recovers. With 12 total attempts, the last
// four reuse the 60s cap (Math.min(idx, length-1)), so total elapsed
// time across all 12 attempts is ≈ 6 minutes before the give-up cap
// fires (0.5+1+2+4+8+15+30+60+60+60+60+60 ≈ 360s).
// Why: tiered backoff — fast early entries recover blips; the slow tail avoids burning a SYN every 4s on an unreachable desktop.
const RECONNECT_DELAYS = [500, 1000, 2000, 4000, 8000, 15_000, 30_000, 60_000]
// Why: cap fast auto-retry once we're clearly unreachable for a long time.
// With the tiered backoff above this is ≈ 6 minutes of continuous
// failure before the UI surfaces the re-pair banner. The longer
// runway tolerates flaky AP-isolation routers and laptop sleep cycles
// that briefly drop the LAN path. MUST stay aligned with
// connection-health.ts UNREACHABLE_ATTEMPTS so the "unreachable"
// verdict matches the moment the loop slows to the trickle cadence.
// Why: ≈6 min of failure before the re-pair banner; MUST stay aligned with connection-health.ts UNREACHABLE_ATTEMPTS.
const GIVE_UP_AFTER_ATTEMPTS = 12
// Why: past the cap the loop must never park permanently. A wedged
// Tailscale/VPN tunnel produces no AppState or network-type transition
// (still Wi-Fi, still "online"), so no revival nudge ever fires — users
// had to toggle Tailscale off/on just to force one. A slow trickle dial
// self-heals once the tunnel recovers while staying cheap: one TCP
// attempt per 90s, foreground-only (iOS/Android suspend JS timers in
// the background).
// Why: never park past the cap — a wedged VPN fires no AppState/network nudge to revive it, so trickle-dial every 90s to self-heal.
const TRICKLE_RECONNECT_DELAY_MS = 90_000
// Why: a single `unauthorized`/`e2ee_error` is not proof the pairing is dead.
// Issue #5200: a tablet showed "Auth failed" and forced a needless re-pair
// while the desktop still listed it as paired with a valid token — a transient
// rejection (mid-session resume race, a stale frame after background) latched
// the terminal auth-failed state permanently. Retry the full handshake this
// many times with a clean reconnect before declaring auth dead. A genuinely
// revoked token is rejected on every attempt and converges to auth-failed in
// seconds; a one-off glitch self-heals without the user re-pairing.
// Why: one unauthorized isn't proof the pairing is dead (issue #5200) — retry the handshake this many times before latching auth-failed.
const AUTH_RETRY_BUDGET = 3
const REQUEST_TIMEOUT_MS = 30_000
const CONNECT_TIMEOUT_MS = 12_000
const HANDSHAKE_TIMEOUT_MS = 5_000
// Why: RN's WebSocket implementation may not expose static readyState
// constants, but the protocol value for CONNECTING is stable across runtimes.
// Why: RN may not expose WebSocket.readyState constants, but the CONNECTING protocol value (0) is stable across runtimes.
const WEBSOCKET_CONNECTING_STATE = 0
// Why: RN auto-pongs WebSocket pings natively, so JS needs an app-level
// liveness probe to detect half-open sockets. Any inbound app traffic after
// a probe starts proves the link is alive; otherwise an unanswered probe
// force-closes the socket so the reconnect path can recover.
// Why: RN auto-pongs pings natively, so JS needs an app-level probe to detect half-open sockets.
const ACTIVITY_PROBE_INTERVAL_MS = 20_000
export type ConnectOptions = {
onStateChange?: (state: ConnectionState) => void
// Fires for every observable lifecycle event so the UI can render a
// detailed connection log. Useful when 'Connecting…' hangs forever
// (e.g. broken Tailscale route) and you need to see *where* it's stuck.
// Fires for every lifecycle event so the UI can show where 'Connecting…' is stuck (e.g. broken Tailscale route).
onLog?: ConnectionLogSink
}
@ -185,14 +147,10 @@ export function connect(
let handshakeTimer: ReturnType<typeof setTimeout> | null = null
let activityProbeTimer: ReturnType<typeof setInterval> | null = null
let intentionallyClosed = false
// Why: consecutive auth rejections since the last successful connect. We
// tolerate up to AUTH_RETRY_BUDGET (issue #5200) before latching auth-failed
// so a transient rejection doesn't force a needless re-pair. Reset to 0 on
// every 'connected'.
// Consecutive auth rejections; tolerate up to AUTH_RETRY_BUDGET (issue #5200) before latching to avoid a needless re-pair.
let authRejectionCount = 0
let lastConnectedAt: number | null = null
// Why: cheap diagnostics for RN/OkHttp process-state poisoning: do retry
// attempts differ, is anything inbound, and are closes instant or slow?
// Why: cheap diagnostics for RN/OkHttp process-state poisoning (retry cadence, inbound traffic, close timing).
let lastInboundAt: number | null = null
let inboundSequence = 0
let lastWsClosedAt: number | null = null
@ -200,7 +158,6 @@ export function connect(
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.
let sharedKey: Uint8Array | null = null
const serverPublicKey = publicKeyFromBase64(serverPublicKeyB64)
@ -218,9 +175,7 @@ export function connect(
stateListeners.add(onStateChange)
}
// Diagnostic: tracks how long we've been in the current state. Useful
// for spotting "stuck in connecting" or "stuck in reconnecting" cases
// in the logs.
// Diagnostic: dwell time in the current state, for spotting "stuck in connecting/reconnecting".
let stateEnteredAt = Date.now()
function rejectConnectWaiters(reason: string) {
@ -250,8 +205,7 @@ export function connect(
})
if (next === 'connected') {
lastConnectedAt = Date.now()
// Why: a clean handshake proves the token is valid — clear the auth
// retry budget so a future isolated rejection gets the full budget again.
// Why: a clean handshake proves the token is valid — reset the auth retry budget.
authRejectionCount = 0
for (const waiter of connectWaiters.splice(0)) {
if (waiter.timeout) {
@ -269,8 +223,7 @@ export function connect(
}
}
// Why: don't dump device tokens / full URLs into log scrolls; truncate to
// the host:port so reconnect lifecycles are still readable.
// Why: keep device tokens / full URLs out of log scrolls — truncate to host:port.
function redactedEndpoint(ep: string): string {
try {
const m = ep.match(/^wss?:\/\/([^/]+)/i)
@ -288,17 +241,13 @@ export function connect(
return Promise.reject(new Error('Client closed'))
}
if (state === 'reconnecting' && reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) {
// Why: past the retry cap the loop only trickles every 90s — callers
// must fail fast rather than hang on a host that's been unreachable
// for minutes. A trickle dial that succeeds flips state to 'connected'
// and later requests go through normally.
// Why: past the cap the loop only trickles every 90s — fail fast instead of hanging on a long-unreachable host.
return Promise.reject(new Error('Connection retry limit reached'))
}
return new Promise((resolve, reject) => {
const waiter: ConnectWaiter = { resolve, reject, timeout: null }
if (timeoutMs !== undefined) {
// Why: explicit per-request timeouts must include offline/reconnect
// waiting, not only the RPC after the socket becomes connected.
// Why: per-request timeouts must cover offline/reconnect waiting, not just the RPC after connect.
waiter.timeout = setTimeout(
() => {
const index = connectWaiters.indexOf(waiter)
@ -328,11 +277,7 @@ export function connect(
console.log('[net] openConnection', {
attempt: reconnectAttempt,
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.
// Why: diagnostic for RN/OkHttp pool corruption — high wsCount + repeated 1006 closes means process-state stuck.
wsCount: wsConstructionCounter,
msSinceLastConnected: lastConnectedAt != null ? now - lastConnectedAt : null,
msSinceLastClose: lastWsClosedAt != null ? now - lastWsClosedAt : null,
@ -354,8 +299,7 @@ export function connect(
if (ws === openingWs) {
return false
}
// Why: React Native can deliver callbacks from a timed-out socket after
// reconnect has swapped in a replacement; stale events must not mutate it.
// Why: RN can deliver callbacks from a timed-out socket after reconnect swapped in a replacement — ignore them.
console.log('[net] stale ws event ignored', {
eventName,
state,
@ -364,9 +308,7 @@ export function connect(
return true
}
// Why: React Native can leave TCP/WebSocket opens pending indefinitely on
// flaky network handoffs. Force the existing onclose reconnect path if
// onopen never arrives, instead of leaving the UI stuck at "Connecting...".
// Why: RN can leave opens pending forever on flaky handoffs — force reconnect if onopen never arrives.
connectTimer = setTimeout(() => {
connectTimer = null
if (ws === openingWs && openingWs.readyState === WEBSOCKET_CONNECTING_STATE) {
@ -396,9 +338,7 @@ export function connect(
setState('handshaking')
emitLog('success', 'WebSocket open', 'Starting E2EE handshake')
// Why: generate a fresh ephemeral keypair for each connection.
// This provides forward secrecy — compromising one session's key
// doesn't compromise past or future sessions.
// Why: fresh ephemeral keypair per connection provides forward secrecy.
const ephemeral = generateKeyPair()
const hello = JSON.stringify({
type: 'e2ee_hello',
@ -437,8 +377,7 @@ export function connect(
lastInboundAt = Date.now()
const raw = typeof rawData === 'string' ? rawData : null
// Why: during handshaking, e2ee_ready is plaintext because it precedes
// encrypted auth; e2ee_authenticated/e2ee_error are encrypted.
// Why: e2ee_ready is plaintext (precedes encrypted auth); e2ee_authenticated/e2ee_error are encrypted.
if (state === 'handshaking') {
if (raw === null) {
return
@ -481,9 +420,7 @@ export function connect(
removeStreamListener(id)
continue
}
// Why: setState('connected') notifies UI listeners synchronously;
// a listener may subscribe and send immediately before this
// reconnect replay loop resumes.
// Why: a UI listener notified synchronously by setState('connected') may already have sent this stream — skip it.
if (stream.sent) {
continue
}
@ -515,8 +452,7 @@ export function connect(
return
}
// Why: guard against decrypt with an invalid key — sharedKey can be null
// after destroy() or if a message arrives during a reconnect race.
// Why: sharedKey can be null after destroy() or a reconnect race — don't decrypt with an invalid key.
if (!sharedKey || sharedKey.length !== 32) {
return
}
@ -553,10 +489,7 @@ export function connect(
}
recordValidatedInboundTraffic()
// Why: a mid-session unauthorized may be a transient glitch, not a dead
// pairing (issue #5200). handleAuthRejection retries the handshake a few
// times before latching auth-failed, while still bounding churn via the
// budget so a genuinely revoked token doesn't reconnect forever.
// Why: a mid-session unauthorized may be transient (issue #5200) — handleAuthRejection retries before latching auth-failed.
if (!response.ok && response.error.code === 'unauthorized') {
handleAuthRejection('Unauthorized — pairing may be revoked')
return
@ -646,18 +579,12 @@ 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 (300ms3s) = 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.
// Why: time-since-construct classifies the failure — instant close = RST/unreachable, slow = SYN timeout/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: statically imported (not closure-built) — an earlier hot-reload
// bug came from a stale closure capturing a half-loaded module.
// Why: statically imported — a hot-reload bug came from a stale closure capturing a half-loaded module.
const closeEvent = describeSocketEvent(event)
console.log('[net] ws.onclose', {
code: e?.code,
@ -682,9 +609,7 @@ export function connect(
if (ignoreStaleSocketEvent('error')) {
return
}
// Why: RN surfaces network errors here (DNS failure, TCP RST, etc).
// onclose fires right after, but logging the error message gives us
// the original cause that the close code alone can hide.
// Why: RN surfaces the original network error here — onclose follows but its close code alone hides the cause.
const e = event as { message?: string } | undefined
const errEvent = describeSocketEvent(event)
console.log('[net] ws.onerror', {
@ -734,11 +659,7 @@ export function connect(
scheduleReconnect()
}
// Why: a token rejection (handshake e2ee_error/unauthorized or a mid-session
// unauthorized RPC) may be transient — issue #5200. Retry the full handshake
// up to AUTH_RETRY_BUDGET times before declaring auth dead, so a one-off
// glitch self-heals instead of forcing the user to re-pair. A genuinely
// revoked token fails every retry and latches auth-failed within seconds.
// Why: an auth rejection may be transient (issue #5200) — retry up to AUTH_RETRY_BUDGET times before latching auth-failed.
function handleAuthRejection(reason: string): void {
activeBrowserScreencastRequestId = null
pendingBrowserScreencastRequestId = null
@ -754,9 +675,7 @@ export function connect(
'Authentication rejected',
`Retrying (${authRejectionCount}/${AUTH_RETRY_BUDGET})`
)
// Why: close the current socket but DON'T set intentionallyClosed —
// we want handleSocketClosed to route into the reconnect path so the
// token gets a fresh handshake. rejectAllPending unblocks in-flight RPCs.
// Why: close without setting intentionallyClosed so handleSocketClosed routes to reconnect and retries the handshake.
const closing = ws
ws = null
sharedKey = null
@ -782,19 +701,11 @@ export function connect(
}
function scheduleReconnect() {
// Why: spinning fast reconnects forever drains battery and floods logs
// when the host is genuinely unreachable (wrong IP, port closed,
// host moved). Past GIVE_UP_AFTER_ATTEMPTS the UI surfaces a
// "Can't reach desktop, re-pair?" banner and the loop drops to the
// 90s trickle cadence instead of parking — a permanently parked loop
// could only be revived by an AppState/network transition, which a
// wedged VPN tunnel never produces.
// Why: past the cap, trickle (never park) — a parked loop only revives on a network transition a wedged VPN never produces.
const pastGiveUpCap = reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS
let delay: number
if (pastGiveUpCap) {
// Why: the counter holds at the cap — connection-health thresholds and
// the "Can't reach desktop" verdict key off attempts >= 12, and a
// successful open resets it to 0 anyway.
// Why: hold the counter at the cap — connection-health's "Can't reach desktop" verdict keys off attempts >= 12.
delay = TRICKLE_RECONNECT_DELAY_MS
rejectConnectWaiters('Connection retry limit reached')
} else {
@ -824,10 +735,7 @@ export function connect(
}
}
// Why: app-level liveness probe — see ACTIVITY_PROBE_INTERVAL_MS comment
// at the top of the file. Fires while the channel is in 'connected'
// state, sends a tiny status.get, and force-closes the WS if the probe
// fails (which the existing onclose path then turns into a reconnect).
// Why: app-level liveness probe (see ACTIVITY_PROBE_INTERVAL_MS) — force-closes the WS on failure so onclose reconnects.
function runActivityProbe() {
if (state !== 'connected' || !ws) {
return
@ -966,8 +874,7 @@ export function connect(
removeStreamListener(id)
return
}
// Why: sent streams may still reply with `ready`; keep a tombstone so we
// can immediately unsubscribe. Queued streams never reached desktop.
// Why: a sent stream may still reply `ready`; keep the tombstone to unsubscribe it (queued streams never reached the desktop).
if (!stream.sent) {
removeStreamListener(id)
}
@ -1013,11 +920,7 @@ export function connect(
hasKey: !!sharedKey,
state
})
// Why: if the state machine still thinks we're connected but the
// underlying WebSocket has flipped to CLOSING/CLOSED without onclose
// having fired (RN's WebSocket sometimes drops the event, or the
// server half-closed the stream), force a reconnect. Without this
// every send silently fails forever and the user sees a frozen UI.
// Why: RN can drop onclose, leaving state 'connected' over a dead socket; force reconnect or every send silently fails forever.
if (state === 'connected' && ws && ws.readyState !== WebSocket.OPEN) {
console.log('[net] sendEncrypted detected ws desync — forcing reconnect', {
readyState: ws.readyState
@ -1125,9 +1028,7 @@ export function connect(
if (pendingBrowserScreencastRequestId && pendingBrowserScreencastRequestId !== id) {
disposeBrowserScreencastStream(pendingBrowserScreencastRequestId)
}
// Why: browser screencast frames are connection-scoped and carry no
// stream id. Wait for the replacement stream's ready response before
// routing frames, so in-flight old-page pixels are dropped.
// Why: screencast frames carry no stream id, so route only after the new stream's ready to drop stale old-page pixels.
pendingBrowserScreencastRequestId = id
activeBrowserScreencastRequestId = null
}
@ -1140,9 +1041,7 @@ export function connect(
removeStreamListener(id)
}
} else {
// Stream is registered but the actual outbound subscribe will be
// sent (or re-sent) when the channel reaches 'connected'. Useful
// when terminals don't load — confirms the request is queued.
// Registered now; the outbound subscribe is (re-)sent once the channel reaches 'connected'.
console.log('[net] subscribe queued — waiting for connected', { method, state })
}
@ -1157,12 +1056,7 @@ export function connect(
return
}
if (stream?.method === 'terminal.subscribe') {
// Why: the runtime registers cleanup under the composite key
// `${terminal}:${clientId}` so two phones subscribing to the same
// terminal handle don't evict each other. Echo that composite key
// back on unsubscribe; also include `client.id` so the server can
// reconstruct it if a stale build emits a bare-handle id. See
// docs/mobile-presence-lock.md.
// Why: server keys cleanup by composite `${terminal}:${clientId}` so two phones don't evict each other. See docs/mobile-presence-lock.md.
const unsubscribeParams = buildTerminalUnsubscribeParams(stream.params)
if (unsubscribeParams) {
sendEncrypted({
@ -1211,20 +1105,14 @@ export function connect(
return
}
if (state === 'connected') {
// Why: the OS can kill the TCP path while the app is backgrounded
// without delivering onclose, leaving a half-open socket that
// blackholes input. Probe now so death is detected in ≤8s instead
// of waiting out the 20s interval (issue #5049).
// Why: OS can kill the TCP path while backgrounded without onclose; probe now to detect the half-open socket in ≤8s (issue #5049).
console.log('[net] foreground — probing live connection')
startActivityProbe()
runActivityProbe()
return
}
if (state === 'reconnecting') {
// Why: while backgrounded the retry loop may be sitting on a 60s
// backoff or 90s trickle timer. Returning to the foreground is a
// strong user signal — restart with a fresh attempt budget
// immediately instead of waiting out the timer.
// Why: foreground is a strong user signal — restart immediately instead of waiting out a 60s/90s backoff timer.
console.log('[net] foreground — restarting reconnect loop', {
attempt: reconnectAttempt,
hadTimer: !!reconnectTimer