Expo + React Native. List/form screens render @expo/ui (real SwiftUI); HeroUI Native
covers the RN surfaces that cannot cross over ("What deliberately stays React Native" below).
Reaches the host through the server tunnel; business data still travels over transport +
@linkcode/schema, the same contract as every other client.
The web renderer conventions do NOT apply here.
.claude/rules/frontend.md (coss-ui, createBrowserRouter,
sdk+tayori+SWR, react-hook-form) targets the Vite/DOM renderers — none of it holds for React
Native. Mobile consumes @linkcode/ui only through its native components
(packages/presentation/ui/src/native/**), never its coss-ui web parts.
src/runtime/**+src/stores/**own transport, connection, and data-plane wiring;src/app/**is route shells only;src/components/**is presentation grouped by surface (shell/,form/,account/,connect/,host/,conversation/,terminal/), with private children of one parent under that parent (conversation/prompt-dock/*).- The hooks exported from
runtime/are the seam — extend a return value, never reshape one, so UI and runtime work can land in parallel. Runtime must not import presentation: a type both sides share (e.g.TerminalRendererRef) is owned by the hook that drives it. - A
runtime/hook must not hand out aRefObject—react-hooks/refsthen taints every property read of that result at the call site ("Cannot access refs during render"). Expose a callback ref instead (setRendererinuse-terminal-session.ts), and destructure a hook's result at the top of the component rather than readinghook.xinside JSX — the same rule fires on member reads it cannot prove. - Terminal:
runtime/use-terminal-session.tsownsLinkCodeClient, attachment/controller state, and all network I/O; the route only renders and navigates. The canvas is the native ghostty surface fromexpo-libghostty: PTY bytes go through the string API (writeText/onInput.text, matching the UTF-8 wire), the daemon's headless terminal is the sole reply authority, and the grid always tracks the local layout — a resize by another controller reflows instead of forcing its cols/rows (read-only fidelity limitation). The package'spostinstalldownloads checksum-pinned native binaries (GhosttyKit.xcframework on iOS; per-ABI libghostty-vt static libs on Android, rendered by the package's own Kotlin Canvas renderer) — it must stay in rootallowBuilds:, and adding/upgrading it changes the native fingerprint (new dev build). src/polyfills.tsis imported first in the root layout, and the connection depends on it. RN installsAbortController/AbortSignalfrom the 2019abort-controllerpackage — nosignal.reason, nothrowIfAborted()— sofoxts/async-retry's openingoptions.signal?.throwIfAborted()(the optional chain guards the signal, not the method) threw before doing any work and the client never reached the network at all (CODE-462); from the outside that read exactly like an unreachable host. When afoxts/web API behaves differently here than under Node, suspect this class first and probe the runtime rather than reasoning from the API's documentation.
Settings, terminal appearance, and connect render a real Form inside a Host (style={{flex:1}}
useViewportSizeMeasurement, or the Form collapses to its content). Each trap below was found only by driving the simulator:
- A view's hit area is its content, not its row.
LabeledContentsizes aTextFieldto the text it holds, so a field with a short placeholder is effectively untappable. UseHStack { Text, TextField }, and give any hand-built tappable rowcontentShape(shapes.rectangle()). - A
Buttonfilling a row swallows horizontal drags, so a row insideSwipeActionsopens its route instead of revealing its actions. Rows that navigate useonTapGesture(components/form/navigation-row.tsx) — also the closer stand-in for theNavigationLinkthat@expo/uidoes not expose. TextFieldhas novalueprop. It is either uncontrolled or bound touseNativeState; prefer the latter and read it with.get()at submit time, so submitting never depends on a change event having reached JS. Keyboard behaviour comes from modifiers (keyboardType,submitLabel,onSubmit,textInputAutocapitalization), not props.- A
Section'sisExpandedis honoured only underlistStyle('sidebar')— that list style is what makes the thread groups collapsible, not a cosmetic choice. BottomSheetneeds aHostlike every other@expo/uiview (its source reads like a plain RN view, but mounting it directly red-boxes). Give that hoststyle={{ position: 'absolute' }}+pointerEvents="box-none"so it claims no layout, and setfitToContentsor SwiftUI presents a short sheet at a near-full-screen detent.- Keep navigation chrome in UIKit end to end. Expo Router's native-stack header is UIKit; when
it needs an unexposed blur/material/mask, register the smallest
UIView/UIVisualEffectViewthrough a direct RN view manager (Expo may autolink the pod) and pass it toheaderBackground. Do not insert anExpoSwiftUI.View/UIHostingController: a hosted.barrendered here but did not blur scrolling rows across that boundary, whileUIVisualEffectViewdid. This rule is for UIKit-owned chrome, not SwiftUI forms.
RN→SwiftUI is the supported direction; going back needs RNHostView, whose bidirectional nesting is
the very thing 57.0.5 had to fix. So anything whose indispensable part is an RN view cannot cross
over: sign-in (AppleAuthenticationButton is an RN view and @expo/ui has no Sign in with
Apple), the conversation surface — timeline, composer, and the screen holding them (excluded by
the redesign decision; the composer also rides react-native-keyboard-controller), the terminal
canvas (expo-libghostty), the startup splash (BrandMark is a bundled RN image), and the
navigation header (react-navigation). Two smaller losses are accepted rather than worked
around: Image takes SF Symbols, asset-catalog names, and local file URIs but never a remote
URL, so the account avatar is an SF Symbol; and the agent brand marks are RN SVG components, so
thread rows and the new-thread picker name the agent in text instead.
- Uniwind + Tailwind v4, NOT NativeWind. HeroUI Native 1.0's official companion is
uniwind: metrowithUniwindConfig, babel is onlybabel-preset-expo, styles are CSS-first insrc/global.css, and the generatedsrc/uniwind-types.d.tsis committed and Biome-ignored. Earlier NativeWind plans are superseded — don't reach fornativewind. HeroUI Native still peers onreact-native-gesture-handler^2.x — gesture-handler 3.x is off the table until that peer widens. - Versions are hard-pinned to the Expo SDK, whose expectations live in
expo/bundledNativeModules.json(SDK 57 = RN 0.86.0 / reanimated 4.5.0 / worklets 0.10.0 / gesture-handler ~2.32.0;react/react-dom19.2.3). Align withpnpm -F @linkcode/mobile exec expo install --fix, then revert itstypescriptedit back tocatalog:. The pin is Expo's, not RN's —react-native@0.86.0peers onreact: ^19.2.3, a caret range the catalog's 19.2.7 also satisfies, so don't argue from "RN requires exactly this". Hold the pin becauseexpo install --fixrewrites anything else back, and because React's renderer internals are compiled against a matchingreact— a drift fails at runtime, subtly. The root catalog deliberately keeps its own 19.2.7: one version fork in exchange for keeping the web apps' React cadence off the Expo SDK's. The cost is two react copies in the tree, whichvitest.config.tsworks around (CODE-444, below).@sentry/react-nativefollows the SDK's expected line (~7.11.0 on SDK 57), not the package's ownlatest. - Two RN-resolution traps: after changing the RN version, run
pnpm dedupe react-native(a residual nested copy at the old version, pulled bypackages/presentation/ui's optional peer, breaks uniwind'sclassNameaugmentation); and keep@gorhom/bottom-sheetinstalled even though it is only an optional peer — Metro statically resolves HeroUI'stry/catchrequire of it and fails without it.
- Hooks are unit-testable through this app's own vitest project (
vitest.config.ts, declared in the root config'sprojects). The react pin makes this app's react/react-dom copies nested while@testing-library/reactis hoisted — left alone the two sides load different React instances and every hook dies on a null dispatcher (CODE-444) — so the project aliases react/react-dom to the hoisted pair (safe because the pin exists for Metro's bundle, which vitest never builds). - Consequence: only RN-free modules belong here. A test that reaches a
react-nativeorexpo-*import needs a different harness, not a wider alias.renderHookneeds a DOM, so such tests carry// @vitest-environment jsdom(src/runtime/__tests__/use-session-actions.test.ts). - Prefer driving a real
LinkCodeClientover a fake one: a controlledTransportlets assertions land on wire payloads, which is where silent breakage actually lives (rootAGENTS.md, Invariant 1).src/runtime/__tests__/client-test-helpers.tsxsupplies that transport plus a connected client, so a test never re-answers the handshake by hand.
maestrocomes from devenv (devenv.nix, gated to Darwin — it drives the iOS simulator, so Linux CI would pull the JVM closure for nothing). Do notbrew install maestro: Homebrew'smaestrocask is an unrelated AI GUI app (the mobile driver lives in a third-party tap), and a per-machine install drifts from the toolchain. Nix's wrapper supplies its own JRE — no JDK to add, and none of thefinal field mutationwarnings a mismatched JDK produces. Analytics upload is on by default — setMAESTRO_CLI_NO_ANALYTICS=1.- Flows live in
e2e/flows/*.yaml, run withpnpm -F @linkcode/mobile run e2e:uiagainst whatever dev build is already installed. Verified against Xcode 26 / iOS 26.5: RN text matches throughaccessibilityText,testIDthroughresource-id. devenv's nixxcrunshim (see the build recipe below) did not break maestro, but that was with its XCUITest runner already installed — a cold run on a fresh simulator is unverified. - Keep flows daemon-free where the path allows it — all three are,
add-hostincluded: it points at a port with nothing listening, and the host screen naming the URL it failed to reach is the proof the typed text made it into the store. A flow that needs a live host also needs the spawn harness fromapps/daemon/e2e/startup.e2e.ts, which no flow does yet. - Every flow starts
stopApp+launchApp+ aretrygroup around its deep link.launchAppalone reuses a running process, inheriting the previous flow's navigation stack; and a cold start redirects to the last active host when the persisted registry hydrates — late enough that an immediateassertVisiblepasses in the gap before the screen is replaced.clearStateis not the fix (it wipes the dev client's Metro URL); retrying the link is. - Maestro can drive SwiftUI, with two gaps:
hideKeyboarddoes nothing on a Form — submit from the return key (pressKey: Enter), because tapping a keyboard-covered element silently lands on the keyboard instead of failing. And revealing a swipe action needs a screen-percentage drag (an element-relativeswipeis too short), which makes the row's position an assumption —add-hostmarks that cleanupoptional. - Assert invariants of the screen, not its current state: flows that asserted an empty state, a collapsed form, or the startup destination all broke on a simulator that had been used before.
-
Dev builds, not Expo Go. Cloud sign-in needs the real
linkcode://scheme: production HQ trusts onlyhttps://linkcode.ai,linkcode://(TRUSTED_ORIGINS), and the@better-auth/exposerver plugin auto-trustsexp://only underNODE_ENV=development, so "Sign in" from Expo Go silently 403s. Build once withdevenv shell -- mobile(expo run:ios; generates the gitignoredios/via prebuild); daily dev is thenpnpm -F @linkcode/mobile start— withexpo-dev-clientinstalled it targets the dev build, not Expo Go. -
Never run
expo run:iosstraight from the devenv shell — use themobilescript. The shell exports a full C toolchain for the Rust sidecar, and xcodebuild honours the same variable names. Two independent poisons, each fatal on its own:DEVELOPER_DIR/SDKROOT(devenv'sapple.sdk, defaultpkgs.apple-sdk) point at a nix macOS SDK, so Xcode resolves its toolchain there and compiles iOS pods with nix clang —clang: error: unknown argument: '-index-store-path', plus nix libc++ headers injected throughNIX_CFLAGS_COMPILE(FP_NORMAL/uint8_terrors).xcrun -f clangreturns the nix clang for the same reason — it isDEVELOPER_DIR, not PATH order, that redirects it.LD=ldmakes Xcode link throughldinstead of the clang driver, so nobody unwraps-Xlinker:ld: -objc_abi_version '-Xlinker' not supported (expected 2). Surviving this one needsLDgone; dropping only the SDK variables still fails at the appex link.
scripts.mobileindevenv.nixunsets both groups (and setsSENTRY_DISABLE_AUTO_UPLOAD, since the Sentry Xcode phase otherwise fails the build without org/project credentials). Outside devenv the shell is already clean andpnpm -F @linkcode/mobile iosworks as-is.Symptoms are misleading: the first thing to fail is usually the
[RNDeps] Replace React Native Dependenciesscript phase, which is collateral — that script runs fine on its own. -
Sentry:
Sentry.init({ dsn: process.env.EXPO_PUBLIC_SENTRY_DSN })+Sentry.wrapon the root layout; the Expo plugin uploads source maps for orgarcbox/ projectlinkcode-mobile. Runtime reporting no-ops without a DSN. EAS profiles select Expo environments (development/preview/production); cloud builds can read the DSN there, whilebuild-mobile.ymlinjects repo secretSENTRY_DSN_MOBILEdirectly for local production builds. Local iOS builds keepSENTRY_DISABLE_AUTO_UPLOAD=trueunlessSENTRY_AUTH_TOKENis available.