Add Expo mobile client and wire up `dev:mobile` workflow#123

closed2
main
codething/ecef18ea
openedFeb 28, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 14d ago

Summary

  • Add new apps/mobile Expo React Native app with Uniwind styling and React Query state handling.
  • Implement mobile WebSocket client/transport to connect directly to the T3 backend, consume welcome/domain events, and fetch orchestration snapshots.
  • Add backend profile persistence (URL/token, active profile, last connected) via AsyncStorage.
  • Configure Expo app metadata and icons, including environment-based local icon selection for development vs production.
  • Update root docs and scripts to include the new bun run dev:mobile workflow.

Testing

  • Not run (lint/test execution not provided in the supplied context).
  • Manual verification suggested: start bun run dev:mobile, connect to a local server, verify snapshot refresh and domain event rendering.
  • Manual verification suggested: confirm development/production icon selection is reflected in Expo app config.

Note

Medium Risk Adds a new Expo mobile app with persistent server credentials and custom WebSocket request/push handling (including reconnection and token-in-URL), which could introduce connectivity/auth edge cases despite being largely additive.

Overview Adds a new Expo React Native app under apps/mobile (Uniwind styling + React Query) that connects directly to the T3 backend over WebSocket.

The mobile client persists multiple backend profiles (URL + optional auth token, active profile, last-connected timestamp) via AsyncStorage, and implements a lightweight WebSocket transport (WsTransport) plus a typed client (T3MobileClient) to fetch orchestration snapshots and subscribe to welcome/domain events.

Docs are updated to include the new dev:mobile workflow and list /apps/mobile in the workspace layout.

Written by Cursor Bugbot for commit c085e1d47c460a76b3ac13e72afbaba6b66fd9a6. This will update automatically on new commits. Configure here.

Note

Add Expo mobile client under apps/mobile and add dev:mobile workflow in package.json to run backend and mobile in parallel

Introduce an Expo-based mobile app with core UI, backend profile storage, and WebSocket transport, and add a root dev:mobile script plus docs and configs to run it alongside the backend. Key entry points include AppContent in apps/mobile/src/App.tsx, transport in apps/mobile/src/wsTransport.ts, client wrapper in apps/mobile/src/t3MobileClient.ts, and dev runner wiring in scripts/dev-runner.mjs.

πŸ“Where to Start

Start with the mobile app root AppContent in apps/mobile/src/App.tsx, then follow transport setup in apps/mobile/src/t3MobileClient.ts and apps/mobile/src/wsTransport.ts.

πŸ“Š Macroscope summarized c085e1d. 12 files reviewed, 22 issues evaluated, 2 issues filtered, 5 comments posted

πŸ—‚οΈ Filtered Issues

apps/mobile/src/App.tsx β€” 1 comment posted, 3 evaluated, 1 filtered
  • line 273: The useEffect hook triggers loadBackendState() inside an async IIFE without a try/catch block or .catch() handler. If loadBackendState rejects (e.g. due to AsyncStorage failure or JSON.parse throwing on corrupted data in parseStoredProfiles), the rejection is unhandled. In React Native environments, unhandled promise rejections can cause the application to crash, leading to a persistent crash loop on startup if the locally stored data is invalid. [ Cross-file consolidated ]
apps/mobile/src/wsTransport.ts β€” 2 comments posted, 12 evaluated, 1 filtered
  • line 65: The request method does not check if the transport is disposed before creating a request. If request is called after dispose() has been executed, it creates a Promise and calls send. The send method (Lines 220-223) detects this.disposed and aborts silently by clearing its interval, but it does not signal failure to the caller. Consequently, the Promise created in request hangs for the full duration of REQUEST_TIMEOUT_MS (20 seconds) before rejecting with a timeout error, causing significant unnecessary latency and resource holding. [ Out of scope ]
Synced from GitHub

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • πŸ” Trigger review
✨ Finishing Touches
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch codething/ecef18ea

Comment @coderabbitai help to get the list of available commands and usage tips.

apps/mobile/src/backendProfiles.tslines 139–149Outdated Β· c085e1dOpen thread
……@@ -0,0 +1,161 @@
146+ profiles: parseStoredProfiles(rawProfiles),
147+ activeProfileId: rawActiveProfileId && rawActiveProfileId.length > 0 ? rawActiveProfileId : null,
148+ };
149+}
MmacroscopeappBoton lines 139–149Feb 28, 2026

🟠 High src/backendProfiles.ts:139

If AsyncStorage.getItem fails, the error propagates uncaught to App.tsx, leaving loadingProfiles stuck as true and the app on the loading screen. Consider wrapping the calls in try/catch and returning a default empty state on failure.

diff
1+export async function loadBackendState(): Promise<StoredBackendState> {
2+ try {
3+ const [rawProfiles, rawActiveProfileId] = await Promise.all([
4+ AsyncStorage.getItem(BACKEND_PROFILES_KEY),
5+ AsyncStorage.getItem(ACTIVE_BACKEND_ID_KEY),
6+ ]);
7+
8+ return {
9+ profiles: parseStoredProfiles(rawProfiles),
10+ activeProfileId: rawActiveProfileId && rawActiveProfileId.length > 0 ? rawActiveProfileId : null,
11+ };
12+ } catch {
13+ return { profiles: [], activeProfileId: null };
14+ }
15+}
Also found in 1 other location(s)

apps/mobile/src/App.tsx:273

The useEffect hook triggers loadBackendState() inside an async IIFE without a try/catch block or .catch() handler. If loadBackendState rejects (e.g. due to AsyncStorage failure or JSON.parse throwing on corrupted data in parseStoredProfiles), the rejection is unhandled. In React Native environments, unhandled promise rejections can cause the application to crash, leading to a persistent crash loop on startup if the locally stored data is invalid.

πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file apps/mobile/src/backendProfiles.ts around lines 139-149:
2
3If `AsyncStorage.getItem` fails, the error propagates uncaught to `App.tsx`, leaving `loadingProfiles` stuck as `true` and the app on the loading screen. Consider wrapping the calls in try/catch and returning a default empty state on failure.
4
5Evidence trail:
6apps/mobile/src/backendProfiles.ts lines 139-152 (loadBackendState with no try/catch around AsyncStorage.getItem)
7apps/mobile/src/App.tsx line 120 (loadingProfiles initialized as true)
8apps/mobile/src/App.tsx lines 270-281 (useEffect calling loadBackendState without try/catch, setLoadingProfiles(false) only called on success)
9apps/mobile/src/App.tsx lines 303-308 (loading screen shown when loadingProfiles is true)
10
11Also found in 1 other location(s):
12- apps/mobile/src/App.tsx:273 -- The `useEffect` hook triggers `loadBackendState()` inside an async IIFE without a `try/catch` block or `.catch()` handler. If `loadBackendState` rejects (e.g. due to `AsyncStorage` failure or `JSON.parse` throwing on corrupted data in `parseStoredProfiles`), the rejection is unhandled. In React Native environments, unhandled promise rejections can cause the application to crash, leading to a persistent crash loop on startup if the locally stored data is invalid.
apps/mobile/src/backendProfiles.tsline 129Outdated Β· c085e1dOpen thread
……@@ -0,0 +1,161 @@
126+}
127+
128+export function buildSocketUrl(profile: Pick<BackendProfile, "url" | "authToken">): string {
129+ const parsed = new URL(profile.url);
MmacroscopeappBoton line 129Feb 28, 2026

🟠 High src/backendProfiles.ts:129

new URL(profile.url) throws if the stored URL is malformed. Consider wrapping in try/catch or validating the URL format in toBackendProfile to handle corrupted persisted data gracefully.

πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file apps/mobile/src/backendProfiles.ts around line 129:
2
3`new URL(profile.url)` throws if the stored URL is malformed. Consider wrapping in try/catch or validating the URL format in `toBackendProfile` to handle corrupted persisted data gracefully.
4
5Evidence trail:
6apps/mobile/src/backendProfiles.ts lines 124-132: `buildSocketUrl` function calls `new URL(profile.url)` without try/catch (REVIEWED_COMMIT)
7apps/mobile/src/backendProfiles.ts lines 32-47: `toBackendProfile` validates that `url` is a string but does not validate URL format (REVIEWED_COMMIT)
8apps/mobile/src/backendProfiles.ts lines 51-67: `parseStoredProfiles` loads from raw storage and calls `toBackendProfile` (REVIEWED_COMMIT)
apps/mobile/src/wsTransport.tslines 225–227Outdated Β· c085e1dOpen thread
……@@ -0,0 +1,256 @@
224+
225+ if (this.ws?.readyState === WebSocket.OPEN) {
226+ clearInterval(check);
227+ this.ws.send(JSON.stringify(message));
MmacroscopeappBoton lines 225–227Feb 28, 2026

🟒 Low src/wsTransport.ts:225

send lacks error handling. JSON.stringify and ws.send can throw; failures can lose the message and leave the request hanging, and the interval may not be cleared. Suggest wrapping both paths (immediate and retry) in try/catch, clearing the interval on success/failure, and rejecting the pending request (by id) on failure.

diff
1- if (this.ws?.readyState === WebSocket.OPEN) {
2- clearInterval(check);
3- this.ws.send(JSON.stringify(message));
4+ if (this.ws?.readyState === WebSocket.OPEN) {
5+ this.ws.send(JSON.stringify(message));
6+ clearInterval(check);
πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file apps/mobile/src/wsTransport.ts around lines 225-227:
2
3`send` lacks error handling. `JSON.stringify` and `ws.send` can throw; failures can lose the message and leave the request hanging, and the interval may not be cleared. Suggest wrapping both paths (immediate and retry) in try/catch, clearing the interval on success/failure, and rejecting the pending request (by `id`) on failure.
4
5Evidence trail:
6apps/mobile/src/wsTransport.ts lines 213-234 (REVIEWED_COMMIT): `send` method with no try/catch around `JSON.stringify` or `ws.send`; lines 219-229 show the interval retry logic where exceptions in the callback would be unhandled; lines 64-87 show `request()` method where `this.send(message)` is called inside Promise executor - synchronous throws are caught but async throws from the interval are not; line 26 shows REQUEST_TIMEOUT_MS = 20_000ms which is how long a failed retry request would hang.
apps/mobile/src/App.tsxline 100Outdated Β· c085e1dOpen thread
……@@ -0,0 +1,500 @@
97+ return "event";
98+}
99+
100+function formatEventTimestamp(event: OrchestrationEvent): string {
MmacroscopeappBoton line 100Feb 28, 2026

🟒 Low src/App.tsx:100

formatEventTimestamp returns "" when createdAt is missing, so events of the same type without timestamps produce duplicate keys (e.g., "log-"). Consider returning a fallback like a random ID or index to ensure uniqueness.

πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file apps/mobile/src/App.tsx around line 100:
2
3`formatEventTimestamp` returns `""` when `createdAt` is missing, so events of the same type without timestamps produce duplicate keys (e.g., `"log-"`). Consider returning a fallback like a random ID or index to ensure uniqueness.
4
5Evidence trail:
6apps/mobile/src/App.tsx lines 101-111 at REVIEWED_COMMIT:
7- Line 101-104: `formatEventTimestamp` returns `""` when `createdAt` is missing
8- Line 106-111: `eventKey` falls back to `${formatEventType(event)}-${formatEventTimestamp(event)}` when `eventId` and `id` are missing
9- Combined: events without timestamps/ids of the same type produce duplicate keys like `"log-"`
apps/mobile/src/wsTransport.tslines 99–104Outdated Β· c085e1dOpen thread
……@@ -0,0 +1,256 @@
101+ if (channelListeners && channelListeners.size === 0) {
102+ this.listeners.delete(channel);
103+ }
104+ };
MmacroscopeappBoton lines 99–104Feb 28, 2026

🟒 Low src/wsTransport.ts:99

The unsubscribe closure captures a stale channelListeners Set, which after resubscribe can cause deletion of the new Set. Suggest resolving the current set inside the closure via this.listeners.get(channel), removing the listener from that set, and only deleting the map entry if it’s the same set and now empty.

suggestion
1 return () => {
2 const currentListeners = this.listeners.get(channel);
3 currentListeners?.delete(listener);
4 if (currentListeners && currentListeners.size === 0) {
5 this.listeners.delete(channel);
6 }
7 };
πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file apps/mobile/src/wsTransport.ts around lines 99-104:
2
3The unsubscribe closure captures a stale `channelListeners` Set, which after resubscribe can cause deletion of the new Set. Suggest resolving the current set inside the closure via `this.listeners.get(channel)`, removing the listener from that set, and only deleting the map entry if it’s the same set and now empty.
4
5Evidence trail:
6apps/mobile/src/wsTransport.ts lines 89-103 (commit REVIEWED_COMMIT): The subscribe() method returns an unsubscribe closure at line 99 that captures the local `channelListeners` variable. When unsubscribe is called after a channel has been unsubscribed and re-subscribed, the closure still holds a reference to the old Set and incorrectly deletes the new Set from `this.listeners` via `this.listeners.delete(channel)` at line 101 because the old captured Set has size 0.
Ccursor[bot]reviewed Β· 2 inline threadsFeb 28, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is ON, but it could not run because the spend limit has been reached. To enable Bugbot Autofix, raise your spend limit in the Cursor dashboard.

apps/mobile/src/App.tsxline 207Outdated Β· c085e1dOpen thread
……@@ -0,0 +1,500 @@
204+ );
205+ void saveBackendProfiles(nextProfiles);
206+ return nextProfiles;
207+ });
Ccursor[bot]on line 207Feb 28, 2026

Async side effect inside React state setter function

Medium Severity

The setProfiles functional updater in connectToProfile calls saveBackendProfiles(nextProfiles) as a side effect inside the state setter callback. React state updaters must be pure functions β€” React may invoke them multiple times (e.g., in Strict Mode with React 19, which this project uses) or discard their results in concurrent rendering. This can cause saveBackendProfiles to write to AsyncStorage redundantly or even persist data from a discarded render.

Fix in CursorΒ Fix in Web

apps/mobile/src/App.tsxline 257Outdated Β· c085e1dOpen thread
……@@ -0,0 +1,500 @@
254+ async (profileId: string) => {
255+ const nextProfiles = profiles.filter((profile) => profile.id !== profileId);
256+ setProfiles(nextProfiles);
257+ await saveBackendProfiles(nextProfiles);
Ccursor[bot]on line 257Feb 28, 2026

Non-functional state update risks overwriting concurrent profile changes

Low Severity

removeProfile reads profiles from its closure and calls setProfiles(nextProfiles) directly, instead of using the functional updater form setProfiles(current => current.filter(...)). saveAndConnectProfile has the same pattern. Since connectToProfile correctly uses the functional updater for setProfiles, a rapid sequence of connect-then-delete could cause the direct setter in removeProfile to overwrite the lastConnectedAt update from connectToProfile.

Additional Locations (1)

Fix in CursorΒ Fix in Web

Added 2 commits

  • Add Expo mobile client and dev:mobile workflow0ea7755
  • Use local mobile app icons for dev and productiona950fde
……@@ -0,0 +1,256 @@
42+ return new TextDecoder().decode(raw);
43+ }
44+
45+ return null;
MmacroscopeappBoton line 45Feb 28, 2026

🟒 Low src/wsTransport.ts:45

Binary frames may arrive as Blob (default WebSocket#binaryType), so websocketRawToString drops them. Suggest setting ws.binaryType = "arraybuffer" in connect() or adding a Blob case.

πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file apps/mobile/src/wsTransport.ts around line 45:
2
3Binary frames may arrive as `Blob` (default `WebSocket#binaryType`), so `websocketRawToString` drops them. Suggest setting `ws.binaryType = "arraybuffer"` in `connect()` or adding a `Blob` case.
4
5Evidence trail:
6apps/mobile/src/wsTransport.ts lines 31-44 (`websocketRawToString` function - no Blob handling, returns null for unrecognized types), lines 120-149 (`connect()` method - no `binaryType` setting), lines 151-154 (`handleMessage` - silently returns on null messageText). WebSocket default binaryType is "blob" per MDN/spec.