feat(mobile): tell a never-connected host apart from an offline one

Merged
KI
2mo ago
+1697-249

What & why

Follow-up to #7642. On mobile, a computer that never had Remote Access turned on looked the same as one that dropped offline: "offline, will appear when it reconnects". The stopgap hint then asked "First time here?" of everyone, every time.

The relay already returns a durable lastSeenAt per host, null for one that has never opened a tunnel. The presence hook dropped it. It now rides along on the host, and Home splits on it:

  • Never connected (null): "One step left", the found computer by name, three stacked steps ending in the desktop toggle's exact label, and a waiting line that polls presence every 5s so the screen gives way on its own.
  • Offline (a timestamp): "Last seen 18 minutes ago", three likely causes, Try again, and Switch host only when another host exists.
  • Presence unavailable: a separate "Can't check " screen with Try again. It does not claim the computer is offline or list causes that blame it.
  • Presence pending: Home keeps loading instead of guessing. Before this, a cold start painted "is offline" for about half a second even for an online host.

The #7642 hint is removed. Design and references: https://app.superset.sh/page/mobile-offline-host-and-first-time-setup-heqfk4

New dependency. Hermes ships no Intl.RelativeTimeFormat, and nothing on a mobile screen catches the throw, so formatRelativeTime would have taken the screen down. Added @formatjs/intl-relativetimeformat (pinned, same family as the plural polyfill) with data for every shipped locale. The polyfill test now formats a relative time per locale and fails if a locale's data is missing. I confirmed it fails by removing one.

Today a host with Remote Access off does not register at all, so the never-connected state is rare until #7243 lands. This makes mobile ready for it.

Verification tooling (third commit, no product code). The edge pass that found the bugs above is now repeatable:

  • .agents/skills/mobile-sim-verification: the mobile counterpart to cdp-verification. What counts as evidence on a phone, how to run the local API, relay and Metro, and the traps (wrangler dev dying when a client aborts, CI=1 Metro not watching files, the worktree's Neon branch being deleted by the daily cleanup).
  • apps/mobile/scripts/e2e/stand-in-host.ts: registers a host, holds the relay control socket open, reads presence and deletes it, all through the API. No second machine, no SQL. Refuses a non-local API.
  • Maestro: sign-in.yml asserted a home-screen testID that exists nowhere and used launchApp, which stops at the dev launcher, so it could not pass. It now opens the bundle by link. host-states/ plus scripts/e2e/host-states.sh walk never connected, online, offline and Try again.
  • apps/mobile/AGENTS.md: Hermes ships a partial Intl and no screen has an error boundary; pending and failed are not answers.

Review by commit: feature, edge-case fixes, tooling, review fixes.

How I tested it

Recording of the whole flow (46s, real Home screen, local API and relay, stand-in host, idle waits cut): never connected, the automatic flip to online, dropped with last seen, Try again, then a cold start with the relay down and its recovery. · design page with the video

Screen recording: never connected, online, offline with last seen, Try again, can't check, recovery Never connected, online after the host connects, offline with last seen, and Switch host with a second host

End to end on an iPhone 17 Pro simulator (iOS 26.5): the real Home screen, signed in against a local API and the local relay worker, with a stand-in host.

  • Never connected: registered a host through host.ensure and never opened a tunnel. The relay reported lastSeenAt: null and Home showed "One step left".
  • Flip: opened a control socket to the relay as that host. The relay log shows the app polling presence every 5s, and within seconds Home turned the dot green and began dialing project.list and workspace.list through the relay. No tap needed.
  • Offline: closed the socket. Home showed "Last seen 1 minute ago", formatted by the new polyfill on Hermes.
  • Try again: reconnected the host right after a timed poll, then tapped Try again with Maestro. Exactly one presence request went out, 16s after the previous poll, and Home flipped online.
  • Switch host: hidden with one host. After registering a second, it appeared and opened the scope picker listing both.
    Edge cases, same setup:
CaseResult
Cold start, host onlineFound: "is offline" flashed for ~0.5s (before). Fixed: splash goes straight to Home (after).
Cold start, relay unreachableFound: said "is offline" with causes. Fixed: "Can't check" screen, then the real state once the relay is back and Try again is tapped (shots).
Relay dies mid-session, Try again tapped"Checking…", recovers, last-seen keeps ticking, no crash.
Clock skew, last-seen ahead of the phoneWould have read "in 3 seconds". Clamped to now, unit tested.
App backgrounded on the setup screen~1 presence request per 5s in the foreground, 0 in the background, resumes on return.
Host flapping, 6 connects in 12sStable, ends in the right state.
66-character host nameWraps, nothing clips (shots). The waiting line no longer repeats the name.
German, Japanese, largest text sizeLay out cleanly, relative time localized (shots).

Checked against Orca's phone app (stablyai/orca, mobile/src/transport/connection-health.ts), which has the same never-connected vs offline split. Two of the fixes above come from its rule that a failed load is not "offline". It shows no last-seen time, so the clamp is ours.

Independent review (fourth commit). A separate agent reviewed the diff and drove the app without my context. It found two regressions from this branch's own pending/unavailable split, both fixed and re-driven:

FoundFixRe-driven
Home spun forever (3m50s) when the API accepted connections and never answered. Presence waited on the relay-URL prime, which has no timeout, so the presence timeout never started. Main painted a bounded screen there.Relay URL read synchronously, prime only refines it. The deadline covers the whole request, token included.API and relay black-holed, cold start: "Can't check" by 18s and still there at 70s (frames).
"Can't check" flipped to a spinner on every 30s refetch and on Try again, unmounting the button. react-query zeroes failureCount as each fetch starts.A failure stays a failure until a success. Status rules are a pure function with tests.No flap across the refetch. Button reads Try again → Checking… → Try again in place (frames).
zh-TW showed Simplified relative-time words inside Traditional copy.zh-Hant data added.Test fails without it.
The 5s setup poll cancelled its own in-flight request and ran while another screen covered Home.cancelRefetch: false, poll only while focused.Code and unit level.

The review also confirmed: Cloud scope is unaffected, host selection does not jump when presence arrives, Hosts settings and the scope sheet are correct with the relay up, background and resume is correct, and all 16 locales keep their placeholders. Pre-existing and left alone: Hosts settings and the scope sheet still say "Offline" while presence is pending or unavailable, a second cold start with the API down drops the cached org and roster, and a relay-denied host would read as "check your connection".

Not covered: a real desktop host toggling Remote Access, and a real device. Two existing behaviours are left alone: going offline is noticed on the 30s presence interval, and right after a host drops Home can say "No projects on this host yet" until that next check. Orca debounces blips with an attempt count; worth a follow-up.

  • bun run check:i18n: pass, 12 new strings translated in 16 locales, the toggle label reused from each locale's existing desktop translation
  • bun run lint and bun test in apps/mobile: pass, 124 tests
  • sign-in.yml from a reset keychain, then scripts/e2e/host-states.sh: all four stages pass on the simulator, and the host is deleted through the API at the end

Checklist

  • PR title follows conventional commits (type(scope): subject)
  • bun run lint passes; typecheck left to CI (my fresh worktree shows 11 errors in two unrelated packages that CI does not)

Summary by CodeRabbit

  • New Features

    • Added clearer host connection states for setup pending, offline, and unavailable presence.
    • Offline hosts now show last-seen time, troubleshooting guidance, and a retry action.
    • Added Remote Access setup instructions, progress guidance, and automatic status checks.
    • Host switching appears only when multiple hosts are available.
  • Bug Fixes

    • Prevented pending or failed checks from incorrectly showing hosts as offline.
    • Prevented future-dated last-seen timestamps.
  • Localization

    • Added translated host connectivity and Remote Access guidance.
    • Added relative-time formatting across supported locales.