Mobile Access — when something is wrong (part 3)
Part 3 of the Mobile Access page: the troubleshooting walkthrough for a phone connection that will not behave, followed by the internals — how the mobile transport, session handling and resume actually fit together for anyone working on the code.
What it is
This is part 3 of the Mobile Access page. It is the half you reach for when the phone will not connect or will not stay connected.
Where to find it
The troubleshooting section is for reading while you are in front of a phone that is misbehaving; the internals are agent- and developer-facing.
How it behaves
Troubleshooting
When something is wrong, the Settings → Remote Access pane in Omniscio shows one of four banners with verbatim copy. Match what the user is seeing on screen against the headers below to pick the right fix. Omniscio polls Tailscale every 5 seconds while the pane is open, so any state change (login, online/offline, ACL grant) takes up to 5s to reflect — close and re-open the pane to force an immediate re-check.
Banner: "Tailscale is required for mobile access"
Body text: "Tailscale creates a secure private network so you can access Omniscio..."
Cause: Omniscio can't find the tailscale binary. Omniscio resolves it against the live system / login-shell PATH first (Windows registry; sh -lc 'echo $PATH' on macOS/Linux), then falls back to standard install paths: C:\Program Files\Tailscale\tailscale.exe and C:\Program Files (x86)\Tailscale\tailscale.exe on Windows; /usr/bin/tailscale, /usr/local/bin/tailscale, /opt/homebrew/bin/tailscale (Apple-Silicon Homebrew), /usr/sbin/tailscale on macOS/Linux. The PATH hit is version-checked before it's trusted, so a dead wrapper (e.g. a leftover /usr/local/bin/tailscale shim pointing at an uninstalled /Applications/Tailscale.app) is skipped and detection continues to the working binary.
Fix: Install Tailscale from https://tailscale.com/download. Omniscio re-reads the live PATH on each detection (on every platform — a GUI app launched from Finder/Dock or the tray doesn't inherit the shell PATH, so a Homebrew / nvm / custom-prefix install you can run in a terminal is found this way), so an install done after Omniscio launched is picked up automatically — but if the banner doesn't clear within ~5 seconds, close and re-open the Settings pane to force a re-check, or restart Omniscio. If the banner persists with Tailscale clearly installed, the binary is in a non-standard path; reinstall to the default location.
Banner: "Tailscale needs sign-in"
Body text: "Open Tailscale and sign in to enable mobile access."
Cause: The Tailscale daemon reports BackendState: NeedsLogin or NoState. Tailscale is installed but no user is signed in.
Fix: Click the Tailscale icon in your system tray → Log in. A browser window opens; sign in with the Google or Microsoft account on your tailnet. The banner clears within ~5s of successful login.
Banner: "Tailscale is offline"
Body text: "Check your internet connection. Mobile access will be available when Tailscale..."
Cause: Logged in, but the daemon reports Self.Online: false. Usually transient network loss, sleep/resume, or the Tailscale daemon paused.
Fix: Confirm internet works on the PC. Right-click the Tailscale tray icon — if it shows Disconnected or Paused, click Connect/Resume. If the daemon is wedged, Exit Tailscale from the tray and re-launch from the Start menu / Applications folder.
My phone can't open the private address
Mobile Access is private by default: the https://<machine>.<tailnet>.ts.net/ address only opens on devices signed in to the same Tailscale account as the desktop. Anything else (a phone without the Tailscale app, one signed in to another account, or one with Tailscale switched off) gets no connection at all, which is the point.
Fix: install the Tailscale app on the phone, sign in to the same account, turn it on, then scan the pairing QR again. If a device genuinely cannot run Tailscale, either pair over the same Wi-Fi, or turn on Allow access from the public internet in Settings → Remote Access → Advanced (desktop only), which switches to Tailscale Funnel.
Notice: "HTTPS certificates are turned off"
Body text: "Tailscale access is off because HTTPS certificates are turned off for your Tailscale network. Turn them on, and Mobile Access connects automatically."
Cause: private (and public) Tailscale access needs a certificate for <machine>.<tailnet>.ts.net, and the tailnet's HTTPS certificates setting is off. Tailscale answers tailscale serve --bg with an enable-HTTPS prompt instead of registering the address. Omniscio treats this as a permanent setup problem: it shows the notice (also after a failed start at boot), offers Open Tailscale admin to fix this (Tailscale's own one-click link when it printed one, otherwise https://login.tailscale.com/admin/dns), and the background reconciler backs off for 10 minutes instead of retrying every minute.
Fix: open the link, turn on HTTPS certificates in the Tailscale admin console's DNS page, then wait (or toggle Mobile Access off and on). No change on the phone is needed.
Banner: "Tailscale Funnel needs approval"
Body text: "Toggle Mobile Access on and your browser will open to approve Funnel access. Just click approve and you're set."
This appears only when public access is turned on (Settings → Remote Access → Advanced → Allow access from the public internet). Private access, the default, needs no Funnel permission, so a tailnet without it still reaches the ready state and starts privately.
This is the most common point of confusion in public mode. Funnel requires two independent permissions, both of which can be missing:
- Device capability — the machine must have the
funnelcapability advertised in the tailnet (visible intailscale status --jsonunderSelf.CapMap, keyhttps://tailscale.com/cap/funnel). - Tailnet ACL policy — the tailnet's admin policy must explicitly grant Funnel to this device. On free Tailscale plans this is a one-time toggle.
When the user clicks the tunnel toggle and gets this state, Omniscio also surfaces a clickable Open Tailscale admin to fix this link. Omniscio parses that URL out of the Tailscale CLI's stderr from tailscale funnel --bg <port> and falls back to https://login.tailscale.com/admin/acls if no URL is embedded in the error.
Fix: Click the admin link in Omniscio's banner, approve Funnel for this device in the Tailscale admin console, then return to Omniscio and toggle the tunnel again. If the admin link doesn't appear, visit https://login.tailscale.com/admin/acls directly and confirm the policy grants Funnel to this device's tag or user.
Tunnel toggle won't stay on, no clear banner
The toggle flips on then back off without an obvious banner. Likely causes:
- Port conflict — default
9876is already bound by another process. Try a different port (range 1024–65535; uncommon picks like19876or28765avoid most conflicts). - First-time certificate provisioning — Tailscale provisions a LetsEncrypt cert the first time the address is used over HTTPS (private or public), which can take 30–60 seconds. Retry once after a minute.
- Tailscale daemon mid-restart — the daemon is briefly unreachable; wait 30 seconds and retry.
Just restarted the PC and the tunnel isn't up yet
On app launch, if tunnelEnabled is on, Omniscio tries to start the tunnel in the background. Tailscale's own daemon (and the system PATH) may still be initializing — production app launches frequently beat Tailscale to ready state. Omniscio retries up to 8 times with exponential backoff — 7 inter-attempt delays of (3s, 6s, 12s, 24s, 48s, 60s, 60s), a ~3.5-minute window. The retry decision lives in isRetryableTunnelStartError() (src/main/services/tunnel-service.ts), called from the boot loop in src/main/index.ts. It is a permanent-error deny-list, not a retryable allow-list: the only failures that stop immediately are Funnel not enabled in the tailnet admin console (public mode; an ACL decision a retry can't change) and HTTPS certificates turned off (private or public; an admin-console setting, see the notice above). Every "not ready yet" shape retries — tailscale status failing before the daemon's local API answers (Failed to get Tailscale status: …), the binary not yet on PATH (not installed), not-logged-in, offline, and a not-yet-populated DNS name.
A busy machine can't fake "not installed" anymore. Right after a restart the PC is often slammed (dozens of sessions resuming), and the 5-second tailscale version check Omniscio runs during binary discovery can time out even though Tailscale is installed and healthy. Discovery (src/main/services/tunnel-service.ts) now distinguishes the two: a probe that was killed on timeout for a binary that exists on disk is trusted provisionally (used immediately, re-verified on the next check), while a fast failure — missing file, dead shim — is still rejected. Before this (2026-07-02), one slow probe made Omniscio conclude "Tailscale is not installed" for 8 minutes, blinding both the boot retry and the reconciler; see the discovery-timeout postmortem. Concurrent start attempts also now share one run instead of bouncing off each other with "already in progress" errors.
Beyond that boot window, a background Tunnel Reconciler keeps it covered. The 8-attempt boot loop is only the fast path. If Tailscale isn't ready within those ~3.5 minutes — e.g. it's mid auto-update right after a reboot, so it reports "not logged in" or its status command keeps failing past the window — the boot loop gives up, but a separate Tunnel Reconciler (src/main/services/web/tunnel-reconciler.ts) takes over: every 60 s, while tunnelEnabled is on and the funnel is down, it re-attempts startTunnel() until Tailscale is ready and the funnel comes up on its own — no toggle, no restart. It reuses the same permanent-vs-transient classifier (a genuine Funnel not enabled ACL error backs off 10 minutes instead of hammering the CLI), is a quiet self-heal (no inbox alert — the funnel just reappears), and is default-on with an AMC_DISABLE_TUNNEL_RECONCILER=1 kill switch. This closes the gap where rebooting during a Tailscale update left mobile dead with tailscale funnel status showing "No serve config" even though Tailscale itself was healthy. Invariants: tunnel-reconciler-contract.md.
And it no longer trusts "the tunnel started" to mean "your phone can reach it" (2026-07-05; split by mode 2026-09-26). In private mode (the default) the reconciler checks Tailscale's own record instead (tunnel-registration-probe.ts): tailscale serve status --json must list our address, pointing at our port, and not public. Missing, pointing elsewhere, or public counts as not serving and is repaired after the usual 3 checks; a record it cannot read counts as "couldn't check" and never triggers a teardown. The public mode check below is unchanged. tailscale funnel --bg can return success while Tailscale's public ingress never actually routes to the machine — observed when the command runs while the box is still CPU-starved right after a restart. The old reconciler trusted that "the funnel is up" flag and went silent; the phone stayed dead for 12 minutes despite a logged "Tunnel started", until a manual off/on. Now, when the funnel is registered, the reconciler verifies the real public path your phone uses (funnel-health-probe.ts) — a check that deliberately bypasses a trap: on the host, MagicDNS resolves the ts.net name to the machine's own tailnet IP, so a naive probe would pass even when the outside world can't connect, so the probe resolves the public ingress via public DNS and dials it with the funnel's SNI. If a registered funnel isn't actually serving for 3 consecutive checks (~3 min — a lone blip never triggers it), it re-establishes cleanly — funnel off then funnel --bg, the automated equivalent of toggling it off and on — with a 5-minute anti-flap cooldown, and keeps going until your phone can reach it. Invariants I10/I11 in the same contract.
And it will not tear a working tunnel down just because it couldn't check (2026-09-09). That check answered a plain yes/no, so "I couldn't tell" and "it's broken" were the same answer — and the response to both was the rebuild, which disconnects every phone. On a busy machine the public address lookup failed in roughly hour-long stretches, and a tunnel that was demonstrably fine (200s straight through both public addresses, and a real phone loading the app mid-window) was rebuilt 30-plus times a day, with nothing in the log saying why. Rebuilding cannot fix an address that won't resolve, so the remedy was powerless against the actual cause and maximally destructive. The check now answers yes / no / couldn't-tell, always with a written reason; only a proven "no" rebuilds anything. The lookup also retries, tries every address it gets back rather than only the first, is capped by a hard overall deadline, and remembers the last address that actually worked so a bad patch of lookups still yields a real answer. If it genuinely can't verify for ~30 minutes it makes one rebuild attempt anyway, so a truly dead tunnel still can't sit unhealed. See the funnel false-teardown postmortem.
On Windows it also recovers a dead Tailscale GUI (2026-07-27). Tailscale's background service runs headless, but on Windows its backend needs the per-user Tailscale GUI (tailscale-ipn.exe) connected to reach Running/online — and a Windows Update to the WindowsAppRuntime (the GUI's own runtime) can kill that GUI so it never relaunches, leaving the backend wedged in BackendState: NoState, offline, with the funnel gone. A plain Tailscale-service restart or tailscale up does not clear this — only relaunching the GUI does (confirmed live: service restart + tailscale up both failed; relaunching tailscale-ipn.exe took the backend NoState → Running in ~15 s). So when the reconciler finds the funnel down and the backend in NoState on Windows, it relaunches tailscale-ipn.exe itself (no admin needed) before re-attempting the start; the backend comes online within seconds and the next tick brings the funnel back — no tray-icon fiddling required. Windows-only, cooldown-guarded (won't relaunch more than once every 2 min), quiet (log-only). Invariant I12 in the same contract.
Fix: Just wait — the tunnel self-heals on its own: first the boot retry (~3.5 min), then the background reconciler (every 60 s thereafter), so a slow or mid-update Tailscale is covered without any action once it finishes coming up. You only need to step in if Tailscale is genuinely misconfigured — e.g. Funnel isn't enabled in your admin console; fix that and the next reconcile tick brings the funnel up (toggling the tunnel switch off then on forces an immediate attempt). History: the retry filter has twice been too narrow and reintroduced an HTTP-421 boot race — the phone shows "Misdirected Request" because the tunnel hostname never reaches the web-access server's DNS-rebinding allow-list. Once for not installed (pre-91361ed4b, 2026-05-14) and again for the status-command failure (2026-06-24); inverting to a deny-list closes the whole class. See remote-access-schema-and-tunnel-boot-race postmortem.
A second HTTP-421 case — reaching the machine directly by name+port on the tailnet (2026-08-02). Separate from the boot race above: from another Tailscale device you can reach Omniscio directly at http://<machine>.<tailnet>.ts.net:<port>/ (over the tailnet, NOT the public Funnel). Previously only the numeric form http://100.x.y.z:<port>/ passed and the name form returned 421 "Misdirected Request" — the 100.x Tailscale address is a detected NIC (added to the allow-list bare AND with the bound port), but the MagicDNS name entered the allow-list only in its bare form (correct for the public Funnel, which terminates TLS on 443 so the browser strips the port). buildHostAllowList() (web-access-guards.ts) now also adds the <hostname>:<boundPort> variant, so name+port direct-tailnet access passes while a rebound attacker host (with or without a port) is still rejected.
Keeping the mobile launcher current
The mobile app checks the renderer build id whenever it starts or reconnects. It accepts the first build it sees without interruption. If Omniscio later serves a different build, the phone records that build and refreshes once, so new launcher options and other UI changes arrive automatically without a reload loop. A phone already running a build from before this protection was installed needs one manual refresh to receive it; later builds synchronize automatically.
URL works on PC but not on phone
- Phone isn't on Tailscale: install Tailscale from the App Store / Play Store and sign in with the same account used on the PC. Mixing Google login on PC with Microsoft login on phone creates separate tailnets that can't see each other.
- Token mismatch: if the user regenerated the token in Omniscio, the old bookmarked link carries the old token and returns HTTP 401. Copy the new URL from Omniscio and re-bookmark.
- HTTPS browser warning: the public
*.ts.netURL has a real LetsEncrypt cert; a cert warning usually means the phone is hitting an old IP-only LAN URL on cellular instead of the Tailscale URL.
Page opens but stays on the loading screen
If the document itself opens but Omniscio never mounts, this is different from a dead tunnel. A cold phone boot requests many compressed JavaScript chunks at once; older builds compressed each uncached chunk synchronously, which could block the mobile listener long enough for Tailscale requests to time out. Static Brotli/Gzip cache misses now compress off the listener's event loop, so other boot requests keep moving while the cache warms. Restart Omniscio after installing the fixed build, then refresh the phone once. Do not clear browser data or regenerate the pairing token unless the page explicitly reports an authentication problem.
Phone says "Connection lost" forever, and reloading never helps (fixed 2026-09-08)
This was not a dead tunnel and not a slow boot — it was a rejected credential wearing a network error's clothes, and it was unrecoverable from the phone.
What the user saw. The phone shows "Connection lost — tap to reload". Tapping reload re-shows the same screen, forever. Nothing appears on the desktop to approve the device, because a phone is only recorded as a device after its socket authenticates — a phone that never authenticates is invisible. Measured on a real device 2026-09-07: five rejected auth attempts, an IP lockout, then a permanent dead screen.
Why it happened. Two independent faults stacked:
- The WS transport treated a
4001close (the server refusing this device's token — rotated, expired, or never valid) as the same terminal state as a dead network, so it raised the reload banner. But a reload cannot fix an auth problem. - Worse, the reload could not even reach the server: the service worker answered every
top-level navigation from the cached app shell, so
/loginreturned the app instead of the sign-in page. That also silently broke the pairing QR code and the pairing link on any phone that had ever loaded the app — every route back in was dead at once.
What happens now.
- A rejected credential raises the "Pair this device" card inside the already-loaded app,
with a box for the access token. It exchanges the token through the existing
POST /loginand reconnects in place — afetch, never a navigation, so nothing in the cache can defeat it. This is what rescues a phone that is already stuck (it still runs the old service worker until its cache updates). - The service worker now lets
/logingo to the network, so the QR code, the pairing link, and a hand-typed address work again for everyone. That stops new phones getting stuck. - The card accepts a pasted sign-in link as well as a bare token, since that is usually what the user has in hand.
- A wrong token, a lockout, an unreachable computer, and a server fault each get their own plain-English message, and the card stays usable so the user can retry. A genuine network drop still behaves exactly as before — this does not swallow real outages.
Contracts: mobile-pairing-rescue-contract.md (P1–P10) and mobile-service-worker-cache-contract.md (S1-serverowned).
Attaching a photo refreshes the page the first time after an update (fixed 2026-09-24)
What the user saw. Right after the desktop app restarted on a new version, the first photo attached on the phone — usually to a new session — made the page refresh and the photo vanish. Attaching again worked.
Why it happened. The phone moves itself onto a new version by reloading quietly the next time the page is hidden, on the idea that nobody is looking. But opening the photo picker also hides the page, so that one reload fired while the user was choosing a photo. The reload also brought the phone up to date, which is why the second try worked.
What happens now. A page hidden by its own photo/file picker no longer counts as "nobody is looking": the update waits and happens the next time the user actually leaves the app. The phone's diagnostic breadcrumbs also reach the desktop log again, so a case like this now leaves a trace. Contract: mobile-service-worker-cache-contract.md (S1a-picker).
Manual debug commands
Run these in a terminal on the user's PC to confirm what Omniscio is seeing — these are the exact commands Omniscio runs internally:
tailscale status --json— full status dump. InspectBackendState,Self.Online,Self.DNSName,Self.CapMap.tailscale serve status --json— what Tailscale is serving. A private entry is aWebentry for<machine>.<tailnet>.ts.net:443proxying tohttp://127.0.0.1:<port>; if the same address also appears underAllowFunnelwithtrue, it is public.- Private mode (default):
tailscale serve --bg <port>to start (replace<port>with the value in Settings → Remote Access; default9876) andtailscale serve offto stop. These are the exact commands Omniscio runs; if the start prints an enable-HTTPS prompt, HTTPS certificates are off for the tailnet. - Public mode:
tailscale funnel --bg <port>to start andtailscale funnel offto stop;tailscale funnel statuslists active funnels.
If the matching command works in a terminal but Omniscio's toggle fails, capture the stderr and check Omniscio's main log at %APPDATA%\omniscio\logs\main.log (Windows) or ~/Library/Logs/omniscio/main.log (macOS) for [tunnel-service] lines.
Token regeneration behavior
Clicking Regenerate in Settings → Remote Access generates a new UUID token, saves it to config.json, and restarts the web-access server. All open phone connections disconnect on the next WebSocket reconnect (~10s). Old bookmarks return HTTP 401 — re-copy the URL from Omniscio.
App quit cleanup
When Omniscio quits, it releases its Tailscale entry with tailscale serve off (private) or tailscale funnel off (public), matching the mode the tunnel was started in. If the Tailscale daemon has crashed, that command can hang briefly — app quit waits up to ~10s before forcing close.
For agents
How it works
The PWA manifest is /src/renderer/public/manifest.webmanifest (icons at /icon-192.png and /icon-512.png in the same directory); <link rel="manifest"> lives in /src/renderer/index.html. The web-access server allowlists /manifest.webmanifest and the icon paths for unauthenticated fetch — Chrome must read the manifest and icons before the user has presented a token, otherwise it never offers "Install app". Any change to the manifest fields (name, start_url, display, icons) is locked in by /tests/unit/lint/pwa-manifest.test.ts — drop display: "standalone" or remove the <link rel="manifest"> tag and CI fails.
The HTTP + WebSocket server is /src/main/services/web/web-access-server.ts. Each WebSocket client declares a subscription scope — all (full desktop parity), inbox (sidebar awareness, the mobile default), or session (per-session events + inbox awareness). The INBOX_ALLOWED_CHANNELS allow-list — now in /src/main/services/web/web-access-push-filter.ts (push-subscription filtering was split out of web-access-server.ts) — filters the push stream to what the mobile UI actually needs. It has grown well past its original core set (SESSION_STATUS_CHANGED, SESSION_ENDED, SESSION_RENAMED, SESSION_LAUNCHED, SESSIONS_REORDERED, PROJECTS_CHANGED, SETTINGS_CHANGED, ACCOUNTS_CHANGED, ACCOUNT_USAGE_UPDATED, …) to ~109 channels — recovery-queue overlays, weekly summary, AI-manager overlays, approval families, board integrations, and more — so most push streams the mobile UI cares about now reach the phone. Push events are gzip/brotli compressed and batched in a 150 ms flush window. The bootstrap payload (WEB_BOOTSTRAP in /src/main/ipc/web-access-handlers.ts) keeps only a trimmed slice — UI/feature settings (theme, feature flags, and tour dismissals — the WEB_BOOTSTRAP_SETTING_KEYS tuple, so a tour dismissed on desktop won't re-prompt on the web client), projects, active sessions, accounts, and a VAPID key — about 3–5 KB instead of the full ~160 KB desktop hydration — and the remaining sessions are fetched on idle via requestIdleCallback. The settings UI is /src/renderer/src/features/settings/sections/web-access/WebAccessSettings.tsx (it also drives the Tailscale install/login/Funnel detection). The mobile session list is /src/renderer/src/features/dashboard/MobileSessionsList.tsx (virtualized, lazy-loaded). Full design + triage flow: feedback_web_access_mobile.md; user-facing marketing page: /docs/intro-sandbox/landing-page.html.
Bootstrap LAM (last-agent-message seed)
The bootstrap payload's sessions[] rows carry an extra field — lastAgentMessageContent: string | null — populated for two status groups (a single correlated subquery with two CASE branches in /src/main/db/queries-sessions/lifecycle.ts listActiveSessions), so a mobile tap shows content instantly instead of a spinner:
- inbox-attention sessions (
needs_you,error,stalled): the final agent message's prose body (summary_prosepreferred,contentfallback, asides excluded viasidechain_group_id IS NULL), capped atLAM_BOOTSTRAP_CAP_CHARS = 51200(~50 KB). - live working sessions (
running,starting): the last settled agent message —summary_proseONLY (nocontentfallback), gated onsummary_prose IS NOT NULL. That filter is load-bearing: the in-flight partial row has null prose mid-stream, so it is skipped and a streaming session never seeds a raw, half-streamed, tool-marker-laden snippet. Capped tighter atLAM_LIVE_SEED_CAP_CHARS = 4096(4 KB) because many live sessions can ship at once and the bootstrap re-runs on every mobile resume — a generous cap × N live sessions would bloat the very payload the seed exists to speed up.
For every other status (and for any seeded row with no eligible agent message yet) it is null. The Zod schema field is .optional() (rather than required) so an old mobile client talking to a new server still parses: the consumer reads ?? null. The status sets are derived literals (LAM_ATTENTION_STATUS_LITERALS, LAM_LIVE_STATUS_LITERALS) — static SessionStatus members, no injection surface.
The renderer uses this to eliminate the cold-mount blank screen. On mobile over Tailscale the lite history fetch can take 1–3 s to round-trip — longer when the single main thread is saturated under a many-session storm — so without a seed, tapping an attention OR running session shows an empty panel with a spinner. With the seed, SessionPanel.tsx builds a synthetic ConversationMessage (id lam-seed-<sessionId>, source: 'agent') from session.lastAgentMessageContent and renders it via the normal MessageBubble path the moment messages.length === 0 and the LAM is non-empty — and it is status-agnostic, so extending the seed to running/starting needed no renderer change. As soon as the real history fetch resolves and messages.length > 0, the normal virtual list takes over and the seed disappears naturally — no merge logic, no flicker. The seed is purely a render-time short-circuit; the conversationCache is never touched. If the LAM is absent (a status outside the seeded set, or a seeded row with no eligible settled agent message yet — e.g. a running session whose only agent output so far is the in-flight partial), the panel falls back to the existing spinner. Backend tests pin the SQL invariants (tests/unit/db/queries/queries-sessions.test.ts § lastAgentMessageContent (bootstrap LAM)); renderer tests pin the seed → real-message handoff (tests/unit/features/sessions/SessionPanel-bootstrap-lam-seed.test.tsx).
Running-session taps prioritize freshness. A mobile tap now selects the session before it
reads the foreground seed, which establishes the session-scoped WebSocket subscription first. A
running session also bypasses the settled-session transcript deferral and immediately loads its
bounded recent history. That one reconciliation fetch closes the gap for output emitted before the
new subscription existed; output emitted afterward continues over the live push stream. Settled
sessions keep the cheaper seed-first glance path, so this freshness guarantee does not restore eager
transcript work across every mobile tap. The ordering is locked by
use-dashboard-search-handlers.test.tsx,
and the running-versus-settled deferral decision is locked by
mobile-lazy-transcript-flag.test.ts.
Bootstrap agent-board seed
The bootstrap payload also carries agentBoard: AgentBoardEntry[] — the Agent Status Board rows (one per live agent), gated on the opt-in agentStatusBoardEnabled (ships [] when off, so a disabled board costs nothing on the hot path — getBoard() is never called). The renderer seeds useAgentStatusBoardStore from it synchronously at first paint (hydrateFromBootstrap), at BOTH the initial-load (useAppBootstrap.ts) and reconnect (App.tsx) bootstrap sites. Without it the per-session Agent Board strip — a sibling ABOVE the scroll container in SessionPanel — got its data only from the async AGENT_STATUS_BOARD_GET invoke, which on mobile resolves over the WS link AFTER the panel painted and the scroll engine settled; the strip then mounted late (entryFor undefined→entry) and shoved the just-positioned message down ~30px ("opened a session, then it scrolled up a little"). Seeding at first paint removes the late mount. The boot hydrate() in useAppPushListeners.ts still refetches afterward (convergent — same getBoard() source), now into an already-rendered strip. Live board updates now reach mobile too — AGENT_STATUS_BOARD_CHANGED is in INBOX_ALLOWED_CHANNELS, so after this initial seed the board refreshes live via the App-level usePushListener (debounced) on every client. Locked by the web-bootstrap producer-payload test + the store hydrateFromBootstrap test + the push-filter delivery test. Full invariant: agent-status-board-contract.md the-live-push-reaches-mobile-too / the-per-session-strip-is-seeded-at-first-paint.
Related
- Mobile Access — the overview page this continues.
- Mobile Access part 2 — the connection behaviour that usually explains what you are seeing.
- mobile-perf-prevention.md — how the phone app is kept fast to open.
Last verified 2026-09-28